mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 10:39:32 +01:00
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:
commit
6d640b08ce
5 files changed
+243
-4
No files matched your search
@@ -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}`));
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
Reference in new issue
Block a user