mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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>
68 lines
2.2 KiB
TypeScript
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');
|
|
});
|
|
});
|