Merge remote-tracking branch 'origin/master' into relprep/rtl

This commit is contained in:
Codeman maintainer committed 2026-11-06 04:01:39 +01:00
commit f37f101137
282 files changed
+39362 -1411

No files matched your search

+253
View File
@@ -0,0 +1,253 @@
/** @fileoverview Strict bounded native rollout attribution without viewer heuristics. */
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdtemp, mkdir, writeFile, rm, symlink } from 'node:fs/promises';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import {
__clearCodexAttributionMemo,
findExactCodexConversation,
resolveEffectiveCodexHome,
} from '../src/agent-codex-identity.js';
const roots: string[] = [];
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
});
const A = '11111111-1111-4111-8111-111111111111';
const B = '22222222-2222-4222-8222-222222222222';
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'agent-attribution-'));
roots.push(root);
const home = join(root, 'home');
await mkdir(join(home, 'sessions'), { recursive: true });
const options = { codexHome: home, sessionId: 'managed', workingDir: root };
const put = async (id: string, payload: object = {}, text?: string) => {
const path = join(home, 'sessions', `rollout-${id}.jsonl`);
await writeFile(
path,
text ??
JSON.stringify({
type: 'session_meta',
payload: { id, cwd: root, originator: 'codeman_managed', ...payload },
}) + '\n'
);
return path;
};
return { root, home, options, put };
}
it('requires one complete native session_meta matching the rollout ID, managed originator and canonical workspace', async () => {
const f = await fixture();
const path = await f.put(A);
expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path });
await f.put(A, { id: B });
expect(await findExactCodexConversation(f.options)).toBeUndefined();
});
it('never chooses newest cwd files or an ambiguous managed originator', async () => {
const f = await fixture();
await f.put(A, { originator: 'codex_cli' });
expect(await findExactCodexConversation(f.options)).toBeUndefined();
await f.put(A);
await f.put(B);
expect(await findExactCodexConversation(f.options)).toBeUndefined();
});
it('allows an explicit resume identity but still requires native metadata and the workspace', async () => {
const f = await fixture();
const path = await f.put(A, { originator: 'original-launch' });
expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toEqual({ id: A, path });
await f.put(B);
expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toBeUndefined();
await f.put(A, { cwd: f.home });
expect(await findExactCodexConversation({ ...f.options, resumeSessionId: A })).toBeUndefined();
});
it.each(['partial', 'duplicate', 'oversized'] as const)('refuses %s metadata', async (kind) => {
const f = await fixture();
const row = JSON.stringify({ type: 'session_meta', payload: { id: A, cwd: f.root, originator: 'codeman_managed' } });
await f.put(
A,
{},
kind === 'partial'
? row
: kind === 'duplicate'
? row + '\n' + row + '\n'
: row.slice(0, -2) + ',"instructions":"' + 'x'.repeat(140000) + '"}}\n'
);
expect(await findExactCodexConversation(f.options)).toBeUndefined();
});
it('enforces traversal/read caps and ignores symlinked directories', async () => {
const f = await fixture();
await f.put(A);
expect(await findExactCodexConversation(f.options, { maxEntries: 0, maxBytes: 1048576 })).toBeUndefined();
expect(await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 128 })).toBeUndefined();
await mkdir(join(f.root, 'elsewhere'));
await symlink(join(f.root, 'elsewhere'), join(f.home, 'sessions', 'alias'));
expect((await findExactCodexConversation(f.options))?.id).toBe(A);
});
it('resolves per-session, process and default CODEX_HOME relative to the session workspace', () => {
expect(resolveEffectiveCodexHome('custom', '/tmp/work', { CODEX_HOME: '/other' })).toBe('/tmp/work/custom');
expect(resolveEffectiveCodexHome(undefined, '/tmp/work', { CODEX_HOME: 'process' })).toBe('/tmp/work/process');
expect(resolveEffectiveCodexHome(undefined, '/tmp/work', { HOME: '/tmp/user' })).toBe('/tmp/user/.codex');
});
it.each([
['trailing-space', 'codex-home ', 'codex-home'],
['whitespace-only', ' ', '.codex'],
])(
'keeps the raw %s CODEX_HOME namespace through the Session getter and attribution helper',
async (_label, configured, wrongHome) => {
const { Session } = await import('../src/session.js');
const f = await fixture();
const expectedHome = join(f.root, configured);
const siblingHome = join(f.root, wrongHome);
const record =
JSON.stringify({ type: 'session_meta', payload: { id: A, cwd: f.root, originator: 'codeman_managed' } }) + '\n';
for (const home of [expectedHome, siblingHome]) {
await mkdir(join(home, 'sessions'), { recursive: true });
await writeFile(join(home, 'sessions', `rollout-${A}.jsonl`), record);
}
const session = new Session({
id: 'managed',
workingDir: f.root,
mode: 'codex',
envOverrides: { CODEX_HOME: configured },
});
try {
const codexHome = resolveEffectiveCodexHome(session.getCodexHomeOverride(), session.workingDir, { HOME: f.root });
expect(await findExactCodexConversation({ ...f.options, codexHome })).toEqual({
id: A,
path: join(expectedHome, 'sessions', `rollout-${A}.jsonl`),
});
expect(session.getCodexHomeOverride()).toBe(configured);
} finally {
await session.stop(false);
}
}
);
it('attributes an owned rollout among 320 unrelated full-size metadata prefixes', async () => {
const f = await fixture();
const path = await f.put(A);
await Promise.all(
Array.from({ length: 320 }, (_, n) => {
const id = `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`;
return f.put(id, { originator: 'codex_cli', instructions: 'x'.repeat(5000) });
})
);
expect(await findExactCodexConversation(f.options)).toEqual({ id: A, path });
expect(
await findExactCodexConversation(f.options, { maxEntries: 8192, maxBytes: 1048576, maxPrefixBytes: 4096 })
).toBeUndefined();
});
it.each(['workspace', 'metadata'] as const)(
'probes %s bounded past the cap before canonical filesystem reads',
async (where) => {
const { vi } = await import('vitest');
const probe = await import('../src/utils/bounded-path-probe.js');
const f = await fixture();
const alias = join(f.root, 'workspace-alias');
await symlink(f.root, alias);
await f.put(A, { cwd: where === 'metadata' ? alias : f.root });
const blocked = where === 'workspace' ? f.root : alias;
const spy = vi
.spyOn(probe, 'probePathKind')
.mockImplementation(async (path) => (path === blocked ? 'unknown' : 'directory'));
try {
expect(await findExactCodexConversation(f.options)).toBeUndefined();
expect(spy).toHaveBeenCalledWith(blocked, { pastCap: true });
} finally {
spy.mockRestore();
}
}
);
describe('remembered attribution on a large, never-pruned store', () => {
beforeEach(() => __clearCodexAttributionMemo());
const OWNED = '33333333-3333-4333-8333-333333333333';
const NEWER = '44444444-4444-4444-8444-444444444444';
/** codex's own layout: sessions/YYYY/MM/DD/rollout-<ts>-<uuid>.jsonl */
async function dated(f: Awaited<ReturnType<typeof fixture>>, day: string, id: string, payload: object = {}) {
const dir = join(f.home, 'sessions', ...day.split('/'));
await mkdir(dir, { recursive: true });
const path = join(dir, `rollout-${day.replaceAll('/', '-')}T00-00-00-${id}.jsonl`);
await writeFile(
path,
JSON.stringify({
type: 'session_meta',
payload: { id, cwd: f.root, originator: 'codeman_managed', ...payload },
}) + '\n'
);
return path;
}
/** `count` unrelated 19 KiB rollouts (a real session_meta size) spread over older days. */
async function unrelated(f: Awaited<ReturnType<typeof fixture>>, count: number) {
const instructions = 'x'.repeat(19 * 1024);
for (let start = 0; start < count; start += 200) {
await Promise.all(
Array.from({ length: Math.min(200, count - start) }, (_, k) => {
const n = start + k;
const day = `2026/${String(1 + (n % 9)).padStart(2, '0')}/${String(1 + (n % 28)).padStart(2, '0')}`;
return dated(f, day, `10000000-1111-4111-8111-${String(n).padStart(12, '0')}`, {
originator: 'codex_cli',
instructions,
});
})
);
}
}
const small = { maxEntries: 20, maxBytes: 1048576 };
it('attributes one owned rollout beside 2,100 unrelated full-size rollouts', async () => {
const f = await fixture();
await unrelated(f, 2100);
const path = await dated(f, '2026/10/24', OWNED);
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
}, 60_000);
it('re-verifies only the remembered file on a later call instead of re-walking the store', async () => {
const f = await fixture();
await unrelated(f, 300);
const path = await dated(f, '2026/10/24', OWNED);
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
// 20 entries cannot cover 300 rollouts in 9 months of day directories: only the
// remembered head plus the directories not older than it fit.
expect(await findExactCodexConversation(f.options, small)).toEqual({ id: OWNED, path });
__clearCodexAttributionMemo();
expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
});
it('still catches a newer owned rollout (codex /new) after remembering one', async () => {
const f = await fixture();
await unrelated(f, 50);
const path = await dated(f, '2026/10/24', OWNED);
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
await dated(f, '2026/10/25', NEWER);
expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
expect(await findExactCodexConversation(f.options)).toBeUndefined();
});
it('answers unattributed, without a full walk, when the remembered file verifies but its incremental scan runs out', async () => {
const f = await fixture();
const path = await dated(f, '2026/10/24', OWNED);
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
// 30 rollouts in the remembered day itself: the incremental scan reads them all,
// so 20 entries cannot cover it, and a full walk (a superset) could not either.
await Promise.all(
Array.from({ length: 30 }, (_, n) =>
dated(f, '2026/10/24', `20000000-2222-4222-8222-${String(n).padStart(12, '0')}`, { originator: 'codex_cli' })
)
);
expect(await findExactCodexConversation(f.options, small)).toBeUndefined();
// The memo was dropped, so a call with room walks the store and attributes again.
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
});
it('drops the remembered file when it is replaced, and walks again', async () => {
const f = await fixture();
const path = await dated(f, '2026/10/24', OWNED);
expect(await findExactCodexConversation(f.options)).toEqual({ id: OWNED, path });
await rm(path);
await dated(f, '2026/10/24', OWNED, { originator: 'codex_cli' });
expect(await findExactCodexConversation(f.options)).toBeUndefined();
const moved = await dated(f, '2026/10/23', NEWER);
expect(await findExactCodexConversation(f.options)).toEqual({ id: NEWER, path: moved });
});
});
+90
View File
@@ -0,0 +1,90 @@
/**
* @fileoverview The synced `agentCoordinationEnabled` setting (default OFF) that gates the
* experimental inspect/stage/submit routes: the strict settings schema admits it, App
* Settings loads and saves it under the same control id, and its row reads in Chinese
* (zh-CN) with no English left beyond the command and product names. The route gate itself
* is pinned in test/routes/agent-session-routes.test.ts.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const SETTINGS_UI = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8');
const ID = 'appSettingsAgentCoordination';
type Api = { configure(o: Record<string, string>): void; start(): void };
/** What may stay Latin: the product, CLI, key and protocol names and the three commands. */
const leftover = (text: string) =>
text.replace(/\b(Codex|Enter|HTTP|inspect|stage|submit)\b/g, '').match(/[A-Za-z]+/g) ?? [];
function rowStrings(doc: Document): { label: string; description: string } {
const row = doc.getElementById(ID)!.closest('.set-row')!;
return {
label: row.querySelector('.set-row-label')!.textContent!.trim(),
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
};
}
const source = new JSDOM(INDEX);
const english = rowStrings(source.window.document);
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
const chinese = rowStrings(dom.window.document);
afterAll(() => {
source.window.close();
dom.window.close();
});
describe('agentCoordinationEnabled', () => {
it('is a boolean the strict settings schema accepts, and nothing else', () => {
expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: true }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: false }).success).toBe(true);
expect(SettingsUpdateSchema.safeParse({ agentCoordinationEnabled: 'yes' }).success).toBe(false);
});
it('has one checkbox in App Settings, loaded only from an explicit true and saved by id', () => {
expect(INDEX.split(`id="${ID}"`)).toHaveLength(2);
expect(source.window.document.getElementById(ID)!.getAttribute('type')).toBe('checkbox');
expect(SETTINGS_UI).toContain(
`document.getElementById('${ID}').checked = settings.agentCoordinationEnabled === true;`
);
expect(SETTINGS_UI).toContain(`agentCoordinationEnabled: document.getElementById('${ID}').checked,`);
});
it('sits in the synced Cross-session features group', () => {
const group = source.window.document.getElementById(ID)!.closest('.set-group')!;
expect(group.querySelector('.set-group-head h4')!.textContent).toBe('Cross-session features');
expect(group.querySelector('.set-group-head .set-scope')!.textContent).toBe('synced');
});
it('finds the row (the translation check is not vacuous)', () => {
expect(english.label).toBe('Experimental agent coordination');
expect(english.description).toMatch(/^Lets agents read another session's output/);
});
it('translates the label and the description, with no English left', () => {
for (const key of ['label', 'description'] as const) {
expect(chinese[key]).not.toBe(english[key]);
expect(leftover(chinese[key])).toEqual([]);
}
});
it('adds each key to the dictionary once', () => {
for (const text of [english.label, english.description]) {
expect(I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2).toBe(1);
}
});
});
+3
View File
@@ -29,6 +29,7 @@ function bareServer(live: string[] = [], persisted: string[] = []): CleanupServe
Object.assign(server, {
sessions: new Map(live.map((id) => [id, {}])),
keepWarmService: { remove: () => {} },
gitFetchScheduler: { remove: () => {} },
store: {
getSession: () => null,
getSessions: () => Object.fromEntries(persisted.map((id) => [id, {}])),
@@ -48,6 +49,8 @@ function bareServer(live: string[] = [], persisted: string[] = []): CleanupServe
stopTranscriptWatcher: () => {},
persistDeb: { cancelKey: () => {} },
sse: { cleanupSessionBatches: () => {} },
// A real delete also tells the Discord bridge, which frees its per-tab state and may archive the thread.
discordBridge: { sessionDeleted: async () => {}, forgetTab: () => {} },
broadcast: () => {},
});
return server;
+202
View File
@@ -0,0 +1,202 @@
/** @fileoverview Bounded JSONL inspection: cursors, byte budgets and file generations. */
import { afterEach, beforeEach, expect, it } from 'vitest';
import { mkdtemp, writeFile, appendFile, rename, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { INSPECT_CURSORS_PER_SCOPE, InspectionReader } from '../src/agent-inspection.js';
let dir: string;
let file: string;
let reader: InspectionReader;
const scope = { owner: 'alice', sessionId: 's', generation: 'pane-1', conversationId: 'thread-1' };
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'inspect-'));
file = join(dir, 'rollout.jsonl');
reader = new InspectionReader();
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
it('returns complete UTF8 records within the byte budget and resumes exactly', async () => {
const row = JSON.stringify({ text: 'é😀' }) + '\n';
await writeFile(file, row + row);
const first = await reader.read(file, scope, Buffer.byteLength(row));
expect(first.evidence).toBe(row);
expect(first.truncated).toBe(true);
await appendFile(file, row);
const next = await reader.read(file, scope, 1024, first.cursor);
expect(next.evidence).toBe(row);
expect(next.truncated).toBe(false);
});
it('does not consume partial final records', async () => {
await writeFile(file, '{"text":"hello');
const first = await reader.read(file, scope, 100);
expect(first.evidence).toBe('');
await appendFile(file, '"}\n');
const next = await reader.read(file, scope, 100, first.cursor);
expect(next.evidence).toBe('{"text":"hello"}\n');
});
it('reports an oversized incremental record without splitting it or advancing past it', async () => {
await writeFile(file, '{}\n');
const initial = await reader.read(file, scope, 100);
await appendFile(file, JSON.stringify({ text: 'x'.repeat(300000) }) + '\n');
const result = await reader.read(file, scope, 100, initial.cursor);
expect(result.evidence).toBe('');
expect(result.truncated).toBe(true);
expect(result.blockedReason).toBe('record-exceeds-scan-limit');
expect(result.scannedBytes).toBeLessThanOrEqual(262144);
});
it('rejects malformed, cross-owner and changed-session cursors', async () => {
await writeFile(file, '{}\n');
const first = await reader.read(file, scope, 100);
for (const changed of [
{ ...scope, owner: 'bob' },
{ ...scope, sessionId: 'other' },
{ ...scope, generation: 'new' },
{ ...scope, conversationId: 'new' },
]) {
await expect(reader.read(file, changed, 100, first.cursor)).rejects.toThrow(/cursor/i);
}
await expect(reader.read(file, scope, 100, 'garbage')).rejects.toThrow(/cursor/i);
});
it('rejects truncation, replacement and rewritten consumed evidence', async () => {
await writeFile(file, '{}\n{}\n');
const first = await reader.read(file, scope, 100);
await writeFile(file, '{}\n');
await expect(reader.read(file, scope, 100, first.cursor)).rejects.toThrow(/cursor/i);
await writeFile(file, '{}\n{}\n');
const second = await reader.read(file, scope, 100);
await writeFile(file, '[]\n[]\n');
await expect(reader.read(file, scope, 100, second.cursor)).rejects.toThrow(/cursor/i);
const third = await reader.read(file, scope, 100);
await rename(file, join(dir, 'old'));
await writeFile(file, '[]\n[]\n');
await expect(reader.read(file, scope, 100, third.cursor)).rejects.toThrow(/cursor/i);
});
it('expires cursors and bounds retained state', async () => {
await writeFile(file, '{}\n');
let now = 0;
reader = new InspectionReader(() => now, 2, 1000);
const first = await reader.read(file, scope, 100);
await reader.read(file, scope, 100);
await reader.read(file, scope, 100);
await expect(reader.read(file, scope, 100, first.cursor)).rejects.toThrow(/cursor/i);
const last = await reader.read(file, scope, 100);
now = 1001;
await expect(reader.read(file, scope, 100, last.cursor)).rejects.toThrow(/cursor/i);
});
it('bounds strict attribution itself and refuses an incomplete inventory', async () => {
const { mkdir } = await import('node:fs/promises');
const { findExactCodexConversation } = await import('../src/agent-codex-identity.js');
const home = join(dir, 'codex');
const sessions = join(home, 'sessions');
await mkdir(sessions, { recursive: true });
const id = '11111111-1111-4111-8111-111111111111';
await writeFile(
join(sessions, `rollout-${id}.jsonl`),
JSON.stringify({ type: 'session_meta', payload: { id, cwd: dir, originator: 'codeman_s' } }) + '\n'
);
await writeFile(join(sessions, 'other.txt'), 'other');
const options = { codexHome: home, sessionId: 's', workingDir: dir };
expect((await findExactCodexConversation(options))?.id).toBe(id);
expect(await findExactCodexConversation(options, { maxEntries: 1, maxBytes: 16384 })).toBeUndefined();
});
it('starts at recent complete records past oversized bootstrap evidence, labeling all omission', async () => {
const old = JSON.stringify({ system: 'x'.repeat(300000) }) + '\n';
const latest = JSON.stringify({ assistant: 'latest 😀' }) + '\n';
await writeFile(file, old + latest);
const result = await reader.read(file, scope, Buffer.byteLength(latest));
expect(result.evidence).toBe(latest);
expect(result.omittedBeforeBytes).toBe(Buffer.byteLength(old));
expect(result.truncated).toBe(true);
await appendFile(file, '{"assistant":"next"}\n');
expect((await reader.read(file, scope, 100, result.cursor)).evidence).toBe('{"assistant":"next"}\n');
});
it('attributes a real-sized session_meta row rather than requiring a tiny header', async () => {
const { mkdir } = await import('node:fs/promises');
const { findExactCodexConversation } = await import('../src/agent-codex-identity.js');
const home = join(dir, 'codex');
await mkdir(join(home, 'sessions'), { recursive: true });
const id = '11111111-1111-4111-8111-111111111111';
await writeFile(
join(home, 'sessions', `rollout-${id}.jsonl`),
JSON.stringify({
type: 'session_meta',
payload: { id, cwd: dir, originator: 'codeman_s', instructions: 'x'.repeat(20000) },
}) + '\n'
);
expect(
(
await findExactCodexConversation(
{ codexHome: home, sessionId: 's', workingDir: dir },
{ maxEntries: 8192, maxBytes: 1048576 }
)
)?.id
).toBe(id);
});
it('rejects same-size rewrites outside the bounded cursor anchors', async () => {
const { utimes } = await import('node:fs/promises');
const row = JSON.stringify({ text: 'a'.repeat(20000) }) + '\n';
await writeFile(file, row);
await utimes(file, 1, 1);
const first = await reader.read(file, scope, 30000);
await writeFile(file, row.slice(0, 10000) + 'b' + row.slice(10001));
await expect(reader.read(file, scope, 30000, first.cursor)).rejects.toThrow(/cursor/i);
});
it('keeps cursor capacity bounded across concurrent reads', async () => {
await writeFile(file, '{}\n');
reader = new InspectionReader(Date.now, 2);
const results = await Promise.all(Array.from({ length: 16 }, () => reader.read(file, scope, 100)));
const resumed = await Promise.allSettled(results.map((result) => reader.read(file, scope, 100, result.cursor)));
expect(resumed.filter((result) => result.status === 'fulfilled').length).toBeLessThanOrEqual(2);
});
it('caps cursors per owner and session, so one polling scope cannot evict another', async () => {
await writeFile(file, '{}\n');
const other = { ...scope, owner: 'bob', sessionId: 't' };
const bobs = await reader.read(file, other, 100);
// Alice polls every second for well past the old global FIFO of 1,024.
let last = await reader.read(file, scope, 100);
for (let i = 0; i < 1100; i++) last = await reader.read(file, scope, 100, last.cursor);
// Bob's cursor survives, and so does a retry of Alice's last call.
await expect(reader.read(file, other, 100, bobs.cursor)).resolves.toMatchObject({ evidence: '' });
await expect(reader.read(file, scope, 100, last.cursor)).resolves.toMatchObject({ evidence: '' });
// Alice keeps only her newest cursors: the oldest of one more than the cap is retired.
const issued: string[] = [];
for (let i = 0; i <= INSPECT_CURSORS_PER_SCOPE; i++) issued.push((await reader.read(file, scope, 100)).cursor);
await expect(reader.read(file, scope, 100, issued[0])).rejects.toThrow(/cursor/i);
await expect(reader.read(file, scope, 100, issued[issued.length - 1])).resolves.toBeDefined();
// The cap counts owner and session: a new pane generation of Alice's session shares
// it, Bob's session does not.
const nextPane = { ...scope, generation: 'pane-2' };
for (let i = 0; i < INSPECT_CURSORS_PER_SCOPE; i++) await reader.read(file, nextPane, 100);
await expect(reader.read(file, scope, 100, issued[issued.length - 1])).rejects.toThrow(/cursor/i);
await expect(reader.read(file, other, 100, bobs.cursor)).resolves.toBeDefined();
});
it('keeps an incomplete UTF8 sequence pending until its JSONL record is complete', async () => {
const row = Buffer.from('{"text":"😀"}\n');
const split = row.indexOf(Buffer.from('😀')) + 2;
await writeFile(file, row.subarray(0, split));
const first = await reader.read(file, scope, 100);
expect(first.evidence).toBe('');
await appendFile(file, row.subarray(split));
expect((await reader.read(file, scope, 100, first.cursor)).evidence).toBe(row.toString('utf8'));
});
it('treats a full incremental page as pageable rather than a blocked oversized record', async () => {
await writeFile(file, '{}\n');
const initial = await reader.read(file, scope, 100);
await appendFile(file, '{}\n{}\n');
const page = await reader.read(file, scope, 3, initial.cursor);
expect(page.evidence).toBe('{}\n');
expect(page.truncated).toBe(true);
expect(page.blockedReason).toBeUndefined();
const next = await reader.read(file, scope, 3, page.cursor);
expect(next.evidence).toBe('{}\n');
});
it('still labels the first selected oversized tail record after omitted older bytes', async () => {
await writeFile(file, '{}\n' + JSON.stringify({ text: 'x'.repeat(100) }) + '\n');
const page = await reader.read(file, scope, 10);
expect(page.evidence).toBe('');
expect(page.blockedReason).toBe('record-exceeds-output-budget');
});
+544
View File
@@ -0,0 +1,544 @@
/** @fileoverview Guarded input observes all production write and lifecycle primitives. */
import { expect, it, vi } from 'vitest';
import { formatPaneSnapshot } from '../src/tmux-manager.js';
import { Session } from '../src/session.js';
import { promptSha256 } from '../src/agent-staging.js';
it('fences writes and reused-pid pane lifecycles without persisting prompt content', async () => {
let screen = '› \n GPT-6-Luna default · ~/case\n';
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(screen.trimEnd().split('\n'), {
cols: 100,
rows: 20,
cursorX: screen.startsWith('› hello') ? 7 : 2,
cursorY: 0,
});
},
sendInput: async () => true,
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 12345 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const before = session.guardedInputSnapshot();
expect(before?.composerSha).toBe(promptSha256(''));
await session.writeViaMux('hello');
screen = '› hello\n GPT-6-Luna default · ~/case\n';
expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 1);
session.trackUserInput('x');
expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 2);
await session.performAuxiliaryInput(async () => {});
expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 3);
const generation = session.inspectionGeneration;
await (session as unknown as { _withPaneLifecycle(op: () => Promise<void>): Promise<void> })._withPaneLifecycle(
async () => {
expect(session.guardedInputSnapshot()).toBeNull();
}
);
expect(session.inspectionGeneration).not.toBe(generation);
session.markPromptSubmitted();
expect(session.guardedInputSnapshot()?.revision).toBe(before!.revision + 4);
await session.stop(false);
expect(session.guardedInputSnapshot()).toBeNull();
});
it('checks conditional Enter at the dispatch boundary and holds concurrent input until after it', async () => {
let dispatch: (() => void) | undefined;
const writes: string[] = [];
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 7,
cursorY: 0,
});
},
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
await new Promise<void>((resolve) => {
dispatch = resolve;
});
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
writes.push(text);
return true;
},
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
const settle = () => new Promise<void>((resolve) => setImmediate(resolve));
const expected = session.guardedInputSnapshot()!;
const sending = session.submitGuardedInput(expected);
// Held behind the lease, not refused: it still invalidates the pending Enter, and
// it goes out once the lease is released.
const other = session.writeViaMux('other');
dispatch!();
expect(await sending).toBe(false);
expect(writes).toEqual([]);
await settle();
dispatch!();
expect(await other).toBe(true);
expect(writes).toEqual(['other']);
const next = session.submitGuardedInput(session.guardedInputSnapshot()!);
dispatch!();
expect(await next).toBe(true);
expect(writes).toEqual(['other', '\r']);
const restarting = session.submitGuardedInput(session.guardedInputSnapshot()!);
await (session as unknown as { _withPaneLifecycle(op: () => Promise<void>): Promise<void> })._withPaneLifecycle(
async () => {}
);
dispatch!();
expect(await restarting).toBe(false);
expect(writes).toEqual(['other', '\r']);
const auxiliary = session.submitGuardedInput(session.guardedInputSnapshot()!);
const key = session.performAuxiliaryInput(async () => {
writes.push('aux');
});
await settle();
// The named key waits for the lease instead of failing, and never runs inside it.
expect(writes).toEqual(['other', '\r']);
dispatch!();
expect(await auxiliary).toBe(false);
await key;
expect(writes).toEqual(['other', '\r', 'aux']);
} finally {
await session.stop(false);
}
});
it('cancels an older automatic submit verifier before staging an identical prompt', async () => {
vi.useFakeTimers();
let composer = '';
const writes: string[] = [];
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 2 + composer.length,
cursorY: 0,
});
},
capturePaneText: () => `› ${composer}\n GPT-6-Luna default · ~/case`,
sendInput: async (_id: string, text: string) => {
writes.push(text);
composer += text;
return true;
},
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
session.verifySubmitted('hello');
expect(await session.stageGuardedInput('hello')).toBe(true);
await vi.advanceTimersByTimeAsync(2000);
expect(writes).toEqual(['hello']);
} finally {
await session.stop(false);
vi.useRealTimers();
}
});
it('routes automatic verifier Enter through the shared guarded input boundary', async () => {
vi.useFakeTimers();
let release!: () => void;
const writes: string[] = [];
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 7,
cursorY: 0,
});
},
capturePaneText: () => '› hello\n GPT-6-Luna default · ~/case',
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
if (options?.beforeDispatch) {
await new Promise<void>((resolve) => {
release = resolve;
});
if (!options.beforeDispatch()) return false;
}
writes.push(text);
return true;
},
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
session.verifySubmitted('hello');
const submission = session.submitGuardedInput(session.guardedInputSnapshot()!);
await vi.advanceTimersByTimeAsync(2000);
release();
expect(await submission).toBe(false);
await vi.advanceTimersByTimeAsync(60000);
expect(writes).toEqual([]);
} finally {
await session.stop(false);
vi.useRealTimers();
}
});
it('refuses legacy one-shot prompt entrypoints during a guarded submission before spawning', async () => {
let release!: () => void;
const writes: string[] = [];
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 7,
cursorY: 0,
});
},
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
await new Promise<void>((resolve) => {
release = resolve;
});
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
writes.push(text);
return true;
},
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const access = session as unknown as { ptyProcess: { write(data: string): void } | null };
try {
const submission = session.submitGuardedInput(session.guardedInputSnapshot()!);
// This also prevents a real spawn if the lease guard regresses.
access.ptyProcess = {
write: () => {
throw new Error('must not write');
},
};
await expect(session.runPrompt('other')).rejects.toThrow('Guarded submission');
await expect(session.sendInput('other')).rejects.toThrow('Guarded submission');
access.ptyProcess = null;
release();
expect(await submission).toBe(false);
expect(writes).toEqual([]);
} finally {
access.ptyProcess = null;
await session.stop(false);
}
});
it('counts an ordinary transport pending window before any awaited acknowledgement', async () => {
let release!: () => void;
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux: {
isAvailable: () => true,
sendInput: async () => {
await new Promise<void>((resolve) => {
release = resolve;
});
return true;
},
},
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
const writing = session.writeViaMux('hello');
expect(session.guardedInputPendingWrites).toBe(1);
release();
await writing;
expect(session.guardedInputPendingWrites).toBe(0);
} finally {
release?.();
await session.stop(false);
}
});
it('uses fresh normalized pane rows for working/dialog denial before exact composer proof', async () => {
let working = false;
const capture = vi.fn(() =>
formatPaneSnapshot(
[working ? '• Working (1s · esc to interrupt)' : 'ready', '› hello', ' GPT-6-Luna default · ~/case'],
{ cols: 100, rows: 20, cursorX: 7, cursorY: 1 }
)
);
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux: {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return capture();
},
capturePaneText: () => {
throw Error('must use same frame');
},
},
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
const blocked = vi.spyOn(session as unknown as { _paneShowsBlocked(text: string): boolean }, '_paneShowsBlocked');
expect(session.guardedInputSnapshot()).not.toBeNull();
expect(blocked.mock.calls[0][0]).toContain('ready\n› hello\n');
working = true;
expect(session.guardedInputSnapshot()).toBeNull();
blocked.mockReturnValue(true);
working = false;
expect(session.guardedInputSnapshot()).toBeNull();
} finally {
await session.stop(false);
}
});
it('keeps raw user input synchronous and preserves receipts for focus/mouse/noop reports', async () => {
const writes: string[] = [];
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux: {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 7,
cursorY: 0,
});
},
},
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const access = session as unknown as {
ptyProcess: { write(data: string): void; resize(cols: number, rows: number): void } | null;
};
access.ptyProcess = {
write: (text) => {
writes.push(text);
},
resize: () => {},
};
try {
const before = session.guardedInputSnapshot()!;
for (const data of ['\x1b[I', '\x1b[O', '\x1b[<0;10;5M', '\x1b[M !!', '']) {
expect(session.write(data, { fromUser: true })).toBe(true);
expect(writes.at(-1)).toBe(data);
expect(session.guardedInputSnapshot()?.revision).toBe(before.revision);
}
session.write('\x1b[Oedit');
expect(session.guardedInputSnapshot()?.revision).toBe(before.revision + 1);
const generation = session.inspectionGeneration;
session.owner = 'other';
session.owner = undefined;
expect(session.inspectionGeneration).not.toBe(generation);
const priorResize = session.guardedInputGeneration;
session.resize(80, 24);
expect(session.guardedInputGeneration).not.toBe(priorResize);
} finally {
access.ptyProcess = null;
await session.stop(false);
}
});
it('refuses a message that would wrap using the actual captured indent and width before any write', async () => {
const writes: string[] = [];
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux: {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane: string,
opts: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 30, rows: 20 };
return formatPaneSnapshot([' › ', ' GPT-6 default · ~/case'], {
cols: 30,
rows: 20,
cursorX: 6,
cursorY: 0,
});
},
sendInput: async (_id: string, text: string) => {
writes.push(text);
return true;
},
},
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js');
const { getCli } = await import('../src/config/cli-registry/registry.js');
try {
const store = new GuardedStageStore();
const target = {
id: session.id,
chrome: () => guardedComposerChrome(getCli('codex')),
snapshot: () => session.guardedInputSnapshot(),
write: (text: string) => session.stageGuardedInput(text),
submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap),
};
await expect(store.stage(target, 'alice', 'x'.repeat(24))).rejects.toThrow(/wrap/);
expect(writes).toEqual([]);
} finally {
await session.stop(false);
}
});
it('keeps inspection generations stable across resize but invalidates a staged snapshot', async () => {
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
const access = session as unknown as { ptyProcess: { resize(cols: number, rows: number): void } | null };
access.ptyProcess = { resize: () => {} };
try {
const inspection = session.inspectionGeneration;
const staging = session.guardedInputGeneration;
session.resize(80, 24);
expect(session.inspectionGeneration).toBe(inspection);
expect(session.guardedInputGeneration).not.toBe(staging);
} finally {
access.ptyProcess = null;
await session.stop(false);
}
});
it('records a verifier re-press as the latest submit time', async () => {
vi.useFakeTimers();
vi.setSystemTime(10000);
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux: { isAvailable: () => true, capturePaneText: () => '› hello\n', sendInput: async () => true },
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
try {
await session.writeViaMux('hello\r');
const initial = session.lastSubmitAt;
await vi.advanceTimersByTimeAsync(2000);
expect(session.lastSubmitAt).toBe(initial + 2000);
} finally {
await session.stop(false);
vi.useRealTimers();
}
});
it('stages through a real session whose first post-write capture is the torn frame codex produces', async () => {
// Reproduces the live 0.162.0 race: right after the keys land, the screen still
// shows the empty composer while the caret is already past the new text. That
// frame proves nothing; the repaint a few ms later does.
let composer = '';
let torn = 0;
const writes: string[] = [];
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux: {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
const shown = torn > 0 ? '' : composer;
if (torn > 0) torn--;
return formatPaneSnapshot([`› ${shown}`, ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 2 + composer.length,
cursorY: 0,
});
},
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
writes.push(text);
if (text !== '\r') {
composer += text;
torn = 2;
}
return true;
},
},
muxSession: { muxName: 'codeman-12345678', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const { GuardedStageStore, guardedComposerChrome } = await import('../src/agent-staging.js');
const { getCli } = await import('../src/config/cli-registry/registry.js');
const sleeps: number[] = [];
const store = new GuardedStageStore(Date.now, 1024, 120000, {
sleep: async (ms) => {
sleeps.push(ms);
},
});
const target = {
id: session.id,
chrome: () => guardedComposerChrome(getCli('codex')),
snapshot: () => session.guardedInputSnapshot(),
write: (text: string) => session.stageGuardedInput(text),
submit: (snap: Parameters<Session['submitGuardedInput']>[0]) => session.submitGuardedInput(snap),
};
try {
const receipt = await store.stage(target, 'alice', 'hello');
expect(receipt.sha256).toBe(promptSha256('hello'));
expect(sleeps).toHaveLength(2);
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).resolves.toMatchObject({
submitted: true,
});
expect(writes).toEqual(['hello', '\r']);
} finally {
await session.stop(false);
}
});
+380
View File
@@ -0,0 +1,380 @@
/** @fileoverview Guarded staging one-shot and content-verification safety. */
import { beforeEach, describe, expect, it } from 'vitest';
import { createHash } from 'node:crypto';
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
import {
GuardedStageStore,
STAGE_SETTLE_INTERVAL_MS,
STAGE_SETTLE_TIMEOUT_MS,
SUBMIT_QUIET_MS,
composerTriggerRefusal,
guardedComposerChrome,
readGuardedComposer,
readGuardedSnapshot,
type GuardedInputTarget,
} from '../src/agent-staging.js';
import { getCli, listClis } from '../src/config/cli-registry/registry.js';
/** Codex's composer chrome exactly as the registry declares it: no copy of the glyph or footer here. */
const CODEX = guardedComposerChrome(getCli('codex'))!;
const readGuardedCodexComposer = (screen: string) => readGuardedComposer(screen, CODEX);
const readGuardedCodexSnapshot = (snapshot: string) => readGuardedSnapshot(snapshot, CODEX);
const sha = (s: string) => createHash('sha256').update(s).digest('hex');
let store: GuardedStageStore;
let time: number;
let generation: string;
let revision: number;
let composer: string | null;
let writes: string[];
let fail: boolean;
let target: GuardedInputTarget;
/** Every settle-window wait, on the fake clock: no real timer runs in this file. */
let sleeps: number[];
const fakeSleep = async (ms: number) => {
sleeps.push(ms);
time += ms;
};
beforeEach(() => {
sleeps = [];
time = 0;
generation = 'pane-1';
revision = 0;
composer = '';
writes = [];
fail = false;
store = new GuardedStageStore(() => time, 2, 1000, { sleep: fakeSleep });
target = {
id: 's',
chrome: () => CODEX,
snapshot: () =>
composer === null ? null : { generation, revision, composerSha: sha(composer), availableColumns: 100 },
submit: async () => target.write('\r'),
write: async (text) => {
writes.push(text);
revision++;
if (fail) return false;
composer = text === '\r' ? '' : composer + text;
return true;
},
};
});
it('stages without Enter, stores only a hash, and submits exactly once', async () => {
const receipt = await store.stage(target, 'alice', 'hello');
expect(writes).toEqual(['hello']);
expect(receipt.sha256).toBe(sha('hello'));
expect(JSON.stringify(receipt)).not.toContain('hello');
await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
expect(writes).toEqual(['hello', '\r']);
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
});
it('refuses multiline, controls, oversized prompts and nonempty or unprovable composers before writing', async () => {
for (const text of ['', 'one\ntwo', 'one\rtwo', '\u001b', '\u0085', 'x'.repeat(20000), '\ud800'])
await expect(store.stage(target, 'alice', text)).rejects.toThrow();
composer = 'existing';
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow();
composer = null;
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow();
expect(writes).toEqual([]);
});
it('refuses altered composer including whitespace-only differences', async () => {
const receipt = await store.stage(target, 'alice', 'hello world');
composer = 'helloworld';
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
expect(writes).toEqual(['hello world']);
});
it('refuses changed generation, intervening input, wrong owner/session/hash and expiry', async () => {
for (const change of [
() => {
generation = 'pane-2';
},
() => {
revision++;
},
() => {
time += 1001;
},
]) {
composer = '';
const receipt = await store.stage(target, 'alice', 'hello');
change();
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
}
composer = '';
const receipt = await store.stage(target, 'alice', 'hello');
await expect(store.submit(target, 'bob', receipt.stageId, receipt.sha256)).rejects.toThrow();
await expect(store.submit({ ...target, id: 'other' }, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
await expect(store.submit(target, 'alice', receipt.stageId, sha('other'))).rejects.toThrow();
expect(writes.filter((s) => s === '\r')).toHaveLength(0);
});
it('consumes before write so concurrent and failed deliveries never replay Enter', async () => {
const receipt = await store.stage(target, 'alice', 'hello');
fail = true;
const results = await Promise.allSettled([
store.submit(target, 'alice', receipt.stageId, receipt.sha256),
store.submit(target, 'alice', receipt.stageId, receipt.sha256),
]);
expect(results.every((r) => r.status === 'rejected')).toBe(true);
expect(writes).toEqual(['hello', '\r']);
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow();
});
it('does not issue a receipt on failed staging or intervening input during its write', async () => {
fail = true;
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow();
fail = false;
composer = '';
const altered = {
...target,
write: async (s: string) => {
await target.write(s);
revision++;
return true;
},
};
await expect(store.stage(altered, 'alice', 'hello')).rejects.toThrow();
});
it('reads exact composer spaces and refuses wrapped or modal frames', async () => {
expect(readGuardedCodexComposer('› hello world\n GPT-6-Luna default · ~/case\n')).toBe('hello world');
expect(
readGuardedCodexComposer(
'› \x1b[2mAsk Codex to do anything\x1b[0m\n GPT-6-Luna default · ~/case\n ← for agents · ? for shortcuts'
)
).toBe('');
expect(readGuardedCodexComposer('› wrapped\n more text\n GPT-6-Luna default · ~/case')).toBeNull();
expect(readGuardedCodexComposer('› 1. Yes\n Press enter to confirm')).toBeNull();
expect(readGuardedCodexComposer('› hello\n')).toBeNull();
});
it('requires capture cursor geometry, rejecting invisible trailing edits and moved cursors', async () => {
const { formatPaneSnapshot } = await import('../src/tmux-manager.js');
const frame = (text: string, x: number, y = 0) =>
formatPaneSnapshot([`› ${text}`, ' GPT-6-Luna default · ~/case'], { cols: 100, rows: 20, cursorX: x, cursorY: y });
expect(readGuardedCodexSnapshot(frame('hello', 7))).toBe('hello');
expect(readGuardedCodexSnapshot(frame('hello', 8))).toBeNull();
expect(readGuardedCodexSnapshot(frame('hello', 4))).toBeNull();
expect(readGuardedCodexSnapshot(frame('hello', 7, 1))).toBeNull();
expect(readGuardedCodexSnapshot('› hello\n GPT-6-Luna default · ~/case')).toBeNull();
expect(readGuardedCodexSnapshot(frame('你好', 6))).toBe('你好');
expect(readGuardedCodexSnapshot(frame('\x1b[38;2;2;100;100mhello\x1b[0m', 7))).toBe('hello');
});
it('reserves receipt capacity before concurrent stages type', async () => {
store = new GuardedStageStore(() => time, 1, 1000, { sleep: fakeSleep });
let release: (() => void) | undefined;
const firstTarget = {
...target,
write: async (text: string) => {
await new Promise<void>((resolve) => {
release = resolve;
});
return target.write(text);
},
};
const first = store.stage(firstTarget, 'alice', 'hello');
const otherTarget = { ...target, id: 'other' };
await expect(store.stage(otherTarget, 'alice', 'other')).rejects.toThrow(/capacity/i);
expect(writes).toEqual([]);
release!();
await first;
});
it.each([...CODEX_REASONING_EFFORTS, 'default'])(
'accepts the shared Codex effort word %s in the footer',
async (word) => {
expect(readGuardedCodexComposer(`› hello\n GPT-6 ${word} · ~/case\n`)).toBe('hello');
}
);
it('refuses messages that reach the available pane columns before typing', async () => {
target.snapshot = () => ({ generation, revision, composerSha: sha(composer ?? ''), availableColumns: 4 });
for (const text of ['four', '界界']) await expect(store.stage(target, 'alice', text)).rejects.toThrow();
expect(writes).toEqual([]);
await expect(store.stage(target, 'alice', 'abc')).resolves.toMatchObject({ submitted: false });
});
it('takes the composer glyph and footer from the registry, and only codex declares a footer', () => {
const codex = getCli('codex')!.capabilities.workDetect!;
expect(CODEX.promptGlyph).toBe(codex.promptGlyph);
expect(CODEX.footerLine.source).toBe(codex.composerFooterLine);
// Another glyph drives the same reader: nothing in it is codex-specific.
const other = { promptGlyph: '>>', footerLine: CODEX.footerLine };
expect(readGuardedComposer('>> hello\n GPT-6 default · ~/case\n', other)).toBe('hello');
expect(readGuardedComposer('› hello\n GPT-6 default · ~/case\n', other)).toBeNull();
const declaring = listClis()
.filter((entry) => entry.capabilities.workDetect?.composerFooterLine)
.map((entry) => entry.id);
expect(declaring).toEqual(['codex']);
expect(guardedComposerChrome(getCli('claude'))).toBeNull();
// A pattern the ReDoS guard refuses yields no chrome, so no guarded input.
const unsafe = structuredClone(getCli('codex')!);
unsafe.capabilities.workDetect!.composerFooterLine = '(a+)+b';
expect(guardedComposerChrome(unsafe)).toBeNull();
});
describe('the settle window after typing (a live codex pane repaints 30 to 60 ms after the keys)', () => {
/** Serve these post-write frames in order, then the target's own snapshot. */
function afterWrite(frames: Array<(() => ReturnType<GuardedInputTarget['snapshot']>) | null>) {
let written = false;
const own = target.snapshot;
const write = target.write;
target.write = async (text) => {
const ok = await write(text);
written = true;
return ok;
};
target.snapshot = () => {
if (!written || frames.length === 0) return own();
const next = frames.shift()!;
return next ? next() : null;
};
}
const stale = () => ({ generation, revision, composerSha: sha(''), availableColumns: 100 });
it('names its bounds', () => {
expect(STAGE_SETTLE_TIMEOUT_MS).toBe(1000);
expect(STAGE_SETTLE_INTERVAL_MS).toBe(50);
});
it('issues a receipt once a null first frame is followed by a valid one', async () => {
afterWrite([null]);
const receipt = await store.stage(target, 'alice', 'hello');
expect(receipt.sha256).toBe(sha('hello'));
expect(sleeps).toEqual([STAGE_SETTLE_INTERVAL_MS]);
});
it('issues a receipt once a stale frame (the old, empty composer) is followed by a valid one', async () => {
afterWrite([stale, stale]);
await expect(store.stage(target, 'alice', 'hello')).resolves.toMatchObject({ submitted: false });
expect(sleeps).toHaveLength(2);
});
it('refuses, saying the text may be in the composer, when no frame verifies within the window', async () => {
target.snapshot = () => (writes.length ? null : stale());
const refusal = await store.stage(target, 'alice', 'hello').catch((error: Error) => error.message);
expect(refusal).toMatch(/within 1000 ms; no receipt was issued/);
expect(refusal).toMatch(/may already be in the composer.*Ctrl\+U/);
expect(sleeps.length).toBeGreaterThanOrEqual(STAGE_SETTLE_TIMEOUT_MS / STAGE_SETTLE_INTERVAL_MS);
});
it('issues no receipt for a frame whose composer text differs from the message', async () => {
const write = target.write;
target.write = async (text) => {
await write(text);
composer = 'hellp'; // codex shows something else: a transformed paste, a dropped key
return true;
};
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(/did not show the staged message/);
// Nothing to submit: no receipt was stored for the frame that did not match.
composer = 'hello';
await expect(store.submit(target, 'alice', '11111111-1111-4111-8111-111111111111', sha('hello'))).rejects.toThrow(
/Unknown/
);
});
it('refuses at once, without waiting, when another writer moved the input during the window', async () => {
afterWrite([() => ({ generation, revision: revision + 1, composerSha: sha('hello'), availableColumns: 100 })]);
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(
/Input or pane changed while staging; no receipt was issued\. The message may already be in the composer/
);
expect(sleeps).toEqual([]);
});
it('words a failed typing delivery like the submit refusal: it cannot know whether the text landed', async () => {
fail = true;
await expect(store.stage(target, 'alice', 'hello')).rejects.toThrow(
/^Staging delivery failed or ambiguous; no receipt was issued\. The message may already be in the composer/
);
});
});
describe('popups and commands that change what Enter does (codex rust-v0.162.0 bottom_pane)', () => {
const footer = ' gpt-5.6-terra default · ~/case';
/** A frame as codex lays it out: transcript, popup, the composer's blank top row, composer, blank, footer. */
const frame = (popup: string[], draft: string) =>
['• Earlier answer from the agent.', '', ...popup, '', `› ${draft}`, '', footer].join('\n');
it('reads the idle frame with transcript above it', () => {
expect(readGuardedCodexComposer(frame([], 'hello'))).toBe('hello');
});
it('refuses the slash-command list codex opens above `› /stat` (Enter would run /status)', () => {
const popup = [
'› /status show current session configuration and token usage',
' /statusline configure which items appear in the status line',
];
expect(readGuardedCodexComposer(frame(popup, '/stat'))).toBeNull();
// A wrapped description row at the bottom of the list does not hide it.
expect(readGuardedCodexComposer(frame(['› /tier-one Use faster', ' inference'], '/tier'))).toBeNull();
});
it('refuses the skill, mention and file-search popups', () => {
const skill = ['› 1password [Skill] Example skill used in tests.', '', ' enter insert · esc close'];
const mention = [
' Mentions',
' All Results Filesystem Only Plugins',
' sa',
'› Sample Plugin Plugin with skills and an MCP server Plugin',
' enter/tab insert · esc close · ↑/↓ select · ←/→ filter',
];
expect(readGuardedCodexComposer(frame(skill, '$1p'))).toBeNull();
expect(readGuardedCodexComposer(frame(mention, '@sa'))).toBeNull();
expect(readGuardedCodexComposer(frame([' loading...', ''], '@src'))).toBeNull();
expect(readGuardedCodexComposer(frame(['no matches'], '/zz'))).toBeNull();
});
it('looks only at the block directly above the composer, not the transcript further up', () => {
const screen = [' /status shows the status', '', '• Answer.', '', '', '', '› hello', '', footer].join('\n');
expect(readGuardedCodexComposer(screen)).toBe('hello');
});
it('takes the popup pattern and the trigger characters from the registry', () => {
const codex = getCli('codex')!.capabilities.workDetect!;
expect(CODEX.popupLine?.source).toBe(new RegExp(codex.composerPopupLine!).source);
expect(CODEX.commandPrefixes).toBe('/!');
expect(CODEX.completionSigils).toBe('@$');
// A popup pattern the ReDoS guard refuses yields no chrome, rather than chrome without it.
const unsafe = structuredClone(getCli('codex')!);
unsafe.capabilities.workDetect!.composerPopupLine = '(a+)+b';
expect(guardedComposerChrome(unsafe)).toBeNull();
});
it.each([
['/stat', '/'],
['/status now', '/'],
[' /review', '/'],
['!ls -la', '!'],
['review @src/app', '@'],
['@notes', '@'],
['run $skill', '$'],
['costs $5', '$'],
])('refuses %j before typing (trigger %s)', async (message, trigger) => {
expect(composerTriggerRefusal(message, CODEX)).toContain(`"${trigger}"`);
await expect(store.stage(target, 'alice', message)).rejects.toThrow(
trigger === '/' || trigger === '!' ? /codeman agent send/ : /completion popup/
);
expect(writes).toEqual([]);
});
it.each(['look at @src/app.ts please', 'mail a@b.example', 'pay $5 today', 'a/b and c!'])(
'stages %j: no trigger at the start, and the caret word opens nothing',
async (message) => {
expect(composerTriggerRefusal(message, CODEX)).toBeNull();
await expect(store.stage(target, 'alice', message)).resolves.toMatchObject({ submitted: false });
}
);
});
describe("the quiet time before Enter (codex's 120 ms paste-burst window turns Enter into a newline)", () => {
it('waits out the rest of the window after the staged keys before checking and sending Enter', async () => {
expect(SUBMIT_QUIET_MS).toBeGreaterThan(120);
const receipt = await store.stage(target, 'alice', 'hello');
time += 40;
await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
expect(sleeps).toEqual([SUBMIT_QUIET_MS - 40]);
expect(writes).toEqual(['hello', '\r']);
});
it('does not wait once the window has passed', async () => {
const receipt = await store.stage(target, 'alice', 'hello');
time += SUBMIT_QUIET_MS;
await store.submit(target, 'alice', receipt.stageId, receipt.sha256);
expect(sleeps).toEqual([]);
});
it('still refuses when a keystroke lands during the wait', async () => {
store = new GuardedStageStore(() => time, 2, 1000, {
sleep: async (ms) => {
await fakeSleep(ms);
revision++; // the user typed while submit waited
},
});
const receipt = await store.stage(target, 'alice', 'hello');
await expect(store.submit(target, 'alice', receipt.stageId, receipt.sha256)).rejects.toThrow(
/changed; nothing submitted/
);
expect(writes).toEqual(['hello']);
});
});
+42
View File
@@ -12,6 +12,7 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
@@ -195,6 +196,47 @@ describe('App Settings modal structure', () => {
}
});
/**
* Git fetch on an interval (src/git-fetch-scheduler.ts). Both keys are SYNCED, not display keys, because
* the server's scheduler is what acts on them: kept per-device, the scheduler would read nobody's number.
* Off by default, and the interval clamped on save to the same bounds the schema accepts.
*/
it('keeps the Git fetch rows synced, off by default, and inside the bounds the scheduler acts on', () => {
const modal = settingsModal();
const bar = modal.match(/<h4>Bottom bar<\/h4>([\s\S]*?)(?=<div class="set-group-head">)/)?.[1];
expect(bar, 'the Git fetch rows are not in the Bottom bar group').toBeTruthy();
for (const id of ['appSettingsGitFetchEnabled', 'appSettingsGitFetchIntervalSeconds']) {
const at = modal.indexOf(`id="${id}"`);
expect(at, `no element ${id}`).toBeGreaterThan(-1);
const row = modal.slice(modal.lastIndexOf('<div class="set-row"', at), modal.indexOf('</div>', at));
expect(row).toContain('<span class="set-scope">synced</span>');
}
// The number row's own bounds must agree with the schema's, or the browser sends a value the PUT rejects.
expect(modal).toContain('id="appSettingsGitFetchIntervalSeconds" class="set-num" value="300" min="30" max="3600"');
// Display keys are the half of the per-device split that keeps a value off the server.
const displayStart = settingsUi.indexOf('const displayKeys = new Set([');
const displayBlock = settingsUi.slice(displayStart, settingsUi.indexOf(']);', displayStart));
expect(displayBlock).not.toContain('gitFetchEnabled');
expect(displayBlock).not.toContain('gitFetchIntervalSeconds');
// The strip destructure is the other half: an aliased key here never reaches the settings PUT.
expect(settingsUi).not.toMatch(/gitFetch(?:Enabled|IntervalSeconds):\s*_\w+/);
// Declared in the .strict() schema: an undeclared key 400s the whole settings PUT.
expect(SettingsUpdateSchema.safeParse({ gitFetchEnabled: true, gitFetchIntervalSeconds: 300 }).success).toBe(true);
// Off by default (only an explicit true loads as on), and an empty box falls back to the default interval.
expect(settingsUi).toContain(
"document.getElementById('appSettingsGitFetchEnabled').checked = settings.gitFetchEnabled === true;"
);
expect(settingsUi).toContain(
"document.getElementById('appSettingsGitFetchIntervalSeconds').value = settings.gitFetchIntervalSeconds ?? 300;"
);
expect(settingsUi).toMatch(
/gitFetchIntervalSeconds: Math\.min\(\s*3600,\s*Math\.max\(30, parseInt\(document\.getElementById\('appSettingsGitFetchIntervalSeconds'\)\.value, 10\) \|\| 300\)\s*\),/
);
});
it('has retired the modal-tab chrome everywhere, not just here', () => {
// Session Options and Add Case moved onto this same `set-*` surface, so the
// old tab classes have no users left. A reappearance means a modal drifted
+127
View File
@@ -0,0 +1,127 @@
/** @fileoverview Agent inspect/stage/submit transport, refusal and file boundaries. */
import { expect, it } from 'vitest';
import { mkdtemp, writeFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
agentInspect,
agentStage,
agentSubmit,
registerAgentCommands,
type AgentDeps,
type RequestOptions,
} from '../src/cli-agent.js';
import { Command } from 'commander';
const SELF = '11111111-1111-4111-8111-111111111111';
const OTHER = '22222222-2222-4222-8222-222222222222';
function deps() {
const calls: RequestOptions[] = [],
output: string[] = [];
const deps: AgentDeps = {
ctx: { apiUrl: 'http://localhost:1', selfId: SELF },
json: true,
now: () => 1,
io: { out: (line) => output.push(line), err: () => {} },
request: async (_ctx, options) => {
calls.push(options);
return {
status: 200,
text: '',
json: { success: true, data: { evidence: 'hello', stageId: OTHER, sha256: 'a'.repeat(64) } },
};
},
};
return { deps, calls, output };
}
it('inspect uses the ownership-checked versioned endpoint with exact bounds and cursor', async () => {
const d = deps();
expect(await agentInspect(d.deps, { id: OTHER, maxBytes: 42, since: 'cursor' })).toBe(0);
expect(d.calls[0]).toMatchObject({
method: 'GET',
path: `/api/v1/sessions/${OTHER}/inspect`,
query: { maxBytes: 42, since: 'cursor' },
});
expect(JSON.parse(d.output[0]).evidence).toBe('hello');
expect(await agentInspect(d.deps, { id: OTHER, maxBytes: 65537 })).toBe(4);
expect(d.calls).toHaveLength(1);
});
it('stage reads a bounded UTF8 file and never adds Enter; submit sends only receipt and hash', async () => {
const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-'));
try {
const path = join(dir, 'message');
await writeFile(path, 'hello');
const d = deps();
expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(0);
expect(d.calls[0]).toMatchObject({
method: 'POST',
path: `/api/v1/sessions/${OTHER}/stage`,
body: { message: 'hello' },
});
expect(await agentSubmit(d.deps, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(0);
expect(d.calls[1].body).toEqual({ stageId: OTHER, confirmSha: 'a'.repeat(64) });
await writeFile(path, 'hello\n');
expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4);
await writeFile(path, Buffer.from([0xff]));
expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4);
await writeFile(path, 'x'.repeat(8193));
expect(await agentStage(d.deps, { id: OTHER, messageFile: path })).toBe(4);
expect(d.calls).toHaveLength(2);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
it('refuses self-target and malformed submission before transport', async () => {
const d = deps();
expect(await agentStage(d.deps, { id: SELF, messageFile: '/nonexistent' })).toBe(4);
expect(await agentSubmit(d.deps, { id: SELF, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(4);
expect(await agentSubmit(d.deps, { id: OTHER, stageId: OTHER, confirmSha: 'bad' })).toBe(4);
expect(d.calls).toHaveLength(0);
});
it('registers required file and confirmation options and bounded inspect flags', () => {
const program = new Command();
registerAgentCommands(program);
const commands = program.commands.find((command) => command.name() === 'agent')!.commands;
for (const name of ['inspect', 'stage', 'submit'])
expect(commands.some((command) => command.name() === name)).toBe(true);
expect(
commands.find((command) => command.name() === 'stage')!.options.find((option) => option.long === '--message-file')
?.mandatory
).toBe(true);
expect(
commands.find((command) => command.name() === 'submit')!.options.find((option) => option.long === '--confirm-sha')
?.mandatory
).toBe(true);
});
it('shows the opt-in 403 verbatim on inspect, stage and submit while the setting is off', async () => {
const dir = await mkdtemp(join(tmpdir(), 'agent-stage-cli-'));
try {
const path = join(dir, 'message');
await writeFile(path, 'hello');
const message =
'Experimental agent coordination (inspect, stage, submit) is disabled. Turn on "Experimental agent coordination" in Settings (agentCoordinationEnabled) and save first.';
for (const json of [false, true]) {
const lines: string[] = [];
const refusing: AgentDeps = {
ctx: { apiUrl: 'http://localhost:1', selfId: SELF },
json,
now: () => 1,
io: { out: (line) => lines.push(line), err: (line) => lines.push(line) },
request: async () => ({
status: 403,
text: '',
json: { success: false, error: message, errorCode: 'FORBIDDEN' },
}),
};
expect(await agentInspect(refusing, { id: OTHER, maxBytes: 10 })).toBe(1);
expect(await agentStage(refusing, { id: OTHER, messageFile: path })).toBe(1);
expect(await agentSubmit(refusing, { id: OTHER, stageId: OTHER, confirmSha: 'a'.repeat(64) })).toBe(1);
expect(lines).toHaveLength(3);
for (const line of lines) {
const shown = json ? (JSON.parse(line) as { success: boolean; error: string }).error : line;
expect(shown).toContain(`FORBIDDEN: ${message} (HTTP 403)`);
}
}
} finally {
await rm(dir, { recursive: true, force: true });
}
});
+351
View File
@@ -0,0 +1,351 @@
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises';
import http from 'node:http';
import { tmpdir } from 'node:os';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { Command } from 'commander';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
agentSpawn,
isHomeDirectory,
registerAgentCommands,
EXIT,
type AgentDeps,
type ApiResponse,
type RequestOptions,
} from '../src/cli-agent.js';
const SELF = '058ee7b5-b2aa-4c33-8cc1-e900eb0b28af';
const OTHER = '94990c6d-e461-4a29-aa83-89275327732c';
const dirs: string[] = [];
afterEach(async () => {
for (const dir of dirs.splice(0)) await rm(dir, { recursive: true, force: true });
});
const ok = (data: unknown): ApiResponse => ({ status: 200, json: { success: true, data }, text: '' });
function deps(responses: ApiResponse[]) {
const calls: RequestOptions[] = [];
const out: string[] = [];
const err: string[] = [];
const d: AgentDeps = {
ctx: { selfId: SELF, apiUrl: 'http://127.0.0.1:1' },
json: true,
now: Date.now,
io: { out: (s) => out.push(s), err: (s) => err.push(s) },
request: async (_ctx, options) => {
calls.push(options);
return responses.shift() ?? ok({});
},
};
return { d, calls, out, err };
}
const base = { mode: 'codex', ready: false, timeoutMs: 1000 };
describe('project-preserving agent spawn', () => {
it('uses existing session create and interactive endpoints with exact directory and lineage', async () => {
const directory = await mkdtemp(join(tmpdir(), 'cod549-project-'));
dirs.push(directory);
const t = deps([ok({ session: { id: OTHER } }), ok({})]);
expect(await agentSpawn(t.d, { ...base, workingDir: directory, name: 'review' })).toBe(EXIT.ok);
expect(t.calls).toHaveLength(2);
expect(t.calls[0]).toMatchObject({
method: 'POST',
path: '/api/v1/sessions',
body: { workingDir: directory, mode: 'codex', name: 'review', parentSessionId: SELF },
});
expect(t.calls[0].headers ?? {}).not.toHaveProperty('X-Codeman-Agent-Origin');
expect(t.calls[1]).toMatchObject({ method: 'POST', path: `/api/v1/sessions/${OTHER}/interactive` });
expect(JSON.parse(t.out[0])).toMatchObject({ sessionId: OTHER, workingDir: directory });
});
it('defaults to the current directory when no case is supplied', async () => {
const t = deps([ok({ session: { id: OTHER } }), ok({})]);
expect(await agentSpawn(t.d, base)).toBe(EXIT.ok);
expect(t.calls[0].body).toMatchObject({ workingDir: process.cwd() });
});
it('keeps explicit case quick-start behavior', async () => {
const t = deps([ok({ sessionId: OTHER })]);
expect(await agentSpawn(t.d, { ...base, caseName: 'scratch' })).toBe(EXIT.ok);
expect(t.calls).toHaveLength(1);
expect(t.calls[0].path).toBe('/api/v1/quick-start');
});
it('refuses case and working directory together before network IO', async () => {
const t = deps([]);
expect(await agentSpawn(t.d, { ...base, caseName: 'scratch', workingDir: process.cwd() })).toBe(EXIT.refused);
expect(t.calls).toEqual([]);
});
it('refuses missing directories, regular files and unknown modes before creating a session', async () => {
const directory = await mkdtemp(join(tmpdir(), 'cod549-project-'));
dirs.push(directory);
const file = join(directory, 'file');
await writeFile(file, 'x');
for (const options of [{ workingDir: join(directory, 'absent') }, { workingDir: file }, { mode: 'not-a-cli' }]) {
const t = deps([]);
expect(await agentSpawn(t.d, { ...base, ...options })).toBe(EXIT.refused);
expect(t.calls).toEqual([]);
}
});
it('reports the created session ID when startup fails instead of retrying or silently leaking it', async () => {
const t = deps([
ok({ session: { id: OTHER } }),
{ status: 400, text: '', json: { success: false, error: 'cannot start', errorCode: 'OPERATION_FAILED' } },
]);
expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
expect(t.out).toHaveLength(1);
expect(JSON.parse(t.out.join('\n'))).toMatchObject({ sessionId: OTHER, created: true, ready: false });
expect(t.calls).toHaveLength(2);
});
it('does not request startup when creation is refused', async () => {
const t = deps([{ status: 403, text: 'Forbidden' }]);
expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
expect(t.calls).toHaveLength(1);
});
it('retains the exact created ID on an ambiguous startup transport failure without retrying', async () => {
const t = deps([ok({ session: { id: OTHER } })]);
const request = t.d.request;
t.d.request = async (context, options) => {
if (options.path.endsWith('/interactive')) {
t.calls.push(options);
throw new Error('connection lost');
}
return request(context, options);
};
expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
expect(t.out).toHaveLength(1);
expect(JSON.parse(t.out.join('\n'))).toMatchObject({
sessionId: OTHER,
created: true,
startupError: 'connection lost',
});
expect(t.calls).toHaveLength(2);
});
it('treats an empty startup exception as a failure and keeps the created ID', async () => {
const t = deps([ok({ session: { id: OTHER } })]);
const request = t.d.request;
t.d.request = async (context, options) => {
if (options.path.endsWith('/interactive')) throw new Error('');
return request(context, options);
};
expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
expect(t.out).toHaveLength(1);
expect(JSON.parse(t.out[0])).toMatchObject({
sessionId: OTHER,
ready: false,
startupError: 'Startup request failed',
});
});
it('exposes an optional case and working directory in the CLI', () => {
const spawn = registerAgentCommands(new Command()).commands.find((command) => command.name() === 'spawn')!;
expect(spawn.registeredArguments[0].required).toBe(false);
expect(spawn.options.some((option) => option.long === '--working-dir')).toBe(true);
});
it('starts a shell through its registered startMode endpoint', async () => {
const t = deps([ok({ session: { id: OTHER } }), ok({})]);
expect(await agentSpawn(t.d, { ...base, mode: 'shell' })).toBe(EXIT.ok);
expect(t.calls[1].path).toBe(`/api/v1/sessions/${OTHER}/shell`);
});
it('a create answer without session.id is a failure, and nothing is started', async () => {
// `POST /api/sessions` answers `{ session: { id } }`; a bare `{ id }` is not that route.
const t = deps([ok({ id: OTHER })]);
expect(await agentSpawn(t.d, base)).toBe(EXIT.error);
expect(t.calls).toHaveLength(1);
});
it('an empty case name goes to quick-start (which refuses it), never to the caller cwd', async () => {
const t = deps([
{
status: 400,
text: '',
json: { success: false, error: 'Invalid case name format', errorCode: 'INVALID_INPUT' },
},
]);
expect(await agentSpawn(t.d, { ...base, caseName: '' })).toBe(EXIT.error);
expect(t.calls).toHaveLength(1);
expect(t.calls[0]).toMatchObject({ method: 'POST', path: '/api/v1/quick-start', body: { caseName: '' } });
expect(t.calls[0].body).not.toHaveProperty('workingDir');
});
it('an empty case name with --working-dir is refused like any case plus --working-dir', async () => {
const t = deps([]);
expect(await agentSpawn(t.d, { ...base, caseName: '', workingDir: process.cwd() })).toBe(EXIT.refused);
expect(t.calls).toEqual([]);
});
it('an empty or whitespace-only --working-dir is refused before any request, never the caller cwd', async () => {
for (const workingDir of ['', ' ', '\t']) {
const t = deps([]);
expect(await agentSpawn(t.d, { ...base, workingDir })).toBe(EXIT.refused);
expect(t.calls).toEqual([]);
expect(t.out.join('\n')).toContain('--working-dir is empty');
}
});
it('with ready on claude: create, then /interactive, then wait-output on the created id', async () => {
const t = deps([ok({ session: { id: OTHER } }), ok({}), ok({ wait: { matched: true } })]);
expect(await agentSpawn(t.d, { ...base, mode: 'claude', ready: true })).toBe(EXIT.ok);
expect(t.calls.map((c) => `${c.method} ${c.path}`)).toEqual([
'POST /api/v1/sessions',
`POST /api/v1/sessions/${OTHER}/interactive`,
`GET /api/v1/sessions/${OTHER}/wait-output`,
]);
expect(t.calls[2].query).toMatchObject({ match: 'shift+tab', from: 'buffer' });
expect(JSON.parse(t.out[0])).toMatchObject({ sessionId: OTHER, created: true, ready: true });
});
it('without --json a failed start prints the id alone on stdout, the reason on stderr, and exits 1', async () => {
const t = deps([
ok({ session: { id: OTHER } }),
{ status: 400, text: '', json: { success: false, error: 'cannot start', errorCode: 'OPERATION_FAILED' } },
]);
t.d.json = false;
expect(await agentSpawn(t.d, { ...base, ready: true })).toBe(EXIT.error);
expect(t.out).toEqual([OTHER]);
expect(t.err).toHaveLength(1);
expect(t.err[0]).toContain(`Session ${OTHER} was created but startup failed`);
expect(t.err[0]).toContain('cannot start');
expect(t.calls).toHaveLength(2); // no readiness wait after a failed start
});
});
describe('agent spawn through the real argument parser and transport', () => {
async function runSpawn(args: string[]) {
const seen: Array<{ method?: string; url?: string; body: unknown }> = [];
const server = http.createServer((req, res) => {
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', () => {
const raw = Buffer.concat(chunks).toString();
seen.push({ method: req.method, url: req.url, body: raw ? JSON.parse(raw) : undefined });
res
.writeHead(400, { 'Content-Type': 'application/json' })
.end(JSON.stringify({ success: false, error: 'refused', errorCode: 'INVALID_INPUT' }));
});
});
await new Promise<void>((done) => server.listen(0, '127.0.0.1', done));
const { port } = server.address() as { port: number };
const saved = { ...process.env };
const savedExit = process.exitCode;
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
const error = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
Object.assign(process.env, {
CODEMAN_MUX: '1',
CODEMAN_API_URL: `http://127.0.0.1:${port}`,
CODEMAN_SESSION_ID: SELF,
});
delete process.env.CODEMAN_PASSWORD;
const program = new Command();
program.exitOverride();
registerAgentCommands(program);
await program.parseAsync(['node', 'codeman', 'agent', 'spawn', ...args, '--no-ready', '--json']);
return { seen, exitCode: process.exitCode };
} finally {
process.exitCode = savedExit;
for (const key of ['CODEMAN_MUX', 'CODEMAN_API_URL', 'CODEMAN_SESSION_ID', 'CODEMAN_PASSWORD']) {
if (saved[key] === undefined) delete process.env[key];
else process.env[key] = saved[key];
}
log.mockRestore();
error.mockRestore();
await new Promise<void>((done) => server.close(() => done()));
}
}
it('`spawn ""` (as `spawn "$CASE"` with CASE unset) reaches quick-start with caseName "" and exits 1', async () => {
const { seen, exitCode } = await runSpawn(['']);
expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/quick-start', body: { caseName: '' } });
expect(exitCode).toBe(EXIT.error);
});
it('`spawn --working-dir ""` (as `--working-dir "$WT"` with WT unset) sends nothing and exits 4', async () => {
for (const value of ['', ' ']) {
const { seen, exitCode } = await runSpawn(['--working-dir', value]);
expect(seen).toEqual([]);
expect(exitCode).toBe(EXIT.refused);
}
});
it('`spawn` with no case argument creates in the caller cwd', async () => {
const { seen, exitCode } = await runSpawn([]);
expect(seen).toHaveLength(1);
expect(seen[0]).toMatchObject({ method: 'POST', url: '/api/v1/sessions', body: { workingDir: process.cwd() } });
expect(exitCode).toBe(EXIT.error);
});
it('bare `spawn` from a HOME cwd sends nothing and exits 4; `--working-dir <HOME>` is sent', async () => {
const home = await mkdtemp(join(tmpdir(), 'cod549-home-'));
dirs.push(home);
vi.stubEnv('HOME', home);
const cwd = vi.spyOn(process, 'cwd').mockReturnValue(home);
try {
const bare = await runSpawn([]);
expect(bare.seen).toEqual([]);
expect(bare.exitCode).toBe(EXIT.refused);
const explicit = await runSpawn(['--working-dir', home]);
expect(explicit.seen).toHaveLength(1);
expect(explicit.seen[0]).toMatchObject({ url: '/api/v1/sessions', body: { workingDir: home } });
} finally {
cwd.mockRestore();
vi.unstubAllEnvs();
}
});
});
describe('agent spawn never treats an implicit HOME cwd as a project', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.restoreAllMocks();
});
async function homeDir(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'cod549-home-'));
dirs.push(directory);
return directory;
}
it('refuses a cwd that is HOME with exit 4 before any request and names the explicit flag', async () => {
const home = await homeDir();
vi.stubEnv('HOME', home);
vi.spyOn(process, 'cwd').mockReturnValue(home);
const t = deps([]);
expect(await agentSpawn(t.d, { ...base, mode: 'claude' })).toBe(EXIT.refused);
expect(t.calls).toEqual([]);
expect(JSON.parse(t.out[0]).error).toContain('pass --working-dir ~');
});
it('compares realpaths, so a symlinked HOME still matches the cwd it points at', async () => {
const real = await homeDir();
const link = join(await homeDir(), 'home-link');
await symlink(real, link);
vi.stubEnv('HOME', link);
vi.spyOn(process, 'cwd').mockReturnValue(real);
const t = deps([]);
expect(await agentSpawn(t.d, base)).toBe(EXIT.refused);
expect(t.calls).toEqual([]);
});
it('allows an explicit --working-dir that is HOME, as POST /api/sessions does', async () => {
const home = await homeDir();
vi.stubEnv('HOME', home);
vi.spyOn(process, 'cwd').mockReturnValue(home);
const t = deps([ok({ session: { id: OTHER } }), ok({})]);
expect(await agentSpawn(t.d, { ...base, workingDir: home })).toBe(EXIT.ok);
expect(t.calls[0].body).toMatchObject({ workingDir: home });
});
it('allows a cwd below HOME', async () => {
const home = await homeDir();
const project = join(home, 'src', 'app');
await mkdir(project, { recursive: true });
vi.stubEnv('HOME', home);
vi.spyOn(process, 'cwd').mockReturnValue(project);
const t = deps([ok({ session: { id: OTHER } }), ok({})]);
expect(await agentSpawn(t.d, base)).toBe(EXIT.ok);
expect(t.calls[0].body).toMatchObject({ workingDir: project });
});
it('never throws when HOME cannot be resolved, and compares the resolved paths instead', async () => {
const missing = join(await homeDir(), 'absent');
vi.stubEnv('HOME', missing);
await expect(isHomeDirectory(process.cwd())).resolves.toBe(false);
await expect(isHomeDirectory(missing)).resolves.toBe(true);
vi.stubEnv('HOME', '');
await expect(isHomeDirectory(process.cwd())).resolves.toBe(false);
});
});
describe('the directory-spawn --json shape is documented the same way everywhere', () => {
// The wiki wraps lines, so whitespace is normalized before comparing.
const clause =
'`{ sessionId, workingDir, created: true }`, plus `ready` when a readiness wait ran, `readinessError` when that call failed, and `startupError` when the start failed';
for (const file of ['README.md', 'docs/api-reference.md', 'docs/wiki/Driving-Codeman-From-An-Agent.md']) {
it(file, () => {
expect(readFileSync(new URL(`../${file}`, import.meta.url), 'utf8').replace(/\s+/g, ' ')).toContain(clause);
});
}
});
+70
View File
@@ -30,6 +30,7 @@ import {
buildSendBody,
deleteRefusal,
describeFailure,
describeRequestError,
httpRequest,
inboxCell,
inputRefusal,
@@ -548,6 +549,75 @@ describe('agent spawn', () => {
expect(deps.calls).toHaveLength(1);
expect(deps.err.join('')).toMatch(/SESSION_BUSY/);
});
it.each([false, true])('preserves the created id when readiness throws (json=%s)', async (json) => {
const deps = fakeDeps((options) => {
if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER, caseName: 'c' });
throw new Error('readiness connection reset');
}, json);
const exit = await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 }).catch(
() => undefined
);
if (json) {
expect(deps.out).toHaveLength(1);
expect(JSON.parse(deps.out[0])).toEqual({
sessionId: OTHER,
caseName: 'c',
readinessError: 'readiness connection reset',
});
expect(deps.err).toEqual([]);
} else {
expect(deps.out).toEqual([OTHER]);
expect(deps.err.join('')).toContain('readiness check failed: readiness connection reset');
expect(deps.err.join('')).not.toMatch(/composer not seen|startup dialog/);
}
expect(exit).toBe(EXIT.error);
expect(deps.calls.map(({ method, path }) => ({ method, path }))).toEqual([
{ method: 'POST', path: '/api/v1/quick-start' },
{ method: 'GET', path: `/api/v1/sessions/${OTHER}/wait-output` },
]);
});
it.each([
{ error: new Error(''), kind: 'Error' },
{ error: '', kind: 'string' },
])('reports a readiness failure when the thrown $kind has no message', async ({ error }) => {
const deps = fakeDeps((options) => {
if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER });
throw error;
}, true);
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
expect(deps.out).toHaveLength(1);
expect(JSON.parse(deps.out[0])).toEqual({ sessionId: OTHER, readinessError: 'request failed without a message' });
expect(deps.calls).toHaveLength(2);
});
it('keeps the error code when a refused connection rejects with an empty AggregateError', async () => {
// Node 22 on a host resolving to ::1 and 127.0.0.1: message '', code ECONNREFUSED.
const refused = Object.assign(new AggregateError([new Error('connect ECONNREFUSED ::1:3000')], ''), {
code: 'ECONNREFUSED',
});
const deps = fakeDeps((options) => {
if (options.path === '/api/v1/quick-start') return ok({ sessionId: OTHER });
throw refused;
}, true);
expect(await agentSpawn(deps, { caseName: 'c', mode: 'claude', ready: true, timeoutMs: 1000 })).toBe(EXIT.error);
expect(JSON.parse(deps.out[0])).toEqual({ sessionId: OTHER, readinessError: 'ECONNREFUSED' });
});
});
describe('describeRequestError', () => {
it('prefers the message, then the code, then a generic line', () => {
expect(describeRequestError(new Error('socket hang up'))).toBe('socket hang up');
expect(describeRequestError(Object.assign(new AggregateError([], ''), { code: 'ECONNREFUSED' }))).toBe(
'ECONNREFUSED'
);
expect(describeRequestError(new Error(''))).toBe('request failed without a message');
expect(describeRequestError('')).toBe('request failed without a message');
});
});
describe('session id prefixes', () => {
+26
View File
@@ -208,3 +208,29 @@ describe('help text', () => {
}
});
});
describe('unauthenticated tunnel launch option', () => {
it.each(['web', 'service install'])('registers --allow-unauthenticated-tunnel on %s', (path) => {
const command = path === 'web' ? find(program, 'web')! : find(find(program, 'service')!, 'install')!;
const options = command.options.filter((option) => option.long === '--allow-unauthenticated-tunnel');
expect(
options.map((option) => ({
flags: option.flags,
attribute: option.attributeName(),
required: option.required,
optional: option.optional,
}))
).toEqual([
{
flags: '--allow-unauthenticated-tunnel',
attribute: 'allowUnauthenticatedTunnel',
required: false,
optional: false,
},
]);
});
it('documents --allow-unauthenticated-tunnel in web help', () => {
expect(find(program, 'web')!.helpInformation()).toContain('--allow-unauthenticated-tunnel');
});
});
+141
View File
@@ -0,0 +1,141 @@
// @vitest-environment node
/**
* @fileoverview `codeman notify`: what it sends, what it prints and the exit code, driven with a
* recording fake transport plus the real `postJson` against a local server. Port: ephemeral 127.0.0.1.
*/
import { createServer, type Server } from 'node:http';
import type { AddressInfo } from 'node:net';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { postJson, runNotify, type JsonPost } from '../src/cli-notify.js';
const ENV = { CODEMAN_SESSION_ID: 'abc12345-full-id', CODEMAN_API_URL: 'http://127.0.0.1:3000' };
function fake(status: number, error?: string, delivery?: { ok: boolean; error?: string }) {
const post = vi.fn<JsonPost>(async () => ({ status, error, delivery }));
return post;
}
describe('runNotify', () => {
it('refuses outside a Codeman session, before any request', async () => {
const post = fake(200);
const r = await runNotify({ message: 'hi' }, {}, '/work', post);
expect(r.code).toBe(1);
expect(r.err).toMatch(/inside a Codeman session/);
expect(post).not.toHaveBeenCalled();
});
it('posts the message to this session with the session id URL-encoded', async () => {
const post = fake(200);
const r = await runNotify({ message: 'press the reset button' }, ENV, '/work', post);
expect(r).toEqual({ code: 0, out: 'Posted to Discord' });
expect(post).toHaveBeenCalledWith('http://127.0.0.1:3000', '/api/sessions/abc12345-full-id/discord-notify', {
message: 'press the reset button',
});
});
it('adds urgent, and turns a relative --file into an absolute path under the working folder', async () => {
const post = fake(200);
await runNotify({ message: 'log', urgent: true, file: 'out/run.log' }, ENV, '/work', post);
expect(post.mock.calls[0][2]).toEqual({ message: 'log', urgent: true, file: '/work/out/run.log' });
await runNotify({ message: 'log', file: '/abs/run.log' }, ENV, '/work', post);
expect(post.mock.calls[1][2]).toEqual({ message: 'log', file: '/abs/run.log' });
});
it('lets --session and --url override the environment', async () => {
const post = fake(200);
await runNotify({ message: 'x', session: 'other', url: 'http://h:1' }, ENV, '/work', post);
expect(post.mock.calls[0].slice(0, 2)).toEqual(['http://h:1', '/api/sessions/other/discord-notify']);
});
it('exits 1 when Codeman answered 200 but Discord refused the post, so a blocked agent knows nobody was told', async () => {
const r = await runNotify(
{ message: 'x' },
ENV,
'/w',
fake(200, undefined, { ok: false, error: 'The bot token was refused' })
);
expect(r).toEqual({ code: 1, err: 'Could not post to Discord: The bot token was refused' });
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(200, undefined, { ok: true }))).code).toBe(0);
});
it('postJson gives up on a server that never answers, with status 0', async () => {
const server = createServer(() => undefined);
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
try {
const { port } = server.address() as AddressInfo;
const r = await postJson(`http://127.0.0.1:${port}`, '/x', {}, 100);
expect(r.status).toBe(0);
} finally {
server.closeAllConnections();
server.close();
}
});
it('postJson settles when the server sends headers and then stalls the body', async () => {
const server = createServer((_req, res) => {
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '500' });
res.write('{"success":');
});
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
try {
const { port } = server.address() as AddressInfo;
expect((await postJson(`http://127.0.0.1:${port}`, '/x', {}, 100)).status).toBe(0);
} finally {
server.closeAllConnections();
server.close();
}
});
it('treats a disabled bridge (409) as success that says so', async () => {
const r = await runNotify({ message: 'x' }, ENV, '/work', fake(409));
expect(r.code).toBe(0);
expect(r.out).toMatch(/bridge is off/);
});
it('fails with the server error, or the status, or a plain message when nothing answered', async () => {
expect(
(await runNotify({ message: 'x' }, ENV, '/w', fake(400, 'The file must be inside the session folder'))).err
).toBe('Could not post to Discord: The file must be inside the session folder');
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(429))).err).toMatch(/Too many messages/);
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(500))).err).toBe('Could not post to Discord (HTTP 500)');
expect((await runNotify({ message: 'x' }, ENV, '/w', fake(0))).err).toBe('Could not post to Discord');
});
});
describe('postJson (real transport)', () => {
let server: Server;
afterEach(() => new Promise<void>((resolve) => server.close(() => resolve())));
async function serve(status: number, reply: unknown) {
const seen: { method?: string; url?: string; body: string; type?: string } = { body: '' };
server = createServer((req, res) => {
req.on('data', (c) => (seen.body += c));
req.on('end', () => {
seen.method = req.method;
seen.url = req.url;
seen.type = req.headers['content-type'];
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(reply));
});
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
return { seen, url: `http://127.0.0.1:${(server.address() as AddressInfo).port}` };
}
it('sends a JSON POST and reports the status and the server error text', async () => {
const { seen, url } = await serve(400, { success: false, error: 'nope' });
expect(await postJson(url, '/api/sessions/s1/discord-notify', { message: 'hi' })).toEqual({
status: 400,
error: 'nope',
});
expect(seen).toMatchObject({ method: 'POST', url: '/api/sessions/s1/discord-notify', type: 'application/json' });
expect(JSON.parse(seen.body)).toEqual({ message: 'hi' });
});
it('answers status 0 when nothing is listening', async () => {
const { url } = await serve(200, {});
await new Promise<void>((resolve) => server.close(() => resolve()));
expect((await postJson(url, '/x', {})).status).toBe(0);
server = createServer().listen(0); // so afterEach has something to close
});
});
+146
View File
@@ -55,6 +55,65 @@ describe('customModelInjection.launchModel', () => {
});
});
/** Mutates the shipped deepseek configDir recipe (the one real-home-linking entry) and expects rejection. */
function expectRejectedOnDeepSeek(mutate: (recipe: Record<string, unknown>) => void, because: string): void {
const entry = baseEntry('deepseek');
const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>;
mutate(recipe);
const result = CliEntrySchema.safeParse(entry);
expect(result.success, `expected rejection: ${because}`).toBe(false);
}
describe('configDir real-home linking (linkFromRealHome / realHomeFallback)', () => {
it('rejects a realHomeFallback that is not a single relative path segment', () => {
// The consumer joins it under the user's home, so any of these escapes that home:
// and bare `.` IS that home: linking through it would expose the whole tree
// unshielded instead of just the declared segments.
for (const bad of ['../x', '.dsh/evil', '/.dsh', '..', '.']) {
expectRejectedOnDeepSeek((r) => {
r.realHomeFallback = bad;
}, `realHomeFallback ${JSON.stringify(bad)}`);
}
});
it('accepts a shipped-style dot-prefixed segment', () => {
const entry = baseEntry('deepseek');
expect(CliEntrySchema.safeParse(entry).success).toBe(true); // ships as `.dsh`
const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>;
recipe.realHomeFallback = '.dsh-relocated';
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
});
it('requires realHomeFallback whenever linkFromRealHome is declared', () => {
expectRejectedOnDeepSeek((r) => {
delete r.realHomeFallback;
}, 'without it the consumer would have to hardcode a per-CLI home default');
});
it('rejects a linked entry that collides with fileName', () => {
// A symlink at the generated file's own name would write THROUGH into the user's real config.
expectRejectedOnDeepSeek((r) => {
r.linkFromRealHome = ['cordis.patch.yml', 'profiles'];
}, 'linkFromRealHome containing fileName');
});
it('requires linkFromRealHomeDirs to be a subset of linkFromRealHome', () => {
// types.ts documents the rule; a name absent from the linked list names nothing,
// so the create-missing-directory behaviour would silently never apply to it.
expectRejectedOnDeepSeek((r) => {
r.linkFromRealHome = ['profiles', 'settings.yaml'];
r.linkFromRealHomeDirs = ['profiles', 'sessions'];
}, 'linkFromRealHomeDirs naming an unlinked entry');
// The shipped recipe is a valid subset (deepseek: profiles and sessions are dirs
// of the three linked entries) and keeps loading.
const entry = baseEntry('deepseek');
const recipe = (entry.capabilities as Record<string, unknown>).customModelInjection as Record<string, unknown>;
expect(recipe.linkFromRealHome).toEqual(['profiles', 'sessions', 'settings.yaml']);
expect(recipe.linkFromRealHomeDirs).toEqual(['profiles', 'sessions']);
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
});
});
describe('the shipped catalog', () => {
it('validates every stock entry exactly as shipped', () => {
// If this fails, the catalog cannot load at all — every other test here is downstream.
@@ -184,6 +243,69 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
}
});
it('holds the optional composerFooterLine to the same guard', () => {
// Run over the rows under the composer each time guarded input reads the pane.
for (const composerFooterLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
composerFooterLine,
};
}, 'guarded input tests it against pane rows a clis.json override can supply');
}
});
it('accepts every shipped composerFooterLine', () => {
for (const entry of STOCK_CLIS) {
const src = entry.capabilities.workDetect?.composerFooterLine;
if (!src) continue;
expect(compileVersionRegex(src), `${entry.id} declares a composerFooterLine the guard refuses`).not.toBeNull();
}
});
it('holds the optional composerPopupLine to the same guard, and accepts the shipped one', () => {
// Run over the rows just above the composer each time guarded input reads the pane.
for (const composerPopupLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
composerPopupLine,
};
}, 'guarded input tests it against pane rows a clis.json override can supply');
}
for (const entry of STOCK_CLIS) {
const src = entry.capabilities.workDetect?.composerPopupLine;
if (src)
expect(compileVersionRegex(src), `${entry.id} declares a composerPopupLine the guard refuses`).not.toBeNull();
}
});
it('takes only short runs of ASCII punctuation as composer trigger characters', () => {
for (const field of ['composerCommandPrefixes', 'composerCompletionSigils']) {
for (const value of ['', 'a', '/a', '/ ', '/\t', '\u00a7', '!!!!!!!!!']) {
expectRejected(
(e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
[field]: value,
};
},
`${field} ${JSON.stringify(value)} would refuse letters, spaces or an unbounded set`
);
}
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
[field]: '/!@$',
};
expect(CliEntrySchema.safeParse(entry).success, field).toBe(true);
}
});
it('holds the optional blockedLine to the same guard', () => {
// Run over the foot of a capture at every settle and on a timer while a dialog is
// up, and a clis.json override can set it.
@@ -485,6 +607,30 @@ describe('capability shapes', () => {
}, 'transcriptPageKeys is a boolean');
});
it("takes keyboardHold as an optional 'inline', absent meaning the keyboard resizes", () => {
const inline = baseEntry();
(inline.capabilities as Record<string, unknown>).keyboardHold = 'inline';
expect(CliEntrySchema.safeParse(inline).success).toBe(true);
const absent = baseEntry();
delete (absent.capabilities as Record<string, unknown>).keyboardHold;
expect(CliEntrySchema.safeParse(absent).success).toBe(true);
for (const value of [true, 'fullscreen', '']) {
expectRejected(
(e) => {
(e.capabilities as Record<string, unknown>).keyboardHold = value;
},
`keyboardHold=${JSON.stringify(value)} names no renderer kind`
);
}
});
it('declares keyboardHold for exactly the inline renderer measured losing lines on a height change', () => {
// Claude Code 2.1.306's inline renderer was measured dropping transcript lines from
// tmux on keyboard-driven height changes; every other stock entry resizes.
const holding = STOCK_CLIS.filter((e) => e.capabilities.keyboardHold === 'inline').map((e) => e.id as string);
expect(holding).toEqual(['claude']);
});
it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => {
// Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot
// forward without a version gate. Unmeasured entries stay 'never'.
+9
View File
@@ -171,6 +171,15 @@ describe('codex', () => {
expect(cx({ model: 'gpt-5', resumeSessionId: 'roll_42' })).toBe('codex --model gpt-5 resume roll_42');
});
it('carries an Ollama-style `qwen3:32b` model id, and still drops a leading-flag shape', () => {
// `model-plain-colon`: the custom-model feature forces the selected id onto this param,
// and ids like `qwen3:32b` carry a colon. The leading-alphanumeric rule is what keeps a
// flag from riding the flag, so `:x` and `-x` must still drop out.
expect(cx({ model: 'qwen3:32b' })).toBe('codex --model qwen3:32b');
expect(cx({ model: ':leading' })).toBe('codex');
expect(cx({ model: '-yolo' })).toBe('codex');
});
it('sends reasoning effort as one model_reasoning_effort config value, for every level', () => {
for (const level of CODEX_REASONING_EFFORTS) {
expect(cx({ reasoningEffort: level })).toBe(`codex --config model_reasoning_effort=${level}`);
+102
View File
@@ -0,0 +1,102 @@
/**
* @fileoverview Issue #706 tunnel flag plumbing through the real command parser into daemon, foreground and service launches. Launch boundaries are mocked, so the CLI's valid nonzero port never binds a socket or creates a service unit.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const launches = vi.hoisted(() => ({
startDaemon: vi.fn(),
startWebServer: vi.fn(),
installService: vi.fn(),
stop: vi.fn(),
}));
vi.mock('../src/daemon-control.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../src/daemon-control.js')>()),
startDaemon: launches.startDaemon,
}));
vi.mock('../src/service-installer.js', async (importOriginal) => ({
...(await importOriginal<typeof import('../src/service-installer.js')>()),
installService: launches.installService,
}));
vi.mock('../src/web/server.js', () => ({ startWebServer: launches.startWebServer }));
const SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'] as const;
let originalListeners: Map<NodeJS.Signals, ReturnType<typeof process.listeners>>;
let exitSpy: ReturnType<typeof vi.spyOn<typeof process, 'exit'>>;
beforeEach(() => {
// Commander retains parsed option values; use a fresh real program for each invocation.
vi.resetModules();
vi.clearAllMocks();
originalListeners = new Map(SIGNALS.map((signal) => [signal, process.listeners(signal)]));
exitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
throw new Error(`Unexpected process.exit(${code})`);
});
launches.startDaemon.mockResolvedValue({ ok: true, url: 'http://127.0.0.1:8080', pid: 123, logPath: 'web.log' });
launches.stop.mockResolvedValue(undefined);
launches.startWebServer.mockResolvedValue({ stop: launches.stop });
launches.installService.mockResolvedValue({ ok: true, message: 'Installed', unitPath: 'codeman-web.service' });
});
afterEach(() => {
for (const signal of SIGNALS) {
for (const listener of process.listeners(signal)) {
if (!originalListeners.get(signal)!.includes(listener)) process.removeListener(signal, listener);
}
}
vi.restoreAllMocks();
});
const expectedOptions = (allowUnauthenticatedTunnel: boolean) => ({
host: '127.0.0.1',
port: 8080,
https: false,
basePath: '',
titleHostname: undefined,
allowUnauthenticatedNetwork: false,
allowUnauthenticatedTunnel,
multiuser: false,
});
async function parse(args: string[], enabled: boolean) {
const { program } = await import('../src/cli.js');
await program.parseAsync([
'node',
'codeman',
...args,
'--port',
'8080',
'--host',
'127.0.0.1',
'--base-url',
'/',
...(enabled ? ['--allow-unauthenticated-tunnel'] : []),
]);
expect(exitSpy).not.toHaveBeenCalled();
}
describe('tunnel flag CLI plumbing', () => {
it.each([true, false])('web -d passes allowUnauthenticatedTunnel=%s to startDaemon', async (enabled) => {
await parse(['web', '-d'], enabled);
expect(launches.startDaemon.mock.calls).toEqual([[expectedOptions(enabled)]]);
expect(launches.startWebServer.mock.calls).toEqual([]);
expect(launches.installService.mock.calls).toEqual([]);
});
it.each([true, false])('foreground web passes allowUnauthenticatedTunnel=%s as argument eight', async (enabled) => {
await parse(['web'], enabled);
expect(launches.startWebServer.mock.calls).toEqual([
[8080, false, false, '127.0.0.1', undefined, false, '', enabled],
]);
expect(launches.startDaemon.mock.calls).toEqual([]);
expect(launches.installService.mock.calls).toEqual([]);
expect(launches.stop.mock.calls).toEqual([]);
});
it.each([true, false])('service install passes allowUnauthenticatedTunnel=%s to installService', async (enabled) => {
await parse(['service', 'install'], enabled);
expect(launches.installService.mock.calls).toEqual([[expectedOptions(enabled)]]);
expect(launches.startDaemon.mock.calls).toEqual([]);
expect(launches.startWebServer.mock.calls).toEqual([]);
});
});
+112
View File
@@ -0,0 +1,112 @@
/**
* @fileoverview The Codex rollout shape helpers shared by the Past Sessions
* scanner (codex-transcript.ts) and the offline history reader
* (local-history.ts): store location, message shapes and the injected-context
* rule. Both readers' own tests cover their use of these; this pins the shared
* contract itself.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { join } from 'node:path';
import {
codexContentText,
codexHomeDir,
codexMessageFromLine,
codexSessionsRoot,
isCodexInjectedContext,
} from '../src/codex-rollout-format.js';
const savedHome = process.env.CODEX_HOME;
afterEach(() => {
if (savedHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = savedHome;
});
describe('codex rollout format', () => {
it('resolves the store from CODEX_HOME, else ~/.codex', () => {
process.env.CODEX_HOME = '/custom/codex';
expect(codexHomeDir()).toBe('/custom/codex');
expect(codexSessionsRoot()).toBe(join('/custom/codex', 'sessions'));
process.env.CODEX_HOME = '';
expect(codexSessionsRoot()).toBe(join(process.env.HOME!, '.codex', 'sessions'));
});
it('reads all three message shapes with their role, shape and phase', () => {
expect(codexMessageFromLine({ type: 'event_msg', payload: { type: 'user_message', message: 'hi' } })).toEqual({
role: 'user',
text: 'hi',
shape: 'event',
phase: undefined,
injectionProne: false,
});
expect(
codexMessageFromLine({
type: 'event_msg',
payload: { type: 'agent_message', message: 'ok', phase: 'commentary' },
})
).toMatchObject({ role: 'assistant', shape: 'event', phase: 'commentary', injectionProne: false });
expect(
codexMessageFromLine({
type: 'event_msg',
payload: {
type: 'item_completed',
item: { type: 'AgentMessage', phase: 'final_answer', content: [{ type: 'Text', text: 'done' }] },
},
})
).toMatchObject({ role: 'assistant', text: 'done', shape: 'item', phase: 'final_answer' });
expect(
codexMessageFromLine({
type: 'response_item',
payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: 'typed' }] },
})
).toMatchObject({ role: 'user', text: 'typed', shape: 'response', injectionProne: true });
expect(
codexMessageFromLine({
type: 'response_item',
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'a' }] },
})
).toMatchObject({ role: 'assistant', shape: 'response', injectionProne: false });
});
it('ignores every other line, empty text and malformed input', () => {
for (const value of [
null,
42,
'text',
[],
{ type: 'session_meta', payload: { id: 'x' } },
{ type: 'response_item', payload: { type: 'message', role: 'developer', content: 'policy' } },
{ type: 'response_item', payload: { type: 'reasoning', summary: [] } },
{ type: 'event_msg', payload: { type: 'user_message', message: '' } },
{ type: 'event_msg', payload: { type: 'item_completed', item: { type: 'CommandExecution' } } },
])
expect(codexMessageFromLine(value)).toBeNull();
});
it('flattens only blocks that carry text', () => {
expect(codexContentText('plain')).toBe('plain');
expect(
codexContentText([
{ type: 'input_text', text: 'a' },
{ type: 'input_image', image_url: 'x' },
{ text: 'b' },
null,
])
).toBe('a\nb');
expect(codexContentText(undefined)).toBe('');
});
it('treats a heading or a tag as injected context, wherever the wrapper name is new', () => {
for (const text of [
'# AGENTS.md instructions for /repo',
'<environment_context>',
'<recommended_plugins>',
'<subagent_notification>',
'<codex_internal_context source="goal">',
'<image name=[Image #1]>',
' <turn_aborted>',
])
expect(isCodexInjectedContext(text)).toBe(true);
for (const text of ['fix the bug', 'You are a reviewer', '$skill run', 'a <b> tag later'])
expect(isCodexInjectedContext(text)).toBe(false);
});
});
@@ -0,0 +1,86 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser } from 'playwright';
import { WebServer } from '../src/web/server.js';
describe('Command-K agent arrow keys in Chromium', () => {
let server: WebServer;
let browser: Browser;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
}, 60000);
afterAll(async () => {
await browser?.close();
await server?.stop();
});
it('cycles both directions at query boundaries while preserving text editing and selection', async () => {
const page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
const errors: string[] = [];
page.on('pageerror', (e) => errors.push(e.message));
try {
await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.openCommandPalette);
await page.waitForTimeout(3500);
await page.evaluate(() => {
const a = (window as any).app;
a.cases = [{ name: 'buildmesh', path: '/fixture/buildmesh' }];
a.sessions = new Map();
a.sessionOrder = [];
a._runMode = 'codex';
(window as any).__codemanCliCatalog = [
{ id: 'claude', label: 'Claude', enabled: true, kind: 'agent' },
{ id: 'codex', label: 'Codex', enabled: true, kind: 'agent' },
{ id: 'shell', label: 'Shell', enabled: true, kind: 'shell' },
];
a._runModeAvailableForCase = () => true;
a._sendInputEphemeral = () => {
throw new Error('Palette leaked terminal input');
};
a.run = () => {
throw new Error('Arrow key launched a session');
};
});
await page.keyboard.press('Control+k');
await page.locator('#commandPaletteModal.active').waitFor();
const input = page.locator('#commandPaletteSearch');
await input.fill('buildmesh');
const label = page.locator('.command-palette-agent-label');
expect(await label.innerText()).toBe('Codex');
await page.keyboard.press('ArrowLeft');
expect(await label.innerText()).toBe('Claude');
await page.keyboard.press('ArrowRight');
expect(await label.innerText()).toBe('Codex');
expect(await input.inputValue()).toBe('buildmesh');
expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(9);
await page.keyboard.press('Home');
await page.keyboard.press('ArrowRight');
expect(await label.innerText()).toBe('Shell');
await page.keyboard.press('ArrowLeft');
expect(await label.innerText()).toBe('Codex');
await input.evaluate((e: HTMLInputElement) => e.setSelectionRange(4, 4));
await page.keyboard.press('ArrowLeft');
expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(3);
await page.keyboard.press('ArrowRight');
expect(await input.evaluate((e: HTMLInputElement) => e.selectionStart)).toBe(4);
await page.keyboard.press('Shift+End');
await page.keyboard.press('ArrowLeft');
expect(await label.innerText()).toBe('Codex');
await page.keyboard.press('ArrowDown');
expect(
await page.evaluate(
() => (window as any).app.commandPaletteItems[(window as any).app.commandPaletteActiveIndex].type
)
).toBe('browse-sessions');
await page.keyboard.press('End');
await page.keyboard.press('ArrowLeft');
expect(await label.innerText()).toBe('Codex');
await page.keyboard.press('Escape');
expect(await page.locator('#commandPaletteModal').evaluate((e) => e.classList.contains('active'))).toBe(false);
expect(errors).toEqual([]);
} finally {
await page.close();
}
});
});
+31 -2
View File
@@ -437,13 +437,28 @@ describe('Command-K new-session agent picker', () => {
expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'opencode' });
});
it('leaves Left/Right to the caret when they would move it', async () => {
it('cycles both ways at the end of a typed case query without moving its caret', async () => {
const { app, listeners, elements } = loadAgentPalette();
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
const left = arrow('ArrowLeft', 7, 'nomatch');
await listeners['search:keydown'](left);
expect(left.preventDefault).toHaveBeenCalled();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('opencode');
const right = arrow('ArrowRight', 7, 'nomatch');
await listeners['search:keydown'](right);
expect(right.preventDefault).toHaveBeenCalled();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
});
it('leaves Left/Right to the caret inside the query', async () => {
const { app, listeners, elements } = loadAgentPalette();
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
const left = arrow('ArrowLeft', 7, 'nomatch');
const left = arrow('ArrowLeft', 3, 'nomatch');
const right = arrow('ArrowRight', 3, 'nomatch');
await listeners['search:keydown'](left);
await listeners['search:keydown'](right);
@@ -453,6 +468,20 @@ describe('Command-K new-session agent picker', () => {
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
});
it.each(['altKey', 'ctrlKey', 'metaKey', 'shiftKey'])(
'preserves %s arrow editing at query boundaries',
async (modifier) => {
const { app, listeners, elements } = loadAgentPalette();
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
const event = { ...arrow('ArrowLeft', 7, 'nomatch'), [modifier]: true };
await listeners['search:keydown'](event);
expect(event.preventDefault).not.toHaveBeenCalled();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].mode).toBe('claude');
}
);
it('does not cycle while a session row is highlighted', async () => {
const { app, listeners } = loadAgentPalette();
app.openCommandPalette();
@@ -481,6 +481,52 @@ describe('refreshAllCustomModelHosts: parallel-slot enrichment (llama.cpp splits
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toEqual({ a: 4 });
});
it('a round where only the listed figure answers resets the slot count a loaded round learned', async () => {
// The false-warning scenario, measured on a real llama-swap: round 1 has the model
// loaded under `--ctx-size 262144 --parallel 4`: a TOTAL window shared across slots,
// correctly stored with its count. Round 2 finds it unloaded and takes the LISTED
// `context_length`, which llama-swap documents as the PER-SLOT window. If the stored 4
// survived, the floor check would divide the per-slot figure a second time
// (65536/4 = 16384) and warn about a model that serves 65536 to every request.
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'loaded' } }] }), { status: 200 });
}
if (url.pathname === '/running') {
return new Response(
JSON.stringify({
running: [{ model: 'm', state: 'ready', cmd: 'llama-server -m /models/m.gguf --ctx-size 262144 --parallel 4' }],
}),
{ status: 200 }
);
}
throw new Error(`unexpected request: ${url.href}`); // the command answers both questions
});
await refreshAllCustomModelHosts();
let [round1] = await readCustomModelHosts(dir);
expect(round1.modelContextLengths).toEqual({ m: 262144 });
expect(round1.modelSlotCounts).toEqual({ m: 4 });
// Round 2: unloaded, no /running or /props probe in order, listing its per-slot figure.
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'unloaded' }, context_length: 65536 }] }), {
status: 200,
})
);
await refreshAllCustomModelHosts();
const [round2] = await readCustomModelHosts(dir);
expect(round2.modelContextLengths).toEqual({ m: 65536 });
// 1 slot, not the stale 4: 65536 per slot with 1 recorded slot is the honest pair, so
// the floor check reads 65536/request: exactly what the server gives a single request.
expect(round2.modelSlotCounts).toEqual({ m: 1 });
});
});
describe('refreshAllCustomModelHosts: model-size enrichment (parsed from /v1/models description)', () => {
+10 -6
View File
@@ -53,9 +53,12 @@ describe('applyCustomModelInjection: context length', () => {
expect(applied?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined();
});
it('deepseek: has no contextLengthVar declared, so a passed-in length is a no-op', () => {
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', 'sess-ctx-3', 16384);
expect(Object.keys(applied?.envOverrides ?? {}).sort()).toEqual(['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL']);
it('deepseek: passes the known context through its isolated model catalogue', () => {
const sessionId = 'sess-deepseek-context';
sessionsToClean.push(sessionId);
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId, 32768);
expect(Object.keys(applied?.envOverrides ?? {}).sort()).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY', 'DSH_HOME']);
expect(readFileSync(join(applied!.configDir!, 'cordis.patch.yml'), 'utf8')).toContain('"contextWindow": 32768');
});
});
@@ -103,11 +106,12 @@ describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => {
expect(applied?.envOverrides.HOME).toBe(customModelConfigDir(sessionId));
});
it('deepseek: no configDirVar declared, so no config dir is created at all', () => {
it('deepseek: creates an isolated profile patch', () => {
const sessionId = 'sess-cfgdir-deepseek';
sessionsToClean.push(sessionId);
const applied = applyCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3', sessionId);
expect(applied?.configDir).toBeUndefined();
expect(existsSync(customModelConfigDir(sessionId))).toBe(false);
expect(applied?.configDir).toBe(customModelConfigDir(sessionId));
expect(existsSync(join(applied!.configDir!, 'cordis.patch.yml'))).toBe(true);
});
});
+10 -10
View File
@@ -188,18 +188,18 @@ describe('custom-model-injection contract (mock server)', () => {
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
});
it('deepseek: DEEPSEEK_BASE_URL already carries the /v1 suffix dsh itself never adds, reaching the mock at the real path dsh requests', async () => {
// Confirmed by reading dsh's own bundled source: it fetches
// `${DEEPSEEK_BASE_URL}/chat/completions` verbatim, no /v1 insertion of its own — so
// this call (unlike gemini's above) passes DEEPSEEK_BASE_URL to callOpenAiCompat
// UNMODIFIED, exactly mirroring what the real harness does, rather than the test
// helping it along.
it('deepseek: the session patch carries the /v1 base URL and exact model, reaching the mock at the real path', async () => {
// dsh fetches `${baseURL}/chat/completions` verbatim, so the patch's baseURL already
// carries /v1 and is passed to callOpenAiCompat unmodified.
const injection = buildCustomModelInjection(entryOrThrow('deepseek'), endpointFor(mock), 'qwen3');
if (injection.kind !== 'env') throw new Error('unreachable');
expect(Object.keys(injection.envOverrides).sort()).toEqual(['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL']);
expect(injection.envOverrides.DEEPSEEK_BASE_URL).toBe(`${mock.baseUrl}/v1`);
if (injection.kind !== 'configDir') throw new Error('unreachable');
expect(injection.dirEnvVar).toBe('DSH_HOME');
expect(Object.keys(injection.extraEnv ?? {})).toEqual(['CODEMAN_CUSTOM_MODEL_API_KEY']);
const provider = JSON.parse(injection.files[0].content)[0].config.providers['codeman-custom'];
expect(provider.baseURL).toBe(`${mock.baseUrl}/v1`);
expect(provider.models[0].id).toBe('qwen3');
await callOpenAiCompat(injection.envOverrides.DEEPSEEK_BASE_URL, injection.envOverrides.DEEPSEEK_API_KEY, 'qwen3');
await callOpenAiCompat(provider.baseURL, injection.extraEnv!.CODEMAN_CUSTOM_MODEL_API_KEY, 'qwen3');
expect(mock.requests[0].path).toBe('/v1/chat/completions');
expect(mock.requests[0].headers.authorization).toBe('Bearer contract-test-key');
+72 -17
View File
@@ -8,6 +8,7 @@
*/
import { describe, it, expect } from 'vitest';
import { parse as parseToml } from 'smol-toml';
import { getCli } from '../src/config/cli-registry/index.js';
import {
buildCustomModelInjection,
@@ -112,6 +113,31 @@ describe('buildCustomModelInjection', () => {
expect(parsed.provider.custom.models.qwen3).toEqual({});
});
it('opencode: threads a discovered context length into the model limit as the full window (issue #749)', () => {
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', 16384);
if (result.kind !== 'env') throw new Error('unreachable');
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
// `limit.context` IS the full window to opencode (it reserves for the reply itself),
// so the discovered total passes through; `output` only caps the reply at a quarter.
expect(parsed.provider.custom.models.qwen3).toEqual({ limit: { context: 16384, output: 4096 } });
});
it('opencode: caps the response headroom reservation at 8192 for big windows', () => {
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', 262144);
if (result.kind !== 'env') throw new Error('unreachable');
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
expect(parsed.provider.custom.models.qwen3).toEqual({ limit: { context: 262144, output: 8192 } });
});
it('opencode: never invents a limit for an unknown or non-finite context length (issue #749 regression)', () => {
for (const contextLength of [undefined, NaN]) {
const result = buildCustomModelInjection(entryOrThrow('opencode'), endpoint, 'qwen3', contextLength);
if (result.kind !== 'env') throw new Error('unreachable');
const parsed = JSON.parse(result.envOverrides.OPENCODE_CONFIG_CONTENT);
expect(parsed.provider.custom.models.qwen3).toEqual({});
}
});
it('codex: configDir writes an isolated config.toml with model/base_url, and the key rides as extraEnv (never a literal TOML field)', () => {
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3');
expect(result.kind).toBe('configDir');
@@ -133,6 +159,30 @@ describe('buildCustomModelInjection', () => {
expect(result.files[0].content).toContain('model = "weird\\"model"');
});
it('codex: writes the discovered window as TOP-LEVEL model_context_window with a 75% compact limit (issue #750)', () => {
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3', 16384);
if (result.kind !== 'configDir') throw new Error('unreachable');
const doc = parseToml(result.files[0].content) as Record<string, unknown>;
// Top-level is the shape codex reads: parked under [model_providers.custom] (its
// provider table) the same keys would be silently ignored by codex itself.
expect(doc.model_context_window).toBe(16384);
expect(doc.model_auto_compact_token_limit).toBe(12288); // 75% of the window, floored
const provider = (doc.model_providers as Record<string, Record<string, unknown>>).custom;
expect(provider.model_context_window).toBeUndefined();
expect(provider.model_auto_compact_token_limit).toBeUndefined();
});
it('codex: writes neither context line for an unknown, non-finite or zero context length', () => {
for (const contextLength of [undefined, NaN, 0]) {
const result = buildCustomModelInjection(entryOrThrow('codex'), endpoint, 'qwen3', contextLength);
if (result.kind !== 'configDir') throw new Error('unreachable');
const doc = parseToml(result.files[0].content) as Record<string, unknown>;
expect(doc.model_context_window, String(contextLength)).toBeUndefined();
expect(doc.model_auto_compact_token_limit, String(contextLength)).toBeUndefined();
expect(doc.model).toBe('qwen3'); // the rest of the file is still written
}
});
it('pi: configDir writes .pi/agent/models.json, redirected via HOME (verified live — PI_CONFIG_DIR does nothing for pi)', () => {
const result = buildCustomModelInjection(entryOrThrow('pi'), endpoint, 'qwen3');
if (result.kind !== 'configDir') throw new Error('unreachable');
@@ -179,29 +229,32 @@ describe('buildCustomModelInjection', () => {
expect(result.extraEnv).toEqual({ XAI_API_KEY: 'my-key' });
});
it('deepseek: env kind sets base URL (with a /v1 suffix appended) and key, no model var', () => {
// appendV1Suffix is REQUIRED here, not cosmetic: confirmed by reading dsh's own
// bundled source (@deepseek-ai/dsh-llm-deepseek) that it builds the request URL as
// `${DEEPSEEK_BASE_URL}/chat/completions` with no "/v1" of its own, while
// llama-swap/llama.cpp only serves "/v1/chat/completions" — without this, every
// request 404s (confirmed live; this is the fix for the originally-reported
// "dsh: HTTP_404: DeepSeek API error (HTTP 404)").
it('deepseek: isolates the selected model route and passes its key by reference', () => {
const result = buildCustomModelInjection(entryOrThrow('deepseek'), endpoint, 'qwen3');
if (result.kind !== 'env') throw new Error('unreachable');
expect(result.envOverrides).toEqual({
DEEPSEEK_BASE_URL: 'http://192.168.1.50:8080/v1',
DEEPSEEK_API_KEY: 'my-key',
});
if (result.kind !== 'configDir') throw new Error('Expected isolated DeepSeek config');
expect(result.dirEnvVar).toBe('DSH_HOME');
expect(result.extraEnv).toEqual({ CODEMAN_CUSTOM_MODEL_API_KEY: 'my-key' });
expect(result.files[0].content).not.toContain('my-key');
const patch = JSON.parse(result.files[0].content);
// The patch references the DEDICATED var by name, never dsh's own DEEPSEEK_API_KEY:
// dsh's llm-deepseek/web-search rows read that too and would leak this key to
// api.deepseek.com on an in-session /model switch back to the official route.
expect(patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'].apiKeyEnv).toBe(
'CODEMAN_CUSTOM_MODEL_API_KEY'
);
expect(result.files[0].content).not.toContain('DEEPSEEK_API_KEY');
expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config.model).toBe('qwen3');
});
it('deepseek: appending the /v1 suffix is idempotent against a baseUrl that already ends in /v1', () => {
it('deepseek: preserves an existing version suffix', () => {
const result = buildCustomModelInjection(
entryOrThrow('deepseek'),
{ ...endpoint, baseUrl: 'http://192.168.1.50:8080/v1' },
{ ...endpoint, baseUrl: endpoint.baseUrl + '/v1' },
'qwen3'
);
if (result.kind !== 'env') throw new Error('unreachable');
expect(result.envOverrides.DEEPSEEK_BASE_URL).toBe('http://192.168.1.50:8080/v1');
if (result.kind !== 'configDir') throw new Error('Expected isolated DeepSeek config');
expect(result.files[0].content).toContain('http://192.168.1.50:8080/v1');
expect(result.files[0].content).not.toContain('/v1/v1');
});
it('antigravity: unsupported', () => {
@@ -232,7 +285,9 @@ describe('launchModel (the model launch param that selects the injected provider
});
it('CLIs whose config selects the model on its own declare none', () => {
for (const id of ['claude', 'opencode', 'codex', 'gemini', 'deepseek']) {
// codex now declares launchModel (selected-model launch override) and is covered by
// its own restart coverage, not here.
for (const id of ['claude', 'opencode', 'gemini', 'deepseek']) {
const result = buildCustomModelInjection(entryOrThrow(id), endpoint, 'qwen3');
if (result.kind === 'unsupported') throw new Error('unreachable');
expect(result.launchModel, id).toBeUndefined();
@@ -0,0 +1,91 @@
/**
* @fileoverview Boot recovery rebuilds a saved custom-model selection through the SAME
* context-length resolution the routes use: the model's discovered figure, falling back to
* the endpoint's `defaultContextLength` (`resolveCustomModelContextLength`). Before the
* shared helper, `_rebuildCustomModelEnv` read `modelContextLengths` alone, so a session
* on a model that only the endpoint default covers came back from a Codeman restart with
* `CLAUDE_CODE_MAX_CONTEXT_TOKENS` (or a config file's context figure) silently gone,
* re-arming exactly the window overflow the var exists to prevent.
*
* `_rebuildCustomModelEnv` reads no `this` state (it re-derives everything from the endpoint
* store and the Session it is handed), so it is driven straight off the prototype with an
* empty receiver: `restoreMuxSessions()` itself cannot be reached under vitest, the same
* constraint recorded in test/session-model-recovery.test.ts.
*/
import { afterEach, describe, expect, it } from 'vitest';
import { getDataDir } from '../src/config/instance.js';
import { writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
import { customModelConfigDir, removeConfigDir, type AppliedCustomModel } from '../src/custom-model-injection-apply.js';
import { Session } from '../src/session.js';
import { WebServer } from '../src/web/server.js';
import type { CustomModelBookkeeping } from '../src/types/session.js';
type RebuildFn = (session: Session, saved: CustomModelBookkeeping) => Promise<AppliedCustomModel | undefined>;
/** The real method, with an empty receiver, see the file header for why that is sound. */
function rebuildCustomModelEnv(session: Session, saved: CustomModelBookkeeping) {
const fn = (WebServer.prototype as unknown as { _rebuildCustomModelEnv: RebuildFn })._rebuildCustomModelEnv;
expect(typeof fn, '_rebuildCustomModelEnv vanished from WebServer.prototype').toBe('function');
return fn.call({}, session, saved);
}
function endpoint(overrides: Partial<CustomModelHost> = {}): CustomModelHost {
return {
id: 'ep',
label: 'Ep',
baseUrl: 'http://localhost:8080',
apiKey: 'recovery-probe-key',
models: ['qwen3'],
...overrides,
};
}
describe('_rebuildCustomModelEnv: context-length fallback on boot recovery', () => {
const configs: string[] = [];
afterEach(() => {
for (const id of configs.splice(0)) removeConfigDir(customModelConfigDir(id));
});
it('falls back to the endpoint defaultContextLength when discovery recorded no per-model figure', async () => {
await writeCustomModelHosts(getDataDir(), [endpoint({ defaultContextLength: 32768 })]);
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
configs.push(session.id);
const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] });
expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('32768');
});
it('a discovered per-model figure still wins over the endpoint default', async () => {
await writeCustomModelHosts(getDataDir(), [
endpoint({ modelContextLengths: { qwen3: 131072 }, defaultContextLength: 32768 }),
]);
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
configs.push(session.id);
const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] });
expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBe('131072');
});
it('states nothing rather than guessing when neither source knows a window', async () => {
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
configs.push(session.id);
const env = await rebuildCustomModelEnv(session, { endpointId: 'ep', modelId: 'qwen3', envKeys: [] });
expect(env?.envOverrides.CLAUDE_CODE_MAX_CONTEXT_TOKENS).toBeUndefined();
});
it('drops the selection to undefined (kept for clearing) when the endpoint no longer exists', async () => {
await writeCustomModelHosts(getDataDir(), []);
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
configs.push(session.id);
await expect(rebuildCustomModelEnv(session, { endpointId: 'gone', modelId: 'qwen3', envKeys: [] })).resolves.toBe(
undefined
);
});
});
+102
View File
@@ -0,0 +1,102 @@
/**
* @fileoverview Boot recovery re-derives a saved custom-model selection through
* `_rebuildCustomModelEnv` and rebuilds the BOOKKEEPING from what the CURRENT recipe
* applied, not from the saved record. A selection persisted while deepseek still used
* the old env redirect comes back env-shaped (envKeys `DEEPSEEK_BASE_URL`/
* `DEEPSEEK_API_KEY`, no `configDir`) although the recipe now writes a per-session patch
* home: restoring the saved record unchanged left `DSH_HOME` on a generated config dir
* that clearing could never delete.
*
* Real-home resolution is NOT recovered from the session's env overrides any more:
* `getEnvOverridesForPersist()` keeps only `CLAUDE_CODE_*`/`CLAUDE_CONFIG_DIR`, so a
* session's own `DSH_HOME` never survives a real restart and the lookup only ever hit on
* the first apply in-process. Recovery falls back to the server's `DSH_HOME` or the
* recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s comment.
*
* `_restoreCustomModel` reads no `this` state beyond the rebuild helper (it re-derives
* everything from the endpoint store and the Session it is handed), so it is driven off
* the prototype with a minimal receiver: `restoreMuxSessions()` itself cannot be
* reached under vitest, the same constraint recorded in test/session-model-recovery.test.ts.
*/
import { mkdirSync, rmSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getDataDir } from '../src/config/instance.js';
import { writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
import { customModelConfigDir, removeConfigDir } from '../src/custom-model-injection-apply.js';
import { Session } from '../src/session.js';
import { WebServer } from '../src/web/server.js';
import type { CustomModelBookkeeping } from '../src/types/session.js';
/** The real pair, on a receiver that only carries the rebuild helper, see the header. */
async function restoreCustomModel(session: Session, saved: CustomModelBookkeeping): Promise<void> {
const proto = WebServer.prototype as unknown as {
_rebuildCustomModelEnv: (session: Session, saved: CustomModelBookkeeping) => Promise<unknown>;
_restoreCustomModel: (session: Session, saved: CustomModelBookkeeping) => Promise<void>;
};
expect(typeof proto._restoreCustomModel, '_restoreCustomModel vanished from WebServer.prototype').toBe('function');
await proto._restoreCustomModel.call({ _rebuildCustomModelEnv: proto._rebuildCustomModelEnv }, session, saved);
}
function endpoint(): CustomModelHost {
return {
id: 'ep',
label: 'Ep',
baseUrl: 'http://localhost:8080',
apiKey: 'recovery-probe-key',
models: ['qwen3'],
};
}
describe('_restoreCustomModel: bookkeeping rebuilt from the current recipe', () => {
const configs: string[] = [];
afterEach(() => {
for (const id of configs.splice(0)) removeConfigDir(customModelConfigDir(id));
vi.unstubAllEnvs();
});
it('carries the configDir and env-key union for a selection saved by the old env recipe', async () => {
// Saved exactly as 1.31.0-1.47.0 persisted a deepseek selection: the env recipe's two
// keys, no configDir. The rebuilt record must carry the per-session config dir the
// CURRENT recipe wrote (so clearing deletes it) while KEEPING the saved keys, which
// the tmux session may still carry `setenv`s for (the union is what a clear unsets).
const dshHome = join(homedir(), 'codeman-recovery-dsh-home');
mkdirSync(join(dshHome, 'profiles'), { recursive: true });
vi.stubEnv('DSH_HOME', dshHome);
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
configs.push(session.id);
await restoreCustomModel(session, {
endpointId: 'ep',
modelId: 'qwen3',
label: 'Ep',
envKeys: ['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY'],
});
const persisted = session.getCustomModelForPersist();
expect(persisted?.configDir).toBe(customModelConfigDir(session.id));
expect(persisted?.launchModel).toBeUndefined();
expect(new Set(persisted?.envKeys)).toEqual(
new Set(['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY', 'DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'])
);
rmSync(dshHome, { recursive: true, force: true });
});
it('restores an env-shaped record unchanged when the endpoint is gone, so a clear still works', async () => {
await writeCustomModelHosts(getDataDir(), []);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
const saved: CustomModelBookkeeping = {
endpointId: 'gone',
modelId: 'qwen3',
label: 'Gone',
envKeys: ['DEEPSEEK_BASE_URL', 'DEEPSEEK_API_KEY'],
};
await restoreCustomModel(session, saved);
expect(session.getCustomModelForPersist()).toEqual(saved);
});
});
+387 -7
View File
@@ -20,6 +20,10 @@ import { describe, expect, it, vi } from 'vitest';
const CONSTANTS_JS = readFileSync(new URL('../src/web/public/constants.js', import.meta.url), 'utf-8');
const SESSION_UI_JS = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
// Only for the [hidden] CSS guard below: the filter toggles the `hidden` property, and
// `.run-mode-option`'s own `display: flex` would silently defeat it without the
// `!important` re-assertion in styles.css.
const STYLES_CSS = readFileSync(new URL('../src/web/public/styles.css', import.meta.url), 'utf-8');
// Only for the real _showCenterStatus DOM tests below (`bootAppWithRealCenterStatus`) —
// every other test in this file stubs _showCenterStatus itself and has no need of it.
const PANELS_UI_JS = readFileSync(new URL('../src/web/public/panels-ui.js', import.meta.url), 'utf-8');
@@ -40,6 +44,8 @@ function bootApp(
cliAvailable?: (id: string) => boolean;
activeCase?: { location?: string } | null;
settingsEnabled?: boolean;
/** Live sessions for the "in use by a session" fallback, in `this.sessions` Map shape. */
sessions?: Array<Record<string, unknown>>;
} = {}
) {
const dom = new JSDOM(
@@ -55,6 +61,10 @@ function bootApp(
<div class="modal" id="customModelPickModal">
<h3 id="customModelPickTitle"></h3>
<p id="customModelPickHint"></p>
<input type="search" id="customModelPickSearch" aria-label="Search models"
oninput="app.filterCustomModelPick(this.value)">
<p id="customModelPickNoMatches" role="status" hidden>No models match</p>
<p id="customModelPickUnknown" role="status" hidden>Nothing is marked: this endpoint did not report which model it has loaded, and none of your running sessions uses one. Pick the model to launch.</p>
<div id="customModelPickList"></div>
</div>
<div class="modal" id="customModelSwapConfirmModal">
@@ -81,6 +91,10 @@ function bootApp(
app.cases = options.activeCase ? [{ name: 'testcase', ...options.activeCase }] : [{ name: 'testcase' }];
app.loadAppSettingsFromStorage = () => ({ customModelEndpointsEnabled: options.settingsEnabled ?? true });
app.isCliAvailable = options.cliAvailable ?? (() => true);
// The real app keeps live sessions in a Map keyed by id (app.js), which is what
// _getCustomModelInUseBySession walks; empty by default so a test that says nothing
// about sessions sees no promotion, exactly like a box with none running.
app.sessions = new Map((options.sessions || []).map((s, i) => [String(i), s]));
app.showToast = () => {};
// Real implementation lives in panels-ui.js, not evaluated into this harness (only
// constants.js + session-ui.js are — see below) — a no-op default handle matching its
@@ -315,6 +329,27 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
expect(buttons[0].textContent).not.toContain('Currently loaded');
});
it('treats a remembered "last used" id the host no longer lists as nothing promoted: no tag, the line shows', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
});
const origApiJson = app._apiJson;
app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/llama-box/running-status') return { isLlamaSwap: false, running: [] };
return origApiJson(path);
};
// The remembered model was deleted from this endpoint since the last launch.
win.localStorage.setItem('codeman:customModelLastUsed:claude:llama-box', 'gone-model');
await app.selectCustomModelEntry('claude', 'llama-box');
const list = win.document.getElementById('customModelPickList')!;
expect(list.textContent).not.toContain('Last used');
// Without a promoted row the probe really did answer "nothing is known", so the
// explanation line must show rather than leave two unmarked rows unexplained.
expect(win.document.getElementById('customModelPickUnknown')!.hidden).toBe(false);
});
it('prefers the currently-loaded model over a stale "last used" entry when both are present', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3', 'phi4'] }],
@@ -532,9 +567,349 @@ describe('Custom Model Endpoint Profiles: the "which model" picker', () => {
await app.selectCustomModelEntry('claude', 'llama-box');
expect(toastMessage).toMatch(/no models discovered/i);
});
it('resets and focuses the search box every time the picker opens', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
});
win.app = app;
await app.selectCustomModelEntry('claude', 'llama-box');
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
// The caret lands in the box, so the first keystroke filters with no click.
expect(win.document.activeElement).toBe(search);
// A stale query from a previous open must not survive into the next one.
search.value = 'zzz';
search.dispatchEvent(new win.Event('input'));
app.closeCustomModelPickModal();
await app.selectCustomModelEntry('claude', 'llama-box');
expect(search.value).toBe('');
expect(win.document.activeElement).toBe(search);
expect(win.document.getElementById('customModelPickNoMatches')!.hasAttribute('hidden')).toBe(true);
const visible = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')].filter(
(b) => !b.hasAttribute('hidden')
);
expect(visible).toHaveLength(2);
});
it('leaves the search box unfocused on touch devices so the OS keyboard stays off the list', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
});
win.app = app;
// The guard reads the global the browser defines (mobile-detection.js).
(win as unknown as { MobileDetection: { isTouchDevice: () => boolean } }).MobileDetection = {
isTouchDevice: () => true,
};
await app.selectCustomModelEntry('claude', 'llama-box');
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
expect(win.document.activeElement).not.toBe(search);
// Only the focus is skipped: the rows still render, unfiltered and functional.
const visible = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')].filter(
(b) => !b.hasAttribute('hidden')
);
expect(visible).toHaveLength(2);
});
it('filters rows case-insensitively on the model id, keeping the surviving row fully functional', async () => {
const { win, app } = bootApp({
hosts: [
{
id: 'llama-box',
label: 'llama.cpp',
baseUrl: 'http://x',
models: ['qwen3', 'llama3'],
defaultModelId: 'qwen3',
},
],
});
let launched: unknown[] | null = null;
app.runCustomModelEntry = async (...args: unknown[]) => {
launched = args;
};
win.app = app;
await app.selectCustomModelEntry('claude', 'llama-box');
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
search.value = 'QWEN';
search.dispatchEvent(new win.Event('input'));
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons.filter((b) => b.hasAttribute('hidden')).map((b) => b.textContent!.trim())).toEqual(['llama3']);
const [visible] = buttons.filter((b) => !b.hasAttribute('hidden'));
// The surviving row keeps its Default badge and its real, callable click handler.
expect(visible.textContent).toContain('qwen3');
expect(visible.textContent).toContain('Default');
(visible as unknown as HTMLButtonElement & { onclick: (e: unknown) => void }).onclick(
new (win as any).Event('click')
);
expect(launched).toEqual(['claude', 'llama-box', 'qwen3']);
});
it('shows the no-matches line only while a query matches nothing, and clears it with the query', async () => {
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
});
win.app = app;
await app.selectCustomModelEntry('claude', 'llama-box');
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
const noMatches = win.document.getElementById('customModelPickNoMatches')!;
const isHidden = () =>
win.document.getElementById('customModelPickList')!.querySelectorAll('button[hidden]').length;
// Whitespace is not a query: everything stays visible, no message.
search.value = ' ';
search.dispatchEvent(new win.Event('input'));
expect(noMatches.hasAttribute('hidden')).toBe(true);
expect(isHidden()).toBe(0);
search.value = 'ghost';
search.dispatchEvent(new win.Event('input'));
expect(noMatches.hasAttribute('hidden')).toBe(false);
expect(isHidden()).toBe(2);
search.value = '';
search.dispatchEvent(new win.Event('input'));
expect(noMatches.hasAttribute('hidden')).toBe(true);
expect(isHidden()).toBe(0);
});
it('keeps an HTML-significant model id inert in the picker rows too, and filterable by its raw text', async () => {
// Same vector as the Run-menu row above, now via data-model-id: escapeHtml covers
// the double quote, and the filter reads the decoded raw id back off dataset.
const dangerousModel = '"><img src=x onerror=alert(1)>';
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', dangerousModel] }],
});
win.app = app;
await app.selectCustomModelEntry('claude', 'llama-box');
const list = win.document.getElementById('customModelPickList')!;
expect(list.querySelector('img')).toBeNull();
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
search.value = 'src=x';
search.dispatchEvent(new win.Event('input'));
const visible = list.querySelectorAll('button:not([hidden])');
expect(visible).toHaveLength(1);
expect(visible[0].getAttribute('data-model-id')).toBe(dangerousModel);
});
it('re-asserts display:none !important for hidden picker rows so the filter cannot be silently defeated', () => {
// `.run-mode-option` carries its own `display: flex` (styles.css, restated in
// mobile.css), which outranks the UA `[hidden]` rule: without this scoped
// !important re-assertion, toggling `hidden` would change nothing on screen.
const rule = STYLES_CSS.match(/#customModelPickModal \.run-mode-option\[hidden\]\s*\{[^}]*\}/);
expect(rule, 'picker rows need a [hidden] rule scoped to #customModelPickModal').toBeTruthy();
expect(rule![0]).toMatch(/display:\s*none\s*!important/);
});
it('pins "No models match" hidden with an empty query and shown only when a query hides every row, in CSS too', async () => {
// The visible symptom: the line carries `.form-hint`, whose `display: block`
// outranks the UA `[hidden]` rule, so `hidden` alone showed "No models match"
// while every model was listed with an empty search box. The attribute half
// was always right (session-ui.js's `filterCustomModelPick`); the CSS half
// needed the same `[hidden] { display: none !important }` re-assertion the
// rows above carry. This test pins both states AND the CSS that makes them
// actually render.
const rule = STYLES_CSS.match(/#customModelPickModal \.custom-model-pick-no-matches\[hidden\]\s*\{[^}]*\}/);
expect(rule, 'the no-matches line needs a [hidden] rule scoped to #customModelPickModal').toBeTruthy();
expect(rule![0]).toMatch(/display:\s*none\s*!important/);
const { win, app } = bootApp({
hosts: [{ id: 'llama-box', label: 'llama.cpp', baseUrl: 'http://x', models: ['qwen3', 'llama3'] }],
});
win.app = app;
await app.selectCustomModelEntry('claude', 'llama-box');
const noMatches = win.document.getElementById('customModelPickNoMatches')!;
// Empty query, models listed: the attribute must be set, and the CSS rule
// above is what makes that attribute actually hide the line.
expect(noMatches.hasAttribute('hidden')).toBe(true);
// A non-empty query that hides every row: shown again.
const search = win.document.getElementById('customModelPickSearch') as HTMLInputElement;
search.value = 'ghost';
search.dispatchEvent(new win.Event('input'));
expect(noMatches.hasAttribute('hidden')).toBe(false);
});
});
describe('Custom Model Endpoint Profiles: _getCustomModelCurrentlyLoaded is client-side bounded', () => {
/**
* The reported bug: a gateway such as Bifrost (40 models, alphabetical, no llama-swap
* `/running` route of its own) showed "Choose a model" with nothing marked, so the user
* could not tell which model the endpoint was actually serving, while the same endpoint
* reached from the Codex CLI appeared to "default to alpha order" for every other CLI.
* The endpoint genuinely cannot answer, so the app falls back to what it does know: a
* live session it has itself pointed at that endpoint. And it says so plainly when it
* knows neither.
*/
describe('Custom Model Endpoint Profiles: gateways without /running fall back to a live session', () => {
const GATEWAY = {
id: 'bifrost',
label: 'bifrost',
baseUrl: 'https://bifrost.example',
models: ['llama-swap/alpha-3b', 'llama-swap/qwen3.8-flash-next-unsloth-ud-iq4_xs', 'llama-swap/zeta-70b'],
};
/** The gateway's exact shape: reachable, OpenAI-compatible, and not llama-swap. */
function gatewayApp(sessions: Array<Record<string, unknown>>) {
const booted = bootApp({ hosts: [GATEWAY], sessions });
const origApiJson = booted.app._apiJson;
booted.app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/bifrost/running-status') return { isLlamaSwap: false, running: [] };
return origApiJson(path);
};
return booted;
}
const live = (modelId: string, overrides: Record<string, unknown> = {}) => ({
name: 'w9-Gateway',
status: 'busy',
pid: 4242,
createdAt: 1_700_000_000_000,
customModel: { endpointId: 'bifrost', modelId, label: 'bifrost' },
...overrides,
});
it('marks the model a live session is running, at the top, for EVERY CLI, not just claude', async () => {
const inUse = 'llama-swap/qwen3.8-flash-next-unsloth-ud-iq4_xs';
for (const mode of ['opencode', 'deepseek', 'codex', 'claude']) {
const { win, app } = gatewayApp([live(inUse)]);
await app._openCustomModelPickModal(mode, GATEWAY);
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons).toHaveLength(3);
expect(buttons[0].textContent, mode).toContain(inUse);
expect(buttons[0].textContent, mode).toContain('In use by a session');
// The other rows keep discovery order and carry no tag of their own.
expect(buttons[1].textContent).toContain('llama-swap/alpha-3b');
expect(buttons[2].querySelectorAll('.set-scope')).toHaveLength(0);
// Nothing was fabricated about the endpoint itself.
expect(win.document.getElementById('customModelPickList')!.textContent).not.toContain('Currently loaded');
expect(win.document.getElementById('customModelPickUnknown')!.hasAttribute('hidden')).toBe(true);
}
});
it('a live session outranks this device’s "last used" memory, and both rank above nothing', async () => {
const { win, app } = gatewayApp([live('llama-swap/zeta-70b')]);
win.localStorage.setItem('codeman:customModelLastUsed:opencode:bifrost', 'llama-swap/alpha-3b');
await app._openCustomModelPickModal('opencode', GATEWAY);
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons[0].textContent).toContain('llama-swap/zeta-70b');
expect(buttons[0].textContent).toContain('In use by a session');
expect(buttons[0].textContent).not.toContain('Last used');
// The remembered pick is still listed, just not promoted or double-tagged.
expect(buttons[1].textContent).toContain('llama-swap/alpha-3b');
expect(buttons[1].querySelectorAll('.set-scope')).toHaveLength(0);
});
it('a stopped session, an exited process, an exited pane, another endpoint or an undiscovered model mark nothing', async () => {
const inUse = 'llama-swap/qwen3.8-flash-next-unsloth-ud-iq4_xs';
const cases: Array<[string, Record<string, unknown>]> = [
['stopped', { status: 'stopped' }],
['process exited (pid null)', { pid: null }],
['pane exited', { paneExit: { at: 1, status: 0 } }],
['other endpoint', { customModel: { endpointId: 'other', modelId: inUse } }],
['no custom model', { customModel: undefined }],
['undiscovered model', { customModel: { endpointId: 'bifrost', modelId: 'llama-swap/retired-model' } }],
];
for (const [label, overrides] of cases) {
const { win, app } = gatewayApp([live(inUse, overrides)]);
await app._openCustomModelPickModal('opencode', GATEWAY);
const list = win.document.getElementById('customModelPickList')!;
expect(list.textContent, label).not.toContain('In use by a session');
expect(list.textContent, label).not.toContain('Last used');
// Discovery order untouched: nothing got dragged to the top.
expect(list.querySelectorAll('button')![0].textContent).toContain('llama-swap/alpha-3b');
}
});
it('the most recently launched live session wins when two share an endpoint', async () => {
const { win, app } = gatewayApp([
live('llama-swap/alpha-3b', { createdAt: 1 }),
live('llama-swap/zeta-70b', { createdAt: 2 }),
]);
await app._openCustomModelPickModal('opencode', GATEWAY);
const buttons = [...win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons[0].textContent).toContain('llama-swap/zeta-70b');
expect(buttons.filter((b) => b.textContent!.includes('In use by a session'))).toHaveLength(1);
});
it('says so plainly when nothing is known (a down endpoint answers the very same shape)', async () => {
const { win, app } = gatewayApp([]);
await app._openCustomModelPickModal('opencode', GATEWAY);
const unknown = win.document.getElementById('customModelPickUnknown')!;
expect(unknown.hasAttribute('hidden')).toBe(false);
expect(unknown.textContent).toContain('did not report which model it has loaded');
// What REALLY arrives here, not a fiction: `getLlamaSwapStatus` (custom-model-routes.ts)
// turns EVERY upstream failure into `isLlamaSwap: false` (a gateway with no
// `/running` route, a closed port, an unresolvable host, a 401), so this stub is
// also exactly what a DOWN endpoint produces, which is why the line's wording says
// only "did not report" and never why.
// The same class-of-bug trap as the no-matches line: `.form-hint`'s `display: block`
// outranks the UA `[hidden]` rule, so the attribute needs the CSS re-assertion.
const rule = STYLES_CSS.match(/#customModelPickModal \.custom-model-pick-unknown\[hidden\]\s*\{[^}]*\}/);
expect(rule, 'the unknown-endpoint line needs a [hidden] rule scoped to #customModelPickModal').toBeTruthy();
expect(rule![0]).toMatch(/display:\s*none\s*!important/);
});
it('hides that line whenever a row IS marked, and for a probe that never got any answer', async () => {
// Marked by a live session: nothing to explain.
const marked = gatewayApp([live('llama-swap/alpha-3b')]);
await marked.app._openCustomModelPickModal('opencode', GATEWAY);
expect(marked.win.document.getElementById('customModelPickUnknown')!.hasAttribute('hidden')).toBe(true);
// `null` (no answer at all) only happens when Codeman's OWN request to the route
// failed or the client-side bound won the race. The route itself never answers this
// way for an unreachable endpoint: every fetch failure arrives as `isLlamaSwap:
// false`, pinned by the test above. The box may merely be asleep, so stay silent.
const asleep = bootApp({ hosts: [GATEWAY] });
asleep.app._apiJson = async (path: string) => (path.includes('running-status') ? null : []);
await asleep.app._openCustomModelPickModal('opencode', GATEWAY);
expect(asleep.win.document.getElementById('customModelPickUnknown')!.hasAttribute('hidden')).toBe(true);
});
it('llama-swap’s own answer still wins over a session pin, and keeps its own tag', async () => {
const booted = bootApp({
hosts: [GATEWAY],
sessions: [live('llama-swap/alpha-3b')],
});
const origApiJson = booted.app._apiJson;
booted.app._apiJson = async (path: string) => {
if (path === '/api/model-endpoints/bifrost/running-status') {
return { isLlamaSwap: true, running: [{ model: 'llama-swap/zeta-70b', state: 'ready' }] };
}
return origApiJson(path);
};
await booted.app._openCustomModelPickModal('opencode', GATEWAY);
const buttons = [...booted.win.document.getElementById('customModelPickList')!.querySelectorAll('button')];
expect(buttons[0].textContent).toContain('llama-swap/zeta-70b');
expect(buttons[0].textContent).toContain('Currently loaded');
expect(buttons[0].textContent).not.toContain('In use by a session');
// The session's model is an ordinary row again: one promotion, not two.
expect(buttons.filter((b) => b.querySelector('.set-scope'))).toHaveLength(1);
});
it('never trusts displayModel: a session whose terminal merely printed a model marks nothing', async () => {
const booted = gatewayApp([
{ name: 'w9-Other', status: 'busy', createdAt: 1, displayModel: { model: 'llama-swap/zeta-70b', source: 'screen' } },
]);
await booted.app._openCustomModelPickModal('opencode', GATEWAY);
expect(booted.win.document.getElementById('customModelPickList')!.textContent).not.toContain('In use by a session');
});
});
describe('Custom Model Endpoint Profiles: _getCustomModelRunningStatus is client-side bounded', () => {
// `timeoutMs` driven in milliseconds rather than the real 800 — same reasoning as
// `_watchLlamaSwapLoading`'s own `pollIntervalMs` a few describe blocks down: this
// code runs inside the JSDOM window's own realm, whose setTimeout vi.useFakeTimers()
@@ -549,26 +924,31 @@ describe('Custom Model Endpoint Profiles: _getCustomModelCurrentlyLoaded is clie
// endpoint that is asleep or firewalled, distinct from one that answers an error.
app._apiJson = () => new Promise(() => {});
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5);
const status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 5);
expect(result).toBeNull();
// `null` means "no answer at all" (Codeman's own request failed or the client-side
// bound won): the picker keeps its "Nothing is marked" line hidden for exactly this
// case (pinned in the gateway describe above). An endpoint that is merely down is
// NOT here: the route turns every fetch failure into `isLlamaSwap: false`.
expect(status).toBeNull();
expect(app._pickReadyRunningModel(status, { id: 'llama-box', models: ['qwen3'] })).toBeNull();
});
it('an endpoint that answers well within the bound is unaffected by it', async () => {
const { app } = bootApp({});
app._apiJson = async () => ({ isLlamaSwap: true, running: [{ model: 'qwen3', state: 'ready' }] });
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 5);
const status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 5);
expect(result).toBe('qwen3');
expect(app._pickReadyRunningModel(status, { id: 'llama-box', models: ['qwen3'] })).toBe('qwen3');
});
it('a rejected probe settles quietly to null rather than leaving an unhandled rejection once the timeout has already won the race', async () => {
const { app } = bootApp({});
app._apiJson = () => new Promise((_resolve, reject) => setTimeout(() => reject(new Error('boom')), 10));
const result = await app._getCustomModelCurrentlyLoaded({ id: 'llama-box', models: ['qwen3'] }, 2);
expect(result).toBeNull();
const status = await app._getCustomModelRunningStatus({ id: 'llama-box', models: ['qwen3'] }, 2);
expect(status).toBeNull();
// Give the loser of the race a turn to actually reject and hit its own .catch —
// an unswallowed rejection here would surface as an "Unhandled Errors" failure
// for the whole test file, not a failed assertion in this test.
@@ -0,0 +1,68 @@
/**
* @fileoverview `guardSessionConfigDirHome` probes a session-supplied configDir home before
* the apply runs synchronous fs calls on it (the #516 rule): a home that does not answer is
* hidden from the apply, so it links from the server's own home instead and says so.
*
* The probe is mocked: a mount that never answers is the case under test, and a real one
* cannot be made here.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../src/utils/bounded-path-probe.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/bounded-path-probe.js')>();
return { ...actual, probePathKind: vi.fn() };
});
import { getCli } from '../src/config/cli-registry/registry.js';
import { probePathKind } from '../src/utils/bounded-path-probe.js';
import { guardSessionConfigDirHome } from '../src/web/routes/session-routes.js';
const probe = vi.mocked(probePathKind);
const deepseek = () => getCli('deepseek')!;
const env =
(values: Record<string, string>) =>
(name: string): string | undefined =>
values[name];
describe('guardSessionConfigDirHome', () => {
afterEach(() => {
probe.mockReset();
vi.restoreAllMocks();
});
it('hides only the home when it does not answer, and warns that the server home is used', async () => {
probe.mockResolvedValue('unknown');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const lookup = await guardSessionConfigDirHome(deepseek(), env({ DSH_HOME: '/mnt/dead/dsh', OTHER: 'kept' }));
expect(lookup('DSH_HOME')).toBeUndefined();
expect(lookup('OTHER')).toBe('kept');
expect(probe).toHaveBeenCalledWith('/mnt/dead/dsh', { pastCap: true });
expect(warn).toHaveBeenCalledTimes(1);
expect(String(warn.mock.calls[0][0])).toContain('/mnt/dead/dsh');
});
it('passes a home that answers straight through', async () => {
probe.mockResolvedValue('directory');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const base = env({ DSH_HOME: '/home/u/.dsh' });
expect(await guardSessionConfigDirHome(deepseek(), base)).toBe(base);
expect(warn).not.toHaveBeenCalled();
});
it('never probes a relative home: the apply refuses to join one', async () => {
const base = env({ DSH_HOME: 'relative/dsh' });
expect(await guardSessionConfigDirHome(deepseek(), base)).toBe(base);
expect(probe).not.toHaveBeenCalled();
});
it('never probes for a CLI with no linkFromRealHome recipe', async () => {
const base = env({ CODEX_HOME: '/mnt/dead/codex' });
expect(await guardSessionConfigDirHome(getCli('codex')!, base)).toBe(base);
expect(probe).not.toHaveBeenCalled();
});
});
+30
View File
@@ -30,6 +30,36 @@ async function closedPort(): Promise<number> {
}
describe('buildWebArgs', () => {
it('forwards the tunnel opt-in between the network and multi-user flags', () => {
const options = {
host: '0.0.0.0',
port: 8080,
https: true,
titleHostname: 'tower',
allowUnauthenticatedNetwork: true,
allowUnauthenticatedTunnel: true,
multiuser: true,
};
expect(buildWebArgs(options)).toEqual([
'web',
'--host',
'0.0.0.0',
'--port',
'8080',
'--https',
'--title-hostname',
'tower',
'--allow-unauthenticated-network',
'--allow-unauthenticated-tunnel',
'--multiuser',
]);
});
it.each([false, undefined])('omits the tunnel opt-in when it is %s', (allowUnauthenticatedTunnel) => {
const options = { host: '127.0.0.1', port: 8080, https: false, allowUnauthenticatedTunnel };
expect(buildWebArgs(options)).toEqual(['web', '--host', '127.0.0.1', '--port', '8080']);
});
it('always passes host and port through explicitly', () => {
expect(buildWebArgs({ host: '127.0.0.1', port: 3000, https: false })).toEqual([
'web',
+207
View File
@@ -0,0 +1,207 @@
import { existsSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getCli } from '../src/config/cli-registry/index.js';
import { buildCustomModelInjection } from '../src/custom-model-injection.js';
import {
applyCustomModelInjection,
removeConfigDir,
customModelConfigDir,
} from '../src/custom-model-injection-apply.js';
afterEach(() => {
removeConfigDir(customModelConfigDir('deepseek-probe'));
vi.unstubAllEnvs();
});
describe('DeepSeek custom endpoint isolation', () => {
it('uses the chosen model and limits without changing installed profiles or embedding the key', () => {
const realHome = join(homedir(), '.dsh');
const profile = join(realHome, 'profiles', 'dsh-tui');
mkdirSync(profile, { recursive: true });
mkdirSync(join(realHome, 'sessions'), { recursive: true });
writeFileSync(join(realHome, 'settings.yaml'), 'theme: dark');
const original = join(profile, 'cordis.patch.yml');
writeFileSync(original, 'original profile');
vi.stubEnv('DSH_HOME', realHome);
const entry = getCli('deepseek');
if (!entry) throw new Error('Missing DeepSeek entry');
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
if (!applied?.configDir) throw new Error('Missing isolated profile');
const content = readFileSync(join(applied.configDir, 'cordis.patch.yml'), 'utf8');
expect(content).not.toContain(endpoint.apiKey);
// The dedicated key var, never DEEPSEEK_API_KEY: dsh's own llm-deepseek/web-search
// rows read that too, and an in-session /model switch back to the official route must
// not take the custom endpoint's key to api.deepseek.com with it.
expect(applied.envOverrides.CODEMAN_CUSTOM_MODEL_API_KEY).toBe(endpoint.apiKey);
expect(applied.envOverrides.DEEPSEEK_API_KEY).toBeUndefined();
const patch = JSON.parse(content);
const provider = patch.find((row: { id: string }) => row.id === 'llm-pi-ai').config.providers['codeman-custom'];
expect(provider.api).toBe('openai-completions');
expect(provider.baseURL).toBe('http://box:8080/v1');
expect(provider.models).toEqual([
{ id: 'chosen-model', name: 'chosen-model', contextWindow: 131072, maxTokens: 8192 },
]);
expect(patch.find((row: { id: string }) => row.id === 'dsh-tui').config).toEqual({
provider: 'codeman-custom',
model: 'chosen-model',
});
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(realHome, 'profiles'));
// The rest of the linkFromRealHome list, consumed generically: `sessions` is the
// transcript root removeConfigDir would otherwise delete, and the user's settings.yaml
// must keep applying through the redirect.
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
expect(realpathSync(join(applied.configDir, 'settings.yaml'))).toBe(join(realHome, 'settings.yaml'));
// Re-apply onto the same config dir (boot recovery re-runs this every restart): the
// lstat guard must skip every existing link, which the old existsSync check missed:
// recreating them would raise EEXIST.
const reapplied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
expect(reapplied?.envOverrides.DSH_HOME).toBe(applied.configDir);
removeConfigDir(applied.configDir);
expect(readFileSync(original, 'utf8')).toBe('original profile');
});
it('creates missing directory targets before linking, skips a missing file target, stays fine on re-apply', () => {
// A fresh install (or relocated DSH_HOME) has never run a turn, so the real home
// carries none of the linked entries. Linking a missing DIRECTORY left a dangling
// symlink whose every write dsh attempts THROUGH it failed with ENOENT on EVERY turn,
// and the lstat guard kept that dangling link forever. Directories are now created
// (0700, what dsh itself does on a first turn) before linking; a missing FILE
// (`settings.yaml`) is simply skipped. A stubbed HOME and CODEMAN_DATA_DIR keep both
// the fake real home and the generated dir off the developer's own dotfiles.
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-fresh-home-'));
const realHome = join(sandbox, '.dsh');
mkdirSync(realHome, { recursive: true }); // exists, but no sessions/, no profiles/, no settings.yaml
vi.stubEnv('HOME', sandbox);
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
vi.stubEnv('DSH_HOME', ''); // force resolution down to the recipe's `realHomeFallback`
try {
const entry = getCli('deepseek');
if (!entry) throw new Error('Missing DeepSeek entry');
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
if (!applied?.configDir) throw new Error('Missing isolated profile');
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(realHome, 'profiles'));
expect(statSync(join(realHome, 'sessions')).mode & 0o777).toBe(0o700);
// The missing FILE target is skipped outright, not a dangling link either: lstat
// must find nothing, so a later apply (once the file exists for real) links it.
expect(existsSync(join(realHome, 'settings.yaml'))).toBe(false);
expect(readlinkSafe(join(applied.configDir, 'settings.yaml'))).toBeUndefined();
// Re-apply stays fine (boot recovery re-runs it every restart): idempotent...
const reapplied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
expect(reapplied?.envOverrides.DSH_HOME).toBe(applied.configDir);
// ...and a target deleted AFTER linking keeps the existing link untouched (the
// lstat guard), rather than recreating it and throwing EEXIST.
rmSync(join(realHome, 'sessions'), { recursive: true, force: true });
expect(
applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072)?.envOverrides.DSH_HOME
).toBe(applied.configDir);
expect(readlinkSync(join(applied.configDir, 'sessions'))).toBe(join(realHome, 'sessions'));
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
it('links from the SESSION-supplied DSH_HOME, not the server environment one', () => {
// Both web routes pass the session's own env-override lookup as the seventh
// argument; a user who relocated the dsh home via an allowlisted DSH_HOME
// override must get their state linked from THAT directory, not the server's.
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-session-home-'));
const serverHome = join(sandbox, 'server-home');
const sessionHome = join(sandbox, 'session-home');
mkdirSync(join(sessionHome, 'sessions'), { recursive: true });
mkdirSync(join(sessionHome, 'profiles'), { recursive: true });
writeFileSync(join(sessionHome, 'settings.yaml'), 'theme: light');
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
vi.stubEnv('DSH_HOME', serverHome);
try {
const entry = getCli('deepseek');
if (!entry) throw new Error('Missing DeepSeek entry');
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
const applied = applyCustomModelInjection(
entry,
endpoint,
'chosen-model',
'deepseek-probe',
131072,
undefined,
(k) => (k === 'DSH_HOME' ? sessionHome : undefined)
);
if (!applied?.configDir) throw new Error('Missing isolated profile');
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(sessionHome, 'sessions'));
expect(realpathSync(join(applied.configDir, 'profiles'))).toBe(join(sessionHome, 'profiles'));
expect(realpathSync(join(applied.configDir, 'settings.yaml'))).toBe(join(sessionHome, 'settings.yaml'));
// The server-environment home was never consulted, let alone created.
expect(existsSync(serverHome)).toBe(false);
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
it('never self-links when the session override IS its own config dir; falls through to the server home', () => {
// Boot recovery re-applies onto a session already holding a previous injection's
// DSH_HOME value; linking that value would point the isolated directory at itself.
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-self-home-'));
const serverHome = join(sandbox, 'server-home');
mkdirSync(join(serverHome, 'sessions'), { recursive: true });
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
vi.stubEnv('DSH_HOME', serverHome);
try {
const entry = getCli('deepseek');
if (!entry) throw new Error('Missing DeepSeek entry');
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
const applied = applyCustomModelInjection(
entry,
endpoint,
'chosen-model',
'deepseek-probe',
131072,
undefined,
(k) => (k === 'DSH_HOME' ? customModelConfigDir('deepseek-probe') : undefined)
);
if (!applied?.configDir) throw new Error('Missing isolated profile');
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(serverHome, 'sessions'));
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
it('skips a relative DSH_HOME rather than creating it under the server cwd', () => {
// A session may supply DSH_HOME; `work` would otherwise mkdir `work/sessions` in
// the SERVER's working directory and leave relative links that dangle (a relative
// home resolves against the CLI's cwd, which the server cannot know). Skipping it
// falls through to the recipe's `realHomeFallback`, and nothing is created under cwd.
const sandbox = mkdtempSync(join(tmpdir(), 'dsh-relative-home-'));
const fallbackHome = join(sandbox, '.dsh');
mkdirSync(fallbackHome, { recursive: true });
const relativeHome = `dsh-relative-probe-${process.pid}-${Date.now()}`;
vi.stubEnv('HOME', sandbox); // the fallback resolves under homedir()
vi.stubEnv('CODEMAN_DATA_DIR', join(sandbox, 'data'));
vi.stubEnv('DSH_HOME', relativeHome);
try {
const entry = getCli('deepseek');
if (!entry) throw new Error('Missing DeepSeek entry');
const endpoint = { id: 'local', label: 'Local', baseUrl: 'http://box:8080', apiKey: 'private-probe-key' };
const applied = applyCustomModelInjection(entry, endpoint, 'chosen-model', 'deepseek-probe', 131072);
if (!applied?.configDir) throw new Error('Missing isolated profile');
expect(existsSync(join(process.cwd(), relativeHome))).toBe(false);
expect(realpathSync(join(applied.configDir, 'sessions'))).toBe(join(fallbackHome, 'sessions'));
} finally {
rmSync(sandbox, { recursive: true, force: true });
}
});
});
/** readlinkSync without the throw an absent link causes (ENOENT -> undefined). */
function readlinkSafe(path: string): string | undefined {
try {
return readlinkSync(path);
} catch {
return undefined;
}
}
+5 -8
View File
@@ -4,10 +4,11 @@
* Two of these assertions exist because the obvious implementation was measured
* to be wrong against real files:
*
* - dsh appends ONE ZSTD FRAME PER WRITE, and Node's `zlib` zstd decoder stops
* at the first frame end. A 56-line transcript decoded as 1 line / 158 bytes,
* which reads as "the worker never answered" rather than as an error. The
* multi-frame fixtures below are the guard.
* - dsh appends ONE ZSTD FRAME PER WRITE, and Node's `zlib` zstd decoder
* before 24.22 and 26.10 stops at the first frame end. A 56-line transcript
* decoded as 1 line / 158 bytes, which reads as "the worker never answered"
* rather than as an error. Newer Node decodes every frame (nodejs/node#65865),
* so the multi-frame fixtures below pin the walker itself, not Node's decoder.
* - a fresh worker in a case directory that had been used before answered its
* first `last-response` with the PREVIOUS session's reply. Session-to-
* transcript pairing is therefore its own describe block.
@@ -67,10 +68,6 @@ describe.skipIf(!zstdSupported())('zstd frame walking', () => {
const lines = Array.from({ length: 40 }, (_, i) => JSON.stringify({ type: 'noise', seq: i }));
const buf = framed(lines);
// The one-shot decoder is what this module had to replace.
const oneShot = (zlib as unknown as { zstdDecompressSync?: (b: Buffer) => Buffer }).zstdDecompressSync!(buf);
expect(oneShot.toString('utf8').trim().split('\n')).toHaveLength(1);
expect(zstdFrameRanges(buf)).toHaveLength(40);
expect(decodeZstdFrames(buf).trim().split('\n')).toHaveLength(40);
});
File diff suppressed because it is too large. Load diff
+208
View File
@@ -0,0 +1,208 @@
// @vitest-environment node
/**
* @fileoverview The glue between WebServer and the Discord bridge: alerts reach it even with no
* Web Push subscription (with the rendered screen as the tail), and tab events reach it routed by
* kind. The bridge and routes are tested on their own; this pins the lines that make the feature
* exist. A real WebServer, never started, with the bridge replaced by a recorder.
*
* Port: N/A (no server start).
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { sendNotification } = vi.hoisted(() => ({ sendNotification: vi.fn(async () => undefined) }));
vi.mock('web-push', () => ({
default: { sendNotification, setVapidDetails: vi.fn(), generateVAPIDKeys: vi.fn() },
}));
import { WebServer } from '../src/web/server.js';
import { SseEvent } from '../src/web/sse-events.js';
type Internals = {
pushStore: { getAll: () => never[]; dispose: () => void };
discordBridge: Record<string, ReturnType<typeof vi.fn>>;
sessions: Map<string, unknown>;
mux: { capturePaneText?: (name: string) => string | null };
lastResponseFor?: (session: unknown, context?: string) => Promise<{ text: string }>;
sendPushNotifications: (event: string, data: Record<string, unknown>) => Promise<void>;
broadcast: (event: string, data: unknown) => void;
cleanupSession: (id: string, killMux?: boolean, reason?: string) => Promise<void>;
discardPartiallyBuiltSession: (id: string) => Promise<void>;
};
function server(): Internals {
const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals;
s.pushStore = { getAll: () => [], dispose: () => undefined };
s.discordBridge = {
notify: vi.fn(async () => null),
sessionCreated: vi.fn(async () => undefined),
sessionRenamed: vi.fn(async () => undefined),
sessionDeleted: vi.fn(async () => undefined),
forgetTab: vi.fn(),
stop: vi.fn(),
};
return s;
}
beforeEach(() => sendNotification.mockClear());
describe('bridge deps', () => {
it('agentExited is true only for a session whose pane exit was observed', () => {
const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals & {
discordBridge: { deps: { agentExited: (id: string) => boolean } };
};
s.sessions.set('dead', { paneExit: { observedAt: 1 } });
s.sessions.set('alive', {});
const { agentExited } = s.discordBridge.deps;
expect(agentExited('dead')).toBe(true);
expect(agentExited('alive')).toBe(false);
expect(agentExited('missing')).toBe(false);
});
it("types a reply as the person's own input, so it can name the tab", async () => {
const s = new WebServer(0, false, true, '127.0.0.1', 'box') as unknown as Internals & {
discordBridge: { deps: { sendInput: (id: string, text: string) => Promise<boolean> } };
};
const writeViaMux = vi.fn(async () => true);
s.sessions.set('s1', { writeViaMux });
expect(await s.discordBridge.deps.sendInput('s1', 'yes\r')).toBe(true);
expect(writeViaMux).toHaveBeenCalledWith('yes\r', { fromUser: true });
expect(await s.discordBridge.deps.sendInput('missing', 'yes\r')).toBe(false);
});
});
describe('sendPushNotifications -> Discord', () => {
it('posts a permission prompt with zero push subscriptions, with the rendered screen as the tail', async () => {
const s = server();
s.sessions.set('s1', { muxName: 'codeman-s1', textOutput: '', mode: 'claude' });
const capture = vi.fn(() => 'Which option?\n──────────\n 1. Cap GPUs\n\n 2. Cap CPU\n');
s.mux = { capturePaneText: capture };
await s.sendPushNotifications('hook:permission_prompt', {
sessionId: 's1',
sessionName: 'w1-app',
tool_name: 'Bash',
});
expect(s.discordBridge.notify).toHaveBeenCalledTimes(1);
const msg = s.discordBridge.notify.mock.calls[0][0];
expect(msg).toMatchObject({
event: 'hook:permission_prompt',
title: 'Permission Required',
body: '[w1-app] Tool: Bash',
urgency: 'critical',
sessionId: 's1',
sessionName: 'w1-app',
});
// A function, so the screen is read (a synchronous tmux exec) only once the bridge will post.
expect(typeof msg.tail).toBe('function');
expect(capture).not.toHaveBeenCalled();
expect(msg.tail()).toBe('Which option?\n 1. Cap GPUs\n 2. Cap CPU');
expect(capture).toHaveBeenCalledTimes(1);
expect(sendNotification).not.toHaveBeenCalled();
});
it('reads the whole last message from the transcript on a finished turn, and keeps the screen for a dialog', async () => {
const s = server();
s.sessions.set('s5', { muxName: 'codeman-s5', textOutput: '', mode: 'claude' });
const reader = vi.fn(async () => ({ text: 'The full answer.\n\n- one\n- two', timestamp: '2026-10-30T00:00:00Z' }));
s.lastResponseFor = reader;
s.mux = { capturePaneText: () => 'screen\n❯\n status' };
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's5', sessionName: 'w5' });
const idle = s.discordBridge.notify.mock.calls[0][0];
expect(await idle.fullText()).toEqual({
text: 'The full answer.\n\n- one\n- two',
version: '2026-10-30T00:00:00Z',
});
expect(idle.tail()).toBe('screen'); // the composer row and the status under it are cut
await s.sendPushNotifications('hook:permission_prompt', { sessionId: 's5', sessionName: 'w5' });
const dialog = s.discordBridge.notify.mock.calls[1][0];
expect(dialog.fullText).toBeUndefined();
expect(dialog.tail()).toBe('screen\n❯\n status');
});
it.each(['claude', 'codex', 'opencode', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp', 'copilot'])(
'a finished turn in a %s tab posts the whole last message',
async (mode) => {
const s = server();
s.sessions.set('s6', { muxName: 'codeman-s6', textOutput: '', mode });
s.lastResponseFor = vi.fn(async () => ({ text: '1. First?\n2. Second?\n3. Third?' }));
s.mux = { capturePaneText: () => 'screen' };
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's6', sessionName: 'w6' });
const msg = s.discordBridge.notify.mock.calls[0][0];
expect((await msg.fullText()).text).toBe('1. First?\n2. Second?\n3. Third?');
}
);
it('a shell tab has no turns, so it keeps the screen tail', async () => {
const s = server();
s.sessions.set('s7', { muxName: 'codeman-s7', textOutput: '', mode: 'shell' });
s.lastResponseFor = vi.fn(async () => ({ text: 'x' }));
s.mux = { capturePaneText: () => 'prompt$' };
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's7', sessionName: 'w7' });
const msg = s.discordBridge.notify.mock.calls[0][0];
expect(await msg.fullText()).toBeUndefined();
expect(msg.tail()).toBe('prompt$');
});
it('falls back to plain text output when there is no tmux pane, and to no tail for an unknown session', async () => {
const s = server();
s.sessions.set('s2', { muxName: null, textOutput: 'plain answer\n', mode: 'claude' });
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 's2', sessionName: 'w2' });
expect(s.discordBridge.notify.mock.calls[0][0].tail()).toBe('plain answer');
await s.sendPushNotifications('hook:idle_prompt', { sessionId: 'gone', sessionName: 'w3' });
expect(s.discordBridge.notify.mock.calls[1][0].tail).toBeUndefined();
});
it('sends nothing for an event Web Push does not carry', async () => {
const s = server();
await s.sendPushNotifications('session:created', { sessionId: 's1', sessionName: 'w1' });
expect(s.discordBridge.notify).not.toHaveBeenCalled();
});
});
describe('broadcast -> Discord tab lifecycle', () => {
it('routes created, updated and deleted by kind, reading both payload shapes', () => {
const s = server();
s.broadcast(SseEvent.SessionCreated, { session: { id: 's1', name: 'w1', createdAt: 123 } });
expect(s.discordBridge.sessionCreated).toHaveBeenCalledWith('s1', 'w1');
s.broadcast(SseEvent.SessionUpdated, { id: 's1', name: 'renamed' });
expect(s.discordBridge.sessionRenamed).toHaveBeenCalledWith('s1', 'renamed');
});
it('does not touch the thread on session:deleted, which a detach also fires', () => {
const s = server();
s.broadcast(SseEvent.SessionDeleted, { id: 's1' });
expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled();
});
it('tells the bridge about a real delete (killMux true) but not a detach (killMux false)', async () => {
const s = server();
await s.cleanupSession('gone', false);
expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled();
await s.cleanupSession('gone', true);
expect(s.discordBridge.sessionDeleted).toHaveBeenCalledWith('gone');
});
it('a discarded half-built session clears the per-tab state but keeps its thread', async () => {
const s = server();
s.sessions.set('rebuild', {
owner: 'u',
ralphTracker: { stopWatchingFixPlan: () => undefined, fullReset: () => undefined },
removeAllListeners: () => undefined,
stop: async () => undefined,
});
await s.discardPartiallyBuiltSession('rebuild');
expect(s.discordBridge.forgetTab).toHaveBeenCalledWith('rebuild');
expect(s.discordBridge.sessionDeleted).not.toHaveBeenCalled();
});
it('ignores an event with no session id', () => {
const s = server();
s.broadcast(SseEvent.SessionUpdated, {});
expect(s.discordBridge.sessionRenamed).not.toHaveBeenCalled();
});
it('closes the bridge when the server stops', async () => {
const s = server();
await (s as unknown as { stop: () => Promise<void> }).stop();
expect(s.discordBridge.stop).toHaveBeenCalledTimes(1);
});
});
+82
View File
@@ -0,0 +1,82 @@
// @vitest-environment node
/**
* @fileoverview The Discord startup hint reaches Claude as `--append-system-prompt`, only while the
* bridge and its `startupHint` setting are on, and never changes a launch that does not use it.
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getCli } from '../src/config/cli-registry/registry.js';
import { buildSpawnCommandFromRegistry } from '../src/session-cli-registry-bridge.js';
import {
DiscordBridge,
DISCORD_STARTUP_HINT,
getDiscordStartupHint,
writeDiscordConfig,
DEFAULT_DISCORD_CONFIG,
} from '../src/discord-bridge.js';
const SID = '0f9c2b14-1111-2222-3333-444455556666';
const BASE = 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"';
function launch(mode: 'claude' | 'codex'): string | undefined {
return buildSpawnCommandFromRegistry(getCli(mode)!, { mode, sessionId: SID, claudeCliVersion: null });
}
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'discord-hint-'));
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
function bridge() {
return new DiscordBridge({
configDir: dir,
fetchImpl: async () => new Response('{}'),
sendInput: async () => true,
socketFactory: () => ({ send() {}, close() {}, on() {} }),
});
}
describe('startup hint on the Claude launch', () => {
it('is absent by default, so a launch is byte-identical to before', () => {
expect(getDiscordStartupHint()).toBeUndefined();
expect(launch('claude')).toBe(BASE);
});
it('is appended, single-quoted, while the bridge is on', async () => {
await writeDiscordConfig(dir, {
...DEFAULT_DISCORD_CONFIG,
enabled: true,
token: 'tok',
guildId: '111111111111111111',
parentChannelId: '222222222222222222',
allowedUserIds: ['333333333333333333'],
});
const b = bridge();
await b.reload();
expect(getDiscordStartupHint()).toBe(DISCORD_STARTUP_HINT);
expect(launch('claude')).toBe(`${BASE} --append-system-prompt '${DISCORD_STARTUP_HINT}'`);
expect(DISCORD_STARTUP_HINT).not.toContain("'");
expect(launch('codex')).not.toContain('append-system-prompt');
b.stop();
expect(launch('claude')).toBe(BASE);
});
it('is absent when startupHint is switched off', async () => {
await writeDiscordConfig(dir, {
...DEFAULT_DISCORD_CONFIG,
enabled: true,
token: 'tok',
guildId: '111111111111111111',
parentChannelId: '222222222222222222',
allowedUserIds: ['333333333333333333'],
startupHint: false,
});
const b = bridge();
await b.reload();
expect(launch('claude')).toBe(BASE);
b.stop();
});
});
+16 -1
View File
@@ -26,7 +26,7 @@
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
import { readFileSync, mkdtempSync, rmSync, writeFileSync, mkdirSync, chownSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
@@ -309,6 +309,21 @@ describe('Update-Codeman.sh (the scripted major-update path — docker/README.md
const casesPath = join(dir, 'cases');
const socketPath = join(dir, 'docker.sock'); // deliberately NOT a real socket — see above
mkdirSync(appdataPath);
// Both scripts derive PUID:PGID from the appdata owner and REFUSE a
// root-owned one (correctly: the container must never run as root).
// mkdirSync gives it the runner's uid, so under a root runner (a CI or
// agent container) the update path stopped at that guard before its
// build. Give it the non-root owner docker/README.md's own
// `chown -R 99:100` example sets up, as a real deployment would have.
if (process.getuid?.() === 0) {
try {
chownSync(appdataPath, 99, 100);
} catch {
// No CAP_CHOWN (root with --cap-drop ALL, as Codeman's own Docker
// case containers run) or a single-uid user namespace: leave it
// root-owned and let the scripts' own guard refuse, as before.
}
}
mkdirSync(casesPath);
writeFileSync(socketPath, '');
+23
View File
@@ -28,6 +28,7 @@ import {
persistDockerCaseClaudeSessionId,
probeDockerCliVersion,
readDockerCases,
readDockerCasesOrThrow,
readDockerHosts,
resolveClaudeJsonSeedMount,
resolveDockerClaudeArtifacts,
@@ -70,6 +71,28 @@ describe('docker-hosts storage', () => {
expect(await readDockerCases(dir)).toEqual([]);
});
/**
* The git fetch paths read the list through the strict reader: the writer truncates and rewrites the file in
* place, and a list read as `[]` there would let a fetch run git inside a Docker workspace.
*/
it('has a strict reader that rejects a list it cannot parse, where the plain reader returns []', async () => {
expect(await readDockerCasesOrThrow(dir)).toEqual([]); // missing: no cases
await writeDockerCases(dir, [CASE]);
expect(await readDockerCasesOrThrow(dir)).toEqual([CASE]);
const path = join(dir, 'docker-cases.json');
for (const text of ['', '[{"name":"myproj","hostWorkspacePath":"/home/arkon/cas', '{"name":"myproj"}', 'null']) {
writeFileSync(path, text);
expect(await readDockerCases(dir)).toEqual([]);
const err = await readDockerCasesOrThrow(dir).then(
() => null,
(e: Error) => e
);
expect(err?.message).toMatch(/not a valid JSON array/);
// The parser's message quotes the file; the strict reader's never does.
expect(err?.message).not.toContain('/home/arkon/cas');
}
});
it('persists the last Claude conversation id keyed by container name', async () => {
await writeDockerCases(dir, [CASE, { ...CASE, name: 'other', container: 'custom-name' }]);
await persistDockerCaseClaudeSessionId(dir, dockerContainerName(CASE.name), 'conv-1');
+165
View File
@@ -0,0 +1,165 @@
/**
* Local edit-source reader against a temp projects dir: the transcript lookup, byte-offset
* reads of the main transcript and its subagents, and the image probe.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
findClaudeTranscript,
probeLocalPaths,
readClaudeEditSources,
resolveClaudeTranscript,
} from '../src/edit-history-local-source.js';
let root: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'edit-src-'));
});
afterEach(() => {
rmSync(root, { recursive: true, force: true });
});
function put(rel: string, content: string | Buffer, mtimeSec?: number): string {
const path = join(root, rel);
mkdirSync(join(path, '..'), { recursive: true });
writeFileSync(path, content);
if (mtimeSec) utimesSync(path, mtimeSec, mtimeSec);
return path;
}
describe('findClaudeTranscript', () => {
it('returns null when no transcript exists under any id', async () => {
put('p/other.jsonl', 'x\n');
expect(await findClaudeTranscript(['sid'], root)).toBeNull();
expect(await findClaudeTranscript(['sid'], join(root, 'missing'))).toBeNull();
});
it('takes the first id that has a transcript, the newest one across project dirs', async () => {
put('a/sid.jsonl', 'old\n', 1000);
const newest = put('b/sid.jsonl', 'new\n', 2000);
put('c/second.jsonl', 'x\n');
expect(await findClaudeTranscript(['bad id!', 'sid', 'second'], root)).toBe(newest);
});
});
describe('resolveClaudeTranscript', () => {
it('takes the watcher path (what the hooks reported) as is', async () => {
expect(await resolveClaudeTranscript('/hooked/conv.jsonl', ['sid'], join(root, 'cfg'))).toBe('/hooked/conv.jsonl');
});
it("without one, looks under the session's own claude config dir", async () => {
const path = put('cfg/projects/p/sid.jsonl', 'x\n');
expect(await resolveClaudeTranscript(null, ['sid'], join(root, 'cfg'))).toBe(path);
expect(await resolveClaudeTranscript(undefined, ['other'], join(root, 'cfg'))).toBeNull();
});
});
describe('readClaudeEditSources', () => {
it('returns null for a missing file or a path that is not an absolute <id>.jsonl', async () => {
const path = put('p/sid.jsonl', 'x\n');
expect(await readClaudeEditSources(join(root, 'p/gone.jsonl'), [], 100)).toBeNull();
expect(await readClaudeEditSources('p/sid.jsonl', [], 100)).toBeNull();
expect(await readClaudeEditSources(path.replace('.jsonl', '.txt'), [], 100)).toBeNull();
expect(await readClaudeEditSources(put('p/bad id.jsonl', 'x\n'), [], 100)).toBeNull();
});
it('names the conversation after the file', async () => {
const res = await readClaudeEditSources(put('p/conv-1.jsonl', 'x\n'), [], 100);
expect(res?.transcriptId).toBe('conv-1');
});
it('reads a new file from its end within the budget, a requested one from its offset', async () => {
const path = put('p/sid.jsonl', '0123456789');
const fresh = await readClaudeEditSources(path, [], 4);
expect(fresh?.files).toEqual([{ relPath: 'main', size: 10, startOffset: 6, chunk: Buffer.from('6789') }]);
const cont = await readClaudeEditSources(path, [{ relPath: 'main', offset: 3, len: 2 }], 100);
expect(cont?.files[0]).toMatchObject({ startOffset: 3, chunk: Buffer.from('34') });
});
it('clamps an offset past the end to the size (a replaced, shorter file)', async () => {
const path = put('p/sid.jsonl', 'abc');
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 50 }], 100);
expect(res?.files[0]).toMatchObject({ size: 3, startOffset: 3, chunk: Buffer.alloc(0) });
});
it('lists subagents with their meta for new ones only, and shares the budget main first', async () => {
const path = put('p/sid.jsonl', 'MAIN');
put('p/sid/subagents/agent-a1.jsonl', 'AAAA');
put('p/sid/subagents/agent-a1.meta.json', '{"description":"Fix it"}');
put('p/sid/subagents/agent-b2.jsonl', 'BBBB');
put('p/sid/subagents/agent-b2.meta.json', '{"description":"Other"}');
put('p/sid/subagents/notes.txt', 'ignored');
const res = await readClaudeEditSources(path, [{ relPath: 'subagents/agent-b2.jsonl', offset: 0 }], 6);
expect(res?.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([
['main', 0, 'MAIN'],
['subagents/agent-a1.jsonl', 2, 'AA'],
['subagents/agent-b2.jsonl', 0, ''],
]);
expect(res?.metas).toEqual({ a1: '{"description":"Fix it"}' });
});
it('ignores invalid requests', async () => {
const path = put('p/sid.jsonl', 'abcdef');
const res = await readClaudeEditSources(
path,
[
{ relPath: '../etc/passwd', offset: 0 },
{ relPath: 'main', offset: -1 },
],
3
);
// The invalid main request is dropped, so main is read like a new file.
expect(res?.files[0]).toMatchObject({ startOffset: 3, chunk: Buffer.from('def') });
});
it('keeps multibyte bytes intact (byte offsets, not string lengths)', async () => {
const path = put('p/sid.jsonl', Buffer.from('äöü\n', 'utf8'));
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 2 }], 100);
expect(res?.files[0].chunk).toEqual(Buffer.from('äöü\n', 'utf8').subarray(2));
});
it('with onlyRequested, reads the requested files and nothing else', async () => {
const path = put('p/sid.jsonl', 'MAIN');
put('p/sid/subagents/agent-a1.jsonl', 'AAAA');
put('p/sid/subagents/agent-b2.jsonl', 'BBBB');
const res = await readClaudeEditSources(path, [{ relPath: 'subagents/agent-b2.jsonl', offset: 1, len: 2 }], 100, {
onlyRequested: true,
});
expect(res?.files.map((f) => [f.relPath, f.startOffset, f.chunk.toString()])).toEqual([
['subagents/agent-b2.jsonl', 1, 'BB'],
]);
});
it('reads the end of a block that does not fit the budget', async () => {
const path = put('p/sid.jsonl', '0123456789');
const res = await readClaudeEditSources(path, [{ relPath: 'main', offset: 2, len: 6 }], 3);
expect(res?.files[0]).toMatchObject({ startOffset: 5, chunk: Buffer.from('567') });
});
});
describe('probeLocalPaths', () => {
it('reports size and mtime of files, expands ~/ against home, null for missing and directories', async () => {
put('home/img.png', 'PNG!', 1_700_000_000);
mkdirSync(join(root, 'dir'));
const res = await probeLocalPaths(['~/img.png', join(root, 'dir'), join(root, 'nope.png')], join(root, 'home'));
expect(res[0]).toEqual({ realPath: join(root, 'home/img.png'), kind: 'file', size: 4, mtimeMs: 1_700_000_000_000 });
expect(res[1]).toBeNull();
expect(res[2]).toBeNull();
});
it('touches a path only after the bounded probe saw a file there', async () => {
put('a.png', 'PNG!', 1_700_000_000);
put('b.png', 'PNG!', 1_700_000_000);
const asked: string[] = [];
const res = await probeLocalPaths([join(root, 'a.png'), join(root, 'b.png'), 'rel.png'], root, async (p) => {
asked.push(p);
return p.endsWith('a.png') ? 'unknown' : 'file';
});
// a.png exists, but its filesystem did not answer the bounded probe: no unbounded stat.
expect(res.map((r) => r?.realPath ?? null)).toEqual([null, join(root, 'b.png'), null]);
expect(asked).toEqual([join(root, 'a.png'), join(root, 'b.png')]); // relative: never probed
});
});
+420
View File
@@ -0,0 +1,420 @@
/**
* Edit history parser: claude transcript lines → edit records and Bash image candidates.
* Fixture lines mirror real transcript records (Claude Code 2.1.300), trimmed to the
* fields the parser reads plus the bulky ones it must ignore (`originalFile`, `content`).
*/
import { describe, expect, it } from 'vitest';
import {
classifyEditPath,
extractImagePathCandidates,
MAX_HUNK_BYTES,
MAX_IMAGE_CANDIDATES,
MAX_PENDING_BASH,
MAX_PENDING_COMMAND_CHARS,
MAX_PENDING_FIELD_CHARS,
parseClaudeEditLines,
} from '../src/edit-history-parse.js';
const CWD = '/work/repo';
function toolResultLine(id: string, ts: string, toolUseResult: unknown, extra: Record<string, unknown> = {}): string {
return JSON.stringify({
type: 'user',
isSidechain: false,
timestamp: ts,
cwd: CWD,
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult,
...extra,
});
}
function bashUseLine(id: string, ts: string, command: string, extra: Record<string, unknown> = {}): string {
return JSON.stringify({
type: 'assistant',
isSidechain: false,
timestamp: ts,
cwd: CWD,
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
...extra,
});
}
const hunk = (lines: string[]) => ({ oldStart: 3, oldLines: 2, newStart: 3, newLines: 3, lines });
const editLine = toolResultLine('toolu_edit', '2026-10-15T19:01:28.880Z', {
filePath: '/work/repo/src/app.ts',
oldString: 'a',
newString: 'b',
originalFile: 'x'.repeat(5000),
structuredPatch: [hunk([' keep', '-old', '+new', '+added'])],
userModified: false,
replaceAll: false,
});
describe('parseClaudeEditLines: edits', () => {
it('turns an Edit result into a record without the whole-file fields', () => {
const { records } = parseClaudeEditLines(editLine + '\n');
expect(records).toEqual([
{
id: 'toolu_edit',
ts: Date.parse('2026-10-15T19:01:28.880Z'),
path: '/work/repo/src/app.ts',
kind: 'code',
tool: 'Edit',
created: false,
added: 2,
removed: 1,
hunks: [hunk([' keep', '-old', '+new', '+added'])],
truncated: false,
},
]);
expect(JSON.stringify(records)).not.toContain('xxxxx');
});
it('reads replaceAll edits like any other Edit', () => {
const line = toolResultLine('toolu_all', '2026-10-15T19:02:00.000Z', {
filePath: '/work/repo/a.ts',
oldString: 'x',
newString: 'y',
originalFile: '',
structuredPatch: [hunk(['-x', '+y']), { ...hunk(['-x', '+y']), oldStart: 9, newStart: 9 }],
userModified: false,
replaceAll: true,
});
const [rec] = parseClaudeEditLines(line).records;
expect(rec).toMatchObject({ tool: 'Edit', added: 2, removed: 2 });
expect(rec.hunks).toHaveLength(2);
});
it('marks a Write that created the file, and one that replaced it', () => {
const create = toolResultLine('toolu_c', '2026-10-15T19:03:00.000Z', {
type: 'create',
filePath: '/work/repo/new.md',
content: 'hello\nworld',
structuredPatch: [],
originalFile: null,
});
const update = toolResultLine('toolu_u', '2026-10-15T19:04:00.000Z', {
type: 'update',
filePath: '/work/repo/new.md',
content: 'hi',
structuredPatch: [hunk(['-hello', '-world', '+hi'])],
originalFile: 'hello\nworld',
});
const { records } = parseClaudeEditLines([create, update].join('\n'));
expect(records.map((r) => [r.tool, r.created, r.added, r.removed])).toEqual([
['Write', true, 2, 0],
['Write', false, 1, 2],
]);
expect(records[0].hunks).toEqual([
{ oldStart: 0, oldLines: 0, newStart: 1, newLines: 2, lines: ['+hello', '+world'] },
]);
});
it('a created file’s diff drops the trailing newline and is capped like any hunk', () => {
const big = 'x'.repeat(1000) + '\n';
const line = toolResultLine('toolu_big', '2026-10-15T19:03:30.000Z', {
type: 'create',
filePath: '/work/repo/big.txt',
content: big.repeat(100),
structuredPatch: [],
originalFile: null,
});
const [rec] = parseClaudeEditLines(line).records;
expect(rec.added).toBe(100);
expect(rec.truncated).toBe(true);
expect(rec.hunks[0].lines.length).toBeLessThan(100);
expect(rec.hunks[0].lines.every((l) => l === '+' + 'x'.repeat(1000))).toBe(true);
});
it('classifies an image written by Write as an image', () => {
const line = toolResultLine('toolu_svg', '2026-10-15T19:05:00.000Z', {
type: 'create',
filePath: '/work/repo/icon.SVG',
content: '<svg/>',
structuredPatch: [],
originalFile: null,
});
expect(parseClaudeEditLines(line).records[0].kind).toBe('image');
});
it('keeps a result of an unknown edit tool that has a path and a patch', () => {
const line = toolResultLine('toolu_nb', '2026-10-15T19:06:00.000Z', {
filePath: '/work/repo/n.ipynb',
structuredPatch: [hunk(['+cell'])],
});
expect(parseClaudeEditLines(line).records[0]).toMatchObject({ tool: 'edit', added: 1 });
});
it('skips failed tool results (a string), unrelated results and lines that do not parse', () => {
const failed = toolResultLine('toolu_err', '2026-10-15T19:07:00.000Z', 'Error: String to replace not found');
const read = toolResultLine('toolu_read', '2026-10-15T19:07:01.000Z', { type: 'text', file: { filePath: '/x' } });
const text = [failed, read, '{"type":"user",broken', '', 'not json'].join('\n');
expect(parseClaudeEditLines(text).records).toEqual([]);
});
it('skips a first line cut mid-record (a byte range starts inside it)', () => {
const cut = editLine.slice(40);
expect(parseClaudeEditLines(cut + '\n' + editLine).records).toHaveLength(1);
});
it('carries the subagent id of a sidechain line', () => {
const line = toolResultLine('toolu_sub', '2026-10-15T19:08:00.000Z', JSON.parse(editLine).toolUseResult, {
isSidechain: true,
agentId: 'a0038561ea4d57a42',
});
expect(parseClaudeEditLines(line).records[0].agentId).toBe('a0038561ea4d57a42');
});
it('caps an oversized patch and says so, counting lines from the whole patch', () => {
const big = Array.from({ length: 4000 }, (_, i) => `+line ${i} ${'y'.repeat(40)}`);
const line = toolResultLine('toolu_big', '2026-10-15T19:09:00.000Z', {
type: 'create',
filePath: '/work/repo/big.ts',
content: '',
structuredPatch: [hunk(big)],
originalFile: null,
});
const [rec] = parseClaudeEditLines(line).records;
expect(rec.truncated).toBe(true);
expect(rec.added).toBe(4000);
expect(JSON.stringify(rec.hunks).length).toBeLessThanOrEqual(MAX_HUNK_BYTES + 1024);
expect(rec.hunks[0].lines.length).toBeLessThan(4000);
});
});
describe('parseClaudeEditLines: Bash image candidates', () => {
const start = '2026-10-15T20:00:00.000Z';
const end = '2026-10-15T20:00:05.000Z';
it('pairs a Bash call with its result and collects image paths from command and output', () => {
const use = bashUseLine('toolu_b', start, 'python3 render.py --out out/frame.png');
const res = toolResultLine('toolu_b', end, {
stdout: 'Saved to: file:///tmp/gen/a b.png\nwrote /work/repo/out/thumb.webp.',
stderr: '',
interrupted: false,
isImage: false,
});
const { candidates, pending } = parseClaudeEditLines([use, res].join('\n'));
expect(pending).toEqual([]);
expect(candidates).toEqual([
{
toolUseId: 'toolu_b',
start: Date.parse(start),
end: Date.parse(end),
paths: ['/work/repo/out/frame.png', '/tmp/gen/a b.png', '/work/repo/out/thumb.webp'],
},
]);
});
it('yields no candidate for a Bash call without image paths', () => {
const use = bashUseLine('toolu_n', start, 'git status');
const res = toolResultLine('toolu_n', end, { stdout: 'clean', stderr: '', interrupted: false, isImage: false });
expect(parseClaudeEditLines([use, res].join('\n')).candidates).toEqual([]);
});
it('uses the tool_result text when the Bash call failed (toolUseResult is a string)', () => {
const use = bashUseLine('toolu_f', start, 'make render');
const res = JSON.stringify({
type: 'user',
timestamp: end,
cwd: CWD,
message: {
role: 'user',
content: [{ tool_use_id: 'toolu_f', type: 'tool_result', is_error: true, content: 'Exit 2\nwrote out/x.jpg' }],
},
toolUseResult: 'Error: Exit 2',
});
expect(parseClaudeEditLines([use, res].join('\n')).candidates[0].paths).toEqual(['/work/repo/out/x.jpg']);
});
it('hands an unanswered Bash call back as pending and resolves it in the next chunk', () => {
const first = parseClaudeEditLines(bashUseLine('toolu_p', start, 'convert a.svg out.png') + '\n');
expect(first.candidates).toEqual([]);
expect(first.pending).toEqual([
{ id: 'toolu_p', ts: Date.parse(start), command: 'convert a.svg out.png', cwd: CWD },
]);
const res = toolResultLine('toolu_p', end, { stdout: '', stderr: '', interrupted: false, isImage: false });
const second = parseClaudeEditLines(res, first.pending);
expect(second.pending).toEqual([]);
expect(second.candidates[0].paths).toEqual(['/work/repo/a.svg', '/work/repo/out.png']);
});
it('carries the subagent id onto the candidate', () => {
const use = bashUseLine('toolu_s', start, 'render > shot.png', { isSidechain: true, agentId: 'abc' });
const res = toolResultLine('toolu_s', end, { stdout: '', stderr: '', interrupted: false, isImage: false });
expect(parseClaudeEditLines([use, res].join('\n')).candidates[0].agentId).toBe('abc');
});
it('bounds the pending list, dropping the oldest', () => {
const lines = Array.from({ length: MAX_PENDING_BASH + 5 }, (_, i) =>
bashUseLine(`toolu_${i}`, start, 'sleep 1')
).join('\n');
const { pending } = parseClaudeEditLines(lines);
expect(pending).toHaveLength(MAX_PENDING_BASH);
expect(pending[0].id).toBe('toolu_5');
});
it(`keeps no call with an id, cwd or agent id over ${MAX_PENDING_FIELD_CHARS} characters (the store would refuse it on load)`, () => {
const long = 'x'.repeat(MAX_PENDING_FIELD_CHARS + 1);
const lines = [
bashUseLine(long, start, 'render > a.png'),
bashUseLine('toolu_cwd', start, 'render > a.png', { cwd: '/' + long }),
bashUseLine('toolu_agent', start, 'render > a.png', { agentId: long }),
bashUseLine('toolu_ok', start, 'render > a.png', { cwd: '/' + 'x'.repeat(MAX_PENDING_FIELD_CHARS - 1) }),
];
expect(parseClaudeEditLines(lines.join('\n')).pending.map((p) => p.id)).toEqual(['toolu_ok']);
});
it('keeps only the head of a long command (the pending list is persisted)', () => {
const command = 'render > out.png && ' + 'x'.repeat(MAX_PENDING_COMMAND_CHARS * 2);
const { pending } = parseClaudeEditLines(bashUseLine('toolu_long', start, command));
expect(pending[0].command).toBe(command.slice(0, MAX_PENDING_COMMAND_CHARS));
});
});
describe('parseClaudeEditLines: images sent to the user', () => {
it('takes the image attachments of a SendUserFile result, absolute paths only, without a window', () => {
const line = toolResultLine(
'toolu_send',
'2026-10-15T20:50:12.538Z',
{
caption: 'Vier Alternativen',
attachments: [
{ path: '/tmp/s/icons/bremse_alt1_pedal.png', file_uuid: 'x' },
{ path: '/tmp/s/icons/bremse_alt1_pedal.png' },
{ path: '/tmp/s/report.pdf' },
{ path: 'relative.png' },
{ nope: 1 },
],
},
{ agentId: 'a1' }
);
const { candidates, records } = parseClaudeEditLines(line);
expect(records).toEqual([]);
const at = Date.parse('2026-10-15T20:50:12.538Z');
expect(candidates).toEqual([
{
toolUseId: 'toolu_send',
start: at,
end: at,
paths: ['/tmp/s/icons/bremse_alt1_pedal.png'],
sent: true,
agentId: 'a1',
},
]);
});
});
describe('extractImagePathCandidates', () => {
it('keeps a colon inside a file:// path (timestamped names)', () => {
expect(extractImagePathCandidates('', 'Saved to file:///tmp/plot-12:30.png', '', CWD)).toEqual([
'/tmp/plot-12:30.png',
]);
});
it('resolves relative paths against cwd and keeps ~/ paths literal', () => {
expect(extractImagePathCandidates('cp a.png ../b.jpeg ~/shots/c.gif', '', '', CWD)).toEqual([
'/work/repo/a.png',
'/work/b.jpeg',
'~/shots/c.gif',
]);
});
it('reads quoted paths with spaces', () => {
expect(extractImagePathCandidates(`magick "in dir/x.png" 'out dir/y.avif'`, '', '', CWD)).toEqual([
'/work/repo/in dir/x.png',
'/work/repo/out dir/y.avif',
]);
});
it('ignores URLs, globs and names that only contain an image extension', () => {
const cmd = 'curl https://x.org/a.png; ls *.png out/frame-?.png; mv a.png.bak b.pngx; echo c.png';
expect(extractImagePathCandidates(cmd, '', '', CWD)).toEqual(['/work/repo/c.png']);
});
it('splits key=value and trailing punctuation off', () => {
expect(extractImagePathCandidates('render --out=out/a.png', 'done (out/b.png), see out/c.png.', '', CWD)).toEqual([
'/work/repo/out/a.png',
'/work/repo/out/b.png',
'/work/repo/out/c.png',
]);
});
it('deduplicates and caps the list', () => {
const many = Array.from({ length: MAX_IMAGE_CANDIDATES + 10 }, (_, i) => `f${i}.png`).join(' ');
const out = extractImagePathCandidates(`a.png a.png ${many}`, '', '', CWD);
expect(out).toHaveLength(MAX_IMAGE_CANDIDATES);
expect(out[0]).toBe('/work/repo/a.png');
expect(out[1]).toBe('/work/repo/f0.png');
});
it('drops relative paths when cwd is unknown', () => {
expect(extractImagePathCandidates('x.png /abs/y.png', '', '', '')).toEqual(['/abs/y.png']);
});
});
describe('extractImagePathCandidates: linear in the line length (#693 review)', () => {
// Claude Code keeps up to 30,000 characters of Bash output, all of it agent-printed, and the
// scan runs synchronously in the request. Before the fix one such line took ~2.7 s.
const N = 30_000;
// Generous: the gated suite runs at nice 19 next to the live service. Linear is ~1 ms.
const BUDGET_MS = 1000;
const timed = (text: string) => {
const t = performance.now();
const out = extractImagePathCandidates('render', text, '', CWD);
return { out, ms: performance.now() - t };
};
const base64 = Buffer.from(Array.from({ length: (N * 3) / 4 }, (_, i) => (i * 131 + 7) & 255)).toString('base64');
it.each([
...'#+%@![{^:/a.~-$'.split('').map((c) => [`30,000 × ${c}`, c.repeat(N)] as const),
['a base64 blob', base64] as const,
['repeated file:///', 'file:///'.repeat(N / 8)] as const,
['repeated file:///x', 'file:///x'.repeat(N / 9)] as const,
['repeated file:///a:', 'file:///a:'.repeat(N / 10)] as const,
['file:/// then 30,000 characters', 'file:///' + 'a'.repeat(N)] as const,
['an unclosed quote', '"' + 'a'.repeat(N)] as const,
['repeated =a', '=a'.repeat(N / 2)] as const,
])('%s stays under the time budget and still finds a path after it', (_label, line) => {
const { out, ms } = timed(`${line}\nsaved out/plot.png`);
expect(ms).toBeLessThan(BUDGET_MS);
expect(out).toContain('/work/repo/out/plot.png');
});
it('parses a chunk of 139 Bash results with a 30,000-character base64 blob each under budget', () => {
const lines: string[] = [];
for (let i = 0; i < 139; i++) {
lines.push(bashUseLine(`toolu_${i}`, '2026-10-15T20:00:00.000Z', 'cat img.b64'));
lines.push(
toolResultLine(`toolu_${i}`, '2026-10-15T20:00:01.000Z', {
stdout: base64,
stderr: '',
interrupted: false,
isImage: false,
})
);
}
const t = performance.now();
parseClaudeEditLines(lines.join('\n'));
expect(performance.now() - t).toBeLessThan(3000); // 6.6 s before the fix
});
});
describe('classifyEditPath', () => {
it.each([
['/a/b.png', 'image'],
['/a/b.JPG', 'image'],
['/a/b.jpeg', 'image'],
['/a/b.webp', 'image'],
['/a/b.gif', 'image'],
['/a/b.avif', 'image'],
['/a/b.svg', 'image'],
['/a/b.ts', 'code'],
['/a/png', 'code'],
['/a/b.png.orig', 'code'],
])('%s → %s', (path, kind) => {
expect(classifyEditPath(path)).toBe(kind);
});
});
+214
View File
@@ -0,0 +1,214 @@
/**
* Edit history end to end: the real reader (edit-history-local-source.ts), sync and store on
* temp transcripts: what the fakes in edit-history-sync.test.ts cannot show (file order,
* a new conversation next to the old one, every line read exactly once).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { appendFileSync, existsSync, mkdirSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { readClaudeEditSources } from '../src/edit-history-local-source.js';
import { EditHistoryStore } from '../src/edit-history-store.js';
import { EditHistorySync, MAX_FORWARD_PASSES, type EditHistorySession } from '../src/web/edit-history-sync.js';
let root: string;
const opened: EditHistoryStore[] = [];
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'edit-pipeline-'));
});
afterEach(async () => {
await Promise.all(opened.splice(0).map((s) => s.remove()));
rmSync(root, { recursive: true, force: true });
});
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
/** One edit result line of `pad` bytes or more, newline included. */
function editLine(id: string, i: number, pad = 0, agentId?: string): string {
const rec: Record<string, unknown> = {
type: 'user',
timestamp: new Date(T0 + i * 1000).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult: {
filePath: `/w/${id}.ts`,
oldString: 'a',
newString: 'b',
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
},
pad: '',
};
if (agentId) rec.agentId = agentId;
rec.pad = 'p'.repeat(Math.max(0, pad - JSON.stringify(rec).length - 1));
return JSON.stringify(rec) + '\n';
}
function setup(budget?: number, base = root) {
const project = join(base, 'projects', 'p');
mkdirSync(project, { recursive: true });
const at = (transcriptId: string) => join(project, `${transcriptId}.jsonl`);
/** The transcript watcher's path (the hooks report it; `/clear` moves it). */
let current = at('convA');
const sync = new EditHistorySync({
transcriptPath: async () => (existsSync(current) ? current : null),
readSources: readClaudeEditSources,
probe: async (paths) => paths.map(() => null),
loadStore: async (id) => {
const store = await EditHistoryStore.load(id, { dir: join(base, 'store'), debounceMs: 10 });
opened.push(store);
return store;
},
deleteStore: (id) => EditHistoryStore.delete(id, { dir: join(base, 'store') }),
sweepStores: async () => 0,
budget,
});
const session: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'convA' };
const ids = async () =>
(await sync.getStore('s1'))
.list()
.map((e) => e.id)
.sort();
return {
project,
at,
sync,
session,
ids,
switchTo: (transcriptId: string) => (current = at(transcriptId)),
};
}
describe('a new conversation (/clear, resume)', () => {
it('reads the previous transcript to its end before the new one', async () => {
const { at, sync, session, ids, switchTo } = setup();
appendFileSync(at('convA'), editLine('a1', 1));
await sync.sync(session);
// The turn's last edits land, then /clear before the panel's debounced re-read.
appendFileSync(at('convA'), editLine('a2', 2) + editLine('a3', 3));
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 4));
await sync.sync(session);
expect(await ids()).toEqual(['a1', 'a2', 'a3', 'b1']);
const store = await sync.getStore('s1');
expect(store.getTranscriptId()).toBe('convB');
expect(store.knownSources()).toEqual(['main']);
});
it('a previous backlog larger than one sync reads on with the next ones; the new transcript follows', async () => {
const { at, sync, session, ids, switchTo } = setup(600);
appendFileSync(at('convA'), editLine('a0', 0));
await sync.sync(session);
const backlog = Array.from({ length: MAX_FORWARD_PASSES * 3 }, (_, i) => `a${i + 1}`);
appendFileSync(at('convA'), backlog.map((id, i) => editLine(id, i + 1, 500)).join(''));
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 99));
await sync.sync(session);
expect(await ids()).not.toContain('b1');
for (let i = 0; i < 5; i++) await sync.sync(session);
expect(await ids()).toEqual(['a0', ...backlog, 'b1'].sort());
});
it('subagents of the previous conversation are read to their end too', async () => {
const { project, at, sync, session, ids, switchTo } = setup();
const sub = join(project, 'convA', 'subagents');
mkdirSync(sub, { recursive: true });
appendFileSync(at('convA'), editLine('a1', 1));
appendFileSync(join(sub, 'agent-x.jsonl'), editLine('x1', 2, 0, 'x'));
await sync.sync(session);
appendFileSync(join(sub, 'agent-x.jsonl'), editLine('x2', 3, 0, 'x'));
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 4));
await sync.sync(session);
expect(await ids()).toEqual(['a1', 'b1', 'x1', 'x2']);
});
it('a previous transcript that is gone does not hold up the new one', async () => {
const { at, sync, session, ids, switchTo } = setup();
appendFileSync(at('convA'), editLine('a1', 1));
await sync.sync(session);
rmSync(at('convA'));
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 2));
await sync.sync(session);
expect(await ids()).toEqual(['a1', 'b1']);
});
it('"Load older" before the forward read moved on reads the stored conversation, not the new file', async () => {
const { at, sync, session, ids, switchTo } = setup(600);
const older = Array.from({ length: 4 }, (_, i) => `a${i}`);
appendFileSync(at('convA'), older.map((id, i) => editLine(id, i, 500)).join(''));
await sync.sync(session);
expect(await ids()).toEqual(['a3']);
switchTo('convB');
appendFileSync(at('convB'), editLine('b1', 99));
await sync.sync(session, { older: true });
expect(await ids()).toEqual(['a2', 'a3']);
});
});
describe('every line exactly once (seeded random transcripts)', () => {
/** Deterministic: a seed fixes budget, line lengths, subagents, partial writes and sync points. */
async function run(seed: number): Promise<string | null> {
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 budget = ri(400, 1500);
const { project, at, sync, session } = setup(budget, mkdtempSync(join(root, 'run-')));
const main = at('convA');
const subDir = join(project, 'convA', 'subagents');
const expected = new Set<string>();
const tooLong = new Set<string>();
const agents: string[] = [];
const tails = new Map<string, string>();
let n = 0;
const write = () => {
if (rnd() < 0.15 && agents.length < 6) {
mkdirSync(subDir, { recursive: true });
agents.push(`a${agents.length}`);
}
const k = ri(0, agents.length);
const agent = k === 0 ? undefined : agents[k - 1];
const file = agent ? join(subDir, `agent-${agent}.jsonl`) : main;
const id = `e${n++}`;
// Some lines are longer than the budget: skipped, never shown, never blocking.
const line = editLine(id, n, rnd() < 0.12 ? ri(budget + 2, budget * 3) : ri(250, budget - 2), agent);
if (Buffer.byteLength(line) < budget) expected.add(id);
else if (Buffer.byteLength(line) > budget) tooLong.add(id);
// Sometimes half a line: claude is still writing it.
const cut = rnd() < 0.15 ? ri(1, line.length - 1) : line.length;
appendFileSync(file, (tails.get(file) ?? '') + line.slice(0, cut));
if (cut < line.length) tails.set(file, line.slice(cut));
else tails.delete(file);
};
for (let i = ri(0, 20); i > 0; i--) write(); // backlog before the panel opens
if (!existsSync(main)) appendFileSync(main, '');
for (let r = ri(5, 25); r > 0; r--) {
for (let i = ri(0, 6); i > 0; i--) write();
await sync.sync(session);
}
for (const [file, rest] of tails) appendFileSync(file, rest);
for (let i = 0; i < 40; i++) await sync.sync(session);
const store = await sync.getStore('s1');
for (let clicks = 0; store.backfillTruncated && clicks < 400; clicks++) await sync.sync(session, { older: true });
const got = new Set(store.list().map((e) => e.id));
const missing = [...expected].filter((id) => !got.has(id));
const extra = [...got].filter((id) => tooLong.has(id));
if (missing.length || extra.length || store.backfillTruncated) {
return `seed ${seed} (budget ${budget}): missing ${missing.join(',')} extra ${extra.join(',')}`;
}
return null;
}
it('reads every line shorter than the budget once, forward and back, and none longer', async () => {
const failures: string[] = [];
for (let seed = 1; seed <= 40; seed++) {
await Promise.all(opened.splice(0).map((s) => s.remove()));
const r = await run(seed);
if (r) failures.push(r);
}
expect(failures).toEqual([]);
});
});
+106
View File
@@ -0,0 +1,106 @@
/**
* Edit history image probe against a filesystem that stopped answering (#693 review): a Bash
* call printed a path on a dead hard mount, whose stat() never settles. The real sync with the
* real probe (`probeLocalPaths` → bounded `probePathKind`) must still finish, the next queued
* sync too, and `remove()` must not wait on it. Own file: the stalled probe stays recorded
* process-wide for the rest of the run.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const DEAD = '/mnt/dead-nas';
vi.hoisted(() => {
process.env.CODEMAN_PATH_PROBE_TIMEOUT_MS = '100';
});
vi.mock('node:fs/promises', async (importOriginal) => {
const real = await importOriginal<typeof import('node:fs/promises')>();
const hangs = (p: unknown) => String(p).startsWith('/mnt/dead-nas');
const stat = ((p: Parameters<typeof real.stat>[0], ...rest: unknown[]) =>
hangs(p) ? new Promise(() => {}) : (real.stat as (...a: unknown[]) => unknown)(p, ...rest)) as typeof real.stat;
const realpath = ((p: Parameters<typeof real.realpath>[0], ...rest: unknown[]) =>
hangs(p)
? new Promise(() => {})
: (real.realpath as (...a: unknown[]) => unknown)(p, ...rest)) as typeof real.realpath;
return { ...real, stat, realpath, default: { ...real, stat, realpath } };
});
import { mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { EditHistoryStore } from '../src/edit-history-store.js';
import { probeLocalPaths, type EditSources } from '../src/edit-history-local-source.js';
import { EditHistorySync, type EditHistorySession } from '../src/web/edit-history-sync.js';
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
const session: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' };
function bashPair(id: string, start: number, end: number, stdout: string): string {
const use = {
type: 'assistant',
timestamp: new Date(start).toISOString(),
cwd: '/w',
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command: 'render' } }] },
};
const res = {
type: 'user',
timestamp: new Date(end).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: stdout }] },
toolUseResult: { stdout, stderr: '', interrupted: false, isImage: false },
};
return JSON.stringify(use) + '\n' + JSON.stringify(res) + '\n';
}
/** Rejects after `ms`, so a hang fails the test instead of timing it out silently. */
function within<T>(p: Promise<T>, ms: number, what: string): Promise<T> {
return Promise.race([
p,
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(`${what} still pending after ${ms} ms`)), ms)),
]);
}
let dir: string;
let stores: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'edit-stall-'));
stores = join(dir, 'stores');
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
describe('edit history image probe on an unresponsive mount', () => {
it('finishes sync, a queued sync and remove() while one printed path never answers', async () => {
const ok = join(dir, 'ok.png');
writeFileSync(ok, 'PNG!');
utimesSync(ok, new Date(T0 + 2000), new Date(T0 + 2000));
let main = Buffer.from(bashPair('b1', T0, T0 + 5000, `${DEAD}/plots/a.png\nsaved ${ok}\n`));
const sync = new EditHistorySync({
transcriptPath: async () => '/p/conv1.jsonl',
readSources: async (_path, requests, budget): Promise<EditSources> => {
const req = requests.find((r) => r.relPath === 'main');
const start = Math.min(req?.offset ?? 0, main.length);
const len = Math.min(budget, main.length - start);
return {
transcriptId: 'conv1',
files: [{ relPath: 'main', size: main.length, startOffset: start, chunk: main.subarray(start, start + len) }],
metas: {},
};
},
probe: (paths) => probeLocalPaths(paths),
loadStore: (id) => EditHistoryStore.load(id, { dir: stores, debounceMs: 0 }),
deleteStore: (id) => EditHistoryStore.delete(id, { dir: stores }),
sweepStores: async () => 0,
});
const first = sync.sync(session);
main = Buffer.concat([main, Buffer.from(bashPair('b2', T0 + 6000, T0 + 7000, `${DEAD}/plots/b.png\n`))]);
const second = sync.sync(session);
expect(await within(first, 1500, 'first sync')).toEqual({ state: 'ok' });
expect(await within(second, 1500, 'queued sync')).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list().map((e) => e.path)).toEqual([ok]);
await within(sync.remove('s1'), 1500, 'remove()');
});
});
+704
View File
@@ -0,0 +1,704 @@
/**
* Edit history store: byte ranges, cursors, dedupe, cap, persistence.
* Transcript bytes are built from JSONL lines shaped like real claude records.
*/
import { mkdtempSync, rmSync, existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
EditHistoryStore,
entryBytes,
MAIN_SOURCE,
MAX_AGENT_META_CHARS,
MAX_AGENTS,
MAX_ENTRIES,
MAX_FILE_BYTES,
MAX_STORE_BYTES,
parseAgentMeta,
READ_BUDGET_BYTES,
TMP_TAG,
} from '../src/edit-history-store.js';
import { MAX_HUNK_BYTES } from '../src/edit-history-parse.js';
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'edit-history-store-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
const T0 = Date.parse('2026-10-15T19:00:00.000Z');
const iso = (s: number) => new Date(T0 + s * 1000).toISOString();
function editLine(id: string, sec: number, path = `/work/${id}.ts`, extra: Record<string, unknown> = {}): string {
return JSON.stringify({
type: 'user',
timestamp: iso(sec),
cwd: '/work',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult: {
filePath: path,
oldString: 'a',
newString: 'b',
originalFile: 'a',
structuredPatch: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-a', '+b'] }],
userModified: false,
replaceAll: false,
},
...extra,
});
}
/** An Edit whose diff carries `size` bytes of added text. */
function bigEditLine(id: string, sec: number, size: number): string {
const line = JSON.parse(editLine(id, sec));
line.toolUseResult.structuredPatch[0].lines = ['-a', '+' + 'x'.repeat(size)];
return JSON.stringify(line);
}
function bashUse(id: string, sec: number, command: string): string {
return JSON.stringify({
type: 'assistant',
timestamp: iso(sec),
cwd: '/work',
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
});
}
function bashResult(id: string, sec: number): string {
return JSON.stringify({
type: 'user',
timestamp: iso(sec),
cwd: '/work',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] },
toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false },
});
}
const jsonl = (lines: string[]) => Buffer.from(lines.map((l) => l + '\n').join(''));
/**
* Read like the reader (edit-history-local-source.ts): on from the cursor of the current
* conversation (clamped to the size), else the file's last `budget` bytes; then apply.
* Null when nothing is new.
*/
function syncForward(
store: EditHistoryStore,
file: Buffer,
budget = READ_BUDGET_BYTES,
source = MAIN_SOURCE,
tid = 'conv-1'
) {
const cursor = tid === store.getTranscriptId() ? store.getCursor(source) : undefined;
const offset = cursor ? Math.min(cursor.offset, file.length) : Math.max(0, file.length - budget);
const len = Math.min(budget, file.length - offset);
if (cursor && offset === cursor.offset && len === 0) return null;
const chunk = file.subarray(offset, offset + len);
return store.apply({ transcriptId: tid, source, startOffset: offset, chunk, size: file.length });
}
function syncOlder(store: EditHistoryStore, file: Buffer, budget?: number, source = MAIN_SOURCE, tid = 'conv-1') {
const range = store.olderRange(source, budget);
if (!range) return null;
const chunk = file.subarray(range.offset, range.offset + range.len);
return store.applyOlder({ transcriptId: tid, source, startOffset: range.offset, chunk, size: file.length });
}
const ids = (store: EditHistoryStore) => store.list().map((e) => e.id);
describe('EditHistoryStore: forward reads', () => {
it('reads a small file whole, then only what was appended', () => {
const store = new EditHistoryStore('s1', { dir });
let file = jsonl([editLine('e1', 1), editLine('e2', 2)]);
expect(syncForward(store, file)?.added).toBe(2);
expect(store.getCursor(MAIN_SOURCE)).toEqual({ offset: file.length, floor: 0 });
expect(syncForward(store, file)).toBeNull();
file = Buffer.concat([file, jsonl([editLine('e3', 3)])]);
expect(syncForward(store, file)?.added).toBe(1);
expect(ids(store)).toEqual(['e3', 'e2', 'e1']);
expect(store.backfillTruncated).toBe(false);
});
it('leaves a partial last line (still being written) for the next read', () => {
const store = new EditHistoryStore('s1', { dir });
const full = jsonl([editLine('e1', 1), editLine('e2', 2)]);
const firstLineEnd = full.indexOf(0x0a) + 1;
const partial = full.subarray(0, firstLineEnd + 30);
expect(syncForward(store, partial)?.added).toBe(1);
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(firstLineEnd);
expect(syncForward(store, full)?.added).toBe(1);
expect(ids(store)).toEqual(['e2', 'e1']);
});
it('deduplicates by tool_use_id', () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl([editLine('e1', 1), editLine('e1', 1)]);
expect(syncForward(store, file)?.added).toBe(1);
expect(store.list()).toHaveLength(1);
});
it('starts a large file near its end, drops the cut line and keeps the floor there', () => {
const store = new EditHistoryStore('s1', { dir });
const lines = Array.from({ length: 20 }, (_, i) => editLine(`e${i}`, i));
const file = jsonl(lines);
const budget = Math.floor(file.length / 3);
syncForward(store, file, budget);
const cursor = store.getCursor(MAIN_SOURCE)!;
expect(cursor.offset).toBe(file.length);
expect(cursor.floor).toBeGreaterThan(file.length - budget);
expect(file[cursor.floor - 1]).toBe(0x0a);
expect(store.backfillTruncated).toBe(true);
expect(ids(store)[0]).toBe('e19');
expect(ids(store)).not.toContain('e0');
});
it('reads a backlog in budget-sized forward steps without losing a line', () => {
const store = new EditHistoryStore('s1', { dir });
const first = jsonl([editLine('e0', 0)]);
syncForward(store, first);
const more = Array.from({ length: 30 }, (_, i) => editLine(`e${i + 1}`, i + 1));
const file = Buffer.concat([first, jsonl(more)]);
const budget = 700;
for (let i = 0; i < 100 && syncForward(store, file, budget); i++);
expect(store.list()).toHaveLength(31);
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length);
});
it('skips a line longer than the budget instead of re-reading it forever', () => {
const store = new EditHistoryStore('s1', { dir });
const huge = editLine('big', 1, '/work/big.ts', { pad: 'x'.repeat(5000) });
const first = jsonl([editLine('e0', 0)]);
syncForward(store, first);
const file = Buffer.concat([first, jsonl([huge, editLine('e2', 2)])]);
for (let i = 0; i < 50 && syncForward(store, file, 1000); i++);
expect(ids(store)).toEqual(['e2', 'e0']);
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(file.length);
});
it('reads the line after a skipped one whole, wherever it falls against the chunk grid', async () => {
const budget = 1000;
const first = jsonl([editLine('e0', 0)]);
const lost: number[] = [];
for (let pad = 0; pad < budget; pad++) {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, first);
const huge = JSON.stringify({ type: 'user', message: { content: 'h'.repeat(2400 + pad) } });
const next = editLine('next', 2, '/work/next.ts', { pad: 'p'.repeat(300) }); // < budget
const file = Buffer.concat([first, jsonl([huge, next, editLine('e3', 3)])]);
for (let i = 0; i < 50 && syncForward(store, file, budget); i++);
if (!store.get('next') || !store.get('e3')) lost.push(pad);
await store.remove();
}
expect(lost).toEqual([]);
});
it('keeps a multibyte character split by the chunk boundary intact', () => {
const store = new EditHistoryStore('s1', { dir });
const path = '/work/Größe – Ümlaut 😀.ts';
const file = jsonl([editLine('e1', 1), editLine('e2', 2, path)]);
const emoji = file.indexOf(Buffer.from('😀'));
// Cut inside the 4-byte emoji, then continue.
const cut = file.subarray(0, emoji + 2);
syncForward(store, cut);
syncForward(store, file);
expect(store.get('e2')?.path).toBe(path);
});
it('restarts a file that shrank (replaced) from its end, with the next read', () => {
const store = new EditHistoryStore('s1', { dir });
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
const replaced = jsonl([editLine('n1', 3)]);
// The reader clamps the cursor to the size: an empty chunk at the end, and the cursor goes.
expect(syncForward(store, replaced)?.added).toBe(0);
expect(store.getCursor(MAIN_SOURCE)).toBeUndefined();
expect(syncForward(store, replaced)?.added).toBe(1);
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(replaced.length);
});
it('ignores a chunk that does not start at the cursor', () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl([editLine('e1', 1), editLine('e2', 2)]);
syncForward(store, file);
const res = store.apply({ transcriptId: 'conv-1', source: MAIN_SOURCE, startOffset: 3, chunk: file, size: 999 });
expect(res.added).toBe(0);
});
});
describe('EditHistoryStore: conversations and sources', () => {
it('starts new cursors for a new transcriptId and keeps the old entries', () => {
const store = new EditHistoryStore('s1', { dir });
syncForward(store, jsonl([editLine('e1', 1)]));
const second = jsonl([editLine('f1', 5)]);
syncForward(store, second, undefined, MAIN_SOURCE, 'conv-2');
expect(store.getTranscriptId()).toBe('conv-2');
expect(ids(store)).toEqual(['f1', 'e1']);
});
it('keeps one cursor per source and the subagent id on its entries', () => {
const store = new EditHistoryStore('s1', { dir });
const main = jsonl([editLine('m1', 1)]);
const sub = jsonl([editLine('s1', 2, '/work/s.ts', { isSidechain: true, agentId: 'a1' })]);
const subSource = 'subagents/agent-a1.jsonl';
syncForward(store, main);
syncForward(store, sub, undefined, subSource);
expect(store.getCursor(MAIN_SOURCE)?.offset).toBe(main.length);
expect(store.getCursor(subSource)?.offset).toBe(sub.length);
expect(store.get('s1')?.agentId).toBe('a1');
});
it('records agent meta from meta.json text', () => {
const store = new EditHistoryStore('s1', { dir });
store.setAgentMeta(
'a1',
JSON.stringify({
agentType: 'coding',
description: 'Impl: fix',
worktreePath: '/w/.claude/worktrees/agent-a1',
worktreeBranch: 'worktree-agent-a1',
toolUseId: 'toolu_x',
})
);
store.setAgentMeta('a2', 'not json');
expect(store.getAgents()).toEqual({
a1: {
description: 'Impl: fix',
agentType: 'coding',
worktreePath: '/w/.claude/worktrees/agent-a1',
worktreeBranch: 'worktree-agent-a1',
},
});
expect(store.hasAgentMeta('a2')).toBe(false);
expect(parseAgentMeta('{"description":"d"}')).toEqual({ description: 'd', agentType: '' });
});
});
describe('EditHistoryStore: older blocks', () => {
it('loads older lines block by block down to the start, without duplicates at the seam', () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl(Array.from({ length: 30 }, (_, i) => editLine(`e${i}`, i)));
const budget = Math.floor(file.length / 4);
syncForward(store, file, budget);
const before = store.list().length;
expect(before).toBeLessThan(30);
let rounds = 0;
while (syncOlder(store, file, budget) && rounds < 20) rounds++;
expect(rounds).toBeGreaterThan(1);
expect(store.list()).toHaveLength(30);
expect(store.getCursor(MAIN_SOURCE)?.floor).toBe(0);
expect(store.backfillTruncated).toBe(false);
expect(store.olderRange(MAIN_SOURCE, budget)).toBeNull();
});
it('skips a line longer than the budget going back and reaches the lines before it', () => {
const store = new EditHistoryStore('s1', { dir });
const huge = JSON.stringify({ type: 'user', message: { content: 'x'.repeat(6000) } });
const older = Array.from({ length: 10 }, (_, i) => editLine(`old${i}`, i));
const newer = Array.from({ length: 5 }, (_, i) => editLine(`new${i}`, 100 + i));
const file = jsonl([...older, huge, ...newer]);
const budget = 4000;
syncForward(store, file, budget);
expect(ids(store).sort()).toEqual(newer.map((_, i) => `new${i}`).sort());
let rounds = 0;
while (syncOlder(store, file, budget) && rounds < 30) rounds++;
expect(store.list()).toHaveLength(15);
expect(store.getCursor(MAIN_SOURCE)).toMatchObject({ floor: 0 });
expect(store.getCursor(MAIN_SOURCE)?.floorMidLine).toBeUndefined();
expect(store.backfillTruncated).toBe(false);
});
it('going back, reads the line before a skipped one whole, wherever it falls against the block grid', async () => {
const budget = 1000;
const huge = JSON.stringify({ type: 'user', message: { content: 'h'.repeat(2500) } });
const lost: number[] = [];
for (let pad = 0; pad < budget; pad++) {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
const lines = [editLine('p0', 0), editLine('p1', 1), editLine('before', 2), huge];
for (let i = 0; i < 6; i++)
lines.push(editLine(`t${i}`, 10 + i, `/work/t${i}.ts`, i === 0 ? { pad: 'p'.repeat(pad) } : {}));
const file = jsonl(lines);
syncForward(store, file, budget);
for (let i = 0; i < 50 && syncOlder(store, file, budget); i++);
if (!store.get('before') || !store.get('p0')) lost.push(pad);
await store.remove();
}
expect(lost).toEqual([]);
});
it('a source whose file is gone (dropSource) offers no older lines and keeps its entries', () => {
const store = new EditHistoryStore('s1', { dir });
const sub = 'subagents/agent-a1.jsonl';
const file = jsonl(Array.from({ length: 10 }, (_, i) => editLine(`s${i}`, i, `/work/s${i}.ts`, { agentId: 'a1' })));
syncForward(store, file, 400, sub);
expect(store.sourcesWithOlder()).toEqual([sub]);
const kept = ids(store);
store.dropSource(sub);
expect(store.getCursor(sub)).toBeUndefined();
expect(store.backfillTruncated).toBe(false);
expect(ids(store)).toEqual(kept);
});
it('refuses an older chunk that does not end at the floor', () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl(Array.from({ length: 10 }, (_, i) => editLine(`e${i}`, i)));
syncForward(store, file, 300);
const res = store.applyOlder({
transcriptId: 'conv-1',
source: MAIN_SOURCE,
startOffset: 0,
chunk: file.subarray(0, 10),
size: file.length,
});
expect(res.added).toBe(0);
});
});
describe('EditHistoryStore: Bash images', () => {
it('pairs a Bash call and its result across a chunk boundary', () => {
const store = new EditHistoryStore('s1', { dir });
const part1 = jsonl([bashUse('b1', 1, 'python render.py -o out/frame.png')]);
const file = Buffer.concat([part1, jsonl([bashResult('b1', 4)])]);
expect(syncForward(store, part1)?.candidates).toEqual([]);
const res = syncForward(store, file)!;
expect(res.candidates).toEqual([
{ toolUseId: 'b1', start: T0 + 1000, end: T0 + 4000, paths: ['/work/out/frame.png'] },
]);
});
it('adds confirmed images as entries without a diff, once', () => {
const store = new EditHistoryStore('s1', { dir });
const img = { toolUseId: 'b1', path: '/work/out/frame.png', ts: T0 + 4000, agentId: 'a1' };
expect(store.addImageEntries([img, img])).toBe(1);
expect(store.addImageEntries([img])).toBe(0);
expect(store.list()[0]).toEqual({
id: 'b1:/work/out/frame.png',
ts: T0 + 4000,
path: '/work/out/frame.png',
kind: 'image',
tool: 'bash',
created: false,
added: 0,
removed: 0,
hunks: [],
truncated: false,
agentId: 'a1',
});
});
});
describe('EditHistoryStore: cap', () => {
it(`keeps the newest ${MAX_ENTRIES} entries`, () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl(Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i)));
const res = syncForward(store, file, file.length);
expect(res?.added).toBe(MAX_ENTRIES);
expect(store.list()).toHaveLength(MAX_ENTRIES);
expect(store.get('e19')).toBeUndefined();
expect(store.get('e20')).toBeDefined();
// Older than everything kept: counts as nothing added.
expect(store.addImageEntries([{ toolUseId: 'old', path: '/a.png', ts: T0 - 1000 }])).toBe(0);
});
it('offers no older block once the cap is full (it would be dropped on read)', () => {
const store = new EditHistoryStore('s1', { dir });
const lines = Array.from({ length: MAX_ENTRIES + 20 }, (_, i) => editLine(`e${i}`, i));
const file = jsonl(lines);
const tail = jsonl(lines.slice(-MAX_ENTRIES)).length;
syncForward(store, file, tail + 10);
expect(store.list()).toHaveLength(MAX_ENTRIES);
expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0);
expect(store.backfillTruncated).toBe(false);
});
it(`keeps at most ${MAX_STORE_BYTES} bytes of diff text, dropping the oldest`, () => {
const store = new EditHistoryStore('s1', { dir });
const n = 300;
const file = jsonl(Array.from({ length: n }, (_, i) => bigEditLine(`e${i}`, i, 20_000)));
syncForward(store, file, file.length);
const kept = store.list();
expect(kept.length).toBeLessThan(n);
expect(kept.reduce((sum, e) => sum + entryBytes(e), 0)).toBeLessThanOrEqual(MAX_STORE_BYTES);
expect(store.get(`e${n - 1}`)).toBeDefined();
expect(store.get('e0')).toBeUndefined();
});
it('offers no older block once the byte cap is full', () => {
const store = new EditHistoryStore('s1', { dir });
const file = jsonl(Array.from({ length: 300 }, (_, i) => bigEditLine(`e${i}`, i, 20_000)));
syncForward(store, file, 5_000_000);
expect(store.list().length).toBeLessThan(MAX_ENTRIES);
expect(store.getCursor(MAIN_SOURCE)!.floor).toBeGreaterThan(0);
expect(store.backfillTruncated).toBe(false);
});
it('caps the agent meta strings', () => {
const meta = parseAgentMeta(JSON.stringify({ description: 'd'.repeat(5000), agentType: 'coding' }));
expect(meta?.description).toHaveLength(MAX_AGENT_META_CHARS);
expect(meta?.agentType).toBe('coding');
});
it(`keeps at most ${MAX_AGENTS} subagent labels, dropping the oldest no entry uses`, () => {
const store = new EditHistoryStore('s1', { dir });
syncForward(store, jsonl([editLine('e1', 1, '/work/a.ts', { agentId: 'a0' })]));
for (let i = 0; i <= MAX_AGENTS + 4; i++) store.setAgentMeta(`a${i}`, `{"description":"d${i}"}`);
const agents = store.getAgents();
expect(Object.keys(agents)).toHaveLength(MAX_AGENTS);
expect(agents.a0).toBeDefined(); // used by an entry
expect(agents[`a${MAX_AGENTS + 4}`]).toBeDefined(); // the newest
expect(agents.a1).toBeUndefined();
});
});
describe('EditHistoryStore: persistence', () => {
it('reloads entries, cursors, pending Bash calls and agents from disk', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
const file = jsonl([editLine('e1', 1), bashUse('b1', 2, 'render > a.png')]);
syncForward(store, file);
store.setAgentMeta('a1', '{"description":"d","agentType":"coding"}');
await store.flush();
const loaded = await EditHistoryStore.load('s1', { dir });
expect(ids(loaded)).toEqual(['e1']);
expect(loaded.getCursor(MAIN_SOURCE)).toEqual(store.getCursor(MAIN_SOURCE));
expect(loaded.getTranscriptId()).toBe('conv-1');
expect(loaded.getAgents()).toEqual({ a1: { description: 'd', agentType: 'coding' } });
const done = Buffer.concat([file, jsonl([bashResult('b1', 3)])]);
expect(syncForward(loaded, done)?.candidates[0]?.paths).toEqual(['/work/a.png']);
});
it('writes the file 0600 in a 0700 directory, also over a wider existing file', async () => {
const sub = join(dir, 'edit-history');
const store = new EditHistoryStore('s1', { dir: sub, debounceMs: 60_000 });
syncForward(store, jsonl([editLine('e1', 1)]));
await store.flush();
expect(statSync(sub).mode & 0o777).toBe(0o700);
expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600);
writeFileSync(join(sub, 's1.json'), '{}', { mode: 0o644 });
await store.flush();
expect(statSync(join(sub, 's1.json')).mode & 0o777).toBe(0o600);
});
it('applies the caps and drops malformed entries when loading', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, jsonl(Array.from({ length: MAX_ENTRIES }, (_, i) => editLine(`e${i}`, i))));
await store.flush();
const data = JSON.parse(readFileSync(join(dir, 's1.json'), 'utf-8'));
const extra = JSON.parse(editLine('late', MAX_ENTRIES + 1));
data.entries.push(
{ ...store.get('e0')!, id: 'newest', ts: T0 + 10_000_000 },
{ id: 'broken', path: 7, hunks: [] },
{ id: 'nohunks', path: '/a.ts' },
extra
);
writeFileSync(join(dir, 's1.json'), JSON.stringify(data));
const loaded = await EditHistoryStore.load('s1', { dir });
expect(loaded.list()).toHaveLength(MAX_ENTRIES);
expect(loaded.get('newest')).toBeDefined();
expect(loaded.get('e0')).toBeUndefined();
expect(loaded.get('broken')).toBeUndefined();
expect(loaded.get('nohunks')).toBeUndefined();
});
it('drops broken cursors, pending Bash calls and agent labels when loading (a damaged file)', async () => {
writeFileSync(
join(dir, 's1.json'),
JSON.stringify({
version: 1,
sessionId: 's1',
transcriptId: 'conv-1',
cursors: {
main: { offset: 1.5, floor: 0 },
'subagents/agent-x.jsonl': { offset: -5, floor: 0 },
'subagents/agent-y.jsonl': { offset: 10, floor: 20 },
'subagents/agent-z.jsonl': { offset: 10, floor: 5, floorMidLine: true },
},
pendingBash: [null, 7, { id: 'b1', ts: 1, command: 'render > a.png', cwd: '/work' }],
agents: { x: null, y: 'text', z: { description: 'd', agentType: 'coding' } },
entries: [{ id: 'e1', ts: 1, path: '/a.ts', hunks: [null] }],
})
);
const loaded = await EditHistoryStore.load('s1', { dir });
expect(loaded.knownSources()).toEqual(['subagents/agent-z.jsonl']);
expect(loaded.getCursor('subagents/agent-z.jsonl')).toEqual({ offset: 10, floor: 5, floorMidLine: true });
expect(loaded.getAgents()).toEqual({ z: { description: 'd', agentType: 'coding' } });
expect(loaded.list()).toEqual([]);
// The surviving pending call still pairs with its result, and nothing throws.
expect(syncForward(loaded, jsonl([bashResult('b1', 3)]))?.candidates[0]?.paths).toEqual(['/work/a.png']);
});
it('rebuilds loaded entries under the same bounds: known fields only, string diff lines, the per-edit cap', async () => {
const good = {
ts: 1,
path: '/a.ts',
kind: 'code',
tool: 'Edit',
created: false,
added: 1,
removed: 0,
truncated: false,
};
writeFileSync(
join(dir, 's1.json'),
JSON.stringify({
version: 1,
sessionId: 's1',
transcriptId: 'conv-1',
cursors: {},
pendingBash: [
{ id: 'b1', ts: 1, command: 'ls', cwd: '/work' },
{ id: 'b2', ts: 1, command: 'ls', cwd: '/' + 'c'.repeat(5000) },
{ id: 'p'.repeat(5000), ts: 1, command: 'ls', cwd: '/work' },
],
agents: { ['a'.repeat(MAX_AGENT_META_CHARS + 1)]: { description: 'd', agentType: 't' } },
entries: [
{ ...good, id: 'extra', hunks: [], blob: 'x'.repeat(1000) },
{ ...good, id: 'objlines', hunks: [{ oldStart: 1, lines: [{ k: 'y' }, '+kept'] }] },
{ ...good, id: 'bighunk', hunks: [{ lines: ['+' + 'q'.repeat(MAX_HUNK_BYTES)] }] },
{ ...good, id: 'badtool', tool: 'Rm', hunks: [] },
],
})
);
const loaded = await EditHistoryStore.load('s1', { dir, debounceMs: 60_000 });
expect(
loaded
.list()
.map((e) => e.id)
.sort()
).toEqual(['bighunk', 'extra', 'objlines']);
expect(loaded.get('extra')).not.toHaveProperty('blob');
expect(loaded.get('objlines')!.hunks[0].lines).toEqual(['+kept']);
expect(loaded.get('bighunk')).toMatchObject({ truncated: true, hunks: [{ lines: [] }] });
expect(loaded.getAgents()).toEqual({});
await loaded.flush();
expect(
JSON.parse(readFileSync(join(dir, 's1.json'), 'utf-8')).pendingBash.map((p: { id: string }) => p.id)
).toEqual(['b1']);
});
it("counts an entry's id against the byte cap", () => {
const e = {
id: 'i'.repeat(1000),
ts: 1,
path: '/a.ts',
kind: 'code',
tool: 'Edit',
created: false,
added: 0,
removed: 0,
hunks: [],
truncated: false,
} as const;
expect(entryBytes(e) - entryBytes({ ...e, id: 'i' })).toBe(999);
});
it(`does not parse a store file larger than ${MAX_FILE_BYTES} bytes`, async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, jsonl([editLine('e1', 1)]));
await store.flush();
const text = readFileSync(join(dir, 's1.json'), 'utf-8');
expect((await EditHistoryStore.load('s1', { dir })).get('e1')).toBeDefined();
writeFileSync(join(dir, 's1.json'), text + ' '.repeat(MAX_FILE_BYTES)); // still valid JSON
expect((await EditHistoryStore.load('s1', { dir })).list()).toEqual([]);
});
it('sweep() deletes the histories of sessions that are gone and stale tmp files, nothing else', async () => {
for (const name of [
'live.json',
'gone.json',
`gone.json.${process.pid + 1}.tmp`,
// An earlier run that had this process's pid (a container's pid 1): stale as well.
`live.json.${process.pid}.tmp`,
`live.json.${process.pid}-0a1b2c3d.tmp`,
`live.json.${TMP_TAG}.tmp`,
'notes.txt',
]) {
writeFileSync(join(dir, name), '{}');
}
expect(await EditHistoryStore.sweep((id) => id === 'live', { dir })).toBe(1);
expect(readdirSync(dir).sort()).toEqual([`live.json.${TMP_TAG}.tmp`, 'live.json', 'notes.txt'].sort());
expect(await EditHistoryStore.sweep(() => false, { dir: join(dir, 'missing') })).toBe(0);
});
it('takes only a plain transcript id from the file (the sync builds a path from it)', async () => {
const cursors = { main: { offset: 10, floor: 0 } };
for (const transcriptId of ['../../etc/passwd', 'a/b', '', 7]) {
writeFileSync(join(dir, 's1.json'), JSON.stringify({ version: 1, transcriptId, cursors, entries: [] }));
const store = await EditHistoryStore.load('s1', { dir });
expect(store.getTranscriptId()).toBeNull();
expect(store.knownSources()).toEqual([]); // offsets into no known conversation
}
writeFileSync(join(dir, 's1.json'), JSON.stringify({ version: 1, transcriptId: 'conv-1.a_b', cursors }));
const store = await EditHistoryStore.load('s1', { dir });
expect(store.getTranscriptId()).toBe('conv-1.a_b');
expect(store.knownSources()).toEqual(['main']);
});
it('settle() writes pending changes and nothing for a store without any', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, jsonl([editLine('e1', 1)]));
await store.settle();
expect((await EditHistoryStore.load('s1', { dir })).get('e1')).toBeDefined();
const loaded = await EditHistoryStore.load('s1', { dir });
rmSync(join(dir, 's1.json'));
await loaded.settle();
await new EditHistoryStore('empty', { dir }).settle();
expect(readdirSync(dir)).toEqual([]);
});
it('delete() removes a stored history without parsing it', async () => {
writeFileSync(join(dir, 's1.json'), 'not json');
await EditHistoryStore.delete('s1', { dir });
await EditHistoryStore.delete('missing', { dir });
expect(readdirSync(dir)).toEqual([]);
});
it('ignores a file of an unknown version', async () => {
writeFileSync(join(dir, 'v9.json'), JSON.stringify({ version: 9, entries: [{ id: 'x' }] }));
expect((await EditHistoryStore.load('v9', { dir })).list()).toEqual([]);
});
it('writes debounced', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 20 });
syncForward(store, jsonl([editLine('e1', 1)]));
const path = join(dir, 's1.json');
expect(existsSync(path)).toBe(false);
await new Promise((r) => setTimeout(r, 80));
expect(JSON.parse(readFileSync(path, 'utf-8')).entries).toHaveLength(1);
});
it('starts empty from a missing or corrupt file', async () => {
expect((await EditHistoryStore.load('none', { dir })).list()).toEqual([]);
const store = new EditHistoryStore('bad', { dir });
await store.flush();
writeFileSync(join(dir, 'bad.json'), '{broken');
expect((await EditHistoryStore.load('bad', { dir })).list()).toEqual([]);
});
it('remove() deletes the file and stops later writes', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 10 });
syncForward(store, jsonl([editLine('e1', 1)]));
await store.flush();
await store.remove();
expect(existsSync(join(dir, 's1.json'))).toBe(false);
syncForward(store, jsonl([editLine('e1', 1), editLine('e2', 2)]));
await new Promise((r) => setTimeout(r, 40));
await store.flush();
expect(existsSync(join(dir, 's1.json'))).toBe(false);
expect(store.list()).toEqual([]); // the content is forgotten too
});
it('remove() during a write leaves nothing behind (the write cannot rename over the deleted file)', async () => {
const store = new EditHistoryStore('s1', { dir, debounceMs: 60_000 });
syncForward(store, jsonl([editLine('e1', 1)]));
const writing = store.flush();
// Let the write get past its first check, so remove() meets it under way.
await new Promise((r) => setImmediate(r));
await Promise.all([writing, store.remove()]);
expect(readdirSync(dir)).toEqual([]);
});
it('defaults to the data directory', () => {
const store = new EditHistoryStore('s1');
expect((store as unknown as { file: string }).file).toMatch(/edit-history[\\/]s1\.json$/);
});
});
+699
View File
@@ -0,0 +1,699 @@
/**
* Edit history sync: reader, store, image confirmation, single-flight, failure states and the
* "reads only on request" wiring. Reader and probe are fakes; the store is real (temp dir).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { EditHistoryStore } from '../src/edit-history-store.js';
import type {
EditSourceReadOptions,
EditSourceRequest,
EditSources,
PathProbe,
} from '../src/edit-history-local-source.js';
import {
confirmImages,
EditHistorySync,
MAX_LOADED_STORES,
MAX_OLDER_SOURCES,
NO_TRANSCRIPT,
REMOVED,
type EditHistorySession,
type EditHistorySyncDeps,
unsupportedReason,
} from '../src/web/edit-history-sync.js';
let dir: string;
/** Stores the tests opened; removed before the temp dir, so no late save recreates it. */
const opened: EditHistoryStore[] = [];
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'edit-sync-'));
});
afterEach(async () => {
await Promise.all(opened.splice(0).map((s) => s.remove()));
rmSync(dir, { recursive: true, force: true });
});
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string {
return (
JSON.stringify({
type: 'user',
timestamp: new Date(ts).toISOString(),
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'] }],
},
...extra,
}) + '\n'
);
}
function bashPair(id: string, start: number, end: number, command: string): string {
const use = JSON.stringify({
type: 'assistant',
timestamp: new Date(start).toISOString(),
cwd: '/w',
message: { role: 'assistant', content: [{ type: 'tool_use', id, name: 'Bash', input: { command } }] },
});
const res = JSON.stringify({
type: 'user',
timestamp: new Date(end).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: '' }] },
toolUseResult: { stdout: '', stderr: '', interrupted: false, isImage: false },
});
return use + '\n' + res + '\n';
}
/** An in-memory transcript: files by relPath, served with the reader contract. */
class FakeHost {
transcriptId = 'conv1';
files = new Map<string, Buffer>();
metas: Record<string, string> = {};
calls: EditSourceRequest[][] = [];
paths: string[] = [];
/** What `transcriptPath` answers (the transcript watcher's path); null = none yet. */
get path(): string | null {
return this.files.size > 0 ? `/p/${this.transcriptId}.jsonl` : null;
}
read = async (
mainPath: string,
requests: readonly EditSourceRequest[],
budget: number,
opts: EditSourceReadOptions = {}
): Promise<EditSources | null> => {
this.calls.push([...requests]);
this.paths.push(mainPath);
if (this.files.size === 0) return null;
let remaining = budget;
const out: EditSources = { transcriptId: this.transcriptId, files: [], metas: {} };
for (const [relPath, buf] of this.files) {
const req = requests.find((r) => r.relPath === relPath);
if (!req && opts.onlyRequested) continue;
let start: number;
let len: number;
if (req?.len !== undefined) {
const blockEnd = Math.min(req.offset + req.len, buf.length);
len = Math.min(blockEnd - Math.min(req.offset, buf.length), remaining);
start = blockEnd - len;
} else if (req) {
start = Math.min(req.offset, buf.length);
len = Math.min(remaining, buf.length - start);
} else {
len = Math.min(buf.length, remaining);
start = buf.length - len;
const agent = /^subagents\/agent-(.+)\.jsonl$/.exec(relPath)?.[1];
if (agent && this.metas[agent]) out.metas[agent] = this.metas[agent];
}
remaining -= len;
out.files.push({ relPath, size: buf.length, startOffset: start, chunk: buf.subarray(start, start + len) });
}
return out;
};
append(relPath: string, text: string): void {
this.files.set(relPath, Buffer.concat([this.files.get(relPath) ?? Buffer.alloc(0), Buffer.from(text)]));
}
}
function setup(over: Partial<EditHistorySyncDeps> = {}) {
const host = new FakeHost();
const probes = new Map<string, PathProbe>();
const lookups: string[] = [];
/** Session ids in the order their stores were loaded. */
const loads: string[] = [];
const deps: EditHistorySyncDeps = {
transcriptPath: async (session) => {
lookups.push(session.id);
return host.path;
},
readSources: (path, req, budget, opts) => host.read(path, req, budget, opts),
probe: async (paths) => paths.map((p) => probes.get(p) ?? null),
loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 0 }),
deleteStore: (id) => EditHistoryStore.delete(id, { dir }),
sweepStores: async () => 0,
...over,
};
const load = deps.loadStore;
deps.loadStore = async (id) => {
loads.push(id);
const store = await load(id);
opened.push(store);
return store;
};
return { host, probes, lookups, loads, deps, sync: new EditHistorySync(deps) };
}
const local: EditHistorySession = { id: 's1', mode: 'claude', remote: undefined, claudeSessionId: 'conv1' };
const remote: EditHistorySession = {
id: 's2',
mode: 'claude',
remote: { host: 'huff' } as EditHistorySession['remote'],
claudeSessionId: 'conv1',
};
describe('unsupportedReason', () => {
it('accepts a local claude session, rejects remote, docker and non-claude CLIs', () => {
expect(unsupportedReason(local)).toBeUndefined();
expect(unsupportedReason(remote)).toBe('remote');
expect(unsupportedReason({ ...local, docker: { image: 'x' } })).toBe('docker');
expect(unsupportedReason({ ...local, mode: 'shell' })).toBe('mode');
});
});
describe('EditHistorySync', () => {
it('reads new edits forward from the stored offset', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
expect(await sync.sync(local)).toEqual({ state: 'ok' });
host.append('main', editLine('t2', T0 + 1000, '/w/b.ts'));
await sync.sync(local);
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t2', 't1']);
expect(host.calls[1]).toEqual([
{ relPath: 'main', offset: host.files.get('main')!.length - editLine('t2', T0 + 1000, '/w/b.ts').length },
]);
expect(host.paths).toEqual(['/p/conv1.jsonl', '/p/conv1.jsonl']);
});
it('reads subagent transcripts and records their meta', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
host.append('subagents/agent-a1.jsonl', editLine('t9', T0 + 5, '/w/sub.ts', { isSidechain: true, agentId: 'a1' }));
host.metas.a1 = '{"description":"Fix it","agentType":"coding"}';
await sync.sync(local);
const store = await sync.getStore('s1');
expect(store.get('t9')?.agentId).toBe('a1');
expect(store.getAgents().a1).toEqual({ description: 'Fix it', agentType: 'coding' });
});
it('reads a new conversation (/clear, resume) from its end, keeping the entries', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
host.transcriptId = 'conv2';
host.files.set('main', Buffer.from(editLine('n1', T0 + 9, '/w/n.ts')));
await sync.sync(local);
expect(host.paths.at(-1)).toBe('/p/conv2.jsonl');
expect(host.calls.at(-1)).toEqual([]); // no offsets of the old file
const store = await sync.getStore('s1');
expect(store.getTranscriptId()).toBe('conv2');
expect(store.list().map((e) => e.id)).toEqual(['n1', 't1']);
});
it('keeps reading in passes when a file grew by more than one budget', async () => {
const { host, sync } = setup({ budget: 600 });
host.append('main', editLine('t0', T0, '/w/z.ts'));
await sync.sync(local);
for (let i = 1; i <= 3; i++) host.append('main', editLine(`t${i}`, T0 + i, `/w/${i}.ts`));
await sync.sync(local);
expect((await sync.getStore('s1')).list()).toHaveLength(4);
});
it('turns a Bash image into an entry only when its mtime lies in the call window', async () => {
const { host, probes, sync } = setup();
host.append('main', bashPair('b1', T0, T0 + 5000, 'render out/new.png old.png ~/x.png'));
probes.set('/w/out/new.png', { realPath: '/w/out/new.png', kind: 'file', size: 9, mtimeMs: T0 + 4000 });
probes.set('/w/old.png', { realPath: '/w/old.png', kind: 'file', size: 9, mtimeMs: T0 - 60_000 });
probes.set('~/x.png', { realPath: '/home/u/x.png', kind: 'file', size: 9, mtimeMs: T0 + 6500 });
await sync.sync(local);
expect((await sync.getStore('s1')).list().map((e) => [e.path, e.tool, e.kind])).toEqual([
['/w/out/new.png', 'bash', 'image'],
['/home/u/x.png', 'bash', 'image'],
]);
});
it('a removal during a read stops it: no further parsing, no probe of printed paths', async () => {
const probe = vi.fn(async (paths: readonly string[]) => paths.map(() => null));
let removal: Promise<void> | undefined;
const ctx = setup({
probe,
readSources: async (path, req, budget, opts) => {
const res = await ctx.host.read(path, req, budget, opts);
removal = ctx.sync.remove('s1'); // the session is deleted while this read is out
return res;
},
});
ctx.host.append('main', bashPair('b1', T0, T0 + 5000, 'render out/a.png'));
ctx.host.append('subagents/agent-a1.jsonl', bashPair('b2', T0, T0 + 5000, 'render out/b.png'));
await ctx.sync.sync(local);
await removal;
expect(probe).not.toHaveBeenCalled();
});
it('keeps the edits when only the image probe fails', async () => {
const { host, sync } = setup({
probe: async () => {
throw new Error('probe failed');
},
});
host.append('main', editLine('t1', T0, '/w/a.ts') + bashPair('b1', T0, T0 + 1, 'x out.png'));
expect(await sync.sync(local)).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
});
it('reports a session without a transcript yet, then reads it once there is one', async () => {
const { host, sync } = setup();
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: NO_TRANSCRIPT });
expect(host.calls).toEqual([]);
host.append('main', editLine('t1', T0, '/w/a.ts'));
expect(await sync.sync(local)).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list()).toHaveLength(1);
});
it('reports a transcript that vanished (watcher path stale) the same way', async () => {
const { host, sync } = setup({ transcriptPath: async () => '/p/gone.jsonl' });
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: NO_TRANSCRIPT });
expect(host.paths).toEqual(['/p/gone.jsonl']);
});
it('reports a read error as unavailable and keeps the stored entries', async () => {
const { host, sync, deps } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
deps.readSources = async () => {
throw new Error('EACCES: permission denied');
};
const status = await sync.sync(local);
expect(status).toEqual({ state: 'unavailable', reason: 'EACCES: permission denied' });
expect((await sync.getStore('s1')).list()).toHaveLength(1);
});
it('marks remote, docker and non-claude sessions unsupported without looking for a transcript', async () => {
const { host, lookups, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
expect(await sync.sync({ ...local, mode: 'shell' })).toEqual({ state: 'unsupported', reason: 'mode' });
expect(await sync.sync(remote)).toEqual({ state: 'unsupported', reason: 'remote' });
expect(await sync.sync({ ...local, docker: {} })).toEqual({ state: 'unsupported', reason: 'docker' });
expect(lookups).toEqual([]);
expect(host.calls).toEqual([]);
});
it('runs one sync per session at a time and folds occasions into one more run', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const orig = host.read;
host.read = async (...a) => {
await gate;
return orig(...a);
};
const runs = [sync.sync(local), sync.sync(local), sync.sync(local)];
release();
await Promise.all(runs);
expect(host.calls).toHaveLength(2); // the first run + one folded follow-up
});
it('loads older blocks on request', async () => {
const { host, sync } = setup({ budget: 400 });
for (let i = 0; i < 4; i++) host.append('main', editLine(`t${i}`, T0 + i, `/w/${i}.ts`));
await sync.sync(local);
const store = await sync.getStore('s1');
const before = store.list().length;
expect(store.backfillTruncated).toBe(true);
await sync.sync(local, { older: true });
expect(store.list().length).toBeGreaterThan(before);
});
it('flushAll writes pending saves (shutdown) so a reload keeps the entries', async () => {
const { host, sync } = setup({ loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 60_000 }) });
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
await sync.flushAll();
const reloaded = await EditHistoryStore.load('s1', { dir });
expect(reloaded.list().map((e) => e.id)).toEqual(['t1']);
});
it('remove deletes the stored history', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
await (await sync.getStore('s1')).flush();
await sync.remove('s1');
expect(() => readFileSync(join(dir, 's1.json'))).toThrow();
});
it('neither a run in flight nor a request during the removal writes the deleted history again', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
let entered!: () => void;
const reading = new Promise<void>((r) => (entered = r));
const orig = host.read;
host.read = async (...a) => {
entered();
await gate;
return orig(...a);
};
const runs = [sync.sync(local), sync.sync(local)]; // one running, one queued
await reading;
const removing = sync.remove('s1');
// The removal waits for the read under way; a request meanwhile reads nothing.
expect(await sync.sync(local)).toEqual({ state: 'unavailable', reason: REMOVED });
release();
await Promise.all([...runs, removing]);
await new Promise((r) => setTimeout(r, 20)); // past the store's debounce
expect(readdirSync(dir)).toEqual([]);
expect(host.calls).toHaveLength(1); // the read under way; the queued run never read
});
it('the same conversation under an unnormalized path reads on from its cursors, nothing else', async () => {
const path = '/p/./conv1.jsonl';
const { host, sync } = setup({ transcriptPath: async () => path });
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
host.append('main', editLine('t2', T0 + 1, '/w/a.ts'));
await sync.sync(local);
expect(host.paths).toEqual([path, path]);
expect(host.calls[1]).toEqual([
{ relPath: 'main', offset: host.files.get('main')!.length - editLine('t2', T0 + 1, '/w/a.ts').length },
]);
});
it('a session that comes back with the same id (reboot restore) reads its history afresh', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(local);
// Boot: the unpinned record is pruned as stale; then "restore" brings the session back.
await sync.remove('s1');
expect(await sync.sync(local)).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
});
it('remove deletes a history it has not loaded without loading it', async () => {
const { sync, loads } = setup();
const other = new EditHistoryStore('s1', { dir });
other.addImageEntries([{ toolUseId: 'b1', path: '/w/a.png', ts: T0 }]);
await other.flush();
await sync.remove('s1');
expect(loads).toEqual([]);
expect(existsSync(join(dir, 's1.json'))).toBe(false);
});
it(`keeps at most ${MAX_LOADED_STORES} stores loaded: a dropped one is written and loads again whole`, async () => {
const { host, sync, loads } = setup({ loadStore: (id) => EditHistoryStore.load(id, { dir, debounceMs: 60_000 }) });
host.append('main', editLine('t1', T0, '/w/a.ts'));
const ids = Array.from({ length: MAX_LOADED_STORES + 2 }, (_, i) => `s${i}`);
for (const id of ids) await sync.sync({ ...local, id });
expect(loads).toEqual(ids);
// The two least recently used went: written (no debounced save ran), not kept in memory.
await sync.flushAll();
expect(existsSync(join(dir, 's0.json'))).toBe(true);
expect((await sync.getStore('s0')).list().map((e) => e.id)).toEqual(['t1']);
expect(loads.slice(ids.length)).toEqual(['s0']);
// The most recently used ones stay loaded.
await sync.getStore(ids[ids.length - 1]);
expect(loads.slice(ids.length)).toEqual(['s0']);
});
it('a dropped store that never changed writes nothing (no file for a session without edits)', async () => {
const { sync } = setup(); // no transcript: nothing to read
for (let i = 0; i <= MAX_LOADED_STORES; i++) await sync.sync({ ...local, id: `s${i}` });
await sync.flushAll();
expect(readdirSync(dir)).toEqual([]);
});
it('a store whose run is in flight is not dropped, and loads once', async () => {
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const { host, sync, loads } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
const orig = host.read;
let first = true;
let inRead!: () => void;
const reachedRead = new Promise<void>((r) => (inRead = r));
host.read = async (...a) => {
if (first) {
first = false;
inRead();
await gate;
}
return orig(...a);
};
const slow = sync.sync(local);
// Only once s1 holds the gate: the sessions load their stores independently, so a later one
// could reach the read first, take the gate and hang the loop below before `release()`.
await reachedRead;
for (let i = 1; i <= MAX_LOADED_STORES + 1; i++) await sync.sync({ ...local, id: `s1-${i}` });
release();
expect(await slow).toEqual({ state: 'ok' });
expect((await sync.getStore('s1')).list().map((e) => e.id)).toEqual(['t1']);
expect(loads.filter((id) => id === 's1')).toEqual(['s1']);
});
it('concurrent first callers share one store', async () => {
const { sync } = setup();
const [a, b] = await Promise.all([sync.getStore('s1'), sync.getStore('s1')]);
expect(a).toBe(b);
});
it('a forward request queued together with an older one still reads forward', async () => {
const { host, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
const orig = host.read;
host.read = async (...a) => {
const res = await orig(...a); // the first run's bytes are taken before the append
await gate;
return res;
};
const first = sync.sync(local);
await new Promise((r) => setTimeout(r, 0));
host.append('main', editLine('t2', T0 + 1, '/w/b.ts'));
const queued = [sync.sync(local), sync.sync(local, { older: true })];
release();
await Promise.all([first, ...queued]);
expect((await sync.getStore('s1')).get('t2')).toBeDefined();
});
it('load older moves every source back, reading only the blocks it asked for', async () => {
const { host, sync } = setup({ budget: 2000 });
for (let i = 0; i < 10; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`));
for (let i = 0; i < 10; i++) {
host.append('subagents/agent-ab.jsonl', editLine(`s${i}`, T0 + 100 + i, `/w/s${i}.ts`, { agentId: 'ab' }));
}
await sync.sync(local);
await sync.sync(local); // the subagent gets its first block once main is up to date
const store = await sync.getStore('s1');
const floors = () => [store.getCursor('main')!.floor, store.getCursor('subagents/agent-ab.jsonl')!.floor];
const [main0, sub0] = floors();
expect(main0).toBeGreaterThan(0);
expect(sub0).toBeGreaterThan(0);
await sync.sync(local, { older: true });
const [main1, sub1] = floors();
expect(main1).toBeLessThan(main0);
expect(sub1).toBeLessThan(sub0);
expect(
host.calls
.at(-1)!
.map((r) => r.relPath)
.sort()
).toEqual(['main', 'subagents/agent-ab.jsonl']);
for (let i = 0; i < 20 && store.backfillTruncated; i++) await sync.sync(local, { older: true });
expect(store.list()).toHaveLength(20);
});
it('a file the shared budget cut short is read in the next pass, not skipped as one long line', async () => {
const budget = 4000;
const { host, sync } = setup({ budget });
const sub = 'subagents/agent-a1.jsonl';
host.append('main', editLine('m0', T0, '/w/m0.ts'));
host.append(sub, editLine('s0', T0 + 1, '/w/s0.ts', { agentId: 'a1' }));
await sync.sync(local);
// main grows by almost a whole budget, leaving the subagent 50 bytes of this pass
const filler = JSON.stringify({ type: 'user', message: { content: '' } });
host.append(
'main',
JSON.stringify({ type: 'user', message: { content: 'z'.repeat(budget - 51 - filler.length) } }) + '\n'
);
host.append(sub, editLine('s1', T0 + 2, '/w/s1.ts', { agentId: 'a1', pad: 'p'.repeat(100) }));
host.append(sub, editLine('s2', T0 + 3, '/w/s2.ts', { agentId: 'a1' }));
await sync.sync(local);
const store = await sync.getStore('s1');
expect(
store
.list()
.map((e) => e.id)
.sort()
).toEqual(['m0', 's0', 's1', 's2']);
expect(store.getCursor(sub)?.midLine).toBeUndefined();
});
it('a first read reaches the subagents in a further pass when main fills the budget', async () => {
const { host, sync } = setup({ budget: 3000 });
for (let i = 0; i < 20; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`));
host.append('subagents/agent-a1.jsonl', editLine('s0', T0 + 100, '/w/s0.ts', { agentId: 'a1' }));
await sync.sync(local);
const store = await sync.getStore('s1');
expect(store.get('s0')?.agentId).toBe('a1');
expect(store.getCursor('subagents/agent-a1.jsonl')).toBeDefined();
});
it('load older reads a full budget per source: a line longer than the budget split over the sources is kept', async () => {
const budget = 2000;
const { host, sync } = setup({ budget });
host.append('main', editLine('m0', T0, '/w/m0.ts'));
host.append('main', editLine('big', T0 + 1, '/w/big.ts', { pad: 'p'.repeat(800) })); // > budget / 3
for (let i = 2; i < 12; i++) host.append('main', editLine(`m${i}`, T0 + i, `/w/m${i}.ts`));
for (const a of ['a1', 'a2']) {
for (let i = 0; i < 12; i++) {
host.append(
`subagents/agent-${a}.jsonl`,
editLine(`${a}-${i}`, T0 + 100 + i, `/w/${a}${i}.ts`, { agentId: a })
);
}
}
for (let i = 0; i < 3; i++) await sync.sync(local);
const store = await sync.getStore('s1');
expect(store.sourcesWithOlder()).toHaveLength(3);
for (let i = 0; i < 20 && store.backfillTruncated; i++) await sync.sync(local, { older: true });
expect(store.get('big')).toBeDefined();
expect(store.list()).toHaveLength(36);
});
it(`load older reads at most ${MAX_OLDER_SOURCES} sources per click, taking turns`, async () => {
const { host, sync } = setup({ budget: 600 });
const subs = ['a1', 'a2', 'a3', 'a4'].map((a) => `subagents/agent-${a}.jsonl`);
for (const relPath of ['main', ...subs]) {
for (let i = 0; i < 8; i++) host.append(relPath, editLine(`${relPath}-${i}`, T0 + i, `/w/${i}.ts`));
}
for (let i = 0; i < 3; i++) await sync.sync(local);
const store = await sync.getStore('s1');
expect(store.sourcesWithOlder()).toHaveLength(5);
const olderCall = () => host.calls.at(-1)!.map((r) => r.relPath);
await sync.sync(local, { older: true });
expect(olderCall()).toEqual(['main', subs[0], subs[1]]);
await sync.sync(local, { older: true });
expect(olderCall()).toEqual([subs[2], subs[3], 'main']);
for (let i = 0; i < 30 && store.backfillTruncated; i++) await sync.sync(local, { older: true });
expect(store.list()).toHaveLength(40);
});
it('a subagent transcript that vanished loses its cursor (no endless "Load older"), not its entries', async () => {
const { host, sync } = setup({ budget: 600 });
const sub = 'subagents/agent-a1.jsonl';
host.append('main', editLine('m0', T0, '/w/m0.ts'));
for (let i = 0; i < 8; i++) host.append(sub, editLine(`s${i}`, T0 + 10 + i, `/w/s${i}.ts`, { agentId: 'a1' }));
for (let i = 0; i < 2; i++) await sync.sync(local);
const store = await sync.getStore('s1');
expect(store.backfillTruncated).toBe(true);
const kept = store.list().length;
host.files.delete(sub);
await sync.sync(local, { older: true });
expect(store.getCursor(sub)).toBeUndefined();
expect(store.backfillTruncated).toBe(false);
expect(store.list()).toHaveLength(kept);
});
it('sweep keeps the sessions in use here and those that exist', async () => {
const asked: Record<string, boolean> = {};
const { sync } = setup({
sweepStores: async (keep) => {
for (const id of ['s1', 'kept', 'gone']) asked[id] = keep(id);
return 1;
},
});
await sync.getStore('s1');
expect(await sync.sweep((id) => id === 'kept')).toBe(1);
expect(asked).toEqual({ s1: true, kept: true, gone: false });
});
});
describe('confirmImages', () => {
it('ignores directories and missing files', () => {
const c = [{ toolUseId: 'b', start: T0, end: T0 + 1, paths: ['/d.png', '/m.png'] }];
const out = confirmImages(c, (p) =>
p === '/d.png' ? { realPath: p, kind: 'directory', size: 0, mtimeMs: T0 } : null
);
expect(out).toEqual([]);
});
it('lists a file sent to the user outside any window, once per file version', () => {
const png = { realPath: '/w/p.png', kind: 'file' as const, size: 9, mtimeMs: T0 };
const sent = { toolUseId: 's', start: T0 + 9000, end: T0 + 9000, paths: ['/w/p.png'], sent: true };
expect(confirmImages([sent], () => png)).toEqual([
{ toolUseId: 's', path: '/w/p.png', ts: T0 + 9000, tool: 'sent' },
]);
// The Bash call that wrote it was already listed: same version, not again.
expect(
confirmImages(
[sent],
() => png,
() => T0 + 500
)
).toEqual([]);
expect(confirmImages([sent], () => null)).toEqual([]);
});
it('skips a file version already recorded: the ls right after the render, a listing after a Write', () => {
const png = { realPath: '/w/p.png', kind: 'file' as const, size: 9, mtimeMs: T0 + 900 };
const render = { toolUseId: 'r', start: T0, end: T0 + 1000, paths: ['/w/p.png'] };
const ls = { toolUseId: 'l', start: T0 + 1500, end: T0 + 1600, paths: ['/w/p.png'] };
expect(confirmImages([ls, render], () => png).map((i) => i.toolUseId)).toEqual(['r']);
// A Write entry for the same path at T0 + 950 already covers it.
expect(
confirmImages(
[ls],
() => png,
() => T0 + 950
)
).toEqual([]);
// A later re-render (newer mtime, outside the slack) is a new version.
const again = { toolUseId: 'r2', start: T0 + 10_000, end: T0 + 11_000, paths: ['/w/p.png'] };
const newer = { ...png, mtimeMs: T0 + 10_500 };
expect(
confirmImages(
[again],
() => newer,
() => T0 + 1000
).map((i) => i.toolUseId)
).toEqual(['r2']);
});
});
describe('reads only on request (wiring guard)', () => {
const read = (file: string) => readFileSync(join(__dirname, '..', file), 'utf-8');
it('the server never starts a sync itself: it only hands the reader to the routes', () => {
const server = read('src/web/server.ts');
const uses = [...server.matchAll(/this\.editHistorySync\b(\.\w+)?/g)].map((m) => m[1] ?? '');
expect(uses.sort()).toEqual(['', '.flushAll', '.remove', '.sweep']);
expect(server).toMatch(/registerEditHistoryRoutes\(this\.app, ctx, this\.editHistorySync\)/);
});
it('no other module holds the reader, and the edit history modules run no timer or watcher', () => {
const root = join(__dirname, '..');
const holders = readdirSync(join(root, 'src'), { recursive: true, encoding: 'utf-8' })
.filter((f) => /\.(ts|js)$/.test(f))
.map((f) => join('src', f).split('\\').join('/'))
.filter((f) => /EditHistorySync|editHistorySync/.test(read(f)));
expect(holders.sort()).toEqual([
'src/web/edit-history-sync.ts',
'src/web/routes/edit-history-routes.ts',
'src/web/server.ts',
]);
for (const file of [
'src/web/edit-history-sync.ts',
'src/edit-history-store.ts',
'src/edit-history-local-source.ts',
'src/edit-history-parse.ts',
'src/web/routes/edit-history-routes.ts',
]) {
const src = read(file);
expect(src, file).not.toMatch(/setInterval|\bwatch\(|chokidar/);
}
// The store's only timer is its debounced save.
expect(read('src/web/edit-history-sync.ts')).not.toMatch(/setTimeout/);
});
});
vi.setConfig({ testTimeout: 10_000 });
+483
View File
@@ -0,0 +1,483 @@
/**
* @fileoverview Edit history button and panel, end to end in a real browser: real server, real
* Chromium. The edits API is mocked with `page.route` (the UI is built against its contract); the
* thumbnails (workspace `file-raw`, the attachment route outside it) and the transcript SSE
* events the open panel follows go through the real server.
*/
import { homedir } from 'node:os';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { chromium, type Browser, type Page, type Route } from 'playwright';
import { WebServer } from '../src/web/server.js';
/** A 2×2 red PNG, so the thumbnail has real pixels to decode. */
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAIAAAD91JpzAAAAEElEQVR4nGP4z8AARAwQCgAf7gP9i18U1AAAAABJRU5ErkJggg==',
'base64'
);
type Entry = {
id: string;
ts: number;
path: string;
relPath: string;
kind: 'code' | 'image';
tool: string;
created: boolean;
added: number;
removed: number;
agentId?: string;
};
describe('Edit history panel in a real browser', () => {
let server: WebServer;
let browser: Browser;
let page: Page;
let root: string;
/** A directory outside the session's workspace, for the attachment-route thumbnail. */
let ext: string;
let sessionA: string;
let sessionB: string;
const settingsPutStatuses: number[] = [];
/** Bodies of POST /api/sessions/:id/attachments (the file preview registering an external image). */
const attachmentPosts: unknown[] = [];
/** Every request to the mocked API: `METHOD url`. */
const calls: string[] = [];
/** The mocked server state per session id. */
const state = new Map<string, Record<string, unknown>>();
let diffs: Record<string, { diff: string; truncated: boolean }> = {};
let olderEntries: Entry[] = [];
const entry = (over: Partial<Entry> & { id: string; relPath: string }): Entry => ({
ts: Date.now(),
path: join(root, over.relPath),
kind: 'code',
tool: 'Edit',
created: false,
added: 1,
removed: 1,
...over,
});
const json = (route: Route, data: unknown) =>
route.fulfill({ contentType: 'application/json', body: JSON.stringify({ success: true, data }) });
const buttonVisible = () =>
page.evaluate(() => getComputedStyle(document.getElementById('editHistoryBtn')!).display !== 'none');
const bodyText = async () => (await page.textContent('#editHistoryBody')) ?? '';
const setSetting = async (on: boolean) => {
await page.evaluate(() => (window as any).app.openAppSettings());
if ((await page.isChecked('#appSettingsShowEditHistory')) !== on)
await page.click('label.set-chip:has(#appSettingsShowEditHistory)');
await page.evaluate(() => (window as any).app.saveAppSettings());
await page.waitForTimeout(300);
await page.evaluate(() => (window as any).app.closeAppSettings());
};
const createSession = (dir: string) =>
page.evaluate(async (workingDir) => {
const res = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, mode: 'shell' }),
});
const id = (await res.json()).data.session.id;
await fetch(`/api/sessions/${id}/shell`, { method: 'POST' });
return id as string;
}, dir);
const select = (id: string) =>
page.evaluate(async (sid) => {
const app = (window as any).app;
for (let i = 0; i < 100 && !app.sessions.has(sid); i++) await new Promise((r) => setTimeout(r, 100));
await app.selectSession(sid);
}, id);
const reload = () => page.evaluate(() => (window as any).app.refreshEditHistory());
const syncReads = (sid: string) => calls.filter((c) => c === `GET /api/sessions/${sid}/edits?sync=1`).length;
const toolEnd = (sessionId: string, toolName: string) =>
(server as any).broadcast('transcript:tool_end', { sessionId, toolName, isError: false, timestamp: Date.now() });
const rows = () => page.locator('#editHistoryBody .edit-history-entry');
beforeAll(async () => {
root = mkdtempSync(join(homedir(), 'edit-history-ui-'));
writeFileSync(join(root, 'shot.png'), PNG);
ext = mkdtempSync(join(homedir(), 'edit-history-ext-'));
writeFileSync(join(ext, 'render.png'), PNG);
server = new WebServer(0, false, true);
await server.start();
browser = await chromium.launch({ headless: true });
page = await browser.newPage({ viewport: { width: 1400, height: 900 } });
page.on('response', (r) => {
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
});
page.on('request', (r) => {
if (r.method() === 'POST' && /\/api\/sessions\/[^/]+\/attachments$/.test(r.url()))
attachmentPosts.push(r.postDataJSON());
});
await page.route('**/api/sessions/*/edits**', async (route) => {
const req = route.request();
const url = new URL(req.url());
calls.push(`${req.method()} ${url.pathname}${url.search}`);
const m = url.pathname.match(/\/api\/sessions\/([^/]+)\/edits(?:\/(.+))?$/)!;
const sid = decodeURIComponent(m[1]);
const rest = m[2] ? decodeURIComponent(m[2]) : '';
const s = state.get(sid) ?? { state: 'unsupported', backfillTruncated: false, agents: {}, entries: [] };
if (req.method() === 'POST' && rest === 'older') {
const entries = [...(s.entries as Entry[]), ...olderEntries];
state.set(sid, { ...s, entries, backfillTruncated: false });
return json(route, state.get(sid));
}
if (req.method() === 'GET' && rest) {
const d = diffs[rest];
if (!d) return route.fulfill({ status: 404, contentType: 'application/json', body: '{"success":false}' });
return json(route, d);
}
return json(route, s);
});
await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 });
sessionA = await createSession(root);
sessionB = await createSession(root);
await select(sessionA);
const t0 = Date.now();
state.set(sessionA, {
state: 'ok',
backfillTruncated: true,
agents: {
agent1: { description: 'Refactor the parser', agentType: 'general-purpose', worktreeBranch: 'wt/parser' },
},
entries: [
entry({ id: 'e4', ts: t0, relPath: 'src/a.ts', added: 3, removed: 1 }),
entry({ id: 'e3', ts: t0 - 1000, relPath: 'shot.png', kind: 'image', tool: 'bash', created: true }),
entry({
id: 'e2',
ts: t0 - 2000,
relPath: 'src/b.ts',
created: true,
tool: 'Write',
added: 10,
removed: 0,
agentId: 'agent1',
}),
entry({ id: 'e1', ts: t0 - 3000, relPath: 'src/a.ts', added: 2, removed: 2 }),
],
});
diffs = {
e4: { diff: '@@ -1,3 +1,3 @@\n context\n-old line\n+new line\n+another\n', truncated: true },
e1: { diff: '@@ -5 +5 @@\n-x\n+y\n', truncated: false },
};
olderEntries = [entry({ id: 'e0', ts: t0 - 86_400_000, relPath: 'src/old.ts', added: 7, removed: 0 })];
}, 120000);
afterAll(async () => {
if (browser) await browser.close();
if (server) await server.stop();
rmSync(root, { recursive: true, force: true });
rmSync(ext, { recursive: true, force: true });
}, 60000);
it('is off by default: no button, and nothing asks the edits API', async () => {
await page.waitForTimeout(1000);
expect(await buttonVisible()).toBe(false);
expect(calls).toEqual([]);
});
it('turning it on through Settings shows the button and saves cleanly (the key never reaches the strict PUT)', async () => {
await setSetting(true);
expect(settingsPutStatuses.length).toBeGreaterThan(0);
expect(settingsPutStatuses.every((s) => s === 200)).toBe(true);
expect(await buttonVisible()).toBe(true);
// In the header next to Attachments, where the panel opens; no longer in the bottom bar.
expect(await page.evaluate(() => !!document.querySelector('#headerRight #editHistoryBtn'))).toBe(true);
expect(await page.evaluate(() => !!document.querySelector('.toolbar #editHistoryBtn'))).toBe(false);
expect(calls).toEqual([]); // no poll: nothing is read until the panel opens
});
it('opens a panel listing the entries newest first, with the subagent chip and an image thumbnail', async () => {
await page.click('#editHistoryBtn');
await page.waitForSelector('#editHistoryPanel.visible');
await page.waitForSelector('#editHistoryBody .edit-history-entry');
expect(calls[0]).toBe(`GET /api/sessions/${sessionA}/edits?sync=1`);
expect(await page.getAttribute('#editHistoryBtn', 'aria-expanded')).toBe('true');
expect(await rows().count()).toBe(4);
const paths = await page.locator('#editHistoryBody .edit-history-path').allTextContents();
expect(paths).toEqual(['src/a.ts', 'shot.png', 'src/b.ts', 'src/a.ts']);
expect(await rows().nth(0).locator('.edit-history-badge').textContent()).toBe('edit');
expect(await rows().nth(2).locator('.edit-history-badge').textContent()).toBe('new');
expect(await rows().nth(0).locator('.edit-history-stats').textContent()).toBe('+3−1');
// The image row: a symbol instead of a word, no +/− counts.
expect(await rows().nth(1).locator('.edit-history-badge--image svg').count()).toBe(1);
expect(await rows().nth(1).locator('.edit-history-stats').count()).toBe(0);
// The subagent chip: its description, and type + branch on hover.
const chip = rows().nth(2).locator('.edit-history-agent');
expect(await chip.textContent()).toBe('Refactor the parser');
expect(await chip.getAttribute('title')).toBe('general-purpose · wt/parser');
expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(1);
// The thumbnail is served by the real file-raw route and decodes.
const thumb = page.locator('#editHistoryBody img.edit-history-thumb');
expect(await thumb.count()).toBe(1);
expect(await thumb.getAttribute('src')).toBe(
`/api/sessions/${sessionA}/file-raw?path=${encodeURIComponent(join(root, 'shot.png'))}`
);
await page.waitForFunction(
() => (document.querySelector('img.edit-history-thumb') as HTMLImageElement)?.naturalWidth === 2
);
expect(await page.textContent('#editHistoryCount')).toBe('4');
});
it('a code entry opens its diff inline with the Git panel line classes, and closes again', async () => {
await rows().nth(0).locator('.edit-history-spacer').click();
await page.waitForSelector('#editHistoryBody .edit-history-diff pre.git-diff');
expect(calls).toContain(`GET /api/sessions/${sessionA}/edits/e4`);
const lines = await page.$$eval('#editHistoryBody pre.git-diff .git-diff-line', (els) =>
els.map((e) => [e.className.replace('git-diff-line', '').trim(), e.textContent])
);
expect(lines).toEqual([
['git-diff-line--hunk', '@@ -1,3 +1,3 @@\n'],
['', ' context\n'],
['git-diff-line--del', '-old line\n'],
['git-diff-line--add', '+new line\n'],
['git-diff-line--add', '+another\n'],
]);
expect(await page.textContent('#editHistoryBody .edit-history-diff')).toContain('Diff cut short');
expect(await rows().nth(0).getAttribute('aria-expanded')).toBe('true');
// A re-read (what an SSE update does) keeps the open diff without fetching it again.
const before = calls.filter((c) => c.endsWith('/edits/e4')).length;
await reload();
expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(1);
expect(calls.filter((c) => c.endsWith('/edits/e4')).length).toBe(before);
await rows().nth(0).locator('.edit-history-spacer').click();
expect(await page.locator('#editHistoryBody pre.git-diff').count()).toBe(0);
});
it('a path and an image open the shared file preview with the absolute path', async () => {
await page.evaluate(() => {
const w = window as any;
w.__previewed = [];
w.__openFilePreview = w.app.openFilePreview;
w.app.openFilePreview = (p: string, sid: string) => w.__previewed.push([p, sid]);
});
await rows().nth(2).locator('.edit-history-path').click();
await page.locator('#editHistoryBody img.edit-history-thumb').click();
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([
[join(root, 'src/b.ts'), sessionA],
[join(root, 'shot.png'), sessionA],
]);
// Clicking the path never toggles the row's diff.
expect(await page.locator('#editHistoryBody .edit-history-diff').count()).toBe(0);
await page.evaluate(() => {
const w = window as any;
w.app.openFilePreview = w.__openFilePreview;
});
});
it('"Main agent only" hides the subagent edits; "Group by file" puts edits under their file', async () => {
await page.check('#editHistoryMainOnlyToggle');
expect(await rows().count()).toBe(3);
expect(await page.locator('#editHistoryBody .edit-history-agent').count()).toBe(0);
expect(await bodyText()).not.toContain('src/b.ts');
await page.uncheck('#editHistoryMainOnlyToggle');
expect(await rows().count()).toBe(4);
await page.check('#editHistoryGroupToggle');
const groups = page.locator('#editHistoryBody .edit-history-group');
expect(await groups.count()).toBe(3);
expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-path').textContent()).toBe('src/a.ts');
expect(await groups.nth(0).locator('.edit-history-entry').count()).toBe(2);
expect(await groups.nth(0).locator('.edit-history-group-head .edit-history-stats').textContent()).toBe('+5−3');
await groups.nth(0).locator('.edit-history-entry').nth(1).locator('.edit-history-spacer').click();
await page.waitForSelector('#editHistoryBody .git-diff-line--add');
expect(await page.textContent('#editHistoryBody .git-diff-line--add')).toBe('+y\n');
await page.uncheck('#editHistoryGroupToggle');
});
it('"Load older" asks the server with a POST and shows what it returns', async () => {
const older = page.locator('#editHistoryBody button.edit-history-older');
expect(await older.count()).toBe(1);
await older.click();
await page.waitForFunction(() =>
/src\/old\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
);
expect(calls).toContain(`POST /api/sessions/${sessionA}/edits/older`);
expect(await rows().count()).toBe(5);
expect(await page.locator('#editHistoryBody button.edit-history-older').count()).toBe(0);
});
it('while open it follows the session: an edit-capable tool end or a turn end re-reads (debounced), other tools, sessions and keep-alive turns do not', async () => {
const before = syncReads(sessionA);
const s = state.get(sessionA)!;
state.set(sessionA, {
...s,
entries: [entry({ id: 'e5', relPath: 'src/fresh.ts', added: 4, removed: 0 }), ...(s.entries as Entry[])],
});
toolEnd(sessionA, 'Read');
toolEnd(sessionA, 'Grep');
toolEnd('someone-else', 'Edit');
await page.waitForTimeout(1000);
expect(syncReads(sessionA)).toBe(before);
toolEnd(sessionA, 'Edit');
toolEnd(sessionA, 'Bash');
await page.waitForFunction(() =>
/src\/fresh\.ts/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
);
await page.waitForTimeout(800);
expect(syncReads(sessionA)).toBe(before + 1);
// The end of a turn re-reads too (a parallel tool call reports one tool end only, a background
// subagent writes later): `session:idle`, since claude's transcript has no `result` line.
// Not another session's, and not the end of Codeman's own keep-alive turn (it edits nothing).
(server as any).broadcast('session:idle', { id: 'someone-else' });
(server as any).broadcast('session:idle', { id: sessionA, keepAlive: true });
await page.waitForTimeout(1200);
expect(syncReads(sessionA)).toBe(before + 1);
(server as any).broadcast('session:idle', { id: sessionA });
await page.waitForTimeout(1200);
expect(syncReads(sessionA)).toBe(before + 2);
});
it('while the panel is closed, transcript events read nothing; opening reads once', async () => {
await page.click('#editHistoryBtn');
await page.waitForSelector('#editHistoryPanel:not(.visible)', { state: 'attached' });
const before = syncReads(sessionA);
toolEnd(sessionA, 'Edit');
(server as any).broadcast('session:idle', { id: sessionA });
await page.waitForTimeout(1200);
expect(syncReads(sessionA)).toBe(before);
expect(await page.$('#editHistoryUnread')).toBeNull(); // no unread badge: closed means idle
await page.click('#editHistoryBtn');
await page.waitForSelector('#editHistoryPanel.visible');
await page.waitForFunction(() => document.querySelectorAll('#editHistoryBody .edit-history-entry').length > 0);
expect(syncReads(sessionA)).toBe(before + 1);
});
it('an image outside the workspace registers nothing until clicked, then previews through the attachment route quietly', async () => {
const s = state.get(sessionA)!;
const outside = join(ext, 'render.png');
state.set(sessionA, {
...s,
entries: [
entry({ id: 'ext', path: outside, relPath: outside, kind: 'image', tool: 'bash', added: 0, removed: 0 }),
...(s.entries as Entry[]),
],
});
await reload();
const row = page.locator('#editHistoryBody .edit-history-entry', { hasText: 'render.png' });
await row.waitFor();
// No thumbnail for it (that would mint an attachment id per render), and no registration.
expect(
await page.evaluate(() =>
[...document.querySelectorAll('#editHistoryBody img.edit-history-thumb')].map((i) => i.getAttribute('src'))
)
).not.toContainEqual(expect.stringContaining('render.png'));
await reload();
expect(attachmentPosts).toEqual([]);
// A click opens the shared preview, which registers the file once, without a notification.
await row.click();
await page.waitForSelector('#filePreviewOverlay.visible');
expect(attachmentPosts).toEqual([{ path: outside, notify: false }]);
await page.evaluate(() => (window as any).app.closeFilePreview());
state.set(sessionA, s);
});
it('a long path outside the workspace keeps its file name readable, the folder is cut at the start', async () => {
const s = state.get(sessionA)!;
const long =
'/tmp/claude-1000/-mnt-caches-ComfyUI/07e259af-d4d2-4ac3-8a7e-95972e3547df/scratchpad/icons/bremse_alt1_pedal.png';
state.set(sessionA, {
...s,
entries: [
entry({ id: 'long', relPath: long, kind: 'image', tool: 'sent', added: 0, removed: 0 }),
...(s.entries as Entry[]),
],
});
await reload();
const row = rows().nth(0);
await row.waitFor();
expect(await row.locator('.edit-history-path').textContent()).toBe(long);
const name = row.locator('.edit-history-path-name');
expect(await name.textContent()).toBe('bremse_alt1_pedal.png');
// The name is shown whole; only the folder part is shortened.
expect(await name.evaluate((el) => el.scrollWidth <= el.clientWidth)).toBe(true);
expect(await row.locator('.edit-history-path-dir').evaluate((el) => el.scrollWidth > el.clientWidth)).toBe(true);
state.set(sessionA, s);
});
it('renders a hostile path as text, never as markup', async () => {
const s = state.get(sessionA)!;
const evil = '<script>window.__pwned=1</script><img src=x onerror="window.__pwned=2">.ts';
state.set(sessionA, {
...s,
agents: { agent1: { description: '<b>bold</b>', agentType: 'x', worktreeBranch: '' } },
entries: [entry({ id: 'evil', relPath: evil, agentId: 'agent1' }), ...(s.entries as Entry[])],
});
await reload();
await page.waitForFunction(() => /__pwned/.test(document.getElementById('editHistoryBody')!.textContent ?? ''));
expect(await rows().nth(0).locator('.edit-history-path').textContent()).toBe(evil);
expect(await rows().nth(0).locator('.edit-history-agent').textContent()).toBe('<b>bold</b>');
expect(await page.$('#editHistoryBody script')).toBeNull();
expect(await page.$('#editHistoryBody b')).toBeNull();
expect(
await page.$$eval(
'#editHistoryBody img',
(els) => els.filter((e) => !e.classList.contains('edit-history-thumb')).length
)
).toBe(0);
expect(await page.evaluate(() => (window as any).__pwned)).toBeUndefined();
});
it('unavailable: the saved entries stay, with the reason on top', async () => {
const s = state.get(sessionA)!;
state.set(sessionA, { ...s, state: 'unavailable', reason: 'EACCES: permission denied' });
await reload();
await page.waitForSelector('#editHistoryBody .edit-history-note--warn');
expect(await page.textContent('#editHistoryBody .edit-history-note--warn')).toBe(
'Showing saved entries only: EACCES: permission denied'
);
expect(await rows().count()).toBeGreaterThan(0);
state.set(sessionA, { ...s, state: 'ok' });
});
it('no transcript yet: says it comes with the next prompt, and names CLAUDE_CONFIG_DIR', async () => {
const s = state.get(sessionA)!;
state.set(sessionA, { ...s, state: 'unavailable', reason: 'no-transcript', entries: [] });
await reload();
await page.waitForSelector('#editHistoryBody .edit-history-note--warn');
const note = (await page.textContent('#editHistoryBody .edit-history-note--warn')) ?? '';
expect(note).toContain('No transcript found for this session yet');
expect(note).toContain('CLAUDE_CONFIG_DIR');
state.set(sessionA, s);
await reload();
});
it('follows the active session, and says when a session is not a Claude session', async () => {
await select(sessionB);
await page.waitForFunction(() =>
/only available for local Claude sessions/.test(document.getElementById('editHistoryBody')!.textContent ?? '')
);
expect(calls).toContain(`GET /api/sessions/${sessionB}/edits?sync=1`);
expect(await rows().count()).toBe(0);
await select(sessionA);
await page.waitForSelector('#editHistoryBody .edit-history-entry');
});
it('drags by the header', async () => {
const before = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left);
const box = (await page.locator('.edit-history-header').boundingBox())!;
await page.mouse.move(box.x + 40, box.y + 10);
await page.mouse.down();
await page.mouse.move(box.x - 140, box.y + 70, { steps: 5 });
await page.mouse.up();
const after = await page.evaluate(() => document.getElementById('editHistoryPanel')!.getBoundingClientRect().left);
expect(after).toBeLessThan(before - 100);
});
it('turning the setting off hides the button and closes the panel; SSE then reads nothing', async () => {
await setSetting(false);
expect(await buttonVisible()).toBe(false);
expect(await page.isVisible('#editHistoryPanel')).toBe(false);
const before = calls.length;
toolEnd(sessionA, 'Edit');
await page.waitForTimeout(1200);
expect(calls.length).toBe(before);
});
});
+50
View File
@@ -0,0 +1,50 @@
/** Verify parser installation through the real server, including API aliases and base paths. */
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { WebServer } from '../src/web/server.js';
describe('empty POST: production server wiring', () => {
let server: WebServer;
let app: FastifyInstance;
beforeAll(async () => {
server = new WebServer(0, false, true, '127.0.0.1', undefined, false, '/codeman');
await server.start();
app = (server as unknown as { app: FastifyInstance }).app;
});
afterAll(async () => {
await server?.stop();
});
it('accepts empty chunked POSTs to a real API through each supported prefix', async () => {
for (const prefix of ['/api', '/api/v1', '/codeman/api', '/codeman/api/v1']) {
const res = await app.inject({
method: 'POST',
url: `${prefix}/approvals/session/nonexistent/viewed`,
headers: { 'transfer-encoding': 'chunked' },
});
// The real handler ran and found no session, instead of the parser returning 415.
expect(res.statusCode, res.body).toBe(404);
expect(res.json().errorCode).toBe('NOT_FOUND');
}
});
it('still rejects a nonempty untyped POST before reaching the same handler', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/approvals/session/nonexistent/viewed',
headers: { 'transfer-encoding': 'chunked' },
payload: '{}',
});
expect(res.statusCode).toBe(415);
});
it('preserves the API 404 envelope for an unknown empty chunked POST', async () => {
const res = await app.inject({
method: 'POST',
url: '/api/nonexistent',
headers: { 'transfer-encoding': 'chunked' },
});
expect(res.statusCode).toBe(404);
expect(res.json().errorCode).toBe('NOT_FOUND');
});
});
+173 -4
View File
@@ -52,13 +52,13 @@ const { findFilePathLinks, resolveLinkedFilePath, terminalPathLine } = loadHelpe
const paths = (text: string) => findFilePathLinks(text).map((f) => f.path);
/** An xterm buffer stand-in: hard-broken rows (no `isWrapped`), as Claude Code emits them. */
function buffer(lines: string[], cols = 80) {
function buffer(lines: string[], cols = 80, wrappedRows = new Set<number>()) {
return {
length: lines.length,
getLine: (row: number) =>
row >= 0 && row < lines.length
? {
isWrapped: false,
isWrapped: wrappedRows.has(row),
translateToString: (trim?: boolean) => (trim ? lines[row].replace(/\s+$/, '') : lines[row].padEnd(cols)),
}
: undefined,
@@ -157,6 +157,10 @@ describe('terminalPathLine', () => {
// A path that already ended, and a next line opening a new rooted path.
expect(terminalPathLine(buffer(['see /tmp/a/b.png', 'c/d.png']), 0, 80)).toMatchObject({ endRow: 0 });
expect(terminalPathLine(buffer(['/home/joe/dir/', '/home/x.png']), 0, 80)).toMatchObject({ endRow: 0 });
expect(terminalPathLine(buffer(['see (/mnt/raid1/folder)', ' file.conf)']), 0, 80)).toMatchObject({ endRow: 0 });
expect(terminalPathLine(buffer(['see (/mnt/raid1/folder', ' /mnt/other/file.conf)']), 0, 80)).toMatchObject({
endRow: 0,
});
});
// `git ls-files`, `find` and `ls -R` print one path per row. An extensionless
@@ -237,13 +241,15 @@ function loadApp() {
app.sessions = new Map([['s1', { workingDir: '/mnt/build/neon_getaway-mgr-hebel' }]]);
app.openFilePreview = vi.fn();
app.openLogViewerWindow = vi.fn();
app._registerExternalPreview = vi.fn(async () => ({ attachmentId: 'att-fixture' }));
app.showToast = vi.fn();
app._isExternalPreviewPath = (p: string) => p.startsWith('~/');
return app;
}
function linksOn(lines: string[], row: number) {
function linksOn(lines: string[], row: number, cols = 80, wrappedRows = new Set<number>()) {
const app = loadApp();
app.terminal = { cols: 80, registerLinkProvider: vi.fn(), buffer: { active: buffer(lines) } };
app.terminal = { cols, registerLinkProvider: vi.fn(), buffer: { active: buffer(lines, cols, wrappedRows) } };
const provider = app.registerFilePathLinkProvider() as Provider;
let out: Link[] = [];
provider.provideLinks(row, (links) => {
@@ -253,6 +259,169 @@ function linksOn(lines: string[], row: number) {
}
describe('terminal link provider', () => {
it.each(['svg', 'markdown', 'avif', 'bmp', 'ico', 'zip'])(
'keeps the workspace opener for a hard-wrapped .%s rejected by the attachment registry',
async (extension) => {
for (const row of [1, 2]) {
const path = `/home/me/proj/out/system_architecture.${extension}`;
const { app, links } = linksOn(
['Saved the diagram (/home/me/proj/out/system_archi', ` tecture.${extension}).`],
row,
120
);
app.sessions = new Map([['s1', { workingDir: '/home/me/proj' }]]);
app._registerExternalPreview.mockResolvedValue({ status: 400, error: 'Unsupported attachment type' });
await links[0].activate({}, links[0].text);
const opener = extension === 'zip' ? app.openLogViewerWindow : app.openFilePreview;
expect(opener).toHaveBeenCalledWith(path, 's1');
expect(app._registerExternalPreview.mock.calls).toEqual([[path, 's1']]);
expect(app.showToast).not.toHaveBeenCalled();
}
}
);
it.each([
[
['Saved the screenshot (/home/me/proj/out/ghost1b_game', ' play.png).'],
'/home/me/proj/out/ghost1b_gameplay.png',
],
[
[' ⎿ Wrote (/mnt/build/neon/builds/captures/ghost1b_gamepl', ' ay.png) ok'],
'/mnt/build/neon/builds/captures/ghost1b_gameplay.png',
],
[
['Saved the screenshot (/home/me/proj/out/ghost1b_', ' very_long_game', ' play.png).'],
'/home/me/proj/out/ghost1b_very_long_gameplay.png',
],
])('preserves a parenthesised filename without spaces across hard wraps: %s', async (rows, path) => {
for (let row = 1; row <= rows.length; row++) {
const { app, links } = linksOn(rows, row, 120);
expect(links.map((link) => link.text)).toEqual([path]);
await links[0].activate({}, links[0].text);
expect(app.openFilePreview).toHaveBeenCalledWith(path, 's1');
expect(app._registerExternalPreview.mock.calls).toEqual([[path, 's1']]);
}
});
it('uses the spaced filename only when the unspaced hard-wrap target is missing', async () => {
const path = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf';
const rows = [
'Found it: I prepared a corrected configuration (/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo',
' eReader.conf).',
];
for (const row of [1, 2]) {
const { app, links } = linksOn(rows, row, 120);
const unspaced = path.replace('Kobo eReader', 'KoboeReader');
expect(links.map((link) => link.text)).toEqual([unspaced]);
expect(links[0].range.end).toEqual({ x: ' eReader.conf'.length + 1, y: 2 });
app._registerExternalPreview.mockImplementation(async (candidate: string) =>
candidate === path ? { attachmentId: 'att-fixture' } : { status: 404, error: 'File not found' }
);
await links[0].activate({}, links[0].text);
expect(app.openLogViewerWindow).toHaveBeenCalledWith(path, 's1');
expect(app._registerExternalPreview.mock.calls).toEqual([
[unspaced, 's1'],
[path, 's1'],
]);
}
});
it.each([403, 500, 503])('does not try another path after a guarded lookup returns HTTP %s', async (status) => {
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120);
app._registerExternalPreview.mockResolvedValue({ status, error: 'Lookup refused' });
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview).toHaveBeenCalledTimes(1);
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
expect(app.showToast).not.toHaveBeenCalled();
});
it('keeps the original opener when the lookup cannot reach the server', async () => {
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120);
app._registerExternalPreview.mockResolvedValue({ error: 'Connection lost' });
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview).toHaveBeenCalledTimes(1);
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
});
it('never looks up a spaced name for a hard-wrapped Bash( row, which holds a command', async () => {
const { app, links } = linksOn(['Bash(/mnt/data/Kobo', ' eReader.conf)'], 1, 120);
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview).not.toHaveBeenCalled();
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
});
it('uses the original opener when both hard-wrap filenames are missing', async () => {
for (const row of [1, 2]) {
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], row, 120);
app._registerExternalPreview.mockResolvedValue({ status: 404, error: 'Attachment file not found' });
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview.mock.calls).toEqual([
['/mnt/data/KoboeReader.conf', 's1'],
['/mnt/data/Kobo eReader.conf', 's1'],
]);
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
expect(app.showToast).not.toHaveBeenCalled();
}
});
it.each(['Path: ', '- ', '- Path: ', ' Path: '])(
'resolves the filename without its parenthesised prefix %j',
async (prefix) => {
for (const row of [1, 2]) {
const { app, links } = linksOn([`Saved the notes (${prefix}/home/me/proj/out/My`, ' Notes.md).'], row, 120);
app._registerExternalPreview
.mockResolvedValueOnce({ status: 404, error: 'Attachment file not found' })
.mockResolvedValueOnce({ attachmentId: 'att-spaced' });
expect(links.map((link) => link.text)).toEqual(['/home/me/proj/out/MyNotes.md']);
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview.mock.calls).toEqual([
['/home/me/proj/out/MyNotes.md', 's1'],
['/home/me/proj/out/My Notes.md', 's1'],
]);
expect(app.openFilePreview).toHaveBeenCalledWith('/home/me/proj/out/My Notes.md', 's1');
}
}
);
it.each([400, 403, 500, 503])('does not open an unregistered spaced alternative after HTTP %s', async (status) => {
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120);
app._registerExternalPreview
.mockResolvedValueOnce({ status: 404, error: 'File not found' })
.mockResolvedValueOnce({ status, error: 'Alternative unavailable' });
await links[0].activate({}, links[0].text);
expect(app._registerExternalPreview.mock.calls).toEqual([
['/mnt/data/KoboeReader.conf', 's1'],
['/mnt/data/Kobo eReader.conf', 's1'],
]);
expect(app.openFilePreview).not.toHaveBeenCalled();
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenCalledWith('Alternative unavailable', 'error');
});
it('keeps the session selected at click time while a path lookup is pending', async () => {
const { app, links } = linksOn(['Saved (/mnt/data/Kobo', ' eReader.conf).'], 1, 120);
const pending = links[0].activate({}, links[0].text);
app.activeSessionId = 'another-session';
await pending;
expect(app._registerExternalPreview).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
expect(app.openLogViewerWindow).toHaveBeenCalledWith('/mnt/data/KoboeReader.conf', 's1');
});
it('opens the complete parenthesised configuration path from either wrapped row', () => {
const path = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf';
const text = `Found it: I prepared a corrected configuration (${path}).`;
const split = text.indexOf(' eReader') + 1;
const rows = [text.slice(0, split), text.slice(split)];
for (const row of [1, 2]) {
const { app, links } = linksOn(rows, row, split, new Set([1]));
expect(links.map((link) => link.text)).toEqual([path]);
expect(links[0].range.start).toEqual({ x: text.indexOf(path) + 1, y: 1 });
expect(links[0].range.end).toEqual({ x: 'eReader.conf'.length + 1, y: 2 });
links[0].activate({}, links[0].text);
expect(app.openLogViewerWindow).toHaveBeenCalledWith(path, 's1');
}
});
it('links a broken path once, across both rows, and opens the whole path', () => {
const { app, links } = linksOn(BROKEN, 2);
expect(links.map((l) => l.text)).toEqual([BROKEN_PATH]);
+22 -1
View File
@@ -14,7 +14,9 @@
* 9. stepping onto a folder-grid page moves the grid's own index with it,
* 10. Alt+←/→ steps, ignores other chords and text fields, and is swallowed at the ends,
* 11. the corner grip doubles the pointer delta and clamps the size,
* 12. phones ignore Expand and the grip (mobile.css).
* 12. phones ignore Expand and the grip (mobile.css),
* 13. the rendered document carries no width cap, so it fills the window the
* grip and Expand give it (styles.css).
*
* Loaded via `vm` against a stub app, same harness style as file-preview-detach.test.ts.
*/
@@ -27,6 +29,7 @@ import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
function btn() {
const attrs: Record<string, string> = {};
@@ -376,6 +379,24 @@ describe('file viewer resize grip', () => {
});
});
describe('file viewer rendered markdown width', () => {
it('carries no fixed reading column, so the document fills the window the grip and Expand give it', () => {
const mdRule = stylesCss.match(/\.file-preview-body > \.file-preview-md \{[^}]*\}/);
expect(mdRule, 'the rule the rendered document lives in').not.toBeNull();
// Any cap here reflows nothing once the window is wider than it, which is
// what the shipped 960px column did. The window width is the user's choice,
// so nothing in this rule may cap it.
expect(mdRule![0], 'no width cap on the document').not.toMatch(/max-width/);
// The shared .rv-text prose rule must not reintroduce one either.
const rvBase = stylesCss.match(/\.rv-text,\s*\.response-viewer-body > :not\(\.rv-message\) \{[^}]*\}/);
expect(rvBase, 'the shared prose rule').not.toBeNull();
expect(rvBase![0], 'no width cap on the shared prose rule').not.toMatch(/max-width/);
// ASCII-diagram blocks cap themselves at var(--rv-content-max, 720px), which only the
// Response Viewer sets; the document lifts it so a diagram follows the window too.
expect(mdRule![0], 'diagram blocks follow the window').toMatch(/--rv-content-max:\s*none;/);
});
});
describe('file viewer on phones', () => {
it('overrides Expand and any dragged size, and hides both controls, under 600px', () => {
const css = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8');
+125
View File
@@ -0,0 +1,125 @@
/**
* @fileoverview Codex rollout lines shaped like real ones, for the offline
* history tests. The structure (keys, their order, the event types around a
* fork boundary) was taken from real rollouts written by codex 0.120 to 0.163
* and from codex-rs `SessionMeta` (`protocol/src/protocol.rs`); no
* conversation content was copied.
*
* A forked or spawned thread's file, as `record_initial_history` writes it for
* `ForkPersistence::Copied`:
*
* own session_meta, parent session_meta, copied parent records,
* thread_settings_applied, the child's own records
*
* In paginated history every line also carries an `ordinal`, and the child's
* own `session_meta` declares `subagent_history_start_ordinal`.
*/
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
/** Codex's base instructions make every real session_meta line about 20 KiB. */
export const BASE_INSTRUCTIONS_BYTES = 19 * 1024;
export interface MetaOptions {
id: string;
/** Spawn parent: sets parent_thread_id, forked_from_id and source.subagent.thread_spawn. */
parent?: string;
/** Shared root (`session_id`); defaults to `parent ?? id`, as Codex writes it. */
root?: string;
mode?: 'legacy' | 'paginated';
historyStart?: number;
timestamp?: string;
baseInstructionsBytes?: number;
}
export function realMeta(options: MetaOptions): Record<string, unknown> {
const { id, parent, mode = 'legacy', timestamp = '2026-10-24T01:00:00.000Z' } = options;
const payload: Record<string, unknown> = {
session_id: options.root ?? parent ?? id,
id,
...(parent ? { forked_from_id: parent, parent_thread_id: parent } : {}),
timestamp,
cwd: '/work/repo',
originator: 'codex_exec',
cli_version: '0.163.0',
source: parent
? {
subagent: {
thread_spawn: {
parent_thread_id: parent,
depth: 1,
agent_path: null,
agent_nickname: 'Kepler',
agent_role: null,
},
},
}
: 'exec',
thread_source: parent ? 'subagent' : 'user',
...(parent ? { agent_nickname: 'Kepler' } : {}),
model_provider: 'openai',
base_instructions: { text: 'b'.repeat(options.baseInstructionsBytes ?? BASE_INSTRUCTIONS_BYTES) },
history_mode: mode,
...(options.historyStart !== undefined ? { subagent_history_start_ordinal: options.historyStart } : {}),
...(parent ? { multi_agent_version: 'v1' } : {}),
context_window: { window_id: `${id.slice(0, 8)}-window` },
};
return { timestamp, type: 'session_meta', payload };
}
export const taskStarted = () => ({
timestamp: '2026-10-24T01:00:00.000Z',
type: 'event_msg',
payload: { type: 'task_started' },
});
export const settingsApplied = () => ({
timestamp: '2026-10-24T01:00:00.000Z',
type: 'event_msg',
payload: { type: 'thread_settings_applied', thread_settings: { model: 'gpt-5.5', cwd: '/work/repo' } },
});
export const developer = () => ({
timestamp: '2026-10-24T01:00:00.000Z',
type: 'response_item',
payload: { type: 'message', role: 'developer', content: [{ type: 'input_text', text: 'policy' }] },
});
export const injected = (tag = 'environment_context') => ({
timestamp: '2026-10-24T01:00:00.000Z',
type: 'response_item',
payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: `<${tag}>\n ctx\n</${tag}>` }] },
});
/** A real user turn: the response_item Codex sent plus its user_message event twin. */
export const userTurn = (text: string, timestamp = '2026-10-24T01:00:00.000Z') => [
{
timestamp,
type: 'response_item',
payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text }] },
},
{ timestamp, type: 'event_msg', payload: { type: 'user_message', message: text, images: [] } },
];
/** An assistant message: the event and the response_item, both carrying Codex's phase. */
export const assistantTurn = (text: string, phase: 'commentary' | 'final_answer' = 'final_answer') => [
{
timestamp: '2026-10-24T01:00:01.000Z',
type: 'event_msg',
payload: { type: 'agent_message', message: text, phase },
},
{
timestamp: '2026-10-24T01:00:01.000Z',
type: 'response_item',
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text }], phase },
},
];
/** Number every line, as paginated history does. */
export function withOrdinals(lines: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
return lines.map((line, ordinal) => {
const { timestamp, type, payload, ...rest } = line;
return { timestamp, ordinal, type, payload, ...rest };
});
}
export async function writeRollout(dir: string, name: string, lines: unknown[]): Promise<string> {
const path = join(dir, name);
await writeFile(path, lines.map((line) => JSON.stringify(line)).join('\n') + '\n');
return path;
}
+53
View File
@@ -212,6 +212,59 @@ function loadApp(fetchImpl: (url: string, init?: { body?: string }) => unknown)
return { app, elements, keyHandlers, fetch: context.fetch as ReturnType<typeof vi.fn> };
}
describe('guarded attachment registration results', () => {
it.each(['svg', 'markdown', 'avif', 'bmp', 'ico', 'zip'])(
'names a rejected workspace .%s path without describing it as external',
async (extension) => {
const { app } = loadApp(async () => ({
ok: false,
status: 400,
json: async () => ({ error: 'Unsupported attachment type' }),
}));
const path = `/home/joe/wiki/out/system architecture.${extension}`;
const result = await Object.getPrototypeOf(app)._registerExternalPreview.call(app, path, 's1');
expect(result.status).toBe(400);
expect(result.error).toContain(path);
expect(result.error).not.toContain('outside the session workspace');
}
);
it('retains the external-file explanation for a rejected external type', async () => {
const { app } = loadApp(async () => ({
ok: false,
status: 400,
json: async () => ({ error: 'Unsupported attachment type' }),
}));
const result = await Object.getPrototypeOf(app)._registerExternalPreview.call(
app,
'/mnt/data/system architecture.svg',
's1'
);
expect(result.error).toBe(
'Cannot preview .svg from outside the session workspace (images, video, audio, PDF, Office documents and text files only).'
);
});
it.each([404, 403, 500, 503])('preserves HTTP %s for the activation decision', async (status) => {
const { app, fetch } = loadApp(async () => ({
ok: false,
status,
json: async () => ({ error: 'Cannot read file' }),
}));
const result = await Object.getPrototypeOf(app)._registerExternalPreview.call(app, '/mnt/data/a.conf', 's1');
expect(result).toEqual({ error: 'Cannot read file', status });
expect(JSON.parse(fetch.mock.calls[0][1].body)).toEqual({ path: '/mnt/data/a.conf', notify: false });
});
it('does not turn a connection failure into a missing file', async () => {
const { app } = loadApp(async () => {
throw new Error('Connection lost');
});
const result = await Object.getPrototypeOf(app)._registerExternalPreview.call(app, '/mnt/data/a.conf', 's1');
expect(result).toEqual({ error: 'Connection lost' });
});
});
const LISTING = {
folderId: 'fld_1',
path: '/mnt/raid1/styleframes',
+101
View File
@@ -0,0 +1,101 @@
/**
* @fileoverview The Git fetch interval rows (App Settings → Bottom bar, Session Options → Git) in Chinese
* (zh-CN): each label and description runs through the real translator over the real index.html in JSDOM and
* reads in Chinese with no English left beyond the product name and the technical names Git / git fetch, and
* every new key (the save toast in session-ui.js included) is in the dictionary exactly once (a repeated key
* in the object literal silently replaces the first).
*
* The Git tab's Status row is not here: its text is written at runtime by updateGitFetchStatus and carries
* data-i18n-skip, so translating it would rewrite the stamp the code builds. 'sec' is not a key either: only
* whole unit words are ('hours'), so 'sec' stays as written.
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
// Keys that contain a quote are written escaped in the object literal, so the dictionary is counted unescaped.
const DICT = I18N.replace(/\\'/g, "'").replace(/\\"/g, '"');
type Api = { configure(o: Record<string, string>): void; start(): void };
const ROWS = [
'appSettingsGitFetchEnabled',
'appSettingsGitFetchIntervalSeconds',
'modalGitFetchFollow',
'modalGitFetchEnabled',
'modalGitFetchInterval',
];
/** What may stay Latin: the product names, and Git / git fetch as technical names. */
const leftover = (text: string) =>
text.replace(/\b(Claude Code|Claude|Codeman|Ralph|Git|git|fetch)\b/g, '').match(/[A-Za-z]+/g) ?? [];
function rowStrings(doc: Document, id: string): { label: string; description: string } {
const row = doc.getElementById(id)!.closest('.set-row')!;
const label = row.querySelector('.set-row-label')!;
// The row's own scope marker ('synced') rides inside the label; the dictionary key is the label without it.
const scope = label.querySelector('.set-scope')?.textContent ?? '';
return {
label: label.textContent!.replace(scope, '').trim(),
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
};
}
/** The Git section's blurb, which is not a row. */
function blurb(doc: Document): string {
const section = doc.getElementById('git-tab')!;
return section.querySelector('.set-section-blurb')!.textContent!.trim();
}
const source = new JSDOM(INDEX);
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
afterAll(() => {
source.window.close();
dom.window.close();
});
describe('the Git fetch rows in zh-CN', () => {
it.each(ROWS)('translates the %s row, with no English left', (id) => {
const english = rowStrings(source.window.document, id);
const chinese = rowStrings(dom.window.document, id);
expect(english.label.length).toBeGreaterThan(0);
for (const key of ['label', 'description'] as const) {
expect(chinese[key]).not.toBe(english[key]);
expect(leftover(chinese[key])).toEqual([]);
}
});
it('translates the Git section blurb too, which is not a row', () => {
expect(blurb(dom.window.document)).not.toBe(blurb(source.window.document));
expect(leftover(blurb(dom.window.document))).toEqual([]);
});
it("leaves the Status row's runtime text to the code that writes it", () => {
// updateGitFetchStatus builds this row's sentence; a dictionary key for it would overwrite the stamp.
expect(INDEX).toContain('id="gitFetchStatus" data-i18n-skip hidden');
});
it('adds each key to the dictionary once, the save toast included', () => {
const texts = ROWS.flatMap((id) => Object.values(rowStrings(source.window.document, id)));
texts.push(blurb(source.window.document), 'Git fetch settings saved');
// 'sec' is deliberately not a key: only whole unit words are, so the unit stays as the markup writes it.
expect(DICT).not.toMatch(/\n sec: /);
for (const text of texts) {
const quoted = DICT.split(`'${text}'`).length + DICT.split(`"${text}"`).length - 2;
const bare = /^\w+$/.test(text) ? DICT.split(`\n ${text}: `).length - 1 : 0;
expect(quoted + bare, text).toBe(1);
}
});
});
+550
View File
@@ -0,0 +1,550 @@
/**
* @fileoverview The Git fetch interval scheduler (src/git-fetch-scheduler.ts): which sessions a tick runs,
* and which it must not. Every rule is checked by calling `tick(now)` with an explicit clock, so nothing here
* depends on fake timers or on how long a real tick takes: the scheduler owns no timers itself.
*
* The run belongs to the FOLDER, so these tests put two sessions on one folder and expect one fetch that
* both see, and they check the tick's two bounds separately: one shared bound for the fetches of the whole
* tick, and the folders bounded by a bound that cannot be the same semaphore. A folder is due when ANY session
* open on it is due, so the order the sessions were read cannot decide it, and that is checked in both orders.
* A run carries the trigger that caused it, and what is published says the run's, not the tick's.
*
* Port: N/A.
*/
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { dirname, resolve } from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import {
GitFetchScheduler,
effectiveGitFetch,
type GitFetchPorts,
type GitFetchSessionView,
} from '../src/git-fetch-scheduler.js';
import {
FETCH_CONCURRENCY,
type GitFetchOptions,
type GitFetchReport,
type GitFetchRun,
type GitFetchSettings,
type GitFetchStatus,
type GitFetchTrigger,
} from '../src/git-fetch.js';
import { DEFAULT_GIT_TIMEOUT_MS, MAX_REPOS } from '../src/git-workspace-status.js';
import type { PathProbeKind } from '../src/utils/bounded-path-probe.js';
import { readGitFetchSettings, readKeepWarmSettingsOrThrow } from '../src/hooks-config.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
const SETTINGS: GitFetchSettings = { enabled: true, intervalSeconds: 300 };
const VIEW = (id: string, extra: Partial<GitFetchSessionView> = {}): GitFetchSessionView => ({
id,
workingDir: `/w/${id}`,
...extra,
});
/** An empty report, in the shape a run produces. */
const report = (cwd: string, name: string): GitFetchReport => ({
state: 'ok',
repos: [{ name, path: '.', ok: true }],
reposTruncated: false,
fetchedAt: Date.now(),
});
/** A run: the report plus the trigger that caused it, which is what the published row has to name. */
const run = (cwd: string, name: string, trigger: GitFetchTrigger = 'interval'): GitFetchRun => ({
report: report(cwd, name),
trigger,
});
type Harness = {
scheduler: GitFetchScheduler;
ports: GitFetchPorts;
/** The folders the tick actually ran, in the order they were picked, with the trigger the tick passed. */
calls: Array<{ cwd: string; opts: GitFetchOptions; trigger: GitFetchTrigger }>;
published: Array<{ id: string; status: GitFetchStatus }>;
/** Every path the tick probed, in order: the probe has to happen before anything is fetched. */
probed: string[];
settings: GitFetchSettings;
dockerReads: number;
/**
* A session that closed: the server calls `remove()` when the session is gone, so this takes it out of the
* ports as well as out of the scheduler. Otherwise a test that removes a session still sees it on the next
* tick, and the folder it was on looks still open.
*/
remove(id: string): void;
};
/** A run the test decides when to let finish, so a slow repository can be held open across ticks. */
function deferred<T>(value: T) {
let resolve = (v: T) => {};
const promise = new Promise<T>((r) => (resolve = r));
return { promise, resolve };
}
/**
* A scheduler over an injected set of ports: nothing here touches a settings file, a repository or the
* server, so what is being pinned is the scheduler's own decisions.
*/
function harness(
sessions: GitFetchSessionView[],
opts: {
settings?: GitFetchSettings;
fetch?: (cwd: string, portOpts: GitFetchOptions, trigger: GitFetchTrigger) => Promise<GitFetchRun>;
/** Default: every path answers as a folder, so a test that does not care about the probe stays quiet. */
probe?: (path: string) => Promise<PathProbeKind>;
} = {}
): Harness {
const customFetch = opts.fetch;
const settings = opts.settings ?? { ...SETTINGS };
const gone = new Set<string>();
const h: Harness = {
scheduler: undefined as unknown as GitFetchScheduler,
ports: undefined as unknown as GitFetchPorts,
calls: [],
published: [],
probed: [],
settings,
dockerReads: 0,
remove: (id) => {
gone.add(id);
h.scheduler.remove(id);
},
};
h.ports = {
sessionIds: () => sessions.filter((s) => !gone.has(s.id)).map((s) => s.id),
getSession: (id) => (gone.has(id) ? undefined : sessions.find((s) => s.id === id)),
readSettings: async () => {
if (h.settings === null) throw new Error('settings.json is not valid JSON');
return h.settings;
},
dockerWorkspaces: async () => {
h.dockerReads++;
return [];
},
probePath: async (path) => {
h.probed.push(path);
return opts.probe ? await opts.probe(path) : 'directory';
},
fetch: async (cwd, portOpts, trigger) => {
h.calls.push({ cwd, opts: portOpts, trigger });
return customFetch ? customFetch(cwd, portOpts, trigger) : run(cwd, cwd.slice(3), trigger);
},
publish: (id, status) => h.published.push({ id, status }),
};
h.scheduler = new GitFetchScheduler(h.ports);
return h;
}
describe('a quiet tick', () => {
it('costs one settings read and nothing else, because nothing is on', async () => {
const h = harness([VIEW('a'), VIEW('b')], { settings: { enabled: false, intervalSeconds: 300 } });
expect(await h.scheduler.tick(1000)).toBe(0);
expect(h.calls).toEqual([]);
// The probe and the Docker case list are only worth reading once something is actually due.
expect(h.probed).toEqual([]);
expect(h.dockerReads).toBe(0);
});
it('reads settings FRESH every tick, so a live toggle takes effect on the next tick and not at boot', async () => {
const h = harness([VIEW('a')], { settings: { enabled: false, intervalSeconds: 30 } });
await h.scheduler.tick(0);
expect(h.calls.length).toBe(0);
h.settings = { enabled: true, intervalSeconds: 30 };
await h.scheduler.tick(1000);
expect(h.calls.length).toBe(1);
// Turning it off has to stop the fetching, not just stop it starting again later.
h.settings = { enabled: false, intervalSeconds: 30 };
await h.scheduler.tick(2000);
expect(h.calls.length).toBe(1);
});
it('skips this tick only when a settings read fails, rather than deciding from defaults', async () => {
const h = harness([VIEW('a')]);
h.settings = null as unknown as GitFetchSettings;
const logged = vi.fn();
h.ports.log = logged;
expect(await h.scheduler.tick(0)).toBe(0);
expect(h.calls).toEqual([]);
expect(logged).toHaveBeenCalled();
h.settings = SETTINGS;
// Nothing was recorded for the failed tick, so the folder is still due at once.
expect(await h.scheduler.tick(1000)).toBe(1);
});
/**
* The tick runs every 15 s whether or not the feature is on, so an unreadable settings.json would be logged
* every 15 s, and V8's parse message quotes the text around the fault: a hand edit with single quotes put
* the API key it held into the log on every tick.
*/
it('logs an unreadable settings.json once until it reads again, and never with the text of the file', async () => {
const secret = 'sk-leak42';
const broken = `{"voiceSettings":{"apiKey":'${secret}'}}`;
await mkdir(dirname(SETTINGS_PATH), { recursive: true });
await writeFile(SETTINGS_PATH, broken);
try {
// The real readers: what they reject with is what gets logged.
await expect(readGitFetchSettings()).rejects.toThrow(/not valid JSON/);
await expect(readGitFetchSettings()).rejects.not.toThrow(secret);
await expect(readKeepWarmSettingsOrThrow()).rejects.not.toThrow(secret);
const h = harness([VIEW('a')]);
h.ports.readSettings = readGitFetchSettings;
const logged = vi.fn();
h.ports.log = logged;
for (const now of [0, 15_000, 30_000]) expect(await h.scheduler.tick(now)).toBe(0);
expect(h.calls).toEqual([]);
expect(logged).toHaveBeenCalledTimes(1);
// A read that succeeds in between re-arms the warning for the next failure.
await writeFile(SETTINGS_PATH, JSON.stringify({ gitFetchEnabled: true }));
expect(await h.scheduler.tick(45_000)).toBe(1);
await writeFile(SETTINGS_PATH, broken);
expect(await h.scheduler.tick(400_000)).toBe(0);
expect(await h.scheduler.tick(415_000)).toBe(0);
expect(logged).toHaveBeenCalledTimes(2);
expect(JSON.stringify(logged.mock.calls)).not.toContain(secret);
} finally {
await rm(SETTINGS_PATH, { force: true });
}
});
});
describe('the bounded probe', () => {
it('probes the folder before fetching it, and skips anything that is not a directory', async () => {
const kinds = new Map<string, PathProbeKind>([
['/w/file', 'file'],
['/w/unknown', 'unknown'],
['/w/absent', 'absent'],
]);
const h = harness([VIEW('file'), VIEW('unknown'), VIEW('absent'), VIEW('ok')], {
probe: async (path) => kinds.get(path) ?? 'directory',
});
const logged = vi.fn();
h.ports.log = logged;
expect(await h.scheduler.tick(0)).toBe(1);
expect(h.calls.map((c) => c.cwd)).toEqual(['/w/ok']);
// The folders that answered badly were probed and left alone, never fetched.
expect(h.probed).toEqual(['/w/file', '/w/unknown', '/w/absent', '/w/ok']);
expect(logged).toHaveBeenCalledTimes(3);
// `unknown` is never read as `absent`: the log says what it answered rather than what it was assumed.
expect(logged.mock.calls.join(' ')).toContain('unknown');
});
});
describe('who runs', () => {
it('runs every session the app setting covers, once per interval', async () => {
const h = harness([VIEW('a'), VIEW('b')], { settings: { enabled: true, intervalSeconds: 60 } });
await h.scheduler.tick(0);
expect(h.calls.map((c) => c.cwd).sort()).toEqual(['/w/a', '/w/b']);
await h.scheduler.tick(30_000); // not due yet
expect(h.calls.length).toBe(2);
await h.scheduler.tick(60_000);
expect(h.calls.length).toBe(4);
});
it('runs a session that turned itself on even while the app-wide switch is off: that is what a per-session switch has to mean', async () => {
const h = harness([VIEW('plain'), VIEW('own', { gitFetchEnabled: true, gitFetchIntervalSeconds: 30 })], {
settings: { enabled: false, intervalSeconds: 300 },
});
await h.scheduler.tick(0);
expect(h.calls.map((c) => c.cwd)).toEqual(['/w/own']);
// Its own interval, not the app's: due at 30 s, not at 300.
await h.scheduler.tick(30_000);
expect(h.calls.length).toBe(2);
});
it('never fetches a remote or Docker session, exactly as the Git panel excludes them', async () => {
const h = harness([
VIEW('ssh', { remote: { host: 'x' } }),
VIEW('docker', { docker: { container: 'c' } }),
VIEW('local'),
]);
await h.scheduler.tick(0);
expect(h.calls.map((c) => c.cwd)).toEqual(['/w/local']);
});
it('re-checks the Docker rule every tick, so a session whose case became a Docker case stops fetching', async () => {
const sessions = [VIEW('a')];
const h = harness(sessions);
await h.scheduler.tick(0);
expect(h.calls.length).toBe(1);
sessions[0] = { ...sessions[0], docker: { container: 'c' } };
await h.scheduler.tick(400_000);
expect(h.calls.length).toBe(1);
});
/**
* The Docker case list is rewritten in place, so a read can land on a half-written file. Read as "no Docker
* workspaces", a tick would run git (and the repository's hooks) inside a folder a container can write to.
*/
it('fetches nothing when the Docker case list cannot be read, keeps the folders due, and logs it once', async () => {
const h = harness([VIEW('a'), VIEW('b')]);
let unreadable = true;
h.ports.dockerWorkspaces = async () => {
h.dockerReads++;
if (unreadable) throw new Error('docker-cases.json is not a valid JSON array');
return [];
};
const logged = vi.fn();
h.ports.log = logged;
expect(await h.scheduler.tick(0)).toBe(0);
expect(await h.scheduler.tick(15_000)).toBe(0);
expect(h.calls).toEqual([]);
expect(h.dockerReads).toBe(2); // the picks were undone, so the next tick picked them again
expect(logged).toHaveBeenCalledTimes(1);
expect(h.scheduler.tracked()).toBe(0); // nothing left in flight or stamped
// The list reads again: both folders run at once, not an interval after the failed ticks.
unreadable = false;
expect(await h.scheduler.tick(30_000)).toBe(2);
expect(h.calls.map((c) => c.cwd).sort()).toEqual(['/w/a', '/w/b']);
// And their interval is timed from that run.
expect(await h.scheduler.tick(30_000 + 299_000)).toBe(0);
expect(await h.scheduler.tick(30_000 + 300_000)).toBe(2);
});
it('is wired to the strict Docker reader the Fetch button uses', () => {
const server = readFileSync(resolve(import.meta.dirname, '../src/web/server.ts'), 'utf8');
const block = server.slice(server.indexOf('new GitFetchScheduler('));
// defaultDockerWorkspaces is the strict one: test/routes/git-status-routes.test.ts pins that it rejects.
expect(block.slice(0, block.indexOf('});'))).toContain('dockerWorkspaces: defaultDockerWorkspaces,');
});
it('uses the module defaults for the limit and the timeout, because those settings are per device and never reach the server', async () => {
const h = harness([VIEW('a')]);
await h.scheduler.tick(0);
expect(h.calls[0].opts).toMatchObject({ maxRepos: MAX_REPOS, timeoutMs: DEFAULT_GIT_TIMEOUT_MS });
});
});
describe('one folder, one run', () => {
it('fetches a shared folder once and publishes the result to every session open on it', async () => {
const h = harness([VIEW('a'), { ...VIEW('b'), workingDir: '/w/a' }]);
expect(await h.scheduler.tick(0)).toBe(1); // one folder, not two runs
expect(h.calls.length).toBe(1);
expect(h.published.map((p) => p.id).sort()).toEqual(['a', 'b']);
// Both see the same run, so the panel's row says the same thing in both tabs.
expect(new Set(h.published.map((p) => p.status.failed)).size).toBe(1);
});
it('is due when ANY session open on the folder is due, in either read order, because the clock belongs to the folder and not to whichever session was read first', async () => {
const short = { ...VIEW('short'), workingDir: '/w/shared', gitFetchIntervalSeconds: 30 };
const long = { ...VIEW('long'), workingDir: '/w/shared', gitFetchIntervalSeconds: 300 };
for (const order of [
[short, long],
[long, short],
]) {
const h = harness(order);
// Nothing has run yet, so both are due and both are published from the one run. The folder is probed
// once, not once per session open on it.
expect(await h.scheduler.tick(0)).toBe(1);
expect(h.probed).toEqual(['/w/shared']);
expect(h.published.map((p) => p.id).sort()).toEqual(['long', 'short']);
// Before the short interval elapses neither is due, and the order they were read in cannot change that.
h.published.length = 0;
expect(await h.scheduler.tick(15_000)).toBe(0);
expect(h.published).toEqual([]);
// The short session is past its own interval, so the folder is fetched (once, for both). The run that
// happens covers the long session's interval too, and both still get it because both are open on the
// folder: publishing only the session that made the folder due would leave the other tab on a stale row.
h.published.length = 0;
expect(await h.scheduler.tick(30_000)).toBe(1);
expect(h.calls.length).toBe(2);
expect(h.published.map((p) => p.id).sort()).toEqual(['long', 'short']);
// The long one's own interval comes round, so the folder is due again.
h.published.length = 0;
expect(await h.scheduler.tick(300_000)).toBe(1);
expect(h.published.map((p) => p.id).sort()).toEqual(['long', 'short']);
}
});
it('does not pick a folder again while its run is going, and does not run a second tick over the same selection', async () => {
const ids = Array.from({ length: FETCH_CONCURRENCY + 2 }, (_, i) => `s${i}`);
const gate = deferred<GitFetchRun>(run('/w/first', 'first'));
const h = harness(
ids.map((id) => VIEW(id)),
{
settings: { enabled: true, intervalSeconds: 30 },
fetch: () => gate.promise,
}
);
const first = h.scheduler.tick(0);
await new Promise((resolve) => setImmediate(resolve)); // the first runs are going, the rest are queued
const picked = h.calls.length;
expect(picked).toBe(FETCH_CONCURRENCY);
// The server fires the next tick without waiting. The schedule was stamped at selection, so the folders
// already picked are not picked again, and this tick is skipped while the previous one is still going.
expect(await h.scheduler.tick(15_000)).toBe(0);
expect(h.calls.length).toBe(picked);
gate.resolve(run('/w/first', 'first'));
await first;
// Every folder ran exactly once, and no folder ran twice.
expect(h.calls.length).toBe(ids.length);
expect(new Set(h.calls.map((c) => c.cwd)).size).toBe(ids.length);
});
it('shares one bound across the whole tick instead of giving each folder its own, and bounds the folders with a bound of its own', async () => {
const ids = Array.from({ length: FETCH_CONCURRENCY + 1 }, (_, i) => `s${i}`);
let fetchesLive = 0;
let fetchesPeak = 0;
let foldersLive = 0;
let foldersPeak = 0;
const hold = () => new Promise((resolve) => setImmediate(resolve));
const h = harness(
ids.map((id) => VIEW(id)),
{
// Every folder is handed the SAME limiter for its work, so that work cannot get its own bound: a
// per-call bound would let 5 folders × 6 items run at once. The folders themselves are bounded by a
// DIFFERENT bound, because one semaphore cannot serve both levels: a folder holding a slot and then
// waiting for one for its own fetch is waiting on itself, and the folders that fill the bound are
// exactly the ones blocking it. If the tick used one semaphore for both, this test would hang and fail
// as a timeout rather than as an assertion.
fetch: async (cwd, opts) => {
foldersLive++;
foldersPeak = Math.max(foldersPeak, foldersLive);
await opts.limiter?.([0, 1, 2, 3, 4, 5], async () => {
fetchesLive++;
fetchesPeak = Math.max(fetchesPeak, fetchesLive);
await hold();
fetchesLive--;
});
foldersLive--;
return run(cwd, cwd.slice(3));
},
}
);
await h.scheduler.tick(0);
expect(fetchesPeak).toBe(FETCH_CONCURRENCY); // the tick's ceiling, not each folder's own
expect(foldersPeak).toBe(FETCH_CONCURRENCY);
expect(h.calls.length).toBe(ids.length); // every folder ran, none was left waiting forever
});
it('times the interval from the START of a run, so a slow repository does not push the next one out', async () => {
const slow = deferred<GitFetchRun>(run('/w/a', 'a'));
const h = harness([VIEW('a')], { settings: { enabled: true, intervalSeconds: 30 }, fetch: () => slow.promise });
const first = h.scheduler.tick(0);
await new Promise((resolve) => setImmediate(resolve));
expect(h.calls.length).toBe(1);
// The run takes 200 s, longer than its own interval. Once it lands, the folder is already past due: the
// next tick runs it again instead of waiting another 30 s from when the slow one finished.
slow.resolve(run('/w/a', 'a'));
await first;
expect(await h.scheduler.tick(200_000)).toBe(1);
expect(h.calls.length).toBe(2);
});
});
describe('what a run reports', () => {
it("publishes the run as the interval's own, so the panel can say the number was not asked for", async () => {
const h = harness([VIEW('a')]);
await h.scheduler.tick(0);
expect(h.published).toEqual([
{
id: 'a',
status: { ranAt: expect.any(Number), trigger: 'interval', state: 'ok', repos: 1, failed: 0 },
},
]);
});
it('publishes the trigger of the run that actually happened rather than the trigger the tick asked for, because a tick that joined a click did not fetch anything', async () => {
const h = harness([VIEW('a')], {
// The port is `fetchWorkspaceOnce`, so it can hand back a run a click had already started for this
// folder. The tick asked for its own; the row has to name the run.
fetch: (cwd, _opts, trigger) => {
expect(trigger).toBe('interval');
return Promise.resolve({ report: report(cwd, 'a'), trigger: 'click' });
},
});
await h.scheduler.tick(0);
expect(h.published[0].status.trigger).toBe('click');
});
it("reports a failure instead of retrying harder, and keeps the folder's next run at its own interval", async () => {
const h = harness([VIEW('a')], {
settings: { enabled: true, intervalSeconds: 30 },
fetch: () => Promise.reject(new Error('spawn git ENOENT')),
});
const logged = vi.fn();
h.ports.log = logged;
await h.scheduler.tick(0);
expect(logged).toHaveBeenCalled();
expect(h.published).toEqual([]);
// Still remembered, so the next run is on schedule rather than immediate.
expect(h.scheduler.tracked()).toBe(1);
});
});
describe('the effective choice', () => {
it("a session's own override wins over the app setting", () => {
const settings = { enabled: false, intervalSeconds: 600 };
expect(effectiveGitFetch(VIEW('a', { gitFetchEnabled: true, gitFetchIntervalSeconds: 45 }), settings)).toEqual({
enabled: true,
intervalSeconds: 45,
});
// Half a choice still follows the app for the other half.
expect(effectiveGitFetch(VIEW('b', { gitFetchEnabled: true }), settings)).toEqual({
enabled: true,
intervalSeconds: 600,
});
});
it('re-clamps a persisted interval that can predate the schema bounds', () => {
expect(effectiveGitFetch(VIEW('a', { gitFetchIntervalSeconds: 5 }), SETTINGS)).toEqual({
enabled: true,
intervalSeconds: 30,
});
expect(effectiveGitFetch(VIEW('b', { gitFetchIntervalSeconds: 9000 }), SETTINGS)).toEqual({
enabled: true,
intervalSeconds: 3600,
});
});
});
describe('a session that goes away', () => {
it('takes its own choice with it, but keeps the folder while another session is still open on it', async () => {
const sessions = [VIEW('a'), { ...VIEW('b'), workingDir: '/w/a' }];
const h = harness(sessions);
await h.scheduler.tick(0);
expect(h.scheduler.tracked()).toBe(1);
h.remove('a');
// The run belongs to the clone, so 'b' keeps the folder's schedule and its next run is still on time:
// the choice that got the folder going was 'a's, but the folder is not 'a's.
expect(h.scheduler.tracked()).toBe(1);
await h.scheduler.tick(1000); // not due yet, and 'b' is still open on the folder
expect(h.calls.length).toBe(1);
// The last session open on the folder goes, so the folder is forgotten: a session coming back on it is due
// at once rather than on the schedule the closed one left behind.
h.remove('b');
expect(h.scheduler.tracked()).toBe(0);
sessions.push({ ...VIEW('c'), workingDir: '/w/a' });
await h.scheduler.tick(2000);
expect(h.calls.length).toBe(2); // remembered again, from now
});
it('stops everything after stop(), including a folder that was already due', async () => {
const h = harness([VIEW('a')]);
h.scheduler.stop();
expect(await h.scheduler.tick(0)).toBe(0);
expect(h.calls).toEqual([]);
expect(h.scheduler.tracked()).toBe(0);
});
});
+155
View File
@@ -0,0 +1,155 @@
/**
* @fileoverview The Session Options → Git rows (session-ui.js `updateGitFetchStatus`) as the page drives them: the
* real index.html in JSDOM with the real app.js and session-ui.js loaded into it, and every session update
* delivered through app.js `_onSessionUpdated`, the path a `session:updated` SSE event takes.
*
* 1. A working session sends `session:updated` on each working/idle change, and that path refreshes the open Git
* tab every time, so the control the user is in is left alone: rewriting it put a half-typed "12" back to "300"
* before `onchange` could save it. Enter saves with the field still focused, so the save writes the clamped
* number back itself.
* 2. The route takes one field at a time, so a session can hold half an override. The half it did not choose
* shows the app's value, which is what the scheduler runs it with, never a default.
*
* Port: N/A (no server; the one save is answered by a stub).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const SID = 'session-git-rows';
/** The app setting every case here runs against: on, every 900 s. */
const APP = { gitFetchEnabled: true, gitFetchIntervalSeconds: 900 };
type Update = Record<string, unknown> & { id: string };
type TestApp = Record<string, unknown> & {
_onSessionUpdated(data: Update): void;
autoSaveGitFetch(): Promise<void>;
_apiJson: ReturnType<typeof vi.fn>;
};
let dom: JSDOM;
let CodemanApp: { prototype: object };
beforeAll(async () => {
dom = new JSDOM(readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'), {
runScripts: 'outside-only',
url: 'http://localhost/',
});
// app.js starts the app on DOMContentLoaded. Loading it once the document is complete means that never fires
// here, so the real constructor (SSE, WebSocket, timers) never runs in a window with no server behind it.
await new Promise<void>((done) =>
dom.window.document.readyState === 'complete' ? done() : dom.window.addEventListener('load', () => done())
);
const context = dom.getInternalVMContext();
// app.js's last line publishes MobileDetection (mobile-handlers.js), which nothing here otherwise needs.
vm.runInContext('var MobileDetection = { isTouchDevice: () => false };', context);
for (const file of ['constants.js', 'app.js', 'session-ui.js']) {
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
}
CodemanApp = vm.runInContext('CodemanApp', context);
});
afterAll(() => dom.window.close());
beforeEach(() => {
(dom.window.document.activeElement as HTMLElement | null)?.blur?.();
});
const el = <T extends HTMLElement = HTMLInputElement>(id: string) => dom.window.document.getElementById(id) as T;
/** An app with the Git tab open on SID and everything the update path draws besides it stubbed. */
function openGitTab(): TestApp {
const app = Object.create(CodemanApp.prototype) as TestApp;
Object.assign(app, {
sessions: new Map(),
_closingSessions: new Set(),
activeSessionId: null,
editingSessionId: SID,
renderSessionTabs: vi.fn(),
updateCost: vi.fn(),
updateSubagentParentNames: vi.fn(),
showToast: vi.fn(),
loadAppSettingsFromStorage: () => ({ ...APP }),
});
return app;
}
describe('Session Options → Git, under session updates', () => {
it('leaves the interval the user is typing alone when a session update arrives, and still refreshes the run line', () => {
const app = openGitTab();
app._onSessionUpdated({ id: SID, status: 'idle', gitFetchEnabled: true, gitFetchIntervalSeconds: 300 });
const num = el('modalGitFetchInterval');
expect(num.disabled).toBe(false);
expect(num.value).toBe('300');
num.focus();
expect(dom.window.document.activeElement).toBe(num);
num.value = '12';
// The working/idle flip that used to put the field back to 300, carrying a scheduled run's result this time.
app._onSessionUpdated({
id: SID,
status: 'busy',
gitFetchEnabled: true,
gitFetchIntervalSeconds: 300,
gitFetch: { ranAt: Date.now(), trigger: 'interval', state: 'ok', repos: 2, failed: 0 },
});
expect(num.value).toBe('12');
expect(el('gitFetchStatus').textContent).toBe('Last run just now on the interval · 2 repositories');
// Only the control the user is in is spared: once they leave it, an update shows the stored value again.
num.blur();
app._onSessionUpdated({ id: SID, status: 'idle', gitFetchEnabled: true, gitFetchIntervalSeconds: 300 });
expect(num.value).toBe('300');
});
it('writes the clamped interval back when Enter saves with the field still focused', async () => {
const app = openGitTab();
app._apiJson = vi.fn(async (_url: string, opts: { body: unknown }) => ({ gitFetchSettings: opts.body }));
app._onSessionUpdated({ id: SID, status: 'idle', gitFetchEnabled: true, gitFetchIntervalSeconds: 300 });
const num = el('modalGitFetchInterval');
num.focus();
num.value = '12';
await app.autoSaveGitFetch();
expect(app._apiJson).toHaveBeenCalledWith(`/api/sessions/${SID}/git-fetch-settings`, {
method: 'POST',
body: { enabled: true, intervalSeconds: 30 },
});
expect(dom.window.document.activeElement).toBe(num);
expect(num.value).toBe('30');
});
});
describe('Session Options → Git, with half an override', () => {
it("shows the app's switch beside an interval-only override, because the scheduler runs it with that switch", () => {
const app = openGitTab();
app._onSessionUpdated({ id: SID, status: 'idle', gitFetchIntervalSeconds: 120 });
expect(el('modalGitFetchFollow').checked).toBe(false);
expect(el('modalGitFetchEnabled').checked).toBe(true);
expect(el('modalGitFetchInterval').value).toBe('120');
// Not following, so both rows are live, and touching either saves what they show.
expect(el('modalGitFetchEnabled').disabled).toBe(false);
expect(el('modalGitFetchInterval').disabled).toBe(false);
});
it("shows the app's interval beside a switch-only override, because the scheduler runs it at that interval", () => {
const app = openGitTab();
app._onSessionUpdated({ id: SID, status: 'idle', gitFetchEnabled: true });
expect(el('modalGitFetchFollow').checked).toBe(false);
expect(el('modalGitFetchEnabled').checked).toBe(true);
expect(el('modalGitFetchInterval').value).toBe('900');
});
it('follows the app on both rows, inert, when the session holds no choice of its own', () => {
const app = openGitTab();
app._onSessionUpdated({ id: SID, status: 'idle' });
expect(el('modalGitFetchFollow').checked).toBe(true);
expect(el('modalGitFetchEnabled').checked).toBe(true);
expect(el('modalGitFetchInterval').value).toBe('900');
expect(el('modalGitFetchEnabled').disabled).toBe(true);
expect(el('modalGitFetchInterval').disabled).toBe(true);
});
});
+770
View File
@@ -0,0 +1,770 @@
/**
* @fileoverview Tests for the Git panel's Fetch button (src/git-fetch.ts): the only git command the Git panel
* runs that writes, and the only one that can run without being asked, which is why it is a click, or an
* interval the user turned on, and why the writing and the hooks it can run are pinned here. Cloning and
* worktree management write too, but they write when the user asked.
*
* Four halves, mirroring the module:
*
* 1. The PURE half: the fixed argv, the `-c` overrides the runner adds ahead of it, the ssh environment the
* runner refuses to override (`fetchEnvFor`), the failure classification, and the two reads the interval
* scheduler depends on: what the settings say (`gitFetchSettingsFrom`) and what a run did
* (`gitFetchStatusFrom`). No spawning.
* 2. The IO half: a REAL git fetching a REAL local bare repo, so that "remote-tracking refs only" is
* proven against git's actual behavior: HEAD, the index and the working tree must be exactly what they
* were while the remote-tracking ref moves. The rest of the half uses an injected runner over real
* repositories on disk, because what is being pinned is WHICH repositories git is allowed to touch.
* 3. The process group: a timed-out fetch has to take its `git-remote-http` helper with it, against a
* server that accepts and never answers, so the assertion is about what is still RUNNING after the answer
* went out. A test that only checked the returned error would pass with the old `execFile` runner, which
* signals the parent only.
* 4. The join (`fetchWorkspaceOnce`): one run per folder at a time, because two fetches in one clone
* collide on git's ref lock, the bound is ONE limiter shared with the scheduler's tick rather than a bound
* each run gets for itself, and the trigger a join reports is the joined run's, not the joiner's.
*
* Port: N/A (no Codeman server). The process-group test listens on its own loopback socket, on a port the OS
* picks, because the point of that test is a remote that never answers.
*/
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { createServer, type AddressInfo } from 'node:http';
import type { Socket } from 'node:net';
import { tmpdir } from 'node:os';
import { basename, join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { classifyGitFailure, isGitAvailable } from '../src/git-clone.js';
import {
FETCH_CONCURRENCY,
FETCH_CONFIG_ARGS,
FETCH_KILL_ESCALATION_MS,
buildFetchArgs,
clearGitFetchLane,
describeFetchFailure,
fetchEnvFor,
fetchGitWorkspaceOverview,
fetchWorkspaceOnce,
gitFetchSettingsFrom,
gitFetchStatusFrom,
runFetch,
type GitFetchReport,
} from '../src/git-fetch.js';
import { createLimiter, runGit, type GitRunner } from '../src/git-workspace-status.js';
const ENV = {
...process.env,
// As the production runner: git's messages are matched in English, and nothing reads the host's config.
LC_ALL: 'C',
GIT_AUTHOR_NAME: 'T',
GIT_AUTHOR_EMAIL: 't@example.com',
GIT_COMMITTER_NAME: 'T',
GIT_COMMITTER_EMAIL: 't@example.com',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
};
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, env: ENV, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
// ─── The pure half ───────────────────────────────────────────────────────────
describe('buildFetchArgs', () => {
it('pins the argv to a fetch and nothing else', () => {
expect(buildFetchArgs()).toEqual(['fetch', '--quiet']);
// Not just the first word: the whole argv is fixed, so no verb that changes the repository can slip in.
for (const verb of ['commit', 'pull', 'merge', 'checkout', 'reset', 'clean', 'prune', 'gc', 'amend']) {
expect(buildFetchArgs().join(' ')).not.toContain(verb);
}
});
it('names no repository, so the repository can only be the process cwd', () => {
// An operand-shaped argument would let a path or a URL choose the repository; the fixed argv cannot.
for (const arg of buildFetchArgs()) {
expect(arg.startsWith('-')).toBe(arg === '--quiet');
}
});
});
describe('describeFetchFailure', () => {
it("calls a repository with no remote a fact, not a fault, in each of git's wordings", () => {
for (const stderr of [
'fatal: no default remote repository -- use --remote or specify a repository',
'No remote configured',
'fatal: nothing to fetch from remote repository',
]) {
const failure = describeFetchFailure({ stderr });
expect(failure.code).toBe('NO_REMOTE');
expect(failure.message).toMatch(/no remote/i);
expect(failure.message).not.toMatch(/fail|error|unable/i);
}
});
it('writes its own timeout message, because the one to reuse was written for clones', () => {
const failure = describeFetchFailure({ code: 'ETIMEDOUT', stderr: 'fatal: repository not found' });
expect(failure.code).toBe('TIMEOUT');
expect(failure.message).toMatch(/Git status: git timeout/);
expect(failure.message).toMatch(/fetch from a shell/i);
expect(failure.message).not.toBe(classifyGitFailure('fatal: repository not found', true).message);
expect(describeFetchFailure({ killed: true, stderr: '' }).code).toBe('TIMEOUT');
});
it('reuses the network reasons it already knows', () => {
const prompt = "fatal: could not read Username for 'https://github.com': terminal prompts disabled";
expect(describeFetchFailure({ stderr: prompt }).code).toBe('AUTH_REQUIRED');
expect(describeFetchFailure({ stderr: 'git@github.com: Permission denied (publickey).' }).code).toBe(
'AUTH_REQUIRED'
);
expect(describeFetchFailure({ message: 'Error: spawn git ENOENT' }).code).toBe('GIT_MISSING');
expect(describeFetchFailure({ stderr: 'fatal: unable to access: Could not resolve host: nope.invalid' }).code).toBe(
'HOST_UNREACHABLE'
);
});
it('hands the client a redacted, control-stripped tail, never what git printed', () => {
const failure = describeFetchFailure({
stderr: "fatal: unable to access 'https://bob:ghp_secret@github.com/x.git/'\u001b[31m",
});
expect(failure.stderr).toBe("fatal: unable to access 'https://***:***@github.com/x.git/'[31m");
});
});
// ─── The two reads the interval feature depends on ───────────────────────────
describe('gitFetchSettingsFrom', () => {
it('is off until the user turns it on, and 300 s when nothing was chosen', () => {
// Absent means off: a quiet install must not start running git on a timer nobody asked for.
for (const settings of [undefined, {}, { gitFetchEnabled: false }, { gitFetchEnabled: 'true' }]) {
expect(gitFetchSettingsFrom(settings).enabled).toBe(false);
}
expect(gitFetchSettingsFrom({ gitFetchEnabled: true }).enabled).toBe(true);
expect(gitFetchSettingsFrom({}).intervalSeconds).toBe(300);
});
it('clamps a number outside the schema instead of trusting it, because a persisted value can predate the bounds', () => {
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 1 }).intervalSeconds).toBe(30);
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 99999 }).intervalSeconds).toBe(3600);
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 120.6 }).intervalSeconds).toBe(121);
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: -300 }).intervalSeconds).toBe(30);
// A hand-edited 0 is still a number it can act on, and acting on it means the smallest interval that runs.
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: 0 }).intervalSeconds).toBe(30);
});
it('falls back to the default on a value it cannot act on, which is what a hand-edited settings.json can hold', () => {
for (const raw of ['300', undefined, null, NaN, Infinity, true]) {
expect(gitFetchSettingsFrom({ gitFetchIntervalSeconds: raw }).intervalSeconds, String(raw)).toBe(300);
}
});
});
describe('gitFetchStatusFrom', () => {
it('counts what a run covered and what it could not do, and names the first failure', () => {
const report: GitFetchReport = {
state: 'ok',
repos: [
{ name: 'Codeman', path: '.', ok: true },
{ name: 'api', path: 'api', ok: false, failure: { code: 'NO_REMOTE', message: 'no remote', stderr: '' } },
],
reposTruncated: false,
fetchedAt: 1700,
};
expect(gitFetchStatusFrom(report, 'interval')).toEqual({
ranAt: 1700,
trigger: 'interval',
state: 'ok',
repos: 2,
failed: 1,
firstFailure: { name: 'api', code: 'NO_REMOTE' },
});
});
it('keeps the trigger, because a number the user did not ask for should read differently from one they did', () => {
const empty: GitFetchReport = { state: 'ok', repos: [], reposTruncated: false, fetchedAt: 1000 };
expect(gitFetchStatusFrom(empty, 'click').trigger).toBe('click');
expect(gitFetchStatusFrom(empty, 'interval').trigger).toBe('interval');
});
it('records a state that stopped the run, with nothing to count and no failure to name', () => {
const status = gitFetchStatusFrom(
{ state: 'unsupported', reason: 'docker', repos: [], reposTruncated: false, fetchedAt: 5 },
'interval'
);
expect(status.state).toBe('unsupported');
expect(status.repos).toBe(0);
expect(status.failed).toBe(0);
expect(status.firstFailure).toBeUndefined();
});
});
describe('FETCH_CONFIG_ARGS', () => {
it('turns off the hooks and the maintenance a fetch can otherwise start, pinned without spawning git', () => {
expect(FETCH_CONFIG_ARGS).toEqual([
'-c',
'core.fsmonitor=false',
'-c',
'maintenance.auto=false',
'-c',
'gc.auto=0',
]);
// Read as pairs: a `-c` and the setting it overrides, and every override is one of these three.
for (let i = 0; i < FETCH_CONFIG_ARGS.length; i += 2) {
expect(FETCH_CONFIG_ARGS[i]).toBe('-c');
expect(FETCH_CONFIG_ARGS[i + 1]).toMatch(/^(core\.fsmonitor|maintenance\.auto|gc\.auto)=/);
}
expect(FETCH_CONFIG_ARGS.filter((a) => a !== '-c').sort()).toEqual([
'core.fsmonitor=false',
'gc.auto=0',
'maintenance.auto=false',
]);
});
it('is kept apart from the pinned argv so both can be read without spawning, and the runner composes them in that order', () => {
// The argv the recorded calls pin stays the verb alone: the overrides come from the runner, not from here.
for (const arg of buildFetchArgs()) expect(arg).not.toBe('-c');
// Overrides before the verb, which is where git will take them.
expect([...FETCH_CONFIG_ARGS, ...buildFetchArgs()]).toEqual([...FETCH_CONFIG_ARGS, 'fetch', '--quiet']);
});
});
describe('fetchEnvFor', () => {
const BASE = { HOME: '/home/t', PATH: '/usr/bin' };
it('writes the BatchMode default when nothing else already answered', () => {
const env = fetchEnvFor(BASE, null);
expect(env.GIT_SSH_COMMAND).toContain('BatchMode=yes');
expect(env.GIT_TERMINAL_PROMPT).toBe('0');
});
it('does not override a repository that already chose its own ssh, because that choice is usually the one that works', () => {
const env = fetchEnvFor(BASE, 'ssh -i /home/t/work_key -o ProxyJump=gw');
// The whole point: nothing is written, so git takes the repository's value from its own config.
expect(env.GIT_SSH_COMMAND).toBeUndefined();
// Honouring the repository's ssh is not the same as letting it prompt, so the rest still applies.
expect(env.GIT_TERMINAL_PROMPT).toBe('0');
expect(env.SSH_ASKPASS_REQUIRE).toBe('never');
});
it("the user's own GIT_SSH_COMMAND still beats the repository's, because that is git's rule and not this module's to second-guess", () => {
const env = fetchEnvFor({ ...BASE, GIT_SSH_COMMAND: 'ssh -i /home/t/personal' }, 'ssh -i /repo/key');
expect(env.GIT_SSH_COMMAND).toBe('ssh -i /home/t/personal');
});
it("leaves a GIT_SSH in the server's environment to git, which reads it last and would never see it under a written default", () => {
const env = fetchEnvFor({ ...BASE, GIT_SSH: '/home/t/bin/work-ssh' }, null);
expect(env.GIT_SSH_COMMAND).toBeUndefined();
expect(env.GIT_SSH).toBe('/home/t/bin/work-ssh');
expect(env.GIT_TERMINAL_PROMPT).toBe('0');
});
});
// ─── Real git, real local repository ─────────────────────────────────────────
const gitPresent = isGitAvailable();
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'git-fetch-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
function initRepo(path: string, remote?: string) {
mkdirSync(path, { recursive: true });
git(path, 'init', '-q', '-b', 'main');
// So a `file://` remote stays allowed for the production runner too, not only for this test's own git calls.
git(path, 'config', 'protocol.file.allow', 'always');
writeFileSync(join(path, 'f.txt'), '1\n');
git(path, 'add', '-A');
git(path, 'commit', '-q', '-m', 'init');
if (remote) git(path, 'remote', 'add', 'origin', remote);
return path;
}
/** Records what each repository was asked to do, and lets real git answer the rest. */
function recordingRunner() {
const calls: Array<[string, string[]]> = [];
const runner: GitRunner = async (cwd, args, opts) => {
calls.push([cwd, args]);
return runGit(cwd, args, opts);
};
return { calls, runner };
}
describe.skipIf(!gitPresent)('fetchGitWorkspaceOverview (real git)', () => {
let work: string;
const snapshot = (path: string) => ({
head: git(path, 'rev-parse', 'HEAD').trim(),
tree: readdirSync(path, { withFileTypes: true })
.filter((e) => e.name !== '.git' && e.isFile())
.sort((a, b) => a.name.localeCompare(b.name))
.map((e) => `${e.name}=${readFileSync(join(path, e.name), 'utf8')}`),
// The global option has to come before the verb, exactly as the read-only status module runs it.
index: git(path, '--no-optional-locks', 'status', '--porcelain=v2').trim(),
});
beforeEach(() => {
const origin = join(dir, 'origin.git');
mkdirSync(origin);
git(origin, 'init', '--bare', '--quiet', '-b', 'main');
work = initRepo(join(dir, 'work'), origin);
git(work, 'push', '-q', 'origin', 'main');
// A commit made elsewhere and pushed: work's origin/main cannot hear about it until a fetch runs.
// This has to be a clone of origin, not a fresh init: a commit on its own root commit is no
// descendant of what origin already holds, so pushing it is rejected as non-fast-forward.
const other = join(dir, 'other');
git(dir, 'clone', '-q', origin, 'other');
git(other, 'config', 'protocol.file.allow', 'always');
writeFileSync(join(other, 'g.txt'), '2\n');
git(other, 'add', '-A');
git(other, 'commit', '-q', '-m', 'second');
git(other, 'push', '-q', 'origin', 'main');
// Left dirty on purpose, so the snapshot has something proving it was not touched.
writeFileSync(join(work, 'untracked.txt'), 'u\n');
writeFileSync(join(work, 'f.txt'), 'modified\n');
});
it('fetches through the production runner, moving remote-tracking refs and writing nothing else', async () => {
// A push does not update the remote-tracking ref, so the fetch is what has to move it: 0 before, 1 after.
expect(git(work, 'rev-list', '--count', 'main..origin/main').trim()).toBe('0');
const before = snapshot(work);
const report = await fetchGitWorkspaceOverview(work);
expect(report.state).toBe('ok');
expect(report.repos).toHaveLength(1);
expect(report.repos[0]).toMatchObject({ name: 'work', path: '.', ok: true });
expect(report.fetchedAt).toBeGreaterThan(0);
expect(snapshot(work)).toEqual(before); // HEAD, the working tree and the index are exactly as they were
expect(git(work, 'rev-list', '--count', 'main..origin/main').trim()).toBe('1');
});
it('fetches exactly the repositories the panel would show, in the panel order', async () => {
const folder = join(dir, 'folder');
for (const name of ['api', 'web']) initRepo(join(folder, name));
mkdirSync(join(folder, 'node_modules', 'pkg'), { recursive: true }); // never a project the panel lists
const { calls, runner } = recordingRunner();
const report = await fetchGitWorkspaceOverview(folder, { git: runner });
expect(report.state).toBe('ok');
expect(report.repos.map((r) => [r.name, r.path])).toEqual([
['api', 'api'],
['web', 'web'],
]);
// Only the fetches, not the resolver's own `rev-parse`: the repositories touched are the point.
// A bounded run does not promise which one starts first, so compare the set.
expect(calls.filter(([, args]) => args[0] === 'fetch').sort()).toEqual([
[join(folder, 'api'), ['fetch', '--quiet']],
[join(folder, 'web'), ['fetch', '--quiet']],
]);
});
it('never reaches an unrelated repository above the workspace', async () => {
const home = join(dir, 'home');
initRepo(home);
const caseDir = join(home, 'case');
mkdirSync(caseDir);
const { calls, runner } = recordingRunner();
const report = await fetchGitWorkspaceOverview(caseDir, { git: runner, home });
expect(report.state).toBe('not-a-repo');
expect(calls.filter(([, args]) => args[0] === 'fetch')).toEqual([]);
});
it('refuses a repository at or inside a Docker case workspace before any git runs', async () => {
initRepo(join(dir, 'repo'));
const { calls, runner } = recordingRunner();
const report = await fetchGitWorkspaceOverview(dir, { git: runner, dockerWorkspaces: [dir] });
expect(report).toMatchObject({ state: 'unsupported', reason: 'docker' });
expect(calls).toEqual([]);
});
it('keeps a repository that could not be fetched, with its reason', async () => {
for (const name of ['ok', 'private']) initRepo(join(dir, 'folder', name));
const runner: GitRunner = async (cwd) => {
if (basename(cwd) === 'private') {
throw Object.assign(new Error('Command failed'), {
stderr: "fatal: could not read Username for 'https://github.com': terminal prompts disabled",
});
}
return '';
};
const report = await fetchGitWorkspaceOverview(join(dir, 'folder'), { git: runner });
expect(report.state).toBe('ok');
const priv = report.repos.find((r) => r.name === 'private');
expect(priv?.ok).toBe(false);
expect(priv?.failure?.code).toBe('AUTH_REQUIRED');
expect(priv?.failure?.message).toMatch(/authentication/i);
expect(report.repos.find((r) => r.name === 'ok')?.ok).toBe(true);
});
it("runs at most FETCH_CONCURRENCY fetches at a time, with the setting's timeout", async () => {
const folder = join(dir, 'folder');
for (let i = 0; i < FETCH_CONCURRENCY + 2; i++) initRepo(join(folder, `r${i}`));
let inFlight = 0;
let max = 0;
const seenTimeouts = new Set<number | undefined>();
const runner: GitRunner = async (cwd, args, opts) => {
seenTimeouts.add(opts?.timeoutMs);
inFlight++;
max = Math.max(max, inFlight);
await new Promise((r) => setTimeout(r, 10));
inFlight--;
return '';
};
const report = await fetchGitWorkspaceOverview(folder, { git: runner, timeoutMs: 7000 });
expect(report.repos).toHaveLength(FETCH_CONCURRENCY + 2);
expect(report.repos.every((r) => r.ok)).toBe(true);
expect(max).toBe(FETCH_CONCURRENCY);
expect(seenTimeouts).toEqual(new Set([7000]));
});
it('answers not-a-repo for a folder that is not a repository, running no fetch', async () => {
const plain = join(dir, 'plain');
mkdirSync(plain);
const { calls, runner } = recordingRunner();
expect(await fetchGitWorkspaceOverview(plain, { git: runner })).toMatchObject({ state: 'not-a-repo', repos: [] });
expect(calls.filter(([, args]) => args[0] === 'fetch')).toEqual([]);
});
});
// ─── The whole tree, not just the git parent ─────────────────────────────────
/** One process as /proc reports it. `null` means it is gone, which is what the last assertion is about. */
interface Proc {
pid: number;
comm: string;
ppid: number;
/** The PROCESS GROUP. `detached: true` puts the fetch in its own, so the negative pid reaches the children. */
pgrp: number;
cmdline: string;
}
/**
* Every process on the machine, as /proc reports it, so this half can only run on Linux, and the fields are
* counted from the closing parenthesis because `comm` can hold spaces.
*
* Membership of the GROUP is what is checked, not parentage: a helper that outlives its parent stops being a
* child of this test process, and a test that looked for children would pass for the wrong reason.
*/
function scanProc(): Proc[] {
const out: Proc[] = [];
for (const entry of readdirSync('/proc')) {
if (!/^\d+$/.test(entry)) continue;
const pid = Number(entry);
try {
const stat = readFileSync(join('/proc', entry, 'stat'), 'utf8');
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
out.push({
pid,
comm: readFileSync(join('/proc', entry, 'comm'), 'utf8').trim(),
ppid: Number(fields[1]),
pgrp: Number(fields[2]),
cmdline: readFileSync(join('/proc', entry, 'cmdline'), 'utf8')
.replace(/\0/g, ' ')
.trim(),
});
} catch {
/* gone between the listing and the read */
}
}
return out;
}
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
describe.skipIf(!gitPresent)('runFetch (the whole tree, not just the git parent)', () => {
it.skipIf(!existsSync('/proc/self/environ'))(
'kills the helper processes too, so nothing is left running after the timeout',
async () => {
const server = createServer(() => {
// Accept and never answer: git's http helper connects, waits, and stays.
});
const sockets = new Set<Socket>();
server.on('connection', (s) => {
sockets.add(s);
s.on('close', () => sockets.delete(s));
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
const port = (server.address() as AddressInfo).port;
try {
const repo = initRepo(join(dir, 'hang'), `http://127.0.0.1:${port}/x.git`);
// The handler goes on the moment the promise is made, not when it is read: the timeout fires while the
// tree is still being looked at, and a rejection with nothing listening to it becomes an unhandled
// rejection instead of this test's answer.
const answered = runFetch(repo, buildFetchArgs(), { timeoutMs: 1_500 }).catch((e) => e);
// The tree has to be a tree before the timeout, or nothing is being proven: git itself, and the helper
// it spawns for an http remote. The helper is a grandchild (`git fetch` runs `git remote-http`, which
// spawns `git-remote-http`), so the group, not the parentage, is what proves it. Each process is
// remembered once found rather than re-scanned: after the kill the group is empty, and a test that kept
// looking would report a tree it had already destroyed. The `git config` read the runner does first is
// a `git` too, so the fetch is found by what it was asked to do.
let parent: Proc | null = null;
let helper: Proc | null = null;
for (let i = 0; i < 60 && !(parent && helper); i++) {
const live = scanProc();
parent =
parent ??
live.find((p) => p.comm === 'git' && p.cmdline.includes('fetch') && p.ppid === process.pid) ??
null;
helper =
helper ??
(parent
? live.find((p) => p.pgrp === parent!.pgrp && p.pid !== parent!.pid && p.cmdline.includes('remote-http'))
: null);
if (!helper) await sleep(25);
}
expect(parent, 'the fetch was never running').not.toBeNull();
expect(helper, 'nothing else in the fetch group, so there was no tree to kill').not.toBeNull();
// `detached: true` puts the fetch in a group of its own, which is why the negative pid is the right
// thing to signal: a helper outside that group would survive a `child.kill()`.
expect(parent!.pgrp).toBe(parent!.pid);
expect(helper!.pgrp).toBe(parent!.pgrp);
const err = (await answered) as { code?: string; killed?: boolean };
expect(err.code).toBe('ETIMEDOUT');
expect(err.killed).toBe(true);
// The answer went out before the tree died: that is the whole point of the fix, and it is why the
// escalation is given its own time here instead of being cancelled when the promise settles.
const deadline = Date.now() + FETCH_KILL_ESCALATION_MS + 2_000;
while (Date.now() < deadline && scanProc().some((p) => p.pgrp === parent!.pgrp)) await sleep(50);
expect(scanProc().some((p) => p.pgrp === parent!.pgrp)).toBe(false);
// Nothing of this test's own is left running either.
await sleep(200);
expect(scanProc().filter((p) => p.ppid === process.pid)).toEqual([]);
} finally {
for (const s of sockets) s.destroy();
server.close();
}
}
);
it('rejects on a non-zero exit instead of resolving empty stdout, so a failure keeps a reason to be read', async () => {
// A remote git cannot read. A repository with no remote is not usable for this: `git fetch --quiet` exits 0
// there on the git this was checked against (2.47.3), so resolving empty stdout would have been the right
// answer and the test would prove nothing. A remote pointing at a path that is not a repository exits 128
// and says why, which is the case the row needs a reason for.
const repo = initRepo(join(dir, 'unreadable-remote'), 'file:///tmp/definitely-not-a-repo-xyz');
let err: unknown = null;
try {
await runFetch(repo, buildFetchArgs());
} catch (e) {
err = e;
}
expect(err, 'a fetch that failed resolved instead of rejecting').not.toBeNull();
const e = err as { code?: unknown; stderr?: string; killed?: boolean };
// The exit code, not the timeout marker, so a caller can still tell a failure from a stall.
expect(e.code).toBe(128);
expect(e.killed).toBe(false);
expect(e.stderr).toMatch(/does not appear to be a git repository/);
expect(describeFetchFailure(err).code).toBe('NOT_FOUND');
});
});
// ─── One bound for the config read and the fetch ─────────────────────────────
describe.skipIf(process.platform === 'win32')('runFetch (one bound for the config read and the fetch)', () => {
it('answers at the timeout when git never answers, not at twice it, because the core.sshCommand read spends part of the bound', async () => {
// A `git` that never answers anything, first on PATH, so the read ahead of the fetch hangs exactly as long as
// the fetch would. Both spawns take PATH from process.env at call time. It records its arguments, so the test
// can tell which of the two it was asked to run.
const bin = join(dir, 'bin');
const calls = join(dir, 'calls');
mkdirSync(bin);
writeFileSync(join(bin, 'git'), `#!/bin/sh\necho "$*" >> '${calls}'\nexec sleep 30\n`, { mode: 0o755 });
const savedPath = process.env.PATH;
process.env.PATH = `${bin}:${savedPath ?? ''}`;
try {
const started = Date.now();
const err = (await runFetch(dir, buildFetchArgs(), { timeoutMs: 1_000 }).catch((e) => e)) as {
code?: unknown;
killed?: boolean;
};
const elapsed = Date.now() - started;
expect(err.code).toBe('ETIMEDOUT');
expect(err.killed).toBe(true);
expect(describeFetchFailure(err).code).toBe('TIMEOUT');
// One bound, not one per spawn: the read used all of it, so the fetch got nothing and was not started
// (2019 ms here before the fix, when the fetch was armed with a fresh timeout of its own).
expect(elapsed).toBeGreaterThanOrEqual(950);
expect(elapsed).toBeLessThan(1_600);
expect(readFileSync(calls, 'utf8').trim().split('\n')).toEqual(['config --get core.sshCommand']);
} finally {
if (savedPath === undefined) delete process.env.PATH;
else process.env.PATH = savedPath;
}
});
});
// ─── One run per folder, and one bound for the whole tick ────────────────────
describe.skipIf(!gitPresent)('fetchWorkspaceOnce (the join)', () => {
// The resolver still runs real git, so these need real repositories on disk; what is injected is only the
// fetch, because what is being pinned here is the joining, not git's answer.
beforeEach(() => {
// The lane is module state: a run left in it by the previous test would be joined by the next one.
clearGitFetchLane();
});
it('joins a run already going for the same folder instead of starting a second git fetch in the same clone', async () => {
const folder = join(dir, 'folder');
initRepo(join(folder, 'api'));
let releaseRun = () => {};
const held = new Promise<void>((resolve) => (releaseRun = resolve));
let running = () => {};
const runStarted = new Promise<void>((resolve) => (running = resolve));
let firstRunFetches = 0;
const firstRunner: GitRunner = async (cwd, args) => {
if (args[0] === 'fetch') {
firstRunFetches++;
running();
await held;
}
return '';
};
const first = fetchWorkspaceOnce(folder, { git: firstRunner }, 'interval');
await runStarted; // the run is going, and still going
// A click while that run is working the folder: it joins, so it never touches git at all, not even the
// resolver's own probe, because it does not resolve anything for itself.
let joinerCalls = 0;
const joinerRunner: GitRunner = async () => {
joinerCalls++;
return '';
};
const joiner = fetchWorkspaceOnce(folder, { git: joinerRunner }, 'click');
releaseRun();
const answer = await first;
// The joiner gets that same run's answer, including its limits: the point is not to fetch twice.
expect(await joiner).toBe(answer);
expect(joinerCalls).toBe(0);
expect(firstRunFetches).toBe(1);
});
it('a join reports the trigger of the run it joined, not the trigger that made it ask, because a click that fetched nothing cannot claim the fetch', async () => {
const folder = join(dir, 'folder');
initRepo(join(folder, 'api'));
let releaseRun = () => {};
const held = new Promise<void>((resolve) => (releaseRun = resolve));
let running = () => {};
const runStarted = new Promise<void>((resolve) => (running = resolve));
const runner: GitRunner = async (cwd, args) => {
if (args[0] === 'fetch') {
running();
await held;
}
return '';
};
// The tick got there first; the click is the joiner.
const intervalRun = fetchWorkspaceOnce(folder, { git: runner }, 'interval');
await runStarted;
const joinerRun = fetchWorkspaceOnce(folder, { git: async () => '' }, 'click');
releaseRun();
const run = await intervalRun;
expect(await joinerRun).toBe(run); // one run, not two answers
expect(run.trigger).toBe('interval');
// The row the panel shows is built from the run that ran, so it names the interval.
expect(gitFetchStatusFrom(run.report, run.trigger).trigger).toBe('interval');
});
it('keys the join on the resolved folder, so the same folder written two ways is one run', async () => {
const folder = join(dir, 'folder');
initRepo(join(folder, 'api'));
let releaseRun = () => {};
const held = new Promise<void>((resolve) => (releaseRun = resolve));
let running = () => {};
const runStarted = new Promise<void>((resolve) => (running = resolve));
let fetches = 0;
const firstRunner: GitRunner = async (cwd, args) => {
if (args[0] === 'fetch') {
fetches++;
running();
await held;
}
return '';
};
const first = fetchWorkspaceOnce(folder, { git: firstRunner }, 'click');
await runStarted;
// Two sessions on one folder reach it by different spellings; the scheduler keys by `resolve`.
let joinerCalls = 0;
const joinerRunner: GitRunner = async () => {
joinerCalls++;
return '';
};
const second = fetchWorkspaceOnce(join(dir, 'folder', '.'), { git: joinerRunner }, 'click');
releaseRun();
expect(await second).toBe(await first);
expect(joinerCalls).toBe(0);
expect(fetches).toBe(1);
});
it('releases the folder when a run lands, so the next click is a new run and not an old answer', async () => {
const folder = join(dir, 'folder');
initRepo(join(folder, 'api'));
let fetches = 0;
const runner: GitRunner = async (cwd, args) => {
if (args[0] === 'fetch') fetches++;
return '';
};
const firstRun = await fetchWorkspaceOnce(folder, { git: runner }, 'interval');
expect(fetches).toBe(1);
const secondRun = await fetchWorkspaceOnce(folder, { git: runner }, 'click');
expect(fetches).toBe(2);
// A run that is its own reports its own trigger; only a joiner borrows the trigger of the run it joined.
expect(firstRun.trigger).toBe('interval');
expect(secondRun.trigger).toBe('click');
});
it('bounds a whole tick with the limiter it is handed, because two runs given a bound each would get twice the bound', async () => {
const left = join(dir, 'left');
const right = join(dir, 'right');
for (let i = 0; i < FETCH_CONCURRENCY + 1; i++) {
initRepo(join(left, `l${i}`));
initRepo(join(right, `r${i}`));
}
const held = () => new Promise((resolve) => setTimeout(resolve, 10));
// Only the fetches go through the limiter, so only they are counted here: the resolver's own probe is
// not a bounded git process.
const counting =
(state: { live: number; peak: number }): GitRunner =>
async (cwd, args) => {
if (args[0] !== 'fetch') return '';
state.live++;
state.peak = Math.max(state.peak, state.live);
await held();
state.live--;
return '';
};
// The scheduler hands every folder in the tick the SAME limiter, so the bound is the tick's, not the run's.
const shared = { live: 0, peak: 0 };
const sharedRunner = counting(shared);
const limiter = createLimiter(FETCH_CONCURRENCY);
await Promise.all([
fetchGitWorkspaceOverview(left, { git: sharedRunner, limiter }),
fetchGitWorkspaceOverview(right, { git: sharedRunner, limiter }),
]);
expect(shared.peak).toBe(FETCH_CONCURRENCY);
// Without a shared limiter each run bounds only its own call, which is the failure the scheduler is there
// to prevent: two folders at FETCH_CONCURRENCY each, so twice as many git processes as the rule says.
const alone = { live: 0, peak: 0 };
const aloneRunner = counting(alone);
await Promise.all([
fetchGitWorkspaceOverview(left, { git: aloneRunner }),
fetchGitWorkspaceOverview(right, { git: aloneRunner }),
]);
expect(alone.peak).toBe(2 * FETCH_CONCURRENCY);
});
});
+145
View File
@@ -29,6 +29,7 @@ describe('Git status indicator in a real browser', () => {
let repoSession: string;
let plainSession: string;
const gitStatusRequests: string[] = [];
const gitFetchRequests: string[] = [];
const settingsPutStatuses: number[] = [];
const write = (rel: string, text = 'x\n') => writeFileSync(join(repo, rel), text);
@@ -91,6 +92,9 @@ describe('Git status indicator in a real browser', () => {
page.on('request', (r) => {
// The API route only: the page also loads /git-status-ui.js, whose URL contains the same words.
if (/\/api\/sessions\/[^/]+\/git-status/.test(r.url())) gitStatusRequests.push(r.url());
// Anchored: the per-case Git settings route (/git-fetch-settings) is a different route, and a bare
// /git-fetch test would count its PUTs as fetches. The fetch route carries the limits as a query.
if (/\/api\/sessions\/[^/]+\/git-fetch(\?|$)/.test(r.url())) gitFetchRequests.push(r.url());
});
page.on('response', (r) => {
if (r.request().method() === 'PUT' && r.url().endsWith('/api/settings')) settingsPutStatuses.push(r.status());
@@ -514,6 +518,147 @@ describe('Git status indicator in a real browser', () => {
await refresh();
}, 40000);
it('puts Fetch in the header row, between Refresh and Close', async () => {
const order = await page.evaluate(() =>
[...document.querySelectorAll('.git-status-actions button')].map((b) => b.getAttribute('aria-label'))
);
expect(order).toEqual(['Refresh git status', 'Fetch git status', 'Close git status']);
// A repository the panel can read is a repository Fetch can fetch, so the button is live here.
expect(await page.evaluate(() => document.getElementById('gitStatusFetchBtn')!.disabled)).toBe(false);
});
it('Fetch fetches for real, moves the behind number, and reports what it brought', async () => {
// A second clone pushes a commit this repository has not fetched, so its remote-tracking ref is stale:
// the panel cannot show a `behind` number until a fetch runs. That is what the footer means by
// "as of the last fetch", and the number only becomes current by the button.
const other = join(root, 'other-clone');
git(root, 'clone', '-q', join(root, 'origin.git'), other);
writeFileSync(join(other, 'from-elsewhere.txt'), 'y\n');
git(other, 'add', '-A');
git(other, 'commit', '-q', '-m', 'from elsewhere');
git(other, 'push', '-q');
await refresh();
expect(await page.textContent('#gitStatusBody')).not.toMatch(/behind/);
const before = gitFetchRequests.length;
await page.click('#gitStatusFetchBtn');
await page.waitForFunction(
() => /↓ 1 behind/.test(document.getElementById('gitStatusBody')?.textContent ?? ''),
null,
{ timeout: 20000 }
);
expect(gitFetchRequests.length).toBe(before + 1);
// The same limits the status route gets go with the fetch, so the two agree on which repositories count.
expect(gitFetchRequests.at(-1)).toMatch(/maxRepos=12/);
expect(await page.locator('.git-status-chip', { hasText: 'behind' }).textContent()).toBe('↓ 1 behind');
expect(await page.textContent('#gitStatusBody .git-status-fetch-summary')).toMatch(
/^Fetched .+: 1 of 1 repository\.$/
);
expect(await page.locator('#gitStatusBody .git-status-fetch-row').count()).toBe(0);
// The 15 s poll rebuilds the whole body, so the note has to come from the renderer, not from the click.
await refresh();
expect(await page.locator('#gitStatusBody .git-status-fetch-summary').count()).toBe(1);
const foot = (await page.textContent('#gitStatusFooter')) ?? '';
expect(foot).toMatch(/Read-only apart from Fetch/);
// No longer "only when you click": an interval the user turned on fetches too, and the footer says so.
expect(foot).toMatch(/interval/);
// The chip itself says how it got current, so the number is not read as live.
expect(await page.locator('.git-status-chip', { hasText: 'behind' }).getAttribute('title')).toMatch(/interval/);
rmSync(other, { recursive: true, force: true });
}, 30000);
it('a repository Fetch could not fetch keeps its row and shows why', async () => {
await page.route('**/api/sessions/*/git-fetch*', (route) =>
route.fulfill({
contentType: 'application/json',
body: JSON.stringify({
success: true,
data: {
state: 'ok',
reposTruncated: false,
fetchedAt: Date.now(),
repos: [
{ name: 'fetchable', path: 'fetchable', ok: true },
{
name: 'private',
path: '/srv/private',
ok: false,
failure: { code: 'AUTH', message: 'could not read the remote' },
},
],
},
}),
})
);
await page.click('#gitStatusFetchBtn');
await page.waitForFunction(() =>
/could not read the remote/.test(document.getElementById('gitStatusBody')?.textContent ?? '')
);
// The summary counts what landed, and the failure is named rather than folded into the count. The
// summary is the panel's first note line, so the count is read off it, not off the whole body.
expect(await page.textContent('#gitStatusBody .git-status-fetch-summary')).toMatch(
/^Fetched .+: 1 of 2 repositories\.$/
);
const row = (await page.textContent('#gitStatusBody .git-status-fetch-row')) ?? '';
expect(row).toContain('private');
expect(row).toContain('⚠ could not read the remote');
expect(await page.locator('#gitStatusBody .git-status-fetch-row').count()).toBe(1);
// Git text is untrusted: the row is text, never markup.
expect(await page.$('#gitStatusBody img')).toBeNull();
await page.unroute('**/api/sessions/*/git-fetch*');
}, 30000);
it('one click means one fetch: the button is busy while it runs and a second call is ignored', async () => {
let slow = true;
await page.route('**/api/sessions/*/git-fetch*', async (route) => {
if (slow) await new Promise((r) => setTimeout(r, 1500));
await route.continue();
});
page.setDefaultTimeout(6000);
await page.click('#gitStatusFetchBtn');
await page.waitForFunction(() => (window as any).app._gitFetchInFlight === true);
const busy = await page.evaluate(() => {
const b = document.getElementById('gitStatusFetchBtn')!;
return { ariaBusy: b.getAttribute('aria-busy'), disabled: b.disabled };
});
expect(busy.ariaBusy).toBe('true');
// Disabled is what stops a second click reaching the handler, so one click is one fetch per repository.
expect(busy.disabled).toBe(true);
const during = gitFetchRequests.length;
await page.evaluate(() => void (window as any).app.fetchGitStatusNow());
expect(gitFetchRequests.length).toBe(during);
slow = false;
// The flag goes false before the re-render that follows it, so wait for the button itself, not the flag:
// reading it right at the flip catches the panel mid-render.
await page.waitForFunction(
() => {
const b = document.getElementById('gitStatusFetchBtn')!;
return (window as any).app._gitFetchInFlight === false && !b.disabled && !b.getAttribute('aria-busy');
},
null,
{ timeout: 15000 }
);
// The handler is still asleep: unrouting without waiting fails the run as an unhandled rejection.
await page.unrouteAll({ behavior: 'wait' });
page.setDefaultTimeout(30000);
}, 30000);
it('Fetch is inert for a session nothing can be fetched for', async () => {
await select(plainSession);
await page.waitForFunction(
() => /No git repository here/.test(document.getElementById('gitStatusBody')?.textContent ?? ''),
null,
{ timeout: 15000 }
);
expect(await page.evaluate(() => document.getElementById('gitStatusFetchBtn')!.disabled)).toBe(true);
await select(repoSession);
await page.waitForFunction(() => document.getElementById('gitStatusFetchBtn')!.disabled === false, null, {
timeout: 15000,
});
await page.waitForSelector('#gitStatusPanel.visible');
}, 30000);
it('closing the panel resets it; turning the setting off hides the button, closes the panel and stops polling', async () => {
await page.click('.git-status-actions button[aria-label="Close git status"]');
expect(await page.isVisible('#gitStatusPanel')).toBe(false);
+450
View File
@@ -0,0 +1,450 @@
/**
* @fileoverview Input that lands while a guarded submit holds the input lease is held
* and applied in arrival order once the lease is released, never refused and lost.
*
* Shaped like the review repro: a real `Session`, a mux that runs `beforeDispatch` and
* then awaits the Enter the way `TmuxManager.sendInput` does, and the real WebSocket
* route driven through a fake socket. The browser's WebSocket path does not wait for
* ACKs, so the client model below sends every frame at once and redelivers whatever
* is still unACKed afterwards, in seq order, the way `_redeliverSweep` does.
*/
import { EventEmitter } from 'node:events';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { FastifyInstance } from 'fastify';
import { formatPaneSnapshot } from '../src/tmux-manager.js';
import { GuardedInputBusyError, Session } from '../src/session.js';
import { registerWsRoutes } from '../src/web/routes/ws-routes.js';
import { registerSessionRoutes } from '../src/web/routes/session-routes.js';
import type { SessionPort } from '../src/web/ports/session-port.js';
import { createRouteTestHarness } from './routes/_route-test-utils.js';
type Frame = { t: 'i'; d: string; seq?: number; cid?: string };
class FakeSocket extends EventEmitter {
readyState = 1;
sent: Array<Record<string, unknown>> = [];
closedWith: number | null = null;
send(data: string): void {
this.sent.push(JSON.parse(data));
}
close(code?: number): void {
this.closedWith = code ?? 1000;
this.readyState = 3;
}
ping(): void {}
terminate(): void {
this.readyState = 3;
}
frame(msg: Frame): void {
this.emit('message', Buffer.from(JSON.stringify(msg)));
}
acks(): number[] {
return this.sent.filter((m) => m.t === 'ia').map((m) => m.seq as number);
}
}
interface Harness {
session: Session;
pty: string[];
/** Resolve the in-flight Enter (true = tmux accepted it, false = it failed). */
finishEnter(ok?: boolean | Error): void;
enterDispatched(): boolean;
connect(cid: string): FakeSocket;
stop(): Promise<void>;
}
const sockets: FakeSocket[] = [];
function harness(awaitBeforeGuard = false): Harness {
const pty: string[] = [];
let finish: ((ok: boolean | Error) => void) | undefined;
let dispatched = false;
const mux = {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot(['› hello', ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 7,
cursorY: 0,
});
},
// Like TmuxManager.sendInput's Enter-only branch: the guard runs synchronously,
// then the `send-keys Enter` child process is awaited with the lease still held.
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
if (options?.beforeDispatch && text !== '\r') return false;
const settle = () =>
new Promise<boolean | Error>((resolve) => {
finish = resolve;
});
// A transport that awaits before its guard leaves a window in which held
// keystrokes must still invalidate the Enter.
const early = awaitBeforeGuard ? await settle() : undefined;
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
dispatched = true;
const outcome = early ?? (await settle());
if (outcome instanceof Error) throw outcome;
if (outcome) pty.push(text);
return outcome;
},
};
const session = new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
const access = session as unknown as { ptyProcess: { write(data: string): void } | null };
access.ptyProcess = { write: (data: string) => pty.push(data) };
let handler: ((socket: FakeSocket, req: unknown) => void) | undefined;
const app = {
get: (_path: string, _opts: unknown, h: (socket: FakeSocket, req: unknown) => void) => {
handler = h;
},
} as unknown as FastifyInstance;
const ctx = { sessions: new Map([[session.id, session]]) } as unknown as SessionPort;
registerWsRoutes(app, ctx, () => ({ bindHost: '127.0.0.1', allowedHosts: [] }));
return {
session,
pty,
finishEnter: (ok = true) => finish!(ok),
enterDispatched: () => dispatched,
connect: (cid: string) => {
const socket = new FakeSocket();
sockets.push(socket);
handler!(socket, { headers: { host: 'localhost:3000' }, params: { id: session.id }, query: { cid } });
return socket;
},
stop: async () => {
access.ptyProcess = null;
await session.stop(false);
},
};
}
/** Redeliver every still-unACKed frame in seq order, as the browser's sweep does. */
function redeliverUnacked(socket: FakeSocket, frames: Frame[]): void {
const acked = new Set(socket.acks());
for (const f of frames) if (f.seq !== undefined && !acked.has(f.seq)) socket.frame(f);
}
afterEach(() => {
for (const s of sockets.splice(0)) s.emit('close', 1000, Buffer.from(''));
});
describe('input during a guarded submit lease', () => {
it('delivers a keystroke that landed during the lease before the one typed after it (review repro)', async () => {
const h = harness();
try {
const socket = h.connect('c1');
const a: Frame = { t: 'i', d: 'a', seq: 1, cid: 'c1' };
const b: Frame = { t: 'i', d: 'b', seq: 2, cid: 'c1' };
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
expect(h.enterDispatched()).toBe(true);
expect(h.session.guardedSubmitInFlight).toBe(true);
socket.frame(a);
// Held, not refused: no ACK yet and nothing written while the lease is held.
expect(socket.acks()).toEqual([]);
expect(h.pty).toEqual([]);
h.finishEnter(true);
await expect(submit).resolves.toBe(true);
socket.frame(b);
redeliverUnacked(socket, [a, b]);
expect(h.pty).toEqual(['\r', 'a', 'b']);
expect(socket.acks()).toEqual([1, 2]);
expect(socket.sent.filter((m) => m.dup)).toEqual([]);
} finally {
await h.stop();
}
});
it('keeps arrival order for several frames during the lease and one after it', async () => {
const h = harness();
try {
const socket = h.connect('c1');
const frames: Frame[] = ['a', 'b', 'c', 'd'].map((d, i) => ({ t: 'i', d, seq: i + 1, cid: 'c1' }));
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
for (const f of frames.slice(0, 3)) socket.frame(f);
expect(h.pty).toEqual([]);
h.finishEnter(true);
await submit;
socket.frame(frames[3]);
redeliverUnacked(socket, frames);
expect(h.pty).toEqual(['\r', 'a', 'b', 'c', 'd']);
expect(socket.acks()).toEqual([1, 2, 3, 4]);
} finally {
await h.stop();
}
});
it('interleaves a second client by arrival, each with its own seq space', async () => {
const h = harness();
try {
const one = h.connect('c1');
const two = h.connect('c2');
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
one.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' });
two.frame({ t: 'i', d: 'x', seq: 1, cid: 'c2' });
one.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' });
h.finishEnter(true);
await submit;
two.frame({ t: 'i', d: 'y', seq: 2, cid: 'c2' });
expect(h.pty).toEqual(['\r', 'a', 'x', 'b', 'y']);
expect(one.acks()).toEqual([1, 2]);
expect(two.acks()).toEqual([1, 2]);
} finally {
await h.stop();
}
});
it('flushes held input when the lease ends in failure, refused Enter or thrown', async () => {
for (const outcome of [false, new Error('tmux send-keys failed')]) {
const h = harness();
try {
const socket = h.connect('c1');
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' });
h.finishEnter(outcome);
if (outcome instanceof Error) await expect(submit).rejects.toThrow('tmux send-keys failed');
else await expect(submit).resolves.toBe(false);
socket.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' });
expect(h.pty).toEqual(['a', 'b']);
expect(socket.acks()).toEqual([1, 2]);
expect(h.session.guardedSubmitInFlight).toBe(false);
} finally {
await h.stop();
}
}
});
it('holds untagged frames too, which have no redelivery to fall back on', async () => {
const h = harness();
try {
const socket = h.connect('c1');
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
socket.frame({ t: 'i', d: 'a' });
h.finishEnter(true);
await submit;
socket.frame({ t: 'i', d: 'b' });
expect(h.pty).toEqual(['\r', 'a', 'b']);
} finally {
await h.stop();
}
});
it('a frame held for a socket that closed is still applied once; its redelivery is a duplicate', async () => {
const h = harness();
try {
const first = h.connect('c1');
const a: Frame = { t: 'i', d: 'a', seq: 1, cid: 'c1' };
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
first.frame(a);
first.close();
first.emit('close', 1006, Buffer.from(''));
h.finishEnter(true);
await submit;
expect(first.sent).toEqual([]);
const second = h.connect('c1');
second.frame(a);
second.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' });
expect(h.pty).toEqual(['\r', 'a', 'b']);
expect(second.sent.filter((m) => m.t === 'ia')).toEqual([
{ t: 'ia', seq: 1, dup: true, last: 1 },
{ t: 'ia', seq: 2 },
]);
} finally {
await h.stop();
}
});
it('closes the socket instead of holding past the cap, and the reconnect resends in order', async () => {
const h = harness();
try {
const socket = h.connect('c1');
const cap = Session.MAX_GUARDED_HELD_INPUTS;
const frames: Frame[] = Array.from({ length: cap + 3 }, (_, i) => ({
t: 'i',
d: `k${i}`,
seq: i + 1,
cid: 'c1',
}));
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
for (const f of frames) socket.frame(f);
// Frames past the cap are not held, not applied and not ACKed; the socket is
// closed so the browser reconnects and resends everything still unACKed.
expect(socket.closedWith).toBe(1013);
h.finishEnter(true);
await submit;
expect(h.pty.length).toBe(1 + cap);
expect(socket.acks()).toEqual([]);
socket.emit('close', 1013, Buffer.from(''));
const again = h.connect('c1');
for (const f of frames) again.frame(f);
expect(h.pty).toEqual(['\r', ...frames.map((f) => f.d)]);
expect(again.acks()).toEqual(frames.map((f) => f.seq));
} finally {
await h.stop();
}
});
it('holds managed writes and named keys behind the lease, and refuses them only when the hold queue is full', async () => {
const h = harness();
try {
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
const order: string[] = [];
const key = h.session.performAuxiliaryInput(async () => {
order.push('key');
});
await new Promise<void>((resolve) => setImmediate(resolve));
expect(order).toEqual([]);
for (let i = 1; i < Session.MAX_GUARDED_HELD_INPUTS; i++) {
expect(h.session.holdForGuardedLease(() => order.push('frame'))).toBe('held');
}
expect(h.session.holdForGuardedLease(() => order.push('lost'))).toBe('full');
await expect(h.session.performAuxiliaryInput(async () => order.push('lost'))).rejects.toBeInstanceOf(
GuardedInputBusyError
);
await expect(h.session.writeViaMux('lost')).resolves.toBe(false);
h.finishEnter(true);
await submit;
await key;
expect(order[0]).toBe('key');
expect(order.filter((o) => o === 'frame')).toHaveLength(Session.MAX_GUARDED_HELD_INPUTS - 1);
expect(order).not.toContain('lost');
} finally {
await h.stop();
}
});
it('caps held bytes as well as held frames', async () => {
const h = harness();
try {
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
expect(h.session.holdForGuardedLease(() => {}, Session.MAX_GUARDED_HELD_BYTES)).toBe('held');
expect(h.session.holdForGuardedLease(() => {}, 1)).toBe('full');
h.finishEnter(true);
await submit;
expect(h.session.holdForGuardedLease(() => {}, Session.MAX_GUARDED_HELD_BYTES + 1)).toBe('free');
} finally {
await h.stop();
}
});
it('a frame arriving while a held named key is still being sent waits for it', async () => {
const h = harness();
try {
const socket = h.connect('c1');
let keySent!: () => void;
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
const key = h.session.performAuxiliaryInput(async () => {
await new Promise<void>((resolve) => {
keySent = resolve;
});
h.pty.push('<newline>');
});
socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' });
h.finishEnter(true);
await submit;
// The lease is over, but the held key is still in flight: input stays held.
expect(h.session.guardedSubmitInFlight).toBe(false);
expect(h.session.guardedInputHeld).toBe(true);
socket.frame({ t: 'i', d: 'b', seq: 2, cid: 'c1' });
expect(h.pty).toEqual(['\r']);
keySent();
await key;
await new Promise<void>((resolve) => setImmediate(resolve));
expect(h.pty).toEqual(['\r', '<newline>', 'a', 'b']);
expect(socket.acks()).toEqual([1, 2]);
expect(h.session.guardedInputHeld).toBe(false);
} finally {
await h.stop();
}
});
it('holds an untagged raw POST .../input during the lease and writes it after the Enter', async () => {
const h = harness();
const route = await createRouteTestHarness(registerSessionRoutes);
try {
(route.ctx.sessions as unknown as Map<string, Session>).set(h.session.id, h.session);
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
expect(h.session.guardedSubmitInFlight).toBe(true);
// The Stop button's Ctrl+C: an untagged POST whose answer nobody reads.
const res = await route.app.inject({
method: 'POST',
url: `/api/sessions/${h.session.id}/input`,
payload: { input: '\x03' },
});
expect(res.statusCode).toBe(200);
expect(h.pty).toEqual([]);
h.finishEnter(true);
await expect(submit).resolves.toBe(true);
expect(h.pty).toEqual(['\r', '\x03']);
} finally {
await route.app.close();
await h.stop();
}
});
it('a held untagged raw send-and-wait reports the held write as delivered', async () => {
const h = harness();
const route = await createRouteTestHarness(registerSessionRoutes);
try {
(route.ctx.sessions as unknown as Map<string, Session>).set(h.session.id, h.session);
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
const answer = route.app.inject({
method: 'POST',
url: `/api/sessions/${h.session.id}/input`,
payload: { input: '\x03', useMux: false, wait: 'exit', waitTimeout: 1000 },
});
// Private, but the only proof the route reached the hold before the lease ends.
const queue = (h.session as unknown as { _guardedHeld: unknown[] })._guardedHeld;
await vi.waitFor(() => expect(queue).toHaveLength(1));
expect(h.pty).toEqual([]);
h.finishEnter(true);
await submit;
const res = await answer;
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ delivered: true, duplicate: false });
expect(h.pty).toEqual(['\r', '\x03']);
} finally {
await route.app.close();
await h.stop();
}
});
it('a keystroke held before the guard runs still invalidates the Enter, then lands', async () => {
const h = harness(true);
try {
const socket = h.connect('c1');
const submit = h.session.submitGuardedInput(h.session.guardedInputSnapshot()!);
await Promise.resolve();
socket.frame({ t: 'i', d: 'a', seq: 1, cid: 'c1' });
h.finishEnter(true);
await expect(submit).resolves.toBe(false);
expect(h.enterDispatched()).toBe(false);
expect(h.pty).toEqual(['a']);
expect(socket.acks()).toEqual([1]);
} finally {
await h.stop();
}
});
});
+161 -6
View File
@@ -33,11 +33,30 @@ interface FakeTrap {
addEventListener: (ev: string, fn: TrapListener) => void;
}
interface SentInput {
sessionId: string;
data: string;
}
interface TargetTerminal {
input: (data: string) => void;
paste?: (text: string) => void;
focus?: () => void;
}
interface Harness {
/** Fire a paste event on the trap the last _handleImagePaste() call created. */
firePaste: (payload: { text?: string; images?: string[] }) => void;
/** Text handed to xterm's terminal.paste(), one entry per call. */
pastedText: string[];
/** Frames handed to the primary terminal's input(), one entry per call. */
inputted: string[];
/** Frames handed to _sendInputAsync(), in order. The trap must not deliver text this way. */
sentInputs: SentInput[];
/** How many times predictive-echo predictions were cleared by the trap. */
predictionsCleared: () => number;
/** Unsent overlay text still held after the paste. Empty when local echo was not set up. */
pendingEcho: () => string;
/** Image batches handed to _uploadAndInsertImages(), one entry per call. */
uploadedBatches: Array<Array<{ type: string }>>;
/** Messages handed to showToast(), one entry per call. */
@@ -47,7 +66,16 @@ interface Harness {
runTimers: () => void;
}
function loadPasteHarness(): Harness {
interface PasteHarnessOptions {
/** Session the paste trap is aimed at. Defaults to the active session. */
sessionId?: string;
/** Unsent overlay text. The trap must leave this for onData to flush. */
localEchoPending?: string;
/** Terminal the Ctrl+V came from, when it is not the primary pane. */
terminal?: TargetTerminal;
}
function loadPasteHarness(options: PasteHarnessOptions = {}): Harness {
const traps: FakeTrap[] = [];
const listeners: TrapListener[] = [];
const attached = new Set<FakeTrap>();
@@ -99,20 +127,68 @@ function loadPasteHarness(): Harness {
const CodemanApp = vm.runInContext('CodemanApp', context) as new () => Record<string, unknown>;
const pastedText: string[] = [];
const inputted: string[] = [];
const sentInputs: SentInput[] = [];
const uploadedBatches: Array<Array<{ type: string }>> = [];
let predictionsCleared = 0;
let pendingEcho = '';
const app = new CodemanApp();
app.activeSessionId = 'session-1';
// Replay leaves xterm's DECSET 2004 mirror off. Text must still go out bracketed,
// and must not ask terminal.paste() to consult that mirror (#716). input() is
// what a real xterm fires onData from, with wasUserInput (scroll + clear selection).
app.terminal = {
paste: (text: string) => pastedText.push(text),
input: (data: string) => inputted.push(data),
focus: () => {},
modes: { bracketedPasteMode: false },
};
app._sendInputAsync = (sessionId: string, data: string) => {
sentInputs.push({ sessionId, data });
};
app._predictiveEcho = {
clearPredictions: () => {
predictionsCleared += 1;
},
};
app._uploadAndInsertImages = (files: Array<{ type: string }>) => {
uploadedBatches.push(Array.from(files));
};
if (options.localEchoPending !== undefined) {
pendingEcho = options.localEchoPending;
app._localEchoEnabled = true;
app._pendingInput = '';
app._localEchoOverlay = {
get pendingText() {
return pendingEcho;
},
set pendingText(value: string) {
pendingEcho = value;
},
clear() {
this.pendingText = '';
},
suppressBufferDetection() {},
};
app._flushedOffsets = new Map();
app._flushedTexts = new Map();
app._flushLocalEchoPending = function (this: Record<string, any>) {
const pending = this._localEchoOverlay?.pendingText || '';
this._localEchoOverlay.clear();
this._localEchoOverlay.suppressBufferDetection();
this._flushedOffsets?.delete(this.activeSessionId);
this._flushedTexts?.delete(this.activeSessionId);
if (pending) this._pendingInput += pending;
};
}
const toasts: string[] = [];
app.showToast = (message: string) => toasts.push(message);
(app._handleImagePaste as () => void).call(app);
const target =
options.sessionId || options.terminal
? { terminal: options.terminal ?? app.terminal, sessionId: options.sessionId || 'session-1' }
: undefined;
(app._handleImagePaste as (t?: object) => void).call(app, target);
return {
firePaste({ text = '', images = [] }) {
@@ -128,6 +204,10 @@ function loadPasteHarness(): Harness {
for (const fn of listeners) fn(event);
},
pastedText,
inputted,
sentInputs,
predictionsCleared: () => predictionsCleared,
pendingEcho: () => pendingEcho,
uploadedBatches,
toasts,
attachedTraps: () => attached.size,
@@ -154,12 +234,82 @@ function loadImageInputApp() {
}
describe('Ctrl+V paste trap', () => {
it('sends clipboard text to the terminal once for a single paste event', () => {
it('hands clipboard text to terminal.input as one bracketed paste, not through paste() or the session path', () => {
const h = loadPasteHarness();
h.firePaste({ text: 'hello world' });
expect(h.pastedText).toEqual(['hello world']);
// terminal.paste() would drop the markers: the harness mirror is off, which
// is what a replay leaves behind. input() is the public entry that fires
// onData with wasUserInput, so the pane scrolls and the selection clears.
// Delivery belongs to that onData handler, not a second write from here.
expect(h.pastedText).toEqual([]);
expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']);
expect(h.sentInputs).toEqual([]);
expect(h.predictionsCleared()).toBe(0);
});
it('brackets a multi-line paste and strips nested paste markers', () => {
const h = loadPasteHarness();
h.firePaste({ text: 'line one\nline two\r\n\x1b[200~nested\x1b[201~\n' });
expect(h.pastedText).toEqual([]);
expect(h.inputted).toEqual(['\x1b[200~line one\rline two\rnested\r\x1b[201~']);
expect(h.sentInputs).toEqual([]);
});
it('strips a marker hidden inside another, so the clipboard cannot end the frame early', () => {
const h = loadPasteHarness();
// One pass over this leaves `ESC[201~` between `ok` and the command.
h.firePaste({ text: 'ok\x1b[20\x1b[201~1~\nrm -rf ~/x\n' });
expect(h.inputted).toEqual(['\x1b[200~ok\rrm -rf ~/x\r\x1b[201~']);
});
it('addresses the bracketed paste to the pane that received Ctrl+V', () => {
const paneInputs: string[] = [];
const pane: TargetTerminal = {
input: (data) => paneInputs.push(data),
paste: () => {
throw new Error('paste() must not run');
},
focus: () => {},
};
const h = loadPasteHarness({ sessionId: 'pane-b', terminal: pane });
h.firePaste({ text: 'into pane b' });
expect(paneInputs).toEqual(['\x1b[200~into pane b\x1b[201~']);
expect(h.inputted).toEqual([]);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
it('leaves unsent local-echo text for onData, instead of flushing it itself', () => {
const h = loadPasteHarness({ localEchoPending: 'hi' });
h.firePaste({ text: 'world\n' });
h.runTimers();
// terminal.input() fires the main pane's onData, whose bracketed-paste
// branch flushes the overlay and delays the frame (#219). Flushing here
// as well would put the prefix on the wire twice.
expect(h.pendingEcho()).toBe('hi');
expect(h.inputted).toEqual(['\x1b[200~world\r\x1b[201~']);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
it('keeps a clipboard that looks like a query reply or a mouse report inside the frame', () => {
const h = loadPasteHarness();
h.firePaste({ text: '\x1b[?1;2c\n\x1b[<0;10;5M\n\x1b[I' });
expect(h.inputted).toEqual(['\x1b[200~\x1b[?1;2c\r\x1b[<0;10;5M\r\x1b[I\x1b[201~']);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
it('ignores a second paste event for the same Ctrl+V', () => {
@@ -170,7 +320,9 @@ describe('Ctrl+V paste trap', () => {
h.firePaste({ text: 'hello world' });
h.firePaste({ text: 'hello world' });
expect(h.pastedText).toEqual(['hello world']);
expect(h.inputted).toEqual(['\x1b[200~hello world\x1b[201~']);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
});
it('uploads a pasted image once when the trap sees two paste events', () => {
@@ -182,6 +334,7 @@ describe('Ctrl+V paste trap', () => {
expect(h.uploadedBatches).toHaveLength(1);
expect(h.uploadedBatches[0]).toEqual([{ type: 'image/png' }]);
expect(h.pastedText).toEqual([]);
expect(h.sentInputs).toEqual([]);
});
it('a paste mixing an image with a refused file uploads the image and says what it skipped', () => {
@@ -199,7 +352,9 @@ describe('Ctrl+V paste trap', () => {
// Copying from a document can carry both: the text is what the user gets.
h.firePaste({ text: 'echo hi', images: ['application/x-sh'] });
expect(h.pastedText).toEqual(['echo hi']);
expect(h.inputted).toEqual(['\x1b[200~echo hi\x1b[201~']);
expect(h.sentInputs).toEqual([]);
expect(h.pastedText).toEqual([]);
expect(h.uploadedBatches).toEqual([]);
expect(h.toasts).toEqual([]);
});
+190
View File
@@ -0,0 +1,190 @@
/**
* The image watcher must never watch a REMOTE session's working directory.
*
* A remote-SSH session's `workingDir` is a path on the remote host. The watcher
* (chokidar) watches it on the LOCAL disk: where nothing exists there it watches
* a missing folder, and where a same-named local folder does exist it reports that
* folder's images as the remote session's. Three places start a watch, and all
* three skip a remote session:
* - `WebServer.setupSessionListeners()` (create, respawn, restore, recovery);
* - `POST /api/sessions/:id/image-watcher` (the per-session toggle), which refuses
* to turn it on, the way the Git fetch settings route refuses a remote session;
* - `PUT /api/settings` turning the app-wide watcher on, which re-watches every
* session that has it enabled.
*
* Spies on the real `imageWatcher` singleton, so all three modules see the spy.
* Port: none (app.inject(), and a WebServer that is never started).
*/
import { mkdirSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { safeRmHomeTree } from './mocks/test-helpers.js';
import { createRouteTestHarness, type RouteTestHarness } from './routes/_route-test-utils.js';
import { imageWatcher } from '../src/image-watcher.js';
import { WebServer } from '../src/web/server.js';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { registerSessionRoutes } from '../src/web/routes/session-routes.js';
import { registerSystemRoutes } from '../src/web/routes/system-routes.js';
const WORKSPACE = join(homedir(), '.codeman-test-image-watcher-remote');
// A path on the remote host: nothing exists there locally.
const REMOTE_PATH = '/nonexistent-remote-host-path/homelab-infrastructure';
const REMOTE = {
hostId: 'proxx',
label: 'proxx',
host: 'proxx.example.lan',
username: 'user',
remotePath: REMOTE_PATH,
owned: true,
};
let watchSession: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
watchSession = vi.spyOn(imageWatcher, 'watchSession').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
imageWatcher.stop();
});
describe('setupSessionListeners()', () => {
interface ServerInternals {
registerSessionWithLayout(session: Session): Promise<void>;
setupSessionListeners(session: Session): Promise<void>;
discardPartiallyBuiltSession(sessionId: string): Promise<void>;
isImageWatcherEnabled(): Promise<boolean>;
}
const SESSION_ID = 'a1b2c3d4e5f60718';
let server: WebServer;
let internals: ServerInternals;
let mux: TmuxManager;
beforeAll(() => {
mkdirSync(WORKSPACE, { recursive: true });
server = new WebServer(0, false, true);
internals = server as unknown as ServerInternals;
mux = new TmuxManager();
});
afterEach(async () => {
await internals.discardPartiallyBuiltSession(SESSION_ID).catch(() => {});
});
afterAll(async () => {
await server.stop().catch(() => {});
safeRmHomeTree(WORKSPACE);
});
async function wire(remote: boolean) {
const session = new Session({
id: SESSION_ID,
workingDir: remote ? REMOTE_PATH : WORKSPACE,
mode: 'claude',
name: remote ? 'remote session' : 'local session',
mux,
useMux: true,
...(remote ? { remote: REMOTE } : {}),
});
session.imageWatcherEnabled = true;
vi.spyOn(internals, 'isImageWatcherEnabled').mockResolvedValue(true);
await internals.registerSessionWithLayout(session);
await internals.setupSessionListeners(session);
}
it('does not watch a remote session working directory', async () => {
await wire(true);
expect(watchSession).not.toHaveBeenCalled();
});
it('still watches a local session working directory', async () => {
await wire(false);
expect(watchSession).toHaveBeenCalledWith(SESSION_ID, WORKSPACE);
});
});
describe('POST /api/sessions/:id/image-watcher', () => {
let harness: RouteTestHarness;
beforeEach(async () => {
harness = await createRouteTestHarness(registerSessionRoutes);
});
afterEach(async () => {
await harness.app.close();
});
const post = (enabled: boolean) =>
harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/image-watcher`,
payload: { enabled },
});
it('refuses to turn it on for a remote session, storing nothing', async () => {
const session = harness.ctx._session as unknown as Record<string, unknown>;
session.remote = REMOTE;
session.workingDir = REMOTE_PATH;
const res = await post(true);
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(watchSession).not.toHaveBeenCalled();
expect(session.imageWatcherEnabled).not.toBe(true);
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
});
it('still turns it off for a remote session', async () => {
(harness.ctx._session as unknown as Record<string, unknown>).remote = REMOTE;
const unwatchSession = vi.spyOn(imageWatcher, 'unwatchSession');
const res = await post(false);
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toMatchObject({ success: true, data: { imageWatcherEnabled: false } });
expect(unwatchSession).toHaveBeenCalledWith(harness.ctx._sessionId);
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
});
it('still turns it on for a local session', async () => {
const res = await post(true);
expect(res.statusCode).toBe(200);
expect(watchSession).toHaveBeenCalledWith(harness.ctx._sessionId, harness.ctx._session.workingDir);
});
});
describe('PUT /api/settings turning the app-wide watcher on', () => {
let harness: RouteTestHarness;
beforeEach(async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
});
afterEach(async () => {
await harness.app.close();
});
it('re-watches the local sessions that have it enabled, never a remote one', async () => {
const local = harness.ctx._session as unknown as Record<string, unknown>;
local.imageWatcherEnabled = true;
harness.ctx.sessions.set('remote-session', {
...harness.ctx._session,
id: 'remote-session',
workingDir: REMOTE_PATH,
imageWatcherEnabled: true,
remote: REMOTE,
} as unknown as typeof harness.ctx._session);
expect(imageWatcher.isRunning()).toBe(false);
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { imageWatcherEnabled: true },
});
expect(res.statusCode).toBe(200);
expect(imageWatcher.isRunning()).toBe(true);
expect(watchSession).toHaveBeenCalledTimes(1);
expect(watchSession).toHaveBeenCalledWith(harness.ctx._sessionId, harness.ctx._session.workingDir);
});
});
+1
View File
@@ -97,6 +97,7 @@ describe('ImageWatcher', () => {
watcher.watchSession('session-1', '/home/user/project');
const [, opts] = vi.mocked(watch).mock.calls.at(-1) as unknown as [string, { ignored: (p: string) => boolean }];
expect(opts.ignored('/home/user/project/.codeman-uploads/paste-1-ab.pdf')).toBe(true);
expect(opts.ignored('/home/user/project/.codeman-discord/1234-report.pdf')).toBe(true);
expect(opts.ignored('/home/user/project/.claude-images/paste-1-ab.png')).toBe(true);
expect(opts.ignored('/home/user/project/docs/report.pdf')).toBe(false);
});
+5
View File
@@ -531,3 +531,8 @@ describe('install.sh review fixes for #460', () => {
expect(drive('--tailscale')).toContain('host=127.0.0.1 pw=s3cret ack=0');
});
});
it('install.sh never contains the unauthenticated tunnel opt-in', () => {
expect(SOURCE).not.toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL');
expect(SOURCE).not.toContain('--allow-unauthenticated-tunnel');
});
+2 -2
View File
@@ -159,8 +159,8 @@ describe('readKeepWarmSettings', () => {
/**
* The service keeps what it reads until the next keep-warm settings change, so its reader must
* never hand back a fallback for a file it could not read: settings.json is rewritten in place
* by several routes, and defaults kept from a mid-write read would hold keep-warm off.
* never hand back a fallback for a file it could not read: defaults kept from a file that does
* not parse (a hand edit; writes are atomic) would hold keep-warm off after it is fixed.
*/
it('the service reader rejects an unreadable file instead of falling back, and the server wires it', async () => {
expect(await readKeepWarmSettingsOrThrow()).toEqual({ keepWarmDefault: false, maxHours: 2 });
+619
View File
@@ -0,0 +1,619 @@
// Port: none (pure logic and a real headless xterm in vm contexts: no browser,
// no server).
//
// Rows held under the on-screen keyboard (`keyboardHoldsRows`, per-device,
// default ON). Every PTY height change makes Claude Code clear the screen and
// reprint only the transcript tail that fits, and the keyboard opening and
// closing used to be such a change each time (measured on the dev tier: tmux
// went 62x38 -> 62x15 -> 62x38 with a SIGWINCH per transition). Now the rows
// captured at the show transition are held, columns still follow the box, and
// the over-tall screen is scrolled up instead (keyboardClipPx).
//
// Outside test/mobile/ on purpose: that suite is not in the CI gate.
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
import xtermHeadless from '@xterm/headless';
type HeadlessBuffer = {
viewportY: number;
baseY: number;
cursorY: number;
length: number;
getLine(y: number): { translateToString(trim?: boolean): string } | undefined;
};
const { Terminal: HeadlessTerminal } = xtermHeadless as unknown as {
Terminal: new (opts: Record<string, unknown>) => {
rows: number;
write(data: string, cb?: () => void): void;
buffer: { active: HeadlessBuffer };
};
};
const read = (file: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', file), 'utf8');
type Geometry = {
keyboardClipPx(input: Record<string, number | boolean>): number;
keyboardVisibleRowLimit(clipPx: number, viewHeight: number, cellHeight: number): number;
};
function loadGeometry(): Geometry {
const window: Record<string, unknown> = {};
vm.runInContext(read('constants.js'), vm.createContext({ console, window, URLSearchParams }));
return window.CodemanTerminalGeometry as Geometry;
}
describe('keyboardClipPx', () => {
const g = loadGeometry();
// 38 held rows of 17px in a 255px box (15 rows visible): 391px of overflow.
const base = { rows: 38, cellHeight: 17, viewHeight: 255, following: true, viewportY: 100, baseY: 100 };
it('puts the last content row at the bottom of the box', () => {
// Claude: composer and footer run to the last row, cursor in the composer.
expect(g.keyboardClipPx({ ...base, cursorRow: 34, lastContentRow: 37 })).toBe(38 * 17 - 255);
// Content ends at row 20: rows 6..20 are in view.
expect(g.keyboardClipPx({ ...base, cursorRow: 20, lastContentRow: 20 })).toBe(21 * 17 - 255);
});
it('counts a cursor just below the content (an empty prompt line)', () => {
expect(g.keyboardClipPx({ ...base, cursorRow: 22, lastContentRow: 20 })).toBe(23 * 17 - 255);
});
it('ignores a cursor parked far below the content (codex dialogs)', () => {
// Measured on the dev tier: codex's update dialog on rows 1-8 of 42, its
// cursor on row 38 of an otherwise blank screen. Anchoring to the cursor
// scrolled the dialog out of view; the top stays in view instead.
expect(g.keyboardClipPx({ ...base, cursorRow: 37, lastContentRow: 8 })).toBe(0);
});
it('shows the top for a prompt near the top (a fresh shell), and uses the cursor on a blank screen', () => {
expect(g.keyboardClipPx({ ...base, cursorRow: 1, lastContentRow: 1, viewportY: 0, baseY: 0 })).toBe(0);
expect(g.keyboardClipPx({ ...base, cursorRow: 37, lastContentRow: -1 })).toBe(38 * 17 - 255);
});
it('follows the scroll position while reading history, top of scrollback included', () => {
const max = 38 * 17 - 255;
expect(g.keyboardClipPx({ ...base, following: false, viewportY: 0, cursorRow: 37, lastContentRow: 37 })).toBe(0);
expect(g.keyboardClipPx({ ...base, following: false, viewportY: 50, cursorRow: 37, lastContentRow: 37 })).toBe(
Math.round(max / 2)
);
});
it('is 0 when nothing overflows or the terminal cannot be measured', () => {
expect(g.keyboardClipPx({ ...base, viewHeight: 38 * 17, cursorRow: 37, lastContentRow: 37 })).toBe(0);
expect(g.keyboardClipPx({ ...base, cellHeight: 0, cursorRow: 37, lastContentRow: 37 })).toBe(0);
});
it('bounds the overlay to the fully visible rows', () => {
expect(g.keyboardVisibleRowLimit(0, 255, 17)).toBe(15);
expect(g.keyboardVisibleRowLimit(38 * 17 - 255, 255, 17)).toBe(38);
expect(g.keyboardVisibleRowLimit(10, 255, 17)).toBe(15);
});
});
type Proto = Record<string, (this: Record<string, unknown>, ...args: unknown[]) => unknown>;
function loadTerminalProto(keyboard: Record<string, unknown> = { keyboardVisible: true }) {
const windowStub: Record<string, unknown> = { addEventListener: vi.fn(), removeEventListener: vi.fn() };
const context = vm.createContext({
console,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: vi.fn(),
URLSearchParams,
document: { addEventListener: vi.fn(), documentElement: { dataset: {} }, getElementById: () => null },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: windowStub,
MobileDetection: { isTouchDevice: () => true, isHandheldDevice: () => false, getDeviceType: () => 'mobile' },
KeyboardHandler: keyboard,
});
vm.runInContext(
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
context
);
const proto = (context as unknown as { __CodemanApp: { prototype: Proto } }).__CodemanApp.prototype;
return { proto, keyboard, windowStub };
}
describe('getTerminalDimensions holds the rows while the keyboard is up', () => {
const { proto, keyboard } = loadTerminalProto();
const fake = (held: number) => ({
_keyboardHeldRows: held,
_keyboardHeldRowsActive: proto._keyboardHeldRowsActive,
fitAddon: { proposeDimensions: () => ({ cols: 62, rows: 15 }) },
});
it('reports the held rows and the container columns', () => {
keyboard.keyboardVisible = true;
expect(proto.getTerminalDimensions.call(fake(38))).toEqual({ cols: 62, rows: 38 });
});
it('follows the container once the keyboard is gone, or with no hold', () => {
keyboard.keyboardVisible = false;
expect(proto.getTerminalDimensions.call(fake(38))).toEqual({ cols: 62, rows: 15 });
keyboard.keyboardVisible = true;
expect(proto.getTerminalDimensions.call(fake(0))).toEqual({ cols: 62, rows: 15 });
});
it('never holds fewer rows than the box offers', () => {
keyboard.keyboardVisible = true;
expect(proto.getTerminalDimensions.call(fake(12))).toEqual({ cols: 62, rows: 15 });
});
it('skips the raw fit while held, so xterm never visits the short size', () => {
keyboard.keyboardVisible = true;
const fit = vi.fn();
const resizes: unknown[] = [];
const self = {
...fake(38),
fitAddon: { fit, proposeDimensions: () => ({ cols: 62, rows: 15 }) },
terminal: { cols: 62, rows: 38, resize: (c: number, r: number) => resizes.push([c, r]) },
getTerminalDimensions: proto.getTerminalDimensions,
_resizeTerminalTo: proto._resizeTerminalTo,
_scheduleOverflowAffordanceSync: () => {},
};
expect(proto.syncTerminalGeometry.call(self)).toEqual({ cols: 62, rows: 38 });
expect(fit).not.toHaveBeenCalled();
expect(resizes).toEqual([]);
});
});
describe('only inline renderers hold, and a hold is released when the session stops qualifying', () => {
const { proto, windowStub } = loadTerminalProto();
windowStub.__codemanKeyboardHoldModes = ['claude'];
const appWith = (session: Record<string, unknown> | null, settings: Record<string, unknown> = {}) => ({
activeSessionId: session ? 's1' : null,
sessions: new Map(session ? [['s1', session]] : []),
loadAppSettingsFromStorage: () => settings,
_keyboardHoldEligible: proto._keyboardHoldEligible,
_releaseKeyboardHold: proto._releaseKeyboardHold,
});
const eligible = (session: Record<string, unknown> | null, settings?: Record<string, unknown>) =>
proto._keyboardHoldEligible.call(appWith(session, settings));
it('holds for an inline renderer (the keyboardHold capability), mode defaulting to claude', () => {
expect(eligible({ mode: 'claude' })).toBe(true);
expect(eligible({})).toBe(true);
});
it('never holds a full-height CLI, fullscreen Claude, a remote or docker pane, or with the setting off', () => {
expect(eligible({ mode: 'codex' })).toBe(false);
expect(eligible({ mode: 'shell' })).toBe(false);
expect(eligible({ mode: 'claude', cliMouseTracking: true })).toBe(false);
expect(eligible({ mode: 'claude', remote: { hostId: 'h' } })).toBe(false);
expect(eligible({ mode: 'claude', docker: { caseName: 'c' } })).toBe(false);
expect(eligible({ mode: 'claude' }, { keyboardHoldsRows: false })).toBe(false);
expect(eligible(null)).toBe(false);
});
it('holds nothing when the server sent no list', () => {
const saved = windowStub.__codemanKeyboardHoldModes;
delete windowStub.__codemanKeyboardHoldModes;
try {
expect(eligible({ mode: 'claude' })).toBe(false);
} finally {
windowStub.__codemanKeyboardHoldModes = saved;
}
});
const reconcile = (session: Record<string, unknown>, opts?: Record<string, unknown>) => {
const app = {
...appWith(session),
_keyboardHeldRows: 38,
_applyKeyboardClip: vi.fn(),
sendResize: vi.fn(() => Promise.resolve(true)),
};
proto._reconcileKeyboardHold.call(app, opts);
return app;
};
it('releases and resizes once the active session no longer qualifies', () => {
const app = reconcile({ mode: 'claude', cliMouseTracking: true });
expect(app._keyboardHeldRows).toBe(0);
expect(app._applyKeyboardClip).toHaveBeenCalled();
expect(app.sendResize).toHaveBeenCalledWith('s1');
});
it('leaves the resize to a caller that does its own (selectSession)', () => {
const app = reconcile({ mode: 'codex' }, { resize: false });
expect(app._keyboardHeldRows).toBe(0);
expect(app.sendResize).not.toHaveBeenCalled();
});
it('keeps a hold the session still qualifies for', () => {
const app = reconcile({ mode: 'claude' });
expect(app._keyboardHeldRows).toBe(38);
expect(app.sendResize).not.toHaveBeenCalled();
});
// session:updated is how a running Claude session reports entering its
// fullscreen renderer (cliMouseTracking), so the shipped handler must release.
const updatedApp = () => ({
...appWith({ mode: 'claude' }),
_keyboardHeldRows: 38,
_applyKeyboardClip: vi.fn(),
sendResize: vi.fn(() => Promise.resolve(true)),
_reconcileKeyboardHold: proto._reconcileKeyboardHold,
renderSessionTabs: vi.fn(),
updateCost: vi.fn(),
updateSubagentParentNames: vi.fn(),
});
it('a session:updated that turns mouse tracking on releases the hold and resizes', () => {
const app = updatedApp();
app.sendResize.mockClear();
proto._onSessionUpdated.call(app, { session: { id: 's1', mode: 'claude', cliMouseTracking: true } });
expect(app._keyboardHeldRows).toBe(0);
expect(app.sendResize).toHaveBeenCalledWith('s1');
});
it('a session:updated for another session, or one that still qualifies, keeps it', () => {
const app = updatedApp();
app.sessions.set('s2', { id: 's2', mode: 'claude' });
proto._onSessionUpdated.call(app, { session: { id: 's2', mode: 'claude', cliMouseTracking: true } });
proto._onSessionUpdated.call(app, { session: { id: 's1', mode: 'claude' } });
expect(app._keyboardHeldRows).toBe(38);
expect(app.sendResize).not.toHaveBeenCalled();
});
});
// The composer must stay on screen with the rows held. The COLUMNS ONLY rule in
// reconcilePtyGeometry exists because a screen taller than its box once hid the
// CLI's input line with nothing able to reach it; the hold makes the screen
// taller on purpose, and this pins that the clip puts the composer back in view.
// A real headless xterm holds Claude's inline frame at the held 38 rows, and the
// shipped getTerminalDimensions and _applyKeyboardClip run against it.
describe('the composer row stays visible with the rows held', () => {
const ROWS = 38;
const COLS = 62;
const CELL = 17;
const BOX = 15 * CELL; // the box left above the keyboard: 15 rows
async function heldApp(transcriptLines: number) {
const { proto, keyboard } = loadTerminalProto();
const term = new HeadlessTerminal({ cols: COLS, rows: ROWS, scrollback: 1000, allowProposedApi: true });
// Claude's inline renderer: the transcript, then its composer between two
// rules with the footer under it, the cursor parked after the prompt glyph.
const rule = '\u2500'.repeat(COLS);
const transcript = Array.from({ length: transcriptLines }, (_, i) => `KBTEST-${String(i + 1).padStart(3, '0')}`);
const frame = [...transcript, rule, '\u276f ', rule, ' ? for shortcuts'].join('\r\n');
await new Promise<void>((done) => term.write(`${frame}\x1b[2A\x1b[3G`, () => done()));
const style = { setProperty: vi.fn(), removeProperty: vi.fn() };
const container = { clientHeight: BOX, style };
const app: Record<string, unknown> = {
_keyboardHeldRows: 0,
fitAddon: { proposeDimensions: () => ({ cols: COLS, rows: 15 }) },
terminal: {
rows: term.rows,
buffer: term.buffer,
element: { parentElement: container },
_core: { _renderService: { dimensions: { css: { cell: { height: CELL } } } } },
},
_localEchoOverlay: { setRowLimit: vi.fn() },
_keyboardHeldRowsActive: proto._keyboardHeldRowsActive,
getTerminalDimensions: proto.getTerminalDimensions,
_applyKeyboardClip: proto._applyKeyboardClip,
};
const buffer = term.buffer.active;
const screenRow = (pattern: RegExp) => {
for (let row = 0; row < ROWS; row++) {
if (pattern.test(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '')) return row;
}
return -1;
};
return { app, keyboard, style, buffer, screenRow };
}
/** The --kb-clip offset last written to the container, 0 when removed or never set. */
function clipOf(style: { setProperty: ReturnType<typeof vi.fn>; removeProperty: ReturnType<typeof vi.fn> }) {
const set = style.setProperty.mock.calls.at(-1);
return set ? parseFloat(String(set[1])) : 0;
}
/** Is screen row `row` wholly inside the box once the screen is translated up by `clip` px? */
const fullyVisible = (row: number, clip: number) => row * CELL >= clip && (row + 1) * CELL <= clip + BOX;
it('a long transcript: the composer and the footer under it sit at the bottom of the box', async () => {
const { app, keyboard, style, buffer, screenRow } = await heldApp(100);
keyboard.keyboardVisible = true;
app._keyboardHeldRows = ROWS; // what KeyboardHandler captures on the show transition
// The PTY keeps its rows while the box shrinks to 15; columns still follow the box.
expect((app.getTerminalDimensions as () => unknown).call(app)).toEqual({ cols: COLS, rows: ROWS });
(app._applyKeyboardClip as () => void).call(app);
const composer = screenRow(/^\u276f/);
const footer = screenRow(/\? for shortcuts/);
expect([composer, footer, buffer.cursorY]).toEqual([35, 37, 35]);
const clip = clipOf(style);
expect(clip).toBe(ROWS * CELL - BOX);
expect(fullyVisible(composer, clip)).toBe(true);
expect(fullyVisible(footer, clip)).toBe(true);
// The local-echo overlay may draw down to the last visible row, the composer's included.
const limit = (app._localEchoOverlay as { setRowLimit: ReturnType<typeof vi.fn> }).setRowLimit.mock.calls.at(
-1
)![0];
expect(limit).toBe(ROWS);
});
it('a fresh session whose frame ends near the top: no offset, the composer already in view', async () => {
const { app, keyboard, style, screenRow } = await heldApp(5);
keyboard.keyboardVisible = true;
app._keyboardHeldRows = ROWS;
(app._applyKeyboardClip as () => void).call(app);
const composer = screenRow(/^\u276f/);
expect(composer).toBe(6);
expect(clipOf(style)).toBe(0);
expect(fullyVisible(composer, 0)).toBe(true);
// Rows 0..14 are the visible ones: the overlay must grow upward from row 14,
// not from row 37 under the keyboard.
expect((app._localEchoOverlay as { setRowLimit: ReturnType<typeof vi.fn> }).setRowLimit).toHaveBeenLastCalledWith(
15
);
});
it('keyboard closed: the offset and the overlay bound are both removed', async () => {
const { app, keyboard, style } = await heldApp(100);
keyboard.keyboardVisible = true;
app._keyboardHeldRows = ROWS;
(app._applyKeyboardClip as () => void).call(app);
expect(clipOf(style)).toBeGreaterThan(0);
keyboard.keyboardVisible = false;
const overlay = app._localEchoOverlay as { setRowLimit: ReturnType<typeof vi.fn> };
overlay.setRowLimit.mockClear();
style.setProperty.mockClear();
(app._applyKeyboardClip as () => void).call(app);
expect(style.removeProperty).toHaveBeenCalledWith('--kb-clip');
expect(style.setProperty).not.toHaveBeenCalled();
expect(overlay.setRowLimit).toHaveBeenCalledWith(null);
expect(app._keyboardClipPx).toBe(0);
});
});
type ManualTimers = { setTimeout(fn: () => void): number; clearTimeout(id: number): void; run(): void };
/** setTimeout/clearTimeout whose callbacks run only when the test says so. */
function manualTimers(): ManualTimers {
const pending = new Map<number, () => void>();
let next = 0;
return {
setTimeout(fn) {
pending.set(++next, fn);
return next;
},
clearTimeout(id) {
pending.delete(id);
},
run() {
for (let round = 0; pending.size && round < 10; round++) {
for (const [id, fn] of [...pending]) {
pending.delete(id);
fn();
}
}
},
};
}
function loadKeyboardHandler(enabled: boolean, opts: { app?: Record<string, unknown>; timers?: ManualTimers } = {}) {
const viewport = { width: 390, height: 664, offsetTop: 0, addEventListener: () => {}, removeEventListener: () => {} };
const layout = { height: 664 };
const app = opts.app ?? {
terminal: { rows: 38 },
_keyboardHeldRows: 0,
_keyboardHoldEligible: () => enabled,
_applyKeyboardClip: vi.fn(),
// The shipped release, so a hide or a shape change goes through the real helper.
_releaseKeyboardHold: loadTerminalProto().proto._releaseKeyboardHold,
relayoutMobileSubagentWindows: () => {},
};
const context = vm.createContext({
console,
app,
navigator: { userAgent: 'iPhone', maxTouchPoints: 5 },
window: {
get innerWidth() {
return viewport.width;
},
get innerHeight() {
return layout.height;
},
visualViewport: viewport,
addEventListener: () => {},
removeEventListener: () => {},
matchMedia: () => ({ matches: true }),
scrollTo: () => {},
},
document: {
body: { classList: { add: () => {}, remove: () => {} } },
documentElement: { style: { setProperty: () => {} } },
addEventListener: () => {},
removeEventListener: () => {},
getElementById: () => null,
querySelector: () => null,
},
setTimeout: opts.timers ? opts.timers.setTimeout : () => 1,
clearTimeout: opts.timers ? opts.timers.clearTimeout : () => {},
});
vm.runInContext(`${read('mobile-handlers.js')}\nglobalThis.__KH = KeyboardHandler;`, context);
const handler = (context as unknown as { __KH: { init(): void; handleViewportResize(): void } }).__KH;
handler.init();
const resizeTo = (width: number, height: number, layoutHeight = height) => {
viewport.width = width;
viewport.height = height;
layout.height = layoutHeight;
handler.handleViewportResize();
};
return { app: app as typeof app & Record<string, any>, handler, resizeTo };
}
describe('KeyboardHandler captures and releases the hold', () => {
it('captures the keyboard-free rows on show and releases them on hide', () => {
const { app, resizeTo } = loadKeyboardHandler(true);
resizeTo(390, 364, 664);
expect(app._keyboardHeldRows).toBe(38);
app._applyKeyboardClip.mockClear();
resizeTo(390, 664);
expect(app._keyboardHeldRows).toBe(0);
expect(app._applyKeyboardClip).toHaveBeenCalledTimes(1);
});
it('holds nothing for a session that does not qualify (setting off, a full-height CLI)', () => {
const { app, resizeTo } = loadKeyboardHandler(false);
resizeTo(390, 364, 664);
expect(app._keyboardHeldRows).toBe(0);
});
it('releases on a shape change with the keyboard still up', () => {
const { app, resizeTo } = loadKeyboardHandler(true);
resizeTo(390, 364, 664);
resizeTo(664, 90, 390); // rotated, keyboard still up
expect(app._keyboardHeldRows).toBe(0);
});
});
// A rotation (or fold) with the keyboard still up releases the hold, since its
// rows belong to the display that is gone. The keyboard is still visible, so
// throttledResize stands aside and only the viewport settle can refit: the
// release has to arm it, or xterm keeps the held rows with no clip and the
// composer sits under the keyboard until it closes. The real KeyboardHandler
// drives the real terminal-ui methods over a headless xterm, with the timers
// run by hand so the opening settle finishes before the rotation.
describe('a rotation with the keyboard up', () => {
const CELL = 17;
async function rotationSetup(eligible: boolean) {
const box = { cols: 44, rows: 38 };
const term = new HeadlessTerminal({ cols: box.cols, rows: box.rows, scrollback: 1000, allowProposedApi: true });
const rule = '\u2500'.repeat(40);
const transcript = Array.from({ length: 100 }, (_, i) => `KBTEST-${String(i + 1).padStart(3, '0')}`);
const frame = [...transcript, rule, '\u276f ', rule, ' ? for shortcuts'].join('\r\n');
await new Promise<void>((done) => term.write(`${frame}\x1b[2A\x1b[3G`, () => done()));
const style = { setProperty: vi.fn(), removeProperty: vi.fn() };
const container = {
get clientHeight() {
return box.rows * CELL;
},
style,
};
const sent: unknown[] = [];
const app: Record<string, any> = {
activeSessionId: 's1',
sessions: new Map([['s1', { id: 's1', mode: eligible ? 'claude' : 'codex' }]]),
_keyboardHeldRows: 0,
loadAppSettingsFromStorage: () => ({}),
relayoutMobileSubagentWindows: () => {},
isTerminalAtBottom: () => true,
_scheduleOverflowAffordanceSync: () => {},
_syncMobileHelperTextareaToCursor: () => {},
fitAddon: { proposeDimensions: () => ({ ...box }), fit: () => term.resize(box.cols, box.rows) },
terminal: {
get rows() {
return term.rows;
},
get cols() {
return term.cols;
},
get buffer() {
return term.buffer;
},
resize: (cols: number, rows: number) => term.resize(cols, rows),
scrollToBottom: () => {},
element: { parentElement: container },
_core: { _renderService: { dimensions: { css: { cell: { height: CELL } } } } },
},
sendResize: vi.fn(() => {
sent.push(app.getTerminalDimensions());
return Promise.resolve(true);
}),
};
const timers = manualTimers();
const { handler, resizeTo } = loadKeyboardHandler(eligible, { app, timers });
const { proto, windowStub } = loadTerminalProto(handler as unknown as Record<string, unknown>);
windowStub.__codemanKeyboardHoldModes = ['claude'];
// Every method not stubbed above is the shipped one.
Object.setPrototypeOf(app, proto);
const buffer = term.buffer.active;
const composerRow = () => {
for (let row = 0; row < term.rows; row++) {
if (/^\u276f/.test(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '')) return row;
}
return -1;
};
// Keyboard up over a portrait display, and the opening settle runs to the end.
box.rows = 15;
resizeTo(390, 364, 664);
timers.run();
return { app, box, term, style, sent, timers, resizeTo, composerRow };
}
it('refits to the new display and tells the PTY, so the composer is back in the box', async () => {
const { app, box, term, style, sent, timers, resizeTo, composerRow } = await rotationSetup(true);
// The hold is in place after the opening settle: 38 rows in a 15-row box, clipped.
expect([term.cols, term.rows, app._keyboardHeldRows]).toEqual([44, 38, 38]);
expect(app._keyboardClipPx).toBe((38 - 15) * CELL);
// Rotate to landscape with the keyboard still up: a 12-row, 76-column box.
box.cols = 76;
box.rows = 12;
app.sendResize.mockClear();
sent.length = 0;
style.removeProperty.mockClear();
resizeTo(664, 120, 390);
timers.run();
expect(app._keyboardHeldRows).toBe(0);
expect([term.cols, term.rows]).toEqual([76, 12]);
expect(app.sendResize).toHaveBeenCalledWith('s1');
expect(sent).toEqual([{ cols: 76, rows: 12 }]);
// No clip left, and with the rows fitted to the box the composer is inside it.
expect(style.removeProperty).toHaveBeenCalledWith('--kb-clip');
expect(app._keyboardClipPx).toBe(0);
// Shrinking the rows, xterm keeps the cursor row (the composer) on the last
// screen row; Claude redraws its footer under it after the SIGWINCH.
const composer = composerRow();
expect(composer).toBe(11);
expect((composer + 1) * CELL).toBeLessThanOrEqual(box.rows * CELL);
});
it('with no hold, a rotation arms nothing, as before', async () => {
const { app, box, term, timers, resizeTo } = await rotationSetup(false);
// Not eligible: the opening settle fitted the short box, as before the hold existed.
expect([term.rows, app._keyboardHeldRows]).toEqual([15, 0]);
box.cols = 76;
box.rows = 12;
app.sendResize.mockClear();
resizeTo(664, 120, 390);
timers.run();
expect(app.sendResize).not.toHaveBeenCalled();
expect([term.cols, term.rows]).toEqual([44, 15]);
});
});
describe('keyboardHoldsRows is a per-device setting', () => {
const settingsUi = read('settings-ui.js');
const html = read('index.html');
const schemas = readFileSync(resolve(import.meta.dirname, '../src/web/schemas.ts'), 'utf8');
it('is a display key, stripped from the PUT and absent from the strict schema', () => {
const start = settingsUi.indexOf('const displayKeys = new Set([');
expect(settingsUi.slice(start, settingsUi.indexOf('])', start))).toContain("'keyboardHoldsRows'");
expect(settingsUi).toContain('keyboardHoldsRows: _khr,');
expect(schemas).not.toContain('keyboardHoldsRows');
});
it('defaults ON and loads/saves by id', () => {
expect(html).toContain('id="appSettingsKeyboardHoldsRows"');
expect(settingsUi).toContain(
"document.getElementById('appSettingsKeyboardHoldsRows').checked = settings.keyboardHoldsRows !== false;"
);
expect(settingsUi).toContain("keyboardHoldsRows: document.getElementById('appSettingsKeyboardHoldsRows').checked,");
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* @fileoverview The "Keep Size Under Keyboard" App Settings row (#672) in Chinese (zh-CN):
* its label and description run through the real translator over the real index.html in
* JSDOM, read in Chinese with no English left beyond `Claude` and `Codex`, and each key is
* in the dictionary once (a repeated key in the object literal silently replaces the first).
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
type Api = { configure(o: Record<string, string>): void; start(): void };
/** What may stay Latin: the two CLI names. */
const leftover = (text: string) => text.replace(/\b(Claude|Codex)\b/g, '').match(/[A-Za-z]+/g) ?? [];
function rowStrings(doc: Document): { label: string; description: string } {
const row = doc.getElementById('appSettingsKeyboardHoldsRows')!.closest('.set-row')!;
return {
label: row.querySelector('.set-row-label')!.textContent!.trim(),
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
};
}
const source = new JSDOM(INDEX);
const english = rowStrings(source.window.document);
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
const chinese = rowStrings(dom.window.document);
afterAll(() => {
source.window.close();
dom.window.close();
});
describe('the Keep Size Under Keyboard row in zh-CN', () => {
it('finds the row (the check is not vacuous)', () => {
expect(english.label).toBe('Keep Size Under Keyboard');
expect(english.description).toMatch(/^For local Claude sessions, opening the on-screen keyboard/);
});
it('translates the label and the description, with no English left', () => {
expect(chinese).toEqual({
label: '键盘弹出时保持尺寸',
description:
'对于本地 Claude 会话,打开屏幕键盘时会保持会话尺寸并改为滚动视图,这样 Claude 不会重绘屏幕而丢失对话记录行。全屏界面(Codex、Claude 的全屏模式)仍像以前一样调整尺寸。',
});
for (const key of ['label', 'description'] as const) expect(leftover(chinese[key])).toEqual([]);
});
it('adds each key to the dictionary once', () => {
for (const text of [english.label, english.description]) {
expect(I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2).toBe(1);
}
});
});
+5 -6
View File
@@ -17,6 +17,7 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'fs';
import { join } from 'path';
import vm from 'node:vm';
const publicFile = (name: string) => readFileSync(join(__dirname, '..', 'src', 'web', 'public', name), 'utf-8');
@@ -99,8 +100,9 @@ describe('terminal link-provider regexes (shipped source)', () => {
// Excluding `&` truncated every real query string: a WordPress edit link
// resolved to `?post=1479` and opened the wrong page, and Claude Code's OAuth
// login URL (many `&` params) was not clickable at all.
const url = shippedPattern('urlPattern');
const strip = (u: string) => u.replace(/[.,;:!?)&]+$/, '');
const context = vm.createContext({ console, window: undefined });
vm.runInContext(`${CONSTANTS_SOURCE}\nglobalThis.findUrls = findTerminalUrls;`, context);
const findUrls = context.findUrls as (text: string) => Array<{ url: string }>;
const cases: Array<[string, string]> = [
[
'updated in place: https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit',
@@ -115,10 +117,7 @@ describe('terminal link-provider regexes (shipped source)', () => {
['trailing https://x.com/a?b=1& next', 'https://x.com/a?b=1'],
];
for (const [line, want] of cases) {
url.lastIndex = 0;
const m = url.exec(line);
expect(m, line).not.toBeNull();
expect(strip(m![0]), line).toBe(want);
expect(findUrls(line).map((match) => match.url), line).toEqual([want]);
}
});
+157
View File
@@ -0,0 +1,157 @@
/**
* @fileoverview The offline history reader against rollouts that change while
* it reads them: a running Codex appending to its own rollout (or a child's),
* an unrelated rollout rewritten mid-scan, and a selected rollout replaced
* outright. `node:fs/promises` is wrapped so a test can act at an exact point
* of the read (after the open, after a read call) without timing races.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { appendFile, mkdtemp, rename, rm, truncate, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
type Hook = (path: string, openCount: number, handle: import('node:fs/promises').FileHandle) => Promise<void> | void;
const hooks: { onOpen?: Hook } = {};
const opens = new Map<string, number>();
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs/promises')>();
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args);
const path = String(args[0]);
const count = (opens.get(path) ?? 0) + 1;
opens.set(path, count);
await hooks.onOpen?.(path, count, handle);
return handle;
},
};
});
const history = await import('../src/local-history.js');
const { assistantTurn, realMeta, settingsApplied, userTurn, writeRollout } =
await import('./fixtures/codex-rollout.js');
const ROOT = 'aaaaaaaa-1111-4111-8111-111111111111';
const CHILD = 'bbbbbbbb-1111-4111-8111-111111111111';
let dir: string;
const line = (value: unknown) => `${JSON.stringify(value)}\n`;
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'local-history-live-'));
opens.clear();
hooks.onOpen = undefined;
});
afterEach(async () => {
hooks.onOpen = undefined;
await rm(dir, { recursive: true, force: true });
});
describe('rollouts that change while they are read', () => {
it('reads a snapshot of a rollout a running Codex appends to after attribution', async () => {
const path = await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('question')]);
hooks.onOpen = async (opened, count) => {
// The second open is the transcript read; Codex writes a whole record and starts the next.
if (opened === path && count === 2) {
await appendFile(path, assistantTurn('answer').map(line).join(''));
await appendFile(path, '{"timestamp":"2026-10-24T01:00:02.000Z","type":"event_');
}
};
const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir });
expect(result.records.map((r) => r.message)).toEqual(['question', 'answer']);
// The record cut mid-write is reported, not parsed and not fatal.
expect(result.scan.partialRecords).toBe(1);
expect(result.scan.malformedRecords).toBe(0);
expect(result.complete).toBe(false);
});
it('reads a family whose child is still running', async () => {
await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('root question')]);
const child = await writeRollout(dir, 'rollout-child.jsonl', [
realMeta({ id: CHILD, parent: ROOT, mode: 'legacy' }),
...userTurn('child task'),
]);
hooks.onOpen = async (opened, count) => {
// Grows during the metadata scan and again before its transcript read.
if (opened === child)
await appendFile(child, assistantTurn(`child progress ${count}`, 'commentary').map(line).join(''));
};
const result = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir });
expect(result.records.map((r) => r.message)).toEqual([
'root question',
'child task',
'child progress 1',
'child progress 2',
]);
expect(result.complete).toBe(true);
});
it('counts an unrelated rollout that shrinks during its head read as unattributed', async () => {
await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('question')]);
const other = await writeRollout(dir, 'rollout-other.jsonl', [realMeta({ id: CHILD }), ...userTurn('other')]);
hooks.onOpen = async (opened, count, handle) => {
if (opened !== other || count !== 1) return;
const read = handle.read.bind(handle) as (...args: unknown[]) => Promise<unknown>;
(handle as unknown as { read: (...args: unknown[]) => Promise<unknown> }).read = async (...args) => {
const result = await read(...args);
await truncate(other, 10);
return result;
};
};
const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir });
expect(result.records.map((r) => r.message)).toEqual(['question']);
expect(result.scan.unattributedFiles).toBe(1);
expect(result.complete).toBe(false);
});
it('still refuses a selected rollout replaced by another file after attribution', async () => {
const path = await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('question')]);
hooks.onOpen = async (opened, count) => {
if (opened !== path || count !== 1) return;
const replacement = join(dir, 'replacement.tmp');
await writeFile(replacement, [realMeta({ id: ROOT }), ...userTurn('rewritten')].map(line).join(''));
await rename(replacement, path);
};
await expect(history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir })).rejects.toThrow(
/changed after attribution/
);
});
it('still refuses a selected rollout truncated after attribution', async () => {
const path = await writeRollout(dir, 'rollout-root.jsonl', [realMeta({ id: ROOT }), ...userTurn('question')]);
hooks.onOpen = async (opened, count) => {
if (opened === path && count === 2) await truncate(path, 100);
};
await expect(history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir })).rejects.toThrow(
/changed after attribution/
);
});
it('leaves a child readable, with an unproven cut, when its parent is replaced after attribution', async () => {
const parentSettings = [settingsApplied(), ...userTurn('parent second question')];
const parent = await writeRollout(dir, 'rollout-root.jsonl', [
realMeta({ id: ROOT }),
...userTurn('parent question'),
...parentSettings,
]);
const child = await writeRollout(dir, 'rollout-child.jsonl', [
realMeta({ id: CHILD, parent: ROOT }),
realMeta({ id: ROOT }),
...userTurn('parent question'),
...parentSettings,
settingsApplied(),
...userTurn('child task'),
]);
hooks.onOpen = async (opened, count) => {
// The child's transcript read comes before the parent's proof read: replace the parent in between.
if (opened !== child || count !== 2) return;
const replacement = join(dir, 'replacement.tmp');
await writeFile(replacement, [realMeta({ id: ROOT }), ...userTurn('rewritten')].map(line).join(''));
await rename(replacement, parent);
};
const result = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir });
expect(result.records.map((r) => r.message)).toEqual(['child task']);
expect(result.scan.unverifiedPrefixes).toBe(1);
expect(result.complete).toBe(false);
});
});
+853
View File
@@ -0,0 +1,853 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { chmod, mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as history from '../src/local-history.js';
import { Command } from 'commander';
import { registerHistoryCommands } from '../src/cli-history.js';
import {
assistantTurn,
developer,
injected,
realMeta,
settingsApplied,
taskStarted,
userTurn,
withOrdinals,
writeRollout,
} from './fixtures/codex-rollout.js';
const ROOT = 'aaaaaaaa-1111-4111-8111-111111111111';
const CHILD = 'bbbbbbbb-1111-4111-8111-111111111111';
const GRANDCHILD = 'cccccccc-1111-4111-8111-111111111111';
let dir: string;
const meta = (id: string, parent?: string) => ({
type: 'session_meta',
payload: {
id,
timestamp: '2026-10-24T01:00:00Z',
...(parent ? { source: { subagent: { thread_spawn: { parent_thread_id: parent } } } } : {}),
},
});
const row = (role: string, text: string, timestamp = '2026-10-24T01:00:00Z') => ({
type: 'response_item',
timestamp,
payload: { type: 'message', role, content: [{ type: role === 'user' ? 'input_text' : 'output_text', text }] },
});
async function fixture(id: string, records: unknown[], parent?: string) {
const path = join(dir, `rollout-${id}.jsonl`);
await writeFile(path, [meta(id, parent), ...records].map((r) => JSON.stringify(r)).join('\n') + '\n');
return path;
}
beforeEach(async () => {
dir = await mkdtemp(join(tmpdir(), 'local-history-'));
});
afterEach(async () => {
await rm(dir, { recursive: true, force: true });
});
describe('offline native history', () => {
it('reads a native transcript without a server and preserves identity and timestamps', async () => {
const file = await fixture(ROOT, [row('user', 'question'), row('assistant', 'answer')]);
const result = await history.readLocalHistory({ file });
expect(result.records.map((r) => r.message)).toEqual(['question', 'answer']);
expect(result.records[0]).toMatchObject({
sessionId: ROOT,
rootSessionId: ROOT,
timestamp: '2026-10-24T01:00:00Z',
});
expect(result.complete).toBe(true);
});
it('resolves metadata identities rather than filenames', async () => {
await fixture(ROOT, [row('assistant', 'yes')]);
const result = await history.readLocalHistory({ sessionId: ROOT.slice(0, 8), sessionsRoot: dir });
expect(result.records[0].sessionId).toBe(ROOT);
});
it('rejects ambiguous native prefixes', async () => {
await fixture(ROOT, [row('assistant', 'one')]);
await fixture('aaaaaaaa-2222-4222-8222-222222222222', [row('assistant', 'two')]);
await expect(history.readLocalHistory({ sessionId: 'aaaaaaaa', sessionsRoot: dir })).rejects.toThrow(/ambiguous/);
});
it('gathers transitive children and retains a whole topic/date matching turn', async () => {
await fixture(ROOT, [row('user', 'other'), row('assistant', 'unrelated')]);
await fixture(
CHILD,
[row('user', 'topic question'), row('assistant', 'answer after midnight', '2026-10-25T00:01:00Z')],
ROOT
);
await fixture(GRANDCHILD, [row('user', 'topic child'), row('assistant', 'child answer')], CHILD);
const result = await history.readLocalHistory({
rootSessionId: ROOT,
sessionsRoot: dir,
topic: 'TOPIC',
before: '2026-10-24',
});
expect(result.sessions).toHaveLength(3);
expect(result.records.map((r) => r.message)).toEqual([
'topic question',
'answer after midnight',
'topic child',
'child answer',
]);
expect(result.records.every((r) => r.rootSessionId === ROOT)).toBe(true);
});
it('resolves the real native root when querying a child by id', async () => {
await fixture(ROOT, [row('assistant', 'root')]);
await fixture(CHILD, [row('assistant', 'child')], ROOT);
await fixture(GRANDCHILD, [row('assistant', 'grandchild')], CHILD);
const result = await history.readLocalHistory({ sessionId: GRANDCHILD, sessionsRoot: dir });
expect(result.records[0].rootSessionId).toBe(ROOT);
expect(result.sessions[0]).toMatchObject({ parentSessionId: CHILD, rootSessionId: ROOT });
});
it('labels a file-only child root unknown rather than inventing an ancestor', async () => {
const file = await fixture(CHILD, [row('assistant', 'child')], ROOT);
const result = await history.readLocalHistory({ file });
expect(result.records[0].rootSessionId).toBeNull();
expect(result.sessions[0]).toMatchObject({ parentSessionId: ROOT, rootSessionId: null });
});
it('labels missing ancestors unknown and refuses cyclic ancestry', async () => {
await fixture(CHILD, [row('assistant', 'child')], ROOT);
expect(
(await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir })).records[0].rootSessionId
).toBeNull();
await fixture(ROOT, [row('assistant', 'root')], CHILD);
await expect(history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir })).rejects.toThrow(/cyclic/);
});
it('supports legacy shared session_id genealogy', async () => {
await fixture(ROOT, [row('assistant', 'root')]);
await writeFile(
join(dir, 'legacy.jsonl'),
[{ type: 'session_meta', payload: { id: CHILD, session_id: ROOT } }, row('assistant', 'legacy child')]
.map((r) => JSON.stringify(r))
.join('\n') + '\n'
);
expect((await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir })).sessions).toHaveLength(2);
});
it('parses modern events and removes adjacent duplicate representations without erasing repeated turns', async () => {
const file = await fixture(ROOT, [
row('user', 'question'),
{
type: 'event_msg',
payload: {
type: 'item_completed',
item: { type: 'UserMessage', content: [{ type: 'Text', text: 'question' }] },
},
},
row('assistant', 'answer'),
{
type: 'event_msg',
payload: {
type: 'item_completed',
item: { type: 'AgentMessage', phase: 'final_answer', content: [{ type: 'Text', text: 'answer' }] },
},
},
{ type: 'event_msg', payload: { type: 'user_message', message: 'question' } },
{ type: 'event_msg', payload: { type: 'agent_message', message: 'answer' } },
]);
expect((await history.readLocalHistory({ file })).records.map((r) => r.message)).toEqual([
'question',
'answer',
'question',
'answer',
]);
});
it('preserves genuinely repeated identical messages from the same representation', async () => {
const file = await fixture(ROOT, [row('user', 'repeat'), row('user', 'repeat'), row('assistant', 'answer')]);
expect((await history.readLocalHistory({ file })).records.map((r) => r.message)).toEqual([
'repeat',
'repeat',
'answer',
]);
});
it('labels unreadable metadata as incomplete rather than hiding possible children', async () => {
await fixture(ROOT, [row('assistant', 'root')]);
await writeFile(join(dir, 'unattributed.jsonl'), '{bad metadata}\n');
const result = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir });
expect(result.complete).toBe(false);
expect(result.scan.unattributedFiles).toBe(1);
});
it('refuses a later session_meta whose id is not a declared ancestor', async () => {
// ROOT declares no parent, fork source or shared root, so CHILD can only be another conversation.
const file = await fixture(ROOT, [row('user', 'root question'), meta(CHILD), row('assistant', 'child answer')]);
await expect(history.readLocalHistory({ file })).rejects.toThrow(/conflicting.*metadata/);
// A fork child is refused too when the inherited metadata names a thread it does not descend from.
const forked = await writeRollout(dir, 'unrelated-fork.jsonl', [
realMeta({ id: CHILD, parent: ROOT, mode: 'legacy' }),
realMeta({ id: GRANDCHILD, mode: 'legacy' }),
row('assistant', 'not ours'),
settingsApplied(),
row('assistant', 'child answer'),
]);
await expect(history.readLocalHistory({ file: forked })).rejects.toThrow(/conflicting.*metadata/);
});
it('accepts repeated metadata only when native identity and genealogy agree', async () => {
const file = await fixture(ROOT, [row('user', 'question'), meta(ROOT), row('assistant', 'answer')]);
expect((await history.readLocalHistory({ file })).records.map((record) => record.message)).toEqual([
'question',
'answer',
]);
await fixture(CHILD, [meta(CHILD, GRANDCHILD), row('assistant', 'child answer')], ROOT);
await expect(history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir })).rejects.toThrow(
/conflicting.*metadata/
);
});
it('labels skipped malformed and pending partial records', async () => {
const file = await fixture(ROOT, [row('assistant', 'complete')]);
const { appendFile } = await import('node:fs/promises');
await appendFile(file, 'not json\n{"type":');
const result = await history.readLocalHistory({ file });
expect(result.complete).toBe(false);
expect(result.scan.malformedRecords).toBe(1);
expect(result.scan.partialRecords).toBe(1);
expect(result.records[0].message).toBe('complete');
});
it('rejects invalid UTF8, nonregular inputs and symlinks', async () => {
const file = await fixture(ROOT, [row('assistant', 'answer')]);
const linked = join(dir, 'linked.jsonl');
await symlink(file, linked);
await expect(history.readLocalHistory({ file: linked })).rejects.toThrow(/regular|symlink/);
await expect(history.readLocalHistory({ file: dir })).rejects.toThrow(/regular/);
await writeFile(file, Buffer.from([0xff, 0x0a]));
await expect(history.readLocalHistory({ file })).rejects.toThrow(/UTF8/);
});
it('fails closed for byte budgets and preserves valid JSON for successful output', async () => {
const file = await fixture(ROOT, [row('assistant', '😀'.repeat(80))]);
await expect(history.readLocalHistory({ file, scanBytes: 32 })).rejects.toThrow(/scan.*budget/);
const result = await history.readLocalHistory({ file });
expect(() => history.formatLocalHistory(result, 'json', 40)).toThrow(/output.*budget/);
const output = history.formatLocalHistory(result, 'json', 8192);
expect(Buffer.byteLength(output)).toBeLessThanOrEqual(8192);
expect(JSON.parse(output).records[0].message).toBe('😀'.repeat(80));
});
it.each([{ after: '2026-02-30' }, { before: 'bad' }, { after: '2026-10-25', before: '2026-10-24' }, { topic: ' ' }])(
'rejects invalid filters %j',
async (filter) => {
const file = await fixture(ROOT, [row('assistant', 'answer')]);
await expect(history.readLocalHistory({ file, ...filter })).rejects.toThrow(/date|topic/);
}
);
it('rejects missing selectors, multiple selectors, empty matches and absent native ids', async () => {
const file = await fixture(ROOT, [row('assistant', 'answer')]);
await expect(history.readLocalHistory({})).rejects.toThrow(/exactly one/);
await expect(history.readLocalHistory({ file, sessionId: ROOT })).rejects.toThrow(/exactly one/);
await expect(history.readLocalHistory({ file, topic: 'absent' })).rejects.toThrow(/evidence/);
await expect(history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir })).rejects.toThrow(/missing/);
});
it('rejects exhausted directory traversal rather than returning an incomplete family', async () => {
await fixture(ROOT, [row('assistant', 'root')]);
await mkdir(join(dir, 'nested'));
await fixture(CHILD, [row('assistant', 'child')], ROOT);
await expect(history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir, maxEntries: 1 })).rejects.toThrow(
/scan.*budget/
);
});
it('executes the offline CLI with complete JSON and no internal parser fields', async () => {
const file = await fixture(ROOT, [row('assistant', 'offline')]);
let output = '';
const program = new Command().exitOverride().configureOutput({
writeOut: (text) => {
output += text;
},
writeErr: () => {},
});
registerHistoryCommands(program);
await program.parseAsync(['history', 'read', '--file', file, '--json'], { from: 'user' });
const result = JSON.parse(output);
expect(result.records[0].message).toBe('offline');
expect(result.records[0]).not.toHaveProperty('representation');
await expect(
program.parseAsync(['history', 'read', '--file', file, '--session-id', ROOT], { from: 'user' })
).rejects.toThrow(/exactly one/);
});
it('registers offline commands and reports invalid selectors without process exit', async () => {
const program = new Command().exitOverride();
registerHistoryCommands(program);
expect(program.commands.find((c) => c.name() === 'history')?.commands.map((c) => c.name())).toEqual(['read']);
});
});
const messages = (result: history.LocalHistoryResult) =>
result.records.map((r) => `${r.sessionId.slice(0, 1)}:${r.message}`);
/** A parent R, and a legacy child C whose file copies R's history before its own, as Codex writes it. */
async function legacyFamily(root: string, parentExtra: unknown[] = [], copiedExtra: unknown[] = []) {
await mkdir(root, { recursive: true });
const parentHistory = [
taskStarted(),
developer(),
injected(),
...userTurn('parent question'),
...assistantTurn('parent commentary', 'commentary'),
...assistantTurn('parent answer'),
];
await writeRollout(root, `rollout-${ROOT}.jsonl`, [
realMeta({ id: ROOT }),
...parentHistory,
...parentExtra,
...userTurn('parent later question'),
...assistantTurn('parent later answer'),
]);
return writeRollout(root, `rollout-${CHILD}.jsonl`, [
realMeta({ id: CHILD, parent: ROOT }),
realMeta({ id: ROOT }),
...parentHistory,
...copiedExtra,
settingsApplied(),
taskStarted(),
...userTurn('child task'),
...assistantTurn('child commentary', 'commentary'),
...assistantTurn('child answer'),
]);
}
describe('forked and spawned threads', () => {
it('drops the parent history a legacy fork copies, so the child reads as itself', async () => {
const childFile = await legacyFamily(dir);
const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir });
expect(messages(child)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']);
expect(child.complete).toBe(true);
expect(child.scan.inheritedPrefixes).toBe(1);
expect(child.sessions[0]).toMatchObject({ sessionId: CHILD, parentSessionId: ROOT, rootSessionId: ROOT });
// The whole family: every message once, the copy not repeated under the child.
const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir });
expect(messages(family)).toEqual([
'a:parent question',
'a:parent commentary',
'a:parent answer',
'a:parent later question',
'a:parent later answer',
'b:child task',
'b:child commentary',
'b:child answer',
]);
expect(family.complete).toBe(true);
// An explicit file needs no store: the copy ends at the one thread_settings_applied.
const file = await history.readLocalHistory({ file: childFile });
expect(messages(file)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']);
expect(file.complete).toBe(true);
});
it('finds the end of a legacy copy that carries the parent own thread_settings_applied events', async () => {
// The parent compacted (or checkpointed its first turn) before the fork, so the copy holds that event too.
const parentSettings = [
settingsApplied(),
...userTurn('parent second question'),
...assistantTurn('parent second answer'),
];
const childFile = await legacyFamily(dir, parentSettings, parentSettings);
const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir });
expect(messages(child)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']);
expect(child.complete).toBe(true);
// Without the parent's file the boundary cannot be proven: nothing of the parent is attributed to the child.
const alone = await history.readLocalHistory({ file: childFile });
expect(messages(alone)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']);
expect(alone.scan.unverifiedPrefixes).toBe(1);
expect(alone.complete).toBe(false);
});
it('keeps the child own later thread_settings_applied events on its side of the cut', async () => {
await legacyFamily(dir);
const path = join(dir, `rollout-${CHILD}.jsonl`);
const { appendFile } = await import('node:fs/promises');
await appendFile(
path,
[settingsApplied(), ...userTurn('child second task'), ...assistantTurn('child second answer')]
.map((line) => JSON.stringify(line))
.join('\n') + '\n'
);
const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir });
expect(messages(child)).toEqual([
'b:child task',
'b:child commentary',
'b:child answer',
'b:child second task',
'b:child second answer',
]);
expect(child.complete).toBe(true);
});
it('accepts the copied metadata of an ancestor that was itself a fork', async () => {
const file = await writeRollout(dir, 'grandchild.jsonl', [
realMeta({ id: GRANDCHILD, parent: CHILD, root: ROOT }),
realMeta({ id: CHILD, parent: ROOT }),
realMeta({ id: ROOT }),
...userTurn('root question'),
settingsApplied(),
...userTurn('child question'),
settingsApplied(),
...userTurn('grandchild task'),
...assistantTurn('grandchild answer'),
]);
const result = await history.readLocalHistory({ file });
expect(messages(result)).toEqual(['c:grandchild task', 'c:grandchild answer']);
expect(result.records[0].rootSessionId).toBe(ROOT);
});
it('marks a legacy copy that never reached its end marker incomplete and keeps none of it', async () => {
const file = await writeRollout(dir, 'cut.jsonl', [
realMeta({ id: CHILD, parent: ROOT }),
realMeta({ id: ROOT }),
...userTurn('parent question'),
]);
await expect(history.readLocalHistory({ file })).rejects.toThrow(/evidence/);
await writeRollout(dir, 'cut.jsonl', [
realMeta({ id: CHILD, parent: ROOT }),
...userTurn('child before copy'),
realMeta({ id: ROOT }),
...userTurn('parent question'),
]);
const result = await history.readLocalHistory({ file });
expect(messages(result)).toEqual(['b:child before copy']);
expect(result.scan.incompletePrefixes).toBe(1);
expect(result.complete).toBe(false);
});
it('drops the inherited records of a paginated sub-agent by ordinal, as Codex projects them', async () => {
const inherited = [
realMeta({ id: CHILD, parent: ROOT, mode: 'paginated', historyStart: 7 }),
realMeta({ id: ROOT, mode: 'paginated' }),
taskStarted(),
injected(),
...userTurn('parent question'),
...assistantTurn('parent answer').slice(1),
];
expect(inherited).toHaveLength(7);
const lines = withOrdinals([
...inherited,
settingsApplied(),
taskStarted(),
...assistantTurn('child commentary', 'commentary'),
...assistantTurn('child answer'),
]);
await writeRollout(
dir,
`rollout-${ROOT}.jsonl`,
withOrdinals([realMeta({ id: ROOT, mode: 'paginated' }), ...userTurn('parent question')])
);
await writeRollout(dir, `rollout-${CHILD}.jsonl`, lines);
const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir });
expect(messages(child)).toEqual(['b:child commentary', 'b:child answer']);
expect(child.records.map((r) => r.phase)).toEqual(['commentary', 'final_answer']);
expect(child.complete).toBe(true);
const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir });
expect(messages(family)).toEqual(['a:parent question', 'b:child commentary', 'b:child answer']);
});
it('reports a paginated copy that ends before its declared start ordinal', async () => {
const file = await writeRollout(
dir,
'paginated-cut.jsonl',
withOrdinals([
realMeta({ id: CHILD, parent: ROOT, mode: 'paginated', historyStart: 9 }),
realMeta({ id: ROOT, mode: 'paginated' }),
...userTurn('parent question'),
...assistantTurn('parent answer'),
])
);
await expect(history.readLocalHistory({ file })).rejects.toThrow(/evidence/);
// In a family read the parent still answers, and the cut child makes the result incomplete.
await writeRollout(
dir,
`rollout-${ROOT}.jsonl`,
withOrdinals([realMeta({ id: ROOT, mode: 'paginated' }), ...userTurn('parent question')])
);
const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir });
expect(messages(family)).toEqual(['a:parent question']);
expect(family.scan.incompletePrefixes).toBe(1);
expect(family.complete).toBe(false);
await rm(join(dir, `rollout-${ROOT}.jsonl`));
// A paginated record without an ordinal is skipped, visibly, as Codex's projection skips it.
const lines = withOrdinals([
realMeta({ id: CHILD, parent: ROOT, mode: 'paginated', historyStart: 2 }),
realMeta({ id: ROOT, mode: 'paginated' }),
settingsApplied(),
...assistantTurn('child answer'),
]);
await writeRollout(dir, 'paginated-cut.jsonl', [...lines, assistantTurn('stray')[0]]);
const result = await history.readLocalHistory({ file });
expect(messages(result)).toEqual(['b:child answer']);
expect(result.scan.malformedRecords).toBe(1);
expect(result.complete).toBe(false);
});
});
describe('one read per file, and a parent that cannot be read', () => {
const parentSettings = [
settingsApplied(),
...userTurn('parent second question'),
...assistantTurn('parent second answer'),
];
const sizes = async () => {
const { stat } = await import('node:fs/promises');
const parent = (await stat(join(dir, `rollout-${ROOT}.jsonl`))).size;
const child = (await stat(join(dir, `rollout-${CHILD}.jsonl`))).size;
return { parent, child };
};
it('charges the parent of a legacy copy to --scan-bytes once, even when it proves the boundary', async () => {
await legacyFamily(dir, parentSettings, parentSettings);
const { parent, child } = await sizes();
// A budget of exactly the two files: the family read selects the parent and proves the child's cut with it.
const family = await history.readLocalHistory({
rootSessionId: ROOT,
sessionsRoot: dir,
scanBytes: parent + child,
});
expect(family.scan.bytesRead).toBe(parent + child);
expect(family.complete).toBe(true);
expect(messages(family).filter((m) => m.startsWith('b:'))).toEqual([
'b:child task',
'b:child commentary',
'b:child answer',
]);
// One byte less fails closed rather than returning less.
await expect(
history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir, scanBytes: parent + child - 1 })
).rejects.toThrow(/scan byte budget/);
// A child read pays for the parent it proves against, once.
const alone = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir, scanBytes: parent + child });
expect(alone.scan.bytesRead).toBe(parent + child);
expect(alone.complete).toBe(true);
});
it('counts a parent record once when the parent is both selected and the proof of a child', async () => {
await legacyFamily(dir, parentSettings, parentSettings);
const { appendFile } = await import('node:fs/promises');
await appendFile(join(dir, `rollout-${ROOT}.jsonl`), '{not json\n');
const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir });
expect(family.scan.malformedRecords).toBe(1);
expect(family.scan.inheritedPrefixes).toBe(1);
});
it('falls back to the unproven cut when the parent file cannot be read, instead of failing the child', async () => {
await legacyFamily(dir, parentSettings, parentSettings);
const { appendFile } = await import('node:fs/promises');
await appendFile(join(dir, `rollout-${ROOT}.jsonl`), Buffer.from([0xff, 0x0a]));
const child = await history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir });
expect(messages(child)).toEqual(['b:child task', 'b:child commentary', 'b:child answer']);
expect(child.scan.unverifiedPrefixes).toBe(1);
expect(child.complete).toBe(false);
// The parent's own read still fails: its evidence is what is broken.
await expect(history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir })).rejects.toThrow(/invalid UTF8/);
});
it('still fails when the parent read runs out of budget', async () => {
await legacyFamily(dir, parentSettings, parentSettings);
const { child } = await sizes();
await expect(
history.readLocalHistory({ sessionId: CHILD, sessionsRoot: dir, scanBytes: child + 1 })
).rejects.toThrow(/scan byte budget/);
});
});
describe('compressed rollouts', () => {
const ZSTD = Buffer.from([0x28, 0xb5, 0x2f, 0xfd, 0x04, 0x00, 0x00, 0x00]);
const compressedName = (id: string) => `rollout-2026-10-24T01-00-00-${id}.jsonl.zst`;
it('counts a .jsonl.zst rollout, so a family with a compressed member is incomplete', async () => {
await legacyFamily(dir);
await mkdir(join(dir, '2026', '10', '24'), { recursive: true });
await writeFile(join(dir, '2026', '10', '24', compressedName(GRANDCHILD)), ZSTD);
const family = await history.readLocalHistory({ rootSessionId: ROOT, sessionsRoot: dir });
expect(family.scan.compressedFiles).toBe(1);
expect(family.scan.unattributedFiles).toBe(0);
expect(family.complete).toBe(false);
});
it('names the compressed rollout when the wanted thread is not otherwise found', async () => {
await fixture(ROOT, [row('assistant', 'root')]);
const path = join(dir, compressedName(CHILD));
await writeFile(path, ZSTD);
const error = await history.readLocalHistory({ sessionId: CHILD.slice(0, 8), sessionsRoot: dir }).catch((e) => e);
expect(error.message).toContain(path);
expect(error.message).toMatch(/compressed rollout/);
// A compressed file whose name does not carry the id is still reported, by count.
const other = await history.readLocalHistory({ sessionId: GRANDCHILD, sessionsRoot: dir }).catch((e) => e);
expect(other.message).toMatch(/1 compressed \.jsonl\.zst rollout\(s\) could not be read/);
});
it('skips a compressed rollout whose plain sibling exists, as Codex does', async () => {
const plain = await fixture(ROOT, [row('assistant', 'root')]);
await writeFile(`${plain}.zst`, ZSTD);
const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir });
expect(result.scan.compressedFiles).toBe(0);
expect(result.complete).toBe(true);
});
it('refuses a Zstandard file given to --file, and counts one in the store whatever its name', async () => {
const file = join(dir, 'renamed.jsonl');
await writeFile(file, ZSTD);
await expect(history.readLocalHistory({ file })).rejects.toThrow(/Zstandard-compressed/);
await fixture(ROOT, [row('assistant', 'root')]);
const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir });
expect(result.scan.unattributedFiles).toBe(1);
expect(result.complete).toBe(false);
});
});
describe('large and untidy stores', () => {
it('finds one id among thousands of rollouts with real-sized session_meta at the default budgets', async () => {
const count = 3500;
const base = realMeta({ id: ROOT });
const pad = JSON.stringify(base).length;
expect(pad).toBeGreaterThan(19 * 1024);
const writes: Array<Promise<void>> = [];
for (let i = 0; i < count; i++) {
const day = join(dir, '2026', String(1 + (i % 12)).padStart(2, '0'), String(1 + (i % 28)).padStart(2, '0'));
const id = `${i.toString(16).padStart(8, '0')}-2222-4222-8222-222222222222`;
writes.push(
mkdir(day, { recursive: true }).then(() =>
writeFile(
join(day, `rollout-${id}.jsonl`),
[realMeta({ id }), ...userTurn(`question ${i}`), ...assistantTurn(`answer ${i}`)]
.map((line) => JSON.stringify(line))
.join('\n') + '\n'
)
)
);
}
await Promise.all(writes);
const target = `${(count - 1).toString(16).padStart(8, '0')}-2222-4222-8222-222222222222`;
const result = await history.readLocalHistory({ sessionId: target, sessionsRoot: dir });
expect(messages(result)).toEqual([`0:question ${count - 1}`, `0:answer ${count - 1}`]);
expect(result.complete).toBe(true);
// Metadata is charged to its own budget; the transcript budget only paid for the one rollout.
expect(result.scan.metadataBytesRead).toBeGreaterThan(history.HISTORY_MAX_SCAN_BYTES / 4);
expect(result.scan.bytesRead).toBeLessThan(64 * 1024);
// The store-wide caps fail closed and are raisable.
await expect(
history.readLocalHistory({ sessionId: target, sessionsRoot: dir, metadataBytes: 1024 * 1024 })
).rejects.toThrow(/metadata.*--metadata-bytes/);
await expect(history.readLocalHistory({ sessionId: target, sessionsRoot: dir, maxEntries: 100 })).rejects.toThrow(
/entry budget.*--max-entries/
);
}, 60_000);
it('reads only the head of each rollout to attribute it, not its whole window', async () => {
await writeRollout(dir, 'rollout-big.jsonl', [
realMeta({ id: ROOT }),
...Array.from({ length: 400 }, (_, i) => assistantTurn(`${i} ${'x'.repeat(2048)}`)).flat(),
]);
const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir, scanBytes: 8 * 1024 * 1024 });
expect(result.records).toHaveLength(400);
// One 32 KiB step holds the ~20 KiB session_meta line; the 128 KiB window is only the ceiling.
expect(result.scan.metadataBytesRead).toBe(32 * 1024);
});
it.each([
['invalid UTF8 in its head', Buffer.from([0x7b, 0xff, 0xfe, 0x7d, 0x0a])],
[
'no session_meta within 128 KiB',
Buffer.from(`${JSON.stringify({ type: 'event_msg', payload: {} })}\n`.repeat(6000)),
],
])('counts one unrelated file with %s as unattributed instead of failing the lookup', async (_label, bytes) => {
await fixture(ROOT, [row('user', 'question'), row('assistant', 'answer')]);
await writeFile(join(dir, 'rollout-broken.jsonl'), bytes);
const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir });
expect(result.records.map((r) => r.message)).toEqual(['question', 'answer']);
expect(result.scan.unattributedFiles).toBe(1);
expect(result.complete).toBe(false);
// The same file named explicitly is still an error.
await expect(history.readLocalHistory({ file: join(dir, 'rollout-broken.jsonl') })).rejects.toThrow(/UTF8|128 KiB/);
});
it('counts an unreadable directory instead of failing the lookup', async () => {
await fixture(ROOT, [row('assistant', 'answer')]);
const locked = join(dir, 'locked');
await mkdir(locked);
await chmod(locked, 0);
try {
const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: dir });
expect(result.records[0].message).toBe('answer');
expect(result.scan.unreadableDirectories).toBe(process.getuid?.() === 0 ? 0 : 1);
} finally {
await chmod(locked, 0o700);
}
});
it('follows a sessions root that is itself a symlink, and still skips links below it', async () => {
const real = join(dir, 'real-sessions');
await mkdir(real);
await writeRollout(real, 'rollout-root.jsonl', [
realMeta({ id: ROOT }),
...userTurn('question'),
...assistantTurn('answer'),
]);
const linkedRoot = join(dir, 'sessions-link');
await symlink(real, linkedRoot);
const result = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: linkedRoot });
expect(result.records.map((r) => r.message)).toEqual(['question', 'answer']);
expect(result.complete).toBe(true);
await symlink(join(real, 'rollout-root.jsonl'), join(real, 'rollout-link.jsonl'));
const withLink = await history.readLocalHistory({ sessionId: ROOT, sessionsRoot: linkedRoot });
expect(withLink.scan.skippedSymlinks).toBe(1);
await expect(
history.readLocalHistory({ sessionId: ROOT, sessionsRoot: join(real, 'rollout-root.jsonl') })
).rejects.toThrow(/must be a directory/);
});
});
describe('records, filters and output', () => {
it('gives user records no phase and keeps the phase Codex recorded on assistant messages', async () => {
const file = await writeRollout(dir, 'phases.jsonl', [
realMeta({ id: ROOT }),
...userTurn('question'),
...assistantTurn('working', 'commentary'),
...assistantTurn('done'),
row('assistant', 'legacy answer without a phase'),
]);
const result = await history.readLocalHistory({ file });
expect(result.records.map((r) => [r.role, r.phase])).toEqual([
['user', null],
['assistant', 'commentary'],
['assistant', 'final_answer'],
['assistant', null],
]);
});
it('drops every injected-context wrapper but never a typed prompt that starts with #', async () => {
const file = await writeRollout(dir, 'injected.jsonl', [
realMeta({ id: ROOT }),
injected('environment_context'),
injected('recommended_plugins'),
injected('subagent_notification'),
{
type: 'response_item',
payload: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: '# AGENTS.md instructions for /x' }],
},
},
...userTurn('# a heading the user typed'),
...assistantTurn('answer'),
]);
const result = await history.readLocalHistory({ file });
expect(result.records.map((r) => r.message)).toEqual(['# a heading the user typed', 'answer']);
});
it('compares date bounds as UTC calendar dates', async () => {
const file = await writeRollout(dir, 'dates.jsonl', [
realMeta({ id: ROOT }),
// 23:30 at UTC-2 is 01:30 UTC the next day.
...userTurn('late evening west of UTC', '2026-10-24T23:30:00.000-02:00'),
...userTurn('plain UTC', '2026-10-24T12:00:00.000Z'),
]);
const after = await history.readLocalHistory({ file, after: '2026-10-25' });
expect(after.records.map((r) => r.message)).toEqual(['late evening west of UTC']);
const on = await history.readLocalHistory({ file, before: '2026-10-24' });
expect(on.records.map((r) => r.message)).toEqual(['plain UTC']);
});
it('reads the default store at $CODEX_HOME/sessions, else ~/.codex/sessions', async () => {
const saved = { CODEX_HOME: process.env.CODEX_HOME, HOME: process.env.HOME };
try {
process.env.CODEX_HOME = join(dir, 'codex-home');
await mkdir(join(dir, 'codex-home', 'sessions', '2026', '10', '24'), { recursive: true });
await writeRollout(join(dir, 'codex-home', 'sessions', '2026', '10', '24'), 'rollout-a.jsonl', [
realMeta({ id: ROOT }),
...userTurn('from CODEX_HOME'),
]);
expect((await history.readLocalHistory({ sessionId: ROOT })).records[0].message).toBe('from CODEX_HOME');
delete process.env.CODEX_HOME;
process.env.HOME = join(dir, 'home');
await mkdir(join(dir, 'home', '.codex', 'sessions'), { recursive: true });
await writeRollout(join(dir, 'home', '.codex', 'sessions'), 'rollout-b.jsonl', [
realMeta({ id: ROOT }),
...userTurn('from ~/.codex'),
]);
const result = await history.readLocalHistory({ sessionId: ROOT });
expect(result.records[0].message).toBe('from ~/.codex');
expect(result.sessions[0].path).toBe(join(dir, 'home', '.codex', 'sessions', 'rollout-b.jsonl'));
} finally {
for (const [key, value] of Object.entries(saved)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
});
});
describe('history CLI flags', () => {
const run = async (args: string[]) => {
let output = '';
const program = new Command().exitOverride().configureOutput({
writeOut: (text) => {
output += text;
},
writeErr: () => {},
});
registerHistoryCommands(program);
await program.parseAsync(['history', 'read', ...args], { from: 'user' });
return output;
};
it('prints text with --format text, a header line and one block per record', async () => {
const file = await writeRollout(dir, 'text.jsonl', [
realMeta({ id: ROOT }),
...userTurn('question'),
...assistantTurn('answer'),
]);
const output = await run(['--file', file, '--format', 'text']);
const blocks = output.trimEnd().split('\n\n');
expect(blocks[0]).toMatch(/^source=codex-jsonl complete=true scannedBytes=\d+ metadataBytes=\d+$/);
expect(blocks[1]).toBe(`[root=${ROOT} session=${ROOT} 2026-10-24T01:00:00.000Z user]\nquestion`);
expect(blocks[2]).toBe(`[root=${ROOT} session=${ROOT} 2026-10-24T01:00:01.000Z assistant final_answer]\nanswer`);
expect(() => JSON.parse(output)).toThrow();
});
it('refuses --json together with --format text, and accepts either alone', async () => {
const file = await writeRollout(dir, 'flags.jsonl', [realMeta({ id: ROOT }), ...assistantTurn('answer')]);
await expect(run(['--file', file, '--json', '--format', 'text'])).rejects.toThrow(
/--json conflicts with --format text/
);
expect(JSON.parse(await run(['--file', file, '--json'])).records[0].message).toBe('answer');
expect(JSON.parse(await run(['--file', file, '--json', '--format', 'json'])).records[0].message).toBe('answer');
expect(await run(['--file', file, '--format', 'text'])).toMatch(/^source=codex-jsonl/);
});
it('validates --max-bytes before any file is read', async () => {
// A missing store would be the first thing a read touches; an invalid --max-bytes must be refused first.
const missing = join(dir, 'no-such-store');
for (const value of ['0', String(history.HISTORY_MAX_OUTPUT_BYTES + 1), 'lots', '0x10', '1e3', ' 5'])
await expect(run(['--session-id', ROOT, '--sessions-root', missing, '--max-bytes', value])).rejects.toThrow(
/Invalid --max-bytes/
);
await expect(run(['--session-id', ROOT, '--sessions-root', missing, '--max-bytes', '65536'])).rejects.toThrow(
/Sessions root not found/
);
});
it('reads budget flags as plain decimal integers only', async () => {
await fixture(ROOT, [row('assistant', 'answer')]);
await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--scan-bytes', '0x100000'])).rejects.toThrow(
/Invalid --scan-bytes/
);
await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--max-entries', '1e3'])).rejects.toThrow(
/Invalid --max-entries/
);
expect(
JSON.parse(await run(['--session-id', ROOT, '--sessions-root', dir, '--scan-bytes', '1048576'])).complete
).toBe(true);
});
it('passes the store caps through and validates them', async () => {
await fixture(ROOT, [row('assistant', 'answer')]);
await fixture(CHILD, [row('assistant', 'child')], ROOT);
await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--max-entries', '1'])).rejects.toThrow(
/--max-entries/
);
expect(JSON.parse(await run(['--session-id', ROOT, '--sessions-root', dir, '--max-entries', '2'])).complete).toBe(
true
);
await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--metadata-bytes', '16'])).rejects.toThrow(
/--metadata-bytes/
);
await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--metadata-bytes', 'lots'])).rejects.toThrow(
/Invalid --metadata-bytes/
);
await expect(run(['--session-id', ROOT, '--sessions-root', dir, '--max-entries', '0'])).rejects.toThrow(
/Invalid --max-entries/
);
});
});
+11
View File
@@ -248,6 +248,17 @@ describe('protocol: both eras', () => {
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('CODEMAN_API_URL');
});
it('a refused connection with an empty AggregateError still names the error code', async () => {
const refused: ApiRequest = async () => {
throw Object.assign(new AggregateError([new Error('connect ECONNREFUSED ::1:9')], ''), { code: 'ECONNREFUSED' });
};
const { out, send } = server(config(), { request: refused, calls: [] });
await send({ jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'list_sessions', arguments: {} } });
const result = (out[0] as { result: { isError: boolean; content: Array<{ text: string }> } }).result;
expect(result.isError).toBe(true);
expect(result.content[0].text).toContain('Cannot reach Codeman at http://127.0.0.1:9: ECONNREFUSED.');
});
});
describe('tool definitions', () => {
+9 -1
View File
@@ -38,7 +38,15 @@ const MOBILE_VISIBLE_ALLOWLIST = new Set<string>([]);
// that removes a hide rule fails loudly (not silently). The attachments button is
// NOT here: it's opt-in (default-hidden everywhere via its own --hidden marker), so
// it's excluded from the default-visible enumeration rather than mobile-hidden.
const KNOWN_PHONE_HIDDEN = ['btn-settings', 'btn-lifecycle-log', 'btn-session-manager', 'btn-file-viewer'];
// The edit history button is opt-in the same way, but it IS here: its panel is
// desktop-sized, so it stays off phones even when the setting is on.
const KNOWN_PHONE_HIDDEN = [
'btn-settings',
'btn-lifecycle-log',
'btn-session-manager',
'btn-file-viewer',
'btn-edit-history',
];
function attrOf(openTag: string, name: string): string {
const m = openTag.match(new RegExp(`${name}="([^"]*)"`));
+10
View File
@@ -725,6 +725,16 @@ describe('mobile shell Paste', () => {
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x1b[200~echo safe\rrm -rf ~/x\x1b[201~');
});
it('drops a paste marker hidden inside another one', () => {
const { app, input, send } = openShellPaste();
// One pass over this leaves `ESC[201~` between `ok` and the command.
input.value = 'ok\x1b[20\x1b[201~1~\nrm -rf ~/x';
send.click();
expect(app._sendInputAsync).toHaveBeenCalledWith('session-1', '\x1b[200~ok\rrm -rf ~/x\x1b[201~');
});
it('refuses a paste longer than one input frame and keeps the text for trimming', () => {
const { app, bar, document, input, send } = openShellPaste();
input.value = 'x'.repeat(bar._composerMaxLength + 1);
+11
View File
@@ -162,6 +162,17 @@ describe('phone tab tap zones', () => {
expect(phoneDeclarations('.session-tab .tab-gear').display).toBe('none');
});
it('with the opt-in "+" switched on, the wider reserve clears all three icons', () => {
// showTabNewButton adds a third icon to the active phone tab (html.tabs-show-new)
// and a larger reserve to go with it; the same arithmetic must still hold.
const plus = phoneDeclarations('html.tabs-show-new .session-tab.active .tab-new');
const wide = len(phoneDeclarations('html.tabs-show-new .session-tab.active .tab-name')['min-width']);
expect(len(plus.width)).toBeGreaterThanOrEqual(28);
expect(wide).toBeGreaterThanOrEqual(requiredReserve() + len(plus.width) + ROUNDING_ALLOWANCE);
expect(wide).toBeLessThanOrEqual(len(nameShared['max-width']));
expect(phoneDeclarations('.session-tab .tab-new').display).toBe('none');
});
it('the pop-out icon stays out of the cluster on handhelds', () => {
// `icons` above counts gear + close only. If detach ever became visible on a
// phone the reserve would be ~30px short and the centre would walk back.
+93
View File
@@ -323,6 +323,99 @@ describe('Virtual Keyboard', () => {
expect(mainPadding).toBe('');
});
it('holds the PTY rows under the keyboard and keeps the Claude composer inside the visible box', async () => {
// #672: a keyboard-driven height change makes Claude's inline renderer reprint
// only the tail that fits and lose the rest, so the rows are held instead, and
// --kb-clip has to keep the composer in view of a screen now taller than its box.
const modes = await page.evaluate(() => (window as any).__codemanKeyboardHoldModes);
expect(modes).toEqual(['claude']);
const rowsBefore = await page.evaluate(async () => {
(window as any).__resizes = [];
app.activeSessionId = 'mobile-keyboard-hold-test';
app.sessions.set('mobile-keyboard-hold-test', {
id: 'mobile-keyboard-hold-test',
mode: 'claude',
status: 'running',
});
app.hideWelcome();
// Fit the terminal to its box first, as a selected session is: these are
// the keyboard-free rows the hold must keep.
app.syncTerminalGeometry();
app.sendResize = (id: string) => {
(window as any).__resizes.push({ id, ...app.getTerminalDimensions() });
return Promise.resolve(true);
};
app.terminal.reset();
const cols = app.terminal.cols;
const rule = '\u2500'.repeat(cols);
const transcript = Array.from({ length: 100 }, (_, i) => `KBTEST-${String(i + 1).padStart(3, '0')}`);
const frame = [...transcript, rule, '\u276f ', rule, ' ? for shortcuts'].join('\r\n');
await new Promise<void>((resolve) => app.terminal.write(`${frame}\x1b[2A\x1b[3G`, resolve));
return app.terminal.rows;
});
await showKeyboard(page, KEYBOARD.TYPICAL_IOS_HEIGHT);
await page.waitForTimeout(800);
const held = await page.evaluate(() => {
const term = app.terminal;
const buffer = term.buffer.active;
const rowOf = (pattern: RegExp) => {
for (let row = 0; row < term.rows; row++) {
if (pattern.test(buffer.getLine(buffer.baseY + row)?.translateToString(true) ?? '')) return row;
}
return -1;
};
const container = document.getElementById('terminalContainer') as HTMLElement;
const box = container.getBoundingClientRect();
const screen = (container.querySelector('.xterm-screen') as HTMLElement).getBoundingClientRect();
const cell = term._core._renderService.dimensions.css.cell.height;
const composer = rowOf(/^\u276f/);
const footer = rowOf(/\? for shortcuts/);
return {
rows: term.rows,
heldRows: app._keyboardHeldRows,
clip: container.style.getPropertyValue('--kb-clip'),
composer,
footer,
// Where the rows land on the page with the clip applied.
composerTop: screen.top + composer * cell,
footerBottom: screen.top + (footer + 1) * cell,
// Where they would land without it: below the box, i.e. under the keyboard.
footerBottomUnclipped: box.top + (footer + 1) * cell,
boxTop: box.top,
boxBottom: box.bottom,
viewportBottom:
(window.visualViewport?.offsetTop ?? 0) + (window.visualViewport?.height ?? window.innerHeight),
resizes: (window as any).__resizes,
};
});
expect(held.rows).toBe(rowsBefore);
expect(held.heldRows).toBe(rowsBefore);
// Every resize sent while the keyboard was up carried the held rows.
for (const r of held.resizes) expect(r.rows).toBe(rowsBefore);
expect([held.composer, held.footer]).toEqual([rowsBefore - 3, rowsBefore - 1]);
// Not vacuous: unclipped, the footer would sit below the box.
expect(held.footerBottomUnclipped).toBeGreaterThan(held.boxBottom + 1);
expect(parseFloat(held.clip)).toBeGreaterThan(0);
expect(held.composerTop).toBeGreaterThanOrEqual(held.boxTop - 1);
expect(held.footerBottom).toBeLessThanOrEqual(held.boxBottom + 1);
expect(held.boxBottom).toBeLessThanOrEqual(held.viewportBottom + 1);
await hideKeyboard(page);
await page.waitForTimeout(800);
// Released: no hold, no offset, and the rows follow the box again (the box
// after a keyboard cycle need not be the one before it, so compare with the fit).
const released = await page.evaluate(() => ({
heldRows: app._keyboardHeldRows,
clip: (document.getElementById('terminalContainer') as HTMLElement).style.getPropertyValue('--kb-clip'),
rows: app.terminal.rows,
fits: window.CodemanTerminalGeometry.clampTerminalDimensions(app.fitAddon.proposeDimensions()).rows,
}));
expect(released).toEqual({ heldRows: 0, clip: '', rows: released.fits, fits: released.fits });
});
it('coalesces keyboard animation frames into one final terminal fit', async () => {
const result = await page.evaluate(async () => {
// `app.terminal` and `app.fitAddon` are only assigned by initTerminal(),
+2
View File
@@ -64,6 +64,7 @@ export function createMockRouteContext(options?: {
forgetCaseDefaultGroup: vi.fn(async () => {}),
},
cleanupSession: vi.fn(async () => {}),
forgetEditHistory: vi.fn(),
setupSessionListeners: vi.fn(async () => {}),
persistSessionState: vi.fn(),
persistSessionStateNow: vi.fn(),
@@ -169,6 +170,7 @@ export function createMockRouteContext(options?: {
scheduledRuns: new Map(),
teamWatcher: { getTeams: vi.fn(() => []), getTeamTasks: vi.fn(() => []), hasActiveTeammates: vi.fn(() => false) },
tunnelManager: null,
allowUnauthenticatedTunnel: false,
pushStore: null,
startScheduledRun: vi.fn(),
stopScheduledRun: vi.fn(),
+55 -3
View File
@@ -11,6 +11,7 @@ import type {
SessionStatus,
SessionRemote,
} from '../../src/types.js';
import type { GitFetchStatus } from '../../src/git-fetch.js';
/**
* Enhanced mock session for testing RespawnController.
@@ -69,6 +70,22 @@ export class MockSession extends EventEmitter {
this.keepWarm = status;
return changed;
}
/**
* Mirrors the Git fetch interval surface on Session (src/git-fetch.ts): the case's own override, `undefined`
* for either half meaning the app-wide setting applies, and what the scheduler last reported.
*/
gitFetchEnabled: boolean | undefined = undefined;
gitFetchIntervalSeconds: number | undefined = undefined;
setGitFetchSettings(enabled: boolean | undefined, intervalSeconds: number | undefined): void {
this.gitFetchEnabled = enabled;
this.gitFetchIntervalSeconds = intervalSeconds;
}
gitFetch: GitFetchStatus | null = null;
setGitFetchStatus(status: GitFetchStatus): boolean {
const changed = JSON.stringify(status) !== JSON.stringify(this.gitFetch);
this.gitFetch = status;
return changed;
}
private _activeChildProcesses: { pid: number; command: string }[] = [];
ralphTracker: null = null;
writeBuffer: string[] = [];
@@ -81,6 +98,13 @@ export class MockSession extends EventEmitter {
claudeSessionIdIsFirstHand: boolean = false;
/** Mirrors Session.claudeSessionChain — oldest first, current last. */
claudeSessionChain: string[] = [];
/** The per-session CLAUDE_CONFIG_DIR override that Session.claudeConfigDir() reads. */
claudeConfigDirOverride: string | undefined = undefined;
/** Mirrors Session.claudeConfigDir: the override, trimmed; undefined when unset. */
claudeConfigDir(): string | undefined {
return this.claudeConfigDirOverride?.trim() || undefined;
}
/** Mirrors Session.adoptClaudeSessionId, including the first-hand chain rule. */
adoptClaudeSessionId(newId: string, options: { firstHand?: boolean } = {}): void {
@@ -107,6 +131,26 @@ export class MockSession extends EventEmitter {
/** Mirrors Session.trackUserInput (the send-key route feeds it around the write path). */
trackUserInput(_data: string): void {}
async performAuxiliaryInput(operation: () => Promise<void>): Promise<void> {
await operation();
}
/** Mirrors Session.guardedInputHeld; a route test sets it to stand in for a guarded submit's lease. */
guardedInputHeld = false;
/** Input held behind that lease, in arrival order (see releaseGuardedLease). */
heldGuardedInput: Array<() => void | Promise<unknown>> = [];
/** Mirrors Session.holdForGuardedLease, without the cap (a test replaces it to model a full queue). */
holdForGuardedLease(run: () => void | Promise<unknown>, _bytes = 0, _data?: string): 'free' | 'held' | 'full' {
if (!this.guardedInputHeld) return 'free';
this.heldGuardedInput.push(run);
return 'held';
}
/** Ends the stand-in lease and runs the held input in arrival order, as Session does. */
releaseGuardedLease(): void {
this.guardedInputHeld = false;
for (const run of this.heldGuardedInput.splice(0)) void run();
}
private _muxName: string | null = null;
constructor(id: string = 'mock-session-id') {
@@ -130,9 +174,14 @@ export class MockSession extends EventEmitter {
/** Write via mux (used by respawn controller) */
async writeViaMux(data: string): Promise<boolean> {
if (this.failWrites) return false;
this.writeBuffer.push(data);
return true;
const write = (): boolean => {
if (this.failWrites) return false;
this.writeBuffer.push(data);
return true;
};
// Like Session.writeViaMux, it waits its turn behind a stand-in guarded lease.
if (!this.guardedInputHeld) return write();
return new Promise<boolean>((resolve) => this.heldGuardedInput.push(() => resolve(write())));
}
/**
@@ -412,6 +461,9 @@ export class MockSession extends EventEmitter {
}
);
restartCli = vi.fn(async () => true);
/** Mirrors Session.envOverrideValue; no override map here, so the custom-model
* real-home lookup defaults to "session says nothing" (overridable per test). */
envOverrideValue = vi.fn((_key: string): string | undefined => undefined);
getCustomModelForPersist = vi.fn(() => this._mockCustomModel);
/** Mirrors Session.noteReportedModel: the statusline route records the CLI's model. */
reportedModel: { model: string; source: 'statusline' | 'screen' } | null = null;
+617
View File
@@ -0,0 +1,617 @@
/**
* @fileoverview A relative path printed in the terminal resolves against the
* directory the pane is actually in, not only the session's `workingDir`.
*
* `workingDir` is recorded once at create time and never follows a `cd`, so in
* a shell session that moved to a subdirectory or a sibling repo every relative
* path resolved against the wrong root, and the preview reported "File not
* found" for a file sitting right there.
*
* Pinned here:
* 1. Parsing: the pane-exit watcher's batched `list-panes -a` read already
* carries `#{pane_current_path}`; `derivePaneCurrentPaths()` turns it into
* one directory per session (single-pane sessions only, absolute paths
* only).
* 2. The manager keeps the last authoritative answer, so a failed read or a
* blank field never blanks a known directory, while a read that lists the
* session as split, or not at all, clears it.
* 3. Scoping: remote and Docker sessions never carry one (their local pane
* runs the ssh / docker-exec CLIENT), and neither does a direct-PTY or a
* rediscovered session.
* 4. Wiring: the reading reaches `toState()` through the existing
* `paneExitsUpdated` event, with no new tmux call and no new SSE event.
* 5. Resolution order in the browser: live cwd, then `workingDir`. The
* resolved path still goes through the same preview routing, so a target
* outside the workspace takes the attachment route like any other.
* 6. Spelling: tmux reports the RESOLVED directory, so a workspace reached
* through a symlink has its reading written back in `workingDir`'s
* spelling, or every relative link would route as out-of-workspace.
*
* Port: ephemeral
*/
import { mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, symlinkSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import vm from 'node:vm';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { Session, rebasePath } from '../src/session.js';
import { WebServer } from '../src/web/server.js';
import type { SessionDocker, SessionRemote } from '../src/types.js';
import type { MuxSession, TerminalMultiplexer } from '../src/mux-interface.js';
import { TmuxManager, derivePaneCurrentPaths, parsePaneRows, type PaneRow } from '../src/tmux-manager.js';
const remote: SessionRemote = { hostId: 'h1', label: 'box', host: 'box', user: 'dev' } as SessionRemote;
const docker: SessionDocker = { hostId: 'd1', label: 'ctr', containerName: 'ctr' } as SessionDocker;
describe('derivePaneCurrentPaths', () => {
it("maps each single-pane session to its pane's directory", () => {
const rows = parsePaneRows(['codeman-a|100|0|||/work/proj/sub', 'codeman-b|200|0|||/srv/app'].join('\n'));
const paths = derivePaneCurrentPaths(rows);
expect(paths.get('codeman-a')).toBe('/work/proj/sub');
expect(paths.get('codeman-b')).toBe('/srv/app');
expect(paths.size).toBe(2);
});
it('keeps a separator that belongs to the directory name', () => {
const paths = derivePaneCurrentPaths(parsePaneRows('codeman-a|100|0|||/home/u/weird|dir/sub'));
expect(paths.get('codeman-a')).toBe('/home/u/weird|dir/sub');
});
it('answers nothing for a session split into several panes', () => {
// Codeman creates one pane per session; which of a hand-split session's
// panes "the" directory belongs to is a guess, so none is made.
const rows = parsePaneRows(['codeman-a|100|0|||/one', 'codeman-a|101|0|||/two'].join('\n'));
expect(derivePaneCurrentPaths(rows).has('codeman-a')).toBe(false);
});
it('drops a missing or relative directory', () => {
const rows = parsePaneRows(['codeman-a|100|0|||', 'codeman-b|200|0|||rel/ative'].join('\n'));
expect(derivePaneCurrentPaths(rows).size).toBe(0);
});
it('carries the previous directory forward for a blank field on a single-pane session', () => {
// A blank `#{pane_current_path}` is a tmux that could not read the process
// cwd that tick, not a pane that has no directory.
const previous = new Map([['codeman-a', '/work/proj/sub']]);
const paths = derivePaneCurrentPaths(parsePaneRows('codeman-a|100|0|||'), previous);
expect(paths.get('codeman-a')).toBe('/work/proj/sub');
});
it('carries nothing forward for a split session, an unlisted one or a relative field', () => {
// Those are authoritative absences: the read answered, and its answer is
// "no single directory".
const previous = new Map([
['codeman-split', '/one'],
['codeman-gone', '/two'],
['codeman-rel', '/three'],
]);
const rows = parsePaneRows(
['codeman-split|100|0|||', 'codeman-split|101|0|||', 'codeman-rel|300|0|||rel/ative'].join('\n')
);
expect(derivePaneCurrentPaths(rows, previous).size).toBe(0);
});
});
describe('TmuxManager pane directory bookkeeping', () => {
class TestManager extends TmuxManager {
rows: PaneRow[] = [];
protected override async readPaneRows(): Promise<PaneRow[]> {
return this.rows;
}
}
const localSession = (sessionId: string): MuxSession =>
({
sessionId,
muxName: `codeman-${sessionId}`,
pid: 100,
createdAt: 0,
workingDir: '/work/proj',
mode: 'shell',
attached: true,
}) as MuxSession;
it('records the directory from the same read the pane-exit watcher makes', async () => {
const manager = new TestManager();
manager.registerSession(localSession('s1'));
manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub');
await manager.refreshPaneExits();
expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub');
});
it('keeps the last known directory when a read comes back empty', async () => {
const manager = new TestManager();
manager.registerSession(localSession('s1'));
manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub');
await manager.refreshPaneExits();
manager.rows = [];
await manager.refreshPaneExits();
expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub');
});
it('keeps the last directory across a blank field on a single-pane read', async () => {
const manager = new TestManager();
manager.registerSession(localSession('s1'));
manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub');
await manager.refreshPaneExits();
manager.rows = parsePaneRows('codeman-s1|100|0|||');
await manager.refreshPaneExits();
expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub');
});
it('forgets the directory once the session is split into several panes', async () => {
const manager = new TestManager();
manager.registerSession(localSession('s1'));
manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub');
await manager.refreshPaneExits();
manager.rows = parsePaneRows(['codeman-s1|100|0|||/work/proj/sub', 'codeman-s1|101|0|||/work/proj'].join('\n'));
await manager.refreshPaneExits();
expect(manager.getPaneCurrentPath('codeman-s1')).toBeUndefined();
});
it('records the directory even when a respawn races the read', async () => {
// A respawn bumps the generation and discards the read's EXIT observation;
// the directory the pane was in at read time is still the best answer.
class RacingManager extends TestManager {
protected override async readPaneRows(): Promise<PaneRow[]> {
this.clearPaneExit('codeman-s1');
return this.rows;
}
}
const manager = new RacingManager();
manager.registerSession(localSession('s1'));
manager.rows = parsePaneRows('codeman-s1|100|1|0||/work/proj/sub');
await manager.refreshPaneExits();
expect(manager.getPaneCurrentPath('codeman-s1')).toBe('/work/proj/sub');
expect(manager.getPaneExit('codeman-s1')).toBeUndefined();
});
it('makes no read, and keeps no directory, when only remote or docker sessions exist', async () => {
const manager = new TestManager();
const read = vi.spyOn(manager as unknown as { readPaneRows: () => Promise<PaneRow[]> }, 'readPaneRows');
manager.registerSession({ ...localSession('s1'), remote } as MuxSession);
manager.registerSession({ ...localSession('s2'), docker } as MuxSession);
manager.rows = parsePaneRows('codeman-s1|100|0|||/tmp');
await manager.refreshPaneExits();
expect(read).not.toHaveBeenCalled();
expect(manager.getPaneCurrentPath('codeman-s1')).toBeUndefined();
});
it('forgets a session that an authoritative read no longer lists', async () => {
const manager = new TestManager();
manager.registerSession(localSession('s1'));
manager.rows = parsePaneRows('codeman-s1|100|0|||/work/proj/sub');
await manager.refreshPaneExits();
manager.rows = parsePaneRows('codeman-other|300|0|||/elsewhere');
await manager.refreshPaneExits();
expect(manager.getPaneCurrentPath('codeman-s1')).toBeUndefined();
});
});
const stubMux = () => ({ isAvailable: () => true }) as unknown as TerminalMultiplexer;
const stubMuxSession = (muxName = 'codeman-aaaa') => ({ muxName, sessionId: 'aaaa' }) as unknown as MuxSession;
function localMuxSession(extra: Record<string, unknown> = {}) {
return new Session({
workingDir: '/work/proj',
mode: 'shell',
useMux: true,
mux: stubMux(),
muxSession: stubMuxSession(),
...extra,
});
}
describe('Session.setPaneCwd scoping', () => {
it('records an absolute directory for a local pane and reports the change once', () => {
const session = localMuxSession();
expect(session.setPaneCwd('/work/proj/sub')).toBe(true);
expect(session.paneCwd).toBe('/work/proj/sub');
expect(session.setPaneCwd('/work/proj/sub')).toBe(false);
expect(session.toState().paneCwd).toBe('/work/proj/sub');
});
it('clears the directory when the reading goes away', () => {
const session = localMuxSession();
session.setPaneCwd('/work/proj/sub');
expect(session.setPaneCwd(undefined)).toBe(true);
expect(session.paneCwd).toBeUndefined();
});
it('refuses a relative directory', () => {
const session = localMuxSession();
expect(session.setPaneCwd('rel/ative')).toBe(false);
expect(session.paneCwd).toBeUndefined();
});
it('refuses a directory for a remote session, whose local pane runs the ssh client', () => {
const session = localMuxSession({ remote });
expect(session.setPaneCwd('/home/dev')).toBe(false);
expect(session.toState().paneCwd).toBeUndefined();
});
it('refuses a directory for a docker case, whose local pane runs docker exec', () => {
const session = localMuxSession({ docker });
expect(session.setPaneCwd('/work/proj')).toBe(false);
expect(session.toState().paneCwd).toBeUndefined();
});
it('refuses a directory for a direct-PTY session and a rediscovered one', () => {
const direct = new Session({ workingDir: '/work/proj', mode: 'shell', useMux: false });
expect(direct.setPaneCwd('/work/proj/sub')).toBe(false);
const discovered = localMuxSession({ discoveredMuxSession: true });
expect(discovered.setPaneCwd('/work/proj/sub')).toBe(false);
});
});
describe('a pane read reaching the session record', () => {
let server: WebServer | null = null;
afterEach(async () => {
await server?.stop?.();
server = null;
});
const build = () => {
const web = new WebServer(0, false, true);
server = web;
const mux = (web as unknown as { mux: Record<string, unknown> & { emit: (e: string) => void } }).mux;
const sessions = (web as unknown as { sessions: Map<string, Session> }).sessions;
return { mux, sessions, web };
};
it("publishes the pane's directory on the existing paneExitsUpdated tick", () => {
const { mux, sessions, web } = build();
const session = localMuxSession();
sessions.set(session.id, session);
mux.getPaneExit = () => undefined;
mux.getPaneCurrentPath = (muxName: string) => (muxName === 'codeman-aaaa' ? '/work/proj/sub' : undefined);
mux.emit('paneExitsUpdated');
expect(session.toState().paneCwd).toBe('/work/proj/sub');
// What the browser actually receives: a future explicit field list must not drop it.
const state = (
web as unknown as { getSessionStateWithRespawn: (s: Session) => { paneCwd?: string } }
).getSessionStateWithRespawn(session);
expect(state.paneCwd).toBe('/work/proj/sub');
// A resolution base only: the confinement root is untouched.
expect(session.toState().workingDir).toBe('/work/proj');
});
it('broadcasts a directory change once, and nothing on an unchanged tick', () => {
const { mux, sessions } = build();
const session = localMuxSession();
sessions.set(session.id, session);
const broadcast = vi.fn();
(server as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
broadcast;
const persist = vi.fn();
(server as unknown as { persistSessionState: (s: Session) => void }).persistSessionState = persist;
let dir = '/work/proj/sub';
mux.getPaneExit = () => undefined;
mux.getPaneCurrentPath = () => dir;
mux.emit('paneExitsUpdated');
expect(broadcast).toHaveBeenCalledTimes(1);
expect(broadcast).toHaveBeenCalledWith(session.id);
mux.emit('paneExitsUpdated');
expect(broadcast).toHaveBeenCalledTimes(1);
dir = '/work/proj/other';
mux.emit('paneExitsUpdated');
expect(broadcast).toHaveBeenCalledTimes(2);
expect(session.toState().paneCwd).toBe('/work/proj/other');
// Runtime-only: a directory change alone is never written to state.json.
expect(persist).not.toHaveBeenCalled();
});
/**
* Drive the server's REAL TmuxManager: its parsing, its derivation and its
* `getPaneCurrentPath()`. Only the tmux exec seam (`readPaneRows`) is faked,
* with raw `list-panes -a` output, as the bookkeeping tests above do.
*/
const buildWithRealManager = () => {
const { sessions } = build();
const mux = (server as unknown as { mux: TmuxManager }).mux;
expect(mux).toBeInstanceOf(TmuxManager);
let output = '';
(mux as unknown as { readPaneRows: () => Promise<PaneRow[]> }).readPaneRows = async () => parsePaneRows(output);
mux.registerSession({
sessionId: 'aaaa',
muxName: 'codeman-aaaa',
pid: 100,
createdAt: 0,
workingDir: '/work/proj',
mode: 'shell',
attached: true,
} as MuxSession);
const session = localMuxSession();
sessions.set(session.id, session);
const broadcast = vi.fn();
(server as unknown as { broadcastSessionStateDebounced: (id: string) => void }).broadcastSessionStateDebounced =
broadcast;
const tick = async (listPanes: string) => {
output = listPanes;
await mux.refreshPaneExits();
mux.emit('paneExitsUpdated');
};
return { session, broadcast, tick };
};
it('clears the directory once the session is split into several panes', async () => {
const { session, broadcast, tick } = buildWithRealManager();
await tick('codeman-aaaa|100|0|||/work/proj/sub');
expect(session.toState().paneCwd).toBe('/work/proj/sub');
expect(broadcast).toHaveBeenCalledTimes(1);
await tick(['codeman-aaaa|100|0|||/work/proj/sub', 'codeman-aaaa|101|0|||/work/proj'].join('\n'));
// An absent cwd is what makes the browser fall back to workingDir.
expect(session.toState().paneCwd).toBeUndefined();
expect(broadcast).toHaveBeenCalledTimes(2);
// The clear is announced once, not on every later tick that agrees.
await tick(['codeman-aaaa|100|0|||/work/proj/sub', 'codeman-aaaa|101|0|||/work/proj'].join('\n'));
expect(broadcast).toHaveBeenCalledTimes(2);
});
it('clears the directory once a read no longer lists the session', async () => {
const { session, broadcast, tick } = buildWithRealManager();
await tick('codeman-aaaa|100|0|||/work/proj/sub');
expect(session.toState().paneCwd).toBe('/work/proj/sub');
await tick('codeman-other|300|0|||/elsewhere');
expect(session.toState().paneCwd).toBeUndefined();
expect(broadcast).toHaveBeenCalledTimes(2);
await tick('codeman-other|300|0|||/elsewhere');
expect(broadcast).toHaveBeenCalledTimes(2);
});
it('keeps the directory across a blank field and across a failed read', async () => {
const { session, broadcast, tick } = buildWithRealManager();
await tick('codeman-aaaa|100|0|||/work/proj/sub');
// tmux could not read the process cwd for one tick.
await tick('codeman-aaaa|100|0|||');
expect(session.toState().paneCwd).toBe('/work/proj/sub');
// A failed read arrives as no rows at all.
await tick('');
expect(session.toState().paneCwd).toBe('/work/proj/sub');
expect(broadcast).toHaveBeenCalledTimes(1);
});
it('publishes nothing for a docker case', () => {
const { mux, sessions } = build();
const session = localMuxSession({ docker });
sessions.set(session.id, session);
mux.getPaneExit = () => undefined;
mux.getPaneCurrentPath = () => '/work/proj';
mux.emit('paneExitsUpdated');
expect(session.toState().paneCwd).toBeUndefined();
});
it('publishes nothing for a remote session', () => {
const { mux, sessions } = build();
const session = localMuxSession({ remote });
sessions.set(session.id, session);
mux.getPaneExit = () => undefined;
mux.getPaneCurrentPath = () => '/home/dev';
mux.emit('paneExitsUpdated');
expect(session.toState().paneCwd).toBeUndefined();
});
});
describe('browser resolution order: live cwd, then workingDir', () => {
const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
const loadProto = () => {
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
document: { addEventListener: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
});
vm.runInContext(
`${read('constants.js')}\n${read('terminal-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
context
);
return (context as unknown as { __proto: { _resolveLinkedFilePath: (p: string, id: string) => string } }).__proto;
};
const resolveFor = (session: Record<string, unknown>, filePath: string) => {
const proto = loadProto();
return proto._resolveLinkedFilePath.call({ sessions: new Map([['s1', session]]) }, filePath, 's1');
};
it("resolves against the pane's live directory after a cd into a subdirectory", () => {
expect(resolveFor({ workingDir: '/work/proj', paneCwd: '/work/proj/sub' }, 'note.md')).toBe(
'/work/proj/sub/note.md'
);
});
it('resolves outside the workspace when the pane cd-ed to a sibling repo', () => {
// Routing is unchanged: an absolute target outside the workspace is what
// `_isExternalPreviewPath` already sends through the attachment routes.
expect(resolveFor({ workingDir: '/work/proj', paneCwd: '/work/other' }, 'src/a.js')).toBe('/work/other/src/a.js');
});
it('falls back to workingDir when no live directory is known', () => {
expect(resolveFor({ workingDir: '/work/proj' }, 'note.md')).toBe('/work/proj/note.md');
});
it('ignores a live directory on a remote or docker session', () => {
expect(resolveFor({ workingDir: '/work/proj', paneCwd: '/tmp/x', remote: {} }, 'note.md')).toBe(
'/work/proj/note.md'
);
expect(resolveFor({ workingDir: '/work/proj', paneCwd: '/tmp/x', docker: {} }, 'note.md')).toBe(
'/work/proj/note.md'
);
});
it('ignores a relative live directory', () => {
expect(resolveFor({ workingDir: '/work/proj', paneCwd: 'sub' }, 'note.md')).toBe('/work/proj/note.md');
});
it('leaves absolute and home paths alone', () => {
const session = { workingDir: '/work/proj', paneCwd: '/work/proj/sub' };
expect(resolveFor(session, '/etc/hosts.md')).toBe('/etc/hosts.md');
expect(resolveFor(session, '~/x.md')).toBe('~/x.md');
});
});
describe('confinement is unchanged by the live cwd', () => {
// The live cwd only picks the base. Whether the preview reads a path through
// the workspace-confined file routes or registers it through the attachment
// routes is still decided against `workingDir` by `_isExternalPreviewPath`.
const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
const loadApp = (session: Record<string, unknown>) => {
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
document: { addEventListener: vi.fn(), getElementById: () => null },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
vm.runInContext(
`${read('constants.js')}\n${read('terminal-ui.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
context
);
const proto = (
context as unknown as {
__proto: {
_resolveLinkedFilePath: (p: string, id: string) => string;
_isExternalPreviewPath: (p: string, id: string) => boolean;
};
}
).__proto;
const app = { sessions: new Map([['s1', session]]) };
return {
target: (p: string) => proto._resolveLinkedFilePath.call(app, p, 's1'),
external: (p: string) => proto._isExternalPreviewPath.call(app, p, 's1'),
};
};
it('keeps a target inside the workspace on the workspace route', () => {
const app = loadApp({ workingDir: '/work/proj', paneCwd: '/work/proj/sub' });
expect(app.external(app.target('note.md'))).toBe(false);
});
it('sends a target outside the workspace through the attachment route', () => {
const app = loadApp({ workingDir: '/work/proj', paneCwd: '/work/other' });
expect(app.target('src/a.js')).toBe('/work/other/src/a.js');
expect(app.external(app.target('src/a.js'))).toBe(true);
});
});
describe('a workspace reached through a symlink', () => {
// tmux reads `/proc/<pgrp>/cwd`, always the resolved path, while `workingDir`
// keeps the spelling the case was created with. Without the rewrite, the
// browser's lexical routing check would call every relative link external.
let root: string;
let physical: string;
let link: string;
const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
const routingFor = (session: Record<string, unknown>) => {
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
document: { addEventListener: vi.fn(), getElementById: () => null },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
vm.runInContext(
`${read('constants.js')}\n${read('terminal-ui.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
context
);
const proto = (
context as unknown as {
__proto: {
_resolveLinkedFilePath: (p: string, id: string) => string;
_isExternalPreviewPath: (p: string, id: string) => boolean;
};
}
).__proto;
const app = { sessions: new Map([['s1', session]]) };
return (p: string) => proto._isExternalPreviewPath.call(app, proto._resolveLinkedFilePath.call(app, p, 's1'), 's1');
};
/** Feed a reading until the background resolution has settled and it is taken. */
const settle = async (session: Session, reading: string) => {
await vi.waitFor(() => {
session.setPaneCwd(reading);
expect(session.paneCwd).toBeDefined();
});
};
afterEach(() => rmSync(root, { recursive: true, force: true }));
const setup = () => {
root = realpathSync(mkdtempSync(join(tmpdir(), 'pane-cwd-link-')));
physical = join(root, 'data', 'app');
mkdirSync(join(physical, 'src'), { recursive: true });
mkdirSync(join(root, 'other'), { recursive: true });
link = join(root, 'cases-app');
symlinkSync(physical, link);
};
it('writes a reading of the resolved workspace back in workingDir spelling', async () => {
setup();
const session = localMuxSession({ workingDir: link });
await settle(session, physical);
expect(session.paneCwd).toBe(link);
expect(session.setPaneCwd(join(physical, 'src'))).toBe(true);
expect(session.paneCwd).toBe(join(link, 'src'));
});
it('keeps an in-workspace relative link on the workspace route from the first reading', async () => {
setup();
const session = localMuxSession({ workingDir: link });
await settle(session, physical);
const external = routingFor(session.toState() as unknown as Record<string, unknown>);
expect(external('src/index.ts')).toBe(false);
});
it('still routes a cd outside the workspace through the attachment route', async () => {
setup();
const session = localMuxSession({ workingDir: link });
await settle(session, join(root, 'other'));
expect(session.paneCwd).toBe(join(root, 'other'));
const external = routingFor(session.toState() as unknown as Record<string, unknown>);
expect(external('notes.md')).toBe(true);
});
it('withholds an out-of-workspace reading until the resolution settles', () => {
setup();
const session = localMuxSession({ workingDir: link });
// The first call starts the resolution; a physical reading cannot be told
// apart from a real cd yet, so it is not stored this tick.
expect(session.setPaneCwd(physical)).toBe(false);
expect(session.paneCwd).toBeUndefined();
// A reading lexically inside workingDir needs no translation and is taken.
expect(session.setPaneCwd(join(link, 'src'))).toBe(true);
});
it('leaves the reading alone when workingDir has no symlink in it', async () => {
setup();
const session = localMuxSession({ workingDir: physical });
await settle(session, join(physical, 'src'));
expect(session.paneCwd).toBe(join(physical, 'src'));
});
it('takes readings untranslated once a missing workingDir has settled', async () => {
setup();
const session = localMuxSession({ workingDir: join(root, 'gone') });
await settle(session, join(root, 'other'));
expect(session.paneCwd).toBe(join(root, 'other'));
});
});
describe('rebasePath', () => {
it('rewrites the prefix itself and paths under it, and nothing else', () => {
expect(rebasePath('/mnt/data/app', '/mnt/data/app', '/home/u/app')).toBe('/home/u/app');
expect(rebasePath('/mnt/data/app/src', '/mnt/data/app', '/home/u/app')).toBe('/home/u/app/src');
expect(rebasePath('/mnt/data/application', '/mnt/data/app', '/home/u/app')).toBe('/mnt/data/application');
expect(rebasePath('/mnt/other', '/mnt/data/app', '/home/u/app')).toBe('/mnt/other');
});
});
+179
View File
@@ -0,0 +1,179 @@
/**
* @fileoverview PathPicker "New folder" (opt-in via `allowNewFolder`). Same jsdom harness as
* path-picker-hidden.test.ts (see its header for why the environment is built by hand).
*
* What can silently go wrong: the button showing for a picker that never asked for it (the
* container-workdir picker cannot create on the host), the folder made in the wrong place, the new
* folder not selected afterwards, and Escape closing the whole picker instead of just the form.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
const sessionUiJs = readFileSync(resolve(PUBLIC, 'session-ui.js'), 'utf8');
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const doc = jsdomWindow.document;
type Call = { url: string; init?: { method?: string; body?: string } };
function loadPathPicker(fetchImpl: (url: string, init?: Call['init']) => Promise<unknown>): any {
const factory = new Function(
'window',
'document',
'localStorage',
'fetch',
'MobileDetection',
`${accessoryJs}\nreturn PathPicker;`
);
return factory(jsdomWindow, doc, jsdomWindow.localStorage, fetchImpl, { isTouchDevice: () => false });
}
const listing = (path: string, names: string[]) => ({
json: async () => ({
success: true,
data: {
path,
parent: null,
root: '/home/dev',
roots: [{ label: 'Home', path: '/home/dev' }],
entries: names.map((name) => ({ name, type: 'directory', path: `${path}/${name}` })),
truncated: false,
},
}),
});
describe('PathPicker New folder', () => {
let PathPicker: any;
let calls: Call[];
let names: string[];
let mkdirReply: () => unknown;
beforeEach(() => {
jsdomWindow.localStorage.clear();
doc.body.replaceChildren();
calls = [];
names = ['src'];
mkdirReply = () => {
names.push('fresh');
return { json: async () => ({ success: true, data: { path: '/home/dev/project/fresh' } }) };
};
PathPicker = loadPathPicker(async (url, init) => {
calls.push({ url, init });
return url.startsWith('/api/filesystem/mkdir') ? mkdirReply() : listing('/home/dev/project', names);
});
});
afterEach(() => {
PathPicker?.close?.(false);
doc.body.replaceChildren();
});
const open = async (options: Record<string, unknown> = {}) => {
PathPicker.open({ onSelect: () => {}, directoriesOnly: true, ...options });
await vi.waitFor(() => expect(doc.querySelectorAll('.path-picker-item').length).toBeGreaterThan(0));
};
const button = () => doc.querySelector('.path-picker-newfolder') as HTMLButtonElement;
const form = () => doc.querySelector('.path-picker-newfolder-form') as HTMLFormElement;
const nameInput = () => doc.querySelector('.path-picker-newfolder-name') as HTMLInputElement;
const submit = (name: string) => {
button().click();
nameInput().value = name;
form().dispatchEvent(new jsdomWindow.Event('submit', { cancelable: true, bubbles: true }));
};
it('is hidden unless the caller opts in', async () => {
await open();
expect(button().hidden).toBe(true);
expect(form().hidden).toBe(true);
});
it('shows the button, and opens the name form on click, when allowed', async () => {
await open({ allowNewFolder: true });
expect(button().hidden).toBe(false);
expect(form().hidden).toBe(true);
button().click();
expect(form().hidden).toBe(false);
});
it('creates inside the folder being viewed, reloads it and selects the new folder', async () => {
await open({ allowNewFolder: true, sessionId: 'abc123' });
submit('fresh');
await vi.waitFor(() =>
expect(doc.querySelector('.path-picker-selection-value')?.textContent).toBe('/home/dev/project/fresh')
);
const post = calls.find((c) => c.url === '/api/filesystem/mkdir');
expect(post?.init?.method).toBe('POST');
expect(JSON.parse(post?.init?.body ?? '{}')).toEqual({
path: '/home/dev/project',
name: 'fresh',
sessionId: 'abc123',
});
expect(Array.from(doc.querySelectorAll('.path-picker-item-name')).map((e) => e.textContent)).toContain('fresh');
expect(form().hidden).toBe(true);
expect((doc.querySelector('.path-picker-confirm') as HTMLButtonElement).disabled).toBe(false);
});
it('shows the server reason and keeps the form open when the folder cannot be made', async () => {
mkdirReply = () => ({ json: async () => ({ success: false, error: '"fresh" already exists in this folder' }) });
await open({ allowNewFolder: true });
submit('fresh');
await vi.waitFor(() =>
expect(doc.querySelector('.path-picker-status')?.textContent).toBe('"fresh" already exists in this folder')
);
expect(doc.querySelector('.path-picker-status')?.classList.contains('error')).toBe(true);
expect(form().hidden).toBe(false);
});
it('forwards showHidden while the picker is showing hidden entries', async () => {
jsdomWindow.localStorage.setItem('codeman:pathPickerShowHidden', '1');
await open({ allowNewFolder: true });
submit('fresh');
await vi.waitFor(() => expect(calls.some((c) => c.url === '/api/filesystem/mkdir')).toBe(true));
const post = calls.find((c) => c.url === '/api/filesystem/mkdir');
expect(JSON.parse(post?.init?.body ?? '{}').showHidden).toBe('true');
});
it('disables Create while the request runs, so a double tap sends one', async () => {
let finish: (v: unknown) => void = () => {};
mkdirReply = () =>
new Promise((resolve) => {
finish = resolve;
});
await open({ allowNewFolder: true });
const create = doc.querySelector('.path-picker-newfolder-create') as HTMLButtonElement;
submit('fresh');
await vi.waitFor(() => expect(create.disabled).toBe(true));
finish({ json: async () => ({ success: true, data: { path: '/home/dev/project/fresh' } }) });
await vi.waitFor(() => expect(create.disabled).toBe(false));
});
it('sends nothing for a blank name', async () => {
await open({ allowNewFolder: true });
submit(' ');
await Promise.resolve();
expect(calls.some((c) => c.url === '/api/filesystem/mkdir')).toBe(false);
});
it('Escape in the name field closes the form, not the picker', async () => {
await open({ allowNewFolder: true });
button().click();
nameInput().dispatchEvent(new jsdomWindow.KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
expect(form().hidden).toBe(true);
expect(doc.querySelector('.path-picker-overlay')).not.toBeNull();
});
it('is on for Link Existing and off for the other session-ui pickers', () => {
const block = (name: string) => {
const start = sessionUiJs.indexOf(`${name}() {`);
return sessionUiJs.slice(start, sessionUiJs.indexOf('PathPicker.open', start) + 400);
};
expect(block('openLinkCasePathPicker')).toContain('allowNewFolder: true');
for (const other of ['openDockerWorkspacePathPicker', 'openDockerWorkdirPicker', 'openNewCasePathPicker']) {
expect(block(other), other).not.toContain('allowNewFolder');
}
});
});
+161
View File
@@ -0,0 +1,161 @@
/**
* @fileoverview Real-browser coverage for the press feedback on the phone toolbar
* and keyboard accessory keys (mobile.css, the `:active` transform + filter rules).
*
* The feedback is transform + filter because a background `:active` on the
* toolbar buttons lost to the skin block on every skin but og. What this pins is
* the cascade itself, on every skin: a later rule that sets transform or filter
* on these buttons at a higher specificity would silently take the press away
* again, and only a browser computing the cascade can see that.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser, type BrowserContext, type Page } from 'playwright';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const html = readFileSync(resolve(publicDir, 'index.html'), 'utf8');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
const mobileStyles = readFileSync(resolve(publicDir, 'mobile.css'), 'utf8');
const accessorySource = readFileSync(resolve(publicDir, 'keyboard-accessory.js'), 'utf8');
const DARK_SKINS = ['og', 'daylight-blue', 'daylight-green'] as const;
const LIGHT_SKINS = ['paper-gray', 'solarized-light', 'catppuccin-latte', 'rose-pine-dawn'] as const;
/** Every phone bar control the rules cover, pressed where a finger would land. */
const TARGETS = [
['Run', '.toolbar .btn-toolbar.btn-run'],
['Run gear', '.toolbar .btn-toolbar.btn-run-gear'],
['Stop', '.toolbar .btn-toolbar.btn-stop'],
['Enter', '.toolbar .btn-toolbar.btn-enter'],
['Case', '.toolbar .btn-toolbar.btn-case-mobile'],
['Voice', '.btn-voice-mobile'],
// The pick is a transparent file input laid over this button, so the press
// lands on the input and only the wrap is :active.
['Attach', '.btn-attach-mobile'],
['Settings', '.btn-settings-mobile'],
['Accessory Tab', '.accessory-btn[data-action="tab"]'],
['Accessory arrow', '.accessory-btn-arrow[data-action="scroll-up"]'],
['Accessory Compose', '.accessory-btn-compose'],
['Accessory dismiss', '.accessory-btn-dismiss'],
] as const;
const PRESSED_SCALE = 'matrix(0.94, 0, 0, 0.94, 0, 0)';
/** The press transition, read at rest: the 250ms hold below would also pass a slower one. */
const PRESS_TRANSITION = {
property: 'transform, filter, background-color, border-color',
duration: '0.08s, 0.08s, 0.08s, 0.08s',
};
function toolbarMarkup(): string {
const start = html.indexOf('<footer class="toolbar">');
const end = html.indexOf('</footer>', start);
if (start < 0 || end < 0) throw new Error('Toolbar markup not found');
return html.slice(start, end + '</footer>'.length);
}
function accessoryMarkup(): string {
const match = accessorySource.match(/_simpleButtons: `([\s\S]*?)`,/);
if (!match) throw new Error('Accessory bar template not found');
return `<div class="keyboard-accessory-bar visible">${match[1]}</div>`;
}
describe('phone toolbar and accessory press feedback in Chromium', () => {
let browser: Browser;
beforeAll(async () => {
browser = await chromium.launch({ headless: true });
});
afterAll(async () => browser.close());
async function openBars(skin: string, reducedMotion: 'reduce' | 'no-preference'): Promise<[BrowserContext, Page]> {
// hasTouch makes Chromium match (hover: none) and (pointer: coarse), the gate the rules sit behind.
const context = await browser.newContext({
viewport: { width: 440, height: 956 },
isMobile: true,
hasTouch: true,
deviceScaleFactor: 1,
reducedMotion,
});
const page = await context.newPage();
await page.setContent(`<!doctype html><html data-skin="${skin}"><head>
<meta name="viewport" content="width=device-width, initial-scale=1"><style>${styles}</style>
<style>@media (max-width: 1023px) { ${mobileStyles} }</style></head>
<body>${toolbarMarkup()}${accessoryMarkup()}</body></html>`);
// Without the viewport meta a mobile context lays out at 980px and never reaches the phone layout.
expect(
await page.evaluate(() => ({
touch: matchMedia('(hover: none) and (pointer: coarse)').matches,
phone: matchMedia('(max-width: 599px)').matches,
}))
).toEqual({ touch: true, phone: true });
return [context, page];
}
/** Hold a press on the control and read its transform and filter once the transition has settled. */
async function pressed(page: Page, selector: string) {
const target = page.locator(selector).first();
const box = await target.boundingBox();
if (!box) throw new Error(`${selector} is not rendered in the phone layout`);
const read = () =>
target.evaluate((el) => ({ transform: getComputedStyle(el).transform, filter: getComputedStyle(el).filter }));
const rest = await read();
const transition = await target.evaluate((el) => ({
property: getComputedStyle(el).transitionProperty,
duration: getComputedStyle(el).transitionDuration,
}));
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.waitForTimeout(250);
const held = await read();
// Release off the control so its click handler never runs.
await page.mouse.move(0, 0);
await page.mouse.up();
await page.waitForTimeout(250);
return { rest, held, transition };
}
for (const [skins, brightness] of [
[DARK_SKINS, 'brightness(1.25)'],
[LIGHT_SKINS, 'brightness(0.9)'],
] as const) {
for (const skin of skins) {
it(`scales every control and applies ${brightness} on the ${skin} skin`, async () => {
const [context, page] = await openBars(skin, 'no-preference');
try {
for (const [name, selector] of TARGETS) {
expect({ name, ...(await pressed(page, selector)) }).toEqual({
name,
rest: { transform: 'none', filter: 'none' },
held: { transform: PRESSED_SCALE, filter: brightness },
transition: PRESS_TRANSITION,
});
}
} finally {
await context.close();
}
});
}
}
it.each([
['daylight-blue', 'brightness(1.25)'],
['paper-gray', 'brightness(0.9)'],
])('drops the scale but keeps the tint under prefers-reduced-motion on %s', async (skin, brightness) => {
const [context, page] = await openBars(skin, 'reduce');
try {
for (const [name, selector] of TARGETS) {
expect({ name, ...(await pressed(page, selector)) }).toEqual({
name,
rest: { transform: 'none', filter: 'none' },
held: { transform: 'none', filter: brightness },
transition: PRESS_TRANSITION,
});
}
} finally {
await context.close();
}
});
});
+5 -2
View File
@@ -70,8 +70,11 @@ describe('saveAppSettings wiring', () => {
);
// Stripped from serverSettings like the other per-device display keys.
expect(save).toMatch(/showPlanUsageLimits: _pul,/);
// Decided once against the device's prior settings, before they are overwritten.
expect(save).toMatch(/const _chipFlip = this\.planUsageCollectionFlip\(_prev, settings\.showPlanUsageLimits\);/);
// Decided once against the device's prior settings, before they are overwritten (or, after a
// save the server did not store, against the settings that save started from).
expect(save).toMatch(
/const _chipFlip = this\.planUsageCollectionFlip\(_prevServer, settings\.showPlanUsageLimits\);/
);
// And only a real flip reaches the PUT body.
expect(save).toMatch(/\.\.\.\(_chipFlip !== undefined \? \{ showPlanUsageLimits: _chipFlip \} : \{\}\),/);
});
+168
View File
@@ -0,0 +1,168 @@
/**
* The Ralph tracker's `@fix_plan.md` watcher must never be pointed at a REMOTE
* session's working directory.
*
* A remote-SSH session's `workingDir` is a path on the remote host. The watcher
* reads and `fs.watch`es it on the LOCAL disk, where it usually does not exist,
* so every server start logged
* `[RalphFixPlanWatcher] Could not watch @fix_plan.md: Error: ENOENT ...` once per
* remote session (and where a same-named local directory did exist, it watched
* the wrong tree). A remote session therefore only RECORDS the path
* (`setRemoteWorkingDir`): nothing is read or watched locally, and the fix
* plan stays authoritative (`isFileAuthoritative`) exactly as for a local
* session, so the tracker's output-based completion guessers stay off. They
* are off for every session with a working directory; turning them on for
* remote sessions alone would let a prose line such as "All tasks completed"
* mark every todo done and end the loop there and nowhere else.
*
* Drives the real `WebServer.setupSessionListeners()`, like
* discard-partially-built-session.test.ts.
*/
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { safeRmHomeTree } from './mocks/test-helpers.js';
import { WebServer } from '../src/web/server.js';
import { Session } from '../src/session.js';
import { TmuxManager } from '../src/tmux-manager.js';
interface ServerInternals {
registerSessionWithLayout(session: Session): Promise<void>;
setupSessionListeners(session: Session): Promise<void>;
discardPartiallyBuiltSession(sessionId: string): Promise<void>;
}
const WORKSPACE = join(homedir(), '.codeman-test-ralph-remote');
// A path on the remote host: nothing exists there locally.
const REMOTE_PATH = '/nonexistent-remote-host-path/homelab-infrastructure';
const SESSION_ID = 'f0e1d2c3b4a59687';
let server: WebServer;
let internals: ServerInternals;
let mux: TmuxManager;
function buildSession(remote: boolean): Session {
return new Session({
id: SESSION_ID,
workingDir: remote ? REMOTE_PATH : WORKSPACE,
mode: 'claude',
name: remote ? 'remote session' : 'local session',
mux,
useMux: true,
...(remote
? {
remote: {
hostId: 'proxx',
label: 'proxx',
host: 'proxx.example.lan',
username: 'user',
remotePath: REMOTE_PATH,
owned: true,
},
}
: {}),
});
}
beforeAll(() => {
mkdirSync(WORKSPACE, { recursive: true });
server = new WebServer(0, false, true);
internals = server as unknown as ServerInternals;
mux = new TmuxManager();
});
afterEach(async () => {
vi.restoreAllMocks();
await internals.discardPartiallyBuiltSession(SESSION_ID).catch(() => {});
});
afterAll(async () => {
await server.stop().catch(() => {});
safeRmHomeTree(WORKSPACE);
});
describe('Ralph @fix_plan.md watcher and the session location', () => {
it('does not watch a remote session working directory on the local disk', async () => {
const session = buildSession(true);
const setWorkingDir = vi.spyOn(session.ralphTracker, 'setWorkingDir');
const setRemoteWorkingDir = vi.spyOn(session.ralphTracker, 'setRemoteWorkingDir');
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
await internals.registerSessionWithLayout(session);
await internals.setupSessionListeners(session);
expect(setWorkingDir).not.toHaveBeenCalled();
expect(setRemoteWorkingDir).toHaveBeenCalledWith(REMOTE_PATH);
expect(log.mock.calls.some((args) => String(args[0]).includes('Could not watch @fix_plan.md'))).toBe(false);
// Same authority as a local session: the output-based completion guessers stay off.
expect(session.ralphTracker.isFileAuthoritative).toBe(true);
});
it('never reads a same-named local file for a remote session', async () => {
// The remote path exists locally too (an sshfs mount, a leftover checkout) and holds a plan.
writeFileSync(join(WORKSPACE, '@fix_plan.md'), '- [ ] a local task the remote session never had\n');
try {
const session = new Session({
id: SESSION_ID,
workingDir: WORKSPACE,
mode: 'claude',
name: 'remote session over a local twin',
mux,
useMux: true,
remote: {
hostId: 'proxx',
label: 'proxx',
host: 'proxx.example.lan',
username: 'user',
remotePath: WORKSPACE,
owned: true,
},
});
vi.spyOn(console, 'log').mockImplementation(() => {});
await internals.registerSessionWithLayout(session);
await internals.setupSessionListeners(session);
expect(await session.ralphTracker.loadFixPlanFromDisk()).toBe(0);
expect(session.ralphTracker.todos).toEqual([]);
} finally {
rmSync(join(WORKSPACE, '@fix_plan.md'), { force: true });
}
});
it('keeps a remote session todo list out of the prose completion guessers', async () => {
const session = buildSession(true);
vi.spyOn(console, 'log').mockImplementation(() => {});
await internals.registerSessionWithLayout(session);
await internals.setupSessionListeners(session);
const tracker = session.ralphTracker;
tracker.enable();
tracker.startLoop('ALLDONE');
const completions: string[] = [];
tracker.on('completionDetected', (phrase: string) => completions.push(phrase));
tracker.processTerminalData('- [ ] Write the parser\n- [ ] Add tests\n');
const pending = () => tracker.todos.filter((t) => t.status !== 'completed').length;
expect(pending()).toBe(2);
// Prose, not the configured completion phrase: it must not end the loop.
tracker.processTerminalData('All tasks completed\n');
expect(pending()).toBe(2);
expect(tracker.loopState.active).toBe(true);
expect(completions).toEqual([]);
});
it('still watches a local session working directory', async () => {
const session = buildSession(false);
const setWorkingDir = vi.spyOn(session.ralphTracker, 'setWorkingDir');
await internals.registerSessionWithLayout(session);
await internals.setupSessionListeners(session);
expect(setWorkingDir).toHaveBeenCalledWith(WORKSPACE);
});
});
+10
View File
@@ -181,6 +181,16 @@ describe('WebServer.renderIndexHtml', () => {
}
});
it('injects the keyboard-hold modes from the keyboardHold capability: inline Claude, never full-height codex', async () => {
for (const solo of [undefined, 'sess-123']) {
const { server } = makeServer();
const html = await render(server, solo);
const match = html.match(/window\.__codemanKeyboardHoldModes=(\[[^<]*\]);/);
expect(match).not.toBeNull();
expect(JSON.parse(match![1])).toEqual(['claude']);
}
});
it('escapes the solo id so it cannot break out of the inline <script>', async () => {
const { server } = makeServer({});
const html = await render(server, 'a</script><b>');
+393
View File
@@ -0,0 +1,393 @@
/** Regression coverage for empty API POSTs reframed as chunked by a reverse proxy. */
import { afterEach, describe, expect, it, vi } from 'vitest';
import Fastify, { type FastifyInstance, type FastifyRequest, type FastifyServerOptions } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyMultipart from '@fastify/multipart';
import { connect } from 'node:net';
import { Readable } from 'node:stream';
import { installEmptyApiPostParser } from '../src/web/request-body-parser.js';
import { registerAuthMiddleware, registerHostGuard } from '../src/web/middleware/auth.js';
import { registerMuxRoutes } from '../src/web/routes/mux-routes.js';
import { registerWebviewRoutes } from '../src/web/routes/webview-routes.js';
import { createMockRouteContext } from './mocks/index.js';
import { writeWebviews } from '../src/webview-store.js';
import { webviewCapabilities } from '../src/webview-capabilities.js';
import { getDataDir } from '../src/config/instance.js';
const apps: FastifyInstance[] = [];
function createApp(options: FastifyServerOptions = {}): FastifyInstance {
const app = Fastify({ logger: false, ...options });
installEmptyApiPostParser(app);
apps.push(app);
return app;
}
afterEach(async () => {
await Promise.all(apps.splice(0).map((app) => app.close()));
webviewCapabilities.revokeWebview('empty-post-preview');
});
const emptyChunked = { 'transfer-encoding': 'chunked' };
describe('empty API POST parser', () => {
it.each([{}, { 'content-length': '0' }, emptyChunked])(
'preserves an absent body and the original headers (%j)',
async (headers) => {
const app = createApp();
app.post('/api/action', async (req) => ({
absent: req.body === undefined,
contentType: req.headers['content-type'] ?? null,
contentLength: req.headers['content-length'] ?? null,
transferEncoding: req.headers['transfer-encoding'] ?? null,
}));
const res = await app.inject({ method: 'POST', url: '/api/action', headers });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({
absent: true,
contentType: null,
contentLength: 'content-length' in headers ? '0' : null,
transferEncoding: 'transfer-encoding' in headers ? 'chunked' : null,
});
}
);
it.each([' ', '{}', '{"prompt":"hello"}', 'x'.repeat(1024)])(
'rejects nonempty untyped chunked input (%#)',
async (payload) => {
const app = createApp();
const handler = vi.fn(async () => ({}));
app.post('/api/action', handler);
const res = await app.inject({ method: 'POST', url: '/api/action', headers: emptyChunked, payload });
expect(res.statusCode).toBe(415);
expect(res.json().code).toBe('FST_ERR_CTP_INVALID_MEDIA_TYPE');
expect(handler).not.toHaveBeenCalled();
}
);
it('rejects a positive Content-Length without reading the body', async () => {
const app = createApp();
let streamRead = false;
app.addHook(
'preParsing',
async () =>
new Readable({
read() {
streamRead = true;
},
})
);
app.post('/api/action', async () => ({}));
const res = await app.inject({ method: 'POST', url: '/api/action', headers: { 'content-length': '1' } });
expect(res.statusCode).toBe(415);
expect(streamRead).toBe(false);
});
it('waits for the end of an empty stream before executing the handler', async () => {
const app = createApp();
let started!: () => void;
const reading = new Promise<void>((resolve) => {
started = resolve;
});
const stream = new Readable({
read() {
started();
},
});
app.addHook('preParsing', async () => stream);
const handler = vi.fn(async (req: FastifyRequest) => ({ absent: req.body === undefined }));
app.post('/api/action', handler);
const response = app.inject({ method: 'POST', url: '/api/action', headers: emptyChunked });
await reading;
expect(handler).not.toHaveBeenCalled();
stream.push(null);
const res = await response;
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ absent: true });
});
it('rejects on the first byte without waiting for an unsupported stream to end', async () => {
const app = createApp({ bodyLimit: 16 });
const stream = new Readable({
read() {
this.push(Buffer.alloc(32));
this._read = () => {};
},
});
app.addHook('preParsing', async () => stream);
app.post('/api/action', async () => ({}));
try {
const res = await app.inject({ method: 'POST', url: '/api/action', headers: emptyChunked });
expect(res.statusCode).toBe(415);
expect(res.headers.connection).toBe('close');
expect(stream.readableEnded).toBe(false);
} finally {
stream.destroy();
}
});
it.each(['application/octet-stream', 'application/xml', 'invalid/type/extra', ''])(
'preserves rejection of an explicit type %j',
async (contentType) => {
const app = createApp();
app.post('/api/action', async () => ({}));
const res = await app.inject({ method: 'POST', url: '/api/action', headers: { 'content-type': contentType } });
expect(res.statusCode).toBe(415);
}
);
it('preserves JSON parsing, required-body validation, text and body limits', async () => {
const app = createApp({ bodyLimit: 64 });
app.post('/api/json', async (req) => ({ body: req.body }));
app.post(
'/api/required',
{ schema: { body: { type: 'object', required: ['prompt'], properties: { prompt: { type: 'string' } } } } },
async (req) => req.body
);
const missing = await app.inject({ method: 'POST', url: '/api/required', headers: emptyChunked });
expect(missing.statusCode).toBe(400);
expect(missing.json().code).toBe('FST_ERR_VALIDATION');
const valid = await app.inject({ method: 'POST', url: '/api/required', payload: { prompt: 'hello' } });
expect(valid.statusCode).toBe(200);
expect(valid.json()).toEqual({ prompt: 'hello' });
for (const [payload, code] of [
['', 'FST_ERR_CTP_EMPTY_JSON_BODY'],
['{broken', 'FST_ERR_CTP_INVALID_JSON_BODY'],
]) {
const res = await app.inject({
method: 'POST',
url: '/api/json',
headers: { 'content-type': 'application/json' },
payload,
});
expect(res.statusCode).toBe(400);
expect(res.json().code).toBe(code);
}
const text = await app.inject({
method: 'POST',
url: '/api/json',
headers: { 'content-type': 'text/plain' },
payload: '{"admin":true}',
});
expect(text.json()).toEqual({ body: '{"admin":true}' });
const oversized = await app.inject({ method: 'POST', url: '/api/json', payload: { value: 'x'.repeat(64) } });
expect(oversized.statusCode).toBe(413);
});
it('leaves other methods and non-API routes outside the compatibility rule', async () => {
const app = createApp();
app.put('/api/action', async () => ({}));
app.patch('/api/action', async () => ({}));
app.post('/outside-api', async () => ({}));
for (const [method, url] of [
['PUT', '/api/action'],
['PATCH', '/api/action'],
['POST', '/outside-api'],
] as const) {
const res = await app.inject({ method, url, headers: emptyChunked });
expect(res.statusCode).toBe(415);
}
});
it('preserves unknown routes and leaves their body stream unread for fallback handlers', async () => {
const app = createApp();
app.setNotFoundHandler(async (req, reply) => {
const chunks: Buffer[] = [];
for await (const chunk of req.raw) chunks.push(chunk);
return reply.code(404).send({ body: Buffer.concat(chunks).toString(), absent: req.body === undefined });
});
const res = await app.inject({
method: 'POST',
url: '/api/unknown',
headers: emptyChunked,
payload: 'untyped-data',
});
expect(res.statusCode).toBe(404);
expect(res.json()).toEqual({ body: 'untyped-data', absent: true });
});
it('keeps multipart uploads and an encapsulated raw-stream proxy parser intact', async () => {
const app = createApp();
await app.register(fastifyMultipart);
app.post('/api/upload', async (req) => {
const file = await req.file();
return { name: file?.filename, data: (await file?.toBuffer())?.toString('base64') };
});
app.register(async (scope) => {
scope.removeAllContentTypeParsers();
scope.addContentTypeParser('*', (_req, stream, done) => done(null, stream));
scope.post('/webview/test/*', async (req) => {
const chunks: Buffer[] = [];
for await (const chunk of req.body as Readable) chunks.push(chunk);
return { data: Buffer.concat(chunks).toString('base64') };
});
});
const bytes = Buffer.from([0, 255, 128, 13, 10, 65]);
const boundary = 'empty-post-boundary';
const multipart = Buffer.concat([
Buffer.from(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="sample.bin"\r\nContent-Type: application/octet-stream\r\n\r\n`
),
bytes,
Buffer.from(`\r\n--${boundary}--\r\n`),
]);
const upload = await app.inject({
method: 'POST',
url: '/api/upload',
headers: { 'content-type': `multipart/form-data; boundary=${boundary}` },
payload: multipart,
});
expect(upload.statusCode).toBe(200);
expect(upload.json()).toEqual({ name: 'sample.bin', data: bytes.toString('base64') });
const proxy = await app.inject({
method: 'POST',
url: '/webview/test/upload',
headers: { 'content-type': 'application/octet-stream' },
payload: bytes,
});
expect(proxy.statusCode).toBe(200);
expect(proxy.json()).toEqual({ data: bytes.toString('base64') });
});
it('exercises real mux operations and the real webview open route without a synthetic JSON body', async () => {
const app = createApp();
const ctx = createMockRouteContext();
registerMuxRoutes(app, ctx as never);
registerWebviewRoutes(app, ctx as never);
await writeWebviews(getDataDir(), [
{
id: 'empty-post-preview',
name: 'Preview',
url: 'http://127.0.0.1:18716/',
embedMode: 'proxy',
trusted: false,
createdAt: 1,
},
]);
const stats = await app.inject({ method: 'POST', url: '/api/mux-sessions/stats/start', headers: emptyChunked });
expect(stats.statusCode).toBe(200);
expect(ctx.mux.startStatsCollection).toHaveBeenCalledOnce();
const preview = await app.inject({
method: 'POST',
url: '/api/webviews/empty-post-preview/open',
headers: emptyChunked,
});
expect(preview.statusCode).toBe(200);
expect(preview.json().data.embedUrl).toMatch(/^\/webview\/.+\/$/);
});
it('preserves Host/Origin guards and authentication before parsing', async () => {
vi.stubEnv('CODEMAN_USERNAME', 'bodyless-test-user');
vi.stubEnv('CODEMAN_PASSWORD', 'isolated-test-password');
const app = createApp();
await app.register(fastifyCookie);
registerHostGuard(app, () => ({ bindHost: '127.0.0.1', allowedHosts: [], tunnelHost: null }));
const auth = registerAuthMiddleware(app, false);
app.post('/api/action', async () => ({ ok: true }));
const authorization = 'Basic ' + Buffer.from('bodyless-test-user:isolated-test-password').toString('base64');
const headers = { host: '127.0.0.1', ...emptyChunked };
try {
expect((await app.inject({ method: 'POST', url: '/api/action', headers })).statusCode).toBe(401);
expect(
(
await app.inject({
method: 'POST',
url: '/api/action',
headers: { ...headers, authorization, origin: 'https://evil.example' },
})
).statusCode
).toBe(403);
expect(
(
await app.inject({
method: 'POST',
url: '/api/action',
headers: { ...headers, authorization, host: 'evil.example' },
})
).statusCode
).toBe(403);
expect(
(await app.inject({ method: 'POST', url: '/api/action', headers: { ...headers, authorization } })).statusCode
).toBe(200);
} finally {
for (const map of Object.values(auth)) map?.dispose();
vi.unstubAllEnvs();
}
});
});
function sendRaw(port: number, frames: string[]): Promise<string> {
return new Promise((resolve, reject) => {
const socket = connect({ host: '127.0.0.1', port });
const chunks: Buffer[] = [];
socket.setTimeout(2000, () => socket.destroy(new Error('Raw request timed out')));
socket.on('error', reject);
socket.on('data', (chunk) => chunks.push(chunk));
socket.on('end', () => resolve(Buffer.concat(chunks).toString()));
socket.on('connect', () => socket.write(frames.join('')));
});
}
describe('real HTTP/1.1 framing', () => {
it('consumes the complete empty chunk before a second request on the same connection', async () => {
const app = createApp();
const seen: unknown[] = [];
app.post('/api/action', async (req) => {
seen.push(req.body);
return { ok: true };
});
await app.listen({ host: '127.0.0.1', port: 0 });
const port = (app.server.address() as { port: number }).port;
const res = await sendRaw(port, [
'POST /api/action HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n0\r\nX-Test-Trailer: done\r\n\r\n',
'POST /api/action HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nConnection: close\r\n\r\n',
]);
expect(res.match(/HTTP\/1.1 200 OK/g)).toHaveLength(2);
expect(seen).toEqual([undefined, undefined]);
});
it('returns 415 after the first nonempty chunk, without needing a terminating chunk', async () => {
const app = createApp();
const handler = vi.fn(async () => ({}));
app.post('/api/action', handler);
await app.listen({ host: '127.0.0.1', port: 0 });
const port = (app.server.address() as { port: number }).port;
const res = await sendRaw(port, [
'POST /api/action HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nx\r\n',
]);
expect(res).toMatch(/^HTTP\/1.1 415 /);
expect(handler).not.toHaveBeenCalled();
});
it('leaves conflicting length and transfer headers to the HTTP parser', async () => {
const app = createApp();
const handler = vi.fn(async () => ({}));
app.post('/api/action', handler);
await app.listen({ host: '127.0.0.1', port: 0 });
const port = (app.server.address() as { port: number }).port;
const res = await sendRaw(port, [
'POST /api/action HTTP/1.1\r\nHost: localhost\r\nContent-Length: 0\r\nTransfer-Encoding: chunked\r\n\r\n0\r\n\r\n',
]);
expect(res).toMatch(/^HTTP\/1.1 400 /);
expect(handler).not.toHaveBeenCalled();
});
it('does not execute the handler after a client aborts an unfinished chunked request', async () => {
const app = createApp();
const handler = vi.fn(async () => ({}));
const onError = vi.fn();
app.addHook('onError', async (_req, _reply, error) => {
onError(error.statusCode);
});
app.post('/api/action', handler);
await app.listen({ host: '127.0.0.1', port: 0 });
const port = (app.server.address() as { port: number }).port;
await new Promise<void>((resolve) => {
const socket = connect({ host: '127.0.0.1', port });
socket.on('connect', () =>
socket.end('POST /api/action HTTP/1.1\r\nHost: localhost\r\nTransfer-Encoding: chunked\r\n\r\n')
);
socket.resume();
socket.on('close', () => resolve());
});
await vi.waitFor(() => expect(onError).toHaveBeenCalledWith(400));
expect(handler).not.toHaveBeenCalled();
});
});
+95
View File
@@ -53,6 +53,15 @@ function loadCodemanAppClass() {
return (context as { __CodemanApp: { prototype: { _linkifyFilePaths(root: unknown): void } } }).__CodemanApp;
}
const markedContext = vm.createContext({});
vm.runInContext(
readFileSync(resolve(import.meta.dirname, '../src/web/public/vendor/marked.min.js'), 'utf8'),
markedContext
);
const marked = markedContext.marked as { parse(text: string, options?: object): string };
/** The options `_renderMarkdown` passes, so the tests see chat's real markup (a newline becomes `<br>`). */
const renderChat = (markdown: string) => marked.parse(markdown, { breaks: true, gfm: true });
const CodemanApp = loadCodemanAppClass();
const APP_SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
@@ -69,6 +78,92 @@ function linkify(html: string): HTMLElement {
const paths = (root: HTMLElement) => Array.from(root.querySelectorAll('a.rv-path'));
describe('response viewer file-path linkifier', () => {
it('links a parenthesised configuration filename with spaces without changing the prose', () => {
const path = '/mnt/user/Scratch/Kobo/Set-KoboCovers/Kobo eReader.conf';
const text = `Prepared a corrected configuration (${path}).`;
const root = linkify(`<p>${text}</p>`);
expect(paths(root).map((a) => a.dataset.path)).toEqual([path]);
expect(paths(root)[0].dataset.folder).toBeUndefined();
expect(root.textContent).toBe(text);
});
it.each([
['- 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx', ['Design/Build-SIPOC.pptx']],
['- notes/a b/c.md', ['b/c.md']],
[
'**Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx',
['03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'],
],
['**Saved to:** ~/My Docs/plan.md', ['~/My Docs/plan.md']],
['- **Path:** notes/a b/c.md', ['notes/a b/c.md']],
['- /mnt/c/My Docs/a.docx', ['/mnt/c/My Docs/a.docx']],
['**Saved to:** /mnt/c/My Docs/a.docx', ['/mnt/c/My Docs/a.docx']],
['**Tests:** 45/45 pass in test/foo.test.ts', ['test/foo.test.ts']],
['**Error:** I/O failure reading data.json', ['data.json']],
[
'Done.\n**Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx',
['03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'],
],
[
'**Size:** 2 MB\n**Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx',
['03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'],
],
])('links rendered Markdown with consumed markers and labels: %s', (markdown, expected) => {
const html = renderChat(markdown);
const original = document.createElement('div');
original.innerHTML = html;
const root = linkify(html);
expect(paths(root).map((a) => a.getAttribute('data-path'))).toEqual(expected);
expect(root.textContent).toBe(original.textContent);
expect(root.querySelector('strong')?.textContent).toBe(original.querySelector('strong')?.textContent);
});
it.each([
['**Wrote:** 120 lines to src/foo.ts', ['src/foo.ts']],
['- **Output:** stored in dist/app.js', ['dist/app.js']],
['**Created:** new file src/components/Button.tsx', ['src/components/Button.tsx']],
['**Files:** src/components and utils/x.ts', ['utils/x.ts']],
['**Wrote:** 3 files to docs/design notes/overview.md', ['notes/overview.md']],
['**Path:** /home/me/proj/out contains a.md', ['/home/me/proj/out', 'a.md']],
['- /tmp/out has the logs in run.log', ['/tmp/out', 'run.log']],
])('keeps prose out of the link in rendered Markdown: %s', (markdown, expected) => {
expect(paths(linkify(renderChat(markdown))).map((a) => a.getAttribute('data-path'))).toEqual(expected);
});
// The terminal splits these lines (`Note:` is not a path label, and the path does
// not start the line), so the viewer must split them too.
it.each([
['**Note:** /home/me/proj/out report.pdf', ['/home/me/proj/out', 'report.pdf']],
['Use `--config` /home/me/proj/cfg data.json', ['/home/me/proj/cfg', 'data.json']],
])('splits a spaced candidate the terminal splits: %s', (markdown, expected) => {
const html = renderChat(markdown);
const original = document.createElement('div');
original.innerHTML = html;
const root = linkify(html);
expect(paths(root).map((a) => a.getAttribute('data-path'))).toEqual(expected);
expect(root.textContent).toBe(original.textContent);
});
it('still links a spaced path inside inline code whole, after another inline element too', () => {
const root = linkify(renderChat('Use `--config` `/home/me/My Docs/cfg data.json`'));
const links = paths(root);
expect(links.map((a) => a.getAttribute('data-path'))).toEqual(['/home/me/My Docs/cfg data.json']);
expect(links[0].closest('code')).not.toBeNull();
});
it('does not inherit a bold label from earlier prose or another block', () => {
for (const markdown of [
'See **Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx',
'**Saved to:**\n\n03 Attachments/Build SIPOC Design/Build-SIPOC.pptx',
'Done.\nSee **Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx',
]) {
expect(paths(linkify(renderChat(markdown))).map((a) => a.getAttribute('data-path'))).toEqual([
'Design/Build-SIPOC.pptx',
]);
}
});
it('links an absolute path written as prose', () => {
const path = '/tmp/claude-1000/-home-arkon-default-claudeman/7b3fefd2/scratchpad/probe-run-native.png';
const root = linkify(`<p>Saved the capture to ${path} — have a look.</p>`);
+262
View File
@@ -0,0 +1,262 @@
/**
* @fileoverview A middle click on a placeholder link in rendered markdown
* (`CodemanApp._bindResponseViewerInteractions`).
*
* File-path links (`a.rv-path`) and in-document links carry `href="#"` /
* `href="#frag"`: placeholders the click delegate resolves itself. A middle
* click fires `auxclick`, never `click`, so the browser used to follow the
* placeholder and open the dashboard root (`<base href="/">` + `#`) in a new
* tab. A middle click on a placeholder now does exactly what a click does; a
* real URL keeps the browser's own middle-click behaviour.
*
* Both surfaces are covered: the Response Viewer's messages
* (`_buildResponseViewerMessage` + `_linkifyFilePaths`) and the File Viewer's
* rendered markdown, whose rebased links name their own session.
*
* Drives the shipping `constants.js`, `app.js` and `terminal-ui.js` (for
* `_resolveLinkedFilePath`) in a `vm` with a jsdom window injected (the
* technique from response-viewer-file-links.test.ts; a per-file jsdom
* environment would externalize node:fs under vite).
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const publicFile = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
const dom = new JSDOM('<!DOCTYPE html><html><head><base href="/"></head><body></body></html>', {
url: 'http://localhost:3000/',
});
const win = dom.window;
const { document } = win;
// app.js instantiates the whole app on DOMContentLoaded; let it pass before app.js is loaded.
if (document.readyState === 'loading') await new Promise((r) => document.addEventListener('DOMContentLoaded', r));
type App = {
activeSessionId: string;
sessions: Map<string, { workingDir: string }>;
openFilePreview: ReturnType<typeof vi.fn>;
openFolderGrid: ReturnType<typeof vi.fn>;
openLinkThroughWebTabIfLoopback: ReturnType<typeof vi.fn>;
_linkifyFilePaths(root: Element): void;
_bindResponseViewerInteractions(body: Element): void;
};
function loadCodemanAppClass(): { prototype: App } {
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
fetch: vi.fn(),
document,
NodeFilter: win.NodeFilter,
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
// The real window, so constants.js publishes CodemanMarkdownAnchors where the delegate reads it.
window: win,
MobileDetection: {},
});
vm.runInContext(
`${publicFile('constants.js')}\n${publicFile('app.js')}\n${publicFile('terminal-ui.js')}\nglobalThis.__CodemanApp = CodemanApp;`,
context
);
return (context as unknown as { __CodemanApp: { prototype: App } }).__CodemanApp;
}
const CodemanApp = loadCodemanAppClass();
let app: App;
beforeEach(() => {
app = Object.create(CodemanApp.prototype) as App;
app.activeSessionId = 'sess-1';
app.sessions = new Map([
['sess-1', { workingDir: '/home/me/proj' }],
['sess-2', { workingDir: '/srv/other' }],
]);
app.openFilePreview = vi.fn();
app.openFolderGrid = vi.fn();
// A loopback URL would be handed to a web tab on a left click; a middle click must not reach it.
app.openLinkThroughWebTabIfLoopback = vi.fn(() => true);
});
/** A Response Viewer message body: rendered `.rv-text` inside the bound container. */
function responseViewer(html: string): HTMLElement {
const body = document.createElement('div');
const text = document.createElement('div');
text.className = 'rv-text';
text.innerHTML = html;
app._linkifyFilePaths(text);
body.appendChild(text);
document.body.replaceChildren(body);
app._bindResponseViewerInteractions(body);
return body;
}
function press(el: Element, type: 'auxclick' | 'click', init: MouseEventInit = {}): MouseEvent {
const ev = new win.MouseEvent(type, { bubbles: true, cancelable: true, ...init });
el.dispatchEvent(ev);
return ev;
}
function noneCalled(): void {
expect(app.openFilePreview).not.toHaveBeenCalled();
expect(app.openFolderGrid).not.toHaveBeenCalled();
expect(app.openLinkThroughWebTabIfLoopback).not.toHaveBeenCalled();
}
describe('middle click on a Response Viewer path link', () => {
it('opens a relative file path in the preview, resolved against the active session', () => {
const body = responseViewer('<p>wrote src/app.ts</p>');
const link = body.querySelector('a.rv-path')!;
expect(link.getAttribute('href')).toBe('#');
const ev = press(link, 'auxclick', { button: 1 });
expect(ev.defaultPrevented).toBe(true);
expect(app.openFilePreview.mock.calls).toEqual([['/home/me/proj/src/app.ts', 'sess-1']]);
expect(app.openFolderGrid).not.toHaveBeenCalled();
expect(app.openLinkThroughWebTabIfLoopback).not.toHaveBeenCalled();
});
it('opens a markdown link whose destination is a path, like a click does', () => {
const body = responseViewer('<p><a href="/home/me/run/summary.json" target="_blank">record</a></p>');
const ev = press(body.querySelector('a.rv-path')!, 'auxclick', { button: 1 });
expect(ev.defaultPrevented).toBe(true);
expect(app.openFilePreview.mock.calls).toEqual([['/home/me/run/summary.json', 'sess-1']]);
});
it('opens a folder path in the folder grid', () => {
const body = responseViewer('<p><a href="/tmp/renders/">renders</a></p>');
const ev = press(body.querySelector('a.rv-path')!, 'auxclick', { button: 1 });
expect(ev.defaultPrevented).toBe(true);
expect(app.openFolderGrid.mock.calls).toEqual([['/tmp/renders/', 'sess-1']]);
expect(app.openFilePreview).not.toHaveBeenCalled();
});
it('does the same with Ctrl held, as Ctrl+click does', () => {
const body = responseViewer('<p>wrote src/app.ts</p>');
const ev = press(body.querySelector('a.rv-path')!, 'auxclick', { button: 1, ctrlKey: true });
expect(ev.defaultPrevented).toBe(true);
expect(app.openFilePreview.mock.calls).toEqual([['/home/me/proj/src/app.ts', 'sess-1']]);
});
it.each([
['right', 2],
['back', 3],
['forward', 4],
])('ignores the %s button', (_name, button) => {
const body = responseViewer('<p>wrote src/app.ts</p>');
const ev = press(body.querySelector('a.rv-path')!, 'auxclick', { button });
expect(ev.defaultPrevented).toBe(false);
noneCalled();
});
it('opens once per gesture: a left click still goes through click alone', () => {
const body = responseViewer('<p>wrote src/app.ts</p>');
// Binding is idempotent: a second bind must not add a second auxclick listener.
app._bindResponseViewerInteractions(body);
const link = body.querySelector('a.rv-path')!;
press(link, 'auxclick', { button: 1 });
expect(app.openFilePreview).toHaveBeenCalledTimes(1);
app.openFilePreview.mockClear();
const click = press(link, 'click', { button: 0 });
expect(click.defaultPrevented).toBe(true);
expect(app.openFilePreview.mock.calls).toEqual([['/home/me/proj/src/app.ts', 'sess-1']]);
});
});
describe('middle click on an in-document link', () => {
it('scrolls to the heading in place instead of following the fragment', () => {
const body = responseViewer('<h2>Install</h2><p>see <a href="#install">install</a></p>');
const heading = body.querySelector('h2')! as HTMLElement & { scrollIntoView: ReturnType<typeof vi.fn> };
heading.scrollIntoView = vi.fn();
const ev = press(body.querySelector('a[href="#install"]')!, 'auxclick', { button: 1 });
expect(ev.defaultPrevented).toBe(true);
// jsdom has no matchMedia, so reduced motion reads as off.
expect(heading.scrollIntoView.mock.calls).toEqual([[{ block: 'start', behavior: 'smooth' }]]);
noneCalled();
});
it('ignores a right click on it', () => {
const body = responseViewer('<h2>Install</h2><p>see <a href="#install">install</a></p>');
const heading = body.querySelector('h2')! as HTMLElement & { scrollIntoView: ReturnType<typeof vi.fn> };
heading.scrollIntoView = vi.fn();
const ev = press(body.querySelector('a[href="#install"]')!, 'auxclick', { button: 2 });
expect(ev.defaultPrevented).toBe(false);
expect(heading.scrollIntoView).not.toHaveBeenCalled();
});
});
describe('middle click on a real URL', () => {
it.each(['https://example.com/a.json', 'http://localhost:5173/'])('leaves %s to the browser', (href) => {
const body = responseViewer(`<p><a href="${href}" target="_blank" rel="noopener noreferrer">x</a></p>`);
expect(body.querySelector('a.rv-path')).toBeNull();
const ev = press(body.querySelector('a')!, 'auxclick', { button: 1 });
expect(ev.defaultPrevented).toBe(false);
noneCalled();
});
});
describe('middle click in the File Viewer rendered markdown', () => {
it('opens a rebased link in the session the preview was opened for, not the active one', () => {
// The shape _rebaseFilePreviewMarkdownRefs gives a workspace link (panels-ui.js).
const bodyEl = document.createElement('div');
bodyEl.innerHTML =
'<div class="rv-text file-preview-md"><a class="rv-path" href="#" data-path="/srv/other/notes.md" data-session-id="sess-2">notes</a></div>';
document.body.replaceChildren(bodyEl);
app._bindResponseViewerInteractions(bodyEl);
const ev = press(bodyEl.querySelector('a.rv-path')!, 'auxclick', { button: 1 });
expect(ev.defaultPrevented).toBe(true);
expect(app.openFilePreview.mock.calls).toEqual([['/srv/other/notes.md', 'sess-2']]);
});
});
describe('a left click keeps its behaviour', () => {
it('opens a path link in the preview', () => {
const body = responseViewer('<p>wrote src/app.ts</p>');
const ev = press(body.querySelector('a.rv-path')!, 'click', { button: 0 });
expect(ev.defaultPrevented).toBe(true);
expect(app.openFilePreview.mock.calls).toEqual([['/home/me/proj/src/app.ts', 'sess-1']]);
expect(app.openLinkThroughWebTabIfLoopback).not.toHaveBeenCalled();
});
it('hands a loopback URL to the web-tab hook', () => {
const body = responseViewer(
'<p><a href="http://localhost:5173/" target="_blank" rel="noopener noreferrer">dev server</a></p>'
);
const ev = press(body.querySelector('a')!, 'click', { button: 0 });
expect(ev.defaultPrevented).toBe(true);
expect(app.openLinkThroughWebTabIfLoopback.mock.calls).toEqual([['http://localhost:5173/']]);
expect(app.openFilePreview).not.toHaveBeenCalled();
});
});
+355
View File
@@ -0,0 +1,355 @@
/** @fileoverview Agent inspection and guarded input HTTP contracts (no live ports). */
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
AGENT_COORDINATION_DISABLED_MESSAGE,
registerAgentSessionRoutes,
} from '../../src/web/routes/agent-session-routes.js';
import { InspectionReader } from '../../src/agent-inspection.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
let harness: RouteTestHarness;
/** The routes are opt-in (`agentCoordinationEnabled`, default OFF); undefined = key absent. */
function setEnabled(on: boolean | undefined): void {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify(on === undefined ? {} : { agentCoordinationEnabled: on }));
}
beforeEach(() => setEnabled(true));
afterEach(async () => {
await harness?.app.close();
rmSync(SETTINGS_PATH, { force: true });
});
describe('the agentCoordinationEnabled gate', () => {
const requests = (id: string) => [
{ method: 'GET' as const, url: `/api/sessions/${id}/inspect` },
{ method: 'GET' as const, url: `/api/sessions/${id}/inspect?maxBytes=0` },
{ method: 'POST' as const, url: `/api/sessions/${id}/stage`, payload: { message: 'hello' } },
{
method: 'POST' as const,
url: `/api/sessions/${id}/submit`,
payload: { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) },
},
];
it.each([
['absent', undefined],
['false', false],
] as const)('answers 403 naming the setting on every route while it is %s', async (_label, value) => {
setEnabled(value);
harness = await createRouteTestHarness(registerAgentSessionRoutes);
const session = [...harness.ctx.sessions.values()][0];
// A session id that does not exist is refused the same way: the gate runs before lookup.
for (const request of [...requests(session.id), ...requests('no-such-session')]) {
const result = await harness.app.inject(request);
expect(result.statusCode).toBe(403);
expect(result.json()).toEqual({
success: false,
error: AGENT_COORDINATION_DISABLED_MESSAGE,
errorCode: 'FORBIDDEN',
});
}
expect(AGENT_COORDINATION_DISABLED_MESSAGE).toContain('agentCoordinationEnabled');
expect(session.writeBuffer).toEqual([]);
});
it('answers on every route once it is on, with no restart', async () => {
setEnabled(false);
harness = await createRouteTestHarness(registerAgentSessionRoutes);
const session = [...harness.ctx.sessions.values()][0];
expect((await harness.app.inject(requests(session.id)[0])).statusCode).toBe(403);
setEnabled(true);
const [inspect, invalid, stage, submit] = await Promise.all(
requests(session.id).map((request) => harness.app.inject(request))
);
expect(inspect.statusCode).toBe(200);
expect(inspect.json().data.source).toBe('pane-snapshot');
expect(invalid.statusCode).toBe(400);
// The mock session has no characterized composer, so the guards refuse: the route answered.
expect(stage.statusCode).toBe(409);
expect(submit.statusCode).toBe(409);
});
});
it('reports a non-cursor inspection failure as OPERATION_FAILED and logs only the session and error code', async () => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const { Session } = await import('../../src/session.js');
const root = await mkdtemp(join(tmpdir(), 'agent-inspect-fail-'));
const home = join(root, 'codex');
await mkdir(join(home, 'sessions'), { recursive: true });
const session = new Session({
id: 'inspect-fail',
workingDir: root,
mode: 'codex',
envOverrides: { CODEX_HOME: home },
});
const id = '11111111-1111-4111-8111-111111111111';
const path = join(home, 'sessions', `rollout-${id}.jsonl`);
await writeFile(
path,
JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n'
);
harness = await createRouteTestHarness(registerAgentSessionRoutes);
(harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session);
const read = vi
.spyOn(InspectionReader.prototype, 'read')
.mockRejectedValue(Object.assign(new Error(`EACCES: permission denied, open '${path}'`), { code: 'EACCES' }));
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` });
expect(read).toHaveBeenCalled();
expect(result.statusCode).toBe(422);
expect(result.json().errorCode).toBe('OPERATION_FAILED');
expect(result.body).not.toContain(path);
const logged = warn.mock.calls.map((call) => call.join(' ')).join('\n');
expect(logged).toContain(session.id);
expect(logged).toContain('EACCES');
expect(logged).not.toContain(home);
} finally {
read.mockRestore();
warn.mockRestore();
await session.stop(false);
await rm(root, { recursive: true, force: true });
}
});
describe('agent session routes', () => {
it('returns an explicitly bounded pane snapshot when no attributed transcript exists', async () => {
harness = await createRouteTestHarness(registerAgentSessionRoutes);
const session = [...harness.ctx.sessions.values()][0];
const result = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=20` });
expect(result.statusCode).toBe(200);
const body = result.json();
expect(body.data.source).toBe('pane-snapshot');
expect(body.data.cursor).toBeNull();
expect(Buffer.byteLength(body.data.evidence)).toBeLessThanOrEqual(20);
});
it('refuses guarded staging for an uncharacterized composer', async () => {
harness = await createRouteTestHarness(registerAgentSessionRoutes);
const session = [...harness.ctx.sessions.values()][0];
const result = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/stage`,
payload: { message: 'hello' },
});
expect(result.statusCode).toBe(409);
});
});
it('checks ownership before inspection, stage and submit', async () => {
process.env.CODEMAN_MULTIUSER = '1';
try {
harness = await createRouteTestHarness(registerAgentSessionRoutes, {
authUser: { username: 'alice', role: 'user' },
});
const session = [...harness.ctx.sessions.values()][0];
session.owner = 'bob';
for (const request of [
{ method: 'GET' as const, url: `/api/sessions/${session.id}/inspect` },
{ method: 'POST' as const, url: `/api/sessions/${session.id}/stage`, payload: { message: 'hello' } },
{
method: 'POST' as const,
url: `/api/sessions/${session.id}/submit`,
payload: { stageId: '11111111-1111-4111-8111-111111111111', confirmSha: 'a'.repeat(64) },
},
])
expect((await harness.app.inject(request)).statusCode).toBe(404);
expect(session.writeBuffer).toEqual([]);
} finally {
delete process.env.CODEMAN_MULTIUSER;
}
});
it('validates output budgets and refuses to resume a pane fallback', async () => {
harness = await createRouteTestHarness(registerAgentSessionRoutes);
const session = [...harness.ctx.sessions.values()][0];
session.terminalBuffer = '😀é'.repeat(20);
for (const value of ['0', '-1', '65537', '1.5', 'NaN'])
expect(
(await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=${value}` })).statusCode
).toBe(400);
const result = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?maxBytes=7` })).json();
expect(result.data.evidence).not.toContain('�');
expect(Buffer.byteLength(result.data.evidence)).toBeLessThanOrEqual(7);
expect(result.data.truncated).toBe(true);
expect(
(
await harness.app.inject({
url: `/api/sessions/${session.id}/inspect?since=11111111-1111-4111-8111-111111111111`,
})
).statusCode
).toBe(409);
});
it('uses real session writes to stage without Enter and refuses intervening API input', async () => {
const { Session } = await import('../../src/session.js');
const { formatPaneSnapshot } = await import('../../src/tmux-manager.js');
harness = await createRouteTestHarness(registerAgentSessionRoutes);
let composer = '';
const writes: string[] = [];
const session = new Session({
id: 'guarded-session',
workingDir: '/tmp',
mode: 'codex',
mux: {
isAvailable: () => true,
capturePaneBuffer: (
_name: string,
_pane?: string,
opts?: { capturedGeometry?: { cols: number; rows: number } }
) => {
if (opts) opts.capturedGeometry = { cols: 100, rows: 20 };
return formatPaneSnapshot([`› ${composer}`, ' GPT-6-Luna default · ~/case'], {
cols: 100,
rows: 20,
cursorX: 2 + composer.length,
cursorY: 0,
});
},
sendInput: async (_id: string, text: string, options?: { beforeDispatch?: () => boolean }) => {
if (options?.beforeDispatch && !options.beforeDispatch()) return false;
writes.push(text);
composer += text;
return true;
},
},
muxSession: { muxName: 'codeman-test', sessionId: 'guarded-session', createdAt: 1, pid: 1 },
} as unknown as ConstructorParameters<typeof Session>[0]);
(harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session);
try {
const stage = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/stage`,
payload: { message: 'hello' },
});
expect(stage.statusCode).toBe(200);
expect(writes).toEqual(['hello']);
const { stageId, sha256 } = stage.json().data;
const submit = () =>
harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/submit`,
payload: { stageId, confirmSha: sha256 },
});
expect((await submit()).statusCode).toBe(200);
expect(writes).toEqual(['hello', '\r']);
expect((await submit()).statusCode).toBe(409);
composer = '';
const next = (
await harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/stage`,
payload: { message: 'again' },
})
).json().data;
await session.writeViaMux('x');
composer = 'again'; // Even restoring the visible text cannot erase the input revision.
const stale = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/submit`,
payload: { stageId: next.stageId, confirmSha: next.sha256 },
});
expect(stale.statusCode).toBe(409);
expect(writes.filter((s) => s === '\r')).toHaveLength(1);
// Codex's command popup opens on `/`; Enter would run the highlighted command.
composer = '';
writes.length = 0;
const slash = await harness.app.inject({
method: 'POST',
url: `/api/sessions/${session.id}/stage`,
payload: { message: '/stat' },
});
expect(slash.statusCode).toBe(409);
expect(slash.json().error).toMatch(/starting with "\/".*codeman agent send/);
expect(writes).toEqual([]);
} finally {
await session.stop(false);
}
});
it('reads only a strictly attributed Codex rollout and binds HTTP cursors to its owner', async () => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const { Session } = await import('../../src/session.js');
const root = await mkdtemp(join(tmpdir(), 'agent-inspect-route-'));
const home = join(root, 'codex');
await mkdir(join(home, 'sessions'), { recursive: true });
const session = new Session({
id: 'inspect-session',
workingDir: root,
mode: 'codex',
envOverrides: { CODEX_HOME: home },
});
const id = '11111111-1111-4111-8111-111111111111';
const row =
JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator: `codeman_${session.id}` } }) + '\n';
await writeFile(join(home, 'sessions', `rollout-${id}.jsonl`), row + '{"text":"hello"}\n');
harness = await createRouteTestHarness(registerAgentSessionRoutes, {
authUser: { username: 'alice', role: 'admin' },
});
(harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session);
try {
const first = await harness.app.inject({
url: `/api/sessions/${session.id}/inspect?maxBytes=${Buffer.byteLength(row)}`,
});
expect(first.statusCode).toBe(200);
const data = first.json().data;
expect(data.source).toBe('codex-jsonl');
expect(data.evidence).toBe('{\"text\":\"hello\"}\n');
expect(data.omittedBeforeBytes).toBe(Buffer.byteLength(row));
expect(data.identity).toEqual({ sessionId: session.id, mode: 'codex', conversationId: id });
expect(data.cursor).not.toContain(home);
const next = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` });
expect(next.json().data.evidence).toBe('');
const access = session as unknown as { ptyProcess: { resize(cols: number, rows: number): void } | null };
access.ptyProcess = { resize: () => {} };
session.resize(80, 24);
access.ptyProcess = null;
expect(
(await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` })).statusCode
).toBe(200);
session.owner = 'changed-owner';
expect(
(await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${data.cursor}` })).statusCode
).toBe(409);
} finally {
await session.stop(false);
await rm(root, { recursive: true, force: true });
}
});
it('refuses a stale launch-resume cursor once a new managed rollout conflicts with it', async () => {
const { mkdtemp, mkdir, writeFile, rm } = await import('node:fs/promises');
const { tmpdir } = await import('node:os');
const { join } = await import('node:path');
const { Session } = await import('../../src/session.js');
const root = await mkdtemp(join(tmpdir(), 'agent-resume-conflict-'));
const home = join(root, 'codex');
const old = '11111111-1111-4111-8111-111111111111',
fresh = '22222222-2222-4222-8222-222222222222';
await mkdir(join(home, 'sessions'), { recursive: true });
const session = new Session({
id: 'resumed-session',
workingDir: root,
mode: 'codex',
envOverrides: { CODEX_HOME: home },
codexConfig: { resumeSessionId: old },
});
const row = (id: string, originator: string) =>
JSON.stringify({ type: 'session_meta', payload: { id, cwd: root, originator } }) + '\n';
await writeFile(join(home, 'sessions', `rollout-${old}.jsonl`), row(old, 'original-launch'));
harness = await createRouteTestHarness(registerAgentSessionRoutes);
(harness.ctx.sessions as unknown as Map<string, Session>).set(session.id, session);
try {
const initial = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` })).json().data;
expect(initial.source).toBe('codex-jsonl');
await writeFile(join(home, 'sessions', `rollout-${fresh}.jsonl`), row(fresh, `codeman_${session.id}`));
const stale = await harness.app.inject({ url: `/api/sessions/${session.id}/inspect?since=${initial.cursor}` });
expect(stale.statusCode).toBe(409);
const fallback = (await harness.app.inject({ url: `/api/sessions/${session.id}/inspect` })).json().data;
expect(fallback.source).toBe('pane-snapshot');
expect(fallback.cursor).toBeNull();
} finally {
await session.stop(false);
await rm(root, { recursive: true, force: true });
}
});
@@ -0,0 +1,389 @@
/**
* @fileoverview POST /api/cases/clone with a `path`: clone INTO that folder (an empty or not yet existing one,
* with no subfolder added) and register it as a linked case. Real filesystem under test/setup.ts's temp
* HOME and a REAL `git` cloning a local bare repo (the path policy itself is in test/case-path.test.ts).
* Port: N/A (app.inject()).
*
* Like case-custom-path-routes.test.ts this suite rewrites the linked-cases registry, so it refuses to
* start outside the sandboxed HOME (a raw `npx vitest` would reach the real ~/.codeman).
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { execFileSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
import { dataPath } from '../../src/config/instance.js';
import {
classifyGitFailure,
cloneRepository,
getActiveGitOperationCount,
getQueuedGitOperationCount,
isGitAvailable,
} from '../../src/git-clone.js';
import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js';
const LINKED = () => dataPath('linked-cases.json');
const linked = (): Record<string, string> => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {});
const clone = (app: Awaited<ReturnType<typeof createRouteTestHarness>>['app'], payload: Record<string, unknown>) =>
app.inject({ method: 'POST', url: '/api/cases/clone', payload });
describe.skipIf(!isGitAvailable())('POST /api/cases/clone with a custom folder', () => {
let root: string;
let origin: string;
let workDir = '';
const git = (args: string[], cwd: string) =>
execFileSync('git', args, { cwd, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
beforeAll(() => {
root = mkdtempSync(join(tmpdir(), 'codeman-clone-custom-'));
origin = join(root, 'origin.git');
mkdirSync(origin);
git(['init', '--bare', '--quiet'], origin);
const work = join(root, 'work');
mkdirSync(work);
git(['init', '--quiet'], work);
git(['config', 'user.email', 'test@example.com'], work);
git(['config', 'user.name', 'Codeman Test'], work);
writeFileSync(join(work, 'README.md'), '# fixture\n');
git(['add', '.'], work);
git(['commit', '--quiet', '-m', 'initial'], work);
git(['branch', '-M', 'main'], work);
git(['remote', 'add', 'origin', origin], work);
git(['push', '--quiet', 'origin', 'main'], work);
git(['symbolic-ref', 'HEAD', 'refs/heads/main'], origin);
});
afterAll(() => rmSync(root, { recursive: true, force: true }));
beforeEach(() => {
if (!isSandboxedHome()) {
throw new Error('case-clone-custom-folder.test.ts rewrites the linked-cases registry: run it via npm test');
}
safeRmHomeTree(LINKED());
// Resolved, because the server answers with the symlink-resolved folder.
workDir = realpathSync(mkdtempSync(join(homedir(), 'clone-custom-')));
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
if (workDir) safeRmHomeTree(workDir);
workDir = '';
if (isSandboxedHome()) safeRmHomeTree(LINKED());
});
it('clones into the chosen folder, scaffolds it and registers it as a linked case', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'my-clone');
const res = await clone(app, { name: 'my-clone', repository: origin, path: target });
expect(res.statusCode).toBe(200);
expect(res.json().data.case).toEqual({ name: 'my-clone', path: target });
expect(existsSync(join(target, 'README.md'))).toBe(true);
expect(existsSync(join(target, '.git'))).toBe(true);
expect(existsSync(join(target, '.claude', 'settings.local.json'))).toBe(true);
expect(linked()).toEqual({ 'my-clone': target });
expect(ctx.broadcast).toHaveBeenCalledWith('case:created', { name: 'my-clone', path: target });
// Nothing lands under the cases directory.
expect(existsSync(join(homedir(), 'codeman-cases', 'my-clone'))).toBe(false);
});
it('expands ~ in the folder', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const folder = workDir.slice(homedir().length + 1);
const res = await clone(app, { name: 'tilde', repository: origin, path: `~/${folder}/tilde` });
expect(res.statusCode).toBe(200);
expect(existsSync(join(workDir, 'tilde', 'README.md'))).toBe(true);
});
it('refuses a parent folder that does not exist, and creates nothing', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await clone(app, { name: 'orphan', repository: origin, path: join(workDir, 'nope', 'orphan') });
expect(res.statusCode).toBe(404);
expect(existsSync(join(workDir, 'nope'))).toBe(false);
expect(linked()).toEqual({});
});
it('refuses a target folder that already has files, and leaves them alone', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'busy');
mkdirSync(target);
writeFileSync(join(target, 'mine.txt'), 'keep me');
const res = await clone(app, { name: 'busy', repository: origin, path: target });
expect(res.statusCode).toBe(409);
expect(res.json().error).toMatch(/already has files/);
expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me');
expect(linked()).toEqual({});
});
it('clones straight INTO the chosen empty folder, with no subfolder named after the case', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'picked');
mkdirSync(target);
const res = await clone(app, { name: 'a-different-name', repository: origin, path: target });
expect(res.statusCode).toBe(200);
expect(res.json().data.case).toEqual({ name: 'a-different-name', path: target });
expect(existsSync(join(target, 'README.md'))).toBe(true);
expect(existsSync(join(target, '.git'))).toBe(true);
expect(existsSync(join(target, 'a-different-name'))).toBe(false);
expect(linked()).toEqual({ 'a-different-name': target });
});
it('leaves the empty folder in place, and empty, when the clone fails', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'stays');
mkdirSync(target);
const res = await clone(app, { name: 'stays', repository: join(root, 'no-such-repo.git'), path: target });
expect(res.json().success).toBe(false);
expect(existsSync(target)).toBe(true);
expect(readdirSync(target)).toEqual([]);
expect(linked()).toEqual({});
});
it('two clones into one empty folder at once: exactly one wins and its files survive', async () => {
const target = join(workDir, 'raced');
mkdirSync(target);
const results = await Promise.all([
cloneRepository({ repository: origin, destination: target, intoEmptyDir: true }),
cloneRepository({ repository: origin, destination: target, intoEmptyDir: true }),
]);
expect(results.filter((r) => r.ok)).toHaveLength(1);
const loser = results.find((r) => !r.ok);
expect(loser && !loser.ok && loser.failure.code).toBe('DESTINATION_EXISTS');
expect(existsSync(join(target, 'README.md'))).toBe(true);
expect(existsSync(join(target, '.git'))).toBe(true);
});
it('a name linked while the clone ran is a 409 that keeps the folder and names it', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const [a, b] = await Promise.all([
clone(app, { name: 'twin', repository: origin, path: join(workDir, 'twin-a') }),
clone(app, { name: 'twin', repository: origin, path: join(workDir, 'twin-b') }),
]);
const loserDir = a.json().success ? join(workDir, 'twin-b') : join(workDir, 'twin-a');
const loser = a.json().success ? b : a;
expect([a.json().success, b.json().success].filter(Boolean)).toHaveLength(1);
expect(loser.statusCode).toBe(409);
expect(loser.json().errorCode).toBe('ALREADY_EXISTS');
expect(loser.json().error).toContain(loserDir);
expect(existsSync(join(loserDir, 'README.md'))).toBe(true);
expect(Object.keys(linked())).toEqual(['twin']);
});
it('a direct clone whose git-slot wait times out leaves what was written into the folder meanwhile', async () => {
const target = join(workDir, 'queued');
mkdirSync(target);
// Two slow ssh clones hold both git slots (no network needed: the ssh command just sleeps).
const prior = process.env.GIT_SSH_COMMAND;
process.env.GIT_SSH_COMMAND = 'sleep 4; true';
try {
const holders = [0, 1].map((i) =>
cloneRepository({
repository: 'ssh://localhost/x.git',
destination: join(workDir, `hold-${i}`),
timeoutMs: 10_000,
})
);
await vi.waitFor(() => expect(getActiveGitOperationCount()).toBe(2));
const queued = cloneRepository({
repository: origin,
destination: target,
intoEmptyDir: true,
timeoutMs: 1_000,
});
await vi.waitFor(() => expect(getQueuedGitOperationCount()).toBe(1));
// Written during the wait, so not this attempt's: a user copying files in, or another case created there.
writeFileSync(join(target, 'mine.txt'), 'keep me');
mkdirSync(join(target, 'src'));
const result = await queued;
expect(!result.ok && result.failure.code).toBe('TIMEOUT');
expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me');
expect(existsSync(join(target, 'src'))).toBe(true);
await Promise.all(holders);
} finally {
if (prior === undefined) delete process.env.GIT_SSH_COMMAND;
else process.env.GIT_SSH_COMMAND = prior;
}
}, 30_000);
it('refuses a folder at or inside a Docker case workspace, and creates nothing', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const workspace = join(workDir, 'dockerws');
mkdirSync(workspace);
writeFileSync(
join(dataPath('docker-cases.json')),
JSON.stringify([{ name: 'box', type: 'docker', hostId: 'h', hostWorkspacePath: workspace, container: 'c' }])
);
try {
for (const path of [workspace, join(workspace, 'repo')]) {
const res = await clone(app, { name: 'inside', repository: origin, path });
expect(res.json().errorCode, path).toBe('INVALID_INPUT');
expect(res.json().error, path).toMatch(/Docker case workspace/);
}
expect(readdirSync(workspace)).toEqual([]);
expect(linked()).toEqual({});
// A sibling of the workspace is fine.
const ok = await clone(app, { name: 'beside', repository: origin, path: join(workDir, 'beside') });
expect(ok.statusCode).toBe(200);
} finally {
safeRmHomeTree(dataPath('docker-cases.json'));
}
});
it('fails closed when the Docker case list cannot be read', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
writeFileSync(dataPath('docker-cases.json'), '{not json');
try {
const res = await clone(app, { name: 'blind', repository: origin, path: join(workDir, 'blind') });
expect(res.statusCode).toBe(503);
expect(existsSync(join(workDir, 'blind'))).toBe(false);
} finally {
safeRmHomeTree(dataPath('docker-cases.json'));
}
});
it.each(['plain', 'openssl', 'rustls', 'does not exist'])(
'a refused folder named %s keeps what was written meanwhile and reports DESTINATION_EXISTS',
async (folder) => {
const target = join(workDir, folder);
mkdirSync(target);
const prior = process.env.GIT_SSH_COMMAND;
process.env.GIT_SSH_COMMAND = 'sleep 2; true';
try {
const holders = [0, 1].map((i) =>
cloneRepository({
repository: 'ssh://localhost/x.git',
destination: join(workDir, `hold-${i}`),
timeoutMs: 10_000,
})
);
await vi.waitFor(() => expect(getActiveGitOperationCount()).toBe(2));
// Queues behind the holders but has time to get a slot, so git runs and refuses the folder.
const queued = cloneRepository({
repository: origin,
destination: target,
intoEmptyDir: true,
timeoutMs: 15_000,
});
await vi.waitFor(() => expect(getQueuedGitOperationCount()).toBe(1));
writeFileSync(join(target, 'mine.txt'), 'keep me');
const result = await queued;
expect(!result.ok && result.failure.code).toBe('DESTINATION_EXISTS');
expect(readFileSync(join(target, 'mine.txt'), 'utf8')).toBe('keep me');
await Promise.all(holders);
} finally {
if (prior === undefined) delete process.env.GIT_SSH_COMMAND;
else process.env.GIT_SSH_COMMAND = prior;
}
},
30_000
);
it('classifies git stderr by what it says, not by words in a path it quotes', () => {
const refusal = (dir: string) => `fatal: destination path '${dir}' already exists and is not an empty directory.`;
for (const dir of ['/home/u/src/openssl', '/home/u/rustls', '/x/tls-test', '/x/repository not found']) {
expect(classifyGitFailure(refusal(dir), false).code, dir).toBe('DESTINATION_EXISTS');
}
expect(classifyGitFailure('fatal: unable to access: SSL certificate problem: self signed', false).code).toBe(
'HOST_UNREACHABLE'
);
expect(classifyGitFailure('fatal: TLS handshake failure', false).code).toBe('HOST_UNREACHABLE');
// `_` is a word character, so a bare \bssl\b misses OpenSSL's and LibreSSL's usual flaky-network error.
expect(
classifyGitFailure(
"fatal: unable to access 'https://github.com/o/r/': OpenSSL SSL_connect: SSL_ERROR_SYSCALL in connection to github.com:443",
false
).code
).toBe('HOST_UNREACHABLE');
expect(classifyGitFailure('fatal: could not resolve host: example.invalid', false).code).toBe('HOST_UNREACHABLE');
expect(classifyGitFailure("fatal: repository 'https://x/y.git/' not found", false).code).toBe('NOT_FOUND');
});
it('refuses a clone without a path into the cases folder when it is a Docker case workspace', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const casesDir = join(homedir(), 'codeman-cases');
mkdirSync(casesDir, { recursive: true });
writeFileSync(
dataPath('docker-cases.json'),
JSON.stringify([{ name: 'box', type: 'docker', hostId: 'h', hostWorkspacePath: casesDir, container: 'c' }])
);
try {
const res = await clone(app, { name: 'default-inside', repository: origin });
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(res.json().error).toMatch(/Docker case workspace/);
expect(existsSync(join(casesDir, 'default-inside'))).toBe(false);
} finally {
safeRmHomeTree(dataPath('docker-cases.json'));
}
});
it('points Clone Repo at itself, not Create New, for the cases folder and a folder directly in it', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const casesDir = join(homedir(), 'codeman-cases');
mkdirSync(casesDir, { recursive: true });
for (const path of [casesDir, join(casesDir, 'direct-child')]) {
const res = await clone(app, { name: 'direct-child', repository: origin, path });
expect(res.statusCode, path).toBe(400);
expect(res.json().error, path).toMatch(/clone there without a custom folder/);
expect(res.json().error, path).not.toMatch(/Create New/);
}
});
it('refuses a blocked tree such as ~/.ssh', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(homedir(), '.ssh'), { recursive: true });
const res = await clone(app, { name: 'sneaky', repository: origin, path: join(homedir(), '.ssh', 'sneaky') });
expect(res.statusCode).toBe(400);
expect(existsSync(join(homedir(), '.ssh', 'sneaky'))).toBe(false);
});
it('refuses a name already linked, before running git', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
writeFileSync(LINKED(), JSON.stringify({ taken: join(workDir, 'elsewhere') }));
const res = await clone(app, { name: 'taken', repository: origin, path: join(workDir, 'taken') });
// The harness has no preSerialization status mapping, so read the envelope's code.
expect(res.json().errorCode).toBe('ALREADY_EXISTS');
expect(existsSync(join(workDir, 'taken'))).toBe(false);
});
it('leaves no folder behind when the clone fails', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const target = join(workDir, 'ghost');
const res = await clone(app, { name: 'ghost', repository: join(root, 'no-such-repo.git'), path: target });
expect(res.json().errorCode).toBe('NOT_FOUND');
expect(existsSync(target)).toBe(false);
expect(linked()).toEqual({});
});
it('without a path the clone still goes under the cases directory and is not linked', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const res = await clone(app, { name: 'custom-folder-default', repository: origin });
try {
expect(res.statusCode).toBe(200);
expect(res.json().data.case.path).toBe(join(homedir(), 'codeman-cases', 'custom-folder-default'));
expect(linked()).toEqual({});
} finally {
safeRmHomeTree(join(homedir(), 'codeman-cases', 'custom-folder-default'));
}
});
it('multi-user: a non-admin is refused (403) and nothing is cloned; an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const user = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
const denied = await clone(user.app, { name: 'bobs', repository: origin, path: join(workDir, 'bobs') });
expect(denied.statusCode).toBe(403);
expect(existsSync(join(workDir, 'bobs'))).toBe(false);
expect(linked()).toEqual({});
const admin = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'root', role: 'admin' } });
const ok = await clone(admin.app, { name: 'roots', repository: origin, path: join(workDir, 'roots') });
expect(ok.statusCode).toBe(200);
});
});
+111 -1
View File
@@ -3,13 +3,16 @@
* read and write it, and the create routes that carry the case into the real
* TabLayoutService so a new session lands in its case's group in the creating
* write (POST /api/sessions, and POST /api/quick-start for local, remote and
* Docker cases).
* Docker cases). The tab "+" picker (app.js) is driven end to end against the
* same routes, since an ungrouped tab relies on this fallback by design.
*
* Uses app.inject(), so no real HTTP port is needed.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { readFileSync } from 'node:fs';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { JSDOM } from 'jsdom';
import { homedir } from 'node:os';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
@@ -101,6 +104,56 @@ const unwrap = (raw: string) => {
return parsed.data ?? parsed;
};
/**
* The shipping frontend in JSDOM, with fetch answered by the route harnesses, so
* a click in the tab "+" picker reaches the real create route and layout service.
*/
async function bootTabApp(route: (url: string, init?: { method?: string; body?: string }) => Promise<unknown>) {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', {
url: 'https://localhost/',
runScripts: 'outside-only',
});
if (dom.window.document.readyState !== 'complete') {
await new Promise((resolve) => dom.window.addEventListener('load', resolve));
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const win = dom.window as any;
win.setInterval = () => 0;
win.requestAnimationFrame = () => 0;
win.__codemanCliCatalog = [{ id: 'shell', kind: 'shell', label: 'Shell', enabled: true }];
const publicDir = join(process.cwd(), 'src/web/public');
win.eval(
'var MobileDetection = { isTouchDevice: () => false, getDeviceType: () => "desktop" }, KeyboardHandler = {}, ' +
'SwipeHandler = {}, VoiceInput = {}, DeepgramProvider = {}, NotificationManager = function(){};\n' +
['constants.js', 'app.js', 'mobile-overview.js', 'session-ui.js']
.map((file) => readFileSync(join(publicDir, file), 'utf8'))
.join('\n;\n') +
'\n;window.__CdgCodemanApp = CodemanApp;'
);
win.fetch = async (url: string, init?: { method?: string; body?: string }) => {
const body = await route(url, init);
return { ok: true, status: 200, json: async () => body };
};
win.document.body.innerHTML = '<div id="sessionTabs"></div><select id="quickStartCase"></select>';
const app = Object.create(win.__CdgCodemanApp.prototype);
Object.assign(app, {
$: (id: string) => win.document.getElementById(id),
sessions: new Map(),
activeSessionId: 'tab-1',
terminal: { clear: () => {}, writeln: () => {}, focus: () => {}, cols: 80, rows: 24 },
_runMinLockMs: 0,
_runMode: 'claude',
getTerminalDimensions: () => null,
selectSession: async () => {},
loadQuickStartCases: () => {},
showToast: vi.fn(),
_applyTabLayout: () => {},
_onSessionCreated: () => {},
_renderSessionTabsImmediate: () => {},
});
return { app, win };
}
describe('case default group: create routes', () => {
let harness: RouteTestHarness;
let world: World;
@@ -280,6 +333,63 @@ describe('case default group: create routes', () => {
// The route's own error path ran cleanupSession for the registered id.
expect(harness.ctx.cleanupSession).toHaveBeenCalledTimes(1);
});
describe('from a tab "+" in the browser', () => {
/** Opens the tab's "+" picker, picks Shell, and returns the create request and the new session's group. */
async function launchBeside(tabGroupId: string | null) {
const cases = await createRouteTestHarness(registerCaseRoutes);
const creates: Array<Record<string, unknown>> = [];
try {
const { app, win } = await bootTabApp(async (url, init) => {
const payload = init?.body ? JSON.parse(init.body) : undefined;
if (url === '/api/sessions' && init?.method === 'POST') creates.push(payload);
const target = url.startsWith('/api/cases/') ? cases : harness;
const res = await target.app.inject({ method: (init?.method ?? 'GET') as 'GET', url, payload });
// The envelope server.ts's preSerialization hook adds, which the harness does not install.
const body = res.body ? JSON.parse(res.body) : {};
return typeof body.success === 'boolean' ? body : { success: true, data: body };
});
app.cases = [{ name: CASE, path: caseDir }];
app.sessions.set('tab-1', { id: 'tab-1', name: 'w1-cdg', workingDir: caseDir });
app.tabLayout = {
...world.layouts[OWNER],
groups: world.layouts[OWNER].groups.map((g) => ({
...g,
refs: g.id === tabGroupId ? [{ kind: 'session', id: 'tab-1' }] : [],
})),
};
app.collapsedTabGroupIds = new Set();
app.toggleTabGroupCollapsed = () => {};
const plus = win.document.createElement('span');
win.document.body.appendChild(plus);
app.openTabNewSessionMenu({ preventDefault() {}, stopPropagation() {}, currentTarget: plus }, 'tab-1');
const shell = [...win.document.querySelectorAll('[aria-label="New session here"] button')].find(
(button: Element) => button.textContent === 'Terminal / Shell'
) as HTMLElement;
shell.click();
await vi.waitFor(() => expect(creates).toHaveLength(1));
await vi.waitFor(() => expect(harness.ctx.sessions.size).toBe(1));
const [id] = [...harness.ctx.sessions.keys()];
return { create: creates[0], group: groupOf(world.layouts[OWNER], id), toasts: app.showToast.mock.calls };
} finally {
await cases.app.close();
}
}
it('an ungrouped tab sends no group, so the case default group places the session (chosen on purpose)', async () => {
const { create, group, toasts } = await launchBeside(null);
expect(create).toEqual({ workingDir: caseDir, mode: 'shell', name: `s1-${CASE}` });
expect(group).toBe('g-ops');
// The launch went through: no error reported along the way.
expect(toasts.filter(([, kind]: [string, string]) => kind === 'error')).toEqual([]);
});
it('a grouped tab sends its own group, which wins over the case default', async () => {
const { create, group } = await launchBeside('g-core');
expect(create).toEqual({ workingDir: caseDir, mode: 'shell', name: `s1-${CASE}`, tabGroupId: 'g-core' });
expect(group).toBe('g-core');
});
});
});
describe('case default group: GET/PUT /api/cases/:name/default-group', () => {
+75 -3
View File
@@ -10,6 +10,7 @@ import {
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
realpathSync,
symlinkSync,
@@ -25,6 +26,7 @@ import { SETTINGS_PATH, resolveCasesDir } from '../../src/web/route-helpers.js';
import { caseSessionSettled, claudeProjectDirName } from '../../src/case-rename.js';
import { Session } from '../../src/session.js';
import { safeRmHomeTree } from '../mocks/index.js';
import { mutateSettingsFile } from '../../src/web/settings-writer.js';
const LINKED = () => dataPath('linked-cases.json');
const CASES_DIR = () => join(homedir(), 'codeman-cases');
@@ -286,6 +288,64 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' });
});
it('puts back only the keys it wrote, keeping a settings write made in between', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj' }));
chmodSync(LINKED(), 0o444);
// Another writer (a settings save) lands after the rename wrote settings.json and before the
// registry write fails.
const realWriteFile = fs.writeFile;
const spy = vi.spyOn(fs, 'writeFile').mockImplementation(async (file, data, options) => {
if (file === LINKED()) await mutateSettingsFile((current) => ({ ...current, agentSkillEnabled: true }));
return realWriteFile(file, data, options);
});
try {
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
expect(res.statusCode).toBe(500);
} finally {
spy.mockRestore();
}
expect(settings()).toEqual({ caseOrder: ['a', 'proj', 'b'], lastUsedCase: 'proj', agentSkillEnabled: true });
});
it('refuses to rename while settings.json is not a JSON object, leaving the folder and the file alone', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
const torn = '{\n "agentSkillEnabled": true\n}tail"\n}';
writeFileSync(SETTINGS_PATH, torn);
const res = await rename(app, 'alpha', { newName: 'beta' });
expect(res.statusCode).toBe(409);
expect(res.json()).toEqual({
success: false,
errorCode: 'CONFLICT',
error: `${SETTINGS_PATH} is not a valid JSON object, so this change was not saved (saving would have replaced every other setting in it). Fix or delete the file, or restore it from a config backup, then try again.`,
});
expect(existsSync(oldDir)).toBe(true);
expect(existsSync(join(CASES_DIR(), 'beta'))).toBe(false);
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(torn);
});
it('checks settings.json before it stops any session, so none is ended for a rename that cannot complete', async () => {
const { app, ctx } = await createRouteTestHarness(registerCaseRoutes);
const oldDir = localCase('alpha');
const torn = '{\n "agentSkillEnabled": true\n}tail"\n}';
writeFileSync(SETTINGS_PATH, torn);
(ctx.sessions as Map<string, unknown>).set('s1', { id: 's1', name: 'w1', workingDir: oldDir });
const res = await rename(app, 'alpha', { newName: 'beta', stopSessions: true });
expect(res.statusCode).toBe(409);
expect(res.json().errorCode).toBe('CONFLICT');
expect(res.json().error).toContain('is not a valid JSON object');
expect(ctx.cleanupSession).not.toHaveBeenCalled();
expect(existsSync(oldDir)).toBe(true);
expect(existsSync(join(CASES_DIR(), 'beta'))).toBe(false);
expect(readFileSync(SETTINGS_PATH, 'utf8')).toBe(torn);
});
it('says where the folder is when it cannot be moved back', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
const real = join(work, 'my-project');
@@ -328,12 +388,24 @@ describe('POST /api/cases/:name/rename (a linked case)', () => {
mkdirSync(real);
writeFileSync(LINKED(), JSON.stringify({ proj: real, other: join(work, 'x') }));
writeFileSync(SETTINGS_PATH, JSON.stringify({ caseOrder: ['proj'] }));
chmodSync(SETTINGS_PATH, 0o444);
const res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
chmodSync(SETTINGS_PATH, 0o644);
// settings.json is replaced by renaming a temp file over it, which a read-only file does not stop,
// so the failure is injected at that rename.
const realRename = fs.rename;
const spy = vi.spyOn(fs, 'rename').mockImplementation(async (from, to) => {
if (to === SETTINGS_PATH) throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
return realRename(from, to);
});
let res;
try {
res = await rename(app, 'proj', { newName: 'renamed', renameFolder: true });
} finally {
spy.mockRestore();
}
expect(res.statusCode).toBe(500);
expect(existsSync(real)).toBe(true);
expect(linked()).toEqual({ proj: real, other: join(work, 'x') });
// The failed write leaves no temp file behind.
expect(readdirSync(dirname(SETTINGS_PATH)).filter((n) => n.startsWith('settings.json.'))).toEqual([]);
});
it('answers 404 for a linked case a non-admin cannot see in multi-user mode', async () => {
+17 -1
View File
@@ -4,7 +4,7 @@
*
* ⚠️ test/setup.ts gives the whole FILE one temp HOME (and so one data dir). Port: N/A (inject()).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
@@ -120,6 +120,22 @@ describe('/api/config-backups', () => {
}
});
it('a restored discord.json reaches the running bridge (it caches its config)', async () => {
const reloadDiscord = vi.fn(async () => undefined);
const { app } = await createRouteTestHarness((a) => registerConfigBackupRoutes(a, { reloadDiscord }));
writeFileSync(join(dirname(SETTINGS_PATH), 'discord.json'), '{"enabled":false}', { mode: 0o600 });
const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
writeFileSync(join(dirname(SETTINGS_PATH), 'discord.json'), '{"enabled":true}', { mode: 0o600 });
const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` });
expect(res.json().data.restored).toContain('discord.json');
expect(reloadDiscord).toHaveBeenCalledTimes(1);
// A restore that does not carry the file leaves the bridge alone.
rmSync(join(dirname(SETTINGS_PATH), 'discord.json'), { force: true });
const id2 = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id;
await app.inject({ method: 'POST', url: `/api/config-backups/${id2}/restore` });
expect(reloadDiscord).toHaveBeenCalledTimes(1);
});
it('a restored intents.json is not reverted by the next captured prompt', async () => {
const { app } = await createRouteTestHarness(registerConfigBackupRoutes);
const dir = mkdtempSync(join(tmpdir(), 'cfg-backup-intent-'));
+164 -1
View File
@@ -11,7 +11,7 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { registerCustomModelRoutes } from '../../src/web/routes/custom-model-routes.js';
import { webviewFetch } from '../../src/web/webview-egress.js';
import { createRouteTestHarness } from './_route-test-utils.js';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
vi.mock('../../src/web/webview-egress.js', async () => {
const actual = await vi.importActual<typeof import('../../src/web/webview-egress.js')>(
@@ -417,3 +417,166 @@ describe('apiKey is never handed back to the browser', () => {
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});
describe('context-length discovery from the /v1/models listing', () => {
// The gateway path (a Bifrost in front of llama-swap, an OpenRouter-style server):
// no /running, no /props, just a per-model `context_length` or `context_window` in the
// model listing. Distinct ports per test keep the file's shared data dir unambiguous.
afterEach(() => {
fetchMock.mockReset();
});
type StoredHost = { id: string; modelContextLengths?: Record<string, number>; defaultContextLength?: number };
type HarnessApp = RouteTestHarness['app'];
async function endpointWith(id: string, port: number, extra: Record<string, unknown> = {}): Promise<HarnessApp> {
const { app } = await setup();
const res = await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id, label: 'A', baseUrl: `http://localhost:${port}`, ...extra },
});
expect(res.statusCode).toBe(200);
return app;
}
async function storedHost(app: HarnessApp, id: string): Promise<StoredHost | undefined> {
const list = await app.inject({ method: 'GET', url: '/api/model-endpoints' });
return (list.json() as StoredHost[]).find((h) => h.id === id);
}
it('reads context_length (preferred) then context_window from each entry into modelContextLengths', async () => {
const app = await endpointWith('ep-ctx-listed', 8101);
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
data: [
{ id: 'both', context_length: 131072, context_window: 32768 },
{ id: 'window-only', context_window: 8192 },
{ id: 'no-figure' },
],
}),
{ status: 200 }
)
);
const discover = await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-listed/discover-models' });
expect(discover.json().success).toBe(true);
const stored = await storedHost(app, 'ep-ctx-listed');
expect(stored?.modelContextLengths).toEqual({ both: 131072, 'window-only': 8192 });
});
it('ignores non-numeric, zero, negative and absurd listed values; the schema bound itself is still accepted', async () => {
const app = await endpointWith('ep-ctx-bad', 8102);
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
data: [
{ id: 'string', context_length: '131072' },
{ id: 'zero', context_window: 0 },
{ id: 'negative', context_length: -10 },
{ id: 'over-bound', context_length: 100_000_001 },
{ id: 'fractional', context_window: 16384.5 },
{ id: 'at-bound', context_length: 100_000_000 },
],
}),
{ status: 200 }
)
);
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-bad/discover-models' });
const stored = await storedHost(app, 'ep-ctx-bad');
expect(stored?.modelContextLengths).toEqual({ 'at-bound': 100_000_000 });
});
it('prefers the /running launch command over the listed figure for a loaded model', async () => {
const app = await endpointWith('ep-ctx-cmd', 8103);
fetchMock.mockImplementation(async (url: URL) => {
if (url.pathname === '/v1/models') {
return new Response(
JSON.stringify({ data: [{ id: 'cmd-wins', status: { value: 'loaded' }, context_length: 131072 }] }),
{ status: 200 }
);
}
if (url.pathname === '/running') {
return new Response(
JSON.stringify({ running: [{ model: 'cmd-wins', state: 'ready', cmd: 'llama-server --ctx-size 16384' }] }),
{ status: 200 }
);
}
if (url.pathname === '/props') return new Response('nope', { status: 404 });
throw new Error(`unexpected request in this test: ${url.href}`);
});
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-cmd/discover-models' });
const stored = await storedHost(app, 'ep-ctx-cmd');
expect(stored?.modelContextLengths).toEqual({ 'cmd-wins': 16384 });
});
it('keeps a previously learned value the fresh listing says nothing about, and discovery never touches defaultContextLength', async () => {
const app = await endpointWith('ep-ctx-keep', 8104, {
models: ['re-listed', 'untouched'],
modelContextLengths: { 're-listed': 16384, untouched: 4096 },
defaultContextLength: 65536,
});
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({ data: [{ id: 're-listed', context_length: 32768 }, { id: 'untouched' }] }),
{ status: 200 }
)
);
await app.inject({ method: 'POST', url: '/api/model-endpoints/ep-ctx-keep/discover-models' });
const stored = await storedHost(app, 'ep-ctx-keep');
expect(stored?.modelContextLengths).toEqual({ 're-listed': 32768, untouched: 4096 });
expect(stored?.defaultContextLength).toBe(65536);
});
});
describe('defaultContextLength (the user-set fallback discovery never overwrites)', () => {
afterEach(() => {
fetchMock.mockReset();
});
it('stores it on create, updates it on PUT, and a PUT without it clears it', async () => {
const { app } = await setup();
const create = await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'ep-defctx', label: 'A', baseUrl: 'http://localhost:8105', defaultContextLength: 131072 },
});
expect(create.json().data.host.defaultContextLength).toBe(131072);
const update = await app.inject({
method: 'PUT',
url: '/api/model-endpoints/ep-defctx',
payload: { label: 'A', baseUrl: 'http://localhost:8105', defaultContextLength: 32768 },
});
expect(update.json().data.host.defaultContextLength).toBe(32768);
const cleared = await app.inject({
method: 'PUT',
url: '/api/model-endpoints/ep-defctx',
payload: { label: 'A', baseUrl: 'http://localhost:8105' },
});
expect(cleared.json().data.host.defaultContextLength).toBeUndefined();
});
it('rejects zero, negative, fractional, over-the-bound and non-numeric values', async () => {
const { app } = await setup();
for (const defaultContextLength of [0, -1, 1.5, 100_000_001, '131072']) {
const res = await app.inject({
method: 'POST',
url: '/api/model-endpoints',
payload: { id: 'ep-defctx-bad', label: 'A', baseUrl: 'http://localhost:8106', defaultContextLength },
});
expect(res.json().success, String(defaultContextLength)).toBe(false);
expect(res.json().errorCode, String(defaultContextLength)).toBe('INVALID_INPUT');
}
});
});
@@ -0,0 +1,196 @@
/**
* @fileoverview The last message a CLI wrote, read through the response viewer's reader (the one the
* Discord bridge posts from), with a message that asks SEVERAL questions: every question must come
* back whole, in order, on its own line, and survive the split into Discord posts.
*
* Uses app.inject() with a temporary HOME and CODEX_HOME; no real ports or user transcripts.
* Port: N/A
*
* Covered here: claude (JSONL transcript), codex (rollout), the codex-shaped pane CLIs (opencode,
* gemini, antigravity, pi) and copilot (its own pane dialect). deepseek and omp read their own
* transcript formats and are covered by their reader's tests.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { splitForDiscord } from '../../src/discord-bridge.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
const QUESTIONS = [
'Should the sweep run daily or hourly?',
'Do you want archived threads deleted as well?',
'Is one channel per Codeman acceptable?',
];
const INTRO = 'I have three questions before I continue:';
const OUTRO = 'Once you answer, I will apply the change.';
/** The message as the CLI wrote it: an intro, a numbered list of questions, an outro. */
const MESSAGE = [INTRO, '', ...QUESTIONS.map((q, i) => `${i + 1}. ${q}`), '', OUTRO].join('\n');
/** Every question present, in order, each on a line of its own. */
function expectWholeQuestions(text: string): void {
let from = 0;
for (const q of QUESTIONS) {
const at = text.indexOf(q, from);
expect(at, `missing or out of order: ${q}`).toBeGreaterThanOrEqual(from);
from = at + q.length;
}
const lines = text.split('\n').map((l) => l.trim());
for (const [i, q] of QUESTIONS.entries()) expect(lines.some((l) => l.endsWith(q))).toBe(true);
expect(text).toContain(INTRO);
expect(text).toContain(OUTRO);
// What goes to Discord: the same questions after the split, joined back.
expectWholeQuestionsAfterSplit(text);
}
function expectWholeQuestionsAfterSplit(text: string): void {
const joined = splitForDiscord(text).join('\n');
for (const q of QUESTIONS) expect(joined).toContain(q);
}
async function createHarness(): Promise<{ app: FastifyInstance; ctx: MockRouteContext }> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
app.addHook('preSerialization', (req, _reply, payload: unknown, done) => {
if (!req.url.startsWith('/api') || payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown };
return done(null, p.success === undefined ? { success: true, data: payload } : payload);
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('the last message with several questions, per CLI', () => {
let harness: { app: FastifyInstance; ctx: MockRouteContext };
let home: string;
let prevHome: string | undefined;
let prevCodexHome: string | undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let session: any;
async function lastText(): Promise<string> {
const res = await harness.app.inject({ method: 'GET', url: `/api/sessions/${session.id}/last-response` });
return JSON.parse(res.body).data.text as string;
}
beforeEach(async () => {
home = mkdtempSync(join(tmpdir(), 'codeman-discord-last-'));
prevHome = process.env.HOME;
prevCodexHome = process.env.CODEX_HOME;
process.env.HOME = home;
process.env.CODEX_HOME = join(home, '.codex');
harness = await createHarness();
session = harness.ctx._session;
});
afterEach(async () => {
if (prevHome === undefined) delete process.env.HOME;
else process.env.HOME = prevHome;
if (prevCodexHome === undefined) delete process.env.CODEX_HOME;
else process.env.CODEX_HOME = prevCodexHome;
rmSync(home, { recursive: true, force: true });
await harness.app.close();
});
it('claude: reads the whole message from the JSONL transcript', async () => {
const id = '40568a29-d4eb-4eb6-b671-8401428e4f39';
session.mode = 'claude';
session.claudeSessionId = id;
session.adoptClaudeSessionId = () => undefined;
const dir = join(home, '.claude', 'projects', '-workspace');
mkdirSync(dir, { recursive: true });
const row = (type: string, text: string, ts: string) => ({
type,
timestamp: ts,
message: { content: [{ type: 'text', text }] },
});
writeFileSync(
join(dir, `${id}.jsonl`),
[row('user', 'plan the cleanup', '2026-10-30T00:00:00Z'), row('assistant', MESSAGE, '2026-10-30T00:00:01Z')]
.map((e) => JSON.stringify(e))
.join('\n')
);
const text = await lastText();
expect(text).toBe(MESSAGE);
expectWholeQuestions(text);
});
it('codex: reads the whole message from the rollout', async () => {
const uuid = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
const workdir = join(home, 'work');
mkdirSync(workdir, { recursive: true });
session.mode = 'codex';
session.workingDir = workdir;
session.codexConfig = { resumeSessionId: uuid };
const dir = join(home, '.codex', 'sessions', '2026', '10', '30');
mkdirSync(dir, { recursive: true });
const file = join(dir, `rollout-2026-10-30T00-00-00-${uuid}.jsonl`);
const entries = [
{
timestamp: '2026-10-30T00:00:00Z',
type: 'session_meta',
payload: { id: uuid, cwd: workdir, originator: 'codex_cli_rs' },
},
{
timestamp: '2026-10-30T00:00:01Z',
type: 'event_msg',
payload: { type: 'user_message', message: 'plan the cleanup' },
},
{
timestamp: '2026-10-30T00:00:02Z',
type: 'response_item',
payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: MESSAGE }] },
},
];
writeFileSync(file, entries.map((e) => JSON.stringify(e)).join('\n') + '\n');
utimesSync(file, 1_750_000_000, 1_750_000_000);
const text = await lastText();
expectWholeQuestions(text);
});
for (const mode of ['opencode', 'gemini', 'antigravity', 'pi'] as const) {
it(`${mode}: reads the whole message from the pane`, async () => {
session.mode = mode;
session.terminalBuffer = [
'› plan the cleanup',
'',
'model · project · main · Ready · Context 100% left',
'',
'• Called write_file',
'',
MESSAGE,
].join('\n');
expectWholeQuestions(await lastText());
});
}
it('copilot: reads the whole message from the pane screen', async () => {
session.mode = 'copilot';
const screen = [
' ❯ plan the cleanup 10:01',
'',
` ● ${INTRO}`,
'',
...QUESTIONS.map((q, i) => ` ${i + 1}. ${q}`),
'',
` ${OUTRO}`,
'',
' ~/project Session: 1 AIC used',
'────────────────────────────────',
' ❯ ',
'────────────────────────────────',
' ← open sidebar · / commands · ? help',
].join('\n');
harness.ctx.mux = { ...(harness.ctx.mux as object), capturePaneText: () => screen } as typeof harness.ctx.mux;
expectWholeQuestions(await lastText());
});
});
+354
View File
@@ -0,0 +1,354 @@
/**
* @fileoverview /api/discord: the bot token is a bearer secret, so it is never returned and the
* settings routes are admin only in multi-user mode. Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { _resetNotifyBuckets, registerDiscordRoutes } from '../../src/web/routes/discord-routes.js';
import { DiscordBridge, discordConfigPath, readDiscordConfig, type DiscordFetch } from '../../src/discord-bridge.js';
const TOKEN = 'MTA1.secret.token-value-1234567890';
const FULL = {
enabled: true,
token: TOKEN,
guildId: '111111111111111111',
parentChannelId: '222222222222222222',
allowedUserIds: ['333333333333333333'],
};
let dir: string;
let fetchImpl: ReturnType<typeof vi.fn<DiscordFetch>>;
async function harness(authUser?: { username: string; role: 'admin' | 'user' }) {
const bridge = new DiscordBridge({
configDir: dir,
fetchImpl,
sendInput: async () => true,
socketFactory: () => ({ send() {}, close() {}, on() {} }),
});
const h = await createRouteTestHarness(
(app, ctx) => registerDiscordRoutes(app, { bridge, configDir: dir, ctx, hostTitle: () => 'codeman:test' }),
authUser ? { authUser } : undefined
);
return { ...h, bridge };
}
beforeEach(() => {
_resetNotifyBuckets();
dir = mkdtempSync(join(tmpdir(), 'discord-routes-'));
fetchImpl = vi.fn<DiscordFetch>(
async () => new Response(JSON.stringify({ id: '444444444444444444' }), { status: 200 })
);
});
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
rmSync(dir, { recursive: true, force: true });
});
describe('/api/discord', () => {
it('starts disabled with no token', async () => {
const { app } = await harness();
const data = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data;
expect(data).toMatchObject({ enabled: false, hasToken: false, tokenMasked: '', gateway: 'off' });
expect(data).not.toHaveProperty('token');
});
it('saves, masks the token in every response and writes the file 0600', async () => {
const { app, bridge } = await harness();
const put = await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
expect(put.statusCode).toBe(200);
expect(put.body).not.toContain('secret');
expect(put.json().data).toMatchObject({ enabled: true, hasToken: true });
const get = await app.inject({ method: 'GET', url: '/api/discord' });
expect(get.body).not.toContain('secret');
expect(statSync(discordConfigPath(dir)).mode & 0o777).toBe(0o600);
expect((await readDiscordConfig(dir)).token).toBe(TOKEN);
bridge.stop();
});
it('keeps the token when only an option changes, and clears it on an empty string', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
await app.inject({
method: 'PUT',
url: '/api/discord',
payload: { pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' },
});
const cfg = await readDiscordConfig(dir);
expect(cfg).toMatchObject({ token: TOKEN, pingMode: 'all', onSessionDelete: 'delete', staleThreads: 'archive' });
const cleared = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: false, token: '' } });
expect(cleared.json().data.hasToken).toBe(false);
bridge.stop();
});
it('stores startupHint (default on) and returns it', async () => {
const { app, bridge } = await harness();
const first = (await app.inject({ method: 'GET', url: '/api/discord' })).json().data;
expect(first).toMatchObject({ startupHint: true });
const put = await app.inject({
method: 'PUT',
url: '/api/discord',
payload: { startupHint: false },
});
expect(put.json().data).toMatchObject({ startupHint: false });
bridge.stop();
});
it('refuses a bad id, an incomplete enable, and unknown fields', async () => {
const { app } = await harness();
const bad = await app.inject({ method: 'PUT', url: '/api/discord', payload: { guildId: 'not-a-number' } });
expect(bad.statusCode).toBe(400);
const incomplete = await app.inject({ method: 'PUT', url: '/api/discord', payload: { enabled: true } });
expect(incomplete.statusCode).toBe(400);
const extra = await app.inject({ method: 'PUT', url: '/api/discord', payload: { nope: 1 } });
expect(extra.statusCode).toBe(400);
});
it('POST /test needs a saved token, then posts to the parent channel', async () => {
const { app, bridge } = await harness();
expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(400);
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const res = await app.inject({ method: 'POST', url: '/api/discord/test' });
expect(res.json().data.ok).toBe(true);
expect(String(fetchImpl.mock.calls.at(-1)![0])).toContain('/channels/222222222222222222/messages');
bridge.stop();
});
it('is admin only in multi-user mode', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await harness({ username: 'bob', role: 'user' });
expect((await app.inject({ method: 'GET', url: '/api/discord' })).statusCode).toBe(403);
expect((await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL })).statusCode).toBe(403);
expect((await app.inject({ method: 'POST', url: '/api/discord/test' })).statusCode).toBe(403);
});
});
describe('POST /api/sessions/:id/discord-notify', () => {
const url = '/api/sessions/test-session-1/discord-notify';
it('is refused while the bridge is off, and posts into the session thread once on', async () => {
const { app, bridge } = await harness();
const url = `/api/sessions/test-session-1/discord-notify`;
const off = await app.inject({ method: 'POST', url, payload: { message: 'press reset' } });
expect(off.statusCode).toBe(409);
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const on = await app.inject({ method: 'POST', url, payload: { message: 'press reset', urgent: true } });
expect(on.json().data.ok).toBe(true);
const sent = fetchImpl.mock.calls.map((c) => String(c[1].body));
expect(sent.some((b) => b.includes('press reset') && b.includes('<@333333333333333333>'))).toBe(true);
bridge.stop();
});
it('accepts a unique 8-character session id prefix (Docker exports it cut), not an ambiguous or short one', async () => {
const { app, bridge, ctx } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const ok = await app.inject({
method: 'POST',
url: '/api/sessions/test-ses/discord-notify',
payload: { message: 'hi' },
});
expect(ok.statusCode).toBe(200);
ctx.sessions.set('test-session-2', ctx.sessions.get('test-session-1')!);
const ambiguous = await app.inject({
method: 'POST',
url: '/api/sessions/test-ses/discord-notify',
payload: { message: 'hi' },
});
expect(ambiguous.statusCode).toBe(404);
const short = await app.inject({
method: 'POST',
url: '/api/sessions/test/discord-notify',
payload: { message: 'hi' },
});
expect(short.statusCode).toBe(404);
bridge.stop();
});
it("does not reveal another user's session through its id prefix (multi-user)", async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app, bridge, ctx } = await harness({ username: 'bob', role: 'user' });
(ctx.sessions.get('test-session-1') as unknown as { owner: string }).owner = 'alice';
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-ses/discord-notify',
payload: { message: 'hi' },
});
expect(res.statusCode).toBe(404);
expect(res.json().error).not.toContain('test-session-1');
bridge.stop();
});
it('lets two different deliberate messages from one tab through within 3 s (neither is lost)', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const first = await app.inject({ method: 'POST', url, payload: { message: 'first thing' } });
const second = await app.inject({ method: 'POST', url, payload: { message: 'second thing' } });
expect(first.statusCode).toBe(200);
expect(second.statusCode).toBe(200);
expect(second.json().data.deduped).toBeUndefined();
const sent = fetchImpl.mock.calls.map((c) => String(c[1].body));
expect(sent.filter((b) => b.includes('first thing') || b.includes('second thing'))).toHaveLength(2);
bridge.stop();
});
it('answers 200 with data.ok false when Discord refuses the post, which the CLI turns into exit 1', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
fetchImpl.mockImplementation(async () => new Response(JSON.stringify({ code: 0 }), { status: 401 }));
const res = await app.inject({ method: 'POST', url, payload: { message: 'help' } });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ ok: false, status: 401 });
bridge.stop();
});
it('answers 429 RATE_LIMITED once a tab posts more than its share in a minute', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
const codes: number[] = [];
for (let i = 0; i < 8; i++) {
codes.push(
(await app.inject({ method: 'POST', url, payload: { message: `ping ${i}`, urgent: true } })).statusCode
);
}
expect(codes.slice(0, 6)).toEqual([200, 200, 200, 200, 200, 200]);
expect(codes.slice(6)).toEqual([429, 429]);
const limited = await app.inject({ method: 'POST', url, payload: { message: 'again' } });
expect(limited.json().errorCode).toBe('RATE_LIMITED');
expect(limited.headers['retry-after']).toBe('60');
bridge.stop();
});
it('does not spend rate-limit tokens on a request refused for its file', async () => {
const { app, bridge } = await harness();
await app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
for (let i = 0; i < 8; i++) {
const bad = await app.inject({ method: 'POST', url, payload: { message: 'x', file: 'no-such-file.txt' } });
expect(bad.statusCode).toBe(400);
}
expect((await app.inject({ method: 'POST', url, payload: { message: 'still allowed' } })).statusCode).toBe(200);
bridge.stop();
});
it('rejects an empty or oversized message', async () => {
const { app } = await harness();
const url = `/api/sessions/test-session-1/discord-notify`;
expect((await app.inject({ method: 'POST', url, payload: { message: '' } })).statusCode).toBe(400);
expect((await app.inject({ method: 'POST', url, payload: { message: 'x'.repeat(2000) } })).statusCode).toBe(400);
});
});
describe('discord-notify with a file', () => {
let work: string;
let outside: string;
const url = '/api/sessions/test-session-1/discord-notify';
async function ready() {
const h = await harness();
(h.ctx.sessions.get('test-session-1') as unknown as { workingDir: string }).workingDir = work;
await h.app.inject({ method: 'PUT', url: '/api/discord', payload: FULL });
return h;
}
const post = (app: Awaited<ReturnType<typeof harness>>['app'], file: string) =>
app.inject({ method: 'POST', url, payload: { message: 'log attached', file } });
beforeEach(() => {
work = mkdtempSync(join(tmpdir(), 'discord-work-'));
outside = mkdtempSync(join(tmpdir(), 'discord-outside-'));
writeFileSync(join(work, 'run.log'), 'hello');
writeFileSync(join(outside, 'secret.txt'), 'nope');
});
afterEach(() => {
rmSync(work, { recursive: true, force: true });
rmSync(outside, { recursive: true, force: true });
});
it('uploads a file inside the session folder as multipart', async () => {
const { app, bridge } = await ready();
const res = await post(app, join(work, 'run.log'));
expect(res.json().data.ok).toBe(true);
const form = fetchImpl.mock.calls.map((c) => c[1].body).find((b) => b instanceof FormData) as FormData;
expect((form.get('files[0]') as File).name).toBe('run.log');
bridge.stop();
});
it.each([
['a relative path', () => 'run.log'],
['a file outside the folder', () => join(outside, 'secret.txt')],
['the folder itself', () => work],
['a missing file', () => join(work, 'nope.log')],
])('refuses %s', async (_n, path) => {
const { app, bridge } = await ready();
const res = await post(app, path());
expect(res.statusCode).toBe(400);
expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false);
bridge.stop();
});
it.each([
['a .env file', () => ['.env', 'TOKEN=x']],
['a private key under .ssh', () => ['.ssh/id_ed25519', 'key']],
['Codeman state with the bot token', () => ['.codeman/discord.json', '{}']],
])('refuses %s even inside the session folder, as with a home directory', async (_n, entry) => {
const [rel, body] = entry();
mkdirSync(dirname(join(work, rel)), { recursive: true });
writeFileSync(join(work, rel), body);
const { app, bridge } = await ready();
const res = await post(app, join(work, rel));
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/credentials/);
expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false);
bridge.stop();
});
it('refuses a symlink inside the folder that points at a blocked file', async () => {
writeFileSync(join(work, '.env'), 'TOKEN=x');
symlinkSync(join(work, '.env'), join(work, 'notes.txt'));
const { app, bridge } = await ready();
const res = await post(app, join(work, 'notes.txt'));
expect(res.statusCode).toBe(400);
expect(fetchImpl.mock.calls.some((c) => c[1].body instanceof FormData)).toBe(false);
bridge.stop();
});
it('refuses to send when the session folder is not a directory it can probe', async () => {
const { app, ctx, bridge } = await ready();
(ctx.sessions.get('test-session-1') as unknown as { workingDir: string }).workingDir = join(work, 'run.log');
const res = await post(app, join(work, 'run.log'));
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/session folder/);
bridge.stop();
});
it('probes the file before realpath: a path that is not a regular file is refused', async () => {
const { app, bridge } = await ready();
mkdirSync(join(work, 'dir'));
expect((await post(app, join(work, 'dir'))).json().error).toMatch(/Not a regular file/);
bridge.stop();
});
it('refuses a symlink that points outside the folder', async () => {
const { app, bridge } = await ready();
mkdirSync(join(work, 'sub'));
symlinkSync(join(outside, 'secret.txt'), join(work, 'sub', 'link.txt'));
expect((await post(app, join(work, 'sub', 'link.txt'))).statusCode).toBe(400);
bridge.stop();
});
it('refuses a file over 8 MB', async () => {
const { app, bridge } = await ready();
writeFileSync(join(work, 'big.bin'), Buffer.alloc(8 * 1024 * 1024 + 1));
const res = await post(app, join(work, 'big.bin'));
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/8 MB/);
bridge.stop();
});
it('refuses a file for a remote session', async () => {
const { app, bridge, ctx } = await ready();
(ctx.sessions.get('test-session-1') as unknown as { remote: object }).remote = { label: 'box' };
expect((await post(app, join(work, 'run.log'))).statusCode).toBe(400);
bridge.stop();
});
});
+288
View File
@@ -0,0 +1,288 @@
/**
* @fileoverview The edit history panel's API (GET/POST /api/sessions/:id/edits…): a real
* EditHistorySync and store over a fake transcript reader. Port: N/A (app.inject()).
*/
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness } from './_route-test-utils.js';
import { hunksToUnified, registerEditHistoryRoutes, relativeTo } from '../../src/web/routes/edit-history-routes.js';
import { EditHistorySync, NO_TRANSCRIPT, type EditHistorySyncDeps } from '../../src/web/edit-history-sync.js';
import { EditHistoryStore } from '../../src/edit-history-store.js';
import type { EditSources } from '../../src/edit-history-local-source.js';
const T0 = Date.parse('2026-10-15T20:00:00.000Z');
let dir: string;
let session: Record<string, unknown>;
function editLine(id: string, ts: number, path: string, extra: Record<string, unknown> = {}): string {
return (
JSON.stringify({
type: 'user',
timestamp: new Date(ts).toISOString(),
cwd: '/w',
message: { role: 'user', content: [{ tool_use_id: id, type: 'tool_result', content: 'ok' }] },
toolUseResult: {
filePath: path,
oldString: 'a',
newString: 'b',
structuredPatch: [{ oldStart: 4, oldLines: 1, newStart: 4, newLines: 2, lines: ['-a', '+b', '+c'] }],
},
...extra,
}) + '\n'
);
}
function sources(main: string, sub?: string): EditSources {
const files = [{ relPath: 'main', size: Buffer.byteLength(main), startOffset: 0, chunk: Buffer.from(main) }];
const metas: Record<string, string> = {};
if (sub) {
files.push({
relPath: 'subagents/agent-a1.jsonl',
size: Buffer.byteLength(sub),
startOffset: 0,
chunk: Buffer.from(sub),
});
metas.a1 = JSON.stringify({
description: 'Fix it',
agentType: 'coding',
worktreePath: '/w/.claude/worktrees/agent-a1',
worktreeBranch: 'worktree-agent-a1',
});
}
return { transcriptId: 'conv1', files, metas };
}
async function setup(
deps: Partial<EditHistorySyncDeps> = {},
opts: { authUser?: { username: string; role: 'admin' | 'user' } } = {}
) {
const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts')));
const sync = new EditHistorySync({
transcriptPath: async () => '/p/conv1.jsonl',
readSources: read,
probe: async (p) => p.map(() => null),
loadStore: async (id) => {
const store = await EditHistoryStore.load(id, { dir, debounceMs: 0 });
opened.push(store);
return store;
},
deleteStore: (id) => EditHistoryStore.delete(id, { dir }),
sweepStores: async () => 0,
...deps,
});
const h = await createRouteTestHarness((app, ctx) => registerEditHistoryRoutes(app, ctx, sync), {
authUser: opts.authUser,
});
session = h.ctx._session as unknown as Record<string, unknown>;
session.workingDir = '/w';
session.mode = 'claude';
session.claudeSessionId = 'conv1';
return { ...h, read, sync };
}
/** Stores the tests opened; removed before the temp dir, so no late save recreates it. */
const opened: EditHistoryStore[] = [];
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'edit-routes-'));
});
afterEach(async () => {
delete process.env.CODEMAN_MULTIUSER;
await Promise.all(opened.splice(0).map((s) => s.remove()));
rmSync(dir, { recursive: true, force: true });
});
const URL = '/api/sessions/test-session-1/edits';
describe('GET /api/sessions/:id/edits', () => {
it('syncs on ?sync=1 and lists entries without diffs, paths relative to the workspace', async () => {
const { app } = await setup();
const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` });
expect(res.statusCode).toBe(200);
const data = res.json().data;
expect(data).toMatchObject({ state: 'ok', backfillTruncated: false, agents: {} });
expect(data.entries).toEqual([
expect.objectContaining({
id: 't1',
path: '/w/src/a.ts',
relPath: 'src/a.ts',
tool: 'Edit',
added: 2,
removed: 1,
}),
]);
expect(data.entries[0]).not.toHaveProperty('hunks');
});
it('starts the sync before it loads the store (a deletion right after the session check stops the sync)', async () => {
const { app, sync } = await setup();
const calls: string[] = [];
const syncFn = sync.sync.bind(sync);
const getStore = sync.getStore.bind(sync);
vi.spyOn(sync, 'sync').mockImplementation((...a) => (calls.push('sync'), syncFn(...a)));
vi.spyOn(sync, 'getStore').mockImplementation((...a) => (calls.push('getStore'), getStore(...a)));
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
await app.inject({ method: 'POST', url: `${URL}/older` });
expect(calls).toEqual(['sync', 'getStore', 'sync', 'getStore']);
});
it('without sync=1 answers from the store and does not read', async () => {
const { app, read } = await setup();
const res = await app.inject({ method: 'GET', url: URL });
expect(res.json().data).toMatchObject({ state: 'ok', entries: [] });
expect(read).not.toHaveBeenCalled();
});
it('labels subagent entries and shows their paths relative to the worktree', async () => {
const sub = editLine('t9', T0 + 5, '/w/.claude/worktrees/agent-a1/lib/x.ts', { isSidechain: true, agentId: 'a1' });
const read = vi.fn(async () => sources(editLine('t1', T0, '/w/src/a.ts'), sub));
const { app } = await setup({ readSources: read });
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data.agents).toEqual({
a1: { description: 'Fix it', agentType: 'coding', worktreeBranch: 'worktree-agent-a1' },
});
expect(data.entries.find((e: { id: string }) => e.id === 't9')).toMatchObject({
agentId: 'a1',
relPath: 'lib/x.ts',
});
});
it('a read error answers unavailable with the stored entries', async () => {
let fail = false;
const read = vi.fn(async () => {
if (fail) throw new Error('EACCES: permission denied');
return sources(editLine('t1', T0, '/w/src/a.ts'));
});
const { app } = await setup({ readSources: read });
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
fail = true;
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data).toMatchObject({ state: 'unavailable', reason: 'EACCES: permission denied' });
expect(data.entries).toHaveLength(1);
});
it('hands the reader the session ids and its own CLAUDE_CONFIG_DIR', async () => {
const seen: unknown[] = [];
const { app } = await setup({
transcriptPath: async (s) => {
seen.push({ id: s.id, claudeSessionId: s.claudeSessionId, claudeConfigDir: s.claudeConfigDir });
return '/p/conv1.jsonl';
},
});
session.claudeConfigDirOverride = '/cfg/alt ';
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
expect(seen).toEqual([{ id: 'test-session-1', claudeSessionId: 'conv1', claudeConfigDir: '/cfg/alt' }]);
});
it('a session without a transcript yet answers unavailable (no-transcript)', async () => {
const { app, read } = await setup({ transcriptPath: async () => null });
const data = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(data).toMatchObject({ state: 'unavailable', reason: NO_TRANSCRIPT, entries: [] });
expect(read).not.toHaveBeenCalled();
});
it.each([
['remote', { remote: { host: 'huff' } }],
['docker', { docker: { image: 'x' } }],
['mode', { mode: 'shell' }],
])('answers unsupported (%s) without reading or loading a store', async (reason, patch) => {
const loadStore = vi.fn(async (id: string) => EditHistoryStore.load(id, { dir, debounceMs: 0 }));
const { app, read } = await setup({ loadStore });
Object.assign(session, patch);
const empty = { state: 'unsupported', reason, backfillTruncated: false, agents: {}, entries: [] };
for (const url of [URL, `${URL}?sync=1`]) {
expect((await app.inject({ method: 'GET', url })).json().data).toEqual(empty);
}
expect((await app.inject({ method: 'POST', url: `${URL}/older` })).json().data).toEqual(empty);
expect((await app.inject({ method: 'GET', url: `${URL}/t1` })).statusCode).toBe(404);
expect(read).not.toHaveBeenCalled();
expect(loadStore).not.toHaveBeenCalled();
});
it('404s an unknown session', async () => {
const { app } = await setup();
expect((await app.inject({ method: 'GET', url: '/api/sessions/nope/edits' })).statusCode).toBe(404);
});
it('multi-user: another user’s session is not found and nothing is read', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app, read } = await setup({}, { authUser: { username: 'bob', role: 'user' } });
session.owner = 'alice';
const res = await app.inject({ method: 'GET', url: `${URL}?sync=1` });
expect(res.statusCode).toBe(404);
expect(read).not.toHaveBeenCalled();
});
});
describe('GET /api/sessions/:id/edits/:entryId', () => {
it('returns the diff as unified text', async () => {
const { app } = await setup();
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
const res = await app.inject({ method: 'GET', url: `${URL}/t1` });
expect(res.json().data).toEqual({ diff: '@@ -4,1 +4,2 @@\n-a\n+b\n+c', truncated: false });
});
it('404s an edit the session does not have', async () => {
const { app } = await setup();
expect((await app.inject({ method: 'GET', url: `${URL}/missing` })).statusCode).toBe(404);
});
it('multi-user: another user’s diff is not found', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await setup({}, { authUser: { username: 'bob', role: 'user' } });
await app.inject({ method: 'GET', url: `${URL}?sync=1` });
session.owner = 'alice';
expect((await app.inject({ method: 'GET', url: `${URL}/t1` })).statusCode).toBe(404);
});
});
describe('POST /api/sessions/:id/edits/older', () => {
it('reads the next older block and lists the result', async () => {
const lines = [0, 1, 2, 3].map((i) => editLine(`t${i}`, T0 + i, `/w/${i}.ts`)).join('');
const full = Buffer.from(lines);
const read = vi.fn(
async (_path: string, requests: readonly { relPath: string; offset: number; len?: number }[], budget: number) => {
const req = requests.find((r) => r.relPath === 'main');
const start = req ? req.offset : full.length - budget;
const len = req?.len ?? full.length - start;
return {
transcriptId: 'conv1',
metas: {},
files: [{ relPath: 'main', size: full.length, startOffset: start, chunk: full.subarray(start, start + len) }],
};
}
);
const { app } = await setup({ readSources: read, budget: 400 });
const first = (await app.inject({ method: 'GET', url: `${URL}?sync=1` })).json().data;
expect(first.backfillTruncated).toBe(true);
const older = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
expect(older.entries.length).toBeGreaterThan(first.entries.length);
});
it('answers unsupported for a remote session without reading', async () => {
const { app, read } = await setup();
session.remote = { host: 'huff' };
const data = (await app.inject({ method: 'POST', url: `${URL}/older` })).json().data;
expect(data).toMatchObject({ state: 'unsupported', reason: 'remote' });
expect(read).not.toHaveBeenCalled();
});
});
describe('helpers', () => {
it('relativeTo strips only a real directory prefix', () => {
expect(relativeTo('/w/a/b.ts', '/w')).toBe('a/b.ts');
expect(relativeTo('/w/a/b.ts', '/w/')).toBe('a/b.ts');
expect(relativeTo('/wx/b.ts', '/w')).toBe('/wx/b.ts');
expect(relativeTo('/w/b.ts', undefined)).toBe('/w/b.ts');
});
it('hunksToUnified writes one header per hunk', () => {
expect(
hunksToUnified([
{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-x', '+y'] },
{ oldStart: 9, oldLines: 0, newStart: 9, newLines: 1, lines: ['+z'] },
])
).toBe('@@ -1,1 +1,1 @@\n-x\n+y\n@@ -9,0 +9,1 @@\n+z');
});
});
+213
View File
@@ -0,0 +1,213 @@
/**
* @fileoverview POST /api/filesystem/mkdir: the path picker's "New folder". Real filesystem under
* test/setup.ts's temp HOME (a root the picker offers), so this proves what is on disk, not a mock.
* Port: N/A (app.inject()).
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { LinkCaseSchema } from '../../src/web/schemas.js';
import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js';
describe('POST /api/filesystem/mkdir', () => {
let workDir = '';
beforeEach(() => {
if (!isSandboxedHome()) throw new Error('filesystem-mkdir-routes.test.ts creates folders: run it via npm test');
workDir = realpathSync(mkdtempSync(join(homedir(), 'picker-mkdir-')));
});
afterEach(() => {
if (workDir) safeRmHomeTree(workDir);
workDir = '';
});
const mkdir = async (payload: Record<string, unknown>) => {
const { app } = await createRouteTestHarness(registerFileRoutes);
return app.inject({ method: 'POST', url: '/api/filesystem/mkdir', payload });
};
it('creates one folder inside the folder being viewed and answers its path', async () => {
const res = await mkdir({ path: workDir, name: 'fresh' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toEqual({ path: join(workDir, 'fresh') });
expect(readdirSync(workDir)).toEqual(['fresh']);
});
it.each(['Kay app', 'project_v2', 'v1.2', 'my-folder', 'Café 2026'])(
'accepts %s, and Link Existing then accepts the folder it made',
async (name) => {
const res = await mkdir({ path: workDir, name });
expect(res.statusCode).toBe(200);
expect(LinkCaseSchema.safeParse({ name: 'x', path: res.json().data.path }).success).toBe(true);
}
);
it('refuses the cases directory as the parent (400): a folder there is already a case', async () => {
const casesDir = join(homedir(), 'codeman-cases');
mkdirSync(casesDir, { recursive: true });
try {
const res = await mkdir({ path: casesDir, name: 'myproj' });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('Add Case > New Case');
expect(existsSync(join(casesDir, 'myproj'))).toBe(false);
// A folder inside a case is fine.
mkdirSync(join(casesDir, 'real-case'));
expect((await mkdir({ path: join(casesDir, 'real-case'), name: 'sub' })).statusCode).toBe(200);
} finally {
safeRmHomeTree(casesDir);
}
});
it('answers 404 for a parent that is gone, the same answer browse gives', async () => {
const res = await mkdir({ path: join(workDir, 'gone'), name: 'x' });
expect(res.statusCode).toBe(404);
});
it('refuses a parent that is a file (400), like browse', async () => {
writeFileSync(join(workDir, 'afile'), 'x');
const res = await mkdir({ path: join(workDir, 'afile'), name: 'x' });
expect(res.statusCode).toBe(400);
expect(res.json().error).toBe('The folder to create in must be a directory');
});
it('creates inside a hidden folder only when the picker is showing hidden entries', async () => {
mkdirSync(join(workDir, '.hid'));
const denied = await mkdir({ path: join(workDir, '.hid'), name: 'x' });
expect(denied.statusCode).toBe(403);
const ok = await mkdir({ path: join(workDir, '.hid'), name: 'x', showHidden: 'true' });
expect(ok.statusCode).toBe(200);
expect(existsSync(join(workDir, '.hid', 'x'))).toBe(true);
// A dot name is still refused there.
expect((await mkdir({ path: join(workDir, '.hid'), name: '.y', showHidden: 'true' })).statusCode).toBe(400);
});
it('trims the name', async () => {
const res = await mkdir({ path: workDir, name: ' spaced ' });
expect(res.json().data.path).toBe(join(workDir, 'spaced'));
});
it('refuses an existing folder (409) without touching it', async () => {
mkdirSync(join(workDir, 'taken'));
writeFileSync(join(workDir, 'taken', 'keep.txt'), 'mine');
const res = await mkdir({ path: workDir, name: 'taken' });
expect(res.statusCode).toBe(409);
expect(res.json().error).toContain('already exists');
expect(readdirSync(join(workDir, 'taken'))).toEqual(['keep.txt']);
});
it.each([
['a slash', 'a/b'],
['a backslash', 'a\\b'],
['a traversal', '..'],
['a dot name', '.'],
['a hidden name', '.secret'],
['a lone tilde', '~'],
['a leading tilde', '~tilde'],
['an empty name', ' '],
['an apostrophe', "Kay's app"],
['parentheses', 'project (v2)'],
['a plus', 'a+b'],
['a comma', 'notes, 2026'],
['a hash', 'c#'],
['brackets', '[draft]'],
['a double dot inside', 'v1..2'],
['a tab', 'a\tb'],
['an escape', 'a\x1b[31mb'],
['a bell', 'a\x07b'],
['a delete char', 'a\x7fb'],
['a name over 255 bytes', 'é'.repeat(200)],
])('refuses %s in the name (400) and creates nothing', async (_label, name) => {
const res = await mkdir({ path: workDir, name });
expect(res.statusCode).toBe(400);
expect(readdirSync(workDir)).toEqual([]);
});
it('refuses a parent that does not exist (404)', async () => {
const res = await mkdir({ path: join(workDir, 'nope'), name: 'x' });
expect(res.statusCode).toBe(404);
expect(existsSync(join(workDir, 'nope'))).toBe(false);
});
it('refuses a parent outside the browse roots (403)', async () => {
const res = await mkdir({ path: '/etc', name: 'x' });
expect(res.statusCode).toBe(403);
expect(existsSync('/etc/x')).toBe(false);
});
it('refuses a relative or traversing parent (400)', async () => {
expect((await mkdir({ path: 'relative', name: 'x' })).statusCode).toBe(400);
expect((await mkdir({ path: `${workDir}/../x`, name: 'x' })).statusCode).toBe(400);
});
describe('multi-user scoping', () => {
let prevMultiUser: string | undefined;
let prevSpaces: string | undefined;
let spaces = '';
beforeEach(() => {
prevMultiUser = process.env.CODEMAN_MULTIUSER;
prevSpaces = process.env.CODEMAN_USER_SPACES_DIR;
spaces = join(workDir, 'spaces');
mkdirSync(join(spaces, 'alice'), { recursive: true });
mkdirSync(join(spaces, 'bob'));
process.env.CODEMAN_MULTIUSER = '1';
process.env.CODEMAN_USER_SPACES_DIR = spaces;
});
afterEach(() => {
if (prevMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = prevMultiUser;
if (prevSpaces === undefined) delete process.env.CODEMAN_USER_SPACES_DIR;
else process.env.CODEMAN_USER_SPACES_DIR = prevSpaces;
});
it("refuses a non-admin creating inside another user's space (403) and creates nothing", async () => {
const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: join(spaces, 'alice'), name: 'x' },
});
expect(res.statusCode).toBe(403);
expect(readdirSync(join(spaces, 'alice'))).toEqual([]);
});
it("refuses a non-admin's own cases folder (400) but not the top of their space", async () => {
const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
const own = join(spaces, 'bob', 'cases');
mkdirSync(own, { recursive: true });
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: own, name: 'myproj' },
});
expect(res.statusCode).toBe(400);
expect(existsSync(join(own, 'myproj'))).toBe(false);
});
it('lets a non-admin create inside their own space', async () => {
const { app } = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: join(spaces, 'bob'), name: 'mine' },
});
expect(res.statusCode).toBe(200);
expect(existsSync(join(spaces, 'bob', 'mine'))).toBe(true);
});
it('404s a sessionId owned by another user, and adopts none of its folder', async () => {
const scoped = await createRouteTestHarness(registerFileRoutes, { authUser: { username: 'bob', role: 'user' } });
scoped.ctx._session.owner = 'alice';
const res = await scoped.app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: join(spaces, 'bob'), name: 'x', sessionId: scoped.ctx._sessionId },
});
expect(res.statusCode).toBe(404);
expect(existsSync(join(spaces, 'bob', 'x'))).toBe(false);
});
});
});
@@ -0,0 +1,37 @@
/**
* @fileoverview `POST /api/filesystem/mkdir` probes the folder bounded before the picker's
* resolver runs `realpathSync` on it (#516): a mount that stopped answering is refused, and
* the request never reaches a synchronous call that would freeze the server.
*
* The probe is mocked: a mount that never answers cannot be made here. Port: none (inject).
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../src/utils/bounded-path-probe.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/bounded-path-probe.js')>();
return { ...actual, probePathKind: vi.fn(actual.probePathKind) };
});
import { probePathKind } from '../../src/utils/bounded-path-probe.js';
import { registerFileRoutes } from '../../src/web/routes/file-routes.js';
import { createRouteTestHarness } from './_route-test-utils.js';
describe('POST /api/filesystem/mkdir on an unresponsive mount', () => {
afterEach(() => vi.mocked(probePathKind).mockClear());
it('refuses before the picker resolver touches the path', async () => {
vi.mocked(probePathKind).mockResolvedValueOnce('unknown');
const { app } = await createRouteTestHarness(registerFileRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/filesystem/mkdir',
payload: { path: '/mnt/dead/projects', name: 'fresh' },
});
expect(res.statusCode).toBe(422);
expect(res.json().errorCode).toBe('OPERATION_FAILED');
expect(res.json().error).toMatch(/not responding|not checked/);
expect(probePathKind).toHaveBeenCalledWith('/mnt/dead/projects', { pastCap: true });
});
});
+323 -8
View File
@@ -9,8 +9,10 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness } from './_route-test-utils.js';
import { registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js';
import { defaultDockerWorkspaces, registerGitStatusRoutes } from '../../src/web/routes/git-status-routes.js';
import { getDataDir } from '../../src/config/instance.js';
import { clearGitStatusCache, runGit, type GitRunner } from '../../src/git-workspace-status.js';
import { clearGitFetchLane, fetchWorkspaceOnce } from '../../src/git-fetch.js';
const ENV = {
...process.env,
@@ -29,14 +31,18 @@ let dir: string;
let session: Record<string, unknown>;
async function setup(
opts: { git?: GitRunner; authUser?: { username: string; role: 'admin' | 'user' }; dockerWorkspaces?: string[] } = {}
opts: {
git?: GitRunner;
authUser?: { username: string; role: 'admin' | 'user' };
/** A fixed list, a reader of the test's own, or `'real'` for the route's default (the data dir's file). */
dockerWorkspaces?: string[] | (() => Promise<string[]>) | 'real';
} = {}
) {
const h = await createRouteTestHarness(
(app, ctx) => registerGitStatusRoutes(app, ctx, opts.git, async () => opts.dockerWorkspaces ?? []),
{
authUser: opts.authUser,
}
);
const docker = opts.dockerWorkspaces;
const reader = docker === 'real' ? undefined : typeof docker === 'function' ? docker : async () => docker ?? [];
const h = await createRouteTestHarness((app, ctx) => registerGitStatusRoutes(app, ctx, opts.git, reader), {
authUser: opts.authUser,
});
session = h.ctx._session as unknown as Record<string, unknown>;
session.workingDir = dir;
return h;
@@ -44,6 +50,8 @@ async function setup(
beforeEach(() => {
clearGitStatusCache();
// The fetch lane is module state: a run left in it by the previous test would be joined by the next one.
clearGitFetchLane();
dir = mkdtempSync(join(tmpdir(), 'git-status-route-'));
});
afterEach(() => {
@@ -353,6 +361,313 @@ describe('GET /api/sessions/:id/git-status limits', () => {
});
});
describe('POST /api/sessions/:id/git-fetch', () => {
/** Records what each repository was asked to do, and lets real git answer the rest. */
function recordingRunner() {
const calls: Array<[string, string[]]> = [];
const seenTimeouts: Array<number | undefined> = [];
const runner: GitRunner = async (cwd, args, opts) => {
calls.push([cwd, args]);
seenTimeouts.push(opts?.timeoutMs);
return execFileSyncGit(cwd, args);
};
return { calls, seenTimeouts, runner, fetches: () => calls.filter(([, args]) => args[0] === 'fetch') };
}
it('runs one fetch per repository the window would show, in the session working directory', async () => {
for (const name of ['api', 'web']) {
mkdirSync(join(dir, name));
git(join(dir, name), 'init', '-q', '-b', 'main');
}
const { calls, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner });
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
expect(res.statusCode).toBe(200);
const data = res.json().data;
expect(data.state).toBe('ok');
expect(data.repos.map((r: { name: string; path: string }) => [r.name, r.path])).toEqual([
['api', 'api'],
['web', 'web'],
]);
expect(data.repos.every((r: { ok: boolean }) => r.ok)).toBe(true);
// A bounded run does not promise which repository starts first, so compare the set.
expect(fetches().sort()).toEqual([
[join(dir, 'api'), ['fetch', '--quiet']],
[join(dir, 'web'), ['fetch', '--quiet']],
]);
// Every repository git was asked to touch sits under the session folder, and under nothing else.
for (const [cwd] of calls) expect(cwd.startsWith(dir)).toBe(true);
});
it('keeps a repository it could not fetch, with its reason, and says which ones it fetched', async () => {
for (const name of ['ok', 'private']) {
mkdirSync(join(dir, name));
git(join(dir, name), 'init', '-q', '-b', 'main');
}
const { fetches, runner } = recordingRunner();
const injected: GitRunner = async (cwd, args, opts) => {
const recorded = runner(cwd, args, opts); // records the call before deciding what git would have said
if (args[0] === 'fetch' && cwd === join(dir, 'private')) {
throw Object.assign(new Error('Command failed'), {
stderr: "fatal: could not read Username for 'https://github.com': terminal prompts disabled",
});
}
return recorded;
};
const { app } = await setup({ git: injected });
const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data;
const priv = data.repos.find((r: { name: string }) => r.name === 'private');
expect(priv?.ok).toBe(false);
expect(priv?.failure?.code).toBe('AUTH_REQUIRED');
expect(data.repos.find((r: { name: string }) => r.name === 'ok')?.ok).toBe(true);
// Both repositories are still in the list, so both were attempted: a failure does not cancel the run.
expect(fetches()).toHaveLength(2);
});
it('redacts credentials git may have echoed back before the failure reaches the client', async () => {
git(dir, 'init', '-q', '-b', 'main');
// Only the fetch is faked: the resolver still needs its own `rev-parse` to find the repository at all.
const runner: GitRunner = async (cwd, args) => {
if (args[0] === 'fetch') {
throw Object.assign(new Error('Command failed'), {
stderr: "fatal: unable to access 'https://bob:ghp_secret@github.com/x.git/'\u001b[31m",
});
}
return execFileSyncGit(cwd, args);
};
const { app } = await setup({ git: runner });
const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data;
expect(data.state).toBe('ok');
expect(data.repos[0].failure.stderr).toBe("fatal: unable to access 'https://***:***@github.com/x.git/'[31m");
});
it.each([
['remote', { host: 'h', user: 'u' }],
['docker', { container: 'c' }],
])('does not run git for a %s session and says it is unsupported', async (kind, value) => {
git(dir, 'init', '-q', '-b', 'main');
const { calls, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner });
session[kind] = value;
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ state: 'unsupported', reason: kind });
expect(fetches()).toEqual([]);
expect(calls).toEqual([]); // the resolver never ran either: the refusal comes before any git
});
it('refuses a repository inside a Docker case workspace before any git runs', async () => {
git(dir, 'init', '-q', '-b', 'main');
const { calls, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner, dockerWorkspaces: [realpathSync(dir)] });
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
expect(res.json().data).toMatchObject({ state: 'unsupported', reason: 'docker' });
expect(fetches()).toEqual([]);
expect(calls).toEqual([]);
});
/**
* docker-cases.json is rewritten in place (truncate, then write), so a read can land on an empty or
* half-written file. Read as "no Docker workspaces", a fetch would run git, and with it the repository's
* hooks, inside a folder a container can write to.
*/
it('fetches nothing when the Docker case list cannot be read, and answers a failed run', async () => {
git(dir, 'init', '-q', '-b', 'main');
const { calls, runner } = recordingRunner();
const { app, ctx } = await setup({
git: runner,
dockerWorkspaces: async () => {
throw new Error('docker-cases.json is not a valid JSON array');
},
});
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
expect(res.statusCode).toBe(200);
expect(res.json().data).toMatchObject({ state: 'error', repos: [] });
expect(res.json().data.error).toMatch(/Docker case list/);
expect(calls).toEqual([]); // not even the resolver's rev-parse
expect(session.gitFetch).toMatchObject({ trigger: 'click', state: 'error', repos: 0, failed: 0 });
expect(ctx.broadcast).toHaveBeenCalled();
});
it('reads the real Docker case list strictly for a fetch, and leaves the status route as it was', async () => {
git(dir, 'init', '-q', '-b', 'main');
mkdirSync(getDataDir(), { recursive: true });
const listPath = join(getDataDir(), 'docker-cases.json');
try {
writeFileSync(listPath, '[{"name":"c","hostWorkspacePath":"/x"');
await expect(defaultDockerWorkspaces()).rejects.toThrow(/not a valid JSON array/);
const { calls, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner, dockerWorkspaces: 'real' });
const fetched = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
expect(fetched.json().data.state).toBe('error');
expect(calls).toEqual([]);
// The read-only status route still reads an unreadable list as none, as it always has.
const status = await app.inject({ method: 'GET', url: '/api/sessions/test-session-1/git-status' });
expect(status.json().data.state).toBe('ok');
expect(fetches()).toEqual([]);
// A readable list that names this folder refuses the fetch as a Docker workspace, so the strict
// reader is the one the route really reads.
writeFileSync(listPath, JSON.stringify([{ name: 'c', hostWorkspacePath: realpathSync(dir) }]));
expect(await defaultDockerWorkspaces()).toEqual([realpathSync(dir)]);
const refused = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
expect(refused.json().data).toMatchObject({ state: 'unsupported', reason: 'docker' });
expect(fetches()).toEqual([]);
} finally {
rmSync(listPath, { force: true });
}
});
it('answers not-a-repo for a folder that is not a repository, running no fetch', async () => {
const { fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner });
const data = (await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).json().data;
expect(data.state).toBe('not-a-repo');
// The resolver looked once and found nothing, so nothing was fetched, and it never guessed a repository.
expect(fetches()).toEqual([]);
});
it("publishes what the click got as the session's own fetch state, naming the trigger the user asked for", async () => {
git(dir, 'init', '-q', '-b', 'main');
const { runner } = recordingRunner();
const { app, ctx } = await setup({ git: runner });
const res = await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
expect(res.statusCode).toBe(200);
// The response body is one tab's copy of the run; the state is what every other tab open on the same
// folder sees, and a number the user did not ask for has to read differently from one they did.
expect(session.gitFetch).toMatchObject({ trigger: 'click', state: 'ok', repos: 1, failed: 0 });
expect(ctx.persistSessionState).toHaveBeenCalled();
expect(ctx.broadcast).toHaveBeenCalled();
});
it('joins a run already going for the same folder instead of starting a second git process in the same clone', async () => {
git(dir, 'init', '-q', '-b', 'main');
let releaseRun = () => {};
const held = new Promise<void>((resolve) => (releaseRun = resolve));
let running = () => {};
const runStarted = new Promise<void>((resolve) => (running = resolve));
const fetches: string[] = [];
const runner: GitRunner = async (cwd, args) => {
if (args[0] === 'fetch') {
fetches.push(cwd);
running();
await held;
}
return execFileSyncGit(cwd, args);
};
const { app } = await setup({ git: runner });
const first = app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
await runStarted; // the run is going, and still going
// A click from another tab open on the same clone. Two fetches there would collide on git's ref lock, so
// this one joins the run already going rather than starting its own.
const second = app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
// Assert the join BEFORE releasing the run: the lane entry goes away when the run finishes, so releasing
// first would let the run complete before this request reached the lane, and it would then be entitled to
// start its own fetch: a test that passes for the wrong reason. One tick of the loop is enough for both
// handlers to reach the lane while the run is still held open.
await new Promise((resolve) => setImmediate(resolve));
expect(fetches).toHaveLength(1); // one run, and the second request is waiting on it, not working
releaseRun();
const a = (await first).json().data;
const b = (await second).json().data;
expect(b).toEqual(a); // both tabs get the same run's answer, not a partial one
});
it('publishes the trigger of the run the click joined, because a click that fetched nothing cannot report itself as the fetch', async () => {
git(dir, 'init', '-q', '-b', 'main');
let releaseRun = () => {};
const held = new Promise<void>((resolve) => (releaseRun = resolve));
let running = () => {};
const runStarted = new Promise<void>((resolve) => (running = resolve));
const fetches: string[] = [];
const runner: GitRunner = async (cwd, args) => {
if (args[0] === 'fetch') {
fetches.push(cwd);
running();
await held;
}
return execFileSyncGit(cwd, args);
};
const { app, ctx } = await setup({ git: runner });
// The interval tick got to this clone first. The click joins it, so the row the other tabs see has to name
// the interval: the user clicked, but nothing this click did was a fetch. The request is left open, not
// awaited: awaiting it here would wait on the run this test is holding, and the test would wait on itself.
const intervalRun = fetchWorkspaceOnce(dir, { git: runner }, 'interval');
await runStarted;
const res = app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' });
await new Promise((resolve) => setImmediate(resolve));
releaseRun();
const run = await intervalRun;
const body = (await res).json().data;
expect(body).toEqual(run.report); // the run it joined, not a partial answer of its own
// Asserted after the run, so this can only pass if the click reached the lane and joined: a click that had
// started its own fetch would make this two, and would have nothing to call the interval.
expect(fetches).toHaveLength(1);
expect(session.gitFetch).toMatchObject({ trigger: 'interval', state: 'ok', repos: 1, failed: 0 });
expect(ctx.broadcast).toHaveBeenCalled();
});
it('404s an unknown session, running no fetch', async () => {
const { calls, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner });
expect((await app.inject({ method: 'POST', url: '/api/sessions/nope/git-fetch' })).statusCode).toBe(404);
expect(fetches()).toEqual([]);
expect(calls).toEqual([]);
});
it('multi-user: another user’s session is not found, and no fetch runs', async () => {
process.env.CODEMAN_MULTIUSER = '1';
git(dir, 'init', '-q', '-b', 'main');
const { fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner, authUser: { username: 'bob', role: 'user' } });
session.owner = 'alice';
expect((await app.inject({ method: 'POST', url: '/api/sessions/test-session-1/git-fetch' })).statusCode).toBe(404);
expect(fetches()).toEqual([]);
});
it('honours maxRepos and timeout (seconds) for a fetch too, clamped, and reports the limit it used', async () => {
for (const n of ['a', 'b', 'c']) {
mkdirSync(join(dir, n));
git(join(dir, n), 'init', '-q', '-b', 'main');
}
const { seenTimeouts, fetches, runner } = recordingRunner();
const { app } = await setup({ git: runner });
const post = async (query: string) => {
const res = await app.inject({ method: 'POST', url: `/api/sessions/test-session-1/git-fetch${query}` });
expect(res.statusCode).toBe(200);
return res.json().data as { repoLimit: number; repos: Array<{ name: string }> };
};
const fetchCount = () => fetches().length;
const ran = async (query: string, expected: number) => {
const before = fetchCount();
const data = await post(query);
expect(fetchCount() - before).toBe(expected);
return data;
};
const limited = await ran('?maxRepos=2&timeout=7', 2);
expect(limited.repos.map((r) => r.name)).toEqual(['a', 'b']);
expect(limited).toMatchObject({ reposTruncated: true, repoLimit: 2 }); // the window has to say three were found
expect(new Set(seenTimeouts)).toEqual(new Set([7000]));
const wild = await ran('?maxRepos=9999&timeout=1', 3);
expect(wild.repoLimit).toBe(50); // clamped to MAX_REPOS_LIMIT, so one request cannot scan the whole disk
expect(seenTimeouts.at(-1)).toBe(5000);
const junk = await ran('?maxRepos=abc&timeout=xyz', 3);
expect(junk.repoLimit).toBe(12);
expect(seenTimeouts.at(-1)).toBe(30_000);
// A repeated key reaches the route as an array: it means "default", never a 500.
await ran('?timeout=5&timeout=6', 3);
expect(seenTimeouts.at(-1)).toBe(30_000);
});
});
function execFileSyncGit(cwd: string, args: string[]): Promise<string> {
return Promise.resolve(execFileSync('git', ['--no-optional-locks', ...args], { cwd, env: ENV, encoding: 'utf8' }));
}
+67 -2
View File
@@ -21,7 +21,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { rm, readFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { existsSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
@@ -122,6 +122,9 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
const { sessionId } = res.json();
const session = ctx.sessions.get(sessionId) as unknown as Session;
expect(session.customModel?.endpointId).toBe('ep1');
// #750, pinned the same way as the pi/grok/omp one-shot paths below: the FIRST
// launch already carries the selected id, not just the file on disk.
expect(session.getCustomModelForPersist()?.launchModel).toBe('qwen3');
expect(restartSpy).not.toHaveBeenCalled();
const configDir = customModelConfigDir(sessionId);
@@ -192,7 +195,9 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
expect(res.json().errorCode).toBe('OPERATION_FAILED');
});
it('refuses a model id the CLI cannot carry on its command line, cleaning up any written config dir', async () => {
it('refuses a model id the CLI cannot carry on its command line, before writing anything', async () => {
const dirs = join(getDataDir(), 'custom-model-configs');
const before = existsSync(dirs) ? readdirSync(dirs).length : 0;
const res = await quickStart({
caseName: 'cm-badmodel',
mode: 'pi',
@@ -200,6 +205,7 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
});
expect(res.json().success).toBe(false);
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(existsSync(dirs) ? readdirSync(dirs).length : 0).toBe(before);
});
it('refuses customModel for a remote case', async () => {
@@ -226,6 +232,21 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
});
}
it('refuses a model id the CLI cannot launch before asking anyone to evict a model', async () => {
const other = ctx.sessions.get('test-session-1')!;
(other as unknown as { customModel: unknown }).customModel = { endpointId: 'ep1', modelId: 'llama3' };
mockRunning([{ model: 'llama3', state: 'ready' }]);
const res = await quickStart({
caseName: 'cm-badmodel-swap',
mode: 'codex',
customModel: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' },
});
expect(res.json().requiresConfirmation).toBeUndefined();
expect(res.json().errorCode).toBe('INVALID_INPUT');
});
it('asks for confirmation instead of launching when another live session is using the currently loaded model', async () => {
const other = ctx.sessions.get('test-session-1')!;
(other as unknown as { customModel: unknown }).customModel = { endpointId: 'ep1', modelId: 'llama3' };
@@ -367,6 +388,50 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', ()
expect(res.statusCode).toBe(200);
expect(res.json().requiresContextWarning).toBeUndefined();
});
it('warns using the endpoint defaultContextLength when discovery recorded no per-model figure', async () => {
const endpoint: CustomModelHost = {
id: 'ep-default',
label: 'silent gateway',
baseUrl: 'http://192.168.1.53:8080',
apiKey: 'k',
defaultContextLength: 16384,
};
await writeCustomModelHosts(getDataDir(), [ENDPOINT, endpoint]);
const res = await quickStart({
caseName: 'cm-default-ctx',
mode: 'claude',
customModel: { endpointId: 'ep-default', modelId: 'some-unknown-model' },
});
const body = res.json();
expect(body.requiresContextWarning).toBe(true);
expect(body.contextLength).toBe(16384);
expect(ctx.sessions.size).toBe(1); // nothing was created
});
it('a discovered per-model figure wins over the endpoint defaultContextLength', async () => {
const endpoint: CustomModelHost = {
id: 'ep-precedence',
label: 'mixed box',
baseUrl: 'http://192.168.1.54:8080',
apiKey: 'k',
modelContextLengths: { m: 131072 },
defaultContextLength: 16384,
};
await writeCustomModelHosts(getDataDir(), [ENDPOINT, endpoint]);
const res = await quickStart({
caseName: 'cm-precedence',
mode: 'claude',
customModel: { endpointId: 'ep-precedence', modelId: 'm' },
});
expect(res.statusCode).toBe(200);
expect(res.json().requiresContextWarning).toBeUndefined();
expect(ctx.sessions.size).toBe(2);
});
});
describe('triggering the actual llama-swap load (not just watching for it)', () => {
+184
View File
@@ -0,0 +1,184 @@
/**
* @fileoverview Ralph file routes for a REMOTE (SSH) session.
*
* A remote session's `workingDir` is a path on the remote host. `fix-plan/read`
* used to read `@fix_plan.md` with local `fs`, and `fix-plan/write` and
* `ralph-prompt/write` wrote their files with it, so with a same-named local folder
* (an sshfs mount, a shared home layout) they read and wrote the LOCAL twin. Now:
* - `fix-plan/read` reads the file on the remote host through `src/remote-files.ts`
* (the File Viewer's ssh path), and a missing remote file is the same 404 as a
* missing local one;
* - both writes are refused (remote writes are out of scope, like the File Viewer's
* remote edit).
*
* The workspace path EXISTS locally here, holding a different `@fix_plan.md`, so a
* route that fell back to local `fs` would visibly import or overwrite the wrong file.
* The ssh layer is mocked (no connection is opened); the pure builders and
* `RemoteFileAccessError` stay real.
*
* Port: N/A (app.inject doesn't open ports)
*/
import { mkdirSync, readFileSync, writeFileSync, existsSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { safeRmHomeTree } from '../mocks/test-helpers.js';
import { registerRalphRoutes } from '../../src/web/routes/ralph-routes.js';
import type { RemoteProbe } from '../../src/remote-files.js';
import type { SessionRemote } from '../../src/types/session.js';
vi.mock('../../src/remote-files.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/remote-files.js')>();
return { ...actual, remoteProbePaths: vi.fn(), remoteReadFile: vi.fn() };
});
import { remoteProbePaths, remoteReadFile, RemoteFileAccessError } from '../../src/remote-files.js';
const mockedProbePaths = vi.mocked(remoteProbePaths);
const mockedReadFile = vi.mocked(remoteReadFile);
// The local twin of the remote workspace: same absolute path, different file.
const TWIN = join(homedir(), '.codeman-test-ralph-remote-twin');
const LOCAL_PLAN = '# Fix Plan\n\n- [ ] LOCAL task\n';
const REMOTE_PLAN = '# Fix Plan\n\n- [ ] REMOTE task\n';
const remote: SessionRemote = {
hostId: 'host-1',
label: 'testhost',
host: '192.0.2.10',
username: 'j',
remotePath: TWIN,
};
function fileProbe(size = REMOTE_PLAN.length): RemoteProbe {
return { realPath: join(TWIN, '@fix_plan.md'), kind: 'file', size, mtimeMs: 1_700_000_000_000 };
}
const rootProbe: RemoteProbe = { realPath: TWIN, kind: 'directory', size: 0, mtimeMs: 0 };
describe('ralph file routes for a remote (SSH) session', () => {
let harness: RouteTestHarness;
let importFixPlanMarkdown: ReturnType<typeof vi.fn>;
beforeAll(() => {
mkdirSync(TWIN, { recursive: true });
});
afterAll(() => {
safeRmHomeTree(TWIN);
});
beforeEach(async () => {
writeFileSync(join(TWIN, '@fix_plan.md'), LOCAL_PLAN);
harness = await createRouteTestHarness(registerRalphRoutes);
importFixPlanMarkdown = vi.fn(() => 1);
const session = harness.ctx._session as unknown as Record<string, unknown>;
session.workingDir = TWIN;
session.remote = { ...remote };
session.ralphTracker = {
generateFixPlanMarkdown: vi.fn(() => '# Fix Plan\n\n- [ ] generated\n'),
importFixPlanMarkdown,
todos: [],
};
mockedProbePaths.mockResolvedValue([fileProbe(), rootProbe]);
mockedReadFile.mockResolvedValue(Buffer.from(REMOTE_PLAN));
});
afterEach(async () => {
await harness.app.close();
vi.clearAllMocks();
});
const post = (route: string, payload?: unknown) =>
harness.app.inject({ method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/${route}`, payload });
describe('POST fix-plan/read', () => {
it('imports the file from the remote host, not the local twin', async () => {
const res = await post('fix-plan/read');
expect(JSON.parse(res.body)).toMatchObject({ success: true, data: { importedCount: 1 } });
expect(mockedProbePaths).toHaveBeenCalledWith(remote, [join(TWIN, '@fix_plan.md'), TWIN]);
expect(mockedReadFile).toHaveBeenCalledWith(remote, join(TWIN, '@fix_plan.md'), expect.any(Number));
expect(importFixPlanMarkdown).toHaveBeenCalledWith(REMOTE_PLAN);
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
});
it('answers the same not-found as a local read when the remote file is missing', async () => {
mockedProbePaths.mockResolvedValue([null, rootProbe]);
const res = await post('fix-plan/read');
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'NOT_FOUND' });
expect(mockedReadFile).not.toHaveBeenCalled();
expect(importFixPlanMarkdown).not.toHaveBeenCalled();
});
it('treats a @fix_plan.md that resolves outside the workspace as missing', async () => {
// e.g. a symlink to ~/.ssh/id_rsa on the remote host: the File Viewer's confinement applies.
mockedProbePaths.mockResolvedValue([{ ...fileProbe(), realPath: '/home/j/.ssh/id_rsa' }, rootProbe]);
const res = await post('fix-plan/read');
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'NOT_FOUND' });
expect(mockedReadFile).not.toHaveBeenCalled();
expect(importFixPlanMarkdown).not.toHaveBeenCalled();
});
it('refuses a remote path that is not a regular file, or one over the cap, without reading it', async () => {
for (const probe of [
{ ...fileProbe(), kind: 'directory' as const },
{ ...fileProbe(), kind: 'other' as const },
fileProbe(64 * 1024 * 1024),
]) {
mockedProbePaths.mockResolvedValue([probe, rootProbe]);
const res = await post('fix-plan/read');
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'OPERATION_FAILED' });
}
expect(mockedReadFile).not.toHaveBeenCalled();
expect(importFixPlanMarkdown).not.toHaveBeenCalled();
});
it('reports an unreachable host as a failed read, never a local fallback', async () => {
mockedProbePaths.mockRejectedValue(new RemoteFileAccessError('remote host testhost unreachable: timeout'));
const res = await post('fix-plan/read');
const body = JSON.parse(res.body);
expect(body).toMatchObject({ success: false, errorCode: 'OPERATION_FAILED' });
expect(body.error).toContain('unreachable');
expect(body.error).not.toContain('RemoteFileAccessError');
expect(importFixPlanMarkdown).not.toHaveBeenCalled();
});
});
describe('writes', () => {
it('refuses fix-plan/write and leaves the local twin alone', async () => {
const res = await post('fix-plan/write');
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(readFileSync(join(TWIN, '@fix_plan.md'), 'utf-8')).toBe(LOCAL_PLAN);
});
it('refuses ralph-prompt/write and creates nothing locally', async () => {
const res = await post('ralph-prompt/write', { content: 'do the thing' });
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(existsSync(join(TWIN, '@ralph_prompt.md'))).toBe(false);
});
});
describe('a local session is unchanged', () => {
beforeEach(() => {
delete (harness.ctx._session as unknown as Record<string, unknown>).remote;
});
it('reads the local file without touching ssh', async () => {
const res = await post('fix-plan/read');
expect(JSON.parse(res.body)).toMatchObject({ success: true });
expect(importFixPlanMarkdown).toHaveBeenCalledWith(LOCAL_PLAN);
expect(mockedProbePaths).not.toHaveBeenCalled();
expect(mockedReadFile).not.toHaveBeenCalled();
});
it('writes the local files', async () => {
expect(JSON.parse((await post('fix-plan/write')).body)).toMatchObject({ success: true });
expect(readFileSync(join(TWIN, '@fix_plan.md'), 'utf-8')).toContain('generated');
expect(JSON.parse((await post('ralph-prompt/write', { content: 'do the thing' })).body)).toMatchObject({
success: true,
});
expect(readFileSync(join(TWIN, '@ralph_prompt.md'), 'utf-8')).toBe('do the thing');
});
});
});
+118 -3
View File
@@ -9,7 +9,7 @@ import { createRouteTestHarness } from './_route-test-utils.js';
import { createMockSession } from '../mocks/index.js';
import { getDataDir } from '../../src/config/instance.js';
import { writeCustomModelHosts, type CustomModelHost } from '../../src/custom-model-hosts.js';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { existsSync, readFileSync, rmSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { webviewFetch } from '../../src/web/webview-egress.js';
@@ -203,6 +203,10 @@ describe('POST /api/sessions/:id/custom-model', () => {
const { app, ctx } = await setup();
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'pi';
// Start from no dir at all (a sibling test's successful pi apply may have left one),
// so the existsSync below says something about THIS request.
const dir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
rmSync(dir, { recursive: true, force: true });
const res = await app.inject({
method: 'POST',
@@ -214,8 +218,45 @@ describe('POST /api/sessions/:id/custom-model', () => {
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(session.setCustomModel).not.toHaveBeenCalled();
expect(session.restartCli).not.toHaveBeenCalled();
// The config dir written before the check is cleaned up again.
expect(existsSync(join(getDataDir(), 'custom-model-configs', 'test-session-1'))).toBe(false);
// The pattern check runs on the pure build BEFORE any write, so a refused id leaves
// no directory behind at all, nothing to clean up.
expect(existsSync(dir)).toBe(false);
});
it('codex: a refused forced launch id never touches the live config dir the session runs on as CODEX_HOME', async () => {
const { app, ctx } = await setup();
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'codex';
// First apply succeeds: the session now runs codex on qwen3:32b, whose config.toml
// lives in the per-session dir the running CLI itself uses as CODEX_HOME.
const ok = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep1', modelId: 'qwen3:32b' },
});
expect(ok.statusCode).toBe(200);
const dir = join(getDataDir(), 'custom-model-configs', 'test-session-1');
const written = join(dir, 'config.toml');
expect(existsSync(written)).toBe(true);
// `@` is outside codex's `model-plain-colon` pattern. The pattern check now runs on
// the pure build BEFORE any write, so the refusal must leave the dir, and the file
// the live CLI reads, exactly as they were, rather than replacing and deleting the
// CODEX_HOME of a session that stays on qwen3:32b.
const before = readFileSync(written, 'utf8');
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' },
});
expect(res.json().success).toBe(false);
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
expect(session.restartCli).toHaveBeenCalledTimes(1);
expect(existsSync(dir)).toBe(true);
expect(readFileSync(written, 'utf8')).toBe(before);
});
it('clear removes the previous config dir the session reports', async () => {
@@ -298,6 +339,28 @@ describe('POST /api/sessions/:id/custom-model', () => {
expect(session.restartCli).not.toHaveBeenCalled();
});
it('refuses a model id the CLI cannot launch before asking anyone to evict a model', async () => {
const { app, ctx } = await setup();
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'codex';
const other = createMockSession('other-session');
other.name = 'w2-otherbox';
other.customModel = { endpointId: 'ep1', modelId: 'llama3' };
ctx.sessions.set('other-session', other);
mockRunning([{ model: 'llama3', state: 'ready' }]);
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep1', modelId: 'org/model@q4_k_m' },
});
// Agreeing to the swap first would only lead to this same 400.
expect(res.json().requiresConfirmation).toBeUndefined();
expect(res.json().errorCode).toBe('INVALID_INPUT');
expect(session.setCustomModel).not.toHaveBeenCalled();
});
describe('multi-user: the confirm dialog must not name a session the caller cannot access', () => {
const saved: Record<string, string | undefined> = {};
@@ -449,6 +512,58 @@ describe('POST /api/sessions/:id/custom-model', () => {
expect(session.restartCli).not.toHaveBeenCalled();
});
it('the endpoint defaultContextLength stands in when discovery recorded no per-model figure', async () => {
const { app, ctx } = await setup();
// A gateway that states nothing per model: only the user's default is known.
const endpoint: CustomModelHost = {
id: 'ep-default',
label: 'silent gateway',
baseUrl: 'http://192.168.1.53:8080',
apiKey: 'k',
defaultContextLength: 16384,
};
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, endpoint]);
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'claude';
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep-default', modelId: 'some-unknown-model' },
});
const body = res.json();
expect(body.requiresContextWarning).toBe(true);
expect(body.contextLength).toBe(16384);
expect(session.setCustomModel).not.toHaveBeenCalled();
});
it('a discovered per-model figure wins over the endpoint defaultContextLength', async () => {
const { app, ctx } = await setup();
// The default is small enough to warn on; the discovered figure for THIS model is
// not, so the fallback must not leak into the per-model path.
const endpoint: CustomModelHost = {
id: 'ep-precedence',
label: 'mixed box',
baseUrl: 'http://192.168.1.54:8080',
apiKey: 'k',
modelContextLengths: { 'qwen3.8-27b-ud-q4_k_xl': 131072 },
defaultContextLength: 16384,
};
await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, endpoint]);
const session = ctx.sessions.get('test-session-1')!;
session.mode = 'claude';
const res = await app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/custom-model',
payload: { endpointId: 'ep-precedence', modelId: 'qwen3.8-27b-ud-q4_k_xl' },
});
expect(res.json().requiresContextWarning).toBeUndefined();
expect(session.setCustomModel).toHaveBeenCalledTimes(1);
});
// llama-server splits its context across --parallel slots, and Claude Code sends several
// requests at once, so a context that clears the floor as a whole can still fail per slot.
describe('parallel slots share the context', () => {
@@ -0,0 +1,104 @@
/**
* `POST /api/sessions/:id/git-fetch-settings` (session-routes.ts): the per-case half of the fetch interval
* (src/git-fetch-scheduler.ts): the case's own switch and its own interval. `null` on either half clears
* that half so the app-wide setting applies again, and a body that names one row leaves the other as it was,
* because the Session Options rows save on their own. Refused for remote and Docker cases, the same rule the
* Git panel and the scheduler use. app.inject(), no port.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
async function createHarness(): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
const ctx = createMockRouteContext();
registerSessionRoutes(app, ctx);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
describe('POST /api/sessions/:id/git-fetch-settings', () => {
let harness: Harness;
beforeEach(async () => {
harness = await createHarness();
});
afterEach(async () => {
await harness.app.close();
});
const post = (payload: unknown) =>
harness.app.inject({ method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/git-fetch-settings`, payload });
it('stores the case’s own choice, persists and broadcasts it', async () => {
const res = await post({ enabled: true, intervalSeconds: 120 });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: true, intervalSeconds: 120 });
expect(harness.ctx._session.gitFetchEnabled).toBe(true);
expect(harness.ctx._session.gitFetchIntervalSeconds).toBe(120);
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
});
it('answers `null` for a case that follows the app, which is what the Session Options row shows', async () => {
const data = JSON.parse((await post({})).body).data.gitFetchSettings;
expect(data).toEqual({ enabled: null, intervalSeconds: null });
});
it('clears the override on null so the app-wide setting applies again', async () => {
await post({ enabled: true, intervalSeconds: 120 });
const res = await post({ enabled: null, intervalSeconds: null });
expect(res.statusCode).toBe(200);
expect(harness.ctx._session.gitFetchEnabled).toBeUndefined();
expect(harness.ctx._session.gitFetchIntervalSeconds).toBeUndefined();
expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: null, intervalSeconds: null });
});
it('leaves the other half as it was when only one row was saved', async () => {
await post({ enabled: true });
expect(harness.ctx._session.gitFetchIntervalSeconds).toBeUndefined(); // still following the app on that half
const res = await post({ intervalSeconds: 90 });
expect(JSON.parse(res.body).data.gitFetchSettings).toEqual({ enabled: true, intervalSeconds: 90 });
});
it('rejects an interval outside the schema bounds, storing nothing, because that is what the scheduler acts on', async () => {
// `null` is NOT on this list: it is the schema's way of clearing the override, tested above.
for (const raw of [29, 3601, 12.5, '120', true]) {
const res = await post({ enabled: true, intervalSeconds: raw });
expect(res.statusCode, String(raw)).toBe(400);
}
expect(harness.ctx._session.gitFetchEnabled).toBeUndefined();
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
});
it.each([
['remote', { host: 'h', user: 'u' }],
['docker', { container: 'c' }],
])('refuses a %s case, storing nothing', async (kind, value) => {
(harness.ctx._session as unknown as Record<string, unknown>)[kind] = value;
const res = await post({ enabled: true, intervalSeconds: 120 });
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
expect(harness.ctx._session.gitFetchEnabled).toBeUndefined();
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
});
it('404s an unknown session, storing nothing', async () => {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/nope/git-fetch-settings',
payload: { enabled: true },
});
expect(res.statusCode).toBe(404);
expect(harness.ctx.persistSessionState).not.toHaveBeenCalled();
});
});
+125 -1
View File
@@ -25,7 +25,7 @@ import { tmpdir } from 'node:os';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { Session } from '../../src/session.js';
import { GuardedInputBusyError, Session } from '../../src/session.js';
import { agentInbox } from '../../src/web/agent-inbox.js';
// Mock execFile so the send-key route's `tmux` invocation is observable (not run for real).
@@ -265,6 +265,33 @@ describe('session-routes', () => {
});
});
it('answers 409 CONFLICT, without an error log, when a guarded submit cannot hold the key', async () => {
execFile.mockReset();
const session = harness.ctx._session as unknown as {
performAuxiliaryInput: (op: () => Promise<void>) => Promise<void>;
};
const original = session.performAuxiliaryInput;
session.performAuxiliaryInput = async () => {
throw new GuardedInputBusyError();
};
const errors = vi.spyOn(console, 'error').mockImplementation(() => {});
try {
const res = await harness.app.inject({
method: 'POST',
url: '/api/sessions/test-session-1/send-key',
payload: { key: 'S-Enter' },
});
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
expect(res.json().error).toMatch(/guarded submit is in progress.*nothing was written/);
expect(errors).not.toHaveBeenCalled();
expect(execFile).not.toHaveBeenCalled();
} finally {
errors.mockRestore();
session.performAuxiliaryInput = original;
}
});
it('rejects keys outside the hex allowlist without invoking tmux', async () => {
execFile.mockReset();
const res = await harness.app.inject({
@@ -984,6 +1011,16 @@ describe('session-routes', () => {
// with no broadcast, so other open tabs kept showing the retired row until
// their next unrelated fetch.
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:deleted', { id: 'ghost-session' });
// Its stored edit history (diffs can hold secrets) goes with the record.
expect(harness.ctx.forgetEditHistory).toHaveBeenCalledWith('ghost-session');
});
it('keeps the edit history of a pinned persisted-only session whose record is only demoted', async () => {
vi.mocked(harness.ctx.store.getSession).mockReturnValueOnce({ id: 'pinned-ghost', owner: undefined } as never);
vi.mocked(harness.ctx.store.demoteOrRemoveSession).mockReturnValueOnce('preserved');
const res = await harness.app.inject({ method: 'DELETE', url: '/api/sessions/pinned-ghost' });
expect(res.statusCode).toBe(200);
expect(harness.ctx.forgetEditHistory).not.toHaveBeenCalled();
});
it('a persisted-only delete drops the agent inbox the detach kept', async () => {
@@ -1197,6 +1234,93 @@ describe('session-routes', () => {
expect(session.writeBuffer).toEqual(['prompt', '\r']);
});
it('refuses tagged input with 409 and gives the seq back while a guarded submit holds the input lease', async () => {
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
session.writeBuffer.length = 0;
const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload });
session.guardedInputHeld = true;
for (const payload of [
{ input: 'tagged', seq: 7, clientId: 'cid-guard' },
{ input: 'prompt\r', useMux: true, seq: 1, clientId: 'cid-guard-mux' },
]) {
const refused = await post(payload);
expect(refused.statusCode, JSON.stringify(payload)).toBe(409);
expect(refused.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
expect(refused.json().error).toMatch(/guarded submit is in progress.*nothing was written/);
}
expect(session.writeBuffer).toEqual([]);
expect(session.heldGuardedInput).toEqual([]);
// Once the lease is released the browser's retry of the same seq is applied, not
// rejected as a duplicate.
session.releaseGuardedLease();
expect((await post({ input: 'tagged', seq: 7, clientId: 'cid-guard' })).statusCode).toBe(200);
expect(session.writeBuffer).toEqual(['tagged']);
});
it('holds an untagged raw POST during a guarded submit and writes it once the lease is released', async () => {
// The phone accessory keys and the Stop button's Ctrl+C are untagged, fire-and-forget
// POSTs that never read the answer, so a 409 here silently lost the tap.
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
session.writeBuffer.length = 0;
session.guardedInputHeld = true;
try {
for (const input of ['\x03', '\x1b[A']) {
const res = await harness.app.inject({ method: 'POST', url, payload: { input } });
expect(res.statusCode, JSON.stringify(input)).toBe(200);
expect(res.json()).toMatchObject({ success: true });
}
expect(session.writeBuffer).toEqual([]);
session.releaseGuardedLease();
expect(session.writeBuffer).toEqual(['\x03', '\x1b[A']);
} finally {
session.releaseGuardedLease();
}
});
it('lets untagged mux input wait its own turn behind the lease instead of refusing it', async () => {
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
session.writeBuffer.length = 0;
const post = (payload: unknown) => harness.app.inject({ method: 'POST', url, payload });
session.guardedInputHeld = true;
try {
// Fire-and-forget `useMux`: answered at once, written in its turn.
expect((await post({ input: 'x', useMux: true })).statusCode).toBe(200);
// A plain prompt's mux write is awaited, so it answers once written.
const prompt = post({ input: 'prompt\r' });
await vi.waitFor(() => expect(session.heldGuardedInput).toHaveLength(2));
expect(session.writeBuffer).toEqual([]);
session.releaseGuardedLease();
expect((await prompt).statusCode).toBe(200);
expect(session.writeBuffer).toEqual(['x', 'prompt\r']);
} finally {
session.releaseGuardedLease();
}
});
it('refuses an untagged raw POST with 409 only when the hold queue is full', async () => {
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const session = harness.ctx.sessions.get(harness.ctx._sessionId) as any;
session.writeBuffer.length = 0;
session.guardedInputHeld = true;
session.holdForGuardedLease = () => 'full';
try {
const res = await harness.app.inject({ method: 'POST', url, payload: { input: '\x03' } });
expect(res.statusCode).toBe(409);
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
expect(session.writeBuffer).toEqual([]);
} finally {
delete session.holdForGuardedLease;
session.releaseGuardedLease();
}
});
it('always applies untagged input (curl/legacy, no dedup)', async () => {
const url = `/api/sessions/${harness.ctx._sessionId}/input`;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -54,6 +54,11 @@ vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => JSON.stringify(STORED)),
writeFile: vi.fn(async () => undefined),
// settings.json is written to a temp file and renamed over the real one (settings-writer.ts).
realpath: vi.fn(async (p: string) => p),
mkdir: vi.fn(async () => undefined),
rename: vi.fn(async () => undefined),
rm: vi.fn(async () => undefined),
},
}));
@@ -4,8 +4,8 @@
*
* PR #361 once made it reconcile `showPlanUsageLimits` on first read: if the
* key was absent, the route persisted `true`. But readJsonConfig() answers
* `{}` for ANY read failure (parse error, EACCES, EMFILE, a read landing inside
* PUT's non-atomic write), not only ENOENT, and every page load calls this
* `{}` for ANY read failure (parse error, EACCES, EMFILE, and at the time a read
* landing inside PUT's non-atomic write), not only ENOENT, and every page load calls this
* route, so one unlucky read replaced the whole settings file with a one-key
* file. The default now lives in the readers instead: an absent key means ON
* to readPlanUsageTelemetryEnabled() (pinned in test/hooks-config.test.ts) and
@@ -70,6 +70,11 @@ vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => JSON.stringify(EXISTING_SETTINGS)),
writeFile: vi.fn(async () => undefined),
// settings.json is written to a temp file and renamed over the real one (settings-writer.ts).
realpath: vi.fn(async (p: string) => p),
mkdir: vi.fn(async () => undefined),
rename: vi.fn(async () => undefined),
rm: vi.fn(async () => undefined),
},
}));
+296 -98
View File
@@ -1,25 +1,21 @@
/**
* @fileoverview COD-55 — tunnel password guard.
*
* Enabling the Cloudflare tunnel publishes the whole app (full terminal control =
* effectively RCE) to a public *.trycloudflare.com URL. When no CODEMAN_PASSWORD
* is set, requests through that URL are unauthenticated. These tests assert the
* PUT /api/settings tunnel-enable path REFUSES to start the tunnel unless a
* password is set OR the unauthenticated-network opt-in is acknowledged.
*
* Uses app.inject() — no real HTTP ports needed. Port: N/A.
* @fileoverview Issue #706 keeps LAN acknowledgement separate from public tunnel consent. A login, tunnel-specific env opt-in, launch flag or per-request acknowledgement permits enabling. Early refusals persist nothing; late race refusals save unrelated settings without enabling the tunnel. Status reports login configuration independently of exposure opt-ins.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
// Settings are written to disk via fs/promises — stub so the guard test never
// Settings are written to disk via fs/promises; stub so the guard test never
// touches the real settings.json, and so we can assert "not persisted on refusal".
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => '{}'),
writeFile: vi.fn(async () => undefined),
// settings.json is written to a temp file and renamed over the real one (settings-writer.ts).
realpath: vi.fn(async (p: string) => p),
mkdir: vi.fn(async () => undefined),
rename: vi.fn(async () => undefined),
rm: vi.fn(async () => undefined),
},
}));
@@ -34,129 +30,331 @@ vi.mock('node:fs', async (importOriginal) => {
});
import fs from 'node:fs/promises';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
const mockedWriteFile = vi.mocked(fs.writeFile);
const mockedRename = vi.mocked(fs.rename);
/** Build a tunnelManager stub the route's ctx can use. */
function makeTunnelManager(running = false) {
/**
* settings.json writes as [path, contents]. The writer (settings-writer.ts) writes each one to a
* temp file and renames it over settings.json, so a write counts once its rename lands. Every
* writeFile must be such a temp file and every one must be renamed into place: a direct write to
* settings.json, or any other write that was never renamed, fails here rather than going unseen.
*/
function settingsWrites(): [string, string][] {
const writes = mockedWriteFile.mock.calls.map(([path, contents]) => [String(path), String(contents)]);
const renames = mockedRename.mock.calls.map(([from, to]) => [String(from), String(to)]);
const isTemp = (path: string) => path.startsWith(`${SETTINGS_PATH}.`) && path.endsWith('.tmp');
const notRenamed = writes.filter(([path]) => !isTemp(path) || !renames.some(([from]) => from === path));
expect(
notRenamed.map(([path]) => path),
'writes that were not a temp file renamed into place'
).toEqual([]);
expect(renames.length, 'one rename per write').toBe(writes.length);
return renames.map(([from, to]) => {
const write = writes.find(([path]) => path === from);
expect(write, `rename of ${from}, which was never written`).toBeDefined();
return [to, String(write?.[1])];
});
}
const STATUS = { running: false, url: null };
function makeTunnelManager() {
return {
start: vi.fn(),
stop: vi.fn(),
isRunning: vi.fn(() => running),
isRunning: vi.fn(() => false),
getUrl: vi.fn(() => null),
getStatus: vi.fn(() => ({ running })),
getStatus: vi.fn(() => ({ ...STATUS })),
};
}
describe('COD-55 tunnel password guard (PUT /api/settings tunnelEnabled)', () => {
describe('tunnel authentication policy', () => {
let harness: RouteTestHarness;
let tunnel: ReturnType<typeof makeTunnelManager>;
const savedPassword = process.env.CODEMAN_PASSWORD;
const savedOptIn = process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
const envKeys = [
'CODEMAN_PASSWORD',
'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK',
'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL',
'CODEMAN_MULTIUSER',
] as const;
let savedEnv: (string | undefined)[];
beforeEach(async () => {
harness = await createRouteTestHarness(registerSystemRoutes);
savedEnv = envKeys.map((key) => process.env[key]);
for (const key of envKeys) delete process.env[key];
harness = await createRouteTestHarness(registerSystemRoutes, {
authUser: { username: 'root', role: 'admin' },
});
vi.clearAllMocks();
vi.mocked(fs.readFile).mockResolvedValue('{}');
mockedWriteFile.mockResolvedValue(undefined);
tunnel = makeTunnelManager(false);
// tunnelManager is null in the default mock ctx — inject our spy.
tunnel = makeTunnelManager();
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
(harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = false;
});
afterEach(async () => {
await harness.app.close();
if (savedPassword === undefined) delete process.env.CODEMAN_PASSWORD;
else process.env.CODEMAN_PASSWORD = savedPassword;
if (savedOptIn === undefined) delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK;
else process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = savedOptIn;
try {
await harness?.app.close();
} finally {
envKeys.forEach((key, i) => {
if (savedEnv[i] === undefined) delete process.env[key];
else process.env[key] = savedEnv[i];
});
}
});
it('REFUSES tunnel-enable with no password and no opt-in (4xx, start not called)', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
});
const put = (payload: Record<string, boolean> = { tunnelEnabled: true }) =>
harness.app.inject({ method: 'PUT', url: '/api/settings', payload });
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(res.statusCode).toBeLessThan(500);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
// Message should tell the user how to fix it.
expect(body.error).toMatch(/CODEMAN_PASSWORD|CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK/);
// The tunnel must NOT have been started.
expect(tunnel.start).not.toHaveBeenCalled();
// And tunnelEnabled:true must NOT have been persisted.
expect(mockedWriteFile).not.toHaveBeenCalled();
});
it('ALLOWS tunnel-enable when CODEMAN_PASSWORD is set (start called, 200)', async () => {
process.env.CODEMAN_PASSWORD = 'hunter2';
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
function expectRefused(res: Awaited<ReturnType<typeof put>>) {
expect.soft(res.statusCode).toBe(403);
expect.soft(res.json()).toEqual({
success: false,
errorCode: 'OPERATION_FAILED',
error: expect.any(String),
});
expect.soft(tunnel.start.mock.calls).toEqual([]);
expect.soft(mockedWriteFile.mock.calls).toEqual([]);
}
function expectEnabled(res: Awaited<ReturnType<typeof put>>) {
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
});
expect(tunnel.start.mock.calls).toEqual([[harness.ctx.port, harness.ctx.https]]);
expect(tunnel.stop.mock.calls).toEqual([]);
expect(settingsWrites()).toEqual([[SETTINGS_PATH, JSON.stringify({ tunnelEnabled: true }, null, 2)]]);
}
it('ALLOWS tunnel-enable with the unauthenticated-network opt-in acknowledged (start called, 200)', async () => {
it('refuses the incident: network acknowledgement does not authorize a public tunnel', async () => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK = '1';
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true },
});
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
const res = await put();
expectRefused(res);
expect.soft(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL');
expect.soft(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK');
expect.soft(res.json().error).toMatch(/no longer/i);
});
it('ALLOWS tunnel-enable with per-request acknowledgeUnauthTunnel:true (start called, 200, flag not persisted)', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true, acknowledgeUnauthTunnel: true },
});
expect(res.statusCode).toBe(200);
expect(tunnel.start).toHaveBeenCalledTimes(1);
// The action flag must NOT be persisted to settings.json.
expect(mockedWriteFile).toHaveBeenCalled();
const persisted = JSON.parse(mockedWriteFile.mock.calls[0][1] as string);
expect(persisted.acknowledgeUnauthTunnel).toBeUndefined();
expect(persisted.tunnelEnabled).toBe(true);
it('refuses without credentials or acknowledgement and names the tunnel opt-in', async () => {
const res = await put();
expectRefused(res);
expect(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL');
});
it('still REFUSES when acknowledgeUnauthTunnel is false (4xx, start not called)', async () => {
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true, acknowledgeUnauthTunnel: false },
});
it.each(['1', 'true', 'yes', 'on', ' TRUE '])(
'allows tunnel env value %j without an acknowledgement',
async (value) => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = value;
expectEnabled(await put());
}
);
expect(res.statusCode).toBeGreaterThanOrEqual(400);
expect(res.statusCode).toBeLessThan(500);
expect(tunnel.start).not.toHaveBeenCalled();
expect(mockedWriteFile).not.toHaveBeenCalled();
it('allows the route context flag without an env opt-in or acknowledgement', async () => {
(harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = true;
expectEnabled(await put());
});
it('does not guard tunnel-disable (tunnelEnabled:false always allowed)', async () => {
tunnel = makeTunnelManager(true);
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
it('allows acknowledgement alone and persists only tunnelEnabled', async () => {
expectEnabled(await put({ tunnelEnabled: true, acknowledgeUnauthTunnel: true }));
});
const res = await harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: false },
});
it('refuses a false acknowledgement', async () => {
expectRefused(await put({ tunnelEnabled: true, acknowledgeUnauthTunnel: false }));
});
it('allows a configured password without acknowledgement', async () => {
process.env.CODEMAN_PASSWORD = 'test-password';
expectEnabled(await put());
});
it('allows a multi-user admin without acknowledgement', async () => {
process.env.CODEMAN_MULTIUSER = '1';
expectEnabled(await put());
});
it.each(['0', 'false', '', 'enabled'])('refuses non-true tunnel env value %j', async (value) => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = value;
expectRefused(await put());
});
it('does not guard disabling a running tunnel', async () => {
tunnel.isRunning.mockReturnValue(true);
const res = await put({ tunnelEnabled: false });
expect(res.statusCode).toBe(200);
expect(tunnel.stop).toHaveBeenCalledTimes(1);
expect(tunnel.start.mock.calls).toEqual([]);
expect(tunnel.stop.mock.calls).toEqual([[]]);
expect(settingsWrites()).toEqual([[SETTINGS_PATH, JSON.stringify({ tunnelEnabled: false }, null, 2)]]);
});
it.each([
['password', 'CODEMAN_PASSWORD', 'test-password', false, true],
['multi-user', 'CODEMAN_MULTIUSER', '1', false, true],
['neither', undefined, undefined, false, false],
['empty password', 'CODEMAN_PASSWORD', '', false, false],
['network opt-in only', 'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK', '1', false, false],
['tunnel opt-in only', 'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL', '1', false, false],
['tunnel flag only', undefined, undefined, true, false],
] as const)('status reports loginConfigured for %s', async (_label, key, value, flag, loginConfigured) => {
if (key !== undefined) process.env[key] = value;
(harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = flag;
const res = await harness.app.inject({ method: 'GET', url: '/api/tunnel/status' });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ ...STATUS, loginConfigured });
expect(tunnel.getStatus.mock.calls).toEqual([[]]);
expect(mockedWriteFile.mock.calls).toEqual([]);
});
});
describe('tunnel enable authorization across a concurrent stop', () => {
let harness: RouteTestHarness;
let tunnel: ReturnType<typeof makeTunnelManager>;
let running: boolean;
let stopDuringWrite: boolean;
let afterWrite: (() => void) | undefined;
let storedSettings: Record<string, boolean>;
const envKeys = [
'CODEMAN_PASSWORD',
'CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK',
'CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL',
'CODEMAN_MULTIUSER',
] as const;
let savedEnv: (string | undefined)[];
beforeEach(async () => {
savedEnv = envKeys.map((key) => process.env[key]);
for (const key of envKeys) delete process.env[key];
harness = await createRouteTestHarness(registerSystemRoutes, {
authUser: { username: 'root', role: 'admin' },
});
vi.clearAllMocks();
running = true;
stopDuringWrite = true;
afterWrite = undefined;
storedSettings = { tunnelEnabled: false, subagentTrackingEnabled: false };
tunnel = makeTunnelManager();
tunnel.isRunning.mockImplementation(() => running);
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
(harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = false;
vi.mocked(fs.readFile).mockImplementation(async (path) => {
expect(path).toBe(SETTINGS_PATH);
return JSON.stringify(storedSettings);
});
const pending = new Map<string, string>();
const store = (contents: string) => {
storedSettings = JSON.parse(contents);
// The stop becomes visible during the awaited write, regardless of isRunning() call count.
if (stopDuringWrite) running = false;
afterWrite?.();
};
mockedWriteFile.mockImplementation(async (path, contents) => {
// A direct write to settings.json lands at once, so the stored file is what the route really left;
// settingsWrites() then fails it for not going through a temp file and a rename.
if (path === SETTINGS_PATH) store(contents as string);
else pending.set(String(path), contents as string);
});
mockedRename.mockImplementation(async (from, to) => {
expect(to).toBe(SETTINGS_PATH);
store(pending.get(String(from)) as string);
});
});
afterEach(async () => {
try {
await harness?.app.close();
} finally {
envKeys.forEach((key, i) => {
if (savedEnv[i] === undefined) delete process.env[key];
else process.env[key] = savedEnv[i];
});
vi.mocked(fs.readFile).mockResolvedValue('{}');
mockedWriteFile.mockResolvedValue(undefined);
mockedRename.mockResolvedValue(undefined);
}
});
const put = (acknowledgement: Record<string, boolean> = {}) =>
harness.app.inject({
method: 'PUT',
url: '/api/settings',
payload: { tunnelEnabled: true, autoNameSessions: false, ...acknowledgement },
});
function expectWritten(tunnelEnabled: boolean) {
const expected = { tunnelEnabled, subagentTrackingEnabled: false, autoNameSessions: false };
// Check every write so a transient unauthorized enable followed by a rollback also fails.
expect
.soft(settingsWrites().map(([path, contents]) => ({ path, settings: JSON.parse(contents) })))
.toEqual([{ path: SETTINGS_PATH, settings: expected }]);
expect.soft(storedSettings).toEqual(expected);
}
function expectLateRefusal(res: Awaited<ReturnType<typeof put>>) {
expect.soft(res.statusCode).toBe(403);
expect.soft(res.json()).toEqual({
success: false,
errorCode: 'OPERATION_FAILED',
error: expect.any(String),
});
expect.soft(res.json().error).toContain('CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL');
expect.soft(res.json().error).toMatch(/not (re)?start/i);
expect.soft(tunnel.start.mock.calls).toEqual([]);
expect.soft(tunnel.stop.mock.calls).toEqual([]);
expectWritten(false);
}
function expectAuthorizedRestart(res: Awaited<ReturnType<typeof put>>) {
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({});
expect(tunnel.start.mock.calls).toEqual([[harness.ctx.port, harness.ctx.https]]);
expect(tunnel.stop.mock.calls).toEqual([]);
expectWritten(true);
}
it('refuses an unauthorized restart after a concurrent stop while saving other settings', async () => {
expectLateRefusal(await put());
});
it.each(['password', 'tunnel env', 'route flag', 'per-request acknowledgement', 'multi-user admin'] as const)(
'allows a restart with %s authorization',
async (authorization) => {
if (authorization === 'password') process.env.CODEMAN_PASSWORD = 'test-password';
if (authorization === 'tunnel env') process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = '1';
if (authorization === 'route flag') {
(harness.ctx as unknown as { allowUnauthenticatedTunnel: boolean }).allowUnauthenticatedTunnel = true;
}
if (authorization === 'multi-user admin') process.env.CODEMAN_MULTIUSER = '1';
expectAuthorizedRestart(
await put(authorization === 'per-request acknowledgement' ? { acknowledgeUnauthTunnel: true } : {})
);
}
);
it.each([false, true])(
'preserves stored tunnelEnabled=%s when an unauthorized tunnel stays running',
async (tunnelEnabled) => {
stopDuringWrite = false;
storedSettings.tunnelEnabled = tunnelEnabled;
const res = await put();
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({});
expect(tunnel.start.mock.calls).toEqual([]);
expect(tunnel.stop.mock.calls).toEqual([]);
expectWritten(tunnelEnabled);
}
);
it('does not acquire restart authorization from an env opt-in added during the write', async () => {
afterWrite = () => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = '1';
};
expectLateRefusal(await put());
});
it('retains the request authorization when the env opt-in is removed during the write', async () => {
process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL = '1';
afterWrite = () => {
delete process.env.CODEMAN_ALLOW_UNAUTHENTICATED_TUNNEL;
};
expectAuthorizedRestart(await put());
});
});
+18
View File
@@ -22,6 +22,11 @@ vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => '{}'),
writeFile: vi.fn(async () => undefined),
// settings.json is written to a temp file and renamed over the real one (settings-writer.ts).
realpath: vi.fn(async (p: string) => p),
mkdir: vi.fn(async () => undefined),
rename: vi.fn(async () => undefined),
rm: vi.fn(async () => undefined),
},
}));
@@ -415,6 +420,19 @@ describe('system-routes', () => {
agentInbox.resetForTests();
}
});
it('deletes the edit history of every session it cleaned (diffs can hold secrets)', async () => {
harness.ctx.tabLayouts.runStaleSessionCleanup = vi.fn(async () => ({
count: 2,
cleaned: [
{ id: 'stale-1', name: 'a' },
{ id: 'stale-2', name: 'b' },
],
})) as never;
const res = await harness.app.inject({ method: 'POST', url: '/api/cleanup-state' });
expect(JSON.parse(res.body).cleanedSessions).toBe(2);
expect(vi.mocked(harness.ctx.forgetEditHistory).mock.calls).toEqual([['stale-1'], ['stale-2']]);
});
});
// ========== GET /api/subagents ==========
Loaded 100 of 282 files, more files were not shown because too many files have changed in this diff. Show more