Merge pull request #728: fix: read a remote session's @fix_plan.md on its host, refuse remote Ralph writes

This commit is contained in:
Codeman maintainer committed 2026-10-28 09:10:06 +01:00
commit 6d640b08ce
5 files changed
+243 -4

No files matched your search

+5 -1
View File
@@ -767,7 +767,11 @@ const FILESYSTEM_IMAGE_PREVIEW_EXTENSIONS = new Set(['png', 'jpg', 'jpeg', 'gif'
const FILESYSTEM_TEXT_PREVIEW_EXTENSIONS = new Set(['md', 'txt', 'json']);
const FILESYSTEM_DOCUMENT_PREVIEW_EXTENSIONS = new Set(['pdf', 'docx', 'pptx']);
function isPathWithinRoot(root: string, candidate: string): boolean {
/**
* Whether `candidate` is `root` itself or lies beneath it (both already resolved). The one lexical
* containment check for workspace confinement; ralph-routes.ts uses it for a remote `@fix_plan.md`.
*/
export function isPathWithinRoot(root: string, candidate: string): boolean {
const rel = relative(root, candidate);
return rel === '' || (!isAbsolute(rel) && rel !== '..' && !rel.startsWith(`..${sep}`));
}
+51 -2
View File
@@ -29,8 +29,14 @@ 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 { isPathWithinRoot } from './file-routes.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 +197,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 +242,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 +265,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 +280,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 +541,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 `NOT_FOUND`, 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;
if (!isPathWithinRoot(rootProbe?.realPath ?? resolve(workingDir), probe.realPath)) 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');
}