2026-10-23 16:29:01 +02:00
|
|
|
|
/**
|
|
|
|
|
|
* @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';
|
2026-11-06 02:57:58 +01:00
|
|
|
|
import { exec, execFileSync } from 'node:child_process';
|
2026-10-23 16:29:01 +02:00
|
|
|
|
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,
|
2026-11-06 02:57:58 +01:00
|
|
|
|
buildRemoteEditHistoryCommand,
|
2026-10-23 16:29:01 +02:00
|
|
|
|
buildRemoteClaudeTranscriptLocateCommand,
|
|
|
|
|
|
isRemoteClaudeTranscriptPath,
|
|
|
|
|
|
parseRemoteClaudeEditSources,
|
|
|
|
|
|
parseRemoteClaudeTranscriptLocate,
|
2026-11-06 03:13:03 +01:00
|
|
|
|
planRemoteEditSourceRounds,
|
|
|
|
|
|
readRemoteEditSourceRounds,
|
2026-10-23 16:52:09 +02:00
|
|
|
|
remoteEditSourcesTimeoutMs,
|
2026-10-23 16:29:01 +02:00
|
|
|
|
remoteLocateClaudeTranscript,
|
|
|
|
|
|
remoteReadClaudeEditSources,
|
|
|
|
|
|
} from '../src/remote-files.js';
|
|
|
|
|
|
import { readClaudeEditSources, type EditSourceRequest } from '../src/edit-history-local-source.js';
|
2026-11-06 03:13:03 +01:00
|
|
|
|
import { EditHistoryStore } from '../src/edit-history-store.js';
|
|
|
|
|
|
import { EditHistorySync, type EditHistorySession } from '../src/web/edit-history-sync.js';
|
2026-10-23 16:29:01 +02:00
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-11-06 03:13:03 +01:00
|
|
|
|
/** Runs a read command the way a {@link readRemoteEditSourceRounds} runner does, here on `sh`. */
|
|
|
|
|
|
async function runRead(command: string): Promise<Buffer | null> {
|
|
|
|
|
|
const { code, stdout } = runSh(command);
|
|
|
|
|
|
return code === 0 ? stdout : null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-23 16:29:01 +02:00
|
|
|
|
/** 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);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-23 16:59:14 +02:00
|
|
|
|
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');
|
2026-10-23 17:06:45 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-10-23 16:29:01 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-23 16:52:09 +02:00
|
|
|
|
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')
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-23 16:29:01 +02:00
|
|
|
|
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
|
2026-11-06 02:57:58 +01:00
|
|
|
|
// ~/.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.
|
2026-10-23 16:29:01 +02:00
|
|
|
|
let bin: string;
|
|
|
|
|
|
const pane = { socket: 'codeman-remote', sessionName: 'codeman-ssh-conv1abc' };
|
|
|
|
|
|
beforeAll(() => {
|
|
|
|
|
|
bin = mkdtempSync(join(tmpdir(), 'codeman-fake-tmux-'));
|
2026-11-06 02:57:58 +01:00
|
|
|
|
const localBin = join(home, '.local', 'bin');
|
|
|
|
|
|
mkdirSync(localBin, { recursive: true });
|
2026-11-06 03:49:59 +01:00
|
|
|
|
// Only the exact `=name:` target reaches the pane. Real tmux (3.4, measured) reads `=name`
|
|
|
|
|
|
// without the colon as a window target too, so it also answers for a window of that name
|
|
|
|
|
|
// or a session the name is a prefix of: the second pattern is that second case.
|
2026-10-23 16:29:01 +02:00
|
|
|
|
writeFileSync(
|
2026-11-06 02:57:58 +01:00
|
|
|
|
join(localBin, 'tmux'),
|
2026-11-06 03:49:59 +01:00
|
|
|
|
[
|
|
|
|
|
|
'#!/bin/sh',
|
|
|
|
|
|
'for a; do',
|
|
|
|
|
|
' case "$a" in =codeman-ssh-conv1abc:|=codeman-ssh-conv1) echo 100; exit 0 ;; esac',
|
|
|
|
|
|
'done',
|
|
|
|
|
|
'echo "no such session" >&2',
|
|
|
|
|
|
'exit 1',
|
|
|
|
|
|
'',
|
|
|
|
|
|
].join('\n')
|
2026-10-23 16:29:01 +02:00
|
|
|
|
);
|
|
|
|
|
|
writeFileSync(join(bin, 'ps'), '#!/bin/sh\nprintf " 1 0\\n100 1\\n200 100\\n300 200\\n"\n');
|
2026-11-06 02:57:58 +01:00
|
|
|
|
chmodSync(join(localBin, 'tmux'), 0o755);
|
2026-10-23 16:29:01 +02:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-10-23 17:06:45 +02:00
|
|
|
|
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.
|
2026-10-23 16:29:01 +02:00
|
|
|
|
session('{"pid":300,"sessionId":"solo","cwd":"/w","kind":"interactive"}');
|
|
|
|
|
|
expect(locateIn(pane)).toBe(join(projects(), 'old', 'solo.jsonl'));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-11-06 02:57:58 +01:00
|
|
|
|
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'));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-23 16:29:01 +02:00
|
|
|
|
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'));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-11-06 03:49:59 +01:00
|
|
|
|
it('never takes the pane of another tmux session whose name starts with this one', () => {
|
|
|
|
|
|
// codeman-ssh-conv1 is gone, codeman-ssh-conv1abc is alive: its conversation is not ours.
|
|
|
|
|
|
session('{"pid":300,"sessionId":"solo"}');
|
|
|
|
|
|
expect(locateIn({ ...pane, sessionName: 'codeman-ssh-conv1' })).toBe(join(projects(), 'new', 'conv-1.jsonl'));
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-23 16:52:09 +02:00
|
|
|
|
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')
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-23 16:29:01 +02:00
|
|
|
|
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');
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-11-06 03:13:03 +01:00
|
|
|
|
describe('planRemoteEditSourceRounds', () => {
|
|
|
|
|
|
const agents = (...ids: string[]): EditSourceRequest[] =>
|
|
|
|
|
|
ids.map((id) => ({ relPath: `subagents/agent-${id}.jsonl`, offset: 1 }));
|
|
|
|
|
|
|
|
|
|
|
|
it('keeps a read that fits one script in one round trip, with no name range', () => {
|
|
|
|
|
|
const requests = [{ relPath: 'main', offset: 5 }, ...agents('b', 'a')];
|
|
|
|
|
|
expect(planRemoteEditSourceRounds(requests, 2)).toEqual([{ requests, main: true, after: '', upTo: '' }]);
|
|
|
|
|
|
expect(planRemoteEditSourceRounds([], 2)).toEqual([{ requests: [], main: true, after: '', upTo: '' }]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('cuts more requests by name: ranges that join up, main in the first, every request once', () => {
|
|
|
|
|
|
const main = { relPath: 'main', offset: 5 };
|
|
|
|
|
|
const rounds = planRemoteEditSourceRounds([...agents('e', 'c'), main, ...agents('a', 'd', 'b')], 2);
|
|
|
|
|
|
expect(rounds.map((r) => [r.main, r.after, r.upTo, r.requests.map((q) => q.relPath)])).toEqual([
|
|
|
|
|
|
[true, '', 'agent-b.jsonl', ['main', 'subagents/agent-a.jsonl', 'subagents/agent-b.jsonl']],
|
|
|
|
|
|
[false, 'agent-b.jsonl', 'agent-d.jsonl', ['subagents/agent-c.jsonl', 'subagents/agent-d.jsonl']],
|
|
|
|
|
|
[false, 'agent-d.jsonl', '', ['subagents/agent-e.jsonl']],
|
|
|
|
|
|
]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('counts only valid requests, the first per relPath', () => {
|
|
|
|
|
|
const rounds = planRemoteEditSourceRounds(
|
|
|
|
|
|
[
|
|
|
|
|
|
...agents('a'),
|
|
|
|
|
|
{ relPath: 'subagents/agent-a.jsonl', offset: 9 },
|
|
|
|
|
|
{ relPath: '../../etc/passwd', offset: 0 },
|
|
|
|
|
|
{ relPath: 'subagents/agent-neg.jsonl', offset: -1 },
|
|
|
|
|
|
],
|
|
|
|
|
|
1
|
|
|
|
|
|
);
|
|
|
|
|
|
expect(rounds).toEqual([{ requests: agents('a'), main: true, after: '', upTo: '' }]);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('keeps every ssh line under the 128 KiB argv limit, longest names and offsets included', () => {
|
|
|
|
|
|
// 255 bytes is the longest file name a filesystem allows; a request without `len` is the
|
2026-11-06 07:40:07 +01:00
|
|
|
|
// longer one, because its `''` costs 16 bytes per quote once escaped and encoded.
|
2026-11-06 03:13:03 +01:00
|
|
|
|
const requests: EditSourceRequest[] = Array.from({ length: 5000 }, (_, i) => ({
|
|
|
|
|
|
relPath: `subagents/agent-${String(i).padStart(6, '0')}${'x'.repeat(237)}.jsonl`,
|
|
|
|
|
|
offset: Number.MAX_SAFE_INTEGER,
|
|
|
|
|
|
}));
|
|
|
|
|
|
const mainPath = `/${'p/'.repeat(2000)}conv-1.jsonl`;
|
|
|
|
|
|
const line = (reqs: EditSourceRequest[], round?: { main: boolean; after: string; upTo: string }) =>
|
|
|
|
|
|
Buffer.byteLength(
|
|
|
|
|
|
buildRemoteEditHistoryCommand(REMOTE, buildRemoteClaudeEditSourcesCommand(mainPath, reqs, 1000, { round }))
|
|
|
|
|
|
);
|
|
|
|
|
|
const ARGV_LIMIT = 128 * 1024;
|
|
|
|
|
|
expect(line(requests)).toBeGreaterThan(ARGV_LIMIT);
|
|
|
|
|
|
const rounds = planRemoteEditSourceRounds(requests);
|
|
|
|
|
|
expect(rounds.length).toBeGreaterThan(1);
|
|
|
|
|
|
for (const round of rounds) expect(line(round.requests, round)).toBeLessThan(ARGV_LIMIT);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-23 16:29:01 +02:00
|
|
|
|
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();
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-23 16:52:09 +02:00
|
|
|
|
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);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-23 16:29:01 +02:00
|
|
|
|
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;
|
2026-11-06 03:13:03 +01:00
|
|
|
|
// The same read cut into several round trips must come out the same.
|
|
|
|
|
|
const split = await readRemoteEditSourceRounds(path, requests, budget, { onlyRequested }, runRead, ri(1, 3));
|
2026-10-23 16:29:01 +02:00
|
|
|
|
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));
|
2026-11-06 03:13:03 +01:00
|
|
|
|
expect(shape(split)).toEqual(shape(local));
|
2026-10-23 16:29:01 +02:00
|
|
|
|
} catch {
|
|
|
|
|
|
failures.push(`seed ${seed}: ${JSON.stringify({ requests, budget, onlyRequested })}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
expect(failures).toEqual([]);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-11-06 03:13:03 +01:00
|
|
|
|
describe('a read too large for one ssh line', () => {
|
|
|
|
|
|
// `ssh` is a fake that runs the remote command on `sh`, so the whole ssh line really goes
|
|
|
|
|
|
// through the local `sh -c` argv: what must not hit E2BIG.
|
|
|
|
|
|
let bin: string;
|
|
|
|
|
|
beforeAll(() => {
|
|
|
|
|
|
bin = mkdtempSync(join(tmpdir(), 'codeman-fake-ssh-read-'));
|
|
|
|
|
|
writeFileSync(join(bin, 'ssh'), '#!/bin/sh\nfor arg; do :; done\nexec sh -c "$arg"\n');
|
|
|
|
|
|
chmodSync(join(bin, 'ssh'), 0o755);
|
|
|
|
|
|
});
|
|
|
|
|
|
afterAll(() => rmSync(bin, { recursive: true, force: true }));
|
|
|
|
|
|
|
|
|
|
|
|
/** A runner that goes through the fake ssh, counting its round trips. */
|
|
|
|
|
|
function overSsh(): { run: (command: string, maxBytes: number) => Promise<Buffer | null>; trips: () => number } {
|
|
|
|
|
|
let trips = 0;
|
|
|
|
|
|
return {
|
|
|
|
|
|
trips: () => trips,
|
|
|
|
|
|
run: async (command, maxBytes) => {
|
|
|
|
|
|
trips++;
|
|
|
|
|
|
try {
|
|
|
|
|
|
return execFileSync('sh', ['-c', buildRemoteEditHistoryCommand(REMOTE, command)], {
|
|
|
|
|
|
env: { ...process.env, HOME: home, PATH: `${bin}:${process.env.PATH}` },
|
|
|
|
|
|
cwd: home,
|
|
|
|
|
|
maxBuffer: maxBytes,
|
|
|
|
|
|
});
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
if ((err as { status?: number }).status === 3) return null;
|
|
|
|
|
|
throw err;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const shape = (r: Awaited<ReturnType<typeof readClaudeEditSources>>) =>
|
|
|
|
|
|
r && {
|
|
|
|
|
|
transcriptId: r.transcriptId,
|
|
|
|
|
|
metas: r.metas,
|
|
|
|
|
|
files: r.files.map((f) => [f.relPath, f.size, f.startOffset, f.chunk.toString('hex')]),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
describe('as a read', () => {
|
|
|
|
|
|
const project = () => join(home, '.claude', 'projects', 'many');
|
|
|
|
|
|
const main = () => join(project(), 'conv-many.jsonl');
|
|
|
|
|
|
const sub = () => join(project(), 'conv-many', 'subagents');
|
|
|
|
|
|
const id = (i: number) => `a${String(i).padStart(4, '0')}`;
|
|
|
|
|
|
/** A cursor for each of 1500 subagents; only every 100th still has a file. */
|
|
|
|
|
|
const requests: EditSourceRequest[] = [
|
|
|
|
|
|
{ relPath: 'main', offset: 2 },
|
|
|
|
|
|
...Array.from({ length: 1500 }, (_, i) => ({ relPath: `subagents/agent-${id(i)}.jsonl`, offset: 1 })),
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
beforeAll(() => {
|
|
|
|
|
|
mkdirSync(sub(), { recursive: true });
|
|
|
|
|
|
writeFileSync(main(), 'main transcript\n');
|
|
|
|
|
|
for (let i = 0; i < 1500; i += 100) writeFileSync(join(sub(), `agent-${id(i)}.jsonl`), `subagent ${i}\n`);
|
|
|
|
|
|
// New files in the first, a middle and the last name range: each read once, with its meta.
|
|
|
|
|
|
for (const name of ['agent-0first', 'agent-a0750new', 'agent-zlast']) {
|
|
|
|
|
|
writeFileSync(join(sub(), `${name}.jsonl`), `${name}\n`);
|
|
|
|
|
|
writeFileSync(join(sub(), `${name}.meta.json`), `{"description":"${name}"}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it.each([0, 40, 8 * 1024 * 1024])('reads what the local reader reads, budget %i', async (budget) => {
|
|
|
|
|
|
const ssh = overSsh();
|
|
|
|
|
|
const read = await readRemoteEditSourceRounds(main(), requests, budget, {}, ssh.run);
|
|
|
|
|
|
expect(shape(read)).toEqual(shape(await readClaudeEditSources(main(), requests, budget)));
|
|
|
|
|
|
expect(ssh.trips()).toBe(5);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('is null when the transcript goes away during the read', async () => {
|
|
|
|
|
|
let trips = 0;
|
|
|
|
|
|
const read = readRemoteEditSourceRounds(main(), requests, 100, {}, async (command) => {
|
|
|
|
|
|
return ++trips === 2 ? null : runRead(command);
|
|
|
|
|
|
});
|
|
|
|
|
|
await expect(read).resolves.toBeNull();
|
|
|
|
|
|
expect(trips).toBe(2);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('fails as a whole on broken output from a later round trip', async () => {
|
|
|
|
|
|
let trips = 0;
|
|
|
|
|
|
const read = readRemoteEditSourceRounds(main(), requests, 100, {}, async (command) => {
|
|
|
|
|
|
return ++trips === 2 ? Buffer.from('\0other-conversation\0') : runRead(command);
|
|
|
|
|
|
});
|
|
|
|
|
|
await expect(read).rejects.toBeInstanceOf(RemoteFileAccessError);
|
|
|
|
|
|
expect(trips).toBe(2);
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
it('gives a later round trip the meta budget the earlier ones left', async () => {
|
|
|
|
|
|
const commands: string[] = [];
|
|
|
|
|
|
await readRemoteEditSourceRounds(
|
|
|
|
|
|
main(),
|
|
|
|
|
|
requests,
|
|
|
|
|
|
1000,
|
|
|
|
|
|
{},
|
|
|
|
|
|
async (command) => {
|
|
|
|
|
|
commands.push(command);
|
|
|
|
|
|
return runRead(command);
|
|
|
|
|
|
},
|
|
|
|
|
|
900
|
|
|
|
|
|
);
|
|
|
|
|
|
const budgets = commands.map((c) => Number(/mbudget=(\d+)/.exec(c)?.[1]));
|
|
|
|
|
|
expect(budgets).toHaveLength(2);
|
|
|
|
|
|
// `agent-0first` sorts before every request, so its meta went with the first round trip.
|
|
|
|
|
|
expect(budgets[0] - budgets[1]).toBe(
|
|
|
|
|
|
Buffer.byteLength('{"description":"agent-0first"}') + Buffer.byteLength('{"description":"agent-a0750new"}')
|
|
|
|
|
|
);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
describe('through the sync', () => {
|
|
|
|
|
|
const project = () => join(home, '.claude', 'projects', 'long-history');
|
|
|
|
|
|
const sub = () => join(project(), 'conv-old', 'subagents');
|
|
|
|
|
|
const edit = (id: string, path: string) =>
|
|
|
|
|
|
JSON.stringify({
|
|
|
|
|
|
type: 'user',
|
|
|
|
|
|
timestamp: '2026-10-15T20:00:00.000Z',
|
|
|
|
|
|
cwd: '/w',
|
|
|
|
|
|
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
|
|
|
|
|
|
toolUseResult: {
|
|
|
|
|
|
filePath: path,
|
|
|
|
|
|
oldString: 'a',
|
|
|
|
|
|
newString: 'b',
|
|
|
|
|
|
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
|
|
|
|
|
|
},
|
|
|
|
|
|
}) + '\n';
|
|
|
|
|
|
|
|
|
|
|
|
it('keeps a conversation with 1200 subagents available, and a /clear gets past them', async () => {
|
|
|
|
|
|
const dir = mkdtempSync(join(tmpdir(), 'codeman-edit-store-'));
|
|
|
|
|
|
try {
|
|
|
|
|
|
mkdirSync(sub(), { recursive: true });
|
|
|
|
|
|
const oldMain = join(project(), 'conv-old.jsonl');
|
|
|
|
|
|
const newMain = join(project(), 'conv-new.jsonl');
|
|
|
|
|
|
writeFileSync(oldMain, '{}\n');
|
|
|
|
|
|
// Ids as long as claude's own: with these, 1200 requests do not fit one ssh line.
|
|
|
|
|
|
const name = (i: number) => `agent-a${String(i).padStart(16, '0')}`;
|
|
|
|
|
|
for (let i = 0; i < 1200; i++) writeFileSync(join(sub(), `${name(i)}.jsonl`), '{}\n');
|
|
|
|
|
|
|
|
|
|
|
|
const ssh = overSsh();
|
|
|
|
|
|
let path = oldMain;
|
|
|
|
|
|
const sync = new EditHistorySync({
|
|
|
|
|
|
transcriptPath: async () => path,
|
|
|
|
|
|
readSources: (_session, mainPath, requests, budget, opts) =>
|
|
|
|
|
|
readRemoteEditSourceRounds(mainPath, requests, budget, opts ?? {}, ssh.run),
|
|
|
|
|
|
probe: async () => [],
|
|
|
|
|
|
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
|
|
|
|
|
|
deleteStore: async () => {},
|
|
|
|
|
|
sweepStores: async () => 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
const session: EditHistorySession = {
|
|
|
|
|
|
id: 's1',
|
|
|
|
|
|
mode: 'claude',
|
|
|
|
|
|
remote: REMOTE,
|
|
|
|
|
|
claudeSessionId: 'conv-old',
|
|
|
|
|
|
};
|
|
|
|
|
|
// The cursors of a history that was read before, one per file at its end.
|
|
|
|
|
|
const store = await sync.getStore('s1');
|
|
|
|
|
|
const seed = (source: string) =>
|
|
|
|
|
|
store.apply({ transcriptId: 'conv-old', source, startOffset: 0, chunk: Buffer.from('{}\n'), size: 3 });
|
|
|
|
|
|
seed('main');
|
|
|
|
|
|
for (let i = 0; i < 1200; i++) seed(`subagents/${name(i)}.jsonl`);
|
|
|
|
|
|
expect(store.knownSources()).toHaveLength(1201);
|
|
|
|
|
|
|
|
|
|
|
|
// Past the argv limit: one script would not even start.
|
|
|
|
|
|
const all = store.knownSources().map((relPath) => ({ relPath, offset: 3 }));
|
|
|
|
|
|
const line = buildRemoteEditHistoryCommand(REMOTE, buildRemoteClaudeEditSourcesCommand(oldMain, all, 1000));
|
|
|
|
|
|
expect(() => execFileSync('sh', ['-c', line])).toThrow(/E2BIG/);
|
|
|
|
|
|
|
|
|
|
|
|
// A known file that grew in the last name range, a new one in the middle.
|
|
|
|
|
|
writeFileSync(join(sub(), `${name(1199)}.jsonl`), `{}\n${edit('late', '/w/late.ts')}`);
|
|
|
|
|
|
const fresh = `${name(600)}new`;
|
|
|
|
|
|
writeFileSync(join(sub(), `${fresh}.jsonl`), edit('fresh', '/w/fresh.ts'));
|
|
|
|
|
|
writeFileSync(join(sub(), `${fresh}.meta.json`), '{"description":"new one","agentType":"Explore"}');
|
|
|
|
|
|
expect(await sync.sync(session)).toEqual({ state: 'ok' });
|
|
|
|
|
|
expect(ssh.trips()).toBe(4);
|
|
|
|
|
|
expect(
|
|
|
|
|
|
store
|
|
|
|
|
|
.list()
|
|
|
|
|
|
.map((e) => e.path)
|
|
|
|
|
|
.sort()
|
|
|
|
|
|
).toEqual(['/w/fresh.ts', '/w/late.ts']);
|
|
|
|
|
|
expect(store.getAgents()[fresh.slice('agent-'.length)]).toMatchObject({ description: 'new one' });
|
|
|
|
|
|
expect(store.knownSources()).toHaveLength(1202); // no cursor lost
|
|
|
|
|
|
|
|
|
|
|
|
// /clear: the stored conversation is read to its end first, then the new one.
|
|
|
|
|
|
writeFileSync(newMain, edit('after', '/w/after.ts'));
|
|
|
|
|
|
path = newMain;
|
|
|
|
|
|
expect(await sync.sync(session)).toEqual({ state: 'ok' });
|
|
|
|
|
|
expect(store.getTranscriptId()).toBe('conv-new');
|
|
|
|
|
|
expect(
|
|
|
|
|
|
store
|
|
|
|
|
|
.list()
|
|
|
|
|
|
.map((e) => e.path)
|
|
|
|
|
|
.sort()
|
|
|
|
|
|
).toEqual(['/w/after.ts', '/w/fresh.ts', '/w/late.ts']);
|
|
|
|
|
|
await store.remove();
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
rmSync(dir, { recursive: true, force: true });
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 60_000);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-11-06 02:57:58 +01:00
|
|
|
|
describe('a timeout ends ssh itself', () => {
|
2026-11-06 03:17:14 +01:00
|
|
|
|
// Node's timeout 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
|
|
|
|
|
|
// waits; the test sends the timeout's SIGTERM itself, once that pid is there, so a slow
|
|
|
|
|
|
// machine cannot kill the fake before it wrote it.
|
2026-11-06 02:57:58 +01:00
|
|
|
|
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 /);
|
2026-11-06 03:17:14 +01:00
|
|
|
|
const child = exec(command, { env: { ...process.env, PATH: `${bin}:${process.env.PATH}` } });
|
|
|
|
|
|
await expect.poll(() => existsSync(pidFile), { timeout: 10_000 }).toBe(true);
|
2026-11-06 02:57:58 +01:00
|
|
|
|
const pid = Number(readFileSync(pidFile, 'utf8'));
|
2026-11-06 03:17:14 +01:00
|
|
|
|
child.kill();
|
2026-11-06 02:57:58 +01:00
|
|
|
|
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 });
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2026-10-23 16:29:01 +02:00
|
|
|
|
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'));
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|