Merge master into feat/offline-state (keep both offline overlay and approvals drawer)

This commit is contained in:
Codeman maintainer
2026-08-09 17:01:34 +02:00
58 changed files with 4768 additions and 138 deletions
+305
View File
@@ -0,0 +1,305 @@
/**
* Approvals Inbox store unit tests (src/web/approval-inbox.ts).
*
* Pure in-memory registry: no ports, no server. Constructs its own
* ApprovalInbox instances (never the process singleton) so tests cannot
* leak state into the route tests that share the module.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
ApprovalInbox,
normalizeCapturedFrame,
parseDialogOptions,
type ApprovalItem,
type ApprovalResolvedInfo,
} from '../src/web/approval-inbox.js';
const PERMISSION_FRAME = [
' Do you want to make this edit to foo.ts?',
' ❯ 1. Yes',
' 2. Yes, allow all edits during this session (shift+tab)',
' 3. No, and tell Claude what to do differently (esc)',
].join('\n');
const TWO_OPTION_FRAME = [' Trust the files in this folder?', ' ❯ 1. Yes, proceed', ' 2. No, exit'].join('\n');
// The live AskUserQuestion shape (measured on Claude Code v2.1.226): a
// description row under every option and a ─ separator before "Chat about this".
const ASK_USER_QUESTION_FRAME = [
' ☐ Color',
' Which color do you prefer?',
'❯ 1. Red',
' Prefer red',
' 2. Blue',
' Prefer blue',
' 3. Green',
' Prefer green',
' 4. Type something.',
'────────────────────────────────────────',
' 5. Chat about this',
'Enter to select · ↑/↓ to navigate · Esc to cancel',
].join('\n');
function collect(inbox: ApprovalInbox) {
const pending: ApprovalItem[] = [];
const updated: ApprovalItem[] = [];
const resolved: ApprovalResolvedInfo[] = [];
inbox.onPending = (i) => pending.push(i);
inbox.onUpdated = (i) => updated.push(i);
inbox.onResolved = (i) => resolved.push(i);
return { pending, updated, resolved };
}
describe('parseDialogOptions', () => {
it('parses a 3-option permission dialog with the ❯ cursor', () => {
const options = parseDialogOptions(PERMISSION_FRAME);
expect(options).toEqual([
{ n: 1, label: 'Yes' },
{ n: 2, label: 'Yes, allow all edits during this session (shift+tab)' },
{ n: 3, label: 'No, and tell Claude what to do differently (esc)' },
]);
});
it('parses a 2-option dialog', () => {
expect(parseDialogOptions(TWO_OPTION_FRAME)).toHaveLength(2);
});
it('returns undefined when nothing parses', () => {
expect(parseDialogOptions('just some terminal output\nwith no menu')).toBeUndefined();
expect(parseDialogOptions(undefined)).toBeUndefined();
// A single numbered line is not a dialog.
expect(parseDialogOptions('1. lonely item')).toBeUndefined();
});
it('requires consecutive numbering from 1', () => {
expect(parseDialogOptions('2. Yes\n3. No')).toBeUndefined();
});
it('takes the LAST complete block in the frame (dialogs render at the bottom)', () => {
const frame = ['1. old option', '2. old option two', 'some output in between', TWO_OPTION_FRAME].join('\n');
const options = parseDialogOptions(frame);
expect(options?.[0].label).toBe('Yes, proceed');
});
it('caps option labels at 120 chars', () => {
const long = 'x'.repeat(300);
const options = parseDialogOptions(`1. ${long}\n2. No`);
expect(options?.[0].label).toHaveLength(120);
});
it('parses the AskUserQuestion shape (descriptions between options, separator before the last)', () => {
const options = parseDialogOptions(ASK_USER_QUESTION_FRAME);
expect(options?.map((o) => o.label)).toEqual(['Red', 'Blue', 'Green', 'Type something.', 'Chat about this']);
});
it('a gap of more than 3 lines ends the option block', () => {
const frame = ['1. Yes', '2. No', 'a', 'b', 'c', 'd', 'unrelated 3. text'].join('\n');
const options = parseDialogOptions(frame);
expect(options).toHaveLength(2);
});
});
describe('normalizeCapturedFrame', () => {
it('strips ANSI, right-trims, and drops trailing blank lines', () => {
const raw = '\x1b[31mred\x1b[0m \nline2\n\n\n';
expect(normalizeCapturedFrame(raw)).toBe('red\nline2');
});
it('keeps only the last 30 lines', () => {
const raw = Array.from({ length: 50 }, (_, i) => `line${i}`).join('\n');
const out = normalizeCapturedFrame(raw)!;
expect(out.split('\n')).toHaveLength(30);
expect(out.startsWith('line20')).toBe(true);
});
it('returns undefined for empty/null captures', () => {
expect(normalizeCapturedFrame(null)).toBeUndefined();
expect(normalizeCapturedFrame('\n\n')).toBeUndefined();
});
it('converts absolute row repaints (formatPaneSnapshot frames) into lines', () => {
// The visible tmux capture carries NO newlines; every row is painted at
// `ESC[<row>;1H`. Measured against a live dialog frame.
const raw = '\x1b[12;1H Which color do you prefer?\x1b[13;1H❯ 1. Red\x1b[14;1H Prefer red\x1b[15;1H 2. Blue';
const out = normalizeCapturedFrame(raw)!;
expect(out.split('\n')).toEqual([' Which color do you prefer?', '❯ 1. Red', ' Prefer red', ' 2. Blue']);
expect(parseDialogOptions(out)).toEqual([
{ n: 1, label: 'Red' },
{ n: 2, label: 'Blue' },
]);
});
it('turns mid-row cursor jumps into spaces instead of gluing words', () => {
const out = normalizeCapturedFrame('\x1b[5;1Hstatus:\x1b[5;20Hready');
expect(out).toBe('status: ready');
});
});
describe('ApprovalInbox', () => {
let inbox: ApprovalInbox;
beforeEach(() => {
vi.useFakeTimers();
inbox = new ApprovalInbox();
});
afterEach(() => {
inbox.stop();
vi.useRealTimers();
});
it('notePrompt creates a pending item with parsed options and emits onPending', () => {
const { pending } = collect(inbox);
const item = inbox.notePrompt({
sessionId: 's1',
sessionName: 'w1-case',
kind: 'permission',
toolName: 'Edit',
capture: () => PERMISSION_FRAME,
});
expect(item.options).toHaveLength(3);
expect(item.context).toContain('Do you want to make this edit');
expect(pending).toHaveLength(1);
expect(inbox.listPending()).toHaveLength(1);
expect(inbox.getById(item.id)?.id).toBe(item.id);
expect(inbox.getForSession('s1')?.id).toBe(item.id);
});
it('a new prompt supersedes the session previous item', () => {
const { resolved } = collect(inbox);
const first = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
const second = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'question' });
expect(inbox.listPending()).toHaveLength(1);
expect(inbox.getById(first.id)).toBeUndefined();
expect(inbox.getById(second.id)).toBeDefined();
expect(resolved).toEqual([expect.objectContaining({ id: first.id, resolution: 'superseded' })]);
});
it('idle prompts never get digit options', () => {
const item = inbox.notePrompt({
sessionId: 's1',
sessionName: 'w1',
kind: 'idle',
capture: () => PERMISSION_FRAME,
});
expect(item.options).toBeUndefined();
expect(item.context).toBeDefined();
});
it('resolveForSession with a kinds filter skips other kinds (working-flap guard)', () => {
const { resolved } = collect(inbox);
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
inbox.resolveForSession('s1', 'resolved_in_terminal', ['idle']);
expect(inbox.listPending()).toHaveLength(1);
inbox.notePrompt({ sessionId: 's2', sessionName: 'w2', kind: 'idle' });
inbox.resolveForSession('s2', 'resolved_in_terminal', ['idle']);
expect(inbox.getForSession('s2')).toBeUndefined();
expect(resolved.filter((r) => r.resolution === 'resolved_in_terminal')).toHaveLength(1);
});
it('take removes as answered; restore re-inserts unless superseded', () => {
const { resolved } = collect(inbox);
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
const taken = inbox.take(item.id)!;
expect(taken.id).toBe(item.id);
expect(inbox.take(item.id)).toBeUndefined();
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'answered' });
inbox.restore(taken);
expect(inbox.getById(item.id)).toBeDefined();
// A newer prompt wins over a restore.
const taken2 = inbox.take(item.id)!;
const newer = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'question' });
inbox.restore(taken2);
expect(inbox.getForSession('s1')?.id).toBe(newer.id);
});
it('dismiss removes without answering', () => {
const { resolved } = collect(inbox);
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'question' });
expect(inbox.dismiss(item.id)).toBe(true);
expect(inbox.dismiss(item.id)).toBe(false);
expect(resolved.at(-1)).toMatchObject({ resolution: 'dismissed' });
});
it('items expire after the TTL on read', () => {
const { resolved } = collect(inbox);
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
vi.advanceTimersByTime(13 * 60 * 60 * 1000);
expect(inbox.listPending()).toHaveLength(0);
expect(resolved.at(-1)).toMatchObject({ resolution: 'expired' });
});
it('re-captures once after a short delay and emits onUpdated', () => {
const { updated } = collect(inbox);
let frame = 'still painting...';
const item = inbox.notePrompt({
sessionId: 's1',
sessionName: 'w1',
kind: 'permission',
capture: () => frame,
});
expect(item.options).toBeUndefined();
frame = PERMISSION_FRAME;
vi.advanceTimersByTime(700);
expect(updated).toHaveLength(1);
expect(inbox.getById(item.id)?.options).toHaveLength(3);
});
it('the delayed re-capture never touches a superseded item', () => {
let frame = 'first';
const first = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission', capture: () => frame });
const second = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'question', capture: () => frame });
frame = PERMISSION_FRAME;
const { updated } = collect(inbox);
vi.advanceTimersByTime(700);
expect(updated.every((i) => i.id !== first.id)).toBe(true);
expect(inbox.getById(second.id)).toBeDefined();
});
describe('verifyStillAnswerable', () => {
it('resolves the item and refuses when a parsed dialog left the screen', () => {
const { resolved } = collect(inbox);
let frame = PERMISSION_FRAME;
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission', capture: () => frame });
expect(item.options).toHaveLength(3);
frame = 'the dialog is gone, claude is typing';
expect(inbox.verifyStillAnswerable(item.id)).toBe(false);
expect(inbox.getById(item.id)).toBeUndefined();
expect(resolved.at(-1)).toMatchObject({ id: item.id, resolution: 'resolved_in_terminal' });
});
it('refreshes context/options when the dialog is still up', () => {
let frame = PERMISSION_FRAME;
const item = inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission', capture: () => frame });
frame = TWO_OPTION_FRAME;
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
expect(inbox.getById(item.id)?.options).toHaveLength(2);
});
it('is inconclusive (allows) for items that never parsed options', () => {
const item = inbox.notePrompt({
sessionId: 's1',
sessionName: 'w1',
kind: 'permission',
capture: () => 'unparseable dialog',
});
expect(item.options).toBeUndefined();
expect(inbox.verifyStillAnswerable(item.id)).toBe(true);
});
it('is true for unknown ids only as false (missing item refuses)', () => {
expect(inbox.verifyStillAnswerable('nope:1')).toBe(false);
});
});
it('stop() clears items and silences events', () => {
const { resolved } = collect(inbox);
inbox.notePrompt({ sessionId: 's1', sessionName: 'w1', kind: 'permission' });
inbox.stop();
expect(inbox.listPending()).toHaveLength(0);
expect(resolved).toHaveLength(0);
});
});
+236
View File
@@ -0,0 +1,236 @@
/**
* @fileoverview File Viewer "show hidden" toggle (issue #221).
*
* Hidden (dot-prefixed) entries are filtered SERVER-side by
* `GET /api/sessions/:id/files`, which has always accepted `showHidden=true`;
* the frontend simply hardcoded `showHidden=false`. So the whole feature is the
* client honouring a persisted per-device flag, and the things that can silently
* break it are:
*
* 1. the request going out with the wrong `showHidden` value (the toggle looks
* dead: the button lights up, the tree does not change),
* 2. the toggle re-rendering the cached tree instead of re-fetching (same
* symptom, and no request in the network tab to explain it),
* 3. toggling collapsing the tree the user just navigated,
* 4. the flag not surviving a reload, or a `localStorage` throw (Safari private
* mode) taking the whole panel down with it.
*
* Loaded via `vm` with a stubbed context (no jsdom; see connection-indicator.test.ts).
* `CodemanApp`'s real constructor calls `init()`, so the prototype is exercised on
* a bare object instead of a real instance; the app.js wiring that seeds the flag
* is pinned statically at the bottom.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { beforeEach, 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 STORAGE_KEY = 'codeman:fileBrowserShowHidden';
interface FakeElement {
innerHTML: string;
textContent: string;
classes: Set<string>;
attrs: Record<string, string>;
classList: { toggle: (name: string, on: boolean) => void };
setAttribute: (name: string, value: string) => void;
}
function fakeElement(): FakeElement {
const classes = new Set<string>();
const attrs: Record<string, string> = {};
return {
innerHTML: '',
textContent: '',
classes,
attrs,
classList: {
toggle(name: string, on: boolean) {
if (on) classes.add(name);
else classes.delete(name);
},
},
setAttribute(name: string, value: string) {
attrs[name] = value;
},
};
}
/** Load panels-ui.js's mixin onto a bare object, with a stubbed DOM + storage. */
function loadPanel(store: Map<string, string> | null) {
const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, unknown>;
const localStorage = {
getItem: (key: string) => {
if (!store) throw new Error('localStorage is disabled');
return store.has(key) ? store.get(key) : null;
},
setItem: (key: string, value: string) => {
if (!store) throw new Error('localStorage is disabled');
store.set(key, value);
},
removeItem: (key: string) => store?.delete(key),
};
const context = vm.createContext({
CodemanApp,
console,
localStorage,
escapeHtml: (s: string) => String(s),
document: { getElementById: () => null, addEventListener: vi.fn() },
window: { addEventListener: vi.fn() },
setTimeout,
clearTimeout,
fetch: () => {
throw new Error('fetch not stubbed');
},
});
vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' });
const elements: Record<string, FakeElement> = {
fileBrowserTree: fakeElement(),
fileBrowserStatus: fakeElement(),
fileBrowserHiddenBtn: fakeElement(),
};
const requests: string[] = [];
const app = new CodemanApp() as Record<string, any>;
app.$ = (id: string) => elements[id] ?? null;
app.activeSessionId = 'sess-1';
app.fileBrowserData = null;
app.fileBrowserExpandedDirs = new Set<string>();
app.fileBrowserFilter = '';
app.fileBrowserShowHidden = app._loadFileBrowserShowHidden();
// Mirror app.js: fetch is a global in the browser, a per-app stub here.
context.fetch = async (url: string) => {
requests.push(url);
return {
ok: true,
json: async () => ({
success: true,
data: { tree: [], totalFiles: 3, totalDirectories: 1, truncated: false },
}),
};
};
return { app, elements, requests };
}
describe('File Viewer show-hidden toggle', () => {
let store: Map<string, string>;
beforeEach(() => {
store = new Map();
});
it('requests showHidden=false by default', async () => {
const { app, requests } = loadPanel(store);
expect(app.fileBrowserShowHidden).toBe(false);
await app.loadFileBrowser('sess-1');
expect(requests).toHaveLength(1);
expect(requests[0]).toContain('showHidden=false');
});
it('restores an enabled toggle from localStorage and requests showHidden=true', async () => {
store.set(STORAGE_KEY, '1');
const { app, requests } = loadPanel(store);
expect(app.fileBrowserShowHidden).toBe(true);
await app.loadFileBrowser('sess-1');
expect(requests[0]).toContain('showHidden=true');
});
it('re-fetches the tree when toggled, since hidden entries are filtered server-side', async () => {
const { app, requests } = loadPanel(store);
await app.loadFileBrowser('sess-1');
expect(requests[0]).toContain('showHidden=false');
await app.toggleFileBrowserHidden();
expect(app.fileBrowserShowHidden).toBe(true);
expect(requests).toHaveLength(2);
expect(requests[1]).toContain('showHidden=true');
expect(store.get(STORAGE_KEY)).toBe('1');
});
it('toggles back off and persists the off state', async () => {
store.set(STORAGE_KEY, '1');
const { app, requests } = loadPanel(store);
await app.toggleFileBrowserHidden();
expect(app.fileBrowserShowHidden).toBe(false);
expect(store.get(STORAGE_KEY)).toBe('0');
expect(requests[0]).toContain('showHidden=false');
});
it('keeps expanded directories across a toggle', async () => {
const { app } = loadPanel(store);
app.fileBrowserExpandedDirs.add('src');
app.fileBrowserExpandedDirs.add('src/web');
await app.toggleFileBrowserHidden();
expect([...app.fileBrowserExpandedDirs]).toEqual(['src', 'src/web']);
});
it('reflects state on the button and in the status line', async () => {
const { app, elements } = loadPanel(store);
const btn = elements.fileBrowserHiddenBtn;
await app.loadFileBrowser('sess-1');
expect(btn.classes.has('active')).toBe(false);
expect(btn.attrs['aria-pressed']).toBe('false');
expect(btn.attrs.title).toBe('Show hidden files and folders');
expect(elements.fileBrowserStatus.textContent).not.toContain('hidden shown');
await app.toggleFileBrowserHidden();
expect(btn.classes.has('active')).toBe(true);
expect(btn.attrs['aria-pressed']).toBe('true');
expect(btn.attrs.title).toBe('Hide hidden files and folders');
expect(btn.attrs['aria-label']).toBe('Hide hidden files and folders');
expect(elements.fileBrowserStatus.textContent).toContain('hidden shown');
});
it('survives a localStorage that throws (private browsing)', async () => {
const { app, requests } = loadPanel(null);
expect(app.fileBrowserShowHidden).toBe(false);
await app.toggleFileBrowserHidden();
expect(app.fileBrowserShowHidden).toBe(true);
expect(requests[0]).toContain('showHidden=true');
});
it('does not reset the preference on a panel refresh', async () => {
store.set(STORAGE_KEY, '1');
const { app, requests } = loadPanel(store);
app.refreshFileBrowser();
await Promise.resolve();
expect(app.fileBrowserShowHidden).toBe(true);
expect(requests[0]).toContain('showHidden=true');
});
});
describe('File Viewer show-hidden wiring', () => {
it('exposes the toggle in the file browser header', () => {
expect(indexHtml).toContain('onclick="app.toggleFileBrowserHidden()"');
expect(indexHtml).toContain('id="fileBrowserHiddenBtn"');
expect(indexHtml).toContain('aria-pressed="false"');
});
it('seeds the flag from storage when the app is constructed', () => {
expect(appJs).toMatch(/this\.fileBrowserShowHidden\s*=\s*this\._loadFileBrowserShowHidden\?\.\(\)/);
});
it('styles the active state so the toggle reads as on', () => {
expect(stylesCss).toContain('.btn-file-browser-hidden.active');
});
});
+19
View File
@@ -81,6 +81,25 @@ describe('refreshStaleCodemanHooks', () => {
expect(readFileSync(settingsPath, 'utf-8')).toBe(healed); // byte-identical: no rewrite
});
it('heals a hooks block that predates the elicitation-closed matchers (Approvals Inbox)', async () => {
// A current-at-the-time block from before elicitation_complete/response
// existed: secret + markers all present, so ONLY the new-matcher probe can
// mark it stale. Build one by healing, then stripping the two matchers.
writeFileSync(settingsPath, JSON.stringify({ hooks: staleCodemanHooks() }, null, 2));
await refreshStaleCodemanHooks(dir);
const healed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
healed.hooks.Notification = (healed.hooks.Notification as Array<{ matcher?: string }>).filter(
(n) => n.matcher !== 'elicitation_complete' && n.matcher !== 'elicitation_response'
);
writeFileSync(settingsPath, JSON.stringify(healed, null, 2));
expect(readFileSync(settingsPath, 'utf-8')).not.toContain('elicitation_complete');
await refreshStaleCodemanHooks(dir);
const after = readFileSync(settingsPath, 'utf-8');
expect(after).toContain('elicitation_complete');
expect(after).toContain('elicitation_response');
});
it('does not touch hooks that are not Codeman’s (no /api/hook-event)', async () => {
const foreign = JSON.stringify(
{ hooks: { Stop: [{ matcher: '', hooks: [{ type: 'command', command: 'echo hi', timeout: 5 }] }] } },
+6 -3
View File
@@ -28,7 +28,7 @@ describe('generateHooksConfig', () => {
it('should have Notification hooks array', () => {
const config = generateHooksConfig();
expect(config.hooks.Notification).toBeInstanceOf(Array);
expect(config.hooks.Notification).toHaveLength(3);
expect(config.hooks.Notification).toHaveLength(5);
});
it('should have Stop hooks array', () => {
@@ -194,7 +194,7 @@ describe('writeHooksConfig', () => {
const settingsPath = join(testDir, '.claude', 'settings.local.json');
const parsed = JSON.parse(readFileSync(settingsPath, 'utf-8'));
expect(parsed.hooks).toBeDefined();
expect(parsed.hooks.Notification).toHaveLength(3);
expect(parsed.hooks.Notification).toHaveLength(5);
expect(parsed.hooks.Stop).toHaveLength(1);
});
@@ -1085,7 +1085,7 @@ describe('Hook Config Generation - Extended', () => {
it('should generate valid JSON structure', () => {
const config = generateHooksConfig();
expect(config.hooks).toBeDefined();
expect(config.hooks.Notification).toHaveLength(3);
expect(config.hooks.Notification).toHaveLength(5);
expect(config.hooks.Stop).toHaveLength(1);
});
@@ -1096,6 +1096,9 @@ describe('Hook Config Generation - Extended', () => {
expect(matchers).toContain('idle_prompt');
expect(matchers).toContain('permission_prompt');
expect(matchers).toContain('elicitation_dialog');
// Approvals Inbox resolution signals (dialog answered in the terminal).
expect(matchers).toContain('elicitation_complete');
expect(matchers).toContain('elicitation_response');
});
it('should use environment variable placeholders', () => {
+177
View File
@@ -0,0 +1,177 @@
/**
* @fileoverview Tests for the version-gated `--name <session name>` claude spawn flag.
*
* The flag makes a Codeman claude worker's cross-session-messaging peer name equal
* its Codeman session name. The gate MUST be fail-closed: a claude CLI older than
* 2.1.224 aborts startup on an unknown option, which would kill every session spawn,
* so an unknown/absent version must produce a command byte-identical to the
* pre-`--name` one. Covers both spawn paths (buildInteractiveArgs for the direct
* PTY fallback, buildSpawnCommand for the tmux pane command) plus the allowlist
* sanitizer that keeps the double-quoted shell interpolation injection-free.
*/
import { describe, it, expect } from 'vitest';
import {
buildInteractiveArgs,
buildNameCliArgs,
sanitizeCliSessionName,
CLAUDE_NAME_FLAG_MIN_VERSION,
} from '../src/session-cli-builder.js';
import { buildSpawnCommand } from '../src/tmux-manager.js';
describe('sanitizeCliSessionName', () => {
it('passes ordinary Codeman session names through', () => {
expect(sanitizeCliSessionName('w1-msgtest-worker')).toBe('w1-msgtest-worker');
expect(sanitizeCliSessionName('w18-claudeman: pi')).toBe('w18-claudeman: pi');
});
it('keeps Unicode letters (CJK session names survive)', () => {
expect(sanitizeCliSessionName('会话-测试 w2')).toBe('会话-测试 w2');
});
it('strips every character that is special inside double quotes', () => {
const cleaned = sanitizeCliSessionName('w1"; $(rm -rf /) `boom` \\ $HOME');
expect(cleaned).toBeDefined();
// The double-quote interpolation in buildSpawnCommand is only safe because
// none of these can survive: " $ ` \ and newlines.
expect(cleaned).not.toMatch(/["$`\\\n\r]/);
expect(cleaned).not.toMatch(/[();/]/);
});
it('strips leading dashes so the value cannot parse as another CLI option', () => {
expect(sanitizeCliSessionName('--resume')).toBe('resume');
expect(sanitizeCliSessionName('-x')).toBe('x');
});
it('collapses whitespace and caps length at 64', () => {
expect(sanitizeCliSessionName('a b\t c')).toBe('a b c');
const long = 'x'.repeat(200);
expect(sanitizeCliSessionName(long)).toHaveLength(64);
});
it('returns undefined when nothing safe remains (flag must be omitted, never --name "")', () => {
expect(sanitizeCliSessionName(undefined)).toBeUndefined();
expect(sanitizeCliSessionName('')).toBeUndefined();
expect(sanitizeCliSessionName('"$`\\')).toBeUndefined();
expect(sanitizeCliSessionName('---')).toBeUndefined();
});
});
describe('buildNameCliArgs version gate', () => {
it('emits the flag from the minimum version up', () => {
// 2.1.224 ships cross-session messaging AND is verified (locally, --help)
// to accept --name; the constant must never drift below it.
expect(CLAUDE_NAME_FLAG_MIN_VERSION).toBe('2.1.224');
expect(buildNameCliArgs('w1-a', '2.1.224')).toEqual(['--name', 'w1-a']);
expect(buildNameCliArgs('w1-a', '2.1.226')).toEqual(['--name', 'w1-a']);
expect(buildNameCliArgs('w1-a', '2.2.0')).toEqual(['--name', 'w1-a']);
expect(buildNameCliArgs('w1-a', '3.0.0')).toEqual(['--name', 'w1-a']);
});
it('FAILS CLOSED below the minimum and on unknown versions', () => {
// An older CLI aborts startup on an unknown flag: [] here is what keeps
// every spawn alive on old installs.
expect(buildNameCliArgs('w1-a', '2.1.223')).toEqual([]);
expect(buildNameCliArgs('w1-a', '2.0.999')).toEqual([]);
expect(buildNameCliArgs('w1-a', '1.0.128')).toEqual([]);
expect(buildNameCliArgs('w1-a', null)).toEqual([]);
expect(buildNameCliArgs('w1-a', undefined)).toEqual([]);
});
it('omits the flag entirely when the name sanitizes away or is absent', () => {
expect(buildNameCliArgs(undefined, '2.1.226')).toEqual([]);
expect(buildNameCliArgs('"$`', '2.1.226')).toEqual([]);
});
});
describe('buildInteractiveArgs with a session name (direct PTY path)', () => {
it('appends --name when the version supports it', () => {
const args = buildInteractiveArgs(
'sid-1',
'dangerously-skip-permissions',
undefined,
undefined,
undefined,
'w1-a',
'2.1.226'
);
const idx = args.indexOf('--name');
expect(idx).toBeGreaterThan(-1);
expect(args[idx + 1]).toBe('w1-a');
});
it('omits --name on an old or unknown version', () => {
expect(
buildInteractiveArgs('sid-1', 'dangerously-skip-permissions', undefined, undefined, undefined, 'w1-a', '2.1.223')
).not.toContain('--name');
expect(
buildInteractiveArgs('sid-1', 'dangerously-skip-permissions', undefined, undefined, undefined, 'w1-a', null)
).not.toContain('--name');
// Version parameter omitted entirely = same fail-closed omission
expect(
buildInteractiveArgs('sid-1', 'dangerously-skip-permissions', undefined, undefined, undefined, 'w1-a')
).not.toContain('--name');
});
});
describe('buildSpawnCommand with a session name (tmux path)', () => {
const base = {
mode: 'claude' as const,
sessionId: 'aaaabbbb-cccc-dddd-eeee-ffff00001111',
claudeMode: 'dangerously-skip-permissions' as const,
};
it('appends a quoted --name when the injected version supports it', () => {
const cmd = buildSpawnCommand({ ...base, sessionName: 'w1-msgtest-worker', claudeCliVersion: '2.1.226' });
expect(cmd).toContain(' --name "w1-msgtest-worker"');
});
it('stays byte-identical to the flagless command on an old version', () => {
const withOld = buildSpawnCommand({ ...base, sessionName: 'w1-a', claudeCliVersion: '2.1.223' });
const without = buildSpawnCommand({ ...base, claudeCliVersion: '2.1.223' });
expect(withOld).toBe(without);
expect(withOld).not.toContain('--name');
});
it('stays byte-identical when the version probe failed (null)', () => {
const cmd = buildSpawnCommand({ ...base, sessionName: 'w1-a', claudeCliVersion: null });
expect(cmd).toBe(buildSpawnCommand({ ...base, claudeCliVersion: null }));
});
it('defaults fail-closed when no version is injected (vitest probe is hermetically null)', () => {
// In production the omitted field resolves through getClaudeCliVersion();
// under vitest that is null by design, which doubles as the fail-closed pin.
const cmd = buildSpawnCommand({ ...base, sessionName: 'w1-a' });
expect(cmd).not.toContain('--name');
});
it('carries the flag in BOTH branches of the resume fallback chain', () => {
const cmd = buildSpawnCommand({
...base,
sessionName: 'w1-a',
claudeCliVersion: '2.1.226',
resumeSessionId: 'aaaabbbb-cccc-dddd-eeee-ffff00001111',
});
const occurrences = cmd.split(' --name "w1-a"').length - 1;
expect(cmd).toContain(' || ');
expect(occurrences).toBe(2);
});
it('sanitizes a hostile name before interpolation', () => {
const cmd = buildSpawnCommand({
...base,
sessionName: 'w1"; rm -rf /; echo "',
claudeCliVersion: '2.1.226',
});
const m = cmd.match(/ --name "([^"]*)"/);
expect(m).not.toBeNull();
// Whatever remains inside the quotes must be inert: no quote/dollar/backtick/
// backslash can survive the allowlist, so the shell sees one literal argv.
expect(m![1]).not.toMatch(/["$`\\;/]/);
});
it('never adds --name to non-claude modes', () => {
const cmd = buildSpawnCommand({ mode: 'shell', sessionId: base.sessionId, sessionName: 'w1-a' });
expect(cmd).not.toContain('--name');
});
});
+187
View File
@@ -0,0 +1,187 @@
/**
* @fileoverview PathPicker "show hidden" toggle (issue #221).
*
* `PathPicker` (keyboard-accessory.js) is the shared browser behind Link
* Existing's "Browse" and the mobile keyboard's `📁 Path` key, so one toggle
* serves both. What can silently go wrong here:
*
* 1. `showHidden` missing from the browse request (toggle looks dead),
* 2. `showHidden` missing from the PREVIEW request, which re-resolves the
* path independently, so the listing would show a hidden file that then
* 403s the moment you tap it,
* 3. the toggle resetting you to the root instead of reloading where you are,
* 4. the flag not surviving a reopen, or a `localStorage` throw taking the
* picker down with it.
*
* The picker builds its dialog with innerHTML and drives it through real
* listeners, so this needs a DOM rather than a `vm` stub. It runs in the DEFAULT
* node environment and constructs a jsdom window here, matching
* markdown-sanitizer.test.ts: a per-file jsdom environment directive
* externalizes node:fs under vite and the suite then fails to load. ⚠️ Do not
* write that directive's literal name anywhere in this file, not even in prose
* like this: vitest scans the whole source for it, so merely explaining the trap
* re-arms it.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { JSDOM } from 'jsdom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const accessoryJs = readFileSync(resolve(PUBLIC, 'keyboard-accessory.js'), 'utf8');
const stylesCss = readFileSync(resolve(PUBLIC, 'styles.css'), 'utf8');
const STORAGE_KEY = 'codeman:pathPickerShowHidden';
const dom = new JSDOM('<!DOCTYPE html><html><body></body></html>', { url: 'https://localhost/' });
const jsdomWindow = dom.window as unknown as Window & typeof globalThis;
const jsdomDocument = jsdomWindow.document;
/** Evaluate keyboard-accessory.js against the jsdom window and return PathPicker. */
function loadPathPicker(fetchImpl: (url: string) => Promise<unknown>): any {
const MobileDetection = { isTouchDevice: () => false };
const factory = new Function(
'window',
'document',
'localStorage',
'fetch',
'MobileDetection',
`${accessoryJs}\nreturn PathPicker;`
);
return factory(jsdomWindow, jsdomDocument, jsdomWindow.localStorage, fetchImpl, MobileDetection);
}
function browseResponse(entries: Array<{ name: string; type: string }>, path = '/home/dev/project') {
return {
ok: true,
json: async () => ({
success: true,
data: {
path,
parent: null,
root: '/home/dev',
roots: [{ label: 'Home', path: '/home/dev' }],
entries: entries.map((e) => ({ ...e, path: `${path}/${e.name}` })),
truncated: false,
},
}),
};
}
describe('PathPicker show-hidden toggle', () => {
let PathPicker: any;
let urls: string[];
let respond: (url: string) => unknown;
beforeEach(() => {
jsdomWindow.localStorage.clear();
jsdomDocument.body.replaceChildren();
urls = [];
respond = () =>
browseResponse([
{ name: '.github', type: 'directory' },
{ name: 'src', type: 'directory' },
]);
PathPicker = loadPathPicker(async (url: string) => {
urls.push(url);
return respond(url);
});
});
afterEach(() => {
PathPicker?.close?.(false);
jsdomDocument.body.replaceChildren();
});
const open = async (options: Record<string, unknown> = {}) => {
PathPicker.open({ onSelect: () => {}, ...options });
await vi.waitFor(() => expect(urls.length).toBeGreaterThan(0));
};
const toggle = () => jsdomDocument.querySelector('.path-picker-hidden') as HTMLButtonElement;
const previewHref = () =>
(jsdomDocument.querySelector('.path-preview-open') as HTMLAnchorElement).getAttribute('href') ?? '';
it('omits showHidden by default', async () => {
await open();
expect(urls[0]).not.toContain('showHidden');
expect(toggle().getAttribute('aria-pressed')).toBe('false');
expect(toggle().classList.contains('active')).toBe(false);
expect(toggle().getAttribute('title')).toBe('Show hidden files and folders');
});
it('sends showHidden=true after the toggle is pressed, and persists it', async () => {
await open();
toggle().click();
await vi.waitFor(() => expect(urls.length).toBe(2));
expect(urls[1]).toContain('showHidden=true');
expect(jsdomWindow.localStorage.getItem(STORAGE_KEY)).toBe('1');
expect(toggle().getAttribute('aria-pressed')).toBe('true');
expect(toggle().classList.contains('active')).toBe(true);
expect(toggle().getAttribute('title')).toBe('Hide hidden files and folders');
});
it('restores the preference when the picker is reopened', async () => {
jsdomWindow.localStorage.setItem(STORAGE_KEY, '1');
await open();
expect(urls[0]).toContain('showHidden=true');
expect(toggle().getAttribute('aria-pressed')).toBe('true');
});
it('reloads the current folder rather than resetting to the root', async () => {
jsdomWindow.localStorage.setItem(STORAGE_KEY, '1');
// Sitting inside a hidden folder, reachable only because the toggle is on.
respond = () => browseResponse([{ name: 'workflows', type: 'directory' }], '/home/dev/project/.github');
await open({ initialPath: '/home/dev/project/.github' });
toggle().click();
await vi.waitFor(() => expect(urls.length).toBe(2));
expect(decodeURIComponent(urls[1])).toContain('path=/home/dev/project/.github');
expect(urls[1]).not.toContain('showHidden=true');
});
it('carries the flag into the preview request', async () => {
jsdomWindow.localStorage.setItem(STORAGE_KEY, '1');
await open();
PathPicker.openPreview({ name: '.gitignore', path: '/home/dev/project/.gitignore', previewKind: 'text' });
expect(previewHref()).toContain('showHidden=true');
});
it('leaves the preview flag off when the toggle is off', async () => {
await open();
PathPicker.openPreview({ name: 'notes.txt', path: '/home/dev/project/notes.txt', previewKind: 'text' });
expect(previewHref()).not.toContain('showHidden');
});
it('survives a localStorage that throws (private browsing)', async () => {
const storage = Object.getPrototypeOf(jsdomWindow.localStorage);
const getItem = vi.spyOn(storage, 'getItem').mockImplementation(() => {
throw new Error('denied');
});
const setItem = vi.spyOn(storage, 'setItem').mockImplementation(() => {
throw new Error('denied');
});
try {
await open();
expect(urls[0]).not.toContain('showHidden');
toggle().click();
await vi.waitFor(() => expect(urls.length).toBe(2));
expect(urls[1]).toContain('showHidden=true');
} finally {
getItem.mockRestore();
setItem.mockRestore();
}
});
it('styles the active toggle so it reads as on', () => {
expect(stylesCss).toContain('.path-picker-hidden.active');
});
});
+74 -11
View File
@@ -76,11 +76,11 @@ describe('push payload hostTitle (Web Push hostname plumbing)', () => {
setVapidDetails.mockClear();
});
it('includes hostTitle = codeman:<titleHostname> in the payload', () => {
it('includes hostTitle = codeman:<titleHostname> in the payload', async () => {
const server = makeServerWithHost('laptop');
(
await (
server as unknown as {
sendPushNotifications: (e: string, d: Record<string, unknown>) => void;
sendPushNotifications: (e: string, d: Record<string, unknown>) => Promise<void>;
}
).sendPushNotifications('hook:idle_prompt', {
sessionId: 's-1',
@@ -93,11 +93,11 @@ describe('push payload hostTitle (Web Push hostname plumbing)', () => {
expect(payload.title).toBe('Waiting for Input');
});
it('falls back to os.hostname() when --title-hostname is not provided', () => {
it('falls back to os.hostname() when --title-hostname is not provided', async () => {
const server = makeServerWithHost(''); // empty -> constructor uses getHostname()
(
await (
server as unknown as {
sendPushNotifications: (e: string, d: Record<string, unknown>) => void;
sendPushNotifications: (e: string, d: Record<string, unknown>) => Promise<void>;
}
).sendPushNotifications('hook:permission_prompt', {
sessionId: 's-2',
@@ -111,18 +111,18 @@ describe('push payload hostTitle (Web Push hostname plumbing)', () => {
expect(payload.title).toBe('Permission Required');
});
it('different WebServer instances ship distinct hostTitles', () => {
it('different WebServer instances ship distinct hostTitles', async () => {
const a = makeServerWithHost('host-a');
const b = makeServerWithHost('host-b');
(
await (
a as unknown as {
sendPushNotifications: (e: string, d: Record<string, unknown>) => void;
sendPushNotifications: (e: string, d: Record<string, unknown>) => Promise<void>;
}
).sendPushNotifications('hook:stop', { sessionId: 's-a', sessionName: 'A' });
(
await (
b as unknown as {
sendPushNotifications: (e: string, d: Record<string, unknown>) => void;
sendPushNotifications: (e: string, d: Record<string, unknown>) => Promise<void>;
}
).sendPushNotifications('hook:stop', { sessionId: 's-b', sessionName: 'B' });
@@ -164,3 +164,66 @@ describe('service worker displayTitle composition (mirrors sw.js)', () => {
expect(computeSwDisplayTitle({})).toBe('Codeman');
});
});
// ─── Approvals Inbox gating ──────────────────────────────────────────────
// The Approve/Deny action buttons answer through the Approvals Inbox, so the
// payload ships them (and the approvalId they act on) only when the OPT-IN
// `approvalsInboxEnabled` setting is on. Pre-inbox these buttons rendered and
// did nothing; with the feature off they must not render at all.
interface ApprovalAwarePayload extends PushPayload {
approvalId?: string;
}
function setSettings(server: WebServer, settings: Record<string, unknown>): void {
(server as unknown as { readSettings: () => Promise<Record<string, unknown>> }).readSettings = async () => settings;
}
async function sendPermissionPush(server: WebServer): Promise<ApprovalAwarePayload> {
await (
server as unknown as {
sendPushNotifications: (e: string, d: Record<string, unknown>) => Promise<void>;
}
).sendPushNotifications('hook:permission_prompt', {
sessionId: 's-gate',
sessionName: 'sess',
tool_name: 'Bash',
approvalId: 's-gate:1',
});
return lastPayload() as ApprovalAwarePayload;
}
describe('push payload Approvals Inbox gating', () => {
beforeEach(() => {
sendNotification.mockClear();
});
it('strips actions and approvalId when the setting is off (the default)', async () => {
const server = makeServerWithHost('gate-off');
setSettings(server, {});
const payload = await sendPermissionPush(server);
expect(payload.actions).toBeUndefined();
expect(payload.approvalId).toBeUndefined();
// The notification itself still goes out; only the inbox parts are gated.
expect(payload.title).toBe('Permission Required');
});
it('ships Approve/Deny actions and the approvalId when the setting is on', async () => {
const server = makeServerWithHost('gate-on');
setSettings(server, { approvalsInboxEnabled: true });
const payload = await sendPermissionPush(server);
expect(payload.actions).toEqual([
{ action: 'approve', title: 'Approve' },
{ action: 'deny', title: 'Deny' },
]);
expect(payload.approvalId).toBe('s-gate:1');
});
it('an explicit false behaves like the default (only true enables)', async () => {
const server = makeServerWithHost('gate-false');
setSettings(server, { approvalsInboxEnabled: false });
const payload = await sendPermissionPush(server);
expect(payload.actions).toBeUndefined();
expect(payload.approvalId).toBeUndefined();
});
});
+15
View File
@@ -115,6 +115,21 @@ describe('hasWorkingPattern', () => {
});
});
describe('current Claude status line', () => {
it('should detect the randomized gerund by the elapsed timer', () => {
// Live captures on Claude Code 2.1.220. The word changes every turn, so the
// WORKING_PATTERNS list above cannot see any of these.
expect(hasWorkingPattern('✻ Actualizing… (15m 17s · ↓ 47.5k tokens)')).toBe(true);
expect(hasWorkingPattern('· Finagling… (4m 45s · ↓ 13.3k tokens)')).toBe(true);
expect(hasWorkingPattern('✽ Herding… (3s · esc to interrupt)')).toBe(true);
});
it('should NOT treat the completion line as working', () => {
expect(hasWorkingPattern('✻ Cooked for 2m 49s')).toBe(false);
expect(hasWorkingPattern('✻ Brewed for 18m 41s')).toBe(false);
});
});
describe('spinner characters', () => {
it('should detect braille spinner characters', () => {
expect(hasWorkingPattern('Loading... \u280B')).toBe(true);
+348
View File
@@ -0,0 +1,348 @@
/**
* Approvals Inbox route tests (src/web/routes/approval-routes.ts) via app.inject(),
* no live port. The hook-event route is registered alongside so items are
* created through the REAL ingestion path (sanitize → notePrompt with the
* terminal-buffer capture fallback), not by poking the store directly.
*
* The routes read the process-wide `approvalInbox` singleton, so every test
* drains it in afterEach; a leaked pending item would bleed into the next test.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { registerApprovalRoutes } from '../../src/web/routes/approval-routes.js';
import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js';
import { approvalInbox } from '../../src/web/approval-inbox.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { httpStatusForErrorCode, type ApiErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockSession } from '../mocks/index.js';
type MockRouteContext = ReturnType<typeof createMockRouteContext>;
interface RouteTestHarness {
app: FastifyInstance;
ctx: MockRouteContext;
}
/**
* Local harness mirroring production's uniform-envelope preSerialization hook
* (server.ts), so `{success:false}` bodies carry their conventional 4xx status.
* The shared createRouteTestHarness deliberately omits that hook; these routes
* signal every guard through returned error envelopes, so the status IS the
* behavior under test. Pattern copied from hook-event-routes.test.ts.
*/
async function createEnvelopeHarness(authUser?: {
username: string;
role: 'admin' | 'user';
}): Promise<RouteTestHarness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
if (authUser) {
app.addHook('onRequest', async (req) => {
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
});
}
const ctx = createMockRouteContext({ sessionId: SESSION_ID });
registerHookEventRoutes(app, ctx as never);
registerApprovalRoutes(app, ctx as never);
app.addHook('preSerialization', (req, reply, payload: unknown, done) => {
if (!req.url.startsWith('/api')) return done(null, payload);
if (payload === null || typeof payload !== 'object') return done(null, payload);
const p = payload as { success?: unknown; errorCode?: unknown };
if (p.success === false) {
if (reply.statusCode === 200 && typeof p.errorCode === 'string') {
reply.code(httpStatusForErrorCode(p.errorCode as ApiErrorCode));
}
return done(null, payload);
}
if (p.success === true) return done(null, payload);
return done(null, { success: true, data: payload });
});
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
const SESSION_ID = 'approval-test-session';
const PERMISSION_DIALOG = [
' Claude needs your permission to use Bash',
' ❯ 1. Yes',
' 2. Yes, and don’t ask again for this command',
' 3. No, and tell Claude what to do differently (esc)',
].join('\n');
async function postHook(harness: RouteTestHarness, event: string, data: Record<string, unknown> = {}): Promise<void> {
const res = await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event, sessionId: SESSION_ID, data },
});
expect(res.statusCode).toBe(200);
}
async function listApprovals(harness: RouteTestHarness): Promise<Array<Record<string, unknown>>> {
const res = await harness.app.inject({ method: 'GET', url: '/api/approvals' });
expect(res.statusCode).toBe(200);
return res.json().data.approvals;
}
describe('approval routes', () => {
let harness: RouteTestHarness;
let session: MockSession;
beforeEach(async () => {
harness = await createEnvelopeHarness();
session = harness.ctx.sessions.get(SESSION_ID)!;
session.terminalBuffer = PERMISSION_DIALOG;
});
afterEach(async () => {
for (const item of approvalInbox.listPending()) {
approvalInbox.resolveForSession(item.sessionId, 'dismissed');
}
approvalInbox.onPending = approvalInbox.onUpdated = approvalInbox.onResolved = undefined;
await harness.app.close();
});
it('a permission_prompt hook creates a pending item with parsed options and context', async () => {
await postHook(harness, 'permission_prompt', {
tool_name: 'Bash',
tool_input: { command: 'rm -rf node_modules' },
message: 'Claude needs your permission to use Bash',
cwd: '/tmp/case',
});
const approvals = await listApprovals(harness);
expect(approvals).toHaveLength(1);
expect(approvals[0]).toMatchObject({
sessionId: SESSION_ID,
kind: 'permission',
toolName: 'Bash',
toolSummary: 'rm -rf node_modules',
message: 'Claude needs your permission to use Bash',
});
expect(approvals[0].options).toHaveLength(3);
expect(String(approvals[0].context)).toContain('permission to use Bash');
});
it('broadcast and push for the prompt carry the approvalId', async () => {
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
const [item] = await listApprovals(harness);
const hookBroadcast = harness.ctx.broadcast.mock.calls.find((c) => c[0] === 'hook:permission_prompt');
expect(hookBroadcast?.[1]).toMatchObject({ approvalId: item.id });
const push = harness.ctx.sendPushNotifications.mock.calls.find((c) => c[0] === 'hook:permission_prompt');
expect(push?.[1]).toMatchObject({ approvalId: item.id });
});
it('answering with a parsed option sends exactly that digit (no Enter)', async () => {
await postHook(harness, 'permission_prompt', { tool_name: 'Bash' });
const [item] = await listApprovals(harness);
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'option', option: 2 },
});
expect(res.statusCode).toBe(200);
expect(session.writeBuffer).toEqual(['2']);
expect(await listApprovals(harness)).toHaveLength(0);
});
it('approve sends "1", deny sends Esc', async () => {
await postHook(harness, 'permission_prompt', {});
let [item] = await listApprovals(harness);
await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'approve' },
});
expect(session.writeBuffer).toEqual(['1']);
session.writeBuffer.length = 0;
await postHook(harness, 'permission_prompt', {});
[item] = await listApprovals(harness);
await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'deny' },
});
expect(session.writeBuffer).toEqual(['\x1b']);
});
it('a second answer 404s (answered items leave the inbox)', async () => {
await postHook(harness, 'permission_prompt', {});
const [item] = await listApprovals(harness);
await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'approve' },
});
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'deny' },
});
expect(res.statusCode).toBe(404);
expect(session.writeBuffer).toEqual(['1']);
});
it('rejects option digits outside the parsed options', async () => {
await postHook(harness, 'permission_prompt', {});
const [item] = await listApprovals(harness);
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'option', option: 7 },
});
expect(res.statusCode).toBe(400);
expect(session.writeBuffer).toEqual([]);
});
it('refuses with 409 when the dialog left the screen since capture', async () => {
await postHook(harness, 'permission_prompt', {});
const [item] = await listApprovals(harness);
// The dialog scrolled away, so the re-capture at answer time must refuse.
session.terminalBuffer = 'claude is off doing something else now';
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'approve' },
});
expect(res.statusCode).toBe(409);
expect(session.writeBuffer).toEqual([]);
expect(await listApprovals(harness)).toHaveLength(0);
});
it('idle prompts take a text answer, submitted with \\r; approve/deny are rejected', async () => {
session.terminalBuffer = 'claude> waiting at the composer';
await postHook(harness, 'idle_prompt', { message: 'Claude is waiting for your input' });
const [item] = await listApprovals(harness);
expect(item.kind).toBe('idle');
const bad = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'approve' },
});
expect(bad.statusCode).toBe(400);
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'text', text: 'continue with the plan\nplease' },
});
expect(res.statusCode).toBe(200);
// Embedded newlines are flattened; the trailing \r submits.
expect(session.writeBuffer).toEqual(['continue with the plan please\r']);
});
it('text answers on dialog items are rejected', async () => {
await postHook(harness, 'elicitation_dialog', {});
const [item] = await listApprovals(harness);
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'text', text: 'hello' },
});
expect(res.statusCode).toBe(400);
});
it('stop resolves the pending item; elicitation_complete resolves questions', async () => {
await postHook(harness, 'elicitation_dialog', {});
expect(await listApprovals(harness)).toHaveLength(1);
await postHook(harness, 'elicitation_complete', {});
expect(await listApprovals(harness)).toHaveLength(0);
await postHook(harness, 'permission_prompt', {});
expect(await listApprovals(harness)).toHaveLength(1);
await postHook(harness, 'stop', {});
expect(await listApprovals(harness)).toHaveLength(0);
});
it('a failed write restores the item and reports 422', async () => {
await postHook(harness, 'permission_prompt', {});
const [item] = await listApprovals(harness);
session.failWrites = true;
const res = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'approve' },
});
expect(res.statusCode).toBe(422);
expect(await listApprovals(harness)).toHaveLength(1);
});
it('dismiss removes without keystrokes', async () => {
await postHook(harness, 'permission_prompt', {});
const [item] = await listApprovals(harness);
const res = await harness.app.inject({ method: 'POST', url: `/api/approvals/${item.id}/dismiss`, payload: {} });
expect(res.statusCode).toBe(200);
expect(session.writeBuffer).toEqual([]);
expect(await listApprovals(harness)).toHaveLength(0);
});
it('non-claude sessions never get inbox items', async () => {
session.mode = 'codex';
await postHook(harness, 'permission_prompt', {});
expect(await listApprovals(harness)).toHaveLength(0);
});
it('unknown ids 404 on answer and dismiss', async () => {
for (const url of ['/api/approvals/nope:1/answer', '/api/approvals/nope:1/dismiss']) {
const res = await harness.app.inject({
method: 'POST',
url,
payload: url.endsWith('answer') ? { action: 'approve' } : {},
});
expect(res.statusCode).toBe(404);
}
});
});
describe('approval routes: multi-user scoping', () => {
const saved: Record<string, string | undefined> = {};
beforeEach(() => {
saved.CODEMAN_MULTIUSER = process.env.CODEMAN_MULTIUSER;
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (saved.CODEMAN_MULTIUSER === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = saved.CODEMAN_MULTIUSER;
for (const item of approvalInbox.listPending()) {
approvalInbox.resolveForSession(item.sessionId, 'dismissed');
}
});
it("a non-admin neither lists nor answers another user's approvals (404, not 403)", async () => {
const harness = await createEnvelopeHarness({ username: 'bob', role: 'user' });
const session = harness.ctx.sessions.get(SESSION_ID)!;
session.terminalBuffer = PERMISSION_DIALOG;
(session as unknown as { owner?: string }).owner = 'alice';
await harness.app.inject({
method: 'POST',
url: '/api/hook-event',
payload: { event: 'permission_prompt', sessionId: SESSION_ID, data: {} },
});
// The item exists in the store...
expect(approvalInbox.listPending()).toHaveLength(1);
const [item] = approvalInbox.listPending();
// ...but bob sees an empty list and cannot act on the id.
const list = await harness.app.inject({ method: 'GET', url: '/api/approvals' });
expect(list.json().data.approvals).toHaveLength(0);
const answer = await harness.app.inject({
method: 'POST',
url: `/api/approvals/${item.id}/answer`,
payload: { action: 'approve' },
});
expect(answer.statusCode).toBe(404);
expect(session.writeBuffer).toEqual([]);
await harness.app.close();
});
});
+112
View File
@@ -167,6 +167,118 @@ describe('file-routes', () => {
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
});
// ===== showHidden=true (issue #221) =====
//
// The dotfile filter used to be doing security work by accident: with every
// hidden path unreachable, the sensitive-path blocklist never had to cover
// `~/.config/gh/hosts.yml` and friends. These pin that opting in lifts the
// hidden filter and NOTHING else — blocked trees, sensitive files and root
// confinement all still apply.
describe('showHidden=true', () => {
it('lists dot-prefixed entries', async () => {
mockedReaddir.mockResolvedValueOnce([
{ name: '.github', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
{ name: '.gitignore', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
{ name: 'src', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
] as never);
const root = harness.ctx._session.workingDir;
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}&showHidden=true`,
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.entries.map((e: { name: string }) => e.name)).toEqual([
'.github',
'src',
'.gitignore',
]);
});
it('allows navigating into a hidden descendant', async () => {
mockedReaddir.mockResolvedValueOnce([
{ name: 'workflows', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
] as never);
const hidden = `${harness.ctx._session.workingDir}/.github`;
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(hidden)}&showHidden=true`,
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.path).toBe(hidden);
});
it('still hides dot-prefixed entries when the flag is absent or false', async () => {
const entries = [
{ name: '.gitignore', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
{ name: 'src', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
];
const root = harness.ctx._session.workingDir;
for (const query of ['', '&showHidden=false']) {
mockedReaddir.mockResolvedValueOnce(entries as never);
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}${query}`,
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.entries.map((e: { name: string }) => e.name)).toEqual(['src']);
}
});
it('rejects a showHidden value that is not a boolean string', async () => {
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&showHidden=yes`,
});
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
});
it('still omits blocked and sensitive entries', async () => {
const root = harness.ctx._session.workingDir;
mockedReaddir.mockResolvedValueOnce([
{ name: '.ssh', isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false },
{ name: '.npmrc', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
{ name: '.env', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
{ name: '.gitignore', isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false },
// A plainly-named symlink whose target is a secret: caught on the
// resolved path, not the visible name.
{ name: 'notes', isDirectory: () => false, isFile: () => false, isSymbolicLink: () => true },
] as never);
mockedRealpathSync.mockImplementation((p: string) =>
p === `${root}/notes` ? (`${root}/.aws/credentials` as never) : (p as never)
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(root)}&showHidden=true`,
});
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body).data.entries.map((e: { name: string }) => e.name)).toEqual(['.gitignore']);
});
it('refuses a hidden path that resolves outside every root', async () => {
const outside = `${harness.ctx._session.workingDir}/.cache`;
mockedRealpathSync.mockImplementation((p: string) =>
p === outside ? ('/tmp/somewhere-else' as never) : (p as never)
);
const res = await harness.app.inject({
method: 'GET',
url: `/api/filesystem/browse?sessionId=${harness.ctx._sessionId}&path=${encodeURIComponent(outside)}&showHidden=true`,
});
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: ApiErrorCode.INVALID_INPUT });
});
});
});
// ========== Multi-user scoping for the filesystem picker ==========
+110
View File
@@ -0,0 +1,110 @@
/**
* @fileoverview The shared sensitive-path blocklist (`src/web/sensitive-path.ts`).
*
* This list guards every browser-facing file surface: workspace download,
* cross-workspace attachment registration, raw/preview serving, and the
* filesystem path picker.
*
* It became load-bearing when the picker gained `showHidden` (issue #221).
* Before that, the picker refused any path with a dot-prefixed segment, so most
* of the credential locations below were unreachable by construction and the
* list only had to cover secrets that sit in plain sight. Opting into hidden
* entries removes that accident, which is why each entry is pinned here: a
* pattern silently dropped in a refactor would re-expose a real token.
*
* The list is a BLOCKLIST by design (cross-workspace attachment is a supported
* feature), so the "stays attachable" cases matter just as much: over-blocking
* breaks the publish skill and the review-card loop.
*/
import { describe, expect, it } from 'vitest';
import { isSensitivePath } from '../src/web/sensitive-path.js';
const HOME = '/home/dev';
describe('isSensitivePath', () => {
describe('blocks', () => {
const blocked: Array<[string, string]> = [
['system shadow file', '/etc/shadow'],
['system gshadow file', '/etc/gshadow'],
['BSD master password db', '/etc/master.passwd'],
['ssh keys in home', `${HOME}/.ssh/id_ed25519`],
// Not only under homedir(): a deploy key in a project is the same secret,
// and the old homedir()-anchored pattern was captured at module load.
['ssh keys anywhere', '/srv/deploy/.ssh/id_rsa'],
['gpg keyring', `${HOME}/.gnupg/private-keys-v1.d/key.key`],
['dotenv', '/srv/app/.env'],
['suffixed dotenv', '/srv/app/.env.production'],
// Pre-existing and deliberate: `.env.*` is blocked wholesale, so even a
// committed `.env.example` is refused rather than risking the one repo
// whose "example" holds a live key.
['a dotenv example', '/srv/app/.env.example'],
['generic credentials file', '/srv/app/credentials'],
['json credentials', '/srv/app/credentials.json'],
['toml credentials', '/srv/app/credentials.toml'],
['aws credentials', `${HOME}/.aws/credentials`],
['aws config', `${HOME}/.aws/config`],
['aws sso cache', `${HOME}/.aws/sso/cache/abc.json`],
['legacy gcloud credential db', `${HOME}/.gcloud/credentials.db`],
['modern gcloud config tree', `${HOME}/.config/gcloud/application_default_credentials.json`],
['azure profile', `${HOME}/.azure/accessTokens.json`],
['docker registry auth', `${HOME}/.docker/config.json`],
['kubernetes context', `${HOME}/.kube/config`],
['npm token', `${HOME}/.npmrc`],
['yarn token', `${HOME}/.yarnrc.yml`],
['git credential store', `${HOME}/.git-credentials`],
['gh cli token', `${HOME}/.config/gh/hosts.yml`],
['hub token', `${HOME}/.config/hub`],
['netrc', `${HOME}/.netrc`],
['windows netrc', `${HOME}/_netrc`],
['pypi token', `${HOME}/.pypirc`],
['rubygems token', `${HOME}/.gem/credentials`],
['cargo token', `${HOME}/.cargo/credentials.toml`],
['terraform cli config', `${HOME}/.terraformrc`],
['terraform credentials dir', `${HOME}/.terraform.d/credentials.tfrc.json`],
['postgres password file', `${HOME}/.pgpass`],
['mysql client config', `${HOME}/.my.cnf`],
['claude oauth token', `${HOME}/.claude/.credentials.json`],
['codeman hook secret', `${HOME}/.codeman/hook-secret`],
['codeman user table', `${HOME}/.codeman/users.json`],
['codeman hook secret on a named instance', `${HOME}/.codeman-beta/hook-secret`],
];
it.each(blocked)('blocks the %s', (_label, path) => {
expect(isSensitivePath(path)).toBe(true);
});
});
describe('leaves ordinary files attachable', () => {
const allowed: Array<[string, string]> = [
['a source file', '/srv/app/src/index.ts'],
['a dotfile that carries no secret', '/srv/app/.gitignore'],
['a hidden CI directory', '/srv/app/.github/workflows/ci.yml'],
// The publish skill and the review-card loop attach from these trees, so
// only their named secret members are blocked, never the whole tree.
['a codeman screenshot', `${HOME}/.codeman/screenshots/shot.png`],
['a claude transcript', `${HOME}/.claude/projects/proj/session.jsonl`],
['a claude team inbox', `${HOME}/.claude/teams/alpha/inboxes/bob.json`],
// isUnderTree-style separator awareness: a sibling name that merely starts
// with a blocked segment must not be caught.
['an unrelated sshd notes file', '/srv/notes/.sshd-setup.md'],
['a file named credentials-policy.md', '/srv/app/credentials-policy.md'],
];
it.each(allowed)('allows %s', (_label, path) => {
expect(isSensitivePath(path)).toBe(false);
});
});
it('matches on the resolved path, so callers must realpath first', () => {
// The function itself is pure string matching; this pins the contract its
// docblock states, which every caller depends on.
expect(isSensitivePath('/srv/app/looks-innocent')).toBe(false);
expect(isSensitivePath(`${HOME}/.ssh/looks-innocent`)).toBe(true);
});
});
+248
View File
@@ -0,0 +1,248 @@
/**
* Working/idle detection for an interactive Claude pane.
*
* The bug this pins: Claude redraws the composer (`❯`) about once a second all
* the way through a turn, so the old "saw a ❯, wait 2s, call it idle" rule
* flipped a busy session to idle two seconds into every turn. Measured on a live
* worker: `GET /api/sessions` reported `idle` for a session that had been
* running for 17 minutes and was mid-tool-call.
*
* The status-line fixtures below are verbatim captures from live panes
* (`tmux -L codeman capture-pane -p`) on Claude Code 2.1.220.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
import { CLAUDE_WORKING_LINE_PATTERN } from '../src/utils/regex-patterns.js';
import {
trackActivityStreak,
isSustainedActivity,
isPaneQuiet,
ACTIVITY_GAP_MS,
WORKING_STREAK_MS,
IDLE_SILENCE_MS,
} from '../src/session-activity.js';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
};
/** One PTY chunk: what the pane emitted, exactly as the interactive handler sees it. */
function feed(session: Session, data: string): void {
const internals = session as unknown as SessionInternals;
internals._handleTerminalOutput(data);
internals._detectInteractiveActivity(data);
}
/**
* A session whose mux reports a fixed (or scripted) screen, so the pane probe has
* something to read. Only `capturePaneText` is exercised by these paths.
*/
function withFakePane(screen: string | (() => string)): Session {
const read = typeof screen === 'function' ? screen : () => screen;
const mux = {
isAvailable: () => true,
capturePaneText: () => read(),
} as unknown as NonNullable<Parameters<typeof Session.prototype.constructor>[0]>['mux'];
return new Session({
workingDir: '/tmp',
mode: 'claude',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as ConstructorParameters<typeof Session>[0]);
}
/** A composer repaint: the frame Claude ships roughly once a second while working. */
const COMPOSER_REPAINT =
'\x1b[31;1H\x1b[38;5;246m❯\xa0\x1b[39m\x1b[0m\x1b[33;1H \x1b[38;5;246mOpus 5 in:143,699 out:669 ctx:14%\x1b[39m';
describe('CLAUDE_WORKING_LINE_PATTERN', () => {
it('matches the live status line, whatever the glyph and gerund are', () => {
// Captured from three different live panes: the glyph animates through
// `· ✢ ✳ ∗ ✻ ✽` and the gerund is randomized per turn, so neither is matchable.
expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Actualizing… (15m 17s · ↓ 47.5k tokens)')).toBe(true);
expect(CLAUDE_WORKING_LINE_PATTERN.test('* Implementing the backend… (18m 59s · ↓ 69.9k tokens)')).toBe(true);
expect(CLAUDE_WORKING_LINE_PATTERN.test('· Finagling… (4m 45s · ↓ 13.3k tokens)')).toBe(true);
expect(CLAUDE_WORKING_LINE_PATTERN.test('✽ Herding… (3s · esc to interrupt)')).toBe(true);
});
it('does not match the FINISHED line, which carries the same glyph', () => {
// `✻ Cooked for 2m 49s` sits on screen for the whole idle period afterwards.
// Matching the glyph alone would pin such a session at "working" forever.
expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Cooked for 2m 49s')).toBe(false);
expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Brewed for 18m 41s')).toBe(false);
expect(CLAUDE_WORKING_LINE_PATTERN.test('✻ Worked for 2m 46s')).toBe(false);
});
it('ignores ordinary prose and the idle footer', () => {
expect(CLAUDE_WORKING_LINE_PATTERN.test(COMPOSER_REPAINT)).toBe(false);
expect(CLAUDE_WORKING_LINE_PATTERN.test(' ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents')).toBe(
false
);
expect(CLAUDE_WORKING_LINE_PATTERN.test('the build took 45s to finish')).toBe(false);
});
});
describe('activity streak helpers', () => {
it('extends a streak while chunks keep arriving', () => {
let streak = trackActivityStreak(null, 1000);
streak = trackActivityStreak(streak, 2000);
streak = trackActivityStreak(streak, 3000);
expect(streak).toEqual({ startedAt: 1000, lastAt: 3000 });
});
it('restarts the streak after a gap', () => {
const first = trackActivityStreak(null, 1000);
const after = trackActivityStreak(first, 1000 + ACTIVITY_GAP_MS + 1);
expect(after.startedAt).toBe(1000 + ACTIVITY_GAP_MS + 1);
});
it('calls it working only once the streak spans the threshold', () => {
expect(isSustainedActivity(null)).toBe(false);
expect(isSustainedActivity({ startedAt: 0, lastAt: WORKING_STREAK_MS - 1 })).toBe(false);
expect(isSustainedActivity({ startedAt: 0, lastAt: WORKING_STREAK_MS })).toBe(true);
});
it('measures the streak on its own span, so a stale streak cannot age into working', () => {
// A single old chunk stays a single chunk no matter how much later we ask.
const oneChunk = { startedAt: 0, lastAt: 0 };
expect(isSustainedActivity(oneChunk)).toBe(false);
});
it('calls the pane quiet only after the silence window', () => {
expect(isPaneQuiet(1000, 1000 + IDLE_SILENCE_MS - 1)).toBe(false);
expect(isPaneQuiet(1000, 1000 + IDLE_SILENCE_MS)).toBe(true);
});
});
describe('Session interactive idle detection', () => {
afterEach(() => {
vi.useRealTimers();
});
it('stays busy through a long turn of composer repaints', () => {
vi.useFakeTimers();
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const events: string[] = [];
session.on('idle', () => events.push('idle'));
session.on('working', () => events.push('working'));
// 30 seconds of the once-a-second repaint a working pane emits. Every one of
// these carries a ❯; the old rule went idle after the first two seconds.
for (let i = 0; i < 30; i++) {
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(1000);
}
expect(events).toEqual(['working']);
expect(session.status).toBe('busy');
});
it('goes idle once the pane falls silent', () => {
vi.useFakeTimers();
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const events: string[] = [];
session.on('idle', () => events.push('idle'));
for (let i = 0; i < 5; i++) {
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(1000);
}
expect(events).toEqual([]);
// Turn over: nothing more is emitted.
vi.advanceTimersByTime(IDLE_SILENCE_MS + 1000);
expect(events).toEqual(['idle']);
expect(session.status).toBe('idle');
});
it('emits idle once, not once per re-check', () => {
vi.useFakeTimers();
const session = new Session({ workingDir: '/tmp', mode: 'claude' });
const events: string[] = [];
session.on('idle', () => events.push('idle'));
for (let i = 0; i < 4; i++) {
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(60_000);
expect(events).toEqual(['idle']);
});
it('refuses to go idle while the screen still shows the working line', () => {
vi.useFakeTimers();
// A turn can go completely silent inside one tool call (measured at 20+
// seconds on a live worker) while `✻ Elucidating… (39s · ↓ 2.0k tokens)`
// sits on screen the whole time. Silence alone must not end the turn.
const session = withFakePane('✻ Elucidating… (39s · ↓ 2.0k tokens)\n❯ \n');
const events: string[] = [];
session.on('idle', () => events.push('idle'));
for (let i = 0; i < 3; i++) {
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(60_000); // silent for a minute
expect(events).toEqual([]);
expect(session.status).toBe('busy');
});
it('goes idle once the working line leaves the screen', () => {
vi.useFakeTimers();
const pane = { text: '✻ Elucidating… (39s · ↓ 2.0k tokens)\n❯ \n' };
const session = withFakePane(() => pane.text);
const events: string[] = [];
session.on('idle', () => events.push('idle'));
for (let i = 0; i < 3; i++) {
feed(session, COMPOSER_REPAINT);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(20_000);
expect(events).toEqual([]);
// Turn over: the same glyph remains, on the FINISHED line this time.
pane.text = '✻ Cooked for 2m 49s\n❯ \n';
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['idle']);
expect(session.status).toBe('idle');
});
it('does not call typing into the composer "working"', () => {
vi.useFakeTimers();
// Keystroke echo is a steady stream of repaints too, so the streak alone
// would call it work. The screen has no working line, which vetoes it.
const session = withFakePane('❯ some prompt being typed\n');
const events: string[] = [];
session.on('working', () => events.push('working'));
for (let i = 0; i < 10; i++) {
feed(session, '\x1b[31;3Hx');
vi.advanceTimersByTime(300);
}
expect(events).toEqual([]);
expect(session.status).toBe('idle');
});
it('does not mark an external CLI pane working off raw activity', () => {
vi.useFakeTimers();
// Codex/Gemini/OpenCode render their own TUIs and have no ❯, so nothing would
// arm the idle confirmation, so a session marked working here would never recover.
const session = new Session({ workingDir: '/tmp', mode: 'codex' });
const events: string[] = [];
session.on('working', () => events.push('working'));
for (let i = 0; i < 10; i++) {
feed(session, '\x1b[2K▌ Working (12s)');
vi.advanceTimersByTime(1000);
}
expect(events).toEqual([]);
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* Workspace-trust dialog auto-accept.
*
* The bug this pins: `data.includes('trust this folder')` could never match,
* because tmux repaints a row with cursor-forward escapes instead of spaces, so
* the wire carries `I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder`. Every session on a fresh
* directory sat on the dialog until a human pressed Enter.
*
* RAW_DIALOG_CHUNK below is a verbatim slice of the PTY stream from a live
* session parked on that dialog (Claude Code 2.1.220).
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
import { isTrustDialogScreen, compactScreenText, TRUST_DIALOG_MAX_ATTEMPTS } from '../src/session-trust-dialog.js';
/** Verbatim from the wire: note the `\x1b[C` where every space should be. */
const RAW_DIALOG_CHUNK =
'\x1b[C\x1b[38;5;246m1.\x1b[C\x1b[38;5;153mYes,\x1b[CI\x1b[Ctrust\x1b[Cthis\x1b[Cfolder\x1b[15;4H' +
'\x1b[38;5;246m2.\x1b[C\x1b[39mNo,\x1b[Cexit\x1b[17;2H\x1b[38;5;246mEnter\x1b[Cto\x1b[Cconfirm\x1b[C·\x1b[CEsc\x1b[Cto\x1b[Ccancel';
/** What `tmux capture-pane -p` shows for the same moment. */
const RENDERED_DIALOG = [
' Quick safety check: Is this a project you created or one you trust? (Like your own code, a well-known open source',
' project, or work from your team). If not, take a moment to review what is in this folder first.',
'',
' ❯ 1. Yes, I trust this folder',
' 2. No, exit',
'',
' Enter to confirm · Esc to cancel',
].join('\n');
/** An ordinary working session: no dialog anywhere. */
const RENDERED_MAIN_UI = [
'✻ Actualizing… (13m 23s · ↓ 47.5k tokens)',
'────────────────────────────────',
'❯ ',
' ⏵⏵ bypass permissions on (shift+tab to cycle) · ← for agents',
].join('\n');
describe('isTrustDialogScreen', () => {
it('sees the dialog in the raw space-less repaint', () => {
// The whole point: the literal phrase is NOT in this chunk.
expect(RAW_DIALOG_CHUNK.includes('trust this folder')).toBe(false);
expect(isTrustDialogScreen(RAW_DIALOG_CHUNK)).toBe(true);
});
it('sees the dialog in the rendered screen', () => {
expect(isTrustDialogScreen(RENDERED_DIALOG)).toBe(true);
});
it('does not fire on a normal session screen', () => {
expect(isTrustDialogScreen(RENDERED_MAIN_UI)).toBe(false);
expect(isTrustDialogScreen('')).toBe(false);
});
it('does not fire on text that merely quotes the dialog', () => {
// An agent reading or writing about this feature (this file, for one) must
// not cause an Enter press. The confirm affordance is what separates the
// widget from prose about it.
expect(isTrustDialogScreen('the installer asks you to trust this folder before it runs')).toBe(false);
expect(isTrustDialogScreen('press Enter to confirm the release')).toBe(false);
});
it('compacts away both real spaces and the escapes tmux sends instead', () => {
expect(compactScreenText('I\x1b[Ctrust\x1b[Cthis\x1b[Cfolder')).toBe('itrustthisfolder');
expect(compactScreenText('I trust this folder')).toBe('itrustthisfolder');
});
});
describe('Session trust-dialog auto-accept', () => {
afterEach(() => vi.useRealTimers());
/** A session whose pane renders `screen`, recording everything written to it. */
function sessionShowing(screen: () => string) {
const writes: string[] = [];
const mux = {
isAvailable: () => true,
capturePaneText: () => screen(),
sendInput: (_id: string, data: string) => {
writes.push(data);
return Promise.resolve(true);
},
};
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as ConstructorParameters<typeof Session>[0]);
const internals = session as unknown as {
_maybeAcceptTrustDialog(): void;
_interactiveStartedAt: number;
};
internals._interactiveStartedAt = Date.now();
return { session, writes, tick: () => internals._maybeAcceptTrustDialog() };
}
it('presses Enter when the dialog is on screen', () => {
vi.useFakeTimers();
const { writes, tick } = sessionShowing(() => RENDERED_DIALOG);
tick();
expect(writes).toEqual(['\r']);
});
it('retries a dropped keystroke, then gives up rather than typing forever', () => {
vi.useFakeTimers();
// Ink can drop a keystroke while it is still mounting the widget, so one
// press is not always enough; a stuck dialog must not become an Enter loop.
const { writes, tick } = sessionShowing(() => RENDERED_DIALOG);
for (let i = 0; i < 20; i++) {
tick();
vi.advanceTimersByTime(2000);
}
expect(writes.length).toBe(TRUST_DIALOG_MAX_ATTEMPTS);
});
it('stops once the dialog is answered', () => {
vi.useFakeTimers();
let screen = RENDERED_DIALOG;
const { writes, tick } = sessionShowing(() => screen);
tick();
expect(writes).toEqual(['\r']);
screen = RENDERED_MAIN_UI;
for (let i = 0; i < 5; i++) {
vi.advanceTimersByTime(2000);
tick();
}
expect(writes).toEqual(['\r']);
});
it('never answers a dialog-looking screen outside the startup window', () => {
vi.useFakeTimers();
// A live agent can print this text hours in; only a launching pane can be
// showing the real widget.
const { writes, tick } = sessionShowing(() => RENDERED_DIALOG);
vi.advanceTimersByTime(10 * 60_000);
tick();
expect(writes).toEqual([]);
});
it('does not press Enter on a normal screen', () => {
vi.useFakeTimers();
const { writes, tick } = sessionShowing(() => RENDERED_MAIN_UI);
for (let i = 0; i < 5; i++) {
tick();
vi.advanceTimersByTime(2000);
}
expect(writes).toEqual([]);
});
});