Files
Codeman/test/remote-edit-sources.test.ts
T
Codeman maintainer 26b63aea2d fix(edit-history): remote lookup finds tmux, a timeout reaches ssh, docs follow
Applies the review of #766.

The live lookup ran tmux on the bare PATH of the ssh command. On a host
where tmux sits in ~/.local/bin or /opt/homebrew/bin it found no pane, the
2>/dev/null hid that, and the panel stayed on the launch conversation after
a /clear. It now goes through withRemoteTmuxPath(), inside the sh script and
scoped to the $(...), so the other tools keep the PATH they had. The fake
tmux of the test block moved to $HOME/.local/bin for the same reason: that
directory is first on the new PATH, so a real tmux is never asked.

The two ssh commands get exec. Node signals the local sh -c, and dash stays
the parent of a plain command, so ssh survived the timeout. A test with a
fake ssh shows the process dies now and survives without exec.

The comment at the meta budget claimed a meta past it comes with a later
read. A meta comes only with a subagent's first read, so it is never sent.

Docs: the store is named as the exception to "never copied to local disk",
remoteProbePaths and the remote transcript lookup are described, and the
line saying a remote session loads no store is gone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 02:57:58 +01:00

536 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @fileoverview Tests for the remote edit-history reads in `src/remote-files.ts`: the transcript
* lookup and the transcript read a remote claude session's edit history uses.
*
* Both scripts are executed by a real `/bin/sh` against a temp `$HOME`: the script IS the
* remote command, so `sh -c <script>` reproduces what sshd runs on the other end. The read is
* checked byte for byte against the local reader (`readClaudeEditSources`) on seeded random
* transcripts, so the edit history sync sees the same thing from a remote host as from a
* local one.
*
* Port: N/A (no HTTP server).
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { exec, execFileSync } from 'node:child_process';
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
utimesSync,
writeFileSync,
} from 'node:fs';
import { dirname, join, relative } from 'node:path';
import { fileURLToPath } from 'node:url';
import { tmpdir } from 'node:os';
import {
RemoteFileAccessError,
buildRemoteClaudeEditSourcesCommand,
buildRemoteEditHistoryCommand,
buildRemoteClaudeTranscriptLocateCommand,
isRemoteClaudeTranscriptPath,
parseRemoteClaudeEditSources,
parseRemoteClaudeTranscriptLocate,
remoteEditSourcesTimeoutMs,
remoteLocateClaudeTranscript,
remoteReadClaudeEditSources,
} from '../src/remote-files.js';
import { readClaudeEditSources, type EditSourceRequest } from '../src/edit-history-local-source.js';
import type { SessionRemote } from '../src/types/session.js';
let home: string;
beforeAll(() => {
// A throwaway HOME: the scripts only ever see this directory as $HOME.
home = mkdtempSync(join(tmpdir(), 'codeman-remote-edit-'));
});
afterAll(() => rmSync(home, { recursive: true, force: true }));
/** Run a built command (`sh -c '…'` or `exit 3`) the way sshd would; status and raw stdout. */
function runSh(command: string, env: Record<string, string> = {}): { code: number; stdout: Buffer } {
expect(home.startsWith(tmpdir())).toBe(true);
try {
return {
code: 0,
stdout: execFileSync('sh', ['-c', command], { env: { ...process.env, HOME: home, ...env }, cwd: home }),
};
} catch (err) {
const e = err as { status: number; stdout: Buffer };
return { code: e.status, stdout: e.stdout };
}
}
/** One edit-source record in the script's wire format. */
function editRecord(relPath: string, size: number, start: number, body: Buffer | string): Buffer {
const bytes = Buffer.isBuffer(body) ? body : Buffer.from(body);
return Buffer.concat([Buffer.from(`${relPath}\0${size}\0${start}\0${bytes.length}\0`), bytes]);
}
const REMOTE: SessionRemote = { hostId: 'h', label: 'h', host: '192.0.2.1', username: 'j', remotePath: '/' };
describe('isRemoteClaudeTranscriptPath', () => {
it('takes an absolute <id>.jsonl path and nothing else', () => {
expect(isRemoteClaudeTranscriptPath('/home/u/.claude/projects/-w/conv-1.jsonl')).toBe(true);
expect(isRemoteClaudeTranscriptPath('rel/conv-1.jsonl')).toBe(false);
expect(isRemoteClaudeTranscriptPath('/p/conv-1.json')).toBe(false);
expect(isRemoteClaudeTranscriptPath('/p/bad id.jsonl')).toBe(false);
expect(isRemoteClaudeTranscriptPath('/p/$(x).jsonl')).toBe(false);
expect(isRemoteClaudeTranscriptPath('/p\n/conv-1.jsonl')).toBe(false);
expect(isRemoteClaudeTranscriptPath('/.jsonl')).toBe(false);
});
});
describe('the transcript lookup on a real shell', () => {
const projects = () => join(home, '.claude', 'projects');
beforeAll(() => {
mkdirSync(join(projects(), 'old'), { recursive: true });
mkdirSync(join(projects(), 'new'), { recursive: true });
writeFileSync(join(projects(), 'old', 'conv-1.jsonl'), 'x\n');
writeFileSync(join(projects(), 'new', 'conv-1.jsonl'), 'y\n');
writeFileSync(join(projects(), 'old', 'solo.jsonl'), 'z\n');
utimesSync(join(projects(), 'old', 'conv-1.jsonl'), 1_000, 1_000);
utimesSync(join(projects(), 'new', 'conv-1.jsonl'), 2_000, 2_000);
});
const locate = (ids: string[], pane?: Parameters<typeof buildRemoteClaudeTranscriptLocateCommand>[1], env = {}) => {
const { code, stdout } = runSh(buildRemoteClaudeTranscriptLocateCommand(ids, pane), env);
return code === 0 ? parseRemoteClaudeTranscriptLocate(stdout) : code;
};
it('runs under an explicit sh and never calls ls', () => {
const command = buildRemoteClaudeTranscriptLocateCommand(['conv-1']);
expect(command).toMatch(/^sh -c '/);
expect(command).not.toMatch(/(^|[\s;|(])ls\s/m);
});
it('takes the most recently written transcript of all ids, not the first id that has one', () => {
// conv-1 lies in old/ (1000 s) and new/ (2000 s); solo is older than both, then newest.
const solo = join(projects(), 'old', 'solo.jsonl');
try {
utimesSync(solo, 1_500, 1_500);
expect(locate(['conv-1', 'solo'])).toBe(join(projects(), 'new', 'conv-1.jsonl'));
expect(locate(['solo', 'conv-1'])).toBe(join(projects(), 'new', 'conv-1.jsonl'));
expect(locate(['missing', 'solo'])).toBe(solo);
// A respawn resumed the launch conversation after a /clear: the one written last wins.
utimesSync(solo, 3_000, 3_000);
expect(locate(['conv-1', 'solo'])).toBe(solo);
} finally {
// Older than conv-1, so the live tests below cannot pass by mtime alone.
utimesSync(solo, 1_500, 1_500);
}
});
it('exits 3 when no id has a transcript, and drops ids that are not plain tokens', () => {
expect(locate(['nope'])).toBe(3);
expect(buildRemoteClaudeTranscriptLocateCommand(['$(touch pwned)', "a'b", '../../x'])).toBe('exit 3');
const command = buildRemoteClaudeTranscriptLocateCommand(['$(touch pwned)', 'conv-1']);
expect(command).toContain('conv-1');
expect(command).not.toContain('pwned');
});
it('looks in an absolute CLAUDE_CONFIG_DIR instead of ~/.claude, as claude does; a relative one is ignored', () => {
const cfg = join(home, 'alt-config');
mkdirSync(join(cfg, 'projects', 'w'), { recursive: true });
writeFileSync(join(cfg, 'projects', 'w', 'alt-1.jsonl'), 'a\n');
expect(locate(['alt-1', 'conv-1'], undefined, { CLAUDE_CONFIG_DIR: cfg })).toBe(
join(cfg, 'projects', 'w', 'alt-1.jsonl')
);
expect(locate(['conv-1'], undefined, { CLAUDE_CONFIG_DIR: cfg })).toBe(3);
expect(locate(['alt-1', 'conv-1'], undefined, { CLAUDE_CONFIG_DIR: 'alt-config' })).toBe(
join(projects(), 'new', 'conv-1.jsonl')
);
});
it('parses only an absolute transcript path after the first NUL (a login banner comes before it)', () => {
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('motd\n\0/p/conv-1.jsonl\0'))).toBe('/p/conv-1.jsonl');
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('no nul'))).toBeNull();
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('\0/p/conv-1.jsonl'))).toBeNull();
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('\0p/conv-1.jsonl\0'))).toBeNull();
expect(parseRemoteClaudeTranscriptLocate(Buffer.from('\0/p/notes.txt\0'))).toBeNull();
});
describe('after a /clear: the conversation the pane’s claude writes now', () => {
// Fake tmux (pane pid 100) and ps (100 → 200 wrapper → 300 claude); claude's own
// ~/.claude/sessions/<pid>.json names the current conversation. tmux sits in the temp
// $HOME/.local/bin, which withRemoteTmuxPath() puts ahead of the system dirs, so a real
// tmux on this machine is never asked.
let bin: string;
const pane = { socket: 'codeman-remote', sessionName: 'codeman-ssh-conv1abc' };
beforeAll(() => {
bin = mkdtempSync(join(tmpdir(), 'codeman-fake-tmux-'));
const localBin = join(home, '.local', 'bin');
mkdirSync(localBin, { recursive: true });
writeFileSync(
join(localBin, 'tmux'),
'#!/bin/sh\ncase "$*" in *"=codeman-ssh-conv1abc"*) echo 100 ;; *) echo "no such session" >&2; exit 1 ;; esac\n'
);
writeFileSync(join(bin, 'ps'), '#!/bin/sh\nprintf " 1 0\\n100 1\\n200 100\\n300 200\\n"\n');
chmodSync(join(localBin, 'tmux'), 0o755);
chmodSync(join(bin, 'ps'), 0o755);
mkdirSync(join(home, '.claude', 'sessions'), { recursive: true });
});
afterAll(() => rmSync(bin, { recursive: true, force: true }));
const locateIn = (p: typeof pane | undefined) => locate(['conv-1'], p, { PATH: `${bin}:${process.env.PATH}` });
const session = (body: string) => writeFileSync(join(home, '.claude', 'sessions', '300.json'), body);
it('finds the live conversation of a grandchild claude before the stored ids, even an older one', () => {
// solo (1500 s) is older than conv-1 (2000 s): a /resume into an older conversation
// that has not written yet still wins.
session('{"pid":300,"sessionId":"solo","cwd":"/w","kind":"interactive"}');
expect(locateIn(pane)).toBe(join(projects(), 'old', 'solo.jsonl'));
});
it('finds the pane with tmux only under $HOME/.local/bin, on a PATH that does not list it', () => {
// A host where tmux is a per-user install: sshd's non-interactive PATH has no such dir.
const PATH = `${bin}:/usr/bin:/bin`;
expect(PATH).not.toContain('.local/bin');
expect(existsSync(join(bin, 'tmux'))).toBe(false);
session('{"pid":300,"sessionId":"solo"}');
expect(locate(['conv-1'], pane, { PATH })).toBe(join(projects(), 'old', 'solo.jsonl'));
});
it('falls back to the stored ids without a pane, for an unknown session, or a live id with no transcript', () => {
session('{"pid":300,"sessionId":"solo"}');
expect(locateIn(undefined)).toBe(join(projects(), 'new', 'conv-1.jsonl'));
expect(locateIn({ ...pane, sessionName: 'codeman-ssh-other' })).toBe(join(projects(), 'new', 'conv-1.jsonl'));
session('{"pid":300,"sessionId":"gone"}');
expect(locateIn(pane)).toBe(join(projects(), 'new', 'conv-1.jsonl'));
});
it('reads the live id from the sessions dir under CLAUDE_CONFIG_DIR', () => {
const cfg = join(home, 'alt-config');
mkdirSync(join(cfg, 'sessions'), { recursive: true });
mkdirSync(join(cfg, 'projects', 'w'), { recursive: true });
writeFileSync(join(cfg, 'projects', 'w', 'alt-1.jsonl'), 'a\n');
writeFileSync(join(cfg, 'projects', 'w', 'conv-1.jsonl'), 'c\n');
writeFileSync(join(cfg, 'sessions', '300.json'), '{"pid":300,"sessionId":"alt-1"}');
session('{"pid":300,"sessionId":"solo"}'); // ~/.claude's file is not claude's here
expect(locate(['conv-1'], pane, { PATH: `${bin}:${process.env.PATH}`, CLAUDE_CONFIG_DIR: cfg })).toBe(
join(cfg, 'projects', 'w', 'alt-1.jsonl')
);
});
it('takes only an id-shaped value from the sessions file, and only plain socket/session names', () => {
session('{"pid":300,"sessionId":"x$(touch pwned)"}');
expect(locateIn(pane)).toBe(join(projects(), 'new', 'conv-1.jsonl'));
expect(existsSync(join(home, 'pwned'))).toBe(false);
expect(buildRemoteClaudeTranscriptLocateCommand(['conv-1'], { socket: "a';x", sessionName: 's' })).not.toContain(
'list-panes'
);
});
});
});
describe('buildRemoteClaudeEditSourcesCommand', () => {
it('runs under an explicit sh and never calls ls', () => {
const command = buildRemoteClaudeEditSourcesCommand('/p/conv-1.jsonl', [], 1024);
expect(command).toMatch(/^sh -c '/);
expect(command).not.toMatch(/(^|[\s;|(])ls\s/m);
});
it('exits 3 for a path that is not an absolute <id>.jsonl, without putting it in a script', () => {
for (const path of ['rel/conv-1.jsonl', "/p/a';touch x;'.jsonl", '/p/conv-1.txt', '/p\n/conv-1.jsonl']) {
expect(buildRemoteClaudeEditSourcesCommand(path, [], 10)).toBe('exit 3');
}
});
it('ignores invalid relPaths, offsets and lengths; the first request per relPath wins', () => {
const command = buildRemoteClaudeEditSourcesCommand(
'/p/conv-1.jsonl',
[
{ relPath: '../../etc/passwd', offset: 0 },
{ relPath: "subagents/agent-a';touch x;'.jsonl", offset: 0 },
{ relPath: 'subagents/agent-neg.jsonl', offset: -1 },
{ relPath: 'subagents/agent-frac.jsonl', offset: 1.5 },
{ relPath: 'subagents/agent-nan.jsonl', offset: Number.NaN },
{ relPath: 'subagents/agent-badlen.jsonl', offset: 0, len: -5 },
{ relPath: 'subagents/agent-ok.jsonl', offset: 7, len: 3 },
{ relPath: 'subagents/agent-ok.jsonl', offset: 99 },
],
100
);
expect(command).not.toMatch(/passwd|touch|agent-neg|agent-frac|agent-nan|agent-badlen/);
expect(command).toContain('agent-ok.jsonl');
expect(command).toContain('r 7 3');
expect(command).not.toContain('r 99');
});
});
describe('parseRemoteClaudeEditSources', () => {
it('splits several records byte-exactly and keeps a UTF-8 sequence cut at a chunk edge intact as bytes', () => {
const euro = Buffer.from('a€', 'utf8'); // 61 e2 82 ac
const stdout = Buffer.concat([
Buffer.from('banner from .zshrc\n\0conv-1\0'),
editRecord('main', 10, 6, euro.subarray(0, 3)), // ends mid-€
editRecord('subagents/agent-x1.jsonl', 4, 0, '{}\n\0'),
editRecord('subagents/agent-x1.meta.json', 23, 0, '{"agentType":"Explore"}'),
]);
const parsed = parseRemoteClaudeEditSources(stdout);
expect(parsed?.transcriptId).toBe('conv-1');
expect(parsed?.files).toHaveLength(2);
expect(parsed?.files[0]).toMatchObject({ relPath: 'main', size: 10, startOffset: 6 });
expect(parsed?.files[0].chunk.equals(euro.subarray(0, 3))).toBe(true);
expect(parsed?.files[1].chunk.equals(Buffer.from('{}\n\0'))).toBe(true);
expect(parsed?.metas).toEqual({ x1: '{"agentType":"Explore"}' });
});
it('accepts a zero-length record (budget used up) and a header with no records', () => {
const parsed = parseRemoteClaudeEditSources(
Buffer.concat([Buffer.from('\0conv-1\0'), editRecord('main', 500, 500, '')])
);
expect(parsed?.files).toEqual([{ relPath: 'main', size: 500, startOffset: 500, chunk: Buffer.alloc(0) }]);
expect(parseRemoteClaudeEditSources(Buffer.from('\0conv-1\0'))).toEqual({
transcriptId: 'conv-1',
files: [],
metas: {},
});
});
it('is null when the protocol is broken', () => {
expect(parseRemoteClaudeEditSources(Buffer.from('no nul at all'))).toBeNull();
expect(parseRemoteClaudeEditSources(Buffer.from('\0conv-1'))).toBeNull();
expect(parseRemoteClaudeEditSources(Buffer.from('\0bad id\0'))).toBeNull();
// Body shorter than declared.
expect(parseRemoteClaudeEditSources(Buffer.from('\0conv-1\0main\x0010\x000\x005\x00abc'))).toBeNull();
// Truncated header.
expect(parseRemoteClaudeEditSources(Buffer.from('\0conv-1\0main\x0010\x00'))).toBeNull();
// Non-numeric size, unknown relPath, slice outside the file.
expect(parseRemoteClaudeEditSources(Buffer.from('\0conv-1\0main\0x\x000\x000\0'))).toBeNull();
expect(
parseRemoteClaudeEditSources(Buffer.concat([Buffer.from('\0conv-1\0'), editRecord('../x', 1, 0, 'a')]))
).toBeNull();
expect(
parseRemoteClaudeEditSources(Buffer.concat([Buffer.from('\0conv-1\0'), editRecord('main', 2, 1, 'ab')]))
).toBeNull();
});
});
describe('remoteEditSourcesTimeoutMs', () => {
it('gives a read of megabytes its transfer time at 256 KiB/s on top of the flat 30 s', () => {
expect(remoteEditSourcesTimeoutMs(0)).toBe(30_000);
expect(remoteEditSourcesTimeoutMs(-5)).toBe(30_000);
expect(remoteEditSourcesTimeoutMs(256 * 1024)).toBe(31_000);
// a forward pass: 8 MiB budget + 4 MiB metas + 1 MiB header slack
expect(remoteEditSourcesTimeoutMs(13 * 1024 * 1024)).toBe(82_000);
});
});
describe('the transcript read on a real shell', () => {
const project = () => join(home, '.claude', 'projects', 'read');
const main = () => join(project(), 'conv-r.jsonl');
const read = (requests: EditSourceRequest[], budget: number, onlyRequested = false) => {
const { code, stdout } = runSh(buildRemoteClaudeEditSourcesCommand(main(), requests, budget, { onlyRequested }));
return code === 0 ? parseRemoteClaudeEditSources(stdout) : code;
};
beforeAll(() => {
const sub = join(project(), 'conv-r', 'subagents');
mkdirSync(sub, { recursive: true });
writeFileSync(main(), 'main-0123456789\n');
writeFileSync(join(sub, 'agent-a1.jsonl'), 'AAAAAAAAAA');
writeFileSync(join(sub, 'agent-a1.meta.json'), '{"agentType":"Explore"}');
writeFileSync(join(sub, 'agent-b2.jsonl'), 'BBBBB');
writeFileSync(join(sub, 'agent-bad name.jsonl'), 'never read');
});
it('exits 3 when the transcript is gone', () => {
const { code } = runSh(buildRemoteClaudeEditSourcesCommand(join(project(), 'gone.jsonl'), [], 100));
expect(code).toBe(3);
});
it('continues from the offsets, reads a new subagent’s end with its meta, and an older block to its end', () => {
const parsed = read(
[
{ relPath: 'main', offset: 5 },
{ relPath: 'subagents/agent-b2.jsonl', offset: 1, len: 3 },
],
1000
);
expect(parsed).not.toBe(3);
if (typeof parsed !== 'object' || !parsed) return;
expect(parsed.files.map((f) => [f.relPath, f.size, f.startOffset, f.chunk.toString()])).toEqual([
['main', 16, 5, '0123456789\n'],
['subagents/agent-a1.jsonl', 10, 0, 'AAAAAAAAAA'],
['subagents/agent-b2.jsonl', 5, 1, 'BBB'],
]);
expect(parsed.metas).toEqual({ a1: '{"agentType":"Explore"}' });
});
it('with onlyRequested lists only the requested files and sends no meta', () => {
const parsed = read([{ relPath: 'subagents/agent-a1.jsonl', offset: 2, len: 4 }], 1000, true);
if (typeof parsed !== 'object' || !parsed) throw new Error('no read');
expect(parsed.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([
['subagents/agent-a1.jsonl', 2, 'AAAA'],
]);
expect(parsed.metas).toEqual({});
});
it('treats a hostile request as data: it never reaches the shell', () => {
const marker = join(home, 'pwned-read');
const parsed = read([{ relPath: `subagents/agent-x.jsonl'; touch ${marker}; '`, offset: 0 }], 1000);
expect(existsSync(marker)).toBe(false);
if (typeof parsed !== 'object' || !parsed) throw new Error('no read');
expect(parsed.files.map((f) => f.relPath)).toEqual([
'main',
'subagents/agent-a1.jsonl',
'subagents/agent-b2.jsonl',
]);
});
it('reads exactly what the local reader reads (seeded random transcripts, requests and budgets)', async () => {
const failures: string[] = [];
for (let seed = 1; seed <= 60; seed++) {
let state = seed;
const rnd = () => {
state = (state * 1103515245 + 12345) & 0x7fffffff;
return state / 0x7fffffff;
};
const ri = (a: number, b: number) => a + Math.floor(rnd() * (b - a + 1));
const dir = join(home, '.claude', 'projects', `fuzz-${seed}`);
const path = join(dir, `conv-${seed}.jsonl`);
const sub = join(dir, `conv-${seed}`, 'subagents');
mkdirSync(sub, { recursive: true });
const text = (n: number) => Array.from({ length: n }, () => ['a', 'ä', '€', '\n', '{', 'z'][ri(0, 5)]).join('');
writeFileSync(path, text(ri(0, 300)));
const names: string[] = [];
for (let i = ri(0, 5); i > 0; i--) {
const name = `agent-${['A', 'b', '0', '_', '-'][ri(0, 4)]}${ri(0, 99)}`;
names.push(name);
writeFileSync(join(sub, `${name}.jsonl`), text(ri(0, 200)));
if (rnd() < 0.6)
writeFileSync(join(sub, `${name}.meta.json`), JSON.stringify({ description: text(ri(0, 40)) }));
}
const relPaths = ['main', ...names.map((n) => `subagents/${n}.jsonl`), 'subagents/agent-missing.jsonl'];
const requests: EditSourceRequest[] = [];
for (const relPath of relPaths) {
if (rnd() < 0.5) continue;
const r: EditSourceRequest = { relPath, offset: ri(0, 350) };
if (rnd() < 0.4) r.len = ri(0, 250);
requests.push(r);
}
const budget = ri(0, 700);
const onlyRequested = rnd() < 0.3;
const local = await readClaudeEditSources(path, requests, budget, { onlyRequested });
const { code, stdout } = runSh(buildRemoteClaudeEditSourcesCommand(path, requests, budget, { onlyRequested }));
const remote = code === 0 ? parseRemoteClaudeEditSources(stdout) : null;
const shape = (r: typeof local) =>
r && {
transcriptId: r.transcriptId,
metas: r.metas,
files: r.files.map((f) => [f.relPath, f.size, f.startOffset, f.chunk.toString('hex')]),
};
try {
expect(shape(remote)).toEqual(shape(local));
} catch {
failures.push(`seed ${seed}: ${JSON.stringify({ requests, budget, onlyRequested })}`);
}
}
expect(failures).toEqual([]);
});
});
describe('a timeout ends ssh itself', () => {
// Node signals the local `sh -c`. On dash that shell stays the parent of a plain command, so
// only `exec` makes the signal reach ssh. The fake ssh records its pid and then just waits.
it('kills the ssh process, not only the shell around it', async () => {
const bin = mkdtempSync(join(tmpdir(), 'codeman-fake-ssh-'));
const pidFile = join(bin, 'pid');
try {
writeFileSync(
join(bin, 'ssh'),
`#!/bin/sh\necho $$ > '${pidFile}.tmp'\nmv '${pidFile}.tmp' '${pidFile}'\nexec sleep 30\n`
);
chmodSync(join(bin, 'ssh'), 0o755);
const command = buildRemoteEditHistoryCommand(REMOTE, buildRemoteClaudeTranscriptLocateCommand(['conv-1']));
expect(command).toMatch(/^exec ssh /);
await new Promise<void>((resolve) => {
exec(command, { timeout: 1000, env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } }, () => resolve());
});
const pid = Number(readFileSync(pidFile, 'utf8'));
const alive = (): boolean => {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
};
try {
await expect.poll(alive, { timeout: 3000 }).toBe(false);
} finally {
if (alive()) process.kill(pid, 'SIGKILL');
}
} finally {
rmSync(bin, { recursive: true, force: true });
}
});
});
describe('under vitest', () => {
it('never opens a connection: the lookup and the read reject with a clear error', async () => {
await expect(remoteLocateClaudeTranscript(REMOTE, ['a'])).rejects.toBeInstanceOf(RemoteFileAccessError);
await expect(remoteReadClaudeEditSources(REMOTE, '/p/a.jsonl', [], 10)).rejects.toBeInstanceOf(
RemoteFileAccessError
);
});
});
describe('never wakes a host', () => {
// Invariant: only real user input or an explicit wake request may wake a host. The edit
// history reads a remote transcript whenever the panel is open, so nothing it can reach may
// hold the wake registry: a sleeping host must stay asleep and the read ends `unavailable`.
// `test/remote-wake.test.ts` pins who imports remote-wake; this pins that the edit-history
// read path reaches neither it nor the route module that owns the registry, transitively.
const SRC = fileURLToPath(new URL('../src', import.meta.url));
const ROOTS = [
'edit-history-local-source.ts',
'edit-history-parse.ts',
'edit-history-store.ts',
'remote-files.ts',
join('web', 'edit-history-sync.ts'),
join('web', 'routes', 'edit-history-routes.ts'),
];
function resolveImport(from: string, spec: string): string | null {
if (!spec.startsWith('.')) return null;
const base = join(dirname(from), spec).replace(/\.js$/, '');
for (const candidate of [`${base}.ts`, join(base, 'index.ts')]) {
try {
readFileSync(candidate);
return candidate;
} catch {
/* next candidate */
}
}
throw new Error(`unresolved import ${spec} in ${relative(SRC, from)}`);
}
it('the read path never imports remote-wake or session-routes, even transitively', () => {
const seen = new Set<string>();
const queue = ROOTS.map((r) => join(SRC, r));
while (queue.length) {
const file = queue.pop()!;
if (seen.has(file)) continue;
seen.add(file);
const text = readFileSync(file, 'utf-8');
// Static imports/re-exports start a line; a dynamic import() counts outside comment lines.
const specs = [
...text.matchAll(/^(?:import|export)\s[^;]*?\sfrom\s+['"]([^'"]+)['"]/gms),
...text.matchAll(/^(?!\s*(?:\*|\/\/)).*\bimport\(\s*['"]([^'"]+)['"]/gm),
].map((m) => m[1]);
for (const spec of specs) {
const next = resolveImport(file, spec);
if (next) queue.push(next);
}
}
const reached = [...seen].map((f) => relative(SRC, f));
expect(reached).toContain(join('web', 'edit-history-sync.ts'));
// `config/remote-wake-limits.ts` (the MAC-count constant of the host schema) may be reached; it wakes nothing.
expect(reached).not.toContain('remote-wake.ts');
expect(reached).not.toContain(join('web', 'routes', 'session-routes.ts'));
});
});