mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 06:29:42 +02:00
Merge master into feat/offline-state (keep both offline overlay and approvals drawer)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 }] }] } },
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ==========
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user