fix(files): fail closed on remote symlinks, guard PUT for remote cases, bound ssh fan-out

Follow-up to #421 (remote-case file reads over ssh), addressing the review.

Symlink escape on a host without `readlink -f` (blocker). The probe's
portable fallback canonicalized only the directory chain and returned the
final component unresolved, so on macOS < 12.3 `ws/notes.txt -> ~/.ssh/id_rsa`
came back as `.../ws/notes.txt` (with the target's size), passed every
containment and blocklist check that runs on `realPath`, and `cat` followed
the link. The fallback now walks the directory chain with `cd -P`/`pwd -P`
and follows the LAST component with plain `readlink` for a bounded number of
hops, and anything it cannot fully resolve (a loop, a readlink failure, the
hop cap) is reported with an `x` marker that parses as null, i.e. 404. It
never returns the unresolved string. Measured on a real /bin/sh with
`readlink -f` shadowed: the pre-fix script reports `/ws/notes.txt`, the fixed
one `/secret/id_rsa`; both branches (native and fallback) now agree.

`PUT /api/sessions/:id/file-content` never had the remote guard the PR
described. It sits ahead of `validateSessionFilePath`, which resolves against
the LOCAL filesystem, because with a same-named directory on the Codeman host
(an sshfs mount of the remote tree, the documented stop-gap) the write landed
on the local twin while the viewer believed it edited the remote file.

ssh fan-out is bounded. `src/remote-ssh-limiter.ts` is a
document-conversion-limiter-shaped semaphore (default 4, env
`CODEMAN_MAX_REMOTE_FILE_SSH`) around every probe and buffered read; the
attachment-history list resolves its whole history in ONE batched probe
(`probeRemoteAttachmentHistory`, threaded into
`registerExternalAttachment({remoteProbes})` so the guards run unchanged)
instead of one handshake per entry; and probes chunk at 40 paths because the
whole script is one argv string. Terminal output in a remote session is
written on the remote host, so a prompt-injected agent printing hundreds of
`codeman://attach` links forked one ssh per link, each holding a 20 s
timeout, and a 100-entry history re-listed on every attachment:detected
tripped OpenSSH's default MaxStartups. Streams are deliberately not counted
(one per browser request, held for a whole playback, and gated behind a
counted probe anyway).

Smaller items from the same review: probe records are NUL-terminated and
index-keyed after a leading NUL (a newline in a filename can no longer shift
the alignment, and the banner is fenced off without last-N-lines guessing);
size comes from `stat -c %s || stat -f %z`; the three IO functions refuse
under VITEST instead of opening a connection; an unreachable host now reads
as unknown (missing: false) for detected AND external history entries, where
external used to fold its 502 into missing; a client that aborted during the
guard probe has its body's ssh child reaped (`reply.raw.destroyed` is checked
before the close listener is attached); `describeExecError` never returns
Node's `Command failed: <ssh line>` message, which carried the identity path
and the probe script into a 502 body; and the docs note that
`isSensitivePath`'s three home-anchored entries resolve against the Codeman
host's home, not the remote one.

Tests: the probe script runs on a real /bin/sh with a `readlink` shim that
rejects `-f` (the escape, a relative chain through a symlinked directory, a
loop, a newline filename, banner chatter that itself looks like a record),
the limiter's cap and FIFO order, and route tests for the PUT guard (local
twin untouched, no connection), the single batched history probe, the
unreachable-host alignment and the aborted-client reap. All four route tests
fail against the pre-fix file-routes.ts.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-14 23:42:06 +02:00
parent 792a251e35
commit e49c48145b
10 changed files with 815 additions and 114 deletions
+31 -12
View File
@@ -13,7 +13,7 @@ import { basename, extname, isAbsolute } from 'node:path';
import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from './config/attachment-guard.js';
import { EDITABLE_EXTENSIONS } from './config/file-editing.js';
import { validateSessionFilePath } from './web/route-helpers.js';
import { remoteProbePaths, RemoteFileAccessError } from './remote-files.js';
import { remoteProbePaths, RemoteFileAccessError, type RemoteProbe } from './remote-files.js';
import type { AttachmentDetectedEvent, AttachmentDetectedType } from './types.js';
import type { SessionRemote } from './types/session.js';
@@ -228,6 +228,13 @@ export interface RegisterExternalAttachmentOptions {
* so a symlinked `remotePath` does not refuse every registration.
*/
remote?: SessionRemote;
/**
* Remote only: `[file, workspaceRoot]` probes a caller already resolved in a BATCHED
* `remoteProbePaths` call (the attachment-history list does one round trip for the
* whole history). Skips this registration's own ssh probe; every guard below still
* runs on the same resolved path it would have produced itself.
*/
remoteProbes?: readonly [RemoteProbe | null, RemoteProbe | null];
}
/**
@@ -277,17 +284,24 @@ async function resolveLocalAttachment(requestedPath: string): Promise<ResolvedAt
async function resolveRemoteAttachment(
requestedPath: string,
remote: SessionRemote,
sessionWorkingDir?: string
sessionWorkingDir?: string,
preResolved?: readonly [RemoteProbe | null, RemoteProbe | null]
): Promise<ResolvedAttachmentFile> {
const paths = sessionWorkingDir ? [requestedPath, sessionWorkingDir] : [requestedPath];
let probes;
try {
probes = await remoteProbePaths(remote, paths);
} catch (err) {
throw new AttachmentRegistrationError(
err instanceof RemoteFileAccessError ? err.message : 'remote host unreachable',
502
);
let probes: ReadonlyArray<RemoteProbe | null>;
if (preResolved) {
probes = preResolved;
} else {
try {
probes = await remoteProbePaths(remote, paths);
} catch (err) {
// 502 marks the TRANSPORT as the failure, distinct from the file's own 404/403,
// so a history listing can report the entry as unknown rather than missing.
throw new AttachmentRegistrationError(
err instanceof RemoteFileAccessError ? err.message : 'remote host unreachable',
502
);
}
}
const [probe, rootProbe] = probes;
@@ -315,7 +329,7 @@ export async function registerExternalAttachment(
}
const resolved = await (options.remote
? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir)
? resolveRemoteAttachment(requestedPath, options.remote, options.sessionWorkingDir, options.remoteProbes)
: resolveLocalAttachment(requestedPath));
// COD-53: enforce the active attachment-guard policy on the symlink-resolved
@@ -343,7 +357,12 @@ export async function registerExternalAttachment(
// codeman-publish and the ~/.codeman review loop keep working.
//
// The list is a pattern list over ABSOLUTE paths, so it is host-agnostic and holds
// for a remote path exactly as it does for a local one.
// for a remote path exactly as it does for a local one, with ONE exception worth
// knowing: `isSensitivePath`'s three home-anchored members (`~/.claude.json`,
// `~/.claude/settings.json`, `~/.claude/settings.local.json`) resolve against THIS
// host's `homedir()`, so on a remote host with a different home they do not match.
// Everything else in that list is depth-anchored (`/.ssh/`, `/.aws/credentials`,
// `/.claude/.credentials.json`, ...) and applies unchanged.
if (isBlockedAttachmentPath(resolved.resolvedPath, guard.blockedTrees)) {
throw new AttachmentRegistrationError('Access to this file is blocked', 403);
}
+154 -52
View File
@@ -28,9 +28,10 @@
import { exec, spawn } from 'node:child_process';
import { promisify } from 'node:util';
import type { Readable } from 'node:stream';
import { PassThrough, type Readable } from 'node:stream';
import type { SessionRemote } from './types/session.js';
import { buildSshConnectionArgs, remoteSshTarget, shellescape } from './remote-hosts.js';
import { runWithRemoteSshLimit } from './remote-ssh-limiter.js';
const execAsync = promisify(exec);
@@ -50,6 +51,37 @@ const READ_BUFFER_SLACK_BYTES = 64 * 1024;
/** Marker a probe prints when the path does not exist on the remote host. */
const NOT_FOUND_MARKER = 'n';
/**
* Marker a probe prints when the path exists but could NOT be canonicalized (no
* `readlink -f`, and the portable fallback hit its hop cap or a `readlink` failure).
* Parsed as `null`, i.e. 404: a path whose real target is unknown must never be
* served, because every containment and blocklist check runs on the resolved path.
*/
const UNRESOLVABLE_MARKER = 'x';
/**
* Paths per ssh round trip. The whole remote script is ONE shellescaped argument,
* and Linux caps a single argv string at 128 KiB, so a 100-entry attachment history
* of long paths is split rather than risking `E2BIG` on the local `sh`.
*/
const REMOTE_PROBE_CHUNK_SIZE = 40;
/** Symlink hops the portable resolver follows before giving up (Linux uses 40). */
const REMOTE_SYMLINK_MAX_HOPS = 40;
/**
* Under vitest no real ssh connection may ever be opened (mirrors
* `checkRemoteTmuxAvailable` and friends in remote-hosts.ts). The route tests mock
* this module, so nothing reaches here today; this is what keeps the NEXT
* remote-session test that touches a file route from opening a connection from CI.
* A clear 502-shaped error, never a fake success: there are no fake bytes to return.
*/
function assertNotUnderTest(): void {
if (process.env.VITEST) {
throw new RemoteFileAccessError('remote file access is disabled under test');
}
}
/** What a remote path turned out to be. `other` = symlink/socket/fifo/device. */
export type RemotePathKind = 'file' | 'directory' | 'other';
@@ -95,40 +127,67 @@ export function buildRemoteFileCommand(remote: SessionRemote, shellCommand: stri
* every extra `ssh` is a fresh handshake, and the file routes need the path AND the
* workspace root canonicalized to compare them.
*
* Each path emits exactly one line — `n` when it does not exist, otherwise
* `kind|size|mtime|realPath` with `realPath` LAST so a path containing `|` still
* parses (the earlier fields are fixed and the remainder is the path).
* Output format: the script first prints a lone NUL, then one NUL-terminated record
* per path, `<index>|n` (missing), `<index>|x` (exists but cannot be canonicalized) or
* `<index>|kind|size|mtime|realPath`. Records are keyed by INDEX and separated by NUL
* rather than newline so that a remote filename containing a newline cannot shift the
* alignment, and the leading NUL is what separates a login banner or an eager rc-file
* `echo` (which land before the script runs) from the records without any "last N
* lines" guesswork. `realPath` is the last field, so a `|` in a path still parses.
*
* Symlink resolution is portable on purpose: `readlink -f` where available (Linux,
* macOS >= 12.3), else the POSIX `cd`/`pwd -P` fallback, which resolves the DIRECTORY
* chain. Resolution is required here rather than optional: `isSensitivePath()`
* demands an already-realpath'd input, so a remote read must not be able to reach a
* blocked target through a symlink any more than a local one can.
* Symlink resolution is portable AND fails closed. `readlink -f` where available
* (Linux, macOS >= 12.3); otherwise the fallback canonicalizes the directory chain
* with `cd -P`/`pwd -P` and then follows the LAST component with plain `readlink`
* (which the systems lacking `-f` do have) for a bounded number of hops. A path the
* fallback cannot resolve prints `x`, never the unresolved string: every containment
* and blocklist check downstream runs on `realPath`, and an earlier version of this
* fallback returned the directory-resolved path with the final symlink still in it,
* so `ws/notes.txt -> ~/.ssh/id_rsa` passed containment while `cat` served the key.
*/
export function buildRemoteProbeCommand(paths: readonly string[]): string {
const probes = paths.map((path) => `probe ${shellescape(path)}`).join('\n');
const probes = paths.map((path, index) => `probe ${index} ${shellescape(path)}`).join('\n');
return [
'resolve_last() {',
' q=$1',
' hops=0',
' while :; do',
' d=$(cd -P "$(dirname "$q")" 2>/dev/null && pwd -P) || return 1',
' q=$d/$(basename "$q")',
' [ -L "$q" ] || break',
' hops=$((hops + 1))',
` [ "$hops" -le ${REMOTE_SYMLINK_MAX_HOPS} ] || return 1`,
' l=$(readlink "$q" 2>/dev/null) || return 1',
' [ -n "$l" ] || return 1',
' case $l in /*) q=$l ;; *) q=$d/$l ;; esac',
' done',
' if [ -d "$q" ]; then q=$(cd -P "$q" 2>/dev/null && pwd -P) || return 1; fi',
' printf %s "$q"',
'}',
'probe() {',
' p=$1',
' r=$(readlink -f "$p" 2>/dev/null) || r=$(cd "$(dirname "$p")" 2>/dev/null && printf %s/%s "$(pwd -P)" "$(basename "$p")")',
' [ -n "$r" ] || r=$p',
` if [ ! -e "$p" ]; then printf '%s\\n' ${NOT_FOUND_MARKER}; return; fi`,
' i=$1',
' p=$2',
` if [ ! -e "$p" ]; then printf '%s|${NOT_FOUND_MARKER}\\0' "$i"; return; fi`,
` r=$(readlink -f "$p" 2>/dev/null) || r=$(resolve_last "$p") || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
` [ -n "$r" ] || { printf '%s|${UNRESOLVABLE_MARKER}\\0' "$i"; return; }`,
' if [ -d "$r" ]; then t=d; elif [ -f "$r" ]; then t=f; else t=o; fi',
' s=0',
' if [ "$t" = f ]; then s=$(wc -c < "$r" 2>/dev/null | tr -d " "); [ -n "$s" ] || s=0; fi',
' if [ "$t" = f ]; then s=$(stat -c %s "$r" 2>/dev/null || stat -f %z "$r" 2>/dev/null); [ -n "$s" ] || s=0; fi',
' m=$(stat -c %Y "$r" 2>/dev/null || stat -f %m "$r" 2>/dev/null || printf 0)',
` printf '%s|%s|%s|%s\\n' "$t" "$s" "$m" "$r"`,
` printf '%s|%s|%s|%s|%s\\0' "$i" "$t" "$s" "$m" "$r"`,
'}',
"printf '\\0'",
probes,
].join('\n');
}
/** Parse one probe line. `null` for the not-found marker or anything malformed. */
export function parseRemoteProbeLine(line: string): RemoteProbe | null {
const trimmed = line.replace(/\r$/, '');
if (!trimmed || trimmed === NOT_FOUND_MARKER) return null;
/**
* Parse one probe record (index prefix already stripped). `null` for the not-found
* and unresolvable markers or anything malformed.
*/
export function parseRemoteProbeRecord(record: string): RemoteProbe | null {
if (!record || record === NOT_FOUND_MARKER || record === UNRESOLVABLE_MARKER) return null;
const parts = trimmed.split('|');
const parts = record.split('|');
if (parts.length < 4) return null;
const [kindRaw, sizeRaw, mtimeRaw] = parts;
@@ -151,48 +210,77 @@ export function parseRemoteProbeLine(line: string): RemoteProbe | null {
/**
* Parse the output of {@link buildRemoteProbeCommand} into one entry per requested
* path, in order. Throws when the output cannot be one line per path — that means the
* transport or the remote shell did something unexpected, and silently treating it as
* "not found" would turn an infrastructure failure into a wrong 404.
* path, in order. Throws when a path's record is missing: that means the transport
* or the remote shell did something unexpected, and silently treating it as "not
* found" would turn an infrastructure failure into a wrong 404.
*
* The LAST `paths.length` lines are used so a login banner or an eager rc-file `echo`
* on the remote host cannot shift the alignment.
* Everything before the first NUL is the remote shell's own chatter (banner, rc-file
* output) and is discarded; records are matched by their index prefix, so neither
* extra output nor a newline inside a filename can shift the mapping.
*/
export function parseRemoteProbeLines(stdout: string, paths: readonly string[]): Array<RemoteProbe | null> {
const lines = stdout.split('\n').filter((line) => line !== '');
if (lines.length < paths.length) {
throw new RemoteFileAccessError('remote host returned no usable file information');
export function parseRemoteProbeOutput(stdout: string, paths: readonly string[]): Array<RemoteProbe | null> {
const records = stdout.split('\0').slice(1);
const byIndex = new Map<number, string>();
for (const record of records) {
const match = /^(\d+)\|([\s\S]*)$/.exec(record);
if (!match) continue;
const index = Number.parseInt(match[1], 10);
if (!byIndex.has(index)) byIndex.set(index, match[2]);
}
return lines.slice(-paths.length).map((line) => parseRemoteProbeLine(line));
return paths.map((_, index) => {
const record = byIndex.get(index);
if (record === undefined) {
throw new RemoteFileAccessError('remote host returned no usable file information');
}
return parseRemoteProbeRecord(record);
});
}
/** Probe one or more remote paths. Entry is `null` for a path that does not exist. */
/**
* Probe one or more remote paths. Entry is `null` for a path that does not exist (or
* could not be canonicalized, which is refused the same way).
*
* Large batches are split into round trips of {@link REMOTE_PROBE_CHUNK_SIZE}, each
* counted against the global ssh limiter, so an attachment history of 100 entries
* costs three connections in sequence rather than 100 at once.
*/
export async function remoteProbePaths(
remote: SessionRemote,
paths: readonly string[]
): Promise<Array<RemoteProbe | null>> {
const command = buildRemoteFileCommand(remote, buildRemoteProbeCommand(paths));
let stdout: string;
try {
const result = await execAsync(command, { timeout: REMOTE_PROBE_TIMEOUT_MS, maxBuffer: 64 * 1024 });
stdout = result.stdout;
} catch (err) {
throw new RemoteFileAccessError(
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
);
assertNotUnderTest();
const results: Array<RemoteProbe | null> = [];
for (let offset = 0; offset < paths.length; offset += REMOTE_PROBE_CHUNK_SIZE) {
const chunk = paths.slice(offset, offset + REMOTE_PROBE_CHUNK_SIZE);
const command = buildRemoteFileCommand(remote, buildRemoteProbeCommand(chunk));
let stdout: string;
try {
const result = await runWithRemoteSshLimit(() =>
execAsync(command, { timeout: REMOTE_PROBE_TIMEOUT_MS, maxBuffer: 256 * 1024 })
);
stdout = result.stdout;
} catch (err) {
throw new RemoteFileAccessError(
`remote host ${remote.label || remote.host} unreachable: ${describeExecError(err)}`
);
}
results.push(...parseRemoteProbeOutput(stdout, chunk));
}
return parseRemoteProbeLines(stdout, paths);
return results;
}
/** Read a whole remote file into memory, capped by `maxBytes`. */
export async function remoteReadFile(remote: SessionRemote, remotePath: string, maxBytes: number): Promise<Buffer> {
assertNotUnderTest();
const command = buildRemoteFileCommand(remote, `cat ${shellescape(remotePath)}`);
try {
const result = await execAsync(command, {
timeout: REMOTE_READ_TIMEOUT_MS,
maxBuffer: maxBytes + READ_BUFFER_SLACK_BYTES,
encoding: 'buffer',
});
const result = await runWithRemoteSshLimit(() =>
execAsync(command, {
timeout: REMOTE_READ_TIMEOUT_MS,
maxBuffer: maxBytes + READ_BUFFER_SLACK_BYTES,
encoding: 'buffer',
})
);
return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout);
} catch (err) {
throw new RemoteFileAccessError(`failed to read remote file: ${describeExecError(err)}`);
@@ -238,6 +326,13 @@ export function remoteCreateReadStream(
remotePath: string,
range?: { start: number; end: number }
): RemoteFileStream {
if (process.env.VITEST) {
// Same rule as the buffered calls, in stream form: the consumer sees the error
// through the stream's normal failure path instead of a connection attempt.
const stream = new PassThrough();
process.nextTick(() => stream.destroy(new RemoteFileAccessError('remote file access is disabled under test')));
return { stream, close: () => stream.destroy() };
}
const command = buildRemoteFileCommand(remote, buildRemoteReadCommand(remotePath, range));
const child = spawn(command, { shell: true, stdio: ['ignore', 'pipe', 'pipe'] });
@@ -277,20 +372,27 @@ export function remoteCreateReadStream(
};
}
/** First useful line of an exec/stderr error, for a user-facing message. */
/**
* First useful line of an exec/stderr error, for a user-facing message.
*
* ⚠️ Never Node's `err.message`: for a failed `exec` it is `Command failed: <the whole
* ssh line>`, which carries the identity-file path and the probe script, and this
* string goes out in a 502 body. stderr, the timeout flag and the exit/spawn code are
* everything a user can act on.
*/
function describeExecError(err: unknown): string {
if (typeof err === 'object' && err !== null) {
const record = err as { stderr?: unknown; message?: unknown; code?: unknown; killed?: unknown };
const record = err as { stderr?: unknown; code?: unknown; killed?: unknown };
const stderr =
typeof record.stderr === 'string' ? record.stderr : Buffer.isBuffer(record.stderr) ? String(record.stderr) : '';
const line = stderr
.split('\n')
.map((entry) => entry.trim())
.find((entry) => entry.length > 0);
if (line) return line;
if (line) return line.slice(0, 300);
if (record.killed) return 'timed out';
if (typeof record.message === 'string' && record.message.length > 0) return record.message;
if (typeof record.code === 'string' || typeof record.code === 'number') return `ssh exit ${record.code}`;
if (typeof record.code === 'number') return `ssh exit ${record.code}`;
if (typeof record.code === 'string') return `ssh could not be started (${record.code})`;
}
return 'unknown error';
}
+89
View File
@@ -0,0 +1,89 @@
/**
* @fileoverview Global concurrency limiter for the short-lived `ssh` children that
* remote-case file access spawns (`src/remote-files.ts`: the realpath+stat probe and
* the buffered text read).
*
* Two paths can fan those out without a human behind each one:
*
* - `GET /api/sessions/:id/attachments` resolves every history entry (up to
* `ATTACHMENT_HISTORY_LIMIT`, 100), and the attachments drawer re-runs it on every
* `attachment:detected` event while it is open, which is exactly when an agent is
* writing files. The route now batches the probes, but a burst of drawers is still
* a burst.
* - A `codeman://attach?path=` magic link in terminal output registers the path
* fire-and-forget, once per distinct link per PTY chunk. In a remote session that
* output is written by a process on the remote host, so a prompt-injected agent can
* print hundreds of links and have the server fork one `ssh` per link, each holding
* a 20s probe timeout.
*
* Without a cap that is the fork-bomb shape `document-conversion-limiter.ts` exists to
* prevent, and it also trips OpenSSH's default `MaxStartups 10:30:100`, which starts
* dropping connections at ten unauthenticated handshakes. This is that limiter for
* ssh: a small fixed pool, FIFO queueing, and a slot handed straight to the next
* waiter on release so the active count can never exceed the cap under interleaved
* async resumption.
*
* Streams (`remoteCreateReadStream`) are deliberately NOT counted: one is opened per
* browser request and held for the life of a media playback, so four open videos
* would otherwise block every preview and the history list. They are already gated
* behind a counted probe (the guard re-probe runs first), so their spawn RATE is
* bounded here even though their concurrency is bounded by the browser.
*
* NOT re-entrant: never acquire from inside a task already holding a slot.
*/
/**
* Max remote probes/reads allowed to run concurrently across the whole process.
* Override with CODEMAN_MAX_REMOTE_FILE_SSH (clamped to >= 1). Four keeps a burst
* well under OpenSSH's ten-handshake default.
*/
const MAX_CONCURRENT_REMOTE_SSH = (() => {
const raw = Number(process.env.CODEMAN_MAX_REMOTE_FILE_SSH);
return Number.isFinite(raw) && raw >= 1 ? Math.floor(raw) : 4;
})();
let active = 0;
const waiters: Array<() => void> = [];
/** Test/diagnostic hook: remote calls currently holding a slot. */
export function getActiveRemoteSshCount(): number {
return active;
}
/** Test/diagnostic hook: remote calls queued behind the cap. */
export function getQueuedRemoteSshCount(): number {
return waiters.length;
}
/** The configured cap, so a test can assert against the real number. */
export function getRemoteSshLimit(): number {
return MAX_CONCURRENT_REMOTE_SSH;
}
function acquire(): Promise<void> {
if (active < MAX_CONCURRENT_REMOTE_SSH) {
active++;
return Promise.resolve();
}
return new Promise<void>((resolve) => waiters.push(resolve));
}
function release(): void {
const next = waiters.shift();
if (next) {
// Hand the slot straight to the next waiter; `active` stays at the cap.
next();
} else {
active--;
}
}
/** Run `task` once an ssh slot is free, releasing the slot afterward. */
export async function runWithRemoteSshLimit<T>(task: () => Promise<T>): Promise<T> {
await acquire();
try {
return await task();
} finally {
release();
}
}
+108 -13
View File
@@ -135,6 +135,16 @@ function sendRawStream(reply: FastifyReply, content: Readable, cleanup?: () => v
// when the client goes away (tab closed, video seek, a cancelled fetch), or the
// ssh process outlives the request. Registered here because this is the one place
// that owns the response's lifecycle.
//
// ⚠️ Check BEFORE attaching: the guard probe that ran ahead of this is an ssh round
// trip, and a client that gave up during it has already closed the response, so
// `close` has already fired and a listener attached now would never run. The
// `open()` call above still spawned the body's ssh child; reap it here instead.
if (reply.raw.destroyed) {
cleanup?.();
content.destroy();
return;
}
if (cleanup) {
reply.raw.on('close', cleanup);
}
@@ -1010,12 +1020,64 @@ function getSessionAttachmentHistory(
};
}
/**
* The remote probes an attachment-history listing needs, resolved in ONE batch.
*
* The list route used to probe each entry on its own, i.e. one ssh handshake per
* history item, up to `ATTACHMENT_HISTORY_LIMIT` (100) of them, and the attachments
* drawer re-runs the route on every `attachment:detected` event while it is open,
* which is exactly when an agent is writing files. OpenSSH's default
* `MaxStartups 10:30:100` starts dropping connections at ten concurrent handshakes,
* so most of such a burst simply failed. `remoteProbePaths` already takes an array
* (and chunks it), so the whole history is one call, plus the global ssh limiter
* bounding whatever is left.
*/
interface RemoteHistoryProbes {
/** The workspace root, canonicalized on the remote host. */
root: RemoteProbe | null;
/** Keyed by the exact path handed to the probe (a lexical resolution or an external path). */
byPath: Map<string, RemoteProbe | null>;
/**
* The batch itself failed (unreachable host). Every entry is then UNKNOWN, not
* missing: reporting "missing" would tell the user their files are gone when the
* host is merely asleep.
*/
unreachable: boolean;
}
async function probeRemoteAttachmentHistory(
scope: SessionFileScope,
history: readonly SessionAttachmentHistoryItem[]
): Promise<RemoteHistoryProbes | undefined> {
const remote = scope.remote;
if (!remote || history.length === 0) return undefined;
const paths = new Set<string>();
for (const item of history) {
if (item.source === 'external') {
if (item.externalPath) paths.add(item.externalPath);
} else if (item.relativePath) {
const lexical = validateSessionFilePathLexical(scope.workingDir, item.relativePath);
if (lexical) paths.add(lexical.resolvedPath);
}
}
const list = [...paths];
try {
const [root, ...rest] = await remoteProbePaths(remote, [scope.workingDir, ...list]);
return { root, byPath: new Map(list.map((path, index) => [path, rest[index] ?? null])), unreachable: false };
} catch {
return { root: null, byPath: new Map(), unreachable: true };
}
}
// History item for a file detected inside the workspace: re-stat for live
// size/mtime and resolve preview/thumbnail/raw routes off the relative path.
async function buildDetectedAttachmentRouteItem(
sessionId: string,
scope: SessionFileScope,
item: SessionAttachmentHistoryItem
item: SessionAttachmentHistoryItem,
batch?: RemoteHistoryProbes
): Promise<AttachmentHistoryRouteItem> {
const safe = sanitizeAttachmentHistoryItem(item);
if (!item.relativePath) {
@@ -1032,15 +1094,22 @@ async function buildDetectedAttachmentRouteItem(
// inside the workspace), executed on the host that owns the files.
const lexical = validateSessionFilePathLexical(workingDir, item.relativePath);
if (!lexical) return { ...safe, missing: true };
let probes: Array<RemoteProbe | null>;
try {
probes = await remoteProbePaths(scope.remote, [lexical.resolvedPath, workingDir]);
} catch {
// Unreachable host: the entry is not "missing", it is unknown. Reporting it as
// missing would tell the user their file is gone when its host is merely asleep.
return { ...safe, missing: false, size, mtimeMs };
let probe: RemoteProbe | null;
let rootProbe: RemoteProbe | null;
if (batch) {
// The list route resolved the whole history in one round trip.
if (batch.unreachable) return { ...safe, missing: false, size, mtimeMs };
probe = batch.byPath.get(lexical.resolvedPath) ?? null;
rootProbe = batch.root;
} else {
try {
[probe, rootProbe] = await remoteProbePaths(scope.remote, [lexical.resolvedPath, workingDir]);
} catch {
// Unreachable host: the entry is not "missing", it is unknown. Reporting it as
// missing would tell the user their file is gone when its host is merely asleep.
return { ...safe, missing: false, size, mtimeMs };
}
}
const [probe, rootProbe] = probes;
if (!probe || !isPathWithinRoot(rootProbe?.realPath ?? workingDir, probe.realPath)) {
return { ...safe, missing: true };
}
@@ -1090,17 +1159,24 @@ async function buildDetectedAttachmentRouteItem(
async function buildExternalAttachmentRouteItem(
sessionId: string,
item: SessionAttachmentHistoryItem,
scope: SessionFileScope
scope: SessionFileScope,
batch?: RemoteHistoryProbes
): Promise<AttachmentHistoryRouteItem> {
const safe = sanitizeAttachmentHistoryItem(item);
if (!item.externalPath) {
return { ...safe, missing: true };
}
// Same answer as the detected branch for the same event: an unreachable host makes
// the entry unknown, never missing.
if (batch?.unreachable) {
return { ...safe, missing: false };
}
try {
const event = await registerExternalAttachment(sessionId, item.externalPath, {
sessionWorkingDir: scope.workingDir,
remote: scope.remote,
remoteProbes: batch ? [batch.byPath.get(item.externalPath) ?? null, batch.root] : undefined,
});
return {
...safe,
@@ -1118,7 +1194,9 @@ async function buildExternalAttachmentRouteItem(
};
} catch (err) {
if (err instanceof AttachmentRegistrationError) {
return { ...safe, missing: true };
// 502 is the transport, not the file (see resolveRemoteAttachment): unknown,
// like the detected branch. Anything else (404, 403, wrong kind) is missing.
return { ...safe, missing: err.statusCode === 502 ? false : true };
}
throw err;
}
@@ -1815,6 +1893,20 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
async (req): Promise<ApiResponse<FileWriteData>> => {
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id, req);
// Remote WRITES are out of scope by design (docs/file-viewer-edit-plan.md §6),
// and this guard must sit ahead of `validateSessionFilePath`: that helper
// resolves against the LOCAL filesystem, so with a directory of the same
// absolute name on this host (an sshfs mount of the remote tree, `/srv/case`,
// a same-named home) the write would land on the local twin while the viewer
// believes it edited the remote file. The read-remote/write-local split is
// exactly what the no-local-fallback rule exists to prevent.
if (session.remote) {
throwFileEditError(
400,
ApiErrorCode.INVALID_INPUT,
'Editing is not supported for files in a remote (SSH) case'
);
}
const body = parseBody(FileWriteSchema, req.body);
// Exact byte cap — the schema's .max() counts UTF-16 code units and is
@@ -2056,11 +2148,14 @@ export function registerFileRoutes(app: FastifyInstance, ctx: SessionPort & Even
return;
}
// Remote: every entry's realpath + stat in one batched probe, never one ssh per
// item (see probeRemoteAttachmentHistory). Local: undefined, each item stats itself.
const batch = await probeRemoteAttachmentHistory(sessionHistory.scope, sessionHistory.history);
const items = await Promise.all(
sessionHistory.history.map((item) =>
(item.source === 'external'
? buildExternalAttachmentRouteItem(id, item, sessionHistory.scope)
: buildDetectedAttachmentRouteItem(id, sessionHistory.scope, item)
? buildExternalAttachmentRouteItem(id, item, sessionHistory.scope, batch)
: buildDetectedAttachmentRouteItem(id, sessionHistory.scope, item, batch)
).catch(() => ({ ...sanitizeAttachmentHistoryItem(item), missing: true }))
)
);