fix(files): read remote-case file previews and downloads over ssh

A remote case's workingDir is an absolute path on the remote host, but the
file read routes resolved it with local `fs`: `validateSessionFilePath`'s
realpathSync fails for a path that does not exist on the Codeman host, so
every preview of an agent-written file answered "File not found" (#415).

Add src/remote-files.ts as the single remote-read layer, built on the same
buildSshConnectionArgs() the launch uses:

- remoteProbePaths(): ONE round trip returning realpath + stat for the
  requested path AND the workspace root, so containment is checked against a
  remotely canonicalized root (a symlinked remotePath is ordinary).
- remoteCreateReadStream(): streams the body (cat, or tail -c +N | head -c L
  for a Range) with nothing buffered in memory, and reaps the ssh child when
  the response ends so an aborted download cannot orphan it.
- remoteReadFile(): bounded read for file-content.

file-raw, file-content, file-preview and file-thumbnail now share one local/
remote target resolution. Guards keep their local strength: lexical pre-check,
remote realpath, workspace containment, sensitive-path blocklist, and the size
cap applied to the remote size before any bytes are read. An unreachable host
answers 502 with the remote reason instead of a misleading 404. Nothing is ever
copied to the Codeman host and there is NO local fallback (an sshfs mount of
the same tree must not shadow the remote bytes).

Deliberately unchanged: writes (edit=1 / PUT now answer 400 explicitly while
the viewer hides its Edit affordance), office previews, thumbnails, file tree,
picker, external attachment registration and tail-file stay local-only.
This commit is contained in:
Randalix
2026-09-14 14:54:41 +02:00
parent a017e9a8e0
commit 013a5d9cc8
12 changed files with 1451 additions and 59 deletions
+34 -2
View File
@@ -88,11 +88,43 @@ export function validateSessionFilePath(
} catch {
return null;
}
const relativePath = relative(resolvedWorkingDir, resolvedPath);
return confineToRoot(resolvedWorkingDir, resolvedPath);
}
/**
* The lexical half of {@link validateSessionFilePath}: same containment rule, but
* WITHOUT touching the filesystem.
*
* Needed for remote-SSH cases (`src/remote-files.ts`), where `workingDir` is an
* absolute path on the REMOTE host and any local `realpathSync` fails by
* construction — which is how every file-raw/file-content request in a remote case
* used to end up as a 404 before a single byte was read. The caller follows this
* pre-check with a remote realpath + the same containment rule, so escapes are
* refused exactly as they are locally; what changes is only WHICH filesystem
* resolves the symlinks.
*
* A lexical check alone would follow nothing, so it must never be the last word for
* a path that can contain a symlink — it is the cheap reject in front of the real
* (local or remote) resolution, not a replacement for it.
*/
export function validateSessionFilePathLexical(
sessionWorkingDir: string,
filePath: string
): { resolvedPath: string; relativePath: string } | null {
return confineToRoot(resolve(sessionWorkingDir), resolve(sessionWorkingDir, filePath));
}
/**
* Shared containment rule: `candidate` must sit inside `root` (both already
* canonical for their filesystem). `relative()` is the whole test — a `..` or an
* absolute result means the candidate escaped.
*/
function confineToRoot(root: string, candidate: string): { resolvedPath: string; relativePath: string } | null {
const relativePath = relative(root, candidate);
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
return null;
}
return { resolvedPath, relativePath };
return { resolvedPath: candidate, relativePath };
}
// Maximum hook data size (prevents oversized SSE broadcasts)