mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
fix: read a remote session's @fix_plan.md on its host, refuse remote Ralph writes
A remote-SSH session's workingDir is a path on the remote host, but the Ralph file routes used local fs on it: fix-plan/read imported a same-named LOCAL @fix_plan.md (or 404'd), and fix-plan/write and ralph-prompt/write wrote their files into the local twin of the remote folder. Same root cause as the Ralph @fix_plan.md watcher fix. - fix-plan/read reads the file on the remote host through src/remote-files.ts (the File Viewer's ssh path): one probe of the file and the workspace, the File Viewer's confinement (a file resolving outside the workspace is treated as missing), then a capped read (1 MB). A missing remote file is the same NOT_FOUND as a missing local one; a non-file, an oversized file or an unreachable host is a failed read, never a local fallback. - fix-plan/write and ralph-prompt/write refuse a remote session (INVALID_INPUT): remote writes are out of scope by design, as for the File Viewer's remote edit. The Ralph panel already shows the error.
This commit is contained in:
1 parent
ecf28fa3be
commit
46122247e4
2 files changed
+235
-3
No files matched your search
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import { FastifyInstance } from 'fastify';
|
||||
import { join, resolve, relative, isAbsolute } from 'node:path';
|
||||
import { join, resolve, relative, isAbsolute, sep } from 'node:path';
|
||||
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js';
|
||||
@@ -29,8 +29,13 @@ import { writeHooksConfig, stripCaseEnvKeys } from '../../hooks-config.js';
|
||||
import { generateClaudeMd } from '../../templates/claude-md.js';
|
||||
import { buildRalphLoopPrompt } from '../../prompts/index.js';
|
||||
import { getLifecycleLog } from '../../session-lifecycle-log.js';
|
||||
import { remoteProbePaths, remoteReadFile, RemoteFileAccessError } from '../../remote-files.js';
|
||||
import type { SessionRemote } from '../../types/session.js';
|
||||
import type { SessionPort, EventPort, RespawnPort, ConfigPort, InfraPort } from '../ports/index.js';
|
||||
|
||||
/** Cap on a remote `@fix_plan.md` read over ssh (a task list, not a document). */
|
||||
const MAX_REMOTE_FIX_PLAN_BYTES = 1024 * 1024;
|
||||
|
||||
export function registerRalphRoutes(
|
||||
app: FastifyInstance,
|
||||
ctx: SessionPort & EventPort & RespawnPort & ConfigPort & InfraPort
|
||||
@@ -191,6 +196,15 @@ export function registerRalphRoutes(
|
||||
app.post('/api/sessions/:id/fix-plan/write', async (req) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// A remote session's workingDir is a path on the remote host. Writing it with local fs would land
|
||||
// the file in a same-named local folder (or fail), and remote writes are out of scope by design
|
||||
// (src/remote-files.ts is read-only), so it is refused, like the File Viewer's remote edit.
|
||||
if (session.remote) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Writing @fix_plan.md is not supported for a remote (SSH) session'
|
||||
);
|
||||
}
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -227,7 +241,14 @@ export function registerRalphRoutes(
|
||||
const filePath = join(workingDir, '@fix_plan.md');
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(filePath, 'utf-8');
|
||||
// A remote session's workingDir is a path on the remote host: read the file there, over the
|
||||
// same ssh path the File Viewer uses, never a same-named local file.
|
||||
const content = session.remote
|
||||
? await readRemoteFixPlan(session.remote, workingDir, filePath)
|
||||
: await fs.readFile(filePath, 'utf-8');
|
||||
if (content === null) {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory');
|
||||
}
|
||||
const importedCount = session.ralphTracker.importFixPlanMarkdown(content);
|
||||
ctx.persistSessionState(session);
|
||||
|
||||
@@ -243,7 +264,8 @@ export function registerRalphRoutes(
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory');
|
||||
}
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${error}`);
|
||||
const reason = error instanceof RemoteFileAccessError ? error.message : String(error);
|
||||
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${reason}`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -257,6 +279,13 @@ export function registerRalphRoutes(
|
||||
const { id } = req.params as { id: string };
|
||||
const { content } = parseBody(RalphPromptWriteSchema, req.body, 'Invalid request body');
|
||||
const session = findSessionOrFail(ctx, id, req);
|
||||
// Same rule as fix-plan/write: never write a remote session's file on the local disk.
|
||||
if (session.remote) {
|
||||
return createErrorResponse(
|
||||
ApiErrorCode.INVALID_INPUT,
|
||||
'Writing @ralph_prompt.md is not supported for a remote (SSH) session'
|
||||
);
|
||||
}
|
||||
|
||||
const workingDir = session.workingDir;
|
||||
if (!workingDir) {
|
||||
@@ -511,3 +540,22 @@ export function registerRalphRoutes(
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a remote session's `@fix_plan.md` on its host. `null` = no such file, or one that resolves outside
|
||||
* the workspace (the caller's 404, as for a local ENOENT); anything that is not a regular file, a file over the cap, or an ssh failure throws
|
||||
* `RemoteFileAccessError`, which the route reports as a failed read.
|
||||
*/
|
||||
async function readRemoteFixPlan(remote: SessionRemote, workingDir: string, filePath: string): Promise<string | null> {
|
||||
// The file and the workspace in one round trip, so a symlinked @fix_plan.md that resolves outside the
|
||||
// workspace is refused like any missing file (the File Viewer's confinement, file-routes.ts).
|
||||
const [probe, rootProbe] = await remoteProbePaths(remote, [filePath, workingDir]);
|
||||
if (!probe) return null;
|
||||
const rel = relative(rootProbe?.realPath ?? resolve(workingDir), probe.realPath);
|
||||
if (rel === '' || isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) return null;
|
||||
if (probe.kind !== 'file') throw new RemoteFileAccessError('@fix_plan.md is not a regular file');
|
||||
if (probe.size > MAX_REMOTE_FIX_PLAN_BYTES) {
|
||||
throw new RemoteFileAccessError(`@fix_plan.md is larger than ${MAX_REMOTE_FIX_PLAN_BYTES / 1024 / 1024}MB`);
|
||||
}
|
||||
return (await remoteReadFile(remote, probe.realPath, MAX_REMOTE_FIX_PLAN_BYTES)).toString('utf-8');
|
||||
}
|
||||
Reference in new issue
Block a user