Files
Codeman/test/remote-ssh-limiter.test.ts
T
Codeman maintainer e49c48145b 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>
2026-09-14 23:42:06 +02:00

68 lines
2.2 KiB
TypeScript

/**
* @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');
});
});