mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
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:
+174
-28
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user