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
+174 -28
View File
@@ -16,7 +16,7 @@
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, statSync } from 'node:fs';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync, existsSync, statSync, chmodSync } from 'node:fs';
import { join } from 'node:path';
import { homedir, tmpdir } from 'node:os';
import {
@@ -24,8 +24,11 @@ import {
buildRemoteFileCommand,
buildRemoteProbeCommand,
buildRemoteReadCommand,
parseRemoteProbeLine,
parseRemoteProbeLines,
parseRemoteProbeRecord,
parseRemoteProbeOutput,
remoteProbePaths,
remoteReadFile,
remoteCreateReadStream,
} from '../src/remote-files.js';
import type { SessionRemote } from '../src/types/session.js';
@@ -103,34 +106,117 @@ describe('buildRemoteProbeCommand', () => {
const script = buildRemoteProbeCommand(['/srv/case/a.png', '/srv/case']);
const probeCalls = script.split('\n').filter((line) => line.startsWith('probe '));
expect(probeCalls).toEqual(["probe '/srv/case/a.png'", "probe '/srv/case'"]);
// The index is what the parser keys records on, so it is part of the call.
expect(probeCalls).toEqual(["probe 0 '/srv/case/a.png'", "probe 1 '/srv/case'"]);
});
it('quotes a path with spaces, quotes and a command substitution', () => {
const nasty = "/srv/case/it's $(touch /tmp/pwned).txt";
const script = buildRemoteProbeCommand([nasty]);
expect(script).toContain(`probe '/srv/case/it'\\''s $(touch /tmp/pwned).txt'`);
expect(script).toContain(`probe 0 '/srv/case/it'\\''s $(touch /tmp/pwned).txt'`);
expect(shellArgv(buildRemoteFileCommand(remoteFixture(), script)).at(-1)).toBe(script);
});
});
/**
* Run the probe script through a real `/bin/sh`. With `shadowReadlinkF` the PATH is
* fronted by a `readlink` that rejects `-f` the way macOS < 12.3 does (`illegal
* option -- f`) and otherwise defers to the real one, which forces the portable
* fallback branch on a host that natively has `readlink -f`.
*/
function runProbe(paths: string[], options: { cwd?: string; shadowReadlinkF?: boolean; shimDir?: string } = {}) {
const env =
options.shadowReadlinkF && options.shimDir
? { ...process.env, PATH: `${options.shimDir}:${process.env.PATH}` }
: process.env;
const stdout = execFileSync('sh', ['-c', buildRemoteProbeCommand(paths)], { cwd: options.cwd, env }).toString();
return parseRemoteProbeOutput(stdout, paths);
}
describe('the probe script on a real shell', () => {
let root: string;
let shimDir: string;
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'codeman-remote-probe-'));
shimDir = join(root, 'shim-bin');
mkdirSync(shimDir);
const realReadlink = execFileSync('sh', ['-c', 'command -v readlink']).toString().trim();
writeFileSync(
join(shimDir, 'readlink'),
`#!/bin/sh\ncase "$1" in -f) echo 'readlink: illegal option -- f' >&2; exit 1;; esac\nexec ${realReadlink} "$@"\n`
);
chmodSync(join(shimDir, 'readlink'), 0o755);
});
afterAll(() => {
rmSync(root, { recursive: true, force: true });
});
it('resolves the fallback branch on a shell whose readlink has no -f', () => {
// Sanity check on the shim itself: without it this whole describe would be
// exercising the native branch twice.
expect(() =>
execFileSync('sh', ['-c', 'readlink -f / 2>/dev/null'], {
env: { ...process.env, PATH: `${shimDir}:${process.env.PATH}` },
})
).toThrow();
});
it.each([
['readlink -f', false],
['portable fallback', true],
])('refuses to report a symlink by its own path (%s): the target is what is served', (_label, shadow) => {
// The reviewer's exact reproduction: ws/notes.txt -> secret/id_rsa. The old
// fallback resolved only the DIRECTORY chain, returned `ws/notes.txt` as the
// realpath (with the TARGET's size), containment passed, and `cat` served the key.
const ws = join(root, `escape-${shadow ? 'fallback' : 'native'}`);
const secret = join(root, `secret-${shadow ? 'fallback' : 'native'}`);
mkdirSync(ws);
mkdirSync(secret);
writeFileSync(join(secret, 'id_rsa'), 'KEYKEYKEYKEY1');
execFileSync('ln', ['-s', join(secret, 'id_rsa'), join(ws, 'notes.txt')]);
const [probe] = runProbe([join(ws, 'notes.txt')], { shadowReadlinkF: shadow, shimDir });
expect(probe?.realPath).toBe(join(secret, 'id_rsa'));
expect(probe?.size).toBe(13);
});
it('follows a relative symlink chain through a symlinked directory on the fallback branch', () => {
const ws = join(root, 'chain');
mkdirSync(join(ws, 'sub'), { recursive: true });
writeFileSync(join(ws, 'sub', 'real.txt'), 'inside');
execFileSync('ln', ['-s', 'real.txt', join(ws, 'sub', 'hop1.txt')]);
execFileSync('ln', ['-s', 'hop1.txt', join(ws, 'sub', 'hop2.txt')]);
execFileSync('ln', ['-s', 'sub', join(ws, 'subl')]);
const probes = runProbe([join(ws, 'subl', 'hop2.txt'), join(ws, 'subl')], { shadowReadlinkF: true, shimDir });
expect(probes[0]).toMatchObject({ kind: 'file', size: 6, realPath: join(ws, 'sub', 'real.txt') });
expect(probes[1]).toMatchObject({ kind: 'directory', realPath: join(ws, 'sub') });
});
it.each([
['readlink -f', false],
['portable fallback', true],
])('fails CLOSED on a symlink loop (%s), never reporting the unresolved path', (_label, shadow) => {
const ws = join(root, `loop-${shadow ? 'fallback' : 'native'}`);
mkdirSync(ws);
execFileSync('ln', ['-s', 'b', join(ws, 'a')]);
execFileSync('ln', ['-s', 'a', join(ws, 'b')]);
const [probe] = runProbe([join(ws, 'a')], { shadowReadlinkF: shadow, shimDir });
expect(probe).toBeNull();
});
it('reports kind, size and realpath for a file, a directory and a missing path', () => {
const filePath = join(root, 'image.png');
writeFileSync(filePath, 'fake png bytes');
const probes = parseRemoteProbeLines(
const probes = parseRemoteProbeOutput(
execFileSync('sh', ['-c', buildRemoteProbeCommand([filePath, root, join(root, 'nope.png')])]).toString(),
[filePath, root, join(root, 'nope.png')]
);
@@ -147,7 +233,7 @@ describe('the probe script on a real shell', () => {
writeFileSync(target, 'x');
execFileSync('ln', ['-s', target, link]);
const [probe] = parseRemoteProbeLines(execFileSync('sh', ['-c', buildRemoteProbeCommand([link])]).toString(), [
const [probe] = parseRemoteProbeOutput(execFileSync('sh', ['-c', buildRemoteProbeCommand([link])]).toString(), [
link,
]);
@@ -161,7 +247,7 @@ describe('the probe script on a real shell', () => {
const hostile = join(root, `it's; touch ${marker}; $(id).txt`);
writeFileSync(hostile, 'hostile');
const [probe] = parseRemoteProbeLines(
const [probe] = parseRemoteProbeOutput(
execFileSync('sh', ['-c', buildRemoteProbeCommand([hostile])], { cwd: root }).toString(),
[hostile]
);
@@ -170,11 +256,39 @@ describe('the probe script on a real shell', () => {
expect(existsSync(join(root, marker))).toBe(false);
});
it('keeps a filename containing a newline aligned with its own index', () => {
// One record per LINE would have made this two lines, shifting every record
// after it by one; records are NUL-terminated and index-keyed instead.
const weird = join(root, 'a\nb.txt');
writeFileSync(weird, 'nl');
const after = join(root, 'after.txt');
writeFileSync(after, 'after');
const probes = runProbe([weird, after, join(root, 'nope')]);
expect(probes[0]).toMatchObject({ kind: 'file', size: 2, realPath: weird });
expect(probes[1]).toMatchObject({ kind: 'file', size: 5, realPath: after });
expect(probes[2]).toBeNull();
});
it('discards a login banner and rc-file chatter printed before the records', () => {
const filePath = join(root, 'banner.txt');
writeFileSync(filePath, 'b');
const stdout = execFileSync('sh', [
'-c',
`echo 'Welcome to box'; printf '0|f|9|9|/etc/shadow\\n'; ${buildRemoteProbeCommand([filePath])}`,
]).toString();
// The chatter even LOOKS like a record; the leading NUL is what fences it off.
expect(parseRemoteProbeOutput(stdout, [filePath])[0]).toMatchObject({ realPath: filePath, size: 1 });
});
it('handles a path containing the field separator', () => {
const pipePath = join(root, 'a|b.txt');
writeFileSync(pipePath, 'xy');
const [probe] = parseRemoteProbeLines(execFileSync('sh', ['-c', buildRemoteProbeCommand([pipePath])]).toString(), [
const [probe] = parseRemoteProbeOutput(execFileSync('sh', ['-c', buildRemoteProbeCommand([pipePath])]).toString(), [
pipePath,
]);
@@ -187,7 +301,7 @@ describe('the probe script on a real shell', () => {
mkdirSync(nested, { recursive: true });
writeFileSync(join(nested, 'f.txt'), 'abc');
const [probe] = parseRemoteProbeLines(
const [probe] = parseRemoteProbeOutput(
execFileSync('sh', ['-c', buildRemoteProbeCommand([join(nested, 'f.txt')])]).toString(),
[join(nested, 'f.txt')]
);
@@ -197,9 +311,9 @@ describe('the probe script on a real shell', () => {
});
});
describe('parseRemoteProbeLine', () => {
it('parses a file line and converts mtime to milliseconds', () => {
expect(parseRemoteProbeLine('f|1234|1700000000|/srv/case/a.png')).toEqual({
describe('parseRemoteProbeRecord', () => {
it('parses a file record and converts mtime to milliseconds', () => {
expect(parseRemoteProbeRecord('f|1234|1700000000|/srv/case/a.png')).toEqual({
realPath: '/srv/case/a.png',
kind: 'file',
size: 1234,
@@ -208,34 +322,66 @@ describe('parseRemoteProbeLine', () => {
});
it('keeps a path that itself contains the separator', () => {
expect(parseRemoteProbeLine('f|7|0|/srv/ca|se/a b.txt')?.realPath).toBe('/srv/ca|se/a b.txt');
expect(parseRemoteProbeRecord('f|7|0|/srv/ca|se/a b.txt')?.realPath).toBe('/srv/ca|se/a b.txt');
});
it('maps directories, other kinds and the not-found marker', () => {
expect(parseRemoteProbeLine('d|0|5|/srv/case')?.kind).toBe('directory');
expect(parseRemoteProbeLine('o|0|0|/srv/case/sock')?.kind).toBe('other');
expect(parseRemoteProbeLine('n')).toBeNull();
expect(parseRemoteProbeLine('')).toBeNull();
it('maps directories, other kinds, the not-found and the unresolvable markers', () => {
expect(parseRemoteProbeRecord('d|0|5|/srv/case')?.kind).toBe('directory');
expect(parseRemoteProbeRecord('o|0|0|/srv/case/sock')?.kind).toBe('other');
expect(parseRemoteProbeRecord('n')).toBeNull();
// Exists but could not be canonicalized: refused like a missing file, never
// served under a path whose real target is unknown.
expect(parseRemoteProbeRecord('x')).toBeNull();
expect(parseRemoteProbeRecord('')).toBeNull();
});
it('rejects malformed lines instead of inventing a path', () => {
expect(parseRemoteProbeLine('f|1|2')).toBeNull();
expect(parseRemoteProbeLine('x|1|2|/p')).toBeNull();
expect(parseRemoteProbeLine('f|1|2|')).toBeNull();
expect(parseRemoteProbeRecord('f|1|2')).toBeNull();
expect(parseRemoteProbeRecord('x|1|2|/p')).toBeNull();
expect(parseRemoteProbeRecord('f|1|2|')).toBeNull();
});
});
describe('parseRemoteProbeLines', () => {
it('aligns the last N lines, so a login banner cannot shift the mapping', () => {
const stdout = 'welcome to the remote box\nf|3|1|/srv/a.txt\nn\n';
expect(parseRemoteProbeLines(stdout, ['/srv/a.txt', '/srv/b.txt'])).toEqual([
describe('parseRemoteProbeOutput', () => {
it('keys records by index after the leading NUL, so a login banner cannot shift the mapping', () => {
const stdout = 'welcome to the remote box\n\x000|f|3|1|/srv/a.txt\x001|n\x00';
expect(parseRemoteProbeOutput(stdout, ['/srv/a.txt', '/srv/b.txt'])).toEqual([
{ realPath: '/srv/a.txt', kind: 'file', size: 3, mtimeMs: 1000 },
null,
]);
});
it('throws when the remote shell returned too little output', () => {
expect(() => parseRemoteProbeLines('f|3|1|/srv/a.txt\n', ['/a', '/b'])).toThrow(RemoteFileAccessError);
it('accepts records in any order and ignores duplicates of an index', () => {
const stdout = '\x001|d|0|0|/srv\x000|f|3|1|/srv/a.txt\x000|f|9|9|/evil\x00';
expect(parseRemoteProbeOutput(stdout, ['/srv/a.txt', '/srv'])).toEqual([
{ realPath: '/srv/a.txt', kind: 'file', size: 3, mtimeMs: 1000 },
{ realPath: '/srv', kind: 'directory', size: 0, mtimeMs: 0 },
]);
});
it('throws when a requested path has no record (transport or shell failure, never a 404)', () => {
expect(() => parseRemoteProbeOutput('\x000|f|3|1|/srv/a.txt\x00', ['/a', '/b'])).toThrow(RemoteFileAccessError);
expect(() => parseRemoteProbeOutput('', ['/a'])).toThrow(RemoteFileAccessError);
// No leading NUL at all: the script never ran, whatever the shell printed.
expect(() => parseRemoteProbeOutput('0|f|3|1|/srv/a.txt', ['/srv/a.txt'])).toThrow(RemoteFileAccessError);
});
});
describe('under vitest', () => {
const remote = remoteFixture();
it('never opens a connection: probes and reads reject with a clear error', async () => {
// Mirrors checkRemoteTmuxAvailable's guard. The route tests mock this module, so
// this is the backstop for the next test that reaches the real one.
await expect(remoteProbePaths(remote, ['/srv/case'])).rejects.toThrow(/disabled under test/);
await expect(remoteReadFile(remote, '/srv/case/a.txt', 1024)).rejects.toThrow(/disabled under test/);
});
it('never opens a connection: a stream fails through its own error path', async () => {
const { stream, close } = remoteCreateReadStream(remote, '/srv/case/a.mp4');
const failure = await new Promise<Error>((resolveError) => stream.on('error', resolveError));
expect(failure).toBeInstanceOf(RemoteFileAccessError);
expect(() => close()).not.toThrow();
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* @fileoverview Tests for the remote-file ssh concurrency limiter
* (`src/remote-ssh-limiter.ts`): the cap holds under interleaved async resumption,
* waiters are served FIFO, and a task that throws still releases its slot.
*
* Port: N/A (no HTTP server).
*/
import { describe, it, expect } from 'vitest';
import {
getActiveRemoteSshCount,
getQueuedRemoteSshCount,
getRemoteSshLimit,
runWithRemoteSshLimit,
} from '../src/remote-ssh-limiter.js';
function deferred(): { promise: Promise<void>; resolve: () => void } {
let resolve!: () => void;
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, resolve };
}
describe('runWithRemoteSshLimit', () => {
it('never lets more than the cap run at once, and queues the rest FIFO', async () => {
const cap = getRemoteSshLimit();
const gates = Array.from({ length: cap + 3 }, () => deferred());
const started: number[] = [];
let peak = 0;
const runs = gates.map((gate, index) =>
runWithRemoteSshLimit(async () => {
started.push(index);
peak = Math.max(peak, getActiveRemoteSshCount());
await gate.promise;
return index;
})
);
await Promise.resolve();
expect(started).toEqual(Array.from({ length: cap }, (_, i) => i));
expect(getActiveRemoteSshCount()).toBe(cap);
expect(getQueuedRemoteSshCount()).toBe(3);
// Releasing one hands the slot to the OLDEST waiter; the count stays at the cap.
gates[0].resolve();
await runs[0];
await Promise.resolve();
expect(started).toEqual([...Array.from({ length: cap }, (_, i) => i), cap]);
expect(getActiveRemoteSshCount()).toBe(cap);
for (const gate of gates) gate.resolve();
expect(await Promise.all(runs)).toEqual(gates.map((_, i) => i));
expect(peak).toBe(cap);
expect(getActiveRemoteSshCount()).toBe(0);
expect(getQueuedRemoteSshCount()).toBe(0);
});
it('releases the slot when the task throws', async () => {
await expect(runWithRemoteSshLimit(async () => Promise.reject(new Error('ssh exit 255')))).rejects.toThrow(
'ssh exit 255'
);
expect(getActiveRemoteSshCount()).toBe(0);
expect(await runWithRemoteSshLimit(async () => 'after')).toBe('after');
});
});
+157 -2
View File
@@ -542,7 +542,9 @@ describe('file routes in a remote (SSH) case', () => {
externalPath: outsidePath,
},
];
mockedProbePaths.mockResolvedValue([fileProbe(outsidePath, 42), dirProbe]);
mockedProbePaths.mockImplementation(async (_remote, paths) =>
paths.map((path) => (path === outsidePath ? fileProbe(outsidePath, 42) : path === REMOTE_DIR ? dirProbe : null))
);
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/attachments` });
@@ -568,7 +570,15 @@ describe('file routes in a remote (SSH) case', () => {
relativePath: 'out.png',
},
];
mockedProbePaths.mockResolvedValue([fileProbe(`${REMOTE_DIR}/out.png`, 7), dirProbe]);
mockedProbePaths.mockImplementation(async (_remote, paths) =>
paths.map((path) =>
path === `${REMOTE_DIR}/out.png`
? fileProbe(`${REMOTE_DIR}/out.png`, 7)
: path === REMOTE_DIR
? dirProbe
: null
)
);
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/attachments` });
@@ -577,5 +587,150 @@ describe('file routes in a remote (SSH) case', () => {
expect(item.size).toBe(7);
expect(item.rawUrl).toContain('file-raw');
});
describe('the history list probes the whole history in ONE ssh round trip', () => {
// One connection per entry (up to ATTACHMENT_HISTORY_LIMIT, re-run on every
// attachment:detected while the drawer is open) tripped OpenSSH's default
// MaxStartups 10:30:100, which drops most of a burst that size.
const history = () => [
{
id: 'hist-a',
sessionId,
fileName: 'out.png',
extension: 'png',
attachmentType: 'image' as const,
size: 1,
mtimeMs: 1,
timestamp: 1,
source: 'detected' as const,
relativePath: 'out.png',
},
{
id: 'hist-b',
sessionId,
fileName: 'shot.png',
extension: 'png',
attachmentType: 'image' as const,
size: 1,
mtimeMs: 1,
timestamp: 1,
source: 'external' as const,
externalPath: outsidePath,
},
{
id: 'hist-c',
sessionId,
fileName: 'gone.png',
extension: 'png',
attachmentType: 'image' as const,
size: 1,
mtimeMs: 1,
timestamp: 1,
source: 'detected' as const,
relativePath: 'gone.png',
},
];
it('issues a single batched probe covering every entry plus the workspace root', async () => {
harness.ctx._session.attachmentHistory = history();
mockedProbePaths.mockImplementation(async (_remote, paths) =>
paths.map((path) =>
path === `${REMOTE_DIR}/out.png`
? fileProbe(path, 7)
: path === outsidePath
? fileProbe(outsidePath, 42)
: path === REMOTE_DIR
? dirProbe
: null
)
);
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/attachments` });
expect(res.statusCode).toBe(200);
expect(mockedProbePaths).toHaveBeenCalledTimes(1);
const [, probed] = mockedProbePaths.mock.calls[0];
expect([...probed].sort()).toEqual(
[REMOTE_DIR, `${REMOTE_DIR}/gone.png`, `${REMOTE_DIR}/out.png`, outsidePath].sort()
);
// (ids are re-minted for external entries by the sanitizer, so key on the name)
const items = JSON.parse(res.body).data.items as Array<{ fileName: string; missing: boolean; size: number }>;
expect(items.map((item) => [item.fileName, item.missing, item.size])).toEqual([
['out.png', false, 7],
['shot.png', false, 42],
['gone.png', true, 1],
]);
});
it('reports every entry as unknown (missing: false), detected AND external alike, when the host is unreachable', async () => {
harness.ctx._session.attachmentHistory = history();
mockedProbePaths.mockRejectedValue(new RemoteFileAccessError('remote host testhost unreachable: timed out'));
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${sessionId}/attachments` });
expect(res.statusCode).toBe(200);
const items = JSON.parse(res.body).data.items as Array<{ id: string; missing: boolean }>;
// The two branches used to disagree here: detected kept missing:false while
// external's 502 was folded into missing:true.
expect(items.map((item) => item.missing)).toEqual([false, false, false]);
});
});
});
describe('PUT /api/sessions/:id/file-content', () => {
// The remote guard has to come BEFORE the local path validation: with a
// directory of the same absolute name on this host (an sshfs mount of the remote
// tree, the documented stop-gap for #415) the write would land on the local twin
// while the viewer believes it edited the remote file.
let shadowRoot: string;
let shadowFile: string;
beforeEach(() => {
shadowRoot = mkdtempSync(join(tmpdir(), 'codeman-remote-put-'));
shadowFile = join(shadowRoot, 'notes.txt');
writeFileSync(shadowFile, 'LOCAL TEXT');
harness.ctx._session.workingDir = shadowRoot;
harness.ctx._session.remote = { ...remote, remotePath: shadowRoot };
});
afterEach(() => {
rmSync(shadowRoot, { recursive: true, force: true });
});
it('answers 400 for a remote case and never touches the local file of the same name', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: `/api/sessions/${sessionId}/file-content`,
payload: { path: 'notes.txt', content: 'OVERWRITTEN', baseHash: 'whatever', force: true },
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/not supported for files in a remote/);
expect(readFileSync(shadowFile, 'utf8')).toBe('LOCAL TEXT');
expect(mockedProbePaths).not.toHaveBeenCalled();
});
});
describe('a client that gives up during the guard probe', () => {
it('still has its ssh body child reaped', async () => {
// The probe is an ssh round trip; a client that aborted during it has already
// closed the response, so a `close` listener attached afterwards never fires.
const controller = new AbortController();
mockedProbePaths.mockImplementation(async () => {
controller.abort();
await new Promise((resolveDelay) => setTimeout(resolveDelay, 20));
return [fileProbe(`${REMOTE_DIR}/img.png`, 9), dirProbe];
});
await harness.app
.inject({ method: 'GET', url: `/api/sessions/${sessionId}/file-raw?path=img.png`, signal: controller.signal })
.catch(() => undefined);
await new Promise((resolveDelay) => setTimeout(resolveDelay, 50));
// The body WAS opened (the route ran to completion against an already-closed
// response), which is exactly the window the guard covers.
expect(mockedCreateReadStream).toHaveBeenCalledTimes(1);
expect(closeSpy).toHaveBeenCalled();
});
});
});