chore: merge upstream master and retain tracked scrolling fixes

Resolve Copilot forwarding and registry/documentation overlaps against upstream e63bb65b. Reuse upstream always policy for measured version-independent forwarders and test slow clicks for Claude, Codex and Copilot.
This commit is contained in:
Saqeb Akhter committed 2026-10-18 14:07:36 +04:00
commit dbb41c70cf
228 files changed
+15240 -862

No files matched your search

+24
View File
@@ -163,6 +163,30 @@ describe('preamble single-source (seed + §0 heredoc parity)', () => {
expect(preamble).toBe(heredoc);
});
it("SKILL.md's shell survives Claude Code's skill-argument substitution unchanged", async () => {
// When Claude Code loads a skill WITH arguments (the Skill tool passes the user's request words),
// it rewrites the skill body before the model sees it. Measured on 2.1.300: every `$ARGUMENTS[N]`,
// `$ARGUMENTS` and bare `$N` (digits, then no word character) becomes the matching argument, 0-based,
// and a backslash before `$` is consumed as an escape. A bare `$1` in §0 arrived as
// `sessions/5/terminal` and `timeout=claude` for "start 5 claude workers", so an agent pasting §0
// wrote a broken preamble. `${1}` is the same bash and is never touched; keep it that way.
const skillMd = await readFile(join(packagedDir, 'SKILL.md'), 'utf-8');
const substitute = (body: string, args: string[]): string =>
body
.replace(/(?<!\\)\\\$(?=\d|ARGUMENTS)/g, '$')
.replace(/\$ARGUMENTS\[(\d+)\]/g, (m, i: string) => args[Number(i)] ?? m)
.replace(/\$(\d+)(?!\w)/g, (m, i: string) => args[Number(i)] ?? m)
.replaceAll('$ARGUMENTS', args.join(' '));
const args = 'start 5 claude workers, one per TODO item'.split(/\s+/);
expect(substitute(skillMd, args)).toBe(skillMd);
// And the heredoc an agent pastes from the substituted body is still the seeded preamble.
const rendered = substitute(skillMd, args);
const start = rendered.indexOf("<<'PREAMBLE'\n") + "<<'PREAMBLE'\n".length;
const preamble = await readFile(join(packagedDir, 'preamble.sh'), 'utf-8');
expect(rendered.slice(start, rendered.indexOf('\nPREAMBLE\n', start) + 1)).toBe(preamble);
});
it('seedAgentSessionPreamble writes the stamped preamble to the XDG cache path, 0600', async () => {
const prevXdg = process.env.XDG_CACHE_HOME;
const cacheDir = join(casePath, 'xdg-cache');
+16 -1
View File
@@ -54,14 +54,29 @@ describe('blockedReason', () => {
expect(blockedReason('/usrlocal', c)).toBeNull();
});
it('refuses a cases directory and anything inside it, when given', () => {
it('refuses a cases directory and its direct children, when given', () => {
const withCases = { ...c, casesDirs: ['/home/u/codeman-cases'] };
expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/plain Create New/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/plain Create New/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toContain('/home/u/codeman-cases');
// Two messages: the cases folder is not "in" itself, and a child that does not exist yet is not
// "already" listed.
expect(blockedReason('/home/u/codeman-cases', withCases)).toMatch(/cases folder itself/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).toMatch(/directly in the cases folder/);
expect(blockedReason('/home/u/codeman-cases/foo', withCases)).not.toMatch(/already/);
expect(blockedReason('/home/u/codeman-cases-old/foo', withCases)).toBeNull();
expect(blockedReason('/home/u/codeman-cases/foo', c)).toBeNull();
});
it('allows a folder deeper than a direct child of the cases directory (it is not listed as a local case)', () => {
// CODEMAN_CASES_PATH can be a broad root such as /mnt/user/Scripts; GET /api/cases lists only its
// direct children, so a folder further down can be linked without appearing twice.
const broad = { ...c, casesDirs: ['/mnt/user/Scripts'] };
expect(blockedReason('/mnt/user/Scripts/GitHub', broad)).toMatch(/plain Create New/);
expect(blockedReason('/mnt/user/Scripts/GitHub/opticon454', broad)).toBeNull();
expect(blockedReason('/mnt/user/Scripts/GitHub/opticon454/app', broad)).toBeNull();
});
it('judges against the system roots it is given', () => {
expect(blockedReason('/private/etc/x', c)).toBeNull();
expect(blockedReason('/private/etc/x', c, ['/private/etc'])).toMatch(/system directory/);
+91 -26
View File
@@ -1,45 +1,110 @@
/**
* @fileoverview Claude's `/resume` title belongs to Claude unless the user chose one.
* @fileoverview The CLI's session name and the opt-in `pinCliSessionName` setting.
*
* `--name` sets the prompt-box label, the `/resume` picker entry and the terminal
* title, and a pinned title stops Claude generating its own. Pinning the
* `w1-myapp` placeholder therefore listed every conversation of a case under the
* same name in `/resume`. Only a manual name is pinned now (`cliPinnedName`), and
* a rename reaches the transcript as a `custom-title` row.
* title, and a pinned title stops Claude generating its own. By default only a name
* the user chose is pinned (`cliPinnedName`); with `pinCliSessionName` on, every tab's
* name is pinned, placeholder and auto names included, so Claude's own session list
* matches the tab. The setting is re-read at each spawn and respawn. A rename reaches
* the transcript as a `custom-title` row.
*/
import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { Session } from '../src/session.js';
import { appendClaudeCustomTitle } from '../src/claude-session-title.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
type RespawnOptionsProbe = { _buildRespawnPaneOptions(): { name?: string; cliName?: string } };
type RespawnOptionsProbe = {
_buildRespawnPaneOptions(): { name?: string; cliName?: string };
_buildRespawnPaneOptionsWithResumePin(): Promise<{ name?: string; cliName?: string }>;
};
const probe = (session: Session) => session as unknown as RespawnOptionsProbe;
/** The respawn builder is the path every respawn takes; it re-reads the setting first. */
const cliNameAtRespawn = async (session: Session) =>
(await probe(session)._buildRespawnPaneOptionsWithResumePin()).cliName;
function writeSettings(settings: Record<string, unknown> | null) {
if (settings === null) {
rmSync(SETTINGS_PATH, { force: true });
return;
}
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify(settings));
}
describe('Session.cliPinnedName', () => {
it('pins nothing for a placeholder, so Claude titles the conversation itself', () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(session.cliPinnedName).toBeUndefined();
const options = (session as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions();
// The tab keeps its name; only the CLI flag is withheld.
expect(options.name).toBe('w1-demo');
expect(options.cliName).toBeUndefined();
afterEach(() => writeSettings(null));
describe('pinCliSessionName off (default)', () => {
it.each([null, {}, { pinCliSessionName: false }])(
'leaves a placeholder name unpinned (settings: %j)',
async (settings) => {
writeSettings(settings);
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(await cliNameAtRespawn(session)).toBeUndefined();
expect(session.cliPinnedName).toBeUndefined();
expect(probe(session)._buildRespawnPaneOptions().name).toBe('w1-demo');
}
);
it('leaves an auto name unpinned', async () => {
writeSettings({ pinCliSessionName: false });
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true);
expect(await cliNameAtRespawn(session)).toBeUndefined();
});
it('still pins a name the user chose, at creation or by a rename', async () => {
writeSettings({ pinCliSessionName: false });
expect(await cliNameAtRespawn(new Session({ workingDir: '/tmp', name: 'msgtest-worker' }))).toBe(
'msgtest-worker'
);
const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' });
renamed.name = '登录修复';
expect(await cliNameAtRespawn(renamed)).toBe('登录修复');
});
});
it('pins nothing for an auto name, whose cut of the prompt Claude beats', () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true);
expect(session.cliPinnedName).toBeUndefined();
describe('pinCliSessionName on', () => {
it('pins the placeholder name, so the CLI session matches the tab', async () => {
writeSettings({ pinCliSessionName: true });
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(await cliNameAtRespawn(session)).toBe('w1-demo');
expect(session.cliPinnedName).toBe('w1-demo');
});
it('pins an auto name too, from the next spawn', async () => {
writeSettings({ pinCliSessionName: true });
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
expect(session.applyAutoName('w1-demo: fix the login redirect')).toBe(true);
expect(await cliNameAtRespawn(session)).toBe('w1-demo: fix the login redirect');
});
it('pins a name the user chose', async () => {
writeSettings({ pinCliSessionName: true });
const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' });
renamed.name = '登录修复';
expect(await cliNameAtRespawn(renamed)).toBe('登录修复');
});
it('never pins a blank name', async () => {
writeSettings({ pinCliSessionName: true });
expect(await cliNameAtRespawn(new Session({ workingDir: '/tmp', name: ' ' }))).toBeUndefined();
});
});
it('pins a name the user chose, at creation or by a rename', () => {
expect(new Session({ workingDir: '/tmp', name: 'msgtest-worker' }).cliPinnedName).toBe('msgtest-worker');
const renamed = new Session({ workingDir: '/tmp', name: 'w1-demo' });
renamed.name = '登录修复';
expect(renamed.cliPinnedName).toBe('登录修复');
expect((renamed as unknown as RespawnOptionsProbe)._buildRespawnPaneOptions().cliName).toBe('登录修复');
it('follows the setting on the next respawn of the same session, with no restart', async () => {
const session = new Session({ workingDir: '/tmp', name: 'w1-demo' });
writeSettings({ pinCliSessionName: true });
expect(await cliNameAtRespawn(session)).toBe('w1-demo');
writeSettings({ pinCliSessionName: false });
expect(await cliNameAtRespawn(session)).toBeUndefined();
});
});
+33 -1
View File
@@ -102,7 +102,7 @@ describe('isMuxAltScreenOnlyStripMode', () => {
expect(isMuxAltScreenOnlyStripMode(mode, false)).toBe(false);
}
// The full strip and the mouse strip already own their modes; never double-gate.
for (const mode of ['claude', 'codex', 'gemini', 'opencode'] as const) {
for (const mode of ['claude', 'codex', 'gemini', 'opencode', 'copilot'] as const) {
expect(isMuxAltScreenOnlyStripMode(mode, true)).toBe(false);
}
});
@@ -366,3 +366,35 @@ describe('strip decision table: live stream = replay, for every stock CLI', () =
}
}
});
/**
* GitHub Copilot CLI is the same shape as opencode: a full-screen TUI that turns mouse tracking
* on itself. Left in, xterm reports a plain drag to the TUI instead of selecting, so Auto Copy and
* copy-on-select did nothing (only Shift+drag selected). Same strip, same reason.
*/
describe('copilot: alt-screen + mouse DECSETs stripped, 3J kept', () => {
it('is a mouse-strip mode under tmux, and only there', () => {
expect(isMuxMouseStripMode('copilot', true)).toBe(true);
expect(isMuxMouseStripMode('copilot', false)).toBe(false);
});
it('drops mouse tracking so xterm keeps local text selection, and keeps 3J', () => {
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: true });
handleOutput(session, '\x1b[?1003h\x1b[?1006h\x1b[3JTUI\x1b[?1006l\x1b[?1003l');
expect(session.terminalBuffer).toBe('\x1b[3JTUI');
});
it('publishes cliMouseTracking so the browser can forward the wheel and hand-encode clicks', () => {
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: true });
handleOutput(session, '\x1b[?1002h\x1b[?1006hTUI');
expect(session.toState().cliMouseTracking).toBe(true);
handleOutput(session, '\x1b[?1002l\x1b[?1006l');
expect(session.toState().cliMouseTracking).toBeFalsy();
});
it('leaves a direct-PTY copilot pane untouched', () => {
const session = new Session({ workingDir: '/tmp', mode: 'copilot' as never, useMux: false });
handleOutput(session, '\x1b[?1049h\x1b[?1002hTUI');
expect(session.terminalBuffer).toBe('\x1b[?1049h\x1b[?1002hTUI');
});
});
+2
View File
@@ -506,6 +506,8 @@ describe('agent spawn', () => {
it('takes the readiness mark from the CLI registry, not from a mode list', () => {
expect(composerReadyMark('claude')).toBe('shift+tab');
expect(composerReadyMark('deepseek')).toBe('❯');
// Copilot's trust dialog draws `❯ 1. Yes`, so its mark is the composer's footer hint instead.
expect(composerReadyMark('copilot')).toBe('/ commands · ? help');
expect(composerReadyMark('pi')).toBeUndefined();
expect(composerReadyMark('no-such-cli')).toBeUndefined();
});
+2 -1
View File
@@ -36,6 +36,7 @@ describe('per-mode capability predicates', () => {
['grok', true, false, false],
['deepseek', true, false, true],
['omp', true, false, false],
['copilot', true, false, false],
] as Array<[SessionMode, boolean, boolean, boolean]>)(
'%s: external=%s altScreenStrip=%s hooks=%s',
(mode, external, altScreen, hooks) => {
@@ -48,7 +49,7 @@ describe('per-mode capability predicates', () => {
it('covers every enabled mode (sanity)', () => {
// If a CLI is added without a row above, this fails rather than the table silently
// describing a subset of reality.
expect(MODES.length).toBe(10);
expect(MODES.length).toBe(11);
});
it('keeps the three predicates genuinely distinct', () => {
@@ -95,6 +95,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"web/routes/session-routes.ts::mode === 'antigravity'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'pi'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'grok'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'copilot'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'deepseek'": allow(2, 'legacy <Mode>Config plumbing'),
"web/server.ts::mode === 'opencode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'codex'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
@@ -102,6 +103,7 @@ const ALLOWED_BRANCHES: Record<string, Allowance> = {
"web/server.ts::mode === 'antigravity'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'pi'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'grok'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'copilot'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'deepseek'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'omp'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
+73 -3
View File
@@ -183,6 +183,63 @@ describe('workDetect.workingLine is guarded like every other config regex', () =
expect(compileVersionRegex(src), `${entry.id} declares an awaitingLine the guard refuses`).not.toBeNull();
}
});
it('holds the optional blockedLine to the same guard', () => {
// Run over the foot of a capture at every settle and on a timer while a dialog is
// up, and a clis.json override can set it.
for (const blockedLine of ['(a+)+b', 'a'.repeat(201), '([unclosed', '']) {
expectRejected(
(e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine,
};
},
`blockedLine ${JSON.stringify(blockedLine.slice(0, 20))} must be refused at LOAD time`
);
}
});
it('bounds the blockedLine window and refuses one with no pattern', () => {
for (const blockedLines of [0, 25, 2.5]) {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine: 'Press enter to confirm$',
blockedLines,
};
}, 'a config file must not be able to widen the search to the whole pane');
}
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLines: 4,
};
}, 'a window with nothing to search is a typo whose failure is otherwise silent');
});
it('accepts a blockedLine with its window, and every shipped one', () => {
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).workDetect = {
promptGlyph: '>',
workingLine: 'working',
blockedLine: 'Press enter to confirm$',
blockedLines: 24,
};
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
let shipped = 0;
for (const stock of STOCK_CLIS) {
const src = stock.capabilities.workDetect?.blockedLine;
if (!src) continue;
shipped++;
expect(compileVersionRegex(src), `${stock.id} declares a blockedLine the guard refuses`).not.toBeNull();
expect(CliEntrySchema.safeParse(JSON.parse(JSON.stringify(stock))).success).toBe(true);
}
expect(shipped).toBeGreaterThan(0);
});
});
describe('no shell text can reach the command line', () => {
@@ -428,18 +485,31 @@ describe('capability shapes', () => {
}, 'transcriptPageKeys is a boolean');
});
it('declares which CLIs get the wheel forwarded, and whether a version gates it', () => {
// Claude forwards from 2.1.187 (fullscreen only); tracked Codex and Copilot
// forward without a version gate. Unmeasured entries stay 'never'.
const mode = (id: string) => STOCK_CLIS.find((e) => (e.id as string) === id)?.capabilities.wheelForward;
expect(mode('copilot')).toEqual({ mode: 'always' });
expect(mode('claude')).toEqual({ mode: 'version-gated', minVersion: '2.1.187' });
const forwarding = STOCK_CLIS.filter((e) => e.capabilities.wheelForward.mode !== 'never').map(
(e) => e.id as string
);
expect(forwarding.sort()).toEqual(['claude', 'codex', 'copilot']);
expect(CliEntrySchema.safeParse(baseEntry('copilot')).success).toBe(true);
});
it('declares transcriptPageKeys for exactly the CLIs measured to page on PageUp/PageDown', () => {
// claude (repaint mode), codex and opencode (both on the alternate screen) were
// claude (repaint mode), codex, opencode and copilot (all on the alternate screen) were
// measured paging their own transcript on PageUp/PageDown; no other stock entry has been.
const paging = STOCK_CLIS.filter((e) => e.capabilities.transcriptPageKeys === true).map((e) => e.id as string);
expect(paging.sort()).toEqual(['claude', 'codex', 'opencode']);
expect(paging.sort()).toEqual(['claude', 'codex', 'copilot', 'opencode']);
});
});
describe('observed mouse-tracking wheel policy', () => {
it('advertises and validates tracked Codex forwarding without a fictitious minimum version', () => {
const codex = STOCK_CLIS.find((e) => e.id === 'codex')!;
expect(codex.capabilities.wheelForward).toEqual({ mode: 'mouse-tracking' });
expect(codex.capabilities.wheelForward).toEqual({ mode: 'always' });
expect(CliEntrySchema.safeParse(codex).success).toBe(true);
expect(codex.capabilities.transcriptPageKeys).toBe(true);
});
+69
View File
@@ -320,3 +320,72 @@ describe('unsafe values are DROPPED, never escaped into the command', () => {
expect(cmd).not.toContain('..');
});
});
describe('copilot', () => {
const cp = (copilotConfig?: SpawnBridgeOptions['copilotConfig']) =>
render({ mode: 'copilot' as never, sessionId: SID, copilotConfig });
it("spawns bare by default, in Copilot's own Manual Approval mode", () => {
expect(cp()).toBe('copilot');
});
it('emits --yolo only when asked, with the model', () => {
expect(cp({ allowAll: true, model: 'claude-sonnet-5.5' })).toBe('copilot --yolo --model claude-sonnet-5.5');
expect(cp({ allowAll: false })).toBe('copilot');
});
it("pins the tab's name as --name", () => {
expect(render({ mode: 'copilot' as never, sessionId: SID, sessionName: 'w4-Codeman' } as never)).toBe(
'copilot --name "w4-Codeman"'
);
});
it('drops --name next to --resume and --continue, which copilot refuses to combine', () => {
const named = (copilotConfig: SpawnBridgeOptions['copilotConfig']) =>
render({ mode: 'copilot' as never, sessionId: SID, sessionName: 'w4-Codeman', copilotConfig } as never);
expect(named({ resumeSessionId: '0c02c30f' })).toBe('copilot --resume 0c02c30f');
expect(named({ continueSession: true })).toBe('copilot --continue');
expect(named({ allowAll: true, model: 'auto' })).toBe('copilot --yolo --name "w4-Codeman" --model auto');
});
it('prefers an explicit resume id over --continue', () => {
expect(cp({ resumeSessionId: '0c02c30f-3383-4139-8b52-b28abad2ad43' })).toBe(
'copilot --resume 0c02c30f-3383-4139-8b52-b28abad2ad43'
);
expect(cp({ continueSession: true })).toBe('copilot --continue');
expect(cp({ continueSession: true, resumeSessionId: '0c02c30f' })).toBe('copilot --resume 0c02c30f');
});
it('drops unsafe values rather than escaping them', () => {
expect(cp({ model: 'a`b' })).toBe('copilot');
expect(cp({ resumeSessionId: 'my session; rm -rf /' })).toBe('copilot');
});
// `--resume`'s value is optional, so `--resume --yolo` would make `--yolo` its own flag and get
// around the allowAll clamp. A resume value must start with an alphanumeric.
it('drops a resume value that would be read as a flag', () => {
for (const resumeSessionId of ['--yolo', '-n', '--allow-all-tools', '.', '..', '.hidden']) {
expect(cp({ resumeSessionId })).toBe('copilot');
}
expect(cp({ allowAll: false, resumeSessionId: '--yolo' })).toBe('copilot');
// A plain word is still passed through (a session NAME also resumes).
expect(cp({ resumeSessionId: 'my-feature' })).toBe('copilot --resume my-feature');
});
// `--model --yolo` would make `--yolo` its own flag and get around the allowAll clamp, so a model id
// starts with an alphanumeric too. A provider/id value (the `/`) is still accepted.
it('drops a model value that would be read as a flag', () => {
for (const model of ['--yolo', '-n', '--allow-all-tools', '.', '/etc/passwd']) {
expect(cp({ model })).toBe('copilot');
expect(cp({ allowAll: false, model })).toBe('copilot');
}
expect(cp({ model: 'claude-sonnet-5.5' })).toBe('copilot --model claude-sonnet-5.5');
expect(cp({ model: 'openai/gpt-5' })).toBe('copilot --model openai/gpt-5');
});
it('never puts a credential on the command line', () => {
const cmd = cp({ allowAll: true, model: 'auto' }) ?? '';
expect(cmd).not.toContain('token');
expect(cmd).not.toContain('key');
});
});
+421
View File
@@ -0,0 +1,421 @@
/**
* A Codex session waiting on one of its own dialogs is BLOCKED, read off the screen.
*
* Codex is the one CLI Codeman launches with its approvals on by default, and it has no
* hook to say it stopped for one (`hooks: 'none'`), so a session parked on "Would you like
* to run the following command?" used to read as plainly idle, and one parked on a
* question (whose key hints carry `esc to interrupt`, codex's working line) as WORKING.
* The registry's `capabilities.workDetect.blockedLine` now names the dialog's chrome, the
* session reads it at every settle and on a timer while it is up, and the server raises
* exactly what a Claude `permission_prompt` hook raises, alert-only.
*
* The fixtures in `test/fixtures/codex-dialogs/` are live `tmux capture-pane -p` frames
* (no -J, what `capturePaneText()` reads) of codex-cli 0.162.0 on 2026-10-14, run with
* `-a on-request -s read-only` at 200, 80, 60, 44 and 36 columns; only trailing
* whitespace was trimmed and the scratch path replaced by `/tmp/codex-scratch/repo`.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { Session } from '../src/session.js';
import { getCli } from '../src/config/cli-registry/index.js';
import { compileVersionRegex } from '../src/config/cli-registry/patterns.js';
import {
isBlockedScreen,
BLOCKED_TAIL_LINES,
BLOCKED_MAX_TAIL_LINES,
BLOCKED_RECHECK_MS,
BLOCKED_CLEAR_READS,
BLOCKED_QUIET_RECHECK_MS,
IDLE_SILENCE_MS,
PANE_PROBE_MIN_INTERVAL_MS,
} from '../src/session-activity.js';
import type { SessionMode } from '../src/types.js';
const DETECT = getCli('codex')!.capabilities.workDetect!;
const CODEX_BLOCKED = compileVersionRegex(DETECT.blockedLine!)!;
const CODEX_WINDOW = DETECT.blockedLines!;
const CODEX_WORKING = compileVersionRegex(DETECT.workingLine)!;
const FIXTURES = new URL('./fixtures/codex-dialogs/', import.meta.url);
const fixture = (name: string): string => readFileSync(new URL(name, FIXTURES), 'utf8');
const blocked = (screen: string): boolean => isBlockedScreen(screen, CODEX_BLOCKED, CODEX_WINDOW);
/** Every dialog codex was measured blocking on. */
const DIALOGS = [
'exec-approval.txt',
'exec-approval-w36.txt', // the key hint wraps: `Press enter to confirm or esc to` / `cancel`
'exec-approval-w44.txt',
'exec-approval-long-command.txt', // a 14-row heredoc preview between the header and the options
'exec-approval-60x14.txt', // a 14-row pane: codex folds the preview to `[… 21 lines]`
'edit-approval.txt',
'network-escalation-approval.txt',
'question.txt',
'question-option2-selected.txt',
'question-1of2.txt', // `| ←/→ to navigate questions |` in the hint
'question-2of2-submit-all.txt', // `enter to submit all` on the last question
'question-w36.txt', // the hint one segment per row, the `›` row 16 rows up
'question-w60.txt',
'question-notes.txt', // the notes field open: `tab or esc to clear notes | enter to submit answer`
'question-notes-typed.txt',
'trust-dialog.txt', // drawn at the TOP of an otherwise blank screen at startup
'trust-dialog-w40.txt',
// The same prompt, reached through the update prompt (`2. Skip`) on 0.162.0 with 0.163.0
// out: `2. Back to Agent Command Center` over `enter continue · esc back`. Captured
// 2026-10-16 from Codeman's own launch flags in a fresh git repo, at 120 columns.
'trust-dialog-back.txt',
];
/** Screens with no dialog codex blocks on, several of them quoting the dialogs' words. */
const NOT_DIALOGS = [
'idle-start.txt',
'composer-typed.txt',
'working.txt',
'after-approve.txt',
'after-deny.txt',
'after-question.txt', // the transcript keeps `• Questions 1/1 answered` and the question
'slash-popup.txt', // `› /plan switch to Plan mode`: a `›` row with nothing after it
'transcript-quotes-dialog.txt', // an answer that printed the dialog's lines verbatim
'composer-typed-dialog-text.txt', // the user TYPED them into the composer
'composer-typed-dialog-text-w60.txt', // ... and at 60 columns, where the text wraps
'model-popup.txt', // menus the user opened at the keyboard: `enter select · esc back`
'permissions-popup.txt', // (with the quoted dialog from the earlier answer still above it)
'review-popup.txt',
'implement-plan.txt', // deliberately not blocked: a plan turn that ended, same footer as /model
];
describe('codex blockedLine on live captures', () => {
it('is a pattern the registry guard accepts, with a window inside the cap', () => {
expect(DETECT.blockedLine!.length).toBeLessThanOrEqual(200);
expect(CODEX_BLOCKED).not.toBeNull();
expect(CODEX_WINDOW).toBeGreaterThan(BLOCKED_TAIL_LINES);
expect(CODEX_WINDOW).toBeLessThanOrEqual(BLOCKED_MAX_TAIL_LINES);
});
it.each(DIALOGS)('reads %s as blocked', (name) => {
expect(blocked(fixture(name))).toBe(true);
});
it.each(NOT_DIALOGS)('does not read %s as blocked', (name) => {
expect(blocked(fixture(name))).toBe(false);
});
it('needs the whole window for the narrowest question, and no more than it declares', () => {
// At 36 columns the selected option sits 16 non-blank rows above the end; a window
// that stops short of it reads nothing, which is the pre-field behaviour, never a
// false alert.
const narrow = fixture('question-w36.txt');
expect(isBlockedScreen(narrow, CODEX_BLOCKED, 16)).toBe(true);
expect(isBlockedScreen(narrow, CODEX_BLOCKED, 15)).toBe(false);
});
it('sees the question dialogs that the working line used to claim', () => {
// Why a blocked screen has to WIN over the working line in the probe: the question's
// own key hints end in `esc to interrupt`.
for (const name of ['question.txt', 'question-w36.txt', 'question-1of2.txt']) {
expect(CODEX_WORKING.test(fixture(name)), name).toBe(true);
expect(blocked(fixture(name)), name).toBe(true);
}
});
});
describe('codex blockedLine against a screen the agent writes', () => {
// The foot of codex's screen while no dialog is up: composer, footer (whose model and
// path vary), hint row.
const composer = (typed = 'Ask Codex to do anything', footer = ' GPT-6-Luna default · ~/cases/x'): string[] => [
`› ${typed}`,
'',
footer,
' ? for shortcuts',
];
const forgedDialog = [
'• Here is what the dialog looks like:',
'› 1. Yes, proceed (y)',
' 2. No, and tell Codex what to do differently (esc)',
'',
' Press enter to confirm or esc to cancel',
];
it('refuses a forged dialog in the transcript, whatever follows it', () => {
expect(blocked([...forgedDialog, '', ...composer()].join('\n'))).toBe(false);
// While a prompt is typed the hint row goes away, so the forged hint sits only two
// rows above the end: the composer row (column 0, `›`) still breaks the run.
expect(blocked([...forgedDialog, ...composer('next thing').slice(0, 3)].join('\n'))).toBe(false);
// Even with a lowercase model and a short path, the footer row would have to end in
// one of the hint's own closing words.
expect(blocked([...forgedDialog, ...composer('x', ' gpt-5.6 default · ~/x').slice(0, 3)].join('\n'))).toBe(false);
});
it('refuses the working view with the dialog quoted right above the status line', () => {
const screen = [...forgedDialog, '', '• Working (3s • esc to interrupt)', '', ...composer()].join('\n');
expect(blocked(screen)).toBe(false);
});
it('refuses a hint row without the option row above it, and an option row without the hint', () => {
expect(
blocked(
[' Would you like to run the following command?', ' Press enter to confirm or esc to cancel'].join('\n')
)
).toBe(false);
expect(blocked(['› 1. Yes, proceed (y)', ' 2. No (esc)'].join('\n'))).toBe(false);
});
it('stays linear on a hostile window', () => {
// Many option rows and long rows, ending in something that is almost the hint: the
// pattern must give up quickly, since it runs on a timer while a dialog is up.
const row = `› 1. ${'x'.repeat(300)}`;
const screen = [
...Array(BLOCKED_MAX_TAIL_LINES - 1).fill(row),
` Press enter to confirm ${'or '.repeat(60)}`,
].join('\n');
const started = performance.now();
for (let i = 0; i < 50; i++) blocked(screen);
expect(performance.now() - started).toBeLessThan(250);
expect(blocked(screen)).toBe(false);
});
it('caps the window at BLOCKED_MAX_TAIL_LINES whatever the caller asks', () => {
const dialog = fixture('exec-approval.txt');
const padded = `${dialog}\n${Array(BLOCKED_MAX_TAIL_LINES).fill(' filler').join('\n')}`;
expect(isBlockedScreen(padded, CODEX_BLOCKED, 10_000)).toBe(false);
});
it('answers false for an empty or unreadable capture', () => {
expect(isBlockedScreen('', CODEX_BLOCKED, CODEX_WINDOW)).toBe(false);
expect(isBlockedScreen(null, CODEX_BLOCKED, CODEX_WINDOW)).toBe(false);
});
});
// ─── The session's blocked state ────────────────────────────────────────────
/** Codex's own composer repaint: carries the glyph that arms the idle confirmation. */
const CODEX_REPAINT = '\x1b[31;1H\x1b[38;5;246m›\xa0\x1b[39m\x1b[0m';
type SessionInternals = {
_handleTerminalOutput(data: string): void;
_detectInteractiveActivity(data: string): void;
};
function feed(session: Session, data: string): void {
const internals = session as unknown as SessionInternals;
internals._handleTerminalOutput(data);
internals._detectInteractiveActivity(data);
}
/**
* What the pane shows, and how often the session has read it: every read is a synchronous
* `tmux capture-pane` on the live server.
*/
type FakeScreen = { text: string | null; captures?: number };
/** A session whose mux answers captures from `screen.text` (null = unreadable). */
function withFakePane(screen: FakeScreen, mode: SessionMode = 'codex'): Session {
const mux = {
isAvailable: () => true,
capturePaneText: () => {
screen.captures = (screen.captures ?? 0) + 1;
return screen.text;
},
} as unknown as NonNullable<ConstructorParameters<typeof Session>[0]>['mux'];
return new Session({
workingDir: '/tmp',
mode,
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as ConstructorParameters<typeof Session>[0]);
}
/**
* The pane changes the only way a real one can: tmux redraws it to the attached client,
* which reaches the session as output. A cursor move and an erase, with no composer glyph
* in it, so it marks activity without arming the idle confirmation.
*/
function redraw(session: Session, screen: FakeScreen, text: string | null): void {
screen.text = text;
feed(session, '\x1b[2;1H\x1b[K');
}
/** Output, then quiet: the moment the idle confirmation reads the screen. */
function settle(session: Session): void {
for (let i = 0; i < 3; i++) {
feed(session, CODEX_REPAINT);
vi.advanceTimersByTime(1000);
}
vi.advanceTimersByTime(IDLE_SILENCE_MS + 2000);
}
function recordChanges(session: Session): Array<{ blocked: boolean; frame: string | null }> {
const changes: Array<{ blocked: boolean; frame: string | null }> = [];
session.on('blockedChanged', (b: boolean, frame: string | null) => changes.push({ blocked: b, frame }));
return changes;
}
describe('Session.screenBlocked', () => {
afterEach(() => {
vi.useRealTimers();
});
it('raises once when the pane settles on an approval, and reads the session idle', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked).toBe(true);
expect(session.status).toBe('idle');
expect(changes).toHaveLength(1);
expect(changes[0].blocked).toBe(true);
// The frame it was read from rides along, as the approval card's context.
expect(changes[0].frame).toContain('Would you like to run the following command?');
// The dialog staying up is one dialog: no second raise from the timer or a repaint.
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 5);
settle(session);
expect(changes).toHaveLength(1);
});
it('calls a question dialog blocked and idle, not working', () => {
// Its key hints carry `esc to interrupt`, which is codex's working line: before the
// field the probe called this pane busy for as long as the question stayed up.
vi.useFakeTimers();
const session = withFakePane({ text: fixture('question.txt') });
settle(session);
expect(session.screenBlocked).toBe(true);
expect(session.status).toBe('idle');
});
it('clears after the dialog has been gone for BLOCKED_CLEAR_READS reads, without waiting for the turn', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') as string | null };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked).toBe(true);
// Answered: codex resumes the turn, and nothing settles while it runs. The timer is
// what notices, and one read alone is not enough.
redraw(session, screen, fixture('working.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
expect(session.screenBlocked).toBe(true);
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * (BLOCKED_CLEAR_READS - 1));
expect(session.screenBlocked).toBe(false);
expect(changes.map((c) => c.blocked)).toEqual([true, false]);
// Nothing keeps reading a screen with no dialog on it.
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 10);
expect(changes).toHaveLength(2);
});
it('does not flap on one read that misses a dialog still on screen', () => {
// A capture inside a full-frame repaint can come back without the footer. Clearing on
// it would raise again a moment later: a second alert, push and webhook for one dialog.
vi.useFakeTimers();
const dialog = fixture('exec-approval.txt');
const screen = { text: dialog as string | null };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
redraw(session, screen, fixture('working.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
redraw(session, screen, dialog);
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 3);
expect(session.screenBlocked).toBe(true);
expect(changes).toHaveLength(1);
});
it('keeps its state through an unreadable capture, in either direction', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') as string | null };
const session = withFakePane(screen);
settle(session);
// The pane moved, so the session looks, and tmux answers nothing it can read.
redraw(session, screen, null);
const before = screen.captures!;
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * 5);
expect(screen.captures! - before).toBeGreaterThanOrEqual(5);
expect(session.screenBlocked).toBe(true);
// ...and it is still watching: once the screen reads again without the dialog, it clears.
redraw(session, screen, fixture('after-approve.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
expect(session.screenBlocked).toBe(false);
});
it('does not re-read a quiet pane every interval while a dialog sits unanswered', () => {
// Each read is a synchronous `tmux capture-pane` (about 8 ms of blocked event loop,
// measured 2026-10-14), and a dialog can sit unanswered overnight. It leaves the
// screen only through a repaint, which reaches the session as output, so a pane that
// drew nothing since the last reading that saw the dialog is still showing it.
vi.useFakeTimers();
const screen: FakeScreen = { text: fixture('exec-approval.txt') };
const session = withFakePane(screen);
settle(session);
expect(session.screenBlocked).toBe(true);
const quietMs = 10 * 60_000;
const before = screen.captures!;
vi.advanceTimersByTime(quietMs);
// The safety-net read only, never one per BLOCKED_RECHECK_MS (that would be 300 here).
expect(screen.captures! - before).toBeLessThanOrEqual(quietMs / BLOCKED_QUIET_RECHECK_MS);
expect(session.screenBlocked).toBe(true);
// The answer repaints the pane, and the clear comes as fast as before.
redraw(session, screen, fixture('after-approve.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
expect(session.screenBlocked).toBe(false);
});
it('takes the confirming read even when no output follows the reading that missed the dialog', () => {
// tmux updates the pane before it sends this client the repaint, so a capture can see
// the dialog gone while the session has been sent nothing yet. Skipping a quiet pane
// is right only while the last reading SAW the dialog: once one reading missed it,
// waiting for output could leave the alert up for the whole safety-net interval.
vi.useFakeTimers();
const screen: FakeScreen = { text: fixture('exec-approval.txt') };
const session = withFakePane(screen);
settle(session);
vi.advanceTimersByTime(PANE_PROBE_MIN_INTERVAL_MS);
screen.text = fixture('after-approve.txt'); // and not one byte of output, from here on
(session as unknown as { _probePaneWorking(): boolean | null })._probePaneWorking();
expect(session.screenBlocked).toBe(true);
vi.advanceTimersByTime(BLOCKED_RECHECK_MS);
expect(session.screenBlocked).toBe(false);
});
it('raises again for the next dialog after a clear', () => {
vi.useFakeTimers();
const screen = { text: fixture('exec-approval.txt') as string | null };
const session = withFakePane(screen);
const changes = recordChanges(session);
settle(session);
redraw(session, screen, fixture('after-approve.txt'));
vi.advanceTimersByTime(BLOCKED_RECHECK_MS * BLOCKED_CLEAR_READS);
screen.text = fixture('edit-approval.txt');
settle(session);
expect(changes.map((c) => c.blocked)).toEqual([true, false, true]);
});
it('never reads a dialog off the screen of a CLI that declares no blockedLine', () => {
// Claude's dialogs arrive as hooks; gemini declares nothing. Same screen, no state.
vi.useFakeTimers();
for (const mode of ['claude', 'gemini'] as const) {
const session = withFakePane({ text: fixture('exec-approval.txt') }, mode);
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked, mode).toBe(false);
expect(changes, mode).toHaveLength(0);
}
});
it('drops the state when a new pane starts, announcing it', () => {
vi.useFakeTimers();
const session = withFakePane({ text: fixture('trust-dialog.txt') });
const changes = recordChanges(session);
settle(session);
expect(session.screenBlocked).toBe(true);
(session as unknown as { _resetBuffers(): void })._resetBuffers();
expect(session.screenBlocked).toBe(false);
expect(changes.map((c) => c.blocked)).toEqual([true, false]);
});
});
+66 -2
View File
@@ -158,10 +158,73 @@ describe('connection indicator — keystroke backlog threshold', () => {
expect(els.connectionText.textContent).toBe('WS');
});
it('annotates a genuine backlog (>4B) with a queued byte count', () => {
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
it('annotates a genuine backlog (>4B) with a queued byte count once it has persisted', () => {
const { app, els } = makeApp({
activeSessionId: 's1',
_wsState: 'connected',
queuedBytes: 40,
// The backlog has been above the threshold for well over the hold.
_backlogOverSince: Date.now() - 5000,
} as never);
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toMatch(/^WS · 40B queued$/);
app._pendingDeliveries.clear();
app._updateConnectionIndicator(); // drains: clears the hold timer
});
});
// A paste, an IME commit or a fast word is a few dozen bytes for a few milliseconds until its ACK
// lands. Showing " · NB queued" for that made the "WS" pill widen and shrink on every burst.
describe('connection indicator — a brief burst does not widen the pill', () => {
it('keeps the plain "WS" while a >4B burst is still inside the hold window', () => {
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toBe('WS');
app._pendingDeliveries.clear();
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toBe('WS');
});
it('forgets the burst when it drains, so a later one starts a fresh hold', () => {
const { app } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
app._updateConnectionIndicator();
expect((app as unknown as { _backlogOverSince: number | null })._backlogOverSince).not.toBeNull();
app._pendingDeliveries.clear();
app._updateConnectionIndicator();
expect((app as unknown as { _backlogOverSince: number | null })._backlogOverSince).toBeNull();
expect((app as unknown as { _backlogHoldTimer: unknown })._backlogHoldTimer).toBeNull();
});
it('schedules the re-render for when the hold elapses, with no keystroke or ACK needed', () => {
const { app } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
app._updateConnectionIndicator();
expect((app as unknown as { _backlogHoldTimer: unknown })._backlogHoldTimer).not.toBeNull();
app._pendingDeliveries.clear();
app._updateConnectionIndicator(); // cancels it
});
it('shows the count by itself once the hold elapses, and the timer is spent', async () => {
// A real wait, on purpose: the vm context runs on its own Date and the real setTimeout, so this
// exercises the shipped timer callback rather than a manual re-render.
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toBe('WS');
await new Promise((r) => setTimeout(r, 1400));
expect(els.connectionText.textContent).toBe('WS · 40B queued');
expect((app as unknown as { _backlogHoldTimer: unknown })._backlogHoldTimer).toBeNull();
app._pendingDeliveries.clear();
app._updateConnectionIndicator();
});
it('shows the count once the same backlog has stayed up past the hold', () => {
const { app, els } = makeApp({ activeSessionId: 's1', _wsState: 'connected', queuedBytes: 40 });
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toBe('WS');
(app as unknown as { _backlogOverSince: number })._backlogOverSince = Date.now() - 1500;
app._updateConnectionIndicator();
expect(els.connectionText.textContent).toBe('WS · 40B queued');
app._pendingDeliveries.clear();
app._updateConnectionIndicator();
});
});
@@ -458,3 +521,4 @@ describe('_updateConnectionIndicator — COD-136 unchanged-skip', () => {
expect(els.connectionText.textContent).toBe('WS');
});
});
+132
View File
@@ -0,0 +1,132 @@
/**
* @fileoverview Tests for the GitHub Copilot CLI resolver wrapper.
*
* Copilot is a resolver with a version probe: `copilot` is a generic binary name, so a
* resolved path is only accepted once `copilot --version` prints a
* `GitHub Copilot CLI <semver>` string (e.g. `GitHub Copilot CLI 1.0.94.`). The probe EXECUTES the candidate, which is
* exactly why it must never run under vitest — the hermeticity test below pins
* that gate with a real executable fixture that would make the test fail
* loudly if the gate were deleted again.
*/
import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { createCopilotResolverForTest } from '../src/utils/copilot-cli-resolver.js';
import {
cliResolveRetryDelayMs,
createProductionCliResolverHost,
type CliResolverHost,
} from '../src/utils/cli-executable-resolver.js';
const temporaryDirectories: string[] = [];
afterEach(() => {
for (const directory of temporaryDirectories.splice(0)) {
rmSync(directory, { recursive: true, force: true });
}
});
function createHost(
options: {
processPathResult?: string | null;
loginShellResults?: Array<string | null>;
existingPaths?: string[];
} = {}
): CliResolverHost {
const loginShellResults = [...(options.loginShellResults ?? [])];
const existingPaths = new Set(options.existingPaths ?? []);
return {
processPath: '/service/bin',
shellPath: '/bin/zsh',
shellArgs: ['-l'],
findOnProcessPath: () => options.processPathResult ?? null,
findInLoginShell: () => loginShellResults.shift() ?? null,
exists: (path) => existingPaths.has(path),
};
}
describe('GitHub Copilot CLI resolver', () => {
it('accepts a candidate the version probe verifies and carries the version as metadata', () => {
const binaryPath = '/service/bin/copilot';
const probe = vi.fn(() => '1.0.94');
const resolver = createCopilotResolverForTest(
createHost({ processPathResult: binaryPath, existingPaths: [binaryPath] }),
probe
);
expect(resolver.resolve()).toMatchObject({
binaryPath,
directory: '/service/bin',
source: 'process-path',
metadata: '1.0.94',
});
expect(probe).toHaveBeenCalledWith(binaryPath);
});
it('rejects a candidate the probe refuses and falls through to a later one', () => {
// An unrelated `copilot` on the service PATH (probe returns null) must not mask
// the real coding agent found by the login shell.
const impostor = '/service/bin/copilot';
const genuine = '/login-shell/bin/copilot';
const probe = vi.fn((binPath: string) => (binPath === genuine ? '1.0.94' : null));
const resolver = createCopilotResolverForTest(
createHost({
processPathResult: impostor,
loginShellResults: [genuine],
existingPaths: [impostor, genuine],
}),
probe
);
expect(resolver.resolve()).toMatchObject({ binaryPath: genuine, source: 'login-shell', metadata: '1.0.94' });
});
it('negative-caches a miss and retries only after the backoff elapses', () => {
const binaryPath = '/late/bin/copilot';
let now = 0;
const probe = vi.fn(() => '1.0.94');
const resolver = createCopilotResolverForTest(
createHost({ loginShellResults: [null, binaryPath], existingPaths: [binaryPath] }),
probe,
() => now
);
expect(resolver.resolve()).toBeNull();
expect(resolver.resolve()).toBeNull(); // within the backoff: no re-run
expect(probe).not.toHaveBeenCalled();
now = cliResolveRetryDelayMs(1);
expect(resolver.resolve()?.metadata).toBe('1.0.94');
expect(resolver.resolve()?.binaryPath).toBe(binaryPath);
});
it('never executes a copilot candidate under vitest (the ambient probe is VITEST-gated)', () => {
// A REAL executable fixture that prints a valid version. If the guard in
// probeCopilotVersion is ever removed again, the probe runs this script, the
// resolution SUCCEEDS, and this test fails — pinning hermeticity by
// behavior rather than by source text. (The suites must never execute
// whatever `copilot` binary the machine running them happens to carry.)
const root = mkdtempSync(join(tmpdir(), 'codeman-copilot-vitest-gate-'));
temporaryDirectories.push(root);
const binaryPath = join(root, 'copilot');
writeFileSync(binaryPath, '#!/bin/sh\necho GitHub Copilot CLI 0.99.0.\n');
chmodSync(binaryPath, 0o755);
const hostOptions = {
processPath: root,
shellPath: '/bin/bash',
shellArgs: ['-i', '-l'] as string[],
runCommand: () => '',
isExecutableFile: (path: string) => path === binaryPath,
};
// Default (ambient) probe: the candidate is found but never executed, so
// the VITEST gate reports it unusable and resolution misses.
const gated = createCopilotResolverForTest(createProductionCliResolverHost(hostOptions));
expect(gated.resolve()).toBeNull();
// Control: identical setup with an injected probe resolves, proving the
// null above comes from the gate, not from the fixture or the host.
const control = createCopilotResolverForTest(createProductionCliResolverHost(hostOptions), () => '0.99.0');
expect(control.resolve()).toMatchObject({ binaryPath, metadata: '0.99.0' });
});
});
+25
View File
@@ -0,0 +1,25 @@
/**
* @fileoverview copilotConfig on the session create body: `model` and `resumeSessionId` follow a flag
* (`--model`, `--resume`), so a value that starts with `-` would be read as its own flag and get
* around the allowAll clamp. Both need a leading letter or digit.
*/
import { describe, it, expect } from 'vitest';
import { CreateSessionSchema } from '../src/web/schemas.js';
const parse = (copilotConfig: unknown) =>
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'copilot', copilotConfig });
describe('copilotConfig schema', () => {
it.each(['--yolo', '-n', '--allow-all-tools', '.hidden', '/etc/passwd'])('rejects model %s', (model) => {
expect(parse({ model }).success).toBe(false);
});
it.each(['--yolo', '-n', '--allow-all-tools'])('rejects resumeSessionId %s', (resumeSessionId) => {
expect(parse({ resumeSessionId }).success).toBe(false);
});
it.each(['auto', 'claude-sonnet-5.5', 'openai/gpt-5'])('accepts model %s', (model) => {
expect(parse({ model }).success).toBe(true);
});
});
+94
View File
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, it, expect } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import { _clampEnvOverridesForOwner } from '../src/web/routes/session-routes.js';
import { CreateSessionSchema } from '../src/web/schemas.js';
const copilot = STOCK_CLIS.find((c) => (c.id as string) === 'copilot')!;
describe('GitHub Copilot custom endpoint (BYOK)', () => {
it('injects its COPILOT_PROVIDER_* variables, with the /v1 suffix an OpenAI route needs', () => {
expect(copilot.capabilities.customModelInjection).toEqual({
kind: 'env',
baseUrlVar: 'COPILOT_PROVIDER_BASE_URL',
apiKeyVar: 'COPILOT_PROVIDER_API_KEY',
modelVars: ['COPILOT_MODEL'],
appendV1Suffix: true,
});
});
it('keeps every injected variable privileged, so a non-granted owner cannot set them by hand', () => {
const injection = copilot.capabilities.customModelInjection;
if (injection.kind !== 'env') throw new Error('expected env injection');
for (const name of [injection.baseUrlVar, injection.apiKeyVar, ...injection.modelVars]) {
expect(copilot.capabilities.privilegedEnvKeys).toContain(name);
expect(name.startsWith('COPILOT_')).toBe(true); // reachable through the prefix allowlist
}
});
});
describe('GitHub Copilot environment and sync declarations', () => {
it('admits only its own COPILOT_ namespace, never the shared GitHub token names', () => {
// The env allowlist is one global list with no mode context, so a foreign key here would be
// settable on every session. COPILOT_GITHUB_TOKEN is already reachable through the prefix.
expect(copilot.env.allowedPrefixes).toEqual(['COPILOT_']);
expect(copilot.env.allowedKeys).toEqual([]);
});
it('declares its own MCP config so Settings does not list it as unsupported', () => {
expect(copilot.capabilities.mcpConfig).toEqual({
path: '.copilot/mcp-config.json',
format: 'copilot-json',
relocation: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
});
});
it('seeds no credentials into a Docker case', () => {
expect(copilot.overlays?.credStore).toBeUndefined();
});
});
describe('GitHub Copilot multi-user clamp: the env-var half', () => {
// COPILOT_ is an allowlisted prefix, so every variable below would otherwise pass the schema.
// Each one hands back what the allowAll clamp removed: an approval switch, code that runs with no
// approval row (a CLI build, an editor, a key command, skills, hooks), or the sign-in token sent to
// a host of the caller's choosing.
const DANGEROUS: Record<string, string> = {
COPILOT_ALLOW_ALL: 'true',
COPILOT_PLAN_THEN_AUTOPILOT: '1',
COPILOT_HOME: '/tmp/planted-copilot-home',
COPILOT_CLI_DIST_DIR: '/tmp/planted-dist',
COPILOT_PKG_CACHE_HOME: '/tmp/planted-cache',
COPILOT_CACHE_HOME: '/tmp/planted-cache',
COPILOT_EDITOR: 'sh -c planted',
COPILOT_PROVIDER_API_KEY_COMMAND: 'sh -c planted',
COPILOT_SKILLS_DIRS: '/tmp/planted-skills',
COPILOT_PROVIDERS_CONFIG: '/tmp/planted-providers.json',
COPILOT_API_URL: 'https://attacker.example',
COPILOT_GH_HOST: 'attacker.example',
COPILOT_PROVIDER_BASE_URL: 'https://attacker.example/v1',
COPILOT_PROVIDER_HEADERS: 'X-Planted: 1',
};
const ORIGINAL = process.env.CODEMAN_MULTIUSER;
beforeEach(() => {
process.env.CODEMAN_MULTIUSER = '1';
});
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = ORIGINAL;
});
it('passes the env allowlist, so only the clamp stands between these and the pane', () => {
expect(() =>
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'copilot', envOverrides: DANGEROUS })
).not.toThrow();
});
it("strips every one of them from a non-granted owner's overrides and keeps the harmless knobs", async () => {
const out = await _clampEnvOverridesForOwner('nobody', {
...DANGEROUS,
COPILOT_GITHUB_TOKEN: 'own-token',
COPILOT_AUTO_UPDATE: 'false',
});
expect(out).toEqual({ COPILOT_GITHUB_TOKEN: 'own-token', COPILOT_AUTO_UPDATE: 'false' });
});
});
@@ -0,0 +1,76 @@
import { describe, it, expect, afterEach } from 'vitest';
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const root = resolve(import.meta.dirname, '..');
const dirs: string[] = [];
afterEach(() => {
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
});
function deepseekInstallCommand(platform: 'linux' | 'darwin'): string {
const ds = STOCK_CLIS.find((c) => (c.id as string) === 'deepseek')!;
return ds.discovery.install.command![platform]!;
}
/**
* Run the stock install command through `/bin/sh -c` (what CLI management does, `shell: true`)
* with a fake `npm` that records each call, on a PATH that holds only the fake bins. So this
* exercises the real string, not a copy of it.
*/
function runInstall(opts: { pnpmOnPath: boolean; npmFails?: boolean }): { status: number | null; npmCalls: string[] } {
const dir = mkdtempSync(join(tmpdir(), 'dsh-install-'));
dirs.push(dir);
const npmBin = join(dir, 'npm-bin');
const pnpmBin = join(dir, 'pnpm-bin');
mkdirSync(npmBin);
mkdirSync(pnpmBin);
const log = join(dir, 'npm.log');
writeFileSync(log, '');
writeFileSync(join(npmBin, 'npm'), '#!/bin/sh\necho "$*" >> "$NPM_LOG"\nexit "${NPM_EXIT:-0}"\n');
chmodSync(join(npmBin, 'npm'), 0o755);
writeFileSync(join(pnpmBin, 'pnpm'), '#!/bin/sh\nexit 0\n');
chmodSync(join(pnpmBin, 'pnpm'), 0o755);
const result = spawnSync('/bin/sh', ['-c', deepseekInstallCommand('linux')], {
env: {
PATH: opts.pnpmOnPath ? `${npmBin}:${pnpmBin}` : npmBin,
NPM_LOG: log,
NPM_EXIT: opts.npmFails ? '1' : '0',
},
encoding: 'utf-8',
});
const npmCalls = readFileSync(log, 'utf-8').split('\n').filter(Boolean);
return { status: result.status, npmCalls };
}
describe('DeepSeek install', () => {
it('is the same command on linux and darwin', () => {
expect(deepseekInstallCommand('darwin')).toBe(deepseekInstallCommand('linux'));
});
it('installs pnpm with dsh when none is on PATH, since `dsh plugin` spawns a literal pnpm', () => {
const { status, npmCalls } = runInstall({ pnpmOnPath: false });
expect(status).toBe(0);
expect(npmCalls).toEqual(['install -g @deepseek-ai/dsh', 'install -g pnpm']);
});
it('leaves an existing pnpm alone (a corepack shim makes `npm install -g pnpm` fail with EEXIST)', () => {
const { status, npmCalls } = runInstall({ pnpmOnPath: true });
expect(status).toBe(0);
expect(npmCalls).toEqual(['install -g @deepseek-ai/dsh']);
});
it('reports a failed dsh install and does not go on to pnpm', () => {
const { status, npmCalls } = runInstall({ pnpmOnPath: false, npmFails: true });
expect(status).not.toBe(0);
expect(npmCalls).toEqual(['install -g @deepseek-ai/dsh']);
});
it("puts dsh's own directory first on the profile install's PATH", () => {
const src = readFileSync(resolve(root, 'src/web/routes/system-routes.ts'), 'utf8');
expect(src).toMatch(/env: \{ \.\.\.process\.env, PATH: \[dir, process\.env\.PATH\]/);
});
});
+1 -1
View File
@@ -128,7 +128,7 @@ describe('docker server image divergence is declared, not accidental', () => {
// untouched by this change because two other open PRs already modify it. Asserting the
// omissions here makes the divergence reviewable without editing the file: if someone adds
// a CLI there, or the intent changes, this fails and the list has to be restated.
const SERVER_INTENTIONAL_OMISSIONS = new Set(['antigravity', 'pi', 'grok', 'deepseek', 'omp']);
const SERVER_INTENTIONAL_OMISSIONS = new Set(['antigravity', 'pi', 'grok', 'deepseek', 'omp', 'copilot']);
it('installs exactly the CLIs it declares, and no more', () => {
for (const entry of enabledAgents) {
+11
View File
@@ -51,6 +51,17 @@ describe('envOverrides exact-key allowlist', () => {
).toThrow();
});
it('rejects the shared GitHub token names (Copilot reads COPILOT_GITHUB_TOKEN, in its own prefix)', () => {
for (const key of ['GH_TOKEN', 'GITHUB_TOKEN']) {
for (const mode of ['claude', 'shell', 'codex', 'copilot'] as const) {
expect(() => CreateSessionSchema.parse({ workingDir: '/tmp', mode, envOverrides: { [key]: 'x' } })).toThrow();
}
}
expect(() =>
CreateSessionSchema.parse({ workingDir: '/tmp', mode: 'copilot', envOverrides: { COPILOT_GITHUB_TOKEN: 'x' } })
).not.toThrow();
});
it('still blocks security-sensitive keys', () => {
for (const key of ['PATH', 'LD_PRELOAD', 'NODE_OPTIONS', 'CODEMAN_MUX_NAME']) {
expect(() =>
+50
View File
@@ -0,0 +1,50 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
✔ You approved codex to run touch created.txt this time
• Ran touch created.txt
└ (no output)
• Created created.txt successfully.
Worked for 17s • 4:23 AM
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo · Request approval to create file
? for shortcuts
+50
View File
@@ -0,0 +1,50 @@
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
✔ You approved codex to run touch created.txt this time
• Ran touch created.txt
└ (no output)
• Created created.txt successfully.
Worked for 17s • 4:23 AM
› Edit calc.py to add a function sub(a, b) that returns a - b. Use your file editing tool, not a shell command.
• I’ll inspect calc.py, then use the file editing tool to add the function.
• Explored
└ Read calc.py
+ Show details
• Edited calc.py (+4 -0)
3 +
4 +
5 +def sub(a, b):
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
✘ Failed to apply patch
└ (error details unavailable)
+ Show details
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo · Request approval to create file
? for shortcuts
+50
View File
@@ -0,0 +1,50 @@
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
• Working (5s • esc to interrupt)
└ Tip: Use /feedback to send logs to the maintainers when something looks off.
› Ask Codex to do anything
GPT-6-Luna medium · /tmp/codex-scratch/repo · Request approval to create file Plan mode
? for shortcuts
@@ -0,0 +1,23 @@
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
• Model changed to gpt-6-luna medium for Plan mode.
› I want a file named animal.txt. Use your question tool to
ask me which animal to write in it, with 3 options. Ask
only that one question.
■ Conversation interrupted - use /feedback if something went
wrong
› › 1. Yes, proceed (y) Press enter to confirm or esc to
cancel tab to add notes | enter to submit answer | esc
to interrupt
GPT-6-Luna medium · /tmp/codex-scratch/repo Plan mode
@@ -0,0 +1,49 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
• Model changed to gpt-6-luna medium for Plan mode.
› I want a file named animal.txt. Use your question tool to ask me which animal to write in it, with 3 options. Ask only that one question.
■ Conversation interrupted - use /feedback if something went wrong
› › 1. Yes, proceed (y) Press enter to confirm or esc to cancel tab to add notes | enter to submit answer | esc to interrupt
GPT-6-Luna medium · /tmp/codex-scratch/repo · Ask for animal choice Plan mode (shift+tab to cycle)
+49
View File
@@ -0,0 +1,49 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
GPT-6-Luna default · /tmp/codex-scratch/repo
+50
View File
@@ -0,0 +1,50 @@
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
✔ You approved codex to run touch created.txt this time
• Ran touch created.txt
└ (no output)
• Created created.txt successfully.
Worked for 17s • 4:23 AM
› Edit calc.py to add a function sub(a, b) that returns a - b. Use your file editing tool, not a shell command.
• I’ll inspect calc.py, then use the file editing tool to add the function.
• Explored
└ Read calc.py
+ Show details
• Edited calc.py (+4 -0)
3 +
4 +
5 +def sub(a, b):
+ Show details
Would you like to make the following edits?
Description: Apply proposed file edits
Destination: /tmp/codex-scratch/repo/calc.py
› 1. Yes, proceed (y)
2. Yes, and don't ask again for these files (a)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
+14
View File
@@ -0,0 +1,14 @@
Would you like to run the following command?
Environment: local
Reason: May I run the requested quoted heredoc with
write access to create lines.txt?
$ cat > lines.txt <<'EOF'
line 1
[… 21 lines] ^a view all
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
@@ -0,0 +1,50 @@
› Reply with exactly these five lines as plain text, nothing else, no tools: 'Would you like to run the following command?' then '› 1. Yes, proceed (y)' then ' 2. No, and tell Codex what to do
differently (esc)' then 'tab to add notes | enter to submit answer | esc to interrupt' then ' Press enter to confirm or esc to cancel'
• Would you like to run the following command?
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
tab to add notes | enter to submit answer | esc to interrupt
Press enter to confirm or esc to cancel
Worked for 1s • 4:31 AM
› Run this exact multi-line shell command with escalated permissions (ask me for approval): a bash heredoc that writes 12 lines 'line 1' through 'line 12' into lines.txt, using cat > lines.txt with a
quoted heredoc, each line written out literally.
• I’ll request approval to run the quoted heredoc that writes the 12 literal lines to lines.txt.
• Running cat > lines.txt <<'EOF' …
+ Show details
Would you like to run the following command?
Environment: local
Reason: May I run the requested quoted heredoc with write access to create lines.txt?
$ cat > lines.txt <<'EOF'
line 1
line 2
line 3
line 4
line 5
line 6
line 7
line 8
line 9
line 10
line 11
line 12
EOF
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
+30
View File
@@ -0,0 +1,30 @@
› Run: touch second.txt (needs app…
access outside the read only
sandbox. I’ll request approval to
run that exact command.
• Running touch second.txt
Would you like to run the
following command?
Environment: local
Reason: May I run `touch
second.txt` outside the read
only sandbox to create the
requested file?
$ touch second.txt
› 1. Yes, proceed (y)
2. Yes, and don't ask again for
commands that start with `touch
second.txt` (p)
3. No, and tell Codex what to do
differently (esc)
Press enter to confirm or esc to
cancel
+30
View File
@@ -0,0 +1,30 @@
outside the sandbox)
• Creating second.txt requires write access
outside the read only sandbox. I’ll
request approval to run that exact
command.
• Running touch second.txt
Would you like to run the following
command?
Environment: local
Reason: May I run `touch second.txt`
outside the read only sandbox to create
the requested file?
$ touch second.txt
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands
that start with `touch second.txt` (p)
3. No, and tell Codex what to do
differently (esc)
Press enter to confirm or esc to cancel
+50
View File
@@ -0,0 +1,50 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
• Running touch created.txt
Would you like to run the following command?
Environment: local
Reason: May I run `touch created.txt` outside the read only sandbox to create the requested file?
$ touch created.txt
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands that start with `touch created.txt` (p)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
+50
View File
@@ -0,0 +1,50 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
⣀⣤⣤⣤⣀⣀
⣠⣶⣿⣿⣿⣿⣿⣿⣿⣿⣦⣤⣤⣤⣤⣤⣤⡀
⢀⣾⣿⣿⠿⠋⠉⠉⢉⣭⣿⣿⣿⣿⣿⠿⣿⣿⣿⣿⣷⣄
⣀⣾⣿⣿⠃ ⣀⣴⣾⣿⣿⡿⠟⠋ ⣀⡀ ⠈⠙⢿⣿⣿⣧
⣀⣶⣿⣿⣿⣿⡇ ⢸⣿⣿⡿⠛⠉⢀⣠⣴⣿⣿⣿⣷⣦⣀⠈⢻⣿⣿⡇
⣰⣿⣿⡿⢿⣿⣿⡇ ⢸⣿⣿⢀⣤⣶⣿⣿⣿⠟⠛⠻⣿⣿⣿⣿⣮⣿⣿⡷
⢰⣿⣿⡟⠁⢸⣿⣿⡇ ⢸⣿⣿⣿⣿⠿⠿⣿⣿⣿⣦⣄⡀⠈⠛⠿⣿⣿⣿⣧⡀
⣾⣿⣿⠃ ⢸⣿⣿⡇ ⢸⣿⣿⠋⠁ ⠈⠙⢻⣿⣿⣿⣶⣤⡀ ⠹⢿⣿⣷⡄
⢸⣿⣿⣇ ⠸⣿⣿⣷⣦⣼⣿⣿ ⢸⣿⣿⠻⢿⣿⣿⡇ ⠘⣿⣿⣿
⠈⢿⣿⣿⣦⡀⠈⠛⠿⣿⣿⣿⣿⣄⡀ ⢀⣠⣼⣿⣿ ⣿⣿⡇ ⣿⣿⣿⠇
⢻⣿⣿⣿⣷⣦⣀⠈⠙⠻⣿⣿⣿⣶⣶⣿⣿⣿⣿⣿ ⣿⣿⡇ ⣠⣿⣿⣿
⢸⣿⣿⡿⢿⣿⣿⣿⣦⣠⣴⣿⣿⣿⡿⠟⠋⢸⣿⣿ ⣿⣿⣷⣴⣿⣿⣿⠃
⠘⣿⣿⣷ ⠈⠛⢿⣿⣿⣿⠿⠋⠉ ⣠⣴⣿⣿⣿ ⣿⣿⣿⣿⣿⠟⠁
⠻⣿⣿⣿⣤⣀ ⠈⠉ ⢀⣤⣶⣿⣿⣿⠿⠟⠁ ⣰⣿⣿⡟⠋⠁
⠈⠿⣿⣿⣿⣿⣶⣾⣿⣿⣿⣿⠟⠋⠁ ⣠⣼⣿⣿⡟
⠙⠛⠛⠛⠻⠛⢿⣿⣿⣿⣿⣷⣾⣿⣿⣿⡿⠏
⠈⠙⠛⠿⠿⠿⠿⠛⠉
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo
? for shortcuts
+50
View File
@@ -0,0 +1,50 @@
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Questions 2/2 answered
• What should the greeting file be named?
answer: greeting.txt (Recommended)
• What greeting should it contain?
answer: Hello, world! (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
• Proposed Plan
# Add an English Greeting File
## Summary
Create greeting.txt in the repository root containing Hello, world!.
## Changes
• Add the greeting as plain text, followed by a newline.
• No public APIs or existing files need to change.
## Acceptance Check
• Confirm greeting.txt exists at the repository root and contains exactly Hello, world! on its first line.
## Assumptions
• Use the selected filename and greeting.
• Keep the file as plain text.
Worked for 1m 47s • 4:27 AM
Implement this plan?
› 1. Yes, implement this plan Switch to Default and start coding
2. Yes, clear context and implement Start a fresh thread (current context: 2% used)
3. No, stay in Plan mode Continue planning with the model
enter select · esc back
+50
View File
@@ -0,0 +1,50 @@
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Questions 2/2 answered
• What should the greeting file be named?
answer: greeting.txt (Recommended)
• What greeting should it contain?
answer: Hello, world! (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
• Proposed Plan
# Add an English Greeting File
## Summary
Create greeting.txt in the repository root containing Hello, world!.
## Changes
• Add the greeting as plain text, followed by a newline.
• No public APIs or existing files need to change.
## Acceptance Check
• Confirm greeting.txt exists at the repository root and contains exactly Hello, world! on its first line.
## Assumptions
• Use the selected filename and greeting.
• Keep the file as plain text.
Worked for 1m 47s • 4:27 AM
Select Model and Effort
› 1. GPT-6-Luna (current) Fast and affordable model for easier tasks.
2. GPT-5.6-Terra Older balanced model for straightforward work.
3. GPT-5.6-Luna Older fast and efficient model.
enter select · esc back
@@ -0,0 +1,50 @@
› Edit calc.py to add a function sub(a, b) that returns a - b. Use your file editing tool, not a shell command.
• Edited calc.py (+4 -0)
3 +
4 +
5 +def sub(a, b):
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
✘ Failed to apply patch
└ (error details unavailable)
+ Show details
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
• Running curl -sS -o /dev/null -w '%{http_code}' https://example.com
Would you like to run the following command?
Environment: local
Reason: May I rerun curl with network access to fetch https://example.com and report its HTTP status?
$ curl -sS -o /dev/null -w '%{http_code}' https://example.com
› 1. Yes, proceed (y)
2. Yes, and don't ask again for commands that start with `curl -sS -o /dev/null -w '%{http_code}' https://example.com` (p)
3. No, and tell Codex what to do differently (esc)
Press enter to confirm or esc to cancel
+50
View File
@@ -0,0 +1,50 @@
› Run: touch second.txt (needs approval outside the sandbox)
• Creating second.txt requires write access outside the read only sandbox. I’ll request approval to run that exact command.
✗ You canceled the request to run touch second.txt
• Failed (exit 1) touch second.txt
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want a new file named color.txt. Ask me with your question tool which color to write in it (offer 3 options). Ask only that one question.
• Explored · 1 failed
└ List rg --files -g color.txt -g AGENTS.md (exit 1)
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna default for Default mode.
› Reply with exactly these five lines as plain text, nothing else, no tools: 'Would you like to run the following command?' then '› 1. Yes, proceed (y)' then ' 2. No, and tell Codex what to do
differently (esc)' then 'tab to add notes | enter to submit answer | esc to interrupt' then ' Press enter to confirm or esc to cancel'
• Would you like to run the following command?
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
tab to add notes | enter to submit answer | esc to interrupt
Press enter to confirm or esc to cancel
Worked for 1s • 4:31 AM
Update Model Permissions
1. Ask for approval Read and edit workspace files and run commands, with approval required for internet access or edits outside the workspace
2. Approve for me Only ask for actions detected as potentially unsafe
3. Full Access Use with caution: Codex can edit files outside this workspace and access the internet without approval
› 4. Read Only (current) Read workspace files, with approval required for edits or internet access
enter select · esc back
+49
View File
@@ -0,0 +1,49 @@
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
Question 1/2 (2 unanswered)
What should the greeting file be named?
› 1. greeting.txt (Recommended) Use a clear, conventional text filename.
2. README greeting Add the greeting as a separate README-style Markdown file.
3. hello.txt Use a short, friendly filename.
4. None of the above Optionally, add details in notes (tab)
tab to add notes | enter to submit answer | ←/→ to navigate questions | esc to interrupt
@@ -0,0 +1,49 @@
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Questions 1/1 answered
• Which language should the greeting file use?
answer: English (Recommended)
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
Question 2/2 (1 unanswered)
What greeting should it contain?
› 1. Hello, world! (Recommended) Use a short, familiar English greeting.
2. Welcome! Use a concise welcoming message.
3. Good morning! Use a time-specific greeting.
4. None of the above Optionally, add details in notes (tab)
tab to add notes | enter to submit all | ←/→ to navigate questions | esc to interrupt
+49
View File
@@ -0,0 +1,49 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
• Model changed to gpt-6-luna medium for Plan mode.
› I want a file named animal.txt. Use your question tool to ask me which animal to write in it, with 3 options. Ask only that one question.
Question 1/1 (1 unanswered)
Which animal should I write in animal.txt?
› 1. Cat Write “cat” in the file.
2. Dog Write “dog” in the file.
3. Elephant Write “elephant” in the file.
4. None of the above Optionally, add details in notes (tab)
› abc
tab or esc to clear notes | enter to submit answer
+49
View File
@@ -0,0 +1,49 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
• Model changed to gpt-6-luna medium for Plan mode.
› I want a file named animal.txt. Use your question tool to ask me which animal to write in it, with 3 options. Ask only that one question.
Question 1/1 (1 unanswered)
Which animal should I write in animal.txt?
› 1. Cat Write “cat” in the file.
2. Dog Write “dog” in the file.
3. Elephant Write “elephant” in the file.
4. None of the above Optionally, add details in notes (tab)
› Add notes
tab or esc to clear notes | enter to submit answer
@@ -0,0 +1,49 @@
+ Show details
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
Question 1/1 (1 unanswered)
Which language should the greeting file use?
1. English (Recommended) Write a simple greeting in English.
› 2. French Write a simple greeting in French.
3. Spanish Write a simple greeting in Spanish.
4. None of the above Optionally, add details in notes (tab)
tab to add notes | enter to submit answer | esc to interrupt
+29
View File
@@ -0,0 +1,29 @@
• Explored · 1 failed
└ List rg --files -g color.txt -g
AGENTS.md (exit 1)
+ Show details
Question 1/1 (1 unanswered)
Which color should I write in
`color.txt`?
› 1. Blue Write the
color
blue.
2. Green Write the
color
green.
3. Red Write the
color red.
4. None of the Optionally
above , add
details in
notes
(tab)
tab to add notes
enter to submit answer
esc to interrupt
+29
View File
@@ -0,0 +1,29 @@
› Run: touch second.txt (needs approval outside the sandbo…
■ Conversation interrupted - use /feedback if something went
wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want a new file named color.txt. Ask me with your
question tool which color to write in it (offer 3
options). Ask only that one question.
• Explored · 1 failed
└ List rg --files -g color.txt -g AGENTS.md (exit 1)
+ Show details
Question 1/1 (1 unanswered)
Which color should I write in `color.txt`?
› 1. Blue Write the color blue.
2. Green Write the color green.
3. Red Write the color red.
4. None of the above Optionally, add details in notes
(tab)
tab to add notes | enter to submit answer
esc to interrupt
+49
View File
@@ -0,0 +1,49 @@
+ Show details
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Explored
└ List rg --files -g AGENTS.md -g '*greet*' -g 'README*' -g Makefile -g pyproject.toml -g package.json -g calc.py
+ Show details
Question 1/1 (1 unanswered)
Which language should the greeting file use?
› 1. English (Recommended) Write a simple greeting in English.
2. French Write a simple greeting in French.
3. Spanish Write a simple greeting in Spanish.
4. None of the above Optionally, add details in notes (tab)
tab to add notes | enter to submit answer | esc to interrupt
+50
View File
@@ -0,0 +1,50 @@
› Run: touch second.txt (needs approval outside the sandbox)
• Creating second.txt requires write access outside the read only sandbox. I’ll request approval to run that exact command.
✗ You canceled the request to run touch second.txt
• Failed (exit 1) touch second.txt
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want a new file named color.txt. Ask me with your question tool which color to write in it (offer 3 options). Ask only that one question.
• Explored · 1 failed
└ List rg --files -g color.txt -g AGENTS.md (exit 1)
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna default for Default mode.
› Reply with exactly these five lines as plain text, nothing else, no tools: 'Would you like to run the following command?' then '› 1. Yes, proceed (y)' then ' 2. No, and tell Codex what to do
differently (esc)' then 'tab to add notes | enter to submit answer | esc to interrupt' then ' Press enter to confirm or esc to cancel'
• Would you like to run the following command?
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
tab to add notes | enter to submit answer | esc to interrupt
Press enter to confirm or esc to cancel
Worked for 1s • 4:31 AM
Select a review preset
› 1. Review against a base branch (PR Style)
2. Review uncommitted changes
3. Review a commit
4. Custom review instructions
enter select · esc back
+49
View File
@@ -0,0 +1,49 @@
› Edit calc.py to add a function sub(a, b) that returns a - b. Use your file editing tool, not a shell command.
• Explored
└ Read calc.py
+ Show details
• Edited calc.py (+4 -0)
3 +
4 +
5 +def sub(a, b):
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
✘ Failed to apply patch
└ (error details unavailable)
+ Show details
› Use curl to fetch https://example.com and tell me the HTTP status code.
• I’ll use curl to fetch the URL and report the response status.
• Failed (exit 6) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ curl: (6) Could not resolve host: example.com
000
• curl couldn’t resolve example.com in the sandbox, so it returned status 000. I need network access to fetch it.
Worked for 3s • 4:24 AM
› Request approval to rerun that curl with network access (escalated permissions).
• I’ll request approval to rerun the same curl command with network access.
✗ You canceled the request to run curl -sS -o /dev/null -w '%{http_code}' https://example.com
• Failed (exit 1) curl -sS -o /dev/null -w '%{http_code}' https://example.com
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
› /plan switch to Plan mode
› /plan
GPT-6-Luna default · /tmp/codex-scratch/repo · Request approval to create file
@@ -0,0 +1,50 @@
› I want to add a greeting file. Before planning, ask me which language the greeting should be in, using your question tool with a few options.
• Model changed to gpt-6-luna default for Default mode.
› Run: touch second.txt (needs approval outside the sandbox)
• Creating second.txt requires write access outside the read only sandbox. I’ll request approval to run that exact command.
✗ You canceled the request to run touch second.txt
• Failed (exit 1) touch second.txt
└ (no output)
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna medium for Plan mode.
› I want a new file named color.txt. Ask me with your question tool which color to write in it (offer 3 options). Ask only that one question.
• Explored · 1 failed
└ List rg --files -g color.txt -g AGENTS.md (exit 1)
+ Show details
■ Conversation interrupted - use /feedback if something went wrong
• Model changed to gpt-6-luna default for Default mode.
› Reply with exactly these five lines as plain text, nothing else, no tools: 'Would you like to run the following command?' then '› 1. Yes, proceed (y)' then ' 2. No, and tell Codex what to do
differently (esc)' then 'tab to add notes | enter to submit answer | esc to interrupt' then ' Press enter to confirm or esc to cancel'
• Would you like to run the following command?
› 1. Yes, proceed (y)
2. No, and tell Codex what to do differently (esc)
tab to add notes | enter to submit answer | esc to interrupt
Press enter to confirm or esc to cancel
└ Tip: NEW: Network proxy can now be enabled from /experimental. Restart Codex after enabling it.
Worked for 1s • 4:31 AM
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo · Request approval to create file
? for shortcuts
+12
View File
@@ -0,0 +1,12 @@
Folder access
/tmp/codex-scratch/repo
Trust this folder? Codex can read, edit, and run files here, subject to your permission settings. Folder settings
can run code automatically, even without a model request. Continue only if you trust these files. Your trust
decision will be saved.
› 1. Trust and continue
2. Back to Agent Command Center
enter continue · esc back
+17
View File
@@ -0,0 +1,17 @@
Folder access
/tmp/codex-scratch/repo
Trust this folder? Codex can read,
edit, and run files here, subject to
your permission settings. Folder
settings can run code automatically,
even without a model request.
Continue only if you trust these
files. Your trust decision will be
saved.
› 1. Trust and continue
2. Quit
enter continue · esc quit
+11
View File
@@ -0,0 +1,11 @@
Folder access
/tmp/codex-scratch/repo
Trust this folder? Codex can read, edit, and run files here, subject to your permission settings. Folder settings can run code automatically, even without a model request. Continue only if you
trust these files. Your trust decision will be saved.
› 1. Trust and continue
2. Quit
enter continue · esc quit
+50
View File
@@ -0,0 +1,50 @@
╭─────────────────────────────────────────────────╮
│ ✨ Update available! 0.162.0 -> 0.162.1 │
│ Run npm install -g @openai/codex to update. │
│ │
│ See full release notes: │
│ https://github.com/openai/codex/releases/latest │
╰─────────────────────────────────────────────────╯
>_ OpenAI Codex (v0.162.0)
/tmp/codex-scratch/repo
› Run the shell command: touch created.txt (it needs write access, ask me for approval to run it outside the sandbox)
• touch created.txt needs write access in the workspace. I’ll request approval to run that exact command outside the read only sandbox.
• Working (1s • esc to interrupt)
› Ask Codex to do anything
GPT-6-Luna default · /tmp/codex-scratch/repo · ⠧
? for shortcuts
+36
View File
@@ -0,0 +1,36 @@
import { vi } from 'vitest';
export const styledCapture = 'normal\n\x1b[30m\x1b[42m[session] 0:claude*\n';
// Exercise the real capture method without a constructor or any real tmux IO.
vi.mock('node:child_process', () => ({
execSync: vi.fn(),
execFileSync: vi.fn(),
exec: vi.fn(),
execFile: vi.fn(),
spawn: vi.fn(),
}));
export async function createStyledPaneCapture() {
vi.resetModules();
vi.stubEnv('VITEST', undefined);
try {
const { TmuxManager } = await import('../../src/tmux-manager.js');
const { execSync } = await import('node:child_process');
const manager = Object.create(TmuxManager.prototype) as InstanceType<typeof TmuxManager>;
Object.defineProperty(manager, 'tmux', { value: () => 'fake-tmux' });
return (fullHistory: boolean, geometry = true, capture = styledCapture) => {
vi.mocked(execSync).mockImplementation((command) => {
if (String(command).includes('capture-pane')) return capture;
if (String(command).includes('display-message')) {
if (!geometry) throw new Error('cursor query failed');
return '0 0 40 2 0 1';
}
throw new Error(`Unexpected tmux command: ${command}`);
});
return manager.capturePaneBuffer('codeman-629', undefined, { fullHistory });
};
} finally {
vi.unstubAllEnvs();
}
}
+484
View File
@@ -0,0 +1,484 @@
/**
* @fileoverview src/git-worktrees.ts: the pure rules (worktree and branch names, `worktree list`
* parsing, the managed-path rule, exclude text) without git, then the IO half against a REAL temp
* repository (create, list, exclude, symlink guards, remove without --force, `branch -d`), plus an
* injected runner proving the Docker refusal never spawns git at all. Port: N/A.
*/
import { execFileSync } from 'node:child_process';
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
realpathSync,
rmSync,
symlinkSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
agentSkillExcludeLine,
createCaseWorktree,
defaultWorktreeBranch,
excludeAppendText,
gitRefusalText,
isPathWithin,
listCaseWorktrees,
managedWorktreeName,
parseWorktreeList,
removeCaseWorktree,
resolveCaseRepository,
resolveManagedWorktree,
respellUnder,
runWorktreeGit,
unquoteGitPath,
worktreeBaseError,
worktreeBranchError,
worktreeNameError,
type CaseRepository,
type WorktreeGitRunner,
} from '../src/git-worktrees.js';
const ENV = {
...process.env,
LC_ALL: 'C',
GIT_AUTHOR_NAME: 'T',
GIT_AUTHOR_EMAIL: 't@example.com',
GIT_COMMITTER_NAME: 'T',
GIT_COMMITTER_EMAIL: 't@example.com',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
};
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, env: ENV, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
describe('worktreeNameError', () => {
it.each(['fix-login', 'a', 'v1.2', 'under_score', 'A9', 'x'.repeat(64)])('accepts %s', (name) => {
expect(worktreeNameError(name)).toBeNull();
});
it.each([
['', 'required'],
['x'.repeat(65), 'at most 64'],
['-flag', 'start with a letter'],
['.hidden', 'start with a letter'],
['a/b', 'only letters'],
['a b', 'only letters'],
['a..b', '".."'],
['name.', 'end with'],
['name.lock', 'end with'],
['semi;colon', 'only letters'],
['ünï', 'only letters'],
])('refuses %j', (name, why) => {
expect(worktreeNameError(name)).toContain(why);
});
it('refuses a non-string', () => {
expect(worktreeNameError(undefined)).not.toBeNull();
expect(worktreeNameError(42)).not.toBeNull();
});
});
describe('branch and base gates', () => {
it('derives Claude Code’s default branch name', () => {
expect(defaultWorktreeBranch('fix-login')).toBe('worktree-fix-login');
});
it.each(['main', 'feat/login', 'worktree-a.b', 'user/x_y-z'])('accepts branch %s', (b) => {
expect(worktreeBranchError(b)).toBeNull();
});
it.each(['-D', '@{-1}', 'a..b', 'a//b', 'trail/', 'x.lock', 'feat/.hidden', 'sp ace', 'a~1', 'a^', 'a:b'])(
'refuses branch %j',
(b) => {
expect(worktreeBranchError(b)).not.toBeNull();
}
);
it('gates the base like Clone Repo’s ref', () => {
expect(worktreeBaseError('HEAD')).toBeNull();
expect(worktreeBaseError('origin/main')).toBeNull();
expect(worktreeBaseError('--upload-pack=x')).not.toBeNull();
expect(worktreeBaseError('HEAD@{1}')).not.toBeNull();
});
});
describe('paths', () => {
it('names only <repo>/.claude/worktrees/<valid-name>', () => {
expect(managedWorktreeName('/r', '/r/.claude/worktrees/a')).toBe('a');
expect(managedWorktreeName('/r', '/r/.claude/worktrees/a/')).toBe('a');
expect(managedWorktreeName('/r', '/r/.claude/worktrees/a/sub')).toBeNull();
expect(managedWorktreeName('/r', '/r/.claude/worktrees/')).toBeNull();
expect(managedWorktreeName('/r', '/r/../x/.claude/worktrees/a')).toBeNull();
expect(managedWorktreeName('/r', '/other/.claude/worktrees/a')).toBeNull();
expect(managedWorktreeName('/r', '/r/.claude/worktrees/-x')).toBeNull();
});
it('isPathWithin is a segment match, not a string prefix', () => {
expect(isPathWithin('/r/.claude/worktrees/a', '/r/.claude/worktrees/a')).toBe(true);
expect(isPathWithin('/r/.claude/worktrees/a/src', '/r/.claude/worktrees/a')).toBe(true);
expect(isPathWithin('/r/.claude/worktrees/ab', '/r/.claude/worktrees/a')).toBe(false);
});
it('respellUnder moves a path named through the case link onto the real repository root', () => {
expect(respellUnder('/link/.claude/worktrees/a', '/link', '/real')).toBe('/real/.claude/worktrees/a');
expect(respellUnder('/link/.claude/worktrees/a/src/', '/link', '/real')).toBe('/real/.claude/worktrees/a/src');
expect(respellUnder('/link', '/link', '/real')).toBe('/real');
// Not under the alias (a segment match, never a string prefix), or no alias at all: unchanged.
expect(respellUnder('/linked/x', '/link', '/real')).toBe('/linked/x');
expect(respellUnder('/real/.claude/worktrees/a', '/link', '/real')).toBe('/real/.claude/worktrees/a');
expect(respellUnder('/real/x', '/real', '/real')).toBe('/real/x');
});
});
describe('parseWorktreeList', () => {
it('parses main, branch, detached, locked and prunable records', () => {
const text = [
'worktree /repo',
'HEAD 1111111111111111111111111111111111111111',
'branch refs/heads/main',
'',
'worktree /repo/.claude/worktrees/a',
'HEAD 2222222222222222222222222222222222222222',
'branch refs/heads/worktree-a',
'locked busy agent',
'',
'worktree /elsewhere/b',
'HEAD 3333333333333333333333333333333333333333',
'detached',
'prunable gitdir file points to non-existent location',
'',
].join('\n');
const list = parseWorktreeList(text);
expect(list).toHaveLength(3);
expect(list[0]).toMatchObject({ path: '/repo', branch: 'main', detached: false, locked: false });
expect(list[1]).toMatchObject({ branch: 'worktree-a', locked: true, lockReason: 'busy agent' });
expect(list[2]).toMatchObject({ branch: null, detached: true, prunable: true });
});
it('reads a bare record and a record with no trailing blank line', () => {
const list = parseWorktreeList('worktree /bare.git\nbare\n\nworktree /w\nHEAD abc\nbranch refs/heads/x');
expect(list[0]).toMatchObject({ bare: true, head: null });
expect(list[1]).toMatchObject({ path: '/w', branch: 'x' });
});
it('unquotes a C-quoted path', () => {
expect(parseWorktreeList('worktree "/r/a\\"b\\303\\251"\nHEAD x\n')[0].path).toBe('/r/a"bé');
});
});
describe('unquoteGitPath', () => {
it('leaves plain text alone and decodes escapes', () => {
expect(unquoteGitPath('/plain path')).toBe('/plain path');
expect(unquoteGitPath('"/a\\tb\\\\c"')).toBe('/a\tb\\c');
expect(unquoteGitPath('"/\\344\\275\\240"')).toBe('/你');
});
});
describe('excludeAppendText', () => {
it('adds only what is missing, after a newline the file lacked', () => {
const r = excludeAppendText('*.log', ['/.claude/worktrees/', '/.claude/settings.local.json']);
expect(r.added).toEqual(['/.claude/worktrees/', '/.claude/settings.local.json']);
expect(r.text.startsWith('\n# Added by Codeman')).toBe(true);
expect(r.text.endsWith('/.claude/settings.local.json\n')).toBe(true);
});
it('is a no-op when every line is present', () => {
expect(excludeAppendText('/.claude/worktrees/\n', ['/.claude/worktrees/'])).toEqual({ text: '', added: [] });
});
it('agentSkillExcludeLine names the skill copy of a plain relative skills folder only', () => {
expect(agentSkillExcludeLine('.claude/skills')).toBe('/.claude/skills/codeman/');
expect(agentSkillExcludeLine('.agents/skills')).toBe('/.agents/skills/codeman/');
for (const bad of ['', '/abs', 'a/../b', 'a//b', '.', 'x/', 'a b', '*', '!neg']) {
expect(agentSkillExcludeLine(bad), bad).toBeNull();
}
});
});
describe('gitRefusalText', () => {
it('keeps fatal/error lines, drops hints and control bytes', () => {
const t = gitRefusalText(
"fatal: '.claude/worktrees/a' contains modified or untracked files, use --force to delete it\nhint: x\n\u001b[31m"
);
expect(t).toBe("fatal: '.claude/worktrees/a' contains modified or untracked files, use --force to delete it");
});
});
// ─── IO against a real repository ────────────────────────────────────────────
describe('git worktrees on a real repository', () => {
let dir: string;
let repoDir: string;
let repo: CaseRepository;
beforeEach(async () => {
dir = realpathSync(mkdtempSync(join(tmpdir(), 'git-worktrees-')));
repoDir = join(dir, 'repo');
mkdirSync(repoDir);
git(repoDir, 'init', '-q', '-b', 'main');
writeFileSync(join(repoDir, 'a.txt'), '1\n');
git(repoDir, 'add', 'a.txt');
git(repoDir, 'commit', '-q', '-m', 'base');
const resolved = await resolveCaseRepository(repoDir, { home: '/nonexistent-home' });
if (!resolved.ok) throw new Error(resolved.message);
repo = resolved.repo;
});
afterEach(() => {
rmSync(dir, { recursive: true, force: true });
});
it('resolves the repository root and the real common git dir', () => {
expect(repo.repoRoot).toBe(repoDir);
expect(repo.commonDir).toBe(join(repoDir, '.git'));
});
it('refuses a folder that is not a repository, and one below the top level', async () => {
const plain = join(dir, 'plain');
mkdirSync(plain);
const notRepo = await resolveCaseRepository(plain, { home: '/nonexistent-home' });
expect(notRepo).toMatchObject({ ok: false, code: 'INVALID_INPUT' });
mkdirSync(join(repoDir, 'sub'));
const sub = await resolveCaseRepository(join(repoDir, 'sub'), { home: '/nonexistent-home' });
expect(sub.ok).toBe(false);
if (!sub.ok) expect(sub.message).toContain('top of a git repository');
});
it('treats a repository at the home folder above the case as no repository', async () => {
mkdirSync(join(repoDir, 'proj'));
const r = await resolveCaseRepository(join(repoDir, 'proj'), { home: repoDir });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.message).toBe('This case folder is not a git repository.');
});
it('never spawns git for a case inside a Docker case workspace', async () => {
let spawned = 0;
const spy: WorktreeGitRunner = async () => {
spawned++;
return { code: 0, stdout: '', stderr: '', timedOut: false };
};
const r = await resolveCaseRepository(repoDir, { git: spy, dockerWorkspaces: [dir] });
expect(r).toMatchObject({ ok: false, code: 'INVALID_INPUT' });
expect(spawned).toBe(0);
});
it('creates <repo>/.claude/worktrees/<name> on worktree-<name>, excluded from the main checkout', async () => {
const created = await createCaseWorktree(repo, { name: 'fix-login' });
expect(created.ok).toBe(true);
if (!created.ok) return;
const wtPath = join(repoDir, '.claude', 'worktrees', 'fix-login');
expect(created.worktree).toMatchObject({ path: wtPath, branch: 'worktree-fix-login', base: 'HEAD' });
expect(created.worktree.excludeAdded).toEqual(['/.claude/worktrees/', '/.claude/settings.local.json']);
expect(readFileSync(join(wtPath, 'a.txt'), 'utf-8')).toBe('1\n');
expect(git(wtPath, 'rev-parse', '--abbrev-ref', 'HEAD').trim()).toBe('worktree-fix-login');
// The main checkout stays clean: the worktree folder is excluded, not untracked.
expect(git(repoDir, 'status', '--porcelain')).toBe('');
expect(readFileSync(join(repoDir, '.git', 'info', 'exclude'), 'utf-8')).toContain('/.claude/worktrees/');
// A hooks file Codeman would write into the worktree does not make it dirty either.
mkdirSync(join(wtPath, '.claude'), { recursive: true });
writeFileSync(join(wtPath, '.claude', 'settings.local.json'), '{}');
expect(git(wtPath, 'status', '--porcelain')).toBe('');
// A second create with the same lines already present appends nothing.
const second = await createCaseWorktree(repo, { name: 'other', branch: 'feat/other' });
expect(second.ok && second.worktree.excludeAdded).toEqual([]);
const listed = await listCaseWorktrees(repo);
expect(listed.ok).toBe(true);
if (!listed.ok) return;
expect(listed.worktrees.map((w) => [w.main, w.isCase, w.managed, w.name ?? null, w.branch])).toEqual([
[true, true, false, null, 'main'],
[false, false, true, 'fix-login', 'worktree-fix-login'],
[false, false, true, 'other', 'feat/other'],
]);
});
it('refuses an existing branch, a bad base, a bad branch and a taken folder before touching anything', async () => {
git(repoDir, 'branch', 'worktree-taken');
expect(await createCaseWorktree(repo, { name: 'taken' })).toMatchObject({ ok: false, code: 'ALREADY_EXISTS' });
expect(await createCaseWorktree(repo, { name: 'x', base: 'nosuchref' })).toMatchObject({
ok: false,
code: 'INVALID_INPUT',
});
expect(await createCaseWorktree(repo, { name: 'x', branch: '@{-1}' })).toMatchObject({
ok: false,
code: 'INVALID_INPUT',
});
expect(await createCaseWorktree(repo, { name: '../escape' })).toMatchObject({ ok: false, code: 'INVALID_INPUT' });
mkdirSync(join(repoDir, '.claude', 'worktrees', 'busy'), { recursive: true });
expect(await createCaseWorktree(repo, { name: 'busy' })).toMatchObject({ ok: false, code: 'ALREADY_EXISTS' });
expect(git(repoDir, 'branch', '--list', 'worktree-busy')).toBe('');
});
it('names a taken worktree name as such, not as its derived branch', async () => {
expect((await createCaseWorktree(repo, { name: 'dup' })).ok).toBe(true);
const again = await createCaseWorktree(repo, { name: 'dup' });
expect(again).toMatchObject({ ok: false, code: 'ALREADY_EXISTS' });
if (!again.ok) expect(again.message).toBe('A worktree named "dup" already exists in this case.');
});
it('says a bare repository is bare', async () => {
const bare = join(dir, 'bare.git');
git(dir, 'init', '-q', '--bare', bare);
const r = await resolveCaseRepository(bare, { home: '/nonexistent-home' });
expect(r.ok).toBe(false);
if (!r.ok) expect(r.message).toContain('bare git repository');
});
it('starts from a named base', async () => {
git(repoDir, 'checkout', '-q', '-b', 'side');
writeFileSync(join(repoDir, 'b.txt'), '2\n');
git(repoDir, 'add', 'b.txt');
git(repoDir, 'commit', '-q', '-m', 'side');
git(repoDir, 'checkout', '-q', 'main');
const created = await createCaseWorktree(repo, { name: 'from-side', base: 'side' });
expect(created.ok).toBe(true);
expect(readFileSync(join(repoDir, '.claude', 'worktrees', 'from-side', 'b.txt'), 'utf-8')).toBe('2\n');
});
it('refuses to create through a symlinked .claude or worktrees directory', async () => {
const outside = join(dir, 'outside');
mkdirSync(join(outside, 'worktrees'), { recursive: true });
symlinkSync(outside, join(repoDir, '.claude'));
const viaClaude = await createCaseWorktree(repo, { name: 'evil' });
expect(viaClaude).toMatchObject({ ok: false, code: 'INVALID_INPUT' });
if (!viaClaude.ok) expect(viaClaude.message).toContain('symlink');
rmSync(join(repoDir, '.claude'));
mkdirSync(join(repoDir, '.claude'));
symlinkSync(join(outside, 'worktrees'), join(repoDir, '.claude', 'worktrees'));
expect(await createCaseWorktree(repo, { name: 'evil' })).toMatchObject({ ok: false, code: 'INVALID_INPUT' });
expect(git(repoDir, 'worktree', 'list', '--porcelain')).not.toContain('evil');
});
it('removes a clean worktree, refuses a dirty one and a live one, and never force-deletes', async () => {
const created = await createCaseWorktree(repo, { name: 'w1' });
expect(created.ok).toBe(true);
const wtPath = join(repoDir, '.claude', 'worktrees', 'w1');
const live = await removeCaseWorktree(repo, { name: 'w1', isInUse: (p) => p === wtPath });
expect(live).toMatchObject({ ok: false, code: 'CONFLICT' });
writeFileSync(join(wtPath, 'scratch.txt'), 'untracked work');
const dirty = await removeCaseWorktree(repo, { name: 'w1', isInUse: () => false });
expect(dirty).toMatchObject({ ok: false, code: 'CONFLICT' });
if (!dirty.ok) expect(dirty.gitMessage).toContain('contains modified or untracked files');
expect(readFileSync(join(wtPath, 'scratch.txt'), 'utf-8')).toBe('untracked work');
rmSync(join(wtPath, 'scratch.txt'));
const clean = await removeCaseWorktree(repo, { name: 'w1', deleteBranch: true, isInUse: () => false });
expect(clean).toMatchObject({ ok: true, removed: { name: 'w1', branch: 'worktree-w1', branchDeleted: true } });
expect(git(repoDir, 'branch', '--list', 'worktree-w1')).toBe('');
expect(git(repoDir, 'worktree', 'list', '--porcelain')).not.toContain('w1');
});
it('never removes a worktree that is, or holds, a Docker case workspace', async () => {
const made = await createCaseWorktree(repo, { name: 'boxed' });
if (!made.ok) throw new Error(made.message);
const wtPath = made.worktree.path;
mkdirSync(join(wtPath, 'sandbox'));
// The worktree IS a Docker case's workspace, or one sits inside it: refused before git runs,
// and nothing is deleted. git still lists it.
for (const dockerWorkspaces of [[wtPath], [join(wtPath, 'sandbox')]]) {
let spawned = 0;
const counting: WorktreeGitRunner = async (cwd, args, o) => {
if (args.includes('remove')) spawned++;
return runWorktreeGit(cwd, args, o);
};
const r = await removeCaseWorktree(
repo,
{ name: 'boxed', isInUse: () => false },
{ git: counting, dockerWorkspaces }
);
expect(r).toMatchObject({ ok: false, code: 'INVALID_INPUT' });
if (!r.ok) expect(r.message).toContain('Docker case workspace');
expect(spawned).toBe(0);
expect(existsSync(join(wtPath, 'a.txt'))).toBe(true);
}
expect(git(repoDir, 'worktree', 'list', '--porcelain')).toContain('boxed');
// An unrelated Docker workspace does not get in the way.
rmSync(join(wtPath, 'sandbox'), { recursive: true });
const elsewhere = join(dir, 'other-box');
mkdirSync(elsewhere);
const ok = await removeCaseWorktree(
repo,
{ name: 'boxed', isInUse: () => false },
{ dockerWorkspaces: [elsewhere] }
);
expect(ok.ok).toBe(true);
expect(existsSync(wtPath)).toBe(false);
});
it('keeps an unmerged branch: git branch -d refuses, never -D', async () => {
await createCaseWorktree(repo, { name: 'w2' });
const wtPath = join(repoDir, '.claude', 'worktrees', 'w2');
writeFileSync(join(wtPath, 'new.txt'), 'x');
git(wtPath, 'add', 'new.txt');
git(wtPath, 'commit', '-q', '-m', 'work');
const r = await removeCaseWorktree(repo, { name: 'w2', deleteBranch: true, isInUse: () => false });
expect(r.ok).toBe(true);
if (!r.ok) return;
expect(r.removed.branchDeleted).toBe(false);
expect(r.removed.branchError).toContain('not fully merged');
expect(git(repoDir, 'branch', '--list', 'worktree-w2')).toContain('worktree-w2');
});
it('only removes a registered worktree that is a real directory', async () => {
mkdirSync(join(repoDir, '.claude', 'worktrees', 'stray'), { recursive: true });
expect(await removeCaseWorktree(repo, { name: 'stray', isInUse: () => false })).toMatchObject({
ok: false,
code: 'NOT_FOUND',
});
expect(await removeCaseWorktree(repo, { name: 'missing', isInUse: () => false })).toMatchObject({
ok: false,
code: 'NOT_FOUND',
});
// A symlink at the worktree's own place is refused, never followed.
await createCaseWorktree(repo, { name: 'real' });
symlinkSync(join(repoDir, '.claude', 'worktrees', 'real'), join(repoDir, '.claude', 'worktrees', 'link'));
expect(await resolveManagedWorktree(repo, 'link')).toMatchObject({ ok: false, code: 'INVALID_INPUT' });
});
it('refuses a locked worktree', async () => {
await createCaseWorktree(repo, { name: 'locked' });
git(repoDir, 'worktree', 'lock', '--reason', 'agent busy', join(repoDir, '.claude', 'worktrees', 'locked'));
const r = await removeCaseWorktree(repo, { name: 'locked', isInUse: () => false });
expect(r).toMatchObject({ ok: false, code: 'CONFLICT' });
if (!r.ok) expect(r.message).toContain('agent busy');
});
it('removes a worktree whose folder was deleted by hand, so its name can be used again', async () => {
const made = await createCaseWorktree(repo, { name: 'gone' });
if (!made.ok) throw new Error(made.message);
rmSync(made.worktree.path, { recursive: true, force: true });
const listed = await listCaseWorktrees(repo);
expect(listed.ok && listed.worktrees.find((w) => w.name === 'gone')).toMatchObject({
managed: true,
prunable: true,
});
// A session never starts in it; only removal accepts a missing folder.
expect(await resolveManagedWorktree(repo, 'gone')).toMatchObject({ ok: false, code: 'NOT_FOUND' });
// Before the fix this was NOT_FOUND, and both ways of reusing the name failed.
const r = await removeCaseWorktree(repo, { name: 'gone', deleteBranch: true, isInUse: () => false });
expect(r).toMatchObject({ ok: true, removed: { name: 'gone', branch: 'worktree-gone', branchDeleted: true } });
expect(git(repoDir, 'worktree', 'list', '--porcelain')).not.toContain('gone');
expect((await createCaseWorktree(repo, { name: 'gone' })).ok).toBe(true);
});
it('refuses a missing worktree that a live session still names, or that is locked', async () => {
const made = await createCaseWorktree(repo, { name: 'held' });
if (!made.ok) throw new Error(made.message);
git(repoDir, 'worktree', 'lock', '--reason', 'on a removable disk', made.worktree.path);
rmSync(made.worktree.path, { recursive: true, force: true });
expect(await removeCaseWorktree(repo, { name: 'held', isInUse: () => true })).toMatchObject({
ok: false,
code: 'CONFLICT',
});
const locked = await removeCaseWorktree(repo, { name: 'held', isInUse: () => false });
expect(locked).toMatchObject({ ok: false, code: 'CONFLICT' });
if (!locked.ok) expect(locked.message).toContain('removable disk');
});
it('never follows a symlink on the way to a missing worktree', async () => {
const made = await createCaseWorktree(repo, { name: 'via' });
if (!made.ok) throw new Error(made.message);
// The whole worktrees folder replaced by a link to somewhere else.
const elsewhere = join(dir, 'elsewhere');
mkdirSync(elsewhere);
rmSync(join(repoDir, '.claude', 'worktrees'), { recursive: true, force: true });
symlinkSync(elsewhere, join(repoDir, '.claude', 'worktrees'));
expect(await removeCaseWorktree(repo, { name: 'via', isInUse: () => false })).toMatchObject({
ok: false,
code: 'INVALID_INPUT',
});
});
});
+2
View File
@@ -69,6 +69,8 @@ beforeAll(async () => {
function makeApp() {
const app = Object.create(CodemanApp.prototype) as Record<string, any>;
app.$ = (id: string) => document.getElementById(id);
// The constructor always creates the input queue; _updateConnectionIndicator reads it.
app._pendingDeliveries = new Map();
return app;
}
+6 -3
View File
@@ -42,11 +42,14 @@ afterAll(() => {
en.dom.window.close();
});
/** Latin words left after removing what may stay: key names, the AI acronym, the N placeholder. */
/**
* Latin words left after removing what may stay: key names, the AI acronym, the N placeholder,
* and Git, a product name every translation that names it keeps (the Git status rows too).
*/
const leftover = (text: string) =>
text
.replace(/\b(Ctrl|Cmd|Shift|Alt|Option)\+\w+/g, '')
.replace(/\b(Ctrl|Cmd|Shift|Alt|Option|Enter|Tab|Space|End|Home|Escape|G|AI|N)\b/g, '')
.replace(/\b(Ctrl|Cmd|Shift|Alt|Option|Enter|Tab|Space|End|Home|Escape|G|AI|N|Git)\b/g, '')
.match(/[A-Za-z]+/g) ?? [];
const helpers = new Function(
@@ -204,7 +207,7 @@ describe('the case picker rows and the host-window and dictation toasts in zh-CN
);
it('finds the strings it checks (the check is not vacuous)', () => {
expect(pickerLabels).toEqual(['New or link a case\u2026', 'Case settings\u2026']);
expect(pickerLabels).toEqual(['New or link a case\u2026', 'Case settings\u2026', 'Git worktrees\u2026']);
expect(SESSION_UI).toContain('<div class="case-combobox-empty">No cases match</div>');
expect(toasts.sort()).toEqual([
'Could not open a new window for this dashboard',
+35 -1
View File
@@ -40,6 +40,8 @@ interface Harness {
pastedText: string[];
/** Image batches handed to _uploadAndInsertImages(), one entry per call. */
uploadedBatches: Array<Array<{ type: string }>>;
/** Messages handed to showToast(), one entry per call. */
toasts: string[];
/** How many trap divs are still attached to the fake body. */
attachedTraps: () => number;
runTimers: () => void;
@@ -107,7 +109,8 @@ function loadPasteHarness(): Harness {
app._uploadAndInsertImages = (files: Array<{ type: string }>) => {
uploadedBatches.push(Array.from(files));
};
app.showToast = () => {};
const toasts: string[] = [];
app.showToast = (message: string) => toasts.push(message);
(app._handleImagePaste as () => void).call(app);
@@ -126,6 +129,7 @@ function loadPasteHarness(): Harness {
},
pastedText,
uploadedBatches,
toasts,
attachedTraps: () => attached.size,
runTimers: () => {
const pending = timers.splice(0, timers.length);
@@ -180,6 +184,36 @@ describe('Ctrl+V paste trap', () => {
expect(h.pastedText).toEqual([]);
});
it('a paste mixing an image with a refused file uploads the image and says what it skipped', () => {
const h = loadPasteHarness();
h.firePaste({ text: 'shot.png', images: ['image/png', 'application/x-sh'] });
expect(h.uploadedBatches).toEqual([[{ type: 'image/png' }]]);
expect(h.toasts).toEqual(['1 file skipped: unsupported type']);
});
it('a paste whose only file is refused pastes its text instead, without a toast', () => {
const h = loadPasteHarness();
// Copying from a document can carry both: the text is what the user gets.
h.firePaste({ text: 'echo hi', images: ['application/x-sh'] });
expect(h.pastedText).toEqual(['echo hi']);
expect(h.uploadedBatches).toEqual([]);
expect(h.toasts).toEqual([]);
});
it('a paste of a refused file and no text says so instead of doing nothing', () => {
const h = loadPasteHarness();
h.firePaste({ images: ['application/x-sh'] });
expect(h.pastedText).toEqual([]);
expect(h.uploadedBatches).toEqual([]);
expect(h.toasts).toEqual(['Unsupported file type']);
});
it('removes the trap and hands focus back after the paste it accepted', () => {
const h = loadPasteHarness();
+8
View File
@@ -86,6 +86,13 @@ const LITERAL_SEARCH_PATHS: Record<string, string[]> = {
'$HOME/.npm-global/bin/omp',
'$HOME/bin/omp',
],
COPILOT: [
'$HOME/.local/bin/copilot',
'/usr/local/bin/copilot',
'$HOME/.bun/bin/copilot',
'$HOME/.npm-global/bin/copilot',
'$HOME/bin/copilot',
],
};
/** Array prefix in `install.sh` -> registry id, for the two that differ. */
@@ -99,6 +106,7 @@ const ARRAY_PREFIX_TO_CLI_ID: Record<string, string> = {
GROK: 'grok',
ANTIGRAVITY: 'antigravity',
OMP: 'omp',
COPILOT: 'copilot',
};
/**
+6 -5
View File
@@ -14,10 +14,11 @@ describe('keyboard shortcuts', () => {
});
it('provides Option+bracket shortcuts for previous and next session', () => {
expect(appSource).toContain("e.code === 'BracketLeft'");
expect(appSource).toContain("e.code === 'BracketRight'");
expect(appSource).toContain('this.prevSession()');
expect(appSource).toContain('this.nextSession()');
// Registry entries (so they can be rebound), matched by physical key code.
expect(appSource).toMatch(/id: 'previous-session'[\s\S]*?code: 'BracketLeft'[\s\S]*?action: 'previousSession'/);
expect(appSource).toMatch(/id: 'next-session-alt'[\s\S]*?code: 'BracketRight'[\s\S]*?action: 'nextSession'/);
expect(appSource).toContain('previousSession: () => this.prevSession()');
expect(appSource).toContain('nextSession: () => this.nextSession()');
});
it('suppresses xterm PTY injection for the same physical Alt nav codes (no ESC leak)', () => {
@@ -53,7 +54,7 @@ describe('keyboard shortcuts', () => {
// getShortcutRegistry() + matchesShortcutEvent() so overrides and per-shortcut
// disables (App Settings → Shortcuts) actually take effect.
expect(appSource).not.toContain('const SHORTCUTS = [');
expect(appSource).toContain('const SHORTCUT_ACTIONS = {');
expect(appSource).toContain('const SHORTCUT_ACTIONS = this._shortcutActions();');
expect(appSource).toContain('for (const shortcut of this.getShortcutRegistry())');
expect(appSource).toContain('if (this.matchesShortcutEvent(e, shortcut))');
expect(appSource).toContain('if (shortcut.disabled || !shortcut.action) continue;');
+6
View File
@@ -151,8 +151,14 @@ describe('terminal link-provider regexes (shipped source)', () => {
// Camera-roll video uploads the agent echoes back (VIDEO_ATTACHMENT_EXTENSIONS).
'/home/a/.codeman-uploads/paste-1-ab.m4v',
'/home/a/.codeman-uploads/paste-2-cd.ogv',
// Document uploads the agent echoes back (zip and tsv join the already-matched office/text set).
'/home/a/.codeman-uploads/paste-3-ef-archive.zip',
'/home/a/.codeman-uploads/paste-4-gh-data.tsv',
// Longer extension of a family must win over its prefix (tsx over ts).
'/home/a/src/App.tsx',
// Diffs an agent writes for review; the attachment allowlist already serves them.
'/tmp/claude-1000/-home-a-repo/b3de6e9f/scratchpad/resolution.diff',
'/home/a/repo/fix.patch',
];
for (const path of cases) {
ext.lastIndex = 0;
+2
View File
@@ -36,6 +36,7 @@ it('pins every remote pane command', () => {
grok: remoteLoginShellCommand('grok'),
deepseek: remoteLoginShellCommand('dsh'),
omp: remoteLoginShellCommand('omp'),
copilot: remoteLoginShellCommand('copilot'),
};
for (const [mode, want] of Object.entries(expected)) {
expect(defaultRemoteCommandForMode(mode as SessionMode), mode).toBe(want);
@@ -55,6 +56,7 @@ it('pins every in-container pane command', () => {
grok: 'exec grok',
deepseek: 'exec dsh',
omp: 'exec omp',
copilot: 'exec copilot',
};
for (const [mode, want] of Object.entries(expected)) {
expect(defaultDockerCommandForMode(mode as SessionMode), mode).toBe(want);
+2 -1
View File
@@ -18,7 +18,7 @@ function withMcp(mcpConfig: unknown) {
describe('capabilities.mcpConfig', () => {
it('is declared by exactly the CLIs whose format is verified', () => {
const declared = STOCK_CLIS.filter((e) => e.capabilities.mcpConfig).map((e) => e.id as string);
expect(declared.sort()).toEqual(['antigravity', 'claude', 'codex', 'gemini', 'opencode']);
expect(declared.sort()).toEqual(['antigravity', 'claude', 'codex', 'copilot', 'gemini', 'opencode']);
});
it('every stock declaration passes the schema, with a distinct file per CLI', () => {
@@ -41,6 +41,7 @@ describe('capabilities.mcpConfig', () => {
claude: { envVar: 'CLAUDE_CONFIG_DIR', path: '.claude.json' },
opencode: { envVar: 'XDG_CONFIG_HOME', path: 'opencode/opencode.json' },
codex: { envVar: 'CODEX_HOME', path: 'config.toml' },
copilot: { envVar: 'COPILOT_HOME', path: 'mcp-config.json' },
gemini: { envVar: 'GEMINI_CLI_HOME', path: '.gemini/settings.json' },
antigravity: undefined,
});
+1
View File
@@ -469,6 +469,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
'grok',
'deepseek',
'omp',
'copilot',
'shell',
]);
});
+109 -7
View File
@@ -13,6 +13,12 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const accessorySource = readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8');
const appSource = readFileSync(resolve('src/web/public/app.js'), 'utf8');
const imageInputSource = readFileSync(resolve('src/web/public/image-input.js'), 'utf8');
// The real filter-and-toast helper both dialogs call (image-input.js), so these
// tests pin the shipped wording rather than a copy of it.
const { _admitPromptAttachments } = new Function(
`class CodemanApp {}\n${imageInputSource}\nreturn CodemanApp.prototype;`
)() as { _admitPromptAttachments: (files: unknown[], options?: { textPasted?: boolean }) => unknown[] };
type Timer = { callback: () => void; delay: number };
@@ -45,9 +51,16 @@ function loadComposer(sessionId = 'session-1') {
_predictiveEcho: { clearPredictions: vi.fn() },
_sendInputAsync: vi.fn(),
_uploadAndInsertImages: vi.fn(async () => ['/tmp/image-one.png']),
_promptAttachKind: (file: { type: string }) =>
file.type.startsWith('image/') ? 'image' : file.type.startsWith('video/') ? 'video' : null,
_promptAttachKind: (file: { type: string; name?: string }) =>
file.type.startsWith('image/')
? 'image'
: file.type.startsWith('video/')
? 'video'
: /\.(pdf|docx|txt|md)$/i.test(file.name || '')
? 'document'
: null,
showToast: vi.fn(),
_admitPromptAttachments,
};
const schedule = (callback: () => void, delay = 0) => {
timers.push({ callback, delay });
@@ -560,7 +573,7 @@ describe('mobile prompt composer', () => {
const pasteImage = () => {
const item = { type: 'image/png', getAsFile: () => image };
const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', { value: { items: [item] } });
Object.defineProperty(event, 'clipboardData', { value: { items: [item], getData: () => '' } });
input.dispatchEvent(event);
};
@@ -576,20 +589,68 @@ describe('mobile prompt composer', () => {
expect((document.querySelector('.paste-send') as HTMLButtonElement).disabled).toBe(false);
});
it('offers videos in the Image key picker and filters the pick through the shared classifier', async () => {
it('offers videos and documents in the Attach key picker and filters the pick through the shared classifier', async () => {
const { app, bar, document } = loadComposer();
bar.composePrompt();
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
expect(fileInput.getAttribute('accept')).toBe('image/*,video/*');
expect(fileInput.getAttribute('accept')).toBe(
'image/*,video/*,.pdf,.docx,.pptx,.xlsx,.zip,.txt,.md,.csv,.tsv,.json,.log,.yaml,.yml,.xml'
);
expect((document.querySelector('.paste-image') as HTMLButtonElement).textContent).toBe('📎 Attach');
const video = new document.defaultView!.File(['clip'], 'clip.mov', { type: 'video/quicktime' });
const pdf = new document.defaultView!.File(['%PDF'], 'notes.pdf', { type: 'application/pdf' });
Object.defineProperty(fileInput, 'files', { configurable: true, value: [video, pdf] });
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
Object.defineProperty(fileInput, 'files', { configurable: true, value: [video, pdf, script] });
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([video], { insert: false });
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([video, pdf], { insert: false });
expect(app.showToast).toHaveBeenCalledWith('1 file skipped: unsupported type', 'info');
await vi.waitFor(() => expect(textarea(document).value).toContain('/tmp/image-one.png'));
});
it('says so when the Compose picker gets nothing it can upload', () => {
const { app, bar, document } = loadComposer();
bar.composePrompt();
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
Object.defineProperty(fileInput, 'files', { configurable: true, value: [script] });
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenCalledWith('Unsupported file type', 'error');
});
it('leaves a text paste that also carries a refused file to the editor, without a toast', () => {
const { app, bar, document } = loadComposer();
bar.composePrompt();
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: { items: [{ type: 'text/x-shellscript', getAsFile: () => script }], getData: () => 'echo hi' },
});
textarea(document).dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
expect(app.showToast).not.toHaveBeenCalled();
});
it('puts an uploaded document path into the draft like a photo', async () => {
const { app, bar, document } = loadComposer();
app._uploadAndInsertImages.mockResolvedValueOnce(['/case/.codeman-uploads/paste-1-ab-notes.pdf']);
bar.composePrompt();
const input = textarea(document);
input.value = 'summarize';
input.selectionStart = input.selectionEnd = input.value.length;
input.dispatchEvent(new document.defaultView!.Event('input', { bubbles: true }));
const fileInput = document.querySelector('.paste-file-input') as HTMLInputElement;
const pdf = new document.defaultView!.File(['%PDF'], 'notes.pdf', { type: '' }); // no MIME: the name decides
Object.defineProperty(fileInput, 'files', { configurable: true, value: [pdf] });
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
await vi.waitFor(() => expect(input.value).toBe('summarize /case/.codeman-uploads/paste-1-ab-notes.pdf'));
expect(app.terminal.paste).not.toHaveBeenCalled();
});
it('takes a video pasted into the editor through the shared classifier', () => {
const { app, bar, document } = loadComposer();
bar.composePrompt();
@@ -602,6 +663,7 @@ describe('mobile prompt composer', () => {
{ type: 'text/plain', getAsFile: () => null },
{ type: 'video/mp4', getAsFile: () => video },
],
getData: () => '',
},
});
input.dispatchEvent(event);
@@ -698,6 +760,46 @@ describe('mobile shell Paste', () => {
expect(document.querySelector('.paste-overlay')).toBeNull();
});
it('uploads the admitted files of a mixed pick and says how many it skipped', () => {
const { app, document } = openShellPaste();
const fileInput = document.querySelector('.paste-overlay .paste-file-input') as HTMLInputElement;
const image = new document.defaultView!.File(['image'], 'shot.png', { type: 'image/png' });
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
Object.defineProperty(fileInput, 'files', { configurable: true, value: [image, script] });
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([image], { sessionId: 'session-1' });
expect(app.showToast).toHaveBeenCalledWith('1 file skipped: unsupported type', 'info');
});
it('keeps the dialog open and says so when the pick holds nothing it can upload', () => {
const { app, document } = openShellPaste();
const fileInput = document.querySelector('.paste-overlay .paste-file-input') as HTMLInputElement;
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
Object.defineProperty(fileInput, 'files', { configurable: true, value: [script] });
fileInput.dispatchEvent(new document.defaultView!.Event('change', { bubbles: true }));
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
expect(app.showToast).toHaveBeenCalledWith('Unsupported file type', 'error');
expect(document.querySelector('.paste-overlay')).not.toBeNull();
});
it('leaves a text paste that also carries a refused file to the textarea, without a toast', () => {
const { app, document, input } = openShellPaste();
const script = new document.defaultView!.File(['#!/bin/sh'], 'run.sh', { type: 'text/x-shellscript' });
const event = new document.defaultView!.Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(event, 'clipboardData', {
value: { items: [{ type: 'text/x-shellscript', getAsFile: () => script }], getData: () => 'echo hi' },
});
input.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(app._uploadAndInsertImages).not.toHaveBeenCalled();
expect(app.showToast).not.toHaveBeenCalled();
expect(document.querySelector('.paste-overlay')).not.toBeNull();
});
it('sends nothing for an empty paste', () => {
const { app, document, send, runTimers } = openShellPaste();
+1 -1
View File
@@ -299,7 +299,7 @@ const context = vm.createContext({
});
vm.runInContext(
`${read('constants.js')}\n${read('app.js')}\n${read('terminal-ui.js')}\n${read('terminal-split.js')}\n` +
`${read('tile-grid.js')}\n${read('mobile-overview.js')}\n` +
`${read('tile-grid.js')}\n${read('mobile-overview.js')}\n${read('image-input.js')}\n` +
'globalThis.__CodemanApp = CodemanApp;',
context
);
+66
View File
@@ -0,0 +1,66 @@
/**
* @fileoverview The "Pin tab names on the CLI" App Settings row (#646) in Chinese (zh-CN):
* its label and description run through the real translator over the real index.html in
* JSDOM, read in Chinese with no English left beyond `CLI`, `Claude`, `Codeman` and
* `/resume`, and each key is in the dictionary once (a repeated key in the object literal
* silently replaces the first).
*
* Port: N/A.
*/
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterAll, describe, expect, it } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8');
const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8');
type Api = { configure(o: Record<string, string>): void; start(): void };
/** What may stay Latin: the product and CLI names and the slash command. */
const leftover = (text: string) => text.replace(/\/resume|\b(CLI|Claude|Codeman)\b/g, '').match(/[A-Za-z]+/g) ?? [];
function rowStrings(doc: Document): { label: string; description: string } {
const row = doc.getElementById('appSettingsPinCliSessionName')!.closest('.set-row')!;
return {
label: row.querySelector('.set-row-label')!.textContent!.trim(),
description: row.querySelector('.set-row-desc')!.textContent!.trim(),
};
}
const source = new JSDOM(INDEX);
const english = rowStrings(source.window.document);
const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' });
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.start();
api.configure({ language: 'zh-CN' });
const chinese = rowStrings(dom.window.document);
afterAll(() => {
source.window.close();
dom.window.close();
});
describe('the Pin tab names on the CLI row in zh-CN', () => {
it('finds the row (the check is not vacuous)', () => {
expect(english.label).toBe('Pin tab names on the CLI');
expect(english.description).toMatch(/^Launch every Claude session with its tab name/);
});
it('translates the label and the description, with no English left', () => {
for (const key of ['label', 'description'] as const) {
expect(chinese[key]).not.toBe(english[key]);
expect(leftover(chinese[key])).toEqual([]);
}
});
it('adds each key to the dictionary once', () => {
for (const text of [english.label, english.description]) {
expect(I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2).toBe(1);
}
});
});
+8
View File
@@ -21,6 +21,7 @@ import { isPiAvailable } from '../src/utils/pi-cli-resolver.js';
import { isGrokAvailable } from '../src/utils/grok-cli-resolver.js';
import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-cli-resolver.js';
import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js';
import { isCopilotAvailable } from '../src/utils/copilot-cli-resolver.js';
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
import { isGitAvailable } from '../src/git-clone.js';
import { enabledClis, reloadCliRegistry } from '../src/config/cli-registry/registry.js';
@@ -76,6 +77,10 @@ vi.mock('../src/utils/omp-cli-resolver.js', () => ({
isOmpAvailable: vi.fn(() => false),
resolveOmpDir: vi.fn(() => null),
}));
vi.mock('../src/utils/copilot-cli-resolver.js', () => ({
isCopilotAvailable: vi.fn(() => false),
resolveCopilotDir: vi.fn(() => null),
}));
vi.mock('../src/utils/cloudflared-resolver.js', () => ({
isCloudflaredAvailable: vi.fn(() => false),
resolveCloudflaredPath: vi.fn(() => null),
@@ -207,6 +212,7 @@ describe('WebServer.renderIndexHtml', () => {
vi.mocked(isDeepSeekAvailable).mockReturnValue(false);
vi.mocked(isDeepSeekRunnable).mockReturnValue(false);
vi.mocked(isOmpAvailable).mockReturnValue(true);
vi.mocked(isCopilotAvailable).mockReturnValue(true);
vi.mocked(isCloudflaredAvailable).mockReturnValue(true);
vi.mocked(isGitAvailable).mockReturnValue(true);
const { server } = makeServer({});
@@ -225,6 +231,7 @@ describe('WebServer.renderIndexHtml', () => {
deepseek: false,
deepseekBinary: false,
omp: true,
copilot: true,
cloudflared: true,
git: true,
shell: true,
@@ -365,6 +372,7 @@ describe('WebServer.renderIndexHtml', () => {
isDeepSeekAvailable,
isDeepSeekRunnable,
isOmpAvailable,
isCopilotAvailable,
isCloudflaredAvailable,
isGitAvailable,
]) {
+201
View File
@@ -460,3 +460,204 @@ Here is the assistant answer at column zero.
});
});
});
describe('GitHub Copilot pane dialect', () => {
// Captured from a real Copilot 1.0.95 pane inside tmux (a throwaway COPILOT_HOME, a local
// OpenAI-compatible endpoint): banner, two turns, status rows, composer between rules, footer.
const rule = '─'.repeat(100);
const pane = [
' ╭─╮╭─╮',
' ╰─╯╰─╯ Copilot v1.0.95 uses AI.',
' █ ▘▝ █ Check for mistakes.',
' ▔▔▔▔',
' ! Model "mock" is not in the built-in catalog. Using defaults for: prompt tokens',
' (COPILOT_PROVIDER_MAX_PROMPT_TOKENS), output tokens (COPILOT_PROVIDER_MAX_OUTPUT_TOKENS). Run copilot help',
' providers for configuration details.',
' ● Tip: /theme',
' └ View or set color mode',
' ❯ multi please 11:16',
' ● First line of the answer. ┃',
' ┃',
' Second paragraph with bold text. ┃',
' ┃',
' • bullet one ┃',
' • bullet two ┃',
' ❯ WORKDONE followed by _4711 11:17',
' ● WORKDONE_4711',
' ● MCP Servers reloaded: 1 server connected',
' /tmp/work/.../scratch-2 Session: credits unavailable',
rule,
'❯',
rule,
' ← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab mock',
].join('\n');
it('is a transcript mode', () => {
expect(isExternalCliTranscriptMode('copilot')).toBe(true);
});
it('returns the last answer, with no banner, status rows, composer or footer in it', () => {
const blocks = parseExternalCliTranscript(pane, 'copilot');
expect(getLastTranscriptResponse(blocks)).toBe('WORKDONE_4711');
});
it('keeps a multi-paragraph answer whole and strips the prompt timestamp', () => {
const blocks = parseExternalCliTranscript(pane, 'copilot');
const prompts = blocks.filter((b) => b.kind === 'prompt').map((b) => b.text);
expect(prompts).toEqual(['multi please', 'WORKDONE followed by _4711']);
const first = blocks.find((b) => b.kind === 'response')!;
expect(first.text).toContain('First line of the answer.');
expect(first.text).toContain('Second paragraph with bold text.');
expect(first.text).toContain('• bullet two');
expect(first.text).not.toMatch(/Session:|open sidebar|Tip:/);
// The scrollbar gutter Copilot draws at the right edge of every row never reaches the text.
expect(first.text).not.toContain('┃');
});
// The footer and composer every screen below ends with.
const tail = [
' /tmp/work/.../scratch-2 Session: credits unavailable',
rule,
'❯',
rule,
' ← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab mock',
];
it("keeps a shell prompt inside an answer's code block as answer text, not a new prompt", () => {
const screen = [
' ❯ how do I run the tests 11:20',
' ● Run this from the repo root:',
'',
' ❯ npm test',
'',
' Then read the summary line.',
...tail,
].join('\n');
const blocks = parseExternalCliTranscript(screen, 'copilot');
expect(blocks.filter((b) => b.kind === 'prompt').map((b) => b.text)).toEqual(['how do I run the tests']);
const answer = getLastTranscriptResponse(blocks);
expect(answer).toContain('Run this from the repo root:');
expect(answer).toContain('❯ npm test');
expect(answer).toContain('Then read the summary line.');
});
it('keeps a paragraph between two rules inside an answer, and treats only the last triple as the composer', () => {
const screen = [
' ❯ quote the passage 11:21',
' ● Here it is:',
rule,
' Quoted passage between two rules.',
rule,
...tail,
].join('\n');
expect(getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'))).toBe(
'Here it is:\n\n---\n\nQuoted passage between two rules.'
);
});
it('keeps an answer whole across a rule inside it, so agent read never returns only its tail', () => {
const screen = [
' ❯ summarize the change 11:22',
' ● Summary',
'',
' One line of summary.',
rule,
' Details',
'',
' The longer explanation.',
...tail,
].join('\n');
const blocks = parseExternalCliTranscript(screen, 'copilot');
expect(blocks.filter((b) => b.kind === 'response')).toHaveLength(1);
const answer = getLastTranscriptResponse(blocks);
expect(answer).toContain('Summary');
expect(answer).toContain('One line of summary.');
expect(answer).toContain('Details');
expect(answer).toContain('The longer explanation.');
// The rule before the footer separates the answer from the chrome and never trails it.
expect(answer.endsWith('---')).toBe(false);
});
it('answers nothing for a session that has only drawn its banner or a trust dialog', () => {
const banner = pane.split('\n').slice(0, 9).join('\n');
expect(getLastTranscriptResponse(parseExternalCliTranscript(banner, 'copilot'))).toBe('');
const trust = [
'│ Do you trust the files in this folder?',
'│ ❯ 1. Yes',
'│ 2. Yes, and remember this folder for future sessions',
].join('\n');
expect(getLastTranscriptResponse(parseExternalCliTranscript(trust, 'copilot'))).toBe('');
});
it('does not take the empty composer glyph for a prompt', () => {
const blocks = parseExternalCliTranscript(pane, 'copilot');
expect(blocks.some((b) => b.kind === 'prompt' && b.text === '')).toBe(false);
});
// The pane is all there is to read, so a long answer scrolls the prompt row (and the banner) off
// the top. That must read as the tail of the answer, not as "nothing yet".
describe('an answer taller than the pane', () => {
const rule = '─'.repeat(100);
const tail = (n: number) =>
Array.from({ length: n }, (_, i) => ` line ${String(i + 1).padStart(3, '0')} of the long answer`);
const footer = [
' /tmp/work/.../scratch-2 Session: 0 AIC used',
rule,
'❯',
rule,
' ← open sidebar · Interactive · Manual Approval · / commands · ? help · tab next tab mock',
];
it('returns the visible rows when the prompt row has scrolled off', () => {
const screen = [...tail(35), ...footer].join('\n');
const text = getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'));
expect(text).toContain('line 001 of the long answer');
expect(text).toContain('line 035 of the long answer');
expect(text).not.toMatch(/Session:|open sidebar|commands/);
});
it('still reads the tail when the answer has a leading bullet row on screen', () => {
const screen = [' ● the answer begins here', ...tail(20), ...footer].join('\n');
const text = getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'));
expect(text).toContain('the answer begins here');
expect(text).toContain('line 020 of the long answer');
});
it('is not fooled by text typed in the composer under the answer', () => {
const typed = [
...tail(30),
' /tmp/work/.../scratch-2 Session: 0 AIC used',
rule,
'❯ half a thought',
rule,
footer[4],
];
const text = getLastTranscriptResponse(parseExternalCliTranscript(typed.join('\n'), 'copilot'));
expect(text).toContain('line 030 of the long answer');
expect(text).not.toContain('half a thought');
});
it('still answers nothing while the banner or a trust dialog is on screen', () => {
const banner = [
' ╭─╮╭─╮',
' ╰─╯╰─╯ Copilot v1.0.95 uses AI.',
' ! Model "mock" is not in the built-in catalog.',
' /tmp/work Session: 0 AIC used',
rule,
'❯',
rule,
footer[4],
].join('\n');
expect(getLastTranscriptResponse(parseExternalCliTranscript(banner, 'copilot'))).toBe('');
});
it('prefers the prompt row when it is on screen', () => {
const screen = [
' ❯ do the thing 11:16',
' ● short answer',
...footer,
].join('\n');
expect(getLastTranscriptResponse(parseExternalCliTranscript(screen, 'copilot'))).toBe('short answer');
});
});
});
@@ -147,6 +147,7 @@ describe('resumeHistorySession: row retirement is gated on actual continuation',
['opencode', 'openCodeConfig'],
['pi', 'piConfig'],
['grok', 'grokConfig'],
['copilot', 'copilotConfig'],
['omp', 'ompConfig'],
])('retires the old row for %s (continueSession is wired via %s)', async (mode, configKey) => {
const app = makeApp();
+54 -1
View File
@@ -13,6 +13,7 @@ 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 { raiseScreenBlocked } from '../../src/web/screen-blocked.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';
@@ -455,7 +456,6 @@ describe('approval routes', () => {
});
});
describe('approval routes: supervised-hook (DeepSeek) alert-only items', () => {
let harness: RouteTestHarness;
let session: MockSession;
@@ -505,6 +505,59 @@ describe('approval routes: supervised-hook (DeepSeek) alert-only items', () => {
});
});
describe('approval routes: dialogs read off a hook-less screen (codex) are alert-only', () => {
let harness: RouteTestHarness;
let session: MockSession;
const noopDeps = {
broadcast: () => {},
broadcastSessionStateDebounced: () => {},
sendPushNotifications: () => {},
};
beforeEach(async () => {
harness = await createEnvelopeHarness();
session = harness.ctx.sessions.get(SESSION_ID)!;
session.mode = 'codex';
});
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('lists the item, and the page-load sweep keeps it', async () => {
raiseScreenBlocked(session as never, noopDeps, '› 1. Yes, proceed (y)\n Press enter to confirm or esc to cancel');
const approvals = await listApprovals(harness);
expect(approvals).toHaveLength(1);
expect(approvals[0]).toMatchObject({ sessionId: SESSION_ID, kind: 'permission', answerInTerminal: true });
});
it('refuses an answer with the terminal-only error, not "cannot have pending approvals"', async () => {
// The hooks gate would say a codex session cannot have an item at all, which is false
// about the one the caller holds; the alert-only check runs first.
raiseScreenBlocked(session as never, noopDeps, null);
const [item] = await listApprovals(harness);
for (const payload of [{ action: 'approve' }, { action: 'deny' }, { action: 'option', option: 1 }]) {
const res = await harness.app.inject({ method: 'POST', url: `/api/approvals/${item.id}/answer`, payload });
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/^Codex approvals must be answered in the terminal/);
}
expect(session.writeBuffer).toEqual([]);
expect(await listApprovals(harness)).toHaveLength(1);
});
it('can still be dismissed', async () => {
raiseScreenBlocked(session as never, noopDeps, null);
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(await listApprovals(harness)).toHaveLength(0);
});
});
describe('approval routes: multi-user scoping', () => {
const saved: Record<string, string | undefined> = {};
@@ -183,6 +183,21 @@ describe('POST /api/cases with a custom path', () => {
}
});
it('creates and links a folder two levels under the cases directory (not listed as a local case)', async () => {
const { app } = await createRouteTestHarness(registerCaseRoutes);
mkdirSync(join(CASES_DIR(), 'parent-dir'), { recursive: true });
try {
const target = join(realpathSync(join(CASES_DIR(), 'parent-dir')), 'nested');
const res = await create(app, { name: 'nested', path: target });
expect(res.statusCode).toBe(200);
expect(res.json().data.case).toEqual({ name: 'nested', path: target });
expect(linked()).toEqual({ nested: target });
expect(existsSync(join(target, 'CLAUDE.md'))).toBe(true);
} finally {
safeRmHomeTree(join(CASES_DIR(), 'parent-dir'));
}
});
it('multi-user: a non-admin is refused (403) and nothing is created; an admin is allowed', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const user = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } });
@@ -104,6 +104,25 @@ describe('clampExternalCliBypassForOwner — multi-user mode', () => {
expect(out.grokConfig).toEqual({ alwaysApprove: false, model: 'grok-4.5' });
});
it("forces a non-granted owner's copilot --yolo off and keeps the model", async () => {
const out = await _clampExternalCliBypassForOwner(
'peon',
undefined,
undefined,
undefined,
undefined,
undefined,
undefined,
{ allowAll: true, model: 'claude-sonnet-5.5' }
);
expect(out.copilotConfig).toEqual({ allowAll: false, model: 'claude-sonnet-5.5' });
});
it('leaves copilot absent when nothing was sent (a bare copilot is already Manual Approval)', async () => {
const out = await _clampExternalCliBypassForOwner('peon', undefined, undefined, undefined, undefined, undefined);
expect(out.copilotConfig).toBeUndefined();
});
it("keeps a non-granted owner's codex reasoning effort while forcing bypass off", async () => {
// The clamp rewrites one field and must carry the rest; a clamp rebuilt from named
// fields would drop the effort here without a word.
+34 -1
View File
@@ -24,7 +24,7 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { MAX_WAIT_MS } from '../../src/config/agent-wait.js';
import { MAX_WAIT_MS, MIN_WAIT_MS } from '../../src/config/agent-wait.js';
// Distinct per file on purpose: the three wait suites share the process-wide
// `sessionWaits` singleton, so a common id let one file's leftover waiter be counted
@@ -315,6 +315,39 @@ describe('POST /api/sessions/:id/input with wait', () => {
expect(body.data.status).toBe('busy');
});
it('a duplicate on a codex pane showing a dialog answers blocked, as GET .../wait does', async () => {
// A screen-read dialog is a level, not an edge: the original delivery may have raised
// it before the caller's connection dropped, and the retry must not wait out its
// timeout for an edge that already passed.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)! as unknown as {
mode: string;
status: string;
screenBlocked?: boolean;
writeBuffer: string[];
};
session.mode = 'codex';
await send(app, { input: 'first', clientId: 'c-codex', seq: 1 });
session.status = 'idle';
session.screenBlocked = true;
const replay = await send(app, {
input: 'first',
clientId: 'c-codex',
seq: 1,
wait: 'blocked',
waitTimeout: MIN_WAIT_MS,
});
const body = replay.json();
expect(body.data.duplicate).toBe(true);
expect(body.data.wait).toMatchObject({ signal: 'blocked', immediate: true, timedOut: false });
// ...and only to a caller that asked for it: such a pane is idle too.
const idle = await send(app, { input: 'first', clientId: 'c-codex', seq: 1, wait: 'idle' });
expect(idle.json().data.wait).toMatchObject({ signal: 'idle', immediate: true });
});
it('gives the dedup seq back when a full waiter pool rejects the request', async () => {
// Otherwise the caller's retry is refused as a duplicate and the input vanishes.
const { app, ctx } = await harness();
@@ -14,9 +14,11 @@
* block without it lost the "You" badge on prompts
* - a pane that has produced no output reports hasContext: false rather than 404ing
* - Claude mode still takes the Claude path (regression guard)
* - copilot (a repainting full-screen TUI) is read off the pane SCREEN via
* capturePaneText, and falls back to the buffer only when there is no screen
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, createMockSession, type MockRouteContext } from '../mocks/index.js';
@@ -154,6 +156,65 @@ describe('GET /api/sessions/:id/last-response — external CLI panes', () => {
expect(body.data.hasContext).toBe(false);
});
describe('copilot reads the pane screen, not the stream', () => {
const rule = '─'.repeat(80);
const screenOf = (answer: string) =>
[
' ❯ what changed 11:30',
` ● ${answer}`,
' /tmp/work Session: 0 AIC used',
rule,
'❯',
rule,
' ← open sidebar · Interactive · Manual Approval · / commands · ? help',
].join('\n');
function stubCapture(result: string | null) {
const capturePaneText = vi.fn((_muxName: string) => result);
(harness.ctx.mux as unknown as { capturePaneText: typeof capturePaneText }).capturePaneText = capturePaneText;
return capturePaneText;
}
it('segments the captured screen of the session pane', async () => {
session.mode = 'copilot';
// What the stream looks like for a repainting TUI once escapes are stripped: rows fused
// together, so a reader of the buffer would return this run of text.
session.terminalBuffer = 'STREAM-ONLY fused rows that are not the answer';
const capture = stubCapture(screenOf('The screen answer.'));
const { res, body } = await lastResponse();
expect(res.statusCode).toBe(200);
expect(capture).toHaveBeenCalledWith(session.muxName);
expect(body.data.text).toBe('The screen answer.');
expect(body.data.text).not.toContain('STREAM-ONLY');
expect(body.data.hasContext).toBe(true);
});
it('falls back to the buffer when the pane has no screen to capture', async () => {
session.mode = 'copilot';
session.terminalBuffer = screenOf('The buffer answer.');
const capture = stubCapture(null);
const { res, body } = await lastResponse();
expect(res.statusCode).toBe(200);
expect(capture).toHaveBeenCalledWith(session.muxName);
expect(body.data.text).toBe('The buffer answer.');
});
it('never captures the screen for a mode that streams lines', async () => {
session.mode = 'opencode';
session.terminalBuffer = PANE;
const capture = stubCapture(screenOf('must not be read'));
const { body } = await lastResponse();
expect(capture).not.toHaveBeenCalled();
expect(body.data.text).toContain('Documented the helper in docs/retry.md');
});
});
it('leaves Claude mode on the Claude transcript path', async () => {
// Regression guard: a claude pane must NOT be segmented off its terminal
// buffer, or a real transcript would be shadowed by scraped pane text.
+149 -11
View File
@@ -17,7 +17,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import fastifyMultipart from '@fastify/multipart';
import { dirname, join } from 'node:path';
import { dirname, extname, join } from 'node:path';
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { registryFilePath, reloadCliRegistry } from '../../src/config/cli-registry/registry.js';
import fs, { mkdtemp, rm, mkdir, writeFile, readFile, readdir, symlink } from 'node:fs/promises';
@@ -352,7 +352,7 @@ describe('session-routes', () => {
});
const jpeg = Buffer.from('ffd8ffe000104a46494600010100', 'hex');
const upload = (name: string, mime: string, bytes: Buffer) =>
const uploadImage = (name: string, mime: string, bytes: Buffer) =>
harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
@@ -368,14 +368,14 @@ describe('session-routes', () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-uploads-'));
harness.ctx._session.workingDir = workDir;
try {
const first = await upload('shot.jpg', 'image/jpeg', jpeg);
const first = await uploadImage('shot.jpg', 'image/jpeg', jpeg);
expect(first.statusCode).toBe(200);
expect(JSON.parse(first.body).data.path).toMatch(/\/\.codeman-uploads\/paste-\d+-[a-f0-9]{8}\.jpg$/);
expect(await readdir(workDir)).toEqual(['.codeman-uploads']);
expect(await readFile(join(workDir, '.codeman-uploads', '.gitignore'), 'utf8')).toBe('*\n');
await writeFile(join(workDir, '.codeman-uploads', '.gitignore'), 'theirs\n');
expect((await upload('shot.jpg', 'image/jpeg', jpeg)).statusCode).toBe(200);
expect((await uploadImage('shot.jpg', 'image/jpeg', jpeg)).statusCode).toBe(200);
expect(await readFile(join(workDir, '.codeman-uploads', '.gitignore'), 'utf8')).toBe('theirs\n');
} finally {
await rm(workDir, { recursive: true });
@@ -392,9 +392,9 @@ describe('session-routes', () => {
throw Object.assign(new Error('no space left on device'), { code: 'ENOSPC' });
});
try {
expect((await upload('shot.jpg', 'image/jpeg', jpeg)).statusCode).toBe(500);
expect((await uploadImage('shot.jpg', 'image/jpeg', jpeg)).statusCode).toBe(500);
expect(existsSync(ignoreFile)).toBe(false);
expect((await upload('shot.jpg', 'image/jpeg', jpeg)).statusCode).toBe(200);
expect((await uploadImage('shot.jpg', 'image/jpeg', jpeg)).statusCode).toBe(200);
expect(await readFile(ignoreFile, 'utf8')).toBe('*\n');
} finally {
spy.mockRestore();
@@ -408,7 +408,7 @@ describe('session-routes', () => {
harness.ctx._session.workingDir = workDir;
await symlink(elsewhere, join(workDir, '.codeman-uploads'));
try {
const res = await upload('shot.jpg', 'image/jpeg', jpeg);
const res = await uploadImage('shot.jpg', 'image/jpeg', jpeg);
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).error).toBe('.codeman-uploads is not a regular directory');
expect(await readdir(elsewhere)).toEqual([]);
@@ -424,7 +424,7 @@ describe('session-routes', () => {
// A remote session's workingDir is the REMOTE path: a file written here is unreadable there.
(harness.ctx._session as unknown as { remote: unknown }).remote = { hostId: 'gpu-box' };
try {
const res = await upload('shot.jpg', 'image/jpeg', jpeg);
const res = await uploadImage('shot.jpg', 'image/jpeg', jpeg);
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/remote/);
expect(await readdir(workDir)).toEqual([]);
@@ -591,9 +591,9 @@ describe('session-routes', () => {
headers: movHeaders,
payload: imageUploadBody(boundary, 'IMG_0001.MOV', 'video/quicktime', qtMov),
});
// The image path falls back to .png for an unknown type and fails its magic check.
expect(res.statusCode).toBe(415);
expect(JSON.parse(res.body).error).toMatch(/do not match declared type/);
// Named as a type the image path does not take: once the sniff fails, refused by name.
expect(res.statusCode).toBe(400);
expect(JSON.parse(res.body).error).toMatch(/Unsupported file type: \.mov/);
});
it('refuses an image declared under ?kind=video, so nothing rides the video cap through memory', async () => {
@@ -757,6 +757,144 @@ describe('session-routes', () => {
await rm(workDir, { recursive: true });
}
});
// Documents: admitted by the name's extension, judged on the bytes, buffered
// like an image, saved with a sanitized stem of the original name.
const pdf = Buffer.concat([Buffer.from('%PDF-1.4\n%'), Buffer.alloc(16)]);
const zip = Buffer.concat([Buffer.from('504b0304140000000800', 'hex'), Buffer.alloc(16)]);
const upload = (name: string, mime: string, bytes: Buffer) =>
harness.app.inject({
method: 'POST',
url: `/api/sessions/${harness.ctx._sessionId}/paste-image`,
headers: movHeaders,
payload: imageUploadBody(boundary, name, mime, bytes),
});
it('accepts a PDF, an OOXML document and a text file into .codeman-uploads with the name kept', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-doc-'));
harness.ctx._session.workingDir = workDir;
for (const [name, mime, bytes, saved] of [
['Q3 report.pdf', 'application/pdf', pdf, /\/\.codeman-uploads\/paste-\d+-[a-f0-9]{8}-Q3-report\.pdf$/],
['notes.docx', 'application/octet-stream', zip, /-notes\.docx$/], // no usable MIME: the name decides
['todo.md', 'text/markdown', Buffer.from('# héllo\n- [ ] one\n'), /-todo\.md$/],
['data.csv', 'application/octet-stream', Buffer.from('a,b\n1,2\n'), /-data\.csv$/],
['archive.zip', 'application/zip', zip, /-archive\.zip$/],
] as const) {
const res = await upload(name, mime, bytes);
expect(res.statusCode, name).toBe(200);
const body = JSON.parse(res.body);
expect(body.data.path, name).toMatch(saved);
expect(await readFile(body.data.path), name).toEqual(bytes);
}
// The workspace-side convention: the dir ignores itself, the old dir is not created.
expect(await readFile(join(workDir, '.codeman-uploads', '.gitignore'), 'utf8')).toBe('*\n');
expect(await readdir(workDir)).toEqual(['.codeman-uploads']);
await rm(workDir, { recursive: true });
});
it('keeps only [A-Za-z0-9._-] of the original stem, capped at 40 chars', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-doc-'));
harness.ctx._session.workingDir = workDir;
const res = await upload('../..\\ (final) ' + 'x'.repeat(60) + '.v2.PDF', 'application/pdf', pdf);
expect(res.statusCode).toBe(200);
const { filename } = JSON.parse(res.body).data;
expect(filename).toMatch(/^paste-\d+-[a-f0-9]{8}-final-x{34}\.pdf$/);
await rm(workDir, { recursive: true });
});
it('refuses a script or an HTML page by name, with a message that says so', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-doc-'));
harness.ctx._session.workingDir = workDir;
for (const [name, mime] of [
['run.sh', 'text/x-shellscript'],
['page.html', 'text/html'],
['app.py', 'text/x-python'],
] as const) {
const res = await upload(name, mime, Buffer.from('#!/bin/sh\necho hi\n'));
expect(res.statusCode, name).toBe(400);
expect(JSON.parse(res.body).error, name).toMatch(/Unsupported file type/);
}
expect(await readdir(workDir)).toEqual([]);
await rm(workDir, { recursive: true });
});
it('keeps the image path for an image MIME under a document name and for HEIC under a generic name', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-doc-'));
harness.ctx._session.workingDir = workDir;
heicConvert.mockClear();
const gif = Buffer.concat([Buffer.from('GIF89a'), Buffer.alloc(10)]);
const heic = Buffer.from('00000034667479706865696300000000', 'hex');
for (const [name, mime, bytes, ext] of [
['animation.txt', 'image/gif', gif, '.gif'], // the MIME type wins over the name, as on the client
['camera.pdf', 'image/heic', heic, '.jpg'],
['camera.bin', 'application/octet-stream', heic, '.jpg'], // sniffed, as it was before documents
] as const) {
const res = await upload(name, mime, bytes);
expect(res.statusCode, name).toBe(200);
expect(JSON.parse(res.body).data.filename.endsWith(ext), name).toBe(true);
}
expect(heicConvert).toHaveBeenCalledTimes(2);
await rm(workDir, { recursive: true });
});
it('logs why a text upload was refused without printing its bytes', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const secret = Buffer.from('token=SECRET\u0000');
expect((await upload('notes.txt', 'text/plain', secret)).statusCode).toBe(415);
const line = warn.mock.calls.map((c) => String(c[0])).find((l) => l.includes('declaredExt=.txt'));
expect(line).toMatch(/reason=nul-byte/);
expect(line).not.toContain(secret.toString('hex'));
expect(line).not.toContain('SECRET');
} finally {
warn.mockRestore();
}
});
it('refuses document bytes that do not match the declared type', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-doc-'));
harness.ctx._session.workingDir = workDir;
const png = Buffer.concat([Buffer.from('89504e470d0a1a0a', 'hex'), Buffer.alloc(24)]);
for (const [name, bytes] of [
['fake.pdf', png], // PNG bytes under a PDF name
['fake.docx', pdf], // a PDF under an OOXML name
] as const) {
const res = await upload(name, 'application/octet-stream', bytes);
expect(res.statusCode, name).toBe(415);
expect(JSON.parse(res.body).error, name).toBe(`File bytes do not match declared type ${extname(name)}`);
}
expect(await readdir(workDir)).toEqual([]);
await rm(workDir, { recursive: true });
});
it('refuses non-UTF-8 text by naming the encoding, UTF-16 when a BOM says so', async () => {
const workDir = await mkdtemp(join(tmpdir(), 'codeman-doc-'));
harness.ctx._session.workingDir = workDir;
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const notUtf8 = (ext: string) => `This ${ext} file is not UTF-8 text; save it as UTF-8 and attach it again`;
const utf16 = (ext: string) => `This ${ext} file is UTF-16 text, not UTF-8; save it as UTF-8 and attach it again`;
try {
for (const [name, bytes, error, reason] of [
// Excel "Unicode Text" / Notepad "Unicode": UTF-16LE with its BOM.
['export.txt', Buffer.from('\ufeffa\tb\n', 'utf16le'), utf16('.txt'), 'utf16-bom'],
['export.csv', Buffer.from([0xfe, 0xff, 0x00, 0x61, 0x00, 0x0a]), utf16('.csv'), 'utf16-bom'], // UTF-16BE
['binary.txt', Buffer.from('hello\u0000world'), notUtf8('.txt'), 'nul-byte'], // NUL, no BOM
['latin1.csv', Buffer.from('a,René\n', 'latin1'), notUtf8('.csv'), 'not-utf8'], // Excel's default CSV
] as const) {
warn.mockClear();
const res = await upload(name, 'application/octet-stream', bytes);
expect(res.statusCode, name).toBe(415);
expect(JSON.parse(res.body).error, name).toBe(error);
expect(String(warn.mock.calls[0]?.[0]), name).toMatch(
new RegExp(`declaredExt=\\S+ size=\\d+ reason=${reason}$`)
);
}
} finally {
warn.mockRestore();
}
expect(await readdir(workDir)).toEqual([]);
await rm(workDir, { recursive: true });
});
});
// ========== GET /api/sessions ==========
+75
View File
@@ -34,6 +34,7 @@ import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
import { sessionWaits } from '../../src/web/session-wait-registry.js';
import { approvalInbox } from '../../src/web/approval-inbox.js';
import { MAX_WAIT_MS, MIN_WAIT_MS, MAX_WAITERS_TOTAL, MAX_WAITERS_PER_OWNER } from '../../src/config/agent-wait.js';
// Distinct per file on purpose: the three wait suites share the process-wide
@@ -463,6 +464,80 @@ describe('GET /api/sessions/:id/wait: modes that install no hooks', () => {
});
});
describe('GET /api/sessions/:id/wait: blocked read off a codex screen', () => {
/** The listener deps the screen-blocked raise reaches; nothing else matters here. */
function stubDeps() {
return {
broadcast: vi.fn(),
broadcastSessionStateDebounced: vi.fn(),
sendPushNotifications: vi.fn(),
getRunSummaryTracker: vi.fn(() => undefined),
};
}
afterEach(() => {
approvalInbox.resolveForSession(SESSION_ID, 'session_ended');
});
it('accepts until=blocked for codex, whose dialogs are read off its screen', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'codex';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=blocked&timeout=1000` });
expect(res.statusCode).toBe(200);
expect(res.json().data.wait.until).toEqual(['blocked']);
});
it('answers blocked at once for a dialog already on screen, and only to a caller who asked', async () => {
// A hook-raised blocked is an edge; this one is a level the server is still reading,
// so a caller who asks after the dialog appeared does not wait out its timeout.
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)! as unknown as {
mode: string;
status: string;
screenBlocked?: boolean;
};
session.mode = 'codex';
session.status = 'idle';
session.screenBlocked = true;
const asked = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle,blocked` });
expect(asked.json().data.wait).toMatchObject({ signal: 'blocked', immediate: true });
// Such a pane is idle too, and a caller that asked for idle alone keeps getting idle.
const idle = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=idle` });
expect(idle.json().data.wait).toMatchObject({ signal: 'idle', immediate: true });
});
it('wakes a parked until=blocked waiter when the session raises a screen-read dialog', async () => {
const { app, ctx } = await harness();
const session = ctx.sessions.get(SESSION_ID)!;
session.mode = 'codex';
attachSessionListeners(session as never, createSessionListeners(session as never, stubDeps() as never));
const pending = app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=blocked&timeout=600000` });
await new Promise((resolve) => setTimeout(resolve, 20));
expect(sessionWaits.signalWaiterCount(SESSION_ID)).toBe(1);
session.emit('blockedChanged', true, '› 1. Yes, proceed (y)\n Press enter to confirm or esc to cancel');
const body = (await pending).json();
expect(body.data.wait).toMatchObject({ signal: 'blocked', immediate: false, timedOut: false });
expect(approvalInbox.getForSession(SESSION_ID)?.answerInTerminal).toBe(true);
// ...and the dialog leaving the screen resolves the item it opened.
session.emit('blockedChanged', false, null);
expect(approvalInbox.getForSession(SESSION_ID)).toBeUndefined();
});
it('still refuses until=stop for codex: nothing on a screen says a turn ended', async () => {
const { app, ctx } = await harness();
ctx.sessions.get(SESSION_ID)!.mode = 'codex';
const res = await app.inject({ method: 'GET', url: `/api/sessions/${SESSION_ID}/wait?until=stop,blocked` });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('Signal(s) stop never fire for codex');
});
});
describe('GET /api/sessions/:id/wait: liveness beats the reported status', () => {
it('answers exit for a session whose PTY is gone, not the idle its status claims', async () => {
// Session parks a dead PTY at status 'idle' and the object survives in the map,
@@ -154,6 +154,20 @@ describe('POST /api/status-telemetry', () => {
expect(events.filter((e) => e === SessionPromptCache)).toHaveLength(1);
});
it('keeps the first 20 miss causes instead of refusing a post that carries more', async () => {
const causes = Array.from({ length: 25 }, (_, i) => `cause_${i}`);
const res = await post({
sessionId: SID,
data: { ...REAL, prompt_cache: { warm: false, ttl: '1h', last_miss_cause: { causes } } },
});
// Accepted, not 400: an over-long causes array must not blank the whole statusline.
expect(res.statusCode).toBe(200);
const session = h.ctx.sessions.get(SID) as unknown as { promptCache: { lastMissCauses?: string[] } | null };
expect(session.promptCache?.lastMissCauses).toHaveLength(20);
expect(session.promptCache?.lastMissCauses?.[0]).toBe('cause_0');
expect(session.promptCache?.lastMissCauses?.[19]).toBe('cause_19');
});
it('rejects a malformed body (missing sessionId) with 400', async () => {
const res = await post({ data: REAL });
expect(res.statusCode).toBe(400);
@@ -212,6 +226,20 @@ describe('POST /api/status-telemetry: the session model (displayModel)', () => {
expect(session.toState().displayModel).toBeUndefined();
});
it('stores no prompt-cache from a CLI with no statusline exporter, but still broadcasts plan usage', async () => {
const session = realSession('codex', 'codex-cache');
const res = await post({
sessionId: 'codex-cache',
data: { ...REAL, prompt_cache: { warm: true, ttl: '1h', expires_at: Math.floor(Date.now() / 1000) + 3600 } },
});
expect(res.statusCode).toBe(200);
// Prompt cache is gated like the model report; the rest of the post still lands.
expect(session.toState().promptCache).toBeUndefined();
const events = h.ctx.broadcast.mock.calls.map(([name]) => name);
expect(events).not.toContain(SessionPromptCache);
expect(events).toContain(SessionStatusTelemetry);
});
it('broadcasts the STORED prompt-cache object, so a compaction stamp reaches the live readout', async () => {
const session = realSession('claude', 'claude-compact');
const expiresAtS = Math.floor(Date.now() / 1000) + 3600;
@@ -0,0 +1,292 @@
/**
* @fileoverview Multi-user mode: server-wide settings writes are admin-only.
*
* `~/.codeman/settings.json` is ONE file for the whole server (feature switches, the
* plan-usage collection switch, run mode, respawn presets, the tunnel). Before this,
* any signed-in NON-ADMIN could rewrite it through PUT /api/settings, although
* docs/multi-user-plan.md makes settings writes admin-only. Pinned here:
* - a non-admin PUT /api/settings is 403 FORBIDDEN before anything is parsed (an
* invalid body is still 403, not 400), written, toggled or started (watchers, the
* tunnel, the tmux history limit);
* - the same gate on the other server-wide writes: PUT /api/execution/model-config,
* PUT /api/config and PUT /api/cases/order;
* - an admin, and single-user mode, write as before; GET /api/settings stays readable,
* but a non-admin reads it without the admin's Deepgram key and case names
* (settingsForNonAdmin).
*
* Uses app.inject(), so no real HTTP port is needed. Port: N/A.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes, settingsForNonAdmin } from '../../src/web/routes/system-routes.js';
import { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
const { STORED, subagentWatcher, imageWatcher, workflowRunWatcher } = vi.hoisted(() => {
const makeWatcher = (running: boolean) => {
let isOn = running;
return {
isRunning: vi.fn(() => isOn),
start: vi.fn(() => {
isOn = true;
}),
stop: vi.fn(() => {
isOn = false;
}),
getStats: vi.fn(() => ({})),
watchSession: vi.fn(),
getRecentRunSummaries: vi.fn(() => []),
__reset: () => {
isOn = running;
},
};
};
return {
// The persisted settings.json these tests start from.
STORED: { subagentTrackingEnabled: false, imageWatcherEnabled: true, cliManagementEnabled: false },
subagentWatcher: makeWatcher(false),
imageWatcher: makeWatcher(true),
workflowRunWatcher: makeWatcher(false),
};
});
vi.mock('node:fs/promises', () => ({
default: {
readFile: vi.fn(async () => JSON.stringify(STORED)),
writeFile: vi.fn(async () => undefined),
},
}));
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:fs')>();
return { ...actual, existsSync: vi.fn(() => true), mkdirSync: vi.fn(), readdirSync: vi.fn(() => []) };
});
vi.mock('../../src/subagent-watcher.js', () => ({ subagentWatcher }));
vi.mock('../../src/image-watcher.js', () => ({ imageWatcher }));
vi.mock('../../src/workflow-run-watcher.js', () => ({ workflowRunWatcher }));
import fs from 'node:fs/promises';
const writeFile = vi.mocked(fs.writeFile);
const readFile = vi.mocked(fs.readFile);
const NON_ADMIN = { username: 'bob', role: 'user' } as const;
const ADMIN = { username: 'root', role: 'admin' } as const;
function makeTunnelManager() {
return {
start: vi.fn(),
stop: vi.fn(),
isRunning: vi.fn(() => false),
getUrl: vi.fn(() => null),
getStatus: vi.fn(() => ({ running: false })),
};
}
/** A body that would touch everything PUT /api/settings can act on. */
const EVERYTHING = {
tunnelEnabled: true,
subagentTrackingEnabled: true,
imageWatcherEnabled: false,
showUltracodeAgents: true,
tmuxHistoryLimit: 50000,
cliManagementEnabled: true,
runMode: 'shell',
};
describe('server-wide settings writes are admin-only in multi-user mode', () => {
const savedMultiUser = process.env.CODEMAN_MULTIUSER;
let harness: RouteTestHarness | undefined;
let tunnel: ReturnType<typeof makeTunnelManager>;
async function as(user: typeof NON_ADMIN | typeof ADMIN | null, register = registerSystemRoutes) {
if (user) process.env.CODEMAN_MULTIUSER = '1';
else delete process.env.CODEMAN_MULTIUSER;
harness = await createRouteTestHarness(register, user ? { authUser: user } : undefined);
tunnel = makeTunnelManager();
(harness.ctx as unknown as { tunnelManager: unknown }).tunnelManager = tunnel;
return harness;
}
beforeEach(() => {
vi.clearAllMocks();
for (const w of [subagentWatcher, imageWatcher, workflowRunWatcher]) w.__reset();
});
afterEach(async () => {
await harness?.app.close();
harness = undefined;
if (savedMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = savedMultiUser;
});
describe('PUT /api/settings', () => {
it('refuses a non-admin with 403 FORBIDDEN, and nothing is written, toggled or started', async () => {
const { app, ctx } = await as(NON_ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/settings', payload: EVERYTHING });
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('FORBIDDEN');
expect(writeFile).not.toHaveBeenCalled();
for (const w of [subagentWatcher, imageWatcher, workflowRunWatcher]) {
expect(w.start).not.toHaveBeenCalled();
expect(w.stop).not.toHaveBeenCalled();
}
expect(tunnel.start).not.toHaveBeenCalled();
expect(ctx.mux.setHistoryLimit).not.toHaveBeenCalled();
});
it('refuses a non-admin before the body is even parsed (an invalid body is 403, not 400)', async () => {
const { app } = await as(NON_ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/settings', payload: { notASetting: 1 } });
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).errorCode).toBe('FORBIDDEN');
expect(writeFile).not.toHaveBeenCalled();
});
it('still lets a non-admin READ the settings', async () => {
const { app } = await as(NON_ADMIN);
const res = await app.inject({ method: 'GET', url: '/api/settings' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual(STORED);
});
for (const [label, user] of [
['an admin in multi-user mode', ADMIN],
['single-user mode', null],
] as const) {
it(`writes, reconciles watchers and starts the tunnel for ${label}`, async () => {
const { app, ctx } = await as(user);
// Single-user mode with no CODEMAN_PASSWORD needs the COD-55 tunnel acknowledgement
// (multi-user mode counts as authenticated); that guard is not what this file tests.
const payload = user ? EVERYTHING : { ...EVERYTHING, acknowledgeUnauthTunnel: true };
const res = await app.inject({ method: 'PUT', url: '/api/settings', payload });
expect(res.statusCode).toBe(200);
expect(writeFile).toHaveBeenCalledTimes(1);
const written = JSON.parse(String(writeFile.mock.calls[0][1]));
expect(written).toMatchObject({ ...STORED, ...EVERYTHING });
expect(subagentWatcher.start).toHaveBeenCalledTimes(1);
expect(imageWatcher.stop).toHaveBeenCalledTimes(1);
expect(workflowRunWatcher.start).toHaveBeenCalledTimes(1);
expect(tunnel.start).toHaveBeenCalledTimes(1);
expect(ctx.mux.setHistoryLimit).toHaveBeenCalledTimes(1);
});
}
});
describe("GET /api/settings: what is the admin's stays the admin's", () => {
// An admin's App Settings save stores the Deepgram key (voiceSettings.apiKey) here, and
// the admin's own cases are named in caseOrder / lastUsedCase.
const WITH_SECRETS = {
autoNameSessions: true,
runMode: 'shell',
caseOrder: ['rootcase', 'secret-project'],
lastUsedCase: 'secret-project',
voiceSettings: { provider: 'deepgram', apiKey: 'dg-SECRET-probe-123', language: 'en' },
};
it('gives a non-admin the settings without the Deepgram key and the case names', async () => {
const { app } = await as(NON_ADMIN);
readFile.mockResolvedValueOnce(JSON.stringify(WITH_SECRETS));
const res = await app.inject({ method: 'GET', url: '/api/settings' });
expect(res.statusCode).toBe(200);
expect(res.body).not.toContain('dg-SECRET-probe-123');
expect(res.body).not.toContain('secret-project');
expect(JSON.parse(res.body)).toEqual({
autoNameSessions: true,
runMode: 'shell',
voiceSettings: { provider: 'deepgram', language: 'en' },
});
});
for (const [label, user] of [
['an admin in multi-user mode', ADMIN],
['single-user mode', null],
] as const) {
it(`gives ${label} the file as it is`, async () => {
const { app } = await as(user);
readFile.mockResolvedValueOnce(JSON.stringify(WITH_SECRETS));
const res = await app.inject({ method: 'GET', url: '/api/settings' });
expect(res.statusCode).toBe(200);
expect(JSON.parse(res.body)).toEqual(WITH_SECRETS);
});
}
it('settingsForNonAdmin leaves its input alone and passes a non-object through', () => {
const input = structuredClone(WITH_SECRETS);
settingsForNonAdmin(input);
expect(input).toEqual(WITH_SECRETS);
expect(settingsForNonAdmin([1, 2])).toEqual([1, 2]);
expect(settingsForNonAdmin(null)).toBe(null);
expect(settingsForNonAdmin({ voiceSettings: { language: 'de' } })).toEqual({ voiceSettings: { language: 'de' } });
});
});
describe('PUT /api/execution/model-config', () => {
const config = { defaultModel: 'opus', agentTypeOverrides: { explore: 'sonnet' } };
it('refuses a non-admin with 403 FORBIDDEN and writes nothing', async () => {
const { app } = await as(NON_ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/execution/model-config', payload: config });
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).errorCode).toBe('FORBIDDEN');
expect(writeFile).not.toHaveBeenCalled();
});
it('writes it for an admin', async () => {
const { app } = await as(ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/execution/model-config', payload: config });
expect(res.statusCode).toBe(200);
expect(JSON.parse(String(writeFile.mock.calls[0][1])).modelConfig).toEqual(config);
});
it('stays readable by a non-admin', async () => {
const { app } = await as(NON_ADMIN);
const res = await app.inject({ method: 'GET', url: '/api/execution/model-config' });
expect(res.statusCode).toBe(200);
});
});
describe('PUT /api/config', () => {
it('refuses a non-admin with 403 FORBIDDEN and changes nothing', async () => {
const { app, ctx } = await as(NON_ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/config', payload: { maxConcurrentSessions: 50 } });
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).errorCode).toBe('FORBIDDEN');
expect(ctx.store.setConfig).not.toHaveBeenCalled();
});
it('updates it for an admin', async () => {
const { app, ctx } = await as(ADMIN);
const res = await app.inject({ method: 'PUT', url: '/api/config', payload: { maxConcurrentSessions: 10 } });
expect(res.statusCode).toBe(200);
expect(ctx.store.setConfig).toHaveBeenCalledTimes(1);
});
});
describe('PUT /api/cases/order (the one global caseOrder in settings.json)', () => {
it('refuses a non-admin with 403 FORBIDDEN and writes nothing', async () => {
const { app, ctx } = await as(NON_ADMIN, registerCaseRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/cases/order', payload: { order: ['b', 'a'] } });
expect(res.statusCode).toBe(403);
expect(JSON.parse(res.body).errorCode).toBe('FORBIDDEN');
expect(writeFile).not.toHaveBeenCalled();
expect(ctx.broadcast).not.toHaveBeenCalled();
});
for (const [label, user] of [
['an admin in multi-user mode', ADMIN],
['single-user mode', null],
] as const) {
it(`writes it for ${label}`, async () => {
const { app } = await as(user, registerCaseRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/cases/order', payload: { order: ['b', 'a'] } });
expect(res.statusCode).toBe(200);
expect(JSON.parse(String(writeFile.mock.calls[0][1])).caseOrder).toEqual(['b', 'a']);
});
}
});
});
@@ -0,0 +1,215 @@
/**
* @fileoverview Multi-user mode: the server-wide system operations are admin-only.
*
* docs/multi-user-plan.md lists the system operations (self-update, the tunnel,
* span-displays) as admin-only, but these routes had no role check, so any signed-in
* NON-ADMIN could run them. Pinned here:
* - POST /api/system/update, POST /api/system/span-displays, POST /api/tunnel/qr/regenerate
* and POST /api/cleanup-state answer 403 FORBIDDEN to a non-admin, and nothing runs;
* - POST /api/auth/revoke: a non-admin revokes only their OWN login sessions (the bare
* form used to clear every session on the server, every admin's included), and a named
* token only when it is one of theirs;
* - GET /api/tunnel/info lists only the caller's own logins for a non-admin;
* - an admin, and single-user mode, behave as before.
*
* Uses app.inject(), so no real HTTP port is needed. Port: N/A.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
import { StaleExpirationMap } from '../../src/utils/stale-expiration-map.js';
import type { AuthSessionRecord } from '../../src/web/ports/auth-port.js';
const { startUpdate } = vi.hoisted(() => ({
startUpdate: vi.fn(async () => ({ ok: true, updateId: 'u1', toTag: 'v9.9.9', toVersion: '9.9.9' })),
}));
vi.mock('../../src/web/self-update.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/web/self-update.js')>();
return { ...actual, startUpdate };
});
const NON_ADMIN = { username: 'bob', role: 'user' } as const;
const ADMIN = { username: 'root', role: 'admin' } as const;
function makeTunnelManager() {
return {
regenerateQrToken: vi.fn(),
isRunning: vi.fn(() => true),
getUrl: vi.fn(() => 'https://example.trycloudflare.com'),
getStatus: vi.fn(() => ({ running: true, url: 'https://example.trycloudflare.com' })),
};
}
function record(username: string | undefined, ip: string): AuthSessionRecord {
return {
ip,
ua: 'test-agent',
createdAt: 1,
method: 'basic',
username,
role: username === 'root' ? 'admin' : 'user',
};
}
describe('server-wide system operations are admin-only in multi-user mode', () => {
const savedMultiUser = process.env.CODEMAN_MULTIUSER;
let harness: RouteTestHarness | undefined;
let tunnel: ReturnType<typeof makeTunnelManager>;
let sessions: StaleExpirationMap<string, AuthSessionRecord>;
async function as(user: typeof NON_ADMIN | typeof ADMIN | null) {
if (user) process.env.CODEMAN_MULTIUSER = '1';
else delete process.env.CODEMAN_MULTIUSER;
harness = await createRouteTestHarness(registerSystemRoutes, user ? { authUser: user } : undefined);
tunnel = makeTunnelManager();
sessions = new StaleExpirationMap<string, AuthSessionRecord>({ ttlMs: 60_000 });
// Two logins of the non-admin, one of an admin, one single-user (no identity).
sessions.set('bob-laptop', record('bob', '10.0.0.2'));
sessions.set('bob-phone', record('bob', '10.0.0.3'));
sessions.set('root-desk', record('root', '10.0.0.1'));
Object.assign(harness.ctx as unknown as Record<string, unknown>, { tunnelManager: tunnel, authSessions: sessions });
return harness;
}
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(async () => {
await harness?.app.close();
harness = undefined;
sessions?.dispose();
if (savedMultiUser === undefined) delete process.env.CODEMAN_MULTIUSER;
else process.env.CODEMAN_MULTIUSER = savedMultiUser;
});
const forbidden = (res: { statusCode: number; body: string }) => {
expect(res.statusCode).toBe(403);
const body = JSON.parse(res.body);
expect(body.success).toBe(false);
expect(body.errorCode).toBe('FORBIDDEN');
};
describe('POST /api/system/update', () => {
it('refuses a non-admin, and no update starts', async () => {
const { app } = await as(NON_ADMIN);
forbidden(await app.inject({ method: 'POST', url: '/api/system/update', payload: {} }));
expect(startUpdate).not.toHaveBeenCalled();
});
for (const [label, user] of [
['an admin in multi-user mode', ADMIN],
['single-user mode', null],
] as const) {
it(`starts it for ${label}`, async () => {
const { app } = await as(user);
const res = await app.inject({ method: 'POST', url: '/api/system/update', payload: {} });
expect(res.statusCode).toBe(200);
expect(startUpdate).toHaveBeenCalledTimes(1);
});
}
});
describe('POST /api/system/span-displays', () => {
it('refuses a non-admin before anything else', async () => {
const { app } = await as(NON_ADMIN);
forbidden(await app.inject({ method: 'POST', url: '/api/system/span-displays' }));
});
it('lets an admin through the gate (off macOS the route then answers its own 400)', async () => {
const { app } = await as(ADMIN);
const res = await app.inject({ method: 'POST', url: '/api/system/span-displays' });
if (process.platform === 'darwin') expect(res.statusCode).not.toBe(403);
else expect(res.statusCode).toBe(400);
});
});
describe('POST /api/tunnel/qr/regenerate', () => {
it("refuses a non-admin, and nobody's QR codes are cleared", async () => {
const { app } = await as(NON_ADMIN);
forbidden(await app.inject({ method: 'POST', url: '/api/tunnel/qr/regenerate' }));
expect(tunnel.regenerateQrToken).not.toHaveBeenCalled();
});
for (const [label, user] of [
['an admin in multi-user mode', ADMIN],
['single-user mode', null],
] as const) {
it(`regenerates for ${label}`, async () => {
const { app } = await as(user);
const res = await app.inject({ method: 'POST', url: '/api/tunnel/qr/regenerate' });
expect(res.statusCode).toBe(200);
expect(tunnel.regenerateQrToken).toHaveBeenCalledTimes(1);
});
}
});
describe('POST /api/cleanup-state', () => {
it("refuses a non-admin, and no user's session records are touched", async () => {
const { app, ctx } = await as(NON_ADMIN);
forbidden(await app.inject({ method: 'POST', url: '/api/cleanup-state' }));
expect(ctx.tabLayouts.runStaleSessionCleanup).not.toHaveBeenCalled();
expect(ctx.store.cleanupSessionsByIds).not.toHaveBeenCalled();
});
it('runs for an admin', async () => {
const { app, ctx } = await as(ADMIN);
const res = await app.inject({ method: 'POST', url: '/api/cleanup-state' });
expect(res.statusCode).toBe(200);
expect(ctx.tabLayouts.runStaleSessionCleanup).toHaveBeenCalledTimes(1);
});
});
describe('POST /api/auth/revoke', () => {
const tokens = () => [...sessions.keys()].sort();
it('the bare form signs a non-admin out of their own sessions only, never anyone else', async () => {
const { app } = await as(NON_ADMIN);
const res = await app.inject({ method: 'POST', url: '/api/auth/revoke', payload: {} });
expect(res.statusCode).toBe(200);
expect(tokens()).toEqual(['root-desk']);
});
it("a non-admin's named token is revoked only when it is theirs", async () => {
const { app } = await as(NON_ADMIN);
await app.inject({ method: 'POST', url: '/api/auth/revoke', payload: { sessionToken: 'root-desk' } });
expect(tokens()).toEqual(['bob-laptop', 'bob-phone', 'root-desk']);
await app.inject({ method: 'POST', url: '/api/auth/revoke', payload: { sessionToken: 'bob-phone' } });
expect(tokens()).toEqual(['bob-laptop', 'root-desk']);
});
for (const [label, user] of [
['an admin in multi-user mode', ADMIN],
['single-user mode', null],
] as const) {
it(`the bare form still clears every session for ${label}, a named token any session`, async () => {
const { app } = await as(user);
await app.inject({ method: 'POST', url: '/api/auth/revoke', payload: { sessionToken: 'bob-phone' } });
expect(tokens()).toEqual(['bob-laptop', 'root-desk']);
const res = await app.inject({ method: 'POST', url: '/api/auth/revoke', payload: {} });
expect(res.statusCode).toBe(200);
expect(tokens()).toEqual([]);
});
}
});
describe('GET /api/tunnel/info', () => {
const ips = (res: { body: string }) =>
(JSON.parse(res.body).authSessions as { ip: string }[]).map((s) => s.ip).sort();
it("lists only the non-admin's own logins", async () => {
const { app } = await as(NON_ADMIN);
const res = await app.inject({ method: 'GET', url: '/api/tunnel/info' });
expect(res.statusCode).toBe(200);
expect(ips(res)).toEqual(['10.0.0.2', '10.0.0.3']);
});
it('lists every login for an admin', async () => {
const { app } = await as(ADMIN);
const res = await app.inject({ method: 'GET', url: '/api/tunnel/info' });
expect(ips(res)).toEqual(['10.0.0.1', '10.0.0.2', '10.0.0.3']);
});
});
});
+440
View File
@@ -0,0 +1,440 @@
/**
* @fileoverview The worktree routes (`/api/cases/:name/worktrees`) and quick-start's `worktree`
* field, against a REAL temp git repository in the per-file temp HOME's case space
* (`test/setup.ts`). Covers the happy path end to end (create, list with live sessions, start a
* session in it, remove, branch delete) and every refusal: Docker and remote cases (git never
* spawned for Docker), a folder that is not a repository, bad names, a symlinked `.claude`, a
* dirty tree, a live session inside, and multi-user ownership. Port: N/A (app.inject()).
*/
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readFileSync, realpathSync, symlinkSync, writeFileSync } from 'node:fs';
import { mkdir, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import Fastify, { type FastifyInstance } from 'fastify';
import fastifyCookie from '@fastify/cookie';
import { createMockRouteContext, safeRmHomeTree, type MockRouteContext } from '../mocks/index.js';
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js';
import { registerWorktreeRoutes } from '../../src/web/routes/worktree-routes.js';
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { CASES_DIR } from '../../src/web/route-helpers.js';
import { getDataDir } from '../../src/config/instance.js';
import { userCasesDir } from '../../src/config/multiuser.js';
import { Session } from '../../src/session.js';
import { applyAgentSkill } from '../../src/hooks-config.js';
import type { WorktreeGitRunner } from '../../src/git-worktrees.js';
const ENV = {
...process.env,
LC_ALL: 'C',
GIT_AUTHOR_NAME: 'T',
GIT_AUTHOR_EMAIL: 't@example.com',
GIT_COMMITTER_NAME: 'T',
GIT_COMMITTER_EMAIL: 't@example.com',
GIT_CONFIG_GLOBAL: '/dev/null',
GIT_CONFIG_NOSYSTEM: '1',
};
const git = (cwd: string, ...args: string[]) =>
execFileSync('git', args, { cwd, env: ENV, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'pipe'] });
/** A git repository with one commit at `dir`. */
function makeRepo(dir: string): string {
mkdirSync(dir, { recursive: true });
git(dir, 'init', '-q', '-b', 'main');
writeFileSync(join(dir, 'README.md'), '# repo\n');
git(dir, 'add', 'README.md');
git(dir, 'commit', '-q', '-m', 'init');
return realpathSync(dir);
}
interface Harness {
app: FastifyInstance;
ctx: MockRouteContext;
}
/**
* The worktree routes plus the session routes (quick-start, POST /api/sessions), with the
* production envelope hook mirrored from server.ts: quick-start returns a bare object and relies
* on it for both the `{ success, data }` wrapper and the error-code status, as in
* case-clone-routes.test.ts.
*/
async function harness(
opts: { authUser?: { username: string; role: 'admin' | 'user' }; git?: WorktreeGitRunner } = {}
): Promise<Harness> {
const app = Fastify({ logger: false });
await app.register(fastifyCookie);
if (opts.authUser) {
const authUser = opts.authUser;
app.addHook('onRequest', async (req) => {
(req as unknown as { authUser: typeof authUser }).authUser = authUser;
});
}
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 });
});
const ctx = createMockRouteContext();
registerWorktreeRoutes(app, ctx as never, { git: opts.git });
registerSessionRoutes(app, ctx as never);
installRouteErrorHandler(app);
await app.ready();
return { app, ctx };
}
let h: Harness;
let repo: string;
beforeEach(async () => {
vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined);
vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined);
repo = makeRepo(join(CASES_DIR, 'wtcase'));
h = await harness();
});
afterEach(async () => {
await h.app.close();
vi.restoreAllMocks();
delete process.env.CODEMAN_MULTIUSER;
await rm(join(getDataDir(), 'docker-hosts.json'), { force: true });
await rm(join(getDataDir(), 'docker-cases.json'), { force: true });
await rm(join(getDataDir(), 'remote-hosts.json'), { force: true });
await rm(join(getDataDir(), 'remote-cases.json'), { force: true });
await rm(join(getDataDir(), 'linked-cases.json'), { force: true });
safeRmHomeTree(CASES_DIR);
safeRmHomeTree(join(process.env.HOME ?? '/nonexistent', 'codeman-users'));
});
const list = (name = 'wtcase') => h.app.inject({ method: 'GET', url: `/api/cases/${name}/worktrees` });
const create = (payload: Record<string, unknown>, name = 'wtcase') =>
h.app.inject({ method: 'POST', url: `/api/cases/${name}/worktrees`, payload });
const remove = (wt: string, query = '', name = 'wtcase') =>
h.app.inject({ method: 'DELETE', url: `/api/cases/${name}/worktrees/${wt}${query}` });
describe('worktree routes: happy path', () => {
it('creates, lists, starts a session in, and removes a worktree', async () => {
const before = await list();
expect(before.statusCode).toBe(200);
expect(before.json().data).toMatchObject({ caseName: 'wtcase', repoRoot: repo, truncated: false });
expect(before.json().data.worktrees).toHaveLength(1);
const res = await create({ name: 'fix-login' });
expect(res.statusCode).toBe(201);
const wtPath = join(repo, '.claude', 'worktrees', 'fix-login');
expect(res.json().data.worktree).toMatchObject({ name: 'fix-login', path: wtPath, branch: 'worktree-fix-login' });
expect(git(repo, 'status', '--porcelain')).toBe('');
// Start a session in it through the EXISTING quick-start path.
const qs = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'wtcase', mode: 'claude', worktree: 'fix-login', sessionName: 'w1-fix-login' },
});
expect(qs.statusCode).toBe(200);
const qsData = qs.json().data;
expect(qsData).toMatchObject({ casePath: repo, worktree: 'fix-login', workingDir: wtPath });
const session = h.ctx.sessions.get(qsData.sessionId) as unknown as { workingDir: string };
expect(session.workingDir).toBe(wtPath);
// The session's hooks land in the worktree it runs in, not the case folder.
expect(readFileSync(join(wtPath, '.claude', 'settings.local.json'), 'utf-8')).toContain('/api/hook-event');
// ...and that file does not make the worktree dirty (it is excluded).
expect(git(wtPath, 'status', '--porcelain')).toBe('');
const listed = (await list()).json().data.worktrees;
const row = listed.find((w: { name?: string }) => w.name === 'fix-login');
expect(row).toMatchObject({ managed: true, branch: 'worktree-fix-login', sessionCount: 1 });
expect(row.sessions).toEqual([{ id: qsData.sessionId, name: 'w1-fix-login' }]);
// The main checkout does not count a session that runs in a worktree below it.
expect(listed[0]).toMatchObject({ main: true, isCase: true, managed: false });
// Refused while that session lives.
const busy = await remove('fix-login');
expect(busy.statusCode).toBe(409);
expect(busy.json().error).toContain('live session');
h.ctx.sessions.delete(qsData.sessionId);
const gone = await remove('fix-login', '?deleteBranch=true');
expect(gone.statusCode).toBe(200);
expect(gone.json().data.removed).toMatchObject({
name: 'fix-login',
branch: 'worktree-fix-login',
branchDeleted: true,
});
expect(existsSync(wtPath)).toBe(false);
expect(git(repo, 'branch', '--list', 'worktree-fix-login')).toBe('');
});
it('surfaces git’s refusal for a dirty tree and keeps the files', async () => {
await create({ name: 'dirty', branch: 'feat/dirty' });
const wtPath = join(repo, '.claude', 'worktrees', 'dirty');
writeFileSync(join(wtPath, 'wip.txt'), 'work');
const res = await remove('dirty');
expect(res.statusCode).toBe(409);
expect(res.json().error).toContain('contains modified or untracked files');
expect(readFileSync(join(wtPath, 'wip.txt'), 'utf-8')).toBe('work');
});
it('removes a worktree its sessions got the agent skill in (agentSkillEnabled on)', async () => {
// The agent skill is injected into the folder a session runs in, so a worktree session gets
// an untracked `<skills dir>/codeman/`, which used to make `git worktree remove` refuse forever.
(h.ctx.getAgentSkillEnabled as unknown as { mockResolvedValue(v: boolean): void }).mockResolvedValue(true);
expect((await create({ name: 'skilled' })).statusCode).toBe(201);
const wtPath = join(repo, '.claude', 'worktrees', 'skilled');
const qs = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'wtcase', mode: 'claude', worktree: 'skilled' },
});
expect(qs.statusCode).toBe(200);
h.ctx.sessions.delete(qs.json().data.sessionId);
expect(existsSync(join(wtPath, '.claude', 'skills', 'codeman', 'SKILL.md'))).toBe(true);
// The other skills directory (codex, gemini and the rest scan `.agents/skills`), written by
// the same function quick-start calls; those CLIs are not installed here.
await applyAgentSkill(wtPath, true, '.agents/skills');
expect(existsSync(join(wtPath, '.agents', 'skills', 'codeman', 'SKILL.md'))).toBe(true);
expect(git(wtPath, 'status', '--porcelain')).toBe('');
expect(git(repo, 'status', '--porcelain')).toBe('');
const res = await remove('skilled');
expect(res.statusCode).toBe(200);
expect(existsSync(wtPath)).toBe(false);
});
it('removes a worktree whose folder was deleted by hand and frees its name', async () => {
await create({ name: 'gone' });
const wtPath = join(repo, '.claude', 'worktrees', 'gone');
await rm(wtPath, { recursive: true, force: true });
const row = (await list()).json().data.worktrees.find((w: { name?: string }) => w.name === 'gone');
expect(row).toMatchObject({ managed: true, prunable: true });
// A session cannot start in it.
const qs = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'wtcase', mode: 'shell', worktree: 'gone' },
});
expect(qs.statusCode).toBe(404);
const res = await remove('gone', '?deleteBranch=true');
expect(res.statusCode).toBe(200);
expect(res.json().data.removed).toMatchObject({ name: 'gone', branchDeleted: true });
expect(git(repo, 'worktree', 'list')).not.toContain('gone');
expect((await create({ name: 'gone' })).statusCode).toBe(201);
});
it('lets a POST /api/sessions create path use the worktree as workingDir', async () => {
await create({ name: 'viasessions' });
const wtPath = join(repo, '.claude', 'worktrees', 'viasessions');
const res = await h.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { workingDir: wtPath, mode: 'shell', name: 's1-viasessions' },
});
expect(res.statusCode).toBe(200);
expect(res.json().data.session.workingDir).toBe(wtPath);
});
});
describe('worktree routes: refusals', () => {
it('rejects bad names before git (schema and the finer rules)', async () => {
for (const name of ['-x', 'a/b', '.hidden', 'x'.repeat(65), 'a..b', 'n.lock']) {
const res = await create({ name });
expect(res.statusCode, name).toBe(400);
}
expect((await remove('a..b')).statusCode).toBe(400);
expect((await create({ name: 'ok', branch: '-D' })).statusCode).toBe(400);
expect((await create({ name: 'ok', base: '--upload-pack=sh' })).statusCode).toBe(400);
expect(git(repo, 'worktree', 'list')).not.toContain('ok');
});
it('answers 404 for a missing case and 400 for a folder that is not a repository', async () => {
expect((await list('nosuchcase')).statusCode).toBe(404);
mkdirSync(join(CASES_DIR, 'plain'), { recursive: true });
const res = await list('plain');
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('not a git repository');
});
it('refuses a Docker case with a 400 and never spawns git', async () => {
let spawned = 0;
const spy: WorktreeGitRunner = async () => {
spawned++;
return { code: 0, stdout: '', stderr: '', timedOut: false };
};
await h.app.close();
h = await harness({ git: spy });
await mkdir(getDataDir(), { recursive: true });
await writeFile(
join(getDataDir(), 'docker-hosts.json'),
JSON.stringify([{ id: 'd1', label: 'box', image: 'codeman/agent:base' }])
);
await writeFile(
join(getDataDir(), 'docker-cases.json'),
JSON.stringify([{ name: 'dockcase', type: 'docker', hostId: 'd1', hostWorkspacePath: repo }])
);
for (const res of [
await list('dockcase'),
await create({ name: 'x' }, 'dockcase'),
await remove('x', '', 'dockcase'),
]) {
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('Docker');
}
// The local case whose folder IS that Docker workspace is refused too (git would run there).
const viaLocal = await list('wtcase');
expect(viaLocal.statusCode).toBe(400);
expect(viaLocal.json().error).toContain('Docker');
expect(spawned).toBe(0);
const qs = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'dockcase', mode: 'shell', worktree: 'x' },
});
expect(qs.statusCode).toBe(400);
expect(qs.json().error).toContain('Docker');
});
it('refuses to remove a worktree a Docker case uses as its workspace', async () => {
expect((await create({ name: 'boxed' })).statusCode).toBe(201);
const wtPath = join(repo, '.claude', 'worktrees', 'boxed');
await mkdir(getDataDir(), { recursive: true });
await writeFile(
join(getDataDir(), 'docker-cases.json'),
JSON.stringify([{ name: 'boxcase', type: 'docker', hostId: 'd1', hostWorkspacePath: wtPath }])
);
// The local case itself is fine (listing and creating still work)...
expect((await list()).statusCode).toBe(200);
// ...but removing the folder the container mounts is refused, and nothing is deleted.
const res = await remove('boxed');
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('Docker case workspace');
expect(existsSync(join(wtPath, 'README.md'))).toBe(true);
expect(git(repo, 'worktree', 'list')).toContain('boxed');
});
it('refuses a remote case with a 400', async () => {
await mkdir(getDataDir(), { recursive: true });
await writeFile(
join(getDataDir(), 'remote-hosts.json'),
JSON.stringify([{ id: 'r1', label: 'far', host: 'far.example', username: 'u' }])
);
await writeFile(
join(getDataDir(), 'remote-cases.json'),
JSON.stringify([{ name: 'farcase', type: 'remote', hostId: 'r1', remotePath: '/srv/repo' }])
);
const res = await list('farcase');
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('remote');
const qs = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'farcase', mode: 'shell', worktree: 'x' },
});
expect(qs.statusCode).toBe(400);
expect(qs.json().error).toContain('remote');
});
it('refuses to create through a symlinked .claude directory', async () => {
const outside = join(CASES_DIR, 'outside');
mkdirSync(join(outside, 'worktrees'), { recursive: true });
symlinkSync(outside, join(repo, '.claude'));
const res = await create({ name: 'evil' });
expect(res.statusCode).toBe(400);
expect(res.json().error).toContain('symlink');
expect(existsSync(join(outside, 'worktrees', 'evil'))).toBe(false);
});
it('quick-start refuses an unregistered worktree and never scaffolds a missing case', async () => {
mkdirSync(join(repo, '.claude', 'worktrees', 'stray'), { recursive: true });
const stray = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'wtcase', mode: 'shell', worktree: 'stray' },
});
expect(stray.statusCode).toBe(404);
const missing = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'brandnew', mode: 'shell', worktree: 'x' },
});
expect(missing.statusCode).toBe(404);
expect(existsSync(join(CASES_DIR, 'brandnew'))).toBe(false);
const badName = await h.app.inject({
method: 'POST',
url: '/api/quick-start',
payload: { caseName: 'wtcase', mode: 'shell', worktree: '../x' },
});
expect(badName.statusCode).toBe(400);
});
});
describe('worktree routes: a case reached through a symlink', () => {
// A linked case's folder can be a symlink (a repository on another disk, macOS's /tmp), and
// POST /api/sessions stores `workingDir` as it was spelled. The worktree list and the removal
// guard compare paths lexically, so a session spelled through the link has to be mapped onto
// the repository's real path first, or it is invisible to both.
it('counts and protects a session whose folder names the worktree through the case link', async () => {
const link = join(CASES_DIR, 'repo-link');
symlinkSync(repo, link);
await mkdir(getDataDir(), { recursive: true });
await writeFile(join(getDataDir(), 'linked-cases.json'), JSON.stringify({ linkedviasym: link }));
const made = await create({ name: 'sym' }, 'linkedviasym');
expect(made.statusCode).toBe(201);
const real = join(repo, '.claude', 'worktrees', 'sym');
expect(made.json().data.worktree.path).toBe(real);
// The same worktree, spelled through the link.
const spelled = join(link, '.claude', 'worktrees', 'sym');
const res = await h.app.inject({
method: 'POST',
url: '/api/sessions',
payload: { workingDir: spelled, mode: 'shell', name: 's1-sym' },
});
expect(res.statusCode).toBe(200);
expect(res.json().data.session.workingDir).toBe(spelled);
const busy = await remove('sym', '', 'linkedviasym');
expect(busy.statusCode).toBe(409);
expect(busy.json().error).toContain('live session');
expect(existsSync(real)).toBe(true);
const row = (await list('linkedviasym')).json().data.worktrees.find((w: { name?: string }) => w.name === 'sym');
expect(row).toMatchObject({ managed: true, sessionCount: 1 });
expect(row.sessions).toEqual([{ id: res.json().data.session.id, name: 's1-sym' }]);
});
});
describe('worktree routes: multi-user ownership', () => {
it('resolves a non-admin’s own case space only, never a linked case or another user’s case', async () => {
process.env.CODEMAN_MULTIUSER = '1';
// A linked case is admin-only: a non-admin's lookup falls through to their own space.
await mkdir(getDataDir(), { recursive: true });
await writeFile(join(getDataDir(), 'linked-cases.json'), JSON.stringify({ linkedrepo: repo }));
makeRepo(join(userCasesDir('alice'), 'alicecase'));
await h.app.close();
h = await harness({ authUser: { username: 'bob', role: 'user' } });
expect((await list('linkedrepo')).statusCode).toBe(404);
expect((await list('alicecase')).statusCode).toBe(404);
expect((await create({ name: 'x' }, 'alicecase')).statusCode).toBe(404);
expect(existsSync(join(userCasesDir('alice'), 'alicecase', '.claude'))).toBe(false);
await h.app.close();
h = await harness({ authUser: { username: 'alice', role: 'user' } });
expect((await list('alicecase')).statusCode).toBe(200);
expect((await create({ name: 'mine' }, 'alicecase')).statusCode).toBe(201);
await h.app.close();
h = await harness({ authUser: { username: 'root', role: 'admin' } });
const linked = await list('linkedrepo');
expect(linked.statusCode).toBe(200);
expect(linked.json().data.repoRoot).toBe(repo);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const publicDir = resolve(import.meta.dirname, '../src/web/public');
const styles = readFileSync(resolve(publicDir, 'styles.css'), 'utf8');
// An agent CLI whose Run button has no `mode-<id>` rule falls back to the default button
// colours, so it reads as a different button from every other CLI's. The Run button, its
// gear, the phone toolbar and the light skins each carry their own rule.
const AGENT_IDS = STOCK_CLIS.filter((c) => c.kind === 'agent').map((c) => c.id as string);
describe('Run button colours for every stock agent CLI', () => {
it('lists agent CLIs (anti-vacuity)', () => {
expect(AGENT_IDS).toContain('copilot');
expect(AGENT_IDS.length).toBeGreaterThan(5);
});
for (const id of AGENT_IDS) {
it(`${id} has a Run button rule`, () => {
expect(styles, `${id} has no .btn-run.mode-${id} rule in styles.css`).toContain(
`.btn-toolbar.btn-run.mode-${id}`
);
});
}
});
+110
View File
@@ -0,0 +1,110 @@
/**
* What the server raises for a dialog read off a hook-less CLI's screen (codex), and what
* clears it: `web/screen-blocked.ts`. The session side, and the codex captures it reads,
* are in `codex-blocked-detection.test.ts`.
*
* The contract: exactly what a Claude `permission_prompt` hook raises (the `blocked` wait
* signal, an Approvals Inbox item, the `hook:permission_prompt` broadcast, push and
* webhook, a run-summary row), but ALERT-ONLY, since the dialog was read from text the
* agent writes: the item is `answerInTerminal`, push carries no `approvalId`, and the
* inbox never resolves or answers it on its own.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { readFileSync } from 'node:fs';
import { raiseScreenBlocked, clearScreenBlocked, screenBlockedMessage } from '../src/web/screen-blocked.js';
import { approvalInbox } from '../src/web/approval-inbox.js';
import { sessionWaits } from '../src/web/session-wait-registry.js';
import { SseEvent } from '../src/web/sse-events.js';
import type { SessionMode } from '../src/types.js';
const FIXTURES = new URL('./fixtures/codex-dialogs/', import.meta.url);
const fixture = (name: string): string => readFileSync(new URL(name, FIXTURES), 'utf8');
describe('raiseScreenBlocked / clearScreenBlocked', () => {
const SESSION = { id: 'screen-blocked-test', name: 'w9-scratch', mode: 'codex' as SessionMode };
function deps() {
const recordHookEvent = vi.fn();
return {
broadcast: vi.fn(),
broadcastSessionStateDebounced: vi.fn(),
sendPushNotifications: vi.fn(),
getRunSummaryTracker: vi.fn(() => ({ recordHookEvent })),
recordHookEvent,
};
}
afterEach(() => {
approvalInbox.resolveForSession(SESSION.id, 'session_ended');
sessionWaits.cancelAll(SESSION.id);
});
it('raises what a permission_prompt hook raises, alert-only', async () => {
const d = deps();
const waiting = sessionWaits.waitForSignal(SESSION.id, { until: ['blocked'], timeoutMs: 5000 });
const itemId = raiseScreenBlocked(SESSION, d, fixture('exec-approval.txt'));
expect((await waiting).signal).toBe('blocked');
const item = approvalInbox.getForSession(SESSION.id)!;
expect(item.id).toBe(itemId);
expect(item.kind).toBe('permission');
expect(item.answerInTerminal).toBe(true);
expect(item.message).toBe('Codex is waiting for your answer in the terminal');
// The frame is the card's context, and nothing parsed it into answer buttons.
expect(item.context).toContain('Press enter to confirm or esc to cancel');
expect(item.options).toBeUndefined();
expect(d.broadcast).toHaveBeenCalledWith(
SseEvent.HookPermissionPrompt,
expect.objectContaining({ sessionId: SESSION.id, source: 'screen', approvalId: itemId })
);
expect(d.broadcastSessionStateDebounced).toHaveBeenCalledWith(SESSION.id);
// Push and webhook get the message and NO approvalId, so no Approve/Deny buttons.
const [event, push] = d.sendPushNotifications.mock.calls[0] as [string, Record<string, unknown>];
expect(event).toBe(SseEvent.HookPermissionPrompt);
expect(push).toMatchObject({ sessionId: SESSION.id, sessionName: 'w9-scratch', source: 'screen' });
expect(push.message).toBe(screenBlockedMessage('codex'));
expect(push.approvalId).toBeUndefined();
expect(d.recordHookEvent).toHaveBeenCalledWith('permission_prompt', expect.objectContaining({ source: 'screen' }));
});
it('never steers a keystroke: the inbox item cannot be answered', () => {
raiseScreenBlocked(SESSION, deps(), fixture('exec-approval.txt'));
const item = approvalInbox.getForSession(SESSION.id)!;
// No capture callback, so the inbox's Claude-shaped sweep never resolves it either:
// the session owns its lifetime.
expect(approvalInbox.verifyStillAnswerable(item.id)).toBe(true);
approvalInbox.resolveIfDialogGone(SESSION.id);
expect(approvalInbox.getForSession(SESSION.id)?.id).toBe(item.id);
});
it('clears by resolving the item it opened, and only that one', () => {
const resolved: string[] = [];
const prev = approvalInbox.onResolved;
approvalInbox.onResolved = (info) => resolved.push(`${info.id}:${info.resolution}`);
try {
const itemId = raiseScreenBlocked(SESSION, deps(), null);
clearScreenBlocked(SESSION.id, itemId);
expect(approvalInbox.getForSession(SESSION.id)).toBeUndefined();
expect(resolved).toContain(`${itemId}:resolved_in_terminal`);
// An item it did not open (here a newer one) is left alone.
const newer = raiseScreenBlocked(SESSION, deps(), null);
clearScreenBlocked(SESSION.id, itemId);
expect(approvalInbox.getForSession(SESSION.id)?.id).toBe(newer);
clearScreenBlocked(SESSION.id, null);
expect(approvalInbox.getForSession(SESSION.id)?.id).toBe(newer);
} finally {
approvalInbox.onResolved = prev;
}
});
it('raises nothing for a session whose CLI reports blocked through hooks', () => {
const d = deps();
expect(raiseScreenBlocked({ ...SESSION, mode: 'claude' }, d, null)).toBeNull();
expect(d.broadcast).not.toHaveBeenCalled();
expect(d.sendPushNotifications).not.toHaveBeenCalled();
expect(approvalInbox.getForSession(SESSION.id)).toBeUndefined();
});
});
+995
View File
@@ -0,0 +1,995 @@
/**
* @fileoverview Security notices: a designed warning card per notice in the HAND-maintained
* src/web/public/security-notices.json (the first: OpenCode's Console breach, October 2026),
* or a warning section INSIDE the once-per-release update toast when that toast shows.
*
* What is pinned, and why it matters:
* - The pure selection (`CodemanSecurityNotices.select`, constants.js), loaded from the
* SHIPPED file: a notice naming a CLI shows ONLY when `available[cli] === true` (absent,
* unknown and false all mean "not installed": a warning about software you do not have
* is noise), a notice with no CLI shows to everyone, dates are inclusive local days
* (start of `published` to the END of `expires`), seen ids are skipped, malformed
* entries and non-https links are dropped rather than repaired, and zh-CN takes the
* entry's own strings field by field.
* - The pure surface plan (`CodemanSecurityNotices.plan`): the update toast shows exactly
* when checkWhatsNew always showed it, every notice then rides inside it, and otherwise
* each notice gets its own card. A notice is never on both surfaces.
* - The shipped JSON itself: it parses, ids are unique, links are https, dates are real
* days, and every `cli` is a real stock registry id (a typo would silently show the
* notice to nobody).
* - The wiring (settings-ui.js checkWhatsNew + panels-ui.js cards): fresh installs and
* same-version reloads get the card, an existing install's update toast carries the
* notice, the update toast WITHOUT a notice is byte-for-byte the toast it was before,
* a notice is recorded as seen only on a user action (never on show), storage blocked
* shows nothing, the notice text is textContent and left alone by i18n.js, and the
* card's own copy is translated.
*
* The real modules run inside a JSDOM window (runScripts: 'outside-only'). Port: none.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
const CONSTANTS = read('constants.js');
const NOTICES_FILE = JSON.parse(read('security-notices.json')) as { notices: Notice[] };
type Notice = Record<string, unknown>;
type Shown = { id: string; title: string; message: string; label: string; url: string };
type SelectOpts = { available?: Record<string, unknown>; seen?: Iterable<string>; now?: number; lang?: string };
type PlanState = { seenVersion?: string | null; runningVersion: string; notes?: unknown; notices?: unknown[] };
type Plan = { recordVersion: boolean; update: boolean; updateNotices: unknown[]; cards: unknown[] };
type Core = { select: (notices: unknown, opts?: SelectOpts) => Shown[]; plan: (state: PlanState) => Plan };
function loadCore(): Core {
const context = vm.createContext({ window: {}, globalThis: {} });
vm.runInContext(CONSTANTS, context, { filename: 'constants.js' });
return (context.window as { CodemanSecurityNotices: Core }).CodemanSecurityNotices;
}
const core = loadCore();
/** Local-time instants, the way the core reads `YYYY-MM-DD` days. */
const at = (y: number, m: number, d: number, h = 12, min = 0, s = 0, ms = 0) =>
new Date(y, m - 1, d, h, min, s, ms).getTime();
const NOW = at(2026, 10, 20);
const notice = (over: Notice = {}): Notice => ({
id: 'n1',
severity: 'warning',
published: '2026-10-18',
expires: '2026-12-31',
message: 'Rotate your keys.',
link: { label: 'Read more', url: 'https://example.com/notice' },
...over,
});
const ids = (shown: Array<{ id: string }>) => shown.map((n) => n.id);
describe('CodemanSecurityNotices.select (pure)', () => {
describe('a notice naming a CLI', () => {
const withCli = [notice({ cli: 'somecli' })];
it('shows when that CLI is installed (strictly true)', () => {
expect(ids(core.select(withCli, { available: { somecli: true }, now: NOW }))).toEqual(['n1']);
});
it('stays hidden when the CLI is not installed', () => {
expect(core.select(withCli, { available: { somecli: false }, now: NOW })).toEqual([]);
});
it('stays hidden when availability is unknown: no entry, no map, or a truthy non-true value', () => {
expect(core.select(withCli, { available: { other: true }, now: NOW })).toEqual([]);
expect(core.select(withCli, { now: NOW })).toEqual([]);
expect(core.select(withCli, { available: undefined, now: NOW })).toEqual([]);
expect(core.select(withCli, { available: { somecli: 'true' }, now: NOW })).toEqual([]);
expect(core.select(withCli, { available: { somecli: 1 }, now: NOW })).toEqual([]);
});
it('never matches a prototype key as an installed CLI', () => {
expect(core.select([notice({ cli: 'constructor' })], { available: {}, now: NOW })).toEqual([]);
});
});
it('shows a notice without a CLI to everyone, whatever is installed', () => {
expect(ids(core.select([notice()], { now: NOW }))).toEqual(['n1']);
expect(ids(core.select([notice()], { available: { somecli: false }, now: NOW }))).toEqual(['n1']);
});
it('returns exactly id, title, message, label and url, trimmed', () => {
const shown = core.select(
[
notice({
id: ' n1 ',
title: ' Heads up ',
message: ' Hi. ',
link: { label: ' Go ', url: ' https://example.com/x ' },
}),
],
{ now: NOW }
);
expect(shown).toEqual([{ id: 'n1', title: 'Heads up', message: 'Hi.', label: 'Go', url: 'https://example.com/x' }]);
});
it('gives a notice without a title an empty one', () => {
expect(core.select([notice()], { now: NOW })[0].title).toBe('');
});
describe('dates (inclusive local days)', () => {
const list = [notice()];
it('shows from the very start of the published day', () => {
expect(ids(core.select(list, { now: at(2026, 10, 18, 0, 0, 0, 0) }))).toEqual(['n1']);
expect(core.select(list, { now: at(2026, 10, 18, 0, 0, 0, 0) - 1 })).toEqual([]);
});
it('shows until the very end of the expires day, then never again', () => {
expect(ids(core.select(list, { now: at(2026, 12, 31, 23, 59, 59, 999) }))).toEqual(['n1']);
expect(core.select(list, { now: at(2026, 12, 31, 23, 59, 59, 999) + 1 })).toEqual([]);
expect(core.select(list, { now: at(2027, 6, 1) })).toEqual([]);
});
it('shows a one-day notice all of that day', () => {
const oneDay = [notice({ published: '2026-10-20', expires: '2026-10-20' })];
expect(ids(core.select(oneDay, { now: at(2026, 10, 20, 0, 0) }))).toEqual(['n1']);
expect(ids(core.select(oneDay, { now: at(2026, 10, 20, 23, 59, 59, 999) }))).toEqual(['n1']);
});
it('falls back to the current time when now is not a finite number', () => {
const always = [notice({ published: '2000-01-01', expires: '2999-12-31' })];
expect(ids(core.select(always))).toEqual(['n1']);
expect(ids(core.select(always, { now: Number.NaN }))).toEqual(['n1']);
});
});
it('skips the ids the user has already closed or followed', () => {
const list = [notice(), notice({ id: 'n2' })];
expect(ids(core.select(list, { seen: ['n1'], now: NOW }))).toEqual(['n2']);
expect(ids(core.select(list, { seen: new Set(['n2']), now: NOW }))).toEqual(['n1']);
expect(core.select(list, { seen: ['n1', 'n2'], now: NOW })).toEqual([]);
// A string is not a list of ids (it would iterate as characters).
expect(ids(core.select(list, { seen: 'n1' as unknown as string[], now: NOW }))).toEqual(['n1', 'n2']);
});
it('keeps file order and shows a repeated id once, as its first entry', () => {
const list = [notice({ id: 'b' }), notice({ id: 'a' }), notice({ id: 'b', message: 'Second.' })];
const shown = core.select(list, { now: NOW });
expect(ids(shown)).toEqual(['b', 'a']);
expect(shown[0].message).toBe('Rotate your keys.');
});
describe('malformed entries are skipped, never repaired', () => {
const cases: Array<[string, Notice | unknown]> = [
['no id', notice({ id: undefined })],
['a blank id', notice({ id: ' ' })],
['a numeric id', notice({ id: 7 })],
['no message', notice({ message: undefined })],
['a blank message', notice({ message: '' })],
['a blank title', notice({ title: ' ' })],
['a title that is not a string', notice({ title: 42 })],
['no link', notice({ link: undefined })],
['a link that is a string', notice({ link: 'https://example.com/' })],
['a link without a label', notice({ link: { url: 'https://example.com/' } })],
['a link without a url', notice({ link: { label: 'Go' } })],
['an http link', notice({ link: { label: 'Go', url: 'http://example.com/notice' } })],
['a javascript: link', notice({ link: { label: 'Go', url: 'javascript:alert(1)' } })],
['a protocol-relative link', notice({ link: { label: 'Go', url: '//example.com/' } })],
['a link with userinfo', notice({ link: { label: 'Go', url: 'https://user@evil.example/' } })],
['a link with whitespace inside', notice({ link: { label: 'Go', url: 'https://example.com/a b' } })],
['no published date', notice({ published: undefined })],
['no expires date', notice({ expires: undefined })],
['a month 13', notice({ expires: '2026-13-01' })],
['a day that does not exist', notice({ expires: '2026-02-30' })],
['a non-ISO date', notice({ published: '18.10.2026' })],
['a timestamp, not a day', notice({ published: '2026-10-18T00:00:00Z' })],
['a cli that is not a string', notice({ cli: 5 })],
['a cli that is not a registry id', notice({ cli: 'Open Code' })],
['an array', []],
['null', null],
['a string', 'notice'],
];
for (const [what, entry] of cases) {
it(`skips ${what}`, () => {
// The good entry beside it proves the bad one is skipped, not the whole list.
expect(ids(core.select([entry, notice({ id: 'ok' })], { available: { somecli: true }, now: NOW }))).toEqual([
'ok',
]);
});
}
it('returns nothing for a notices value that is not an array', () => {
expect(core.select(undefined, { now: NOW })).toEqual([]);
expect(core.select({ notices: [notice()] }, { now: NOW })).toEqual([]);
expect(core.select('[]', { now: NOW })).toEqual([]);
});
});
describe('language', () => {
const zh = { 'zh-CN': { title: '注意', message: '请更换密钥。', label: '了解更多' } };
it('takes the zh-CN title, message and label under zh-CN', () => {
const [shown] = core.select([notice({ title: 'Heads up', i18n: zh })], { lang: 'zh-CN', now: NOW });
expect(shown).toMatchObject({
title: '注意',
message: '请更换密钥。',
label: '了解更多',
url: 'https://example.com/notice',
});
});
it('falls back to English field by field when a translation is missing or blank', () => {
const onlyMessage = { 'zh-CN': { message: '请更换密钥。', label: ' ' } };
expect(
core.select([notice({ title: 'Heads up', i18n: onlyMessage })], { lang: 'zh-CN', now: NOW })[0]
).toMatchObject({ title: 'Heads up', message: '请更换密钥。', label: 'Read more' });
expect(core.select([notice()], { lang: 'zh-CN', now: NOW })[0]).toMatchObject({
message: 'Rotate your keys.',
label: 'Read more',
});
expect(core.select([notice({ i18n: 'nope' })], { lang: 'zh-CN', now: NOW })[0].message).toBe('Rotate your keys.');
});
it('stays English in English and in a language it has no strings for', () => {
expect(core.select([notice({ i18n: zh })], { lang: 'en', now: NOW })[0].message).toBe('Rotate your keys.');
expect(core.select([notice({ i18n: zh })], { now: NOW })[0].message).toBe('Rotate your keys.');
expect(core.select([notice({ i18n: zh })], { lang: 'fr', now: NOW })[0].message).toBe('Rotate your keys.');
});
});
});
describe('CodemanSecurityNotices.plan (pure): which surface carries each notice', () => {
const NOTES = { version: '1.43.0', highlights: ['Alpha'] };
const A = { id: 'a' };
const B = { id: 'b' };
it('puts every notice inside the update toast when an existing install sees a new version', () => {
expect(core.plan({ seenVersion: '1.42.0', runningVersion: '1.43.0', notes: NOTES, notices: [A, B] })).toEqual({
recordVersion: true,
update: true,
updateNotices: [A, B],
cards: [],
});
});
it('shows the plain update toast when no notice is selected', () => {
expect(core.plan({ seenVersion: '1.42.0', runningVersion: '1.43.0', notes: NOTES, notices: [] })).toEqual({
recordVersion: true,
update: true,
updateNotices: [],
cards: [],
});
});
it('gives a fresh install (no seen version) cards and records the version, with no update toast', () => {
for (const seenVersion of [null, undefined, '']) {
expect(core.plan({ seenVersion, runningVersion: '1.43.0', notes: NOTES, notices: [A] })).toEqual({
recordVersion: true,
update: false,
updateNotices: [],
cards: [A],
});
}
});
it('gives a reload on the same version cards and records nothing', () => {
expect(core.plan({ seenVersion: '1.43.0', runningVersion: '1.43.0', notes: NOTES, notices: [A] })).toEqual({
recordVersion: false,
update: false,
updateNotices: [],
cards: [A],
});
});
it('falls back to cards, recording no version, when the notes cannot carry a toast', () => {
for (const notes of [
null,
undefined,
{ version: '1.42.9', highlights: ['Old'] },
{ version: '1.43.0', highlights: [] },
{ version: '1.43.0', highlights: 'Alpha' },
{ version: '1.43.0' },
]) {
expect(core.plan({ seenVersion: '1.42.0', runningVersion: '1.43.0', notes, notices: [A] })).toEqual({
recordVersion: false,
update: false,
updateNotices: [],
cards: [A],
});
}
});
it('never shows one notice on both surfaces, and never drops one', () => {
const notices = [A, B];
for (const seenVersion of [null, '1.42.0', '1.43.0']) {
for (const notes of [null, NOTES, { version: '1.43.0', highlights: [] }]) {
const plan = core.plan({ seenVersion, runningVersion: '1.43.0', notes, notices });
const all = [...plan.updateNotices, ...plan.cards] as Array<{ id: string }>;
expect(ids(all).sort(), JSON.stringify({ seenVersion, notes })).toEqual(['a', 'b']);
expect(plan.updateNotices.length > 0 && plan.cards.length > 0).toBe(false);
}
}
});
it('tolerates a missing notices list', () => {
expect(core.plan({ seenVersion: '1.42.0', runningVersion: '1.43.0', notes: NOTES }).updateNotices).toEqual([]);
});
});
describe('the shipped security-notices.json', () => {
const notices = NOTICES_FILE.notices;
const stockIds = new Set(STOCK_CLIS.map((entry) => entry.id as string));
const DAY = /^\d{4}-\d{2}-\d{2}$/;
const realDay = (value: unknown) => {
if (typeof value !== 'string' || !DAY.test(value)) return false;
const [y, m, d] = value.split('-').map(Number);
const date = new Date(Date.UTC(y, m - 1, d));
return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d;
};
const nonBlank = (value: unknown) => typeof value === 'string' && value.trim().length > 0;
it('is a list of notices with unique, stable ids', () => {
expect(Array.isArray(notices)).toBe(true);
expect(notices.length).toBeGreaterThan(0);
const all = notices.map((n) => n.id);
expect(new Set(all).size).toBe(all.length);
for (const id of all) expect(id).toMatch(/^[a-z0-9][a-z0-9-]*$/);
});
it('gives every notice a warning severity, an https link and real dates in order', () => {
for (const n of notices) {
expect(n.severity, String(n.id)).toBe('warning');
expect(nonBlank(n.message), String(n.id)).toBe(true);
if (n.title !== undefined) expect(nonBlank(n.title), String(n.id)).toBe(true);
const link = n.link as { label?: unknown; url?: unknown };
expect(nonBlank(link?.label), String(n.id)).toBe(true);
expect(typeof link?.url === 'string' && new URL(link.url).protocol, String(n.id)).toBe('https:');
expect(realDay(n.published), `${String(n.id)} published`).toBe(true);
expect(realDay(n.expires), `${String(n.id)} expires`).toBe(true);
expect(String(n.expires) >= String(n.published), String(n.id)).toBe(true);
}
});
it('names only real stock CLI ids, so a typo cannot hide a notice from everyone', () => {
for (const n of notices) {
if (n.cli === undefined) continue;
expect(stockIds.has(n.cli as string), `${String(n.id)}: cli "${String(n.cli)}"`).toBe(true);
}
});
it('translates only into languages the UI has, with every string the English entry has', () => {
for (const n of notices) {
if (n.i18n === undefined) continue;
const langs = n.i18n as Record<string, { title?: unknown; message?: unknown; label?: unknown }>;
for (const [lang, strings] of Object.entries(langs)) {
expect(lang, String(n.id)).toBe('zh-CN');
expect(nonBlank(strings.message), `${String(n.id)} ${lang} message`).toBe(true);
expect(nonBlank(strings.label), `${String(n.id)} ${lang} label`).toBe(true);
if (n.title !== undefined) expect(nonBlank(strings.title), `${String(n.id)} ${lang} title`).toBe(true);
}
}
});
it('passes the shipped selection on its published day for a browser with its CLI', () => {
// A field the core rejects would make the notice silently show to nobody.
for (const n of notices) {
const [y, m, d] = String(n.published).split('-').map(Number);
const available = typeof n.cli === 'string' ? { [n.cli]: true } : {};
expect(ids(core.select([n], { available, now: at(y, m, d) })), String(n.id)).toEqual([n.id]);
}
});
it('shows the OpenCode notice, with its title, only to OpenCode users', () => {
const breach = notices.find((n) => n.id === 'opencode-console-breach-2026-10');
expect(breach?.cli).toBe('opencode');
const [shown] = core.select(notices, { available: { opencode: true }, now: NOW });
expect(shown).toMatchObject({
id: 'opencode-console-breach-2026-10',
title: 'OpenCode Console breach: check your API keys',
label: "Read OpenCode's notice",
url: 'https://x.com/opencode/status/2111519637049319818',
});
expect(core.select(notices, { available: { opencode: true }, now: NOW, lang: 'zh-CN' })[0].title).toBe(
'OpenCode Console 遭入侵:请检查你的 API 密钥'
);
expect(core.select(notices, { available: { opencode: false, claude: true }, now: NOW })).toEqual([]);
expect(core.select(notices, { available: {}, now: NOW })).toEqual([]);
expect(core.select(notices, { available: { opencode: true }, now: at(2027, 1, 1) })).toEqual([]);
});
it('is fetched by the page and never generated by the release script', () => {
const pkg = readFileSync(join(process.cwd(), 'package.json'), 'utf8');
expect(pkg).not.toContain('security-notices');
expect(read('settings-ui.js')).toContain("fetch(CodemanBase.url('/security-notices.json'), { cache: 'no-cache' })");
});
});
// ─── Wiring: settings-ui.js checkWhatsNew + panels-ui.js notice cards ───
const SEEN_KEY = 'codeman-security-notices-seen';
const VERSION_KEY = 'codeman-whats-new-seen';
const RUNNING = '1.43.0';
const NOTES = { version: RUNNING, highlights: ['Alpha', 'Beta', 'Gamma', 'Delta'] };
const UPDATE_TEXT = 'Codeman updated to v1.43.0: Alpha · Beta · Gamma (+1 more)';
const RELEASE_URL = 'https://github.com/Ark0N/Codeman/releases/tag/codeman%401.43.0';
const DATA = {
notices: [
notice({
id: 'cli-notice',
cli: 'somecli',
title: 'Keys may be exposed',
i18n: { 'zh-CN': { title: '密钥可能泄露', message: '中文提示。', label: '查看' } },
}),
],
};
/** checkWhatsNew's plain update toast exactly as master rendered it before this feature. */
const PLAIN_UPDATE_TOAST =
'<div class="toast toast-info"><span class="toast-message">Codeman updated to v1.43.0: Alpha · Beta · Gamma (+1 more)</span>' +
'<button style="margin-left: 12px; padding: 2px 10px; background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 3px; color: inherit; cursor: pointer; font-size: 12px;">See what\'s new</button>' +
'<button class="toast-close" aria-label="Dismiss">×</button></div>';
type Reply = { ok: boolean; body?: unknown } | 'throw';
type WiredApp = {
isSoloWindow?: boolean;
checkWhatsNew(version: string): Promise<void>;
showSecurityNotice(notice: Shown, opts?: { onClose?: (reason: string) => void }): { dismiss(): void };
showUpdateNotice(opts: { text: string; releaseUrl: string; notices: Shown[]; onNoticeSeen?: (id: string) => void }): {
dismiss(): void;
};
};
const windows: Array<{ close(): void }> = [];
afterEach(() => {
for (const win of windows.splice(0)) win.close();
vi.restoreAllMocks();
});
interface BootOpts {
available?: Record<string, unknown> | null;
storage?: 'blocked';
seenIds?: unknown;
/** The version this browser recorded; undefined = a fresh install. */
seenVersion?: string;
notices?: Reply;
notes?: Reply;
base?: string;
lang?: 'zh-CN';
i18n?: boolean;
/** A detached session window (/session/:id), rendered without __codemanCliAvailable. */
solo?: boolean;
}
/** constants.js, i18n.js (optional), settings-ui.js and panels-ui.js in one JSDOM page. */
function boot(opts: BootOpts = {}) {
const dom = new JSDOM('<!doctype html><html><head></head><body></body></html>', {
url: 'http://localhost/',
runScripts: 'outside-only',
pretendToBeVisual: true,
});
const win = dom.window as unknown as Window &
typeof globalThis & { eval(src: string): void; __App: { prototype: object }; [key: string]: unknown };
windows.push(win);
// The page's own clock, pinned inside the fixtures' date window: JSDOM gives the window its
// own Date (vi.setSystemTime never reaches it), and on the real clock every test that expects
// a card would fail once the fixture's notice expired (2027-01-01).
win.eval(`Date.now = () => ${NOW};`);
if (opts.seenIds !== undefined) win.localStorage.setItem(SEEN_KEY, JSON.stringify(opts.seenIds));
if (opts.seenVersion !== undefined) win.localStorage.setItem(VERSION_KEY, opts.seenVersion);
if (opts.storage === 'blocked') {
Object.defineProperty(win, 'localStorage', {
configurable: true,
get() {
throw new win.DOMException('blocked', 'SecurityError');
},
});
}
const fetchCalls: Array<{ url: string; init: RequestInit | undefined }> = [];
win.fetch = (async (url: string, init?: RequestInit) => {
fetchCalls.push({ url, init });
const reply: Reply = url.endsWith('/whats-new.json')
? (opts.notes ?? { ok: true, body: NOTES })
: (opts.notices ?? { ok: true, body: DATA });
if (reply === 'throw') throw new TypeError('offline');
return { ok: reply.ok, json: async () => reply.body };
}) as unknown as typeof fetch;
const opened: unknown[][] = [];
win.open = ((...args: unknown[]) => {
opened.push(args);
return null;
}) as typeof win.open;
if (opts.base) win.__CODEMAN_BASE__ = opts.base;
if (opts.available !== null) win.__codemanCliAvailable = opts.available ?? { somecli: true };
if (opts.lang) win.__codemanLanguage = opts.lang;
win.eval(
[
'var CodemanApp = function CodemanApp() {};',
CONSTANTS,
opts.i18n ? read('i18n.js') : '',
read('settings-ui.js'),
read('panels-ui.js'),
'window.__App = CodemanApp;',
].join('\n')
);
if (opts.i18n) (win.CodemanI18n as { start(): void }).start();
const app = Object.create(win.__App.prototype) as WiredApp;
if (opts.solo) app.isSoloWindow = true;
const doc = win.document;
const cards = () => Array.from(doc.querySelectorAll<HTMLElement>('.security-notice:not(.update-notice)'));
const updateCards = () => Array.from(doc.querySelectorAll<HTMLElement>('.security-notice.update-notice'));
const plainToasts = () => Array.from(doc.querySelectorAll<HTMLElement>('.toast'));
const button = (root: Element, cls: string) => root.querySelector<HTMLButtonElement>(`button.${cls}`)!;
const text = (root: Element, cls: string) => root.querySelector(`.${cls}`)?.textContent ?? null;
const seenIds = () => JSON.parse(dom.window.localStorage.getItem(SEEN_KEY) || 'null') as unknown;
const seenVersion = () => dom.window.localStorage.getItem(VERSION_KEY);
return {
dom,
win,
doc,
app,
fetchCalls,
opened,
cards,
updateCards,
plainToasts,
button,
text,
seenIds,
seenVersion,
};
}
/** i18n.js translates inserted DOM from a MutationObserver: give it a tick. */
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
const afterClose = () => new Promise((resolve) => setTimeout(resolve, 260));
describe('a browser with no update toast this load: the standalone card', () => {
it('greets a fresh install with the card, records the version, and does not fetch the notes', async () => {
const { app, fetchCalls, cards, updateCards, plainToasts, text, button, seenIds, seenVersion } = boot();
await app.checkWhatsNew(RUNNING);
expect(fetchCalls).toEqual([{ url: '/security-notices.json', init: { cache: 'no-cache' } }]);
expect(seenVersion()).toBe(RUNNING);
expect(plainToasts()).toEqual([]);
expect(updateCards()).toEqual([]);
const [card, ...rest] = cards();
expect(rest).toEqual([]);
expect(text(card, 'security-notice-eyebrow')).toBe('Security notice');
expect(text(card, 'security-notice-title')).toBe('Keys may be exposed');
expect(text(card, 'security-notice-body')).toBe('Rotate your keys.');
expect(button(card, 'security-notice-primary').textContent).toBe('Read more');
expect(button(card, 'security-notice-quiet').textContent).toBe('Dismiss');
expect(button(card, 'security-notice-close').getAttribute('aria-label')).toBe('Close');
// Nothing is recorded just for appearing.
expect(seenIds()).toBeNull();
});
it('shows it on a reload of the same version while it is unseen, and not once it is seen', async () => {
const unseen = boot({ seenVersion: RUNNING });
await unseen.app.checkWhatsNew(RUNNING);
expect(unseen.cards()).toHaveLength(1);
expect(unseen.fetchCalls.map((c) => c.url)).toEqual(['/security-notices.json']);
const seen = boot({ seenVersion: RUNNING, seenIds: ['cli-notice'] });
await seen.app.checkWhatsNew(RUNNING);
expect(seen.cards()).toEqual([]);
expect(seen.plainToasts()).toEqual([]);
});
it('is an accessible, sticky card in the toast stack, built from text only', async () => {
const hostile = {
notices: [
notice({
title: '<b>bold</b>',
message: '<img src=x onerror="alert(1)">',
link: { label: '<i>Go</i>', url: 'https://example.com/' },
}),
],
};
const { win, doc, app, cards, button } = boot({ notices: { ok: true, body: hostile } });
const timeout = vi.spyOn(win, 'setTimeout');
await app.checkWhatsNew(RUNNING);
const [card] = cards();
expect(card.parentElement?.className).toBe('toast-container');
expect(card.getAttribute('role')).toBe('alert');
expect(card.getAttribute('aria-live')).toBe('assertive');
expect(card.querySelector('.security-notice-icon svg')).not.toBeNull();
expect(card.querySelector('img, b, i')).toBeNull();
expect(card.querySelector('.security-notice-body')!.textContent).toBe('<img src=x onerror="alert(1)">');
for (const btn of card.querySelectorAll('button')) expect(btn.type).toBe('button');
expect(button(card, 'security-notice-primary').hasAttribute('data-i18n-skip')).toBe(true);
expect(timeout.mock.calls.filter(([, delay]) => Number(delay) > 0)).toEqual([]);
expect(doc.querySelectorAll('.toast-container').length).toBe(1);
});
for (const [how, cls, reason] of [
['the ×', 'security-notice-close', 'close'],
['Dismiss', 'security-notice-quiet', 'dismiss'],
] as const) {
it(`records the notice when the user closes it with ${how} (${reason}), and the next load stays quiet`, async () => {
const first = boot();
await first.app.checkWhatsNew(RUNNING);
const card = first.cards()[0];
first.button(card, cls).click();
first.button(card, cls).click(); // a double click while it fades
expect(first.seenIds()).toEqual(['cli-notice']);
await afterClose();
expect(first.cards()).toEqual([]);
const next = boot({ seenVersion: RUNNING, seenIds: ['cli-notice'] });
await next.app.checkWhatsNew(RUNNING);
expect(next.cards()).toEqual([]);
});
}
it('reports a close once, however many times the card is clicked while it fades', async () => {
for (const cls of ['security-notice-close', 'security-notice-quiet', 'security-notice-primary']) {
const { app, cards, button, opened } = boot();
const onClose = vi.fn();
app.showSecurityNotice(
{ id: 'x', title: '', message: 'm', label: 'Go', url: 'https://example.com/' },
{ onClose }
);
const card = cards()[0];
button(card, cls).click();
button(card, cls).click();
button(card, 'security-notice-close').click();
expect(onClose, cls).toHaveBeenCalledTimes(1);
// A double click on the link opens it once, not twice.
expect(opened, cls).toHaveLength(cls === 'security-notice-primary' ? 1 : 0);
}
});
it('draws no link button for a link that is not https, whoever calls it', async () => {
const { app, cards, opened } = boot();
const onClose = vi.fn();
app.showSecurityNotice({ id: 'x', title: '', message: 'm', label: 'Go', url: 'javascript:alert(1)' }, { onClose });
const card = cards()[0];
expect(card.querySelector('.security-notice-primary')).toBeNull();
expect(Array.from(card.querySelectorAll('.security-notice-actions button')).map((b) => b.textContent)).toEqual([
'Dismiss',
]);
expect(opened).toEqual([]);
});
it('opens the link with noopener and noreferrer, records the notice and closes the card', async () => {
const { app, cards, button, opened, seenIds } = boot({ seenIds: ['older-notice'] });
await app.checkWhatsNew(RUNNING);
button(cards()[0], 'security-notice-primary').click();
expect(opened).toEqual([['https://example.com/notice', '_blank', 'noopener,noreferrer']]);
expect(seenIds()).toEqual(['older-notice', 'cli-notice']);
await afterClose();
expect(cards()).toEqual([]);
});
it('merges with ids another tab recorded after this page loaded', async () => {
const { dom, app, cards, button, seenIds } = boot();
await app.checkWhatsNew(RUNNING);
dom.window.localStorage.setItem(SEEN_KEY, JSON.stringify(['from-another-tab']));
button(cards()[0], 'security-notice-close').click();
expect(seenIds()).toEqual(['from-another-tab', 'cli-notice']);
});
it('records nothing when the card is closed by code rather than by the user', async () => {
const { app } = boot();
const onClose = vi.fn();
app
.showSecurityNotice({ id: 'x', title: '', message: 'm', label: 'Go', url: 'https://example.com/' }, { onClose })
.dismiss();
await afterClose();
expect(onClose).not.toHaveBeenCalled();
});
it('takes the card when the notes cannot carry an update toast, and records no version', async () => {
for (const notes of [
{ ok: false } as Reply,
'throw' as Reply,
{ ok: true, body: { version: '9.9.9', highlights: ['x'] } },
]) {
const { app, cards, updateCards, plainToasts, seenVersion } = boot({ seenVersion: '1.42.0', notes });
await app.checkWhatsNew(RUNNING);
expect(cards(), JSON.stringify(notes)).toHaveLength(1);
expect(updateCards()).toEqual([]);
expect(plainToasts()).toEqual([]);
expect(seenVersion()).toBe('1.42.0');
}
});
it('treats a corrupt seen value as nothing seen', async () => {
const { dom, app, cards, button, seenIds } = boot();
dom.window.localStorage.setItem(SEEN_KEY, '{not json');
await app.checkWhatsNew(RUNNING);
expect(cards()).toHaveLength(1);
button(cards()[0], 'security-notice-close').click();
expect(seenIds()).toEqual(['cli-notice']);
});
});
describe('an existing install that sees a new version: the notice rides inside the update toast', () => {
it('shows ONE notification with the release text, the warning section and both buttons', async () => {
const { win, app, cards, updateCards, plainToasts, text, seenIds, seenVersion } = boot({ seenVersion: '1.42.0' });
const timeout = vi.spyOn(win, 'setTimeout');
await app.checkWhatsNew(RUNNING);
expect(cards()).toEqual([]);
expect(plainToasts()).toEqual([]);
const [card, ...rest] = updateCards();
expect(rest).toEqual([]);
expect(card.getAttribute('role')).toBe('alert');
expect(text(card, 'update-notice-text')).toBe(UPDATE_TEXT);
const section = card.querySelector('.security-notice-section')!;
expect(section.querySelector('svg')).not.toBeNull();
expect(text(section, 'security-notice-eyebrow')).toBe('Security notice');
expect(text(section, 'security-notice-title')).toBe('Keys may be exposed');
expect(text(section, 'security-notice-body')).toBe('Rotate your keys.');
const labels = Array.from(card.querySelectorAll('.security-notice-actions button')).map((b) => b.textContent);
expect(labels).toEqual(['Read more', "See what's new"]);
// The version is recorded on show, as checkWhatsNew always did; the notice is not.
expect(seenVersion()).toBe(RUNNING);
expect(seenIds()).toBeNull();
// Sticky: it carries a warning.
expect(timeout.mock.calls.filter(([, delay]) => Number(delay) > 0)).toEqual([]);
});
it('records the notice when the user closes it', async () => {
const { app, updateCards, button, seenIds } = boot({ seenVersion: '1.42.0' });
await app.checkWhatsNew(RUNNING);
button(updateCards()[0], 'security-notice-close').click();
expect(seenIds()).toEqual(['cli-notice']);
await afterClose();
expect(updateCards()).toEqual([]);
});
it('records the notice and closes when the user follows the notice link', async () => {
const { app, updateCards, button, opened, seenIds } = boot({ seenVersion: '1.42.0' });
await app.checkWhatsNew(RUNNING);
button(updateCards()[0], 'security-notice-primary').click();
button(updateCards()[0], 'security-notice-primary').click(); // a double click while it fades
expect(opened).toEqual([['https://example.com/notice', '_blank', 'noopener,noreferrer']]);
expect(seenIds()).toEqual(['cli-notice']);
await afterClose();
expect(updateCards()).toEqual([]);
});
it('carries two notices as two sections, and a link records only its own notice', async () => {
const two = {
notices: [
notice({ id: 'first', link: { label: 'First', url: 'https://example.com/1' } }),
notice({ id: 'second', link: { label: 'Second', url: 'https://example.com/2' } }),
],
};
const { app, updateCards, opened, seenIds } = boot({ seenVersion: '1.42.0', notices: { ok: true, body: two } });
await app.checkWhatsNew(RUNNING);
const [card] = updateCards();
expect(card.querySelectorAll('.security-notice-section')).toHaveLength(2);
const labels = Array.from(card.querySelectorAll('.security-notice-actions button')).map((b) => b.textContent);
expect(labels).toEqual(['First', 'Second', "See what's new"]);
card.querySelectorAll<HTMLButtonElement>('.security-notice-primary')[1].click();
expect(opened).toEqual([['https://example.com/2', '_blank', 'noopener,noreferrer']]);
// The other notice was never answered, so it comes back next load (as a card).
expect(seenIds()).toEqual(['second']);
});
it('opens the release notes from "See what\'s new" and keeps the unanswered warning up', async () => {
const { app, updateCards, button, opened, seenIds } = boot({ seenVersion: '1.42.0' });
await app.checkWhatsNew(RUNNING);
button(updateCards()[0], 'security-notice-secondary').click();
expect(opened).toEqual([[RELEASE_URL, '_blank', 'noopener,noreferrer']]);
expect(seenIds()).toBeNull();
await afterClose();
expect(updateCards()).toHaveLength(1);
});
it('reports each notice once, and opens nothing, once the card is closing', async () => {
const { app, updateCards, button, opened } = boot();
const onNoticeSeen = vi.fn();
const one = { id: 'x', title: '', message: 'm', label: 'Go', url: 'https://example.com/' };
app.showUpdateNotice({ text: 'Updated', releaseUrl: RELEASE_URL, notices: [one], onNoticeSeen });
const card = updateCards()[0];
button(card, 'security-notice-close').click();
button(card, 'security-notice-close').click();
button(card, 'security-notice-primary').click();
button(card, 'security-notice-secondary').click();
expect(onNoticeSeen.mock.calls).toEqual([['x']]);
expect(opened).toEqual([]);
});
it('keeps the update toast exactly as it was when no notice is selected', async () => {
for (const variant of [
{ available: { somecli: false } },
{ available: null },
{ seenIds: ['cli-notice'] },
{ notices: { ok: false } as Reply },
{ notices: 'throw' as Reply },
] as BootOpts[]) {
const { win, app, cards, updateCards, plainToasts, opened, seenVersion } = boot({
seenVersion: '1.42.0',
...variant,
});
const timeout = vi.spyOn(win, 'setTimeout');
await app.checkWhatsNew(RUNNING);
expect(cards(), JSON.stringify(variant)).toEqual([]);
expect(updateCards()).toEqual([]);
const toasts = plainToasts();
expect(toasts.map((t) => t.outerHTML)).toEqual([PLAIN_UPDATE_TOAST]);
expect(timeout.mock.calls.map(([, delay]) => delay)).toContain(20000);
expect(seenVersion()).toBe(RUNNING);
toasts[0].querySelector<HTMLButtonElement>('button:not(.toast-close)')!.click();
expect(opened).toEqual([[RELEASE_URL, '_blank', 'noopener']]);
}
});
});
describe('the gates every surface shares', () => {
it('shows nothing, and fetches nothing, when storage is blocked', async () => {
for (const seenVersion of [undefined, '1.42.0']) {
const { app, fetchCalls, cards, updateCards, plainToasts } = boot({ storage: 'blocked', seenVersion });
await app.checkWhatsNew(RUNNING);
expect(fetchCalls).toEqual([]);
expect([...cards(), ...updateCards(), ...plainToasts()]).toEqual([]);
}
});
it('shows nothing when the CLI is not installed or availability was never injected', async () => {
for (const available of [{ somecli: false }, {}, null]) {
const { app, cards, updateCards, seenIds } = boot({ available });
await app.checkWhatsNew(RUNNING);
expect([...cards(), ...updateCards()], JSON.stringify(available)).toEqual([]);
expect(seenIds()).toBeNull();
}
});
it('shows nothing when the notices file cannot be fetched or read', async () => {
for (const notices of [{ ok: false }, 'throw', { ok: true, body: { notices: 'x' } }] as Reply[]) {
const { app, cards } = boot({ notices });
await app.checkWhatsNew(RUNNING);
expect(cards(), JSON.stringify(notices)).toEqual([]);
}
});
it('checks once per page load, however many times init runs', async () => {
const { app, fetchCalls, cards } = boot({ seenVersion: RUNNING });
await app.checkWhatsNew(RUNNING);
await app.checkWhatsNew(RUNNING);
expect(fetchCalls).toHaveLength(1);
expect(cards()).toHaveLength(1);
});
it('fetches both files under a reverse-proxy base path, prefixed exactly once', async () => {
const { app, fetchCalls } = boot({ base: '/codeman', seenVersion: '1.42.0' });
await app.checkWhatsNew(RUNNING);
expect(fetchCalls.map((c) => c.url).sort()).toEqual(['/codeman/security-notices.json', '/codeman/whats-new.json']);
});
});
describe('a detached session window (/session/:id), rendered without CLI availability', () => {
it('leaves a pending notice and the update toast to the dashboard, recording nothing', async () => {
for (const seenVersion of ['1.42.0', RUNNING, undefined]) {
const {
app,
cards,
updateCards,
plainToasts,
seenIds,
seenVersion: version,
} = boot({
solo: true,
available: null,
seenVersion,
});
await app.checkWhatsNew(RUNNING);
expect([...cards(), ...updateCards(), ...plainToasts()], String(seenVersion)).toEqual([]);
expect(version()).toBe(seenVersion ?? null);
expect(seenIds()).toBeNull();
}
});
it('then lets the dashboard carry the notice inside its update toast', async () => {
const { win, app, updateCards, cards } = boot({ solo: true, available: null, seenVersion: '1.42.0' });
await app.checkWhatsNew(RUNNING);
// The dashboard's load: same browser storage, availability injected.
const dashboard = Object.create(Object.getPrototypeOf(app)) as WiredApp;
win.__codemanCliAvailable = { somecli: true };
await dashboard.checkWhatsNew(RUNNING);
expect(cards()).toEqual([]);
expect(updateCards()).toHaveLength(1);
});
it('shows the plain update toast as before when no notice is pending', async () => {
for (const variant of [{ seenIds: ['cli-notice'] }, { notices: { ok: false } as Reply }] as BootOpts[]) {
const { app, plainToasts, cards, seenVersion } = boot({
solo: true,
available: null,
seenVersion: '1.42.0',
...variant,
});
await app.checkWhatsNew(RUNNING);
expect(
plainToasts().map((t) => t.outerHTML),
JSON.stringify(variant)
).toEqual([PLAIN_UPDATE_TOAST]);
expect(cards()).toEqual([]);
expect(seenVersion()).toBe(RUNNING);
}
});
});
describe('zh-CN', () => {
it("shows the card in the notice's own Chinese and translates the card's own copy", async () => {
const { app, cards, text, button } = boot({ lang: 'zh-CN', i18n: true });
await app.checkWhatsNew(RUNNING);
await tick();
const [card] = cards();
expect(text(card, 'security-notice-eyebrow')).toBe('安全提示');
expect(text(card, 'security-notice-title')).toBe('密钥可能泄露');
expect(text(card, 'security-notice-body')).toBe('中文提示。');
expect(button(card, 'security-notice-primary').textContent).toBe('查看');
expect(button(card, 'security-notice-quiet').textContent).toBe('忽略');
expect(button(card, 'security-notice-close').getAttribute('aria-label')).toBe('关闭');
for (const cls of ['security-notice-title', 'security-notice-body']) {
expect(card.querySelector(`.${cls}`)!.hasAttribute('data-i18n-skip'), cls).toBe(true);
}
});
it('translates the update text and "See what\'s new" inside the combined toast', async () => {
const { app, updateCards, text, button } = boot({ lang: 'zh-CN', i18n: true, seenVersion: '1.42.0' });
await app.checkWhatsNew(RUNNING);
await tick();
const [card] = updateCards();
expect(text(card, 'update-notice-text')).toBe('Codeman 已更新到 v1.43.0:Alpha · Beta · Gamma(另有 1 项)');
expect(button(card, 'security-notice-secondary').textContent).toBe('查看更新内容');
expect(text(card, 'security-notice-body')).toBe('中文提示。');
});
it('leaves English notice text untranslated even when it matches a dictionary entry', async () => {
const plain = {
notices: [notice({ message: 'Settings saved', link: { label: 'Dismiss', url: 'https://example.com/' } })],
};
const { app, cards, text, button } = boot({ lang: 'zh-CN', i18n: true, notices: { ok: true, body: plain } });
await app.checkWhatsNew(RUNNING);
await tick();
expect(text(cards()[0], 'security-notice-body')).toBe('Settings saved');
expect(button(cards()[0], 'security-notice-primary').textContent).toBe('Dismiss');
});
});
describe('the card styles', () => {
const css = read('styles.css');
const block = css.slice(css.indexOf('.security-notice {'), css.indexOf('/* Notification Bell Button */'));
it('lives beside the toast rules and uses theme tokens, not fixed colours, for its ink', () => {
expect(css.indexOf('.toast-info {')).toBeLessThan(css.indexOf('.security-notice {'));
for (const token of ['var(--bg-card)', 'var(--text)', 'var(--text-dim)', 'var(--yellow)', 'var(--red)']) {
expect(block, token).toContain(token);
}
expect(block).not.toMatch(/(?<!rgba\()#[0-9a-f]{3,8}\b/i);
});
it('animates opacity and transform only, and nothing under prefers-reduced-motion', () => {
const rule = block.slice(0, block.indexOf('}'));
expect(rule).toMatch(/transition: opacity [^;]*, transform [^;]*;/);
expect(block).toMatch(
/@media \(prefers-reduced-motion: reduce\) \{\s*\.security-notice \{\s*transform: none;\s*transition: none;/
);
});
it('fills a phone width minus the stack gutters', () => {
expect(block).toMatch(/@media \(max-width: 599px\) \{\s*\.security-notice \{\s*width: calc\(100vw - 1rem\);/);
});
it('caps its height and scrolls inside itself, since the fixed stack cannot scroll', () => {
const rule = block.slice(0, block.indexOf('}'));
expect(rule).toContain('max-height: calc(var(--app-height, 100dvh) - var(--header-height) - 1rem);');
expect(rule).toContain('overflow-y: auto;');
// A positioned accent bar would scroll away with the content.
expect(block).not.toContain('.security-notice::before');
});
it('darkens the amber ink on the light skins only', () => {
expect(block).toContain('--notice-ink: var(--yellow);');
expect(block).toMatch(
/html:is\(\[data-skin="paper-gray"\], \[data-skin="solarized-light"\], \[data-skin="catppuccin-latte"\], \[data-skin="rose-pine-dawn"\]\) \.security-notice \{\s*--notice-ink: color-mix\(in srgb, var\(--yellow\) 85%, black\);/
);
});
});
+153 -4
View File
@@ -1,6 +1,6 @@
/**
* Working/idle detection for an interactive agent pane, Claude's, Codex's, pi's, opencode's, omp's
* and Gemini CLI's.
* Gemini CLI's and Antigravity's.
*
* 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
@@ -15,8 +15,9 @@
*
* The status-line fixtures below are verbatim captures from live panes
* (`tmux -L codeman capture-pane -p`) on Claude Code 2.1.220, Codex CLI 0.152.1, pi 1.1.0,
* opencode 1.3.0, omp 18.8.6 / 18.0.11 and Gemini CLI 0.63.0 (its turns driven by a local
* stand-in for the Gemini API, since the CLI's look does not depend on the backend).
* opencode 1.3.0, omp 18.8.6 / 18.0.11, Gemini CLI 0.63.0 (its turns driven by a local
* stand-in for the Gemini API, since the CLI's look does not depend on the backend) and
* Antigravity `agy` 1.3.3.
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
@@ -51,7 +52,7 @@ function feed(session: Session, data: string): void {
*/
function withFakePane(
screen: string | (() => string),
mode: 'claude' | 'codex' | 'pi' | 'opencode' | 'omp' | 'gemini' = 'claude'
mode: 'claude' | 'codex' | 'pi' | 'opencode' | 'omp' | 'gemini' | 'antigravity' = 'claude'
): Session {
const read = typeof screen === 'function' ? screen : () => screen;
const mux = {
@@ -978,6 +979,154 @@ describe('gemini interactive idle detection', () => {
});
});
/**
* Antigravity's (`agy` 1.3.3) pane, verbatim from live captures (rules shortened). A running
* turn animates an 8-dot braille frame and a label one row above the composer, and the
* footer reads `esc to cancel`; at rest the footer reads `? for shortcuts`. The composer
* row is `>`, which the echoed prompt starts with as well.
*/
const AGY_RULE = '─'.repeat(48);
const AGY_FOOTER_REST = '? for shortcuts Gemini 3.8 Flash · medium\n';
const AGY_FOOTER_WORKING = 'esc to cancel Gemini 3.8 Flash · medium\n';
const AGY_WORKING = `> say ok\n⣷ Generating...\n${AGY_RULE}\n>\n${AGY_RULE}\n${AGY_FOOTER_WORKING}`;
const AGY_AT_REST = ` ok\n${AGY_RULE}\n>\n${AGY_RULE}\n${AGY_FOOTER_REST}`;
/** A shell call replaces the composer with a confirmation list and the spinner stops. */
const AGY_CONFIRM =
'> Run the shell command: sleep 6 && echo done\n sleep 6 && echo done\nRun this command?\n' +
"> 1. Yes, run command\n 2. Yes, and always allow in this conversation for commands that start with 'sleep'\n" +
' 4. No, cancel\n ↑/↓ Navigate · tab Amend · ctrl+g edit/expand command\n';
/** One spinner frame on the wire, verbatim: the glyph is not one of SPINNER_PATTERN's. */
const AGY_SPINNER_FRAME = '\r\n\x1b[34;1m> say ok\x1b[m\x1b[K\r\n⣷ \x1b[90mGenerating...\x1b[m\x1b[K\r\n';
/** The turn's last repaint, verbatim (shortened): the footer hint comes back. */
const AGY_TURN_END = `${AGY_RULE}\x1b[m\r\n\x1b[90m? for shortcuts\x1b[m\x1b[80X\x1b[80C\x1b[2mGemini 3.8 Flash · medium\r`;
describe("antigravity's work-detection descriptor", () => {
const agy = getCli('antigravity')?.capabilities.workDetect;
const working = () => new RegExp(agy!.workingLine);
it('matches every frame of the spinner and every label, on screen and on the wire', () => {
expect(working().test(AGY_WORKING)).toBe(true);
expect(working().test(stripAnsi(AGY_SPINNER_FRAME))).toBe(true);
for (const frame of ['⣯', '⡿', '⣻', '⣾', '⣷', '⣟', '⢿', '⣽']) {
for (const label of ['Generating...', 'Working...', 'Loading...']) {
expect(working().test(`${frame} ${label}`)).toBe(true);
}
}
});
it('does not match a pane at rest, a pending confirmation, or prose', () => {
expect(working().test(AGY_AT_REST)).toBe(false);
expect(working().test(AGY_CONFIRM)).toBe(false);
expect(working().test('Loading... is what the spinner label reads\n')).toBe(false);
// An answer that quotes the spinner row mid-line must not hold the tab busy.
expect(working().test(` ok\n it matches \`⣯ Generating...\` in the pane\n${AGY_RULE}\n>\n`)).toBe(false);
});
it('names the footer hint the turn end carries, and no working repaint does', () => {
expect(AGY_TURN_END).toContain(agy!.promptGlyph);
expect(AGY_AT_REST).toContain(agy!.promptGlyph);
expect(AGY_WORKING).not.toContain(agy!.promptGlyph);
expect(AGY_SPINNER_FRAME).not.toContain(agy!.promptGlyph);
});
it('leaves the submit verifier unable to press Enter on an antigravity pane', () => {
// The composer row starts with `>`, but the glyph is the footer hint, so a prompt
// typed into the composer (or echoed above it) is never read as "still unsubmitted".
const typed = AGY_AT_REST.replace('>\n', '> say ok\n');
expect(promptStillInComposer(typed, 'say ok', agy!.promptGlyph)).not.toBe(true);
expect(promptStillInComposer(AGY_WORKING, 'say ok', agy!.promptGlyph)).not.toBe(true);
expect(promptStillInComposer(AGY_CONFIRM, 'Run the shell command', agy!.promptGlyph)).not.toBe(true);
});
});
describe('antigravity interactive activity', () => {
afterEach(() => {
vi.useRealTimers();
});
function spin(session: Session, ms: number): void {
for (let t = 0; t < ms; t += 100) {
feed(session, AGY_SPINNER_FRAME);
vi.advanceTimersByTime(100);
}
}
it('shows a turn as working, and lets it end instead of latching busy', () => {
vi.useFakeTimers();
let screen = AGY_WORKING;
const session = withFakePane(() => screen, 'antigravity');
(session as unknown as { _status: string })._status = 'idle';
const events: string[] = [];
session.on('working', () => events.push('working'));
session.on('idle', () => events.push('idle'));
// Before antigravity declared its spinner line no frame ever marked the tab working.
spin(session, 4000);
expect(events).toEqual(['working']);
expect(session.status).toBe('busy');
screen = AGY_AT_REST;
feed(session, AGY_TURN_END);
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['working', 'idle']);
expect(session.status).toBe('idle');
});
it('keeps a pending shell confirmation working until it is answered, and ends the resumed turn too', () => {
vi.useFakeTimers();
let screen = AGY_WORKING;
const session = withFakePane(() => screen, 'antigravity');
(session as unknown as { _status: string })._status = 'idle';
const events: string[] = [];
session.on('working', () => events.push('working'));
session.on('idle', () => events.push('idle'));
spin(session, 3000);
expect(events).toEqual(['working']);
screen = AGY_CONFIRM;
feed(session, 'Run this command?\r\n');
vi.advanceTimersByTime(60_000);
// The footer hint is not drawn under the confirmation, so nothing arms the idle check:
// the pane stays working (unlike gemini, whose bar returns on every repaint) until the
// user answers and the turn ends below.
expect(events).toEqual(['working']);
expect(session.status).toBe('busy');
screen = AGY_WORKING;
spin(session, 3000);
screen = AGY_AT_REST;
feed(session, AGY_TURN_END);
vi.advanceTimersByTime(20_000);
expect(events[0]).toBe('working');
expect(events[events.length - 1]).toBe('idle');
expect(session.status).toBe('idle');
});
});
describe('antigravity reattach', () => {
afterEach(() => {
vi.useRealTimers();
});
// Declaring workDetect turns off the 3 s settle for a restored pane, so a reattached agy pane
// depends on the footer hint arriving in tmux's attach repaint to leave `busy`.
it('settles a reattached antigravity pane that is at rest', () => {
vi.useFakeTimers();
const session = withFakePane(AGY_AT_REST, 'antigravity');
(session as unknown as { _status: string })._status = 'busy';
const events: string[] = [];
session.on('idle', () => events.push('idle'));
feed(session, AGY_TURN_END);
vi.advanceTimersByTime(20_000);
expect(events).toEqual(['idle']);
expect(session.status).toBe('idle');
});
});
describe('wire activity stamp across recovery', () => {
// The stamp both home screens sort the quiet group on. Recovery restores the
// previous run's value, and the settle window keeps the boot attach repaint
+154
View File
@@ -16,6 +16,17 @@
*/
import { describe, expect, it, vi, afterEach } from 'vitest';
import { Session } from '../src/session.js';
// The restart case below starts a real direct-PTY session; the real `claude` must never
// run from a test, so pty.spawn runs a harmless stand-in instead.
vi.mock('node-pty', async (importOriginal) => {
const real = await importOriginal<typeof import('node-pty')>();
return {
...real,
spawn: (_file: string, _args: string[] | string, options: import('node-pty').IPtyForkOptions) =>
real.spawn(process.execPath, ['-e', 'setInterval(() => {}, 1000)'], options),
};
});
import {
isTrustDialogScreen,
compactScreenText,
@@ -239,6 +250,48 @@ describe('Session trust-dialog auto-accept', () => {
expect(writes).toEqual([]);
});
it('reads the dialog that painted inside the throttle of the read before it', () => {
vi.useFakeTimers();
// Measured on 2.1.294: one read at +156 ms saw a blank pane, the dialog painted
// within the throttle, and a quiet dialog sends no further output to read on.
let screen = '';
const { writes, tick } = sessionShowing(() => screen);
tick();
vi.advanceTimersByTime(500);
screen = RENDERED_DIALOG_2_1_252;
tick(); // the dialog's own paint: throttled, and the last output the pane produces
vi.advanceTimersByTime(5000);
expect(writes[0]).toBe(TRUST_KEY_DOWN);
});
it('cancels a deferred read when the session is stopped', async () => {
vi.useFakeTimers();
// DELETE goes through stop(), and a read deferred just before it must not outlive it.
let screen = '';
const { session, tick } = sessionShowing(() => screen);
tick();
screen = RENDERED_DIALOG_2_1_252;
const before = vi.getTimerCount();
tick(); // throttled, so a read is deferred
expect(vi.getTimerCount()).toBe(before + 1);
await session.stop(false);
expect(vi.getTimerCount()).toBe(0);
});
it('neither scans nor arms a timer once the session is stopped', async () => {
vi.useFakeTimers();
// Killing the pane produces output of its own, and the buffer tail still shows the
// dialog, so a deleted session went on "answering" it on a timer for several seconds.
const { session, tick } = sessionShowing(() => RENDERED_DIALOG_2_1_252);
await session.stop(false);
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
tick();
tick(); // inside the throttle of the first
expect(vi.getTimerCount()).toBe(0);
expect(log.mock.calls.some(([line]) => String(line).includes('trust dialog'))).toBe(false);
log.mockRestore();
});
it('does not press Enter on a normal screen', () => {
vi.useFakeTimers();
const { writes, tick } = sessionShowing(() => RENDERED_MAIN_UI);
@@ -249,3 +302,104 @@ describe('Session trust-dialog auto-accept', () => {
expect(writes).toEqual([]);
});
});
describe('Session idle detection while the trust dialog is up', () => {
afterEach(() => vi.useRealTimers());
/** A just-launched pane (busy, inside the startup window) rendering `screen` (null: capture failed). */
function launchingSession(screen: () => string | null) {
const mux = { isAvailable: () => true, capturePaneText: () => screen() };
const session = new Session({
workingDir: '/tmp',
mode: 'claude',
mux,
muxSession: { muxName: 'codeman-test', sessionId: 'test', createdAt: Date.now() },
} as unknown as ConstructorParameters<typeof Session>[0]);
const internals = session as unknown as {
_detectInteractiveActivity(data: string): void;
_markActivity(): void;
_interactiveStartedAt: number;
_status: string;
};
internals._interactiveStartedAt = Date.now();
internals._status = 'busy'; // what startInteractive() leaves a new pane at
let idles = 0;
session.on('idle', () => idles++);
/** One repaint carrying a `❯`, which is what arms the idle confirmation. */
const paint = (chunk: string) => {
internals._markActivity();
internals._detectInteractiveActivity(chunk);
};
return { session, paint, idles: () => idles };
}
it('does not call the dialog idle, although its own ❯ arms the check', () => {
vi.useFakeTimers();
const { session, paint, idles } = launchingSession(() => RENDERED_DIALOG_2_1_252);
paint(RENDERED_DIALOG_2_1_252);
vi.advanceTimersByTime(30_000);
expect(session.status).toBe('busy');
expect(idles()).toBe(0);
});
it('goes idle once the dialog has been answered and the composer is up', () => {
vi.useFakeTimers();
let screen = RENDERED_DIALOG_2_1_252;
const { session, paint, idles } = launchingSession(() => screen);
paint(RENDERED_DIALOG_2_1_252);
vi.advanceTimersByTime(10_000);
expect(session.status).toBe('busy');
// No new output needed: the check keeps re-reading the screen on its own.
screen = RENDERED_MAIN_UI.replace('✻ Actualizing… (13m 23s · ↓ 47.5k tokens)', '');
vi.advanceTimersByTime(10_000);
expect(session.status).toBe('idle');
expect(idles()).toBe(1);
});
it('keeps holding when a capture fails after the dialog was seen', () => {
vi.useFakeTimers();
// A failed capture says nothing about whether the dialog went away.
let screen: string | null = RENDERED_DIALOG_2_1_252;
const { session, paint, idles } = launchingSession(() => screen);
paint(RENDERED_DIALOG_2_1_252);
vi.advanceTimersByTime(5000);
screen = null;
vi.advanceTimersByTime(20_000);
expect(session.status).toBe('busy');
expect(idles()).toBe(0);
});
it("does not carry the last pane's dialog verdict into a restarted pane", async () => {
// The session saw the dialog on its last pane, then restarted where no capture can
// replace that verdict (here the direct-PTY fallback, which has no pane to read; a
// failed capture keeps it the same way). The new pane must still be able to go idle.
const session = new Session({ workingDir: '/tmp', mode: 'claude', useMux: false });
(session as unknown as { _lastPaneProbeTrustDialog: boolean })._lastPaneProbeTrustDialog = true;
try {
await session.startInteractive();
vi.useFakeTimers();
const internals = session as unknown as {
_detectInteractiveActivity(data: string): void;
_markActivity(): void;
};
internals._markActivity();
internals._detectInteractiveActivity('❯ ');
vi.advanceTimersByTime(30_000);
expect(session.status).toBe('idle');
} finally {
vi.useRealTimers();
await session.stop();
}
});
it('lets a dialog-looking screen go idle outside the startup window', () => {
vi.useFakeTimers();
// A live agent can print the dialog's words hours in, and must not be held busy.
const { session, paint } = launchingSession(() => RENDERED_DIALOG_2_1_252);
vi.advanceTimersByTime(10 * 60_000);
paint(RENDERED_DIALOG_2_1_252);
vi.advanceTimersByTime(30_000);
expect(session.status).toBe('idle');
});
});
+36 -1
View File
@@ -11,6 +11,7 @@ import {
SessionWaitRegistry,
WaitCapacityError,
hooksAvailableForMode,
blockedSignalAvailable,
parseWaitSignals,
resolveWaitSignals,
signalForStatus,
@@ -100,6 +101,26 @@ describe('signalForStatus', () => {
});
});
describe('blockedSignalAvailable', () => {
it('is true where hooks report it, and for a CLI that declares a blockedLine (codex)', () => {
expect(blockedSignalAvailable('claude')).toBe(true);
expect(blockedSignalAvailable('codex')).toBe(true);
// Not keyed on hooks: codex has none, and its screen-read dialogs are still a source.
expect(hooksAvailableForMode('codex')).toBe(false);
});
it('is false for a CLI with neither (shell, gemini, opencode)', () => {
for (const mode of ['shell', 'gemini', 'opencode'] as const) {
expect(blockedSignalAvailable(mode)).toBe(false);
}
});
it('follows the per-session deepseek bridge, which is its only source', () => {
expect(blockedSignalAvailable('deepseek')).toBe(true);
expect(blockedSignalAvailable('deepseek', { deepSeekStatusReporting: false })).toBe(false);
});
});
describe('hooksAvailableForMode', () => {
it('is true only for claude', () => {
expect(hooksAvailableForMode('claude')).toBe(true);
@@ -144,8 +165,22 @@ describe('resolveWaitSignals', () => {
const result = resolveWaitSignals('stop', codex);
expect(result.until).toEqual([]);
expect(result.error).toContain('codex');
expect(resolveWaitSignals('blocked', codex).error).toBeTruthy();
expect(resolveWaitSignals('idle,stop', codex).error).toBeTruthy();
// gemini declares no blockedLine, so nothing can ever raise `blocked` for it.
expect(resolveWaitSignals('blocked', { mode: 'gemini' as SessionMode }).error).toBeTruthy();
});
it('accepts `blocked` on codex, whose dialogs are read off the screen, but still not `stop`', () => {
// capabilities.workDetect.blockedLine gives codex a source for `blocked`; nothing on a
// screen says a turn ended, so `stop` keeps its 400.
expect(resolveWaitSignals('blocked', codex)).toEqual({ until: ['blocked'], error: null });
expect(resolveWaitSignals('idle,blocked,exit', codex)).toEqual({ until: ['idle', 'blocked', 'exit'], error: null });
const stop = resolveWaitSignals('stop,blocked', codex);
expect(stop.until).toEqual([]);
expect(stop.error).toContain('stop');
expect(stop.error).not.toContain('Signal(s) stop, blocked');
// The suggestion names what does fire for codex.
expect(stop.error).toContain('idle, blocked or exit');
});
it('drops hook-only signals from the DEFAULT set instead of erroring', () => {
+18
View File
@@ -45,6 +45,24 @@ describe('worktree fields through the unified merge (#266)', () => {
expect(merged[0].gitBranch).toBeUndefined();
});
it('names the worktree of a LIVE session from its folder layout (no transcript record yet)', () => {
const merged = mergeUnifiedSessions({
live: [{ id: 'live1', name: 'w1-fix-login', mode: 'codex', workingDir: '/repo/.claude/worktrees/fix-login' }],
});
expect(merged[0]).toMatchObject({ worktreeName: 'fix-login', worktreeRepo: '/repo' });
});
it('lets a transcript record win over the layout, and ignores a subfolder of a worktree', () => {
const fromTranscript = mergeUnifiedSessions({
history: [historyRow({ worktreeName: 'named-by-claude', worktreeRepo: '/elsewhere' })],
});
expect(fromTranscript[0]).toMatchObject({ worktreeName: 'named-by-claude', worktreeRepo: '/elsewhere' });
const sub = mergeUnifiedSessions({
live: [{ id: 'live2', name: 'x', workingDir: '/repo/.claude/worktrees/fix-login/src' }],
});
expect(sub[0].worktreeName).toBeUndefined();
});
it('finds a session by worktree name and by branch', () => {
const items = [
{ sessionId: 'a', worktreeName: 'autodev', sources: ['history'] },
+178
View File
@@ -0,0 +1,178 @@
/**
* @fileoverview Multi-user mode, end to end: a non-admin cannot change the server-wide
* settings.json (live server, ephemeral port, real multi-user auth, real file on disk).
*
* The route tests (test/routes/system-routes-settings-admin-only.test.ts) pin each
* gate with a mocked filesystem. This one runs the real auth middleware and reads the
* real file back, so "settings.json is unchanged" is asserted on bytes:
* - PUT /api/settings, PUT /api/execution/model-config and PUT /api/cases/order are
* 403 FORBIDDEN for a non-admin, and the file is byte-identical afterwards;
* - GET /api/settings stays readable by the non-admin, without the admin's Deepgram
* key and case names (an admin still reads the file as it is);
* - a non-admin's quick-start does not record its case as `lastUsedCase` (a key every
* user can read through GET /api/settings), while an admin's still does;
* - an admin's PUT /api/settings still writes;
* - a non-admin's bare POST /api/auth/revoke signs out only their own logins: an admin's
* cookie still works afterwards (it used to clear every login on the server).
*/
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import fs from 'node:fs/promises';
import { dirname } from 'node:path';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
import { createUser, invalidateUsersCache } from '../src/user-store.js';
import { SETTINGS_PATH } from '../src/web/route-helpers.js';
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
const basic = (u: string, p: string) => 'Basic ' + Buffer.from(`${u}:${p}`).toString('base64');
const bob = { Authorization: basic('bob', 'bobpass1234') };
const root = { Authorization: basic('root', 'rootpass123') };
let server: WebServer;
const saved: Record<string, string | undefined> = {};
const url = (p: string) => `http://localhost:${server.boundPort}${p}`;
const INITIAL = {
autoNameSessions: false,
cliManagementEnabled: false,
lastUsedCase: 'rootcase',
caseOrder: ['rootcase'],
modelConfig: { defaultModel: 'sonnet' },
voiceSettings: { provider: 'deepgram', apiKey: 'dg-SECRET-live-456', language: 'en' },
};
const readSettingsBytes = () => fs.readFile(SETTINGS_PATH, 'utf-8');
async function send(method: string, path: string, headers: Record<string, string>, body?: unknown) {
const res = await fetch(url(path), {
method,
headers: { ...headers, ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
body: body === undefined ? undefined : JSON.stringify(body),
});
return { status: res.status, body: (await res.json()) as Record<string, any> };
}
/** The quick-start write of lastUsedCase is fire-and-forget: poll for it briefly. */
async function settleSettings(predicate: (s: Record<string, unknown>) => boolean): Promise<Record<string, unknown>> {
let last: Record<string, unknown> = {};
for (let i = 0; i < 40; i++) {
last = JSON.parse(await readSettingsBytes());
if (predicate(last)) return last;
await new Promise((r) => setTimeout(r, 25));
}
return last;
}
beforeAll(async () => {
for (const k of ['CODEMAN_MULTIUSER', 'CODEMAN_PASSWORD', 'CODEMAN_USERNAME']) saved[k] = process.env[k];
process.env.CODEMAN_MULTIUSER = '1';
delete process.env.CODEMAN_PASSWORD;
delete process.env.CODEMAN_USERNAME;
invalidateUsersCache();
await createUser({ username: 'root', role: 'admin', password: 'rootpass123' });
await createUser({ username: 'bob', role: 'user', password: 'bobpass1234' });
await fs.mkdir(dirname(SETTINGS_PATH), { recursive: true });
await fs.writeFile(SETTINGS_PATH, JSON.stringify(INITIAL, null, 2));
server = new WebServer(0, false, true);
await server.start();
});
afterAll(async () => {
await server?.stop();
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
invalidateUsersCache();
});
describe('a non-admin and the server-wide settings.json', () => {
it('cannot write it through any settings route, and the file stays byte-identical', async () => {
const before = await readSettingsBytes();
for (const [path, body] of [
['/api/settings', { autoNameSessions: true, cliManagementEnabled: true, runMode: 'shell' }],
['/api/execution/model-config', { defaultModel: 'opus' }],
['/api/cases/order', { order: ['bobcase'] }],
] as const) {
const res = await send('PUT', path, bob, body);
expect(res.status, path).toBe(403);
expect(res.body.success, path).toBe(false);
expect(res.body.errorCode, path).toBe('FORBIDDEN');
}
expect(await readSettingsBytes()).toBe(before);
});
it("can still read it, without the admin's Deepgram key and case names", async () => {
const res = await send('GET', '/api/settings', bob);
expect(res.status).toBe(200);
expect(res.body.data.autoNameSessions).toBe(false);
expect(JSON.stringify(res.body)).not.toContain('dg-SECRET-live-456');
expect(res.body.data.voiceSettings).toEqual({ provider: 'deepgram', language: 'en' });
expect('caseOrder' in res.body.data).toBe(false);
expect('lastUsedCase' in res.body.data).toBe(false);
const admin = await send('GET', '/api/settings', root);
expect(admin.body.data.voiceSettings.apiKey).toBe('dg-SECRET-live-456');
expect(admin.body.data.caseOrder).toEqual(['rootcase']);
});
it("does not get its quick-start case recorded as everyone's lastUsedCase; an admin's still is", async () => {
const mine = await send('POST', '/api/quick-start', bob, { caseName: 'bobproj' });
expect(mine.status).toBe(200);
expect(mine.body.success).toBe(true);
// Give a (wrong) fire-and-forget write every chance to land before checking.
await new Promise((r) => setTimeout(r, 200));
expect(JSON.parse(await readSettingsBytes()).lastUsedCase).toBe('rootcase');
const theirs = await send('POST', '/api/quick-start', root, { caseName: 'rootproj2' });
expect(theirs.status).toBe(200);
expect((await settleSettings((s) => s.lastUsedCase === 'rootproj2')).lastUsedCase).toBe('rootproj2');
});
it('does not get its Ralph loop case recorded as lastUsedCase either; an admin still does', async () => {
const mine = await send('POST', '/api/ralph-loop/start', bob, { caseName: 'bobralph', taskDescription: 'x' });
expect(mine.status).toBe(200);
expect(mine.body.success).toBe(true);
await new Promise((r) => setTimeout(r, 200));
expect(JSON.parse(await readSettingsBytes()).lastUsedCase).toBe('rootproj2');
const theirs = await send('POST', '/api/ralph-loop/start', root, { caseName: 'rootralph', taskDescription: 'x' });
expect(theirs.status).toBe(200);
expect((await settleSettings((s) => s.lastUsedCase === 'rootralph')).lastUsedCase).toBe('rootralph');
});
});
describe("a non-admin's Revoke All", () => {
/** Sign in with Basic auth and keep only the session cookie it mints. */
async function cookieFor(headers: Record<string, string>) {
const res = await fetch(url('/api/status'), { headers });
expect(res.status).toBe(200);
const cookie = (res.headers.get('set-cookie') || '').split(';')[0];
expect(cookie).toMatch(/^codeman_session=/);
return { Cookie: cookie };
}
it("signs out only the non-admin's own logins, never an admin's", async () => {
const rootCookie = await cookieFor(root);
const bobCookie = await cookieFor(bob);
expect((await fetch(url('/api/status'), { headers: rootCookie })).status).toBe(200);
const res = await send('POST', '/api/auth/revoke', bobCookie, {});
expect(res.status).toBe(200);
expect((await fetch(url('/api/status'), { headers: rootCookie })).status).toBe(200);
expect((await fetch(url('/api/status'), { headers: bobCookie })).status).toBe(401);
});
});
describe('an admin and the server-wide settings.json', () => {
it('still writes it', async () => {
const res = await send('PUT', '/api/settings', root, { autoNameSessions: true });
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(JSON.parse(await readSettingsBytes()).autoNameSessions).toBe(true);
});
});
+550
View File
@@ -0,0 +1,550 @@
/**
* @fileoverview Multi-user mode: a NON-ADMIN's browser and the admin-only settings writes.
*
* In multi-user mode PUT /api/settings (and PUT /api/execution/model-config,
* PUT /api/cases/order) answer 403 FORBIDDEN for a non-admin (system-routes.ts,
* case-routes.ts), because settings.json is one file for the whole server. The page
* must not break on that:
* - App Settings Save/Apply keeps saving the per-device keys to localStorage exactly
* as before, sends no settings PUT and no model-config PUT, keeps every SYNCED key
* at the server's value, and shows ONE "only an admin" toast, only when the user
* really changed a synced setting (an untouched synced key that no admin ever
* stored must not read as a change: that is what the form baseline is for).
* - The background writes (run mode, respawn presets, last used case, case order,
* the respawn-preset migration on load, the tunnel toggles) are not sent, and the
* non-admin's own run mode and respawn presets stay theirs on this device across a
* reload (the server's would otherwise overwrite them on every load).
* - A save sent before the role was known (/api/me slow or failed) that the server
* refuses with 403 FORBIDDEN ends like a known non-admin's: synced keys back to the
* server's value, the one admin toast (never "Settings saved"), read-only from then.
* - The display-key split does not depend on GET /api/settings having loaded.
* - Admins and single-user mode send everything as before.
* - Both toasts read in Chinese under zh-CN.
*
* The real modules run inside a JSDOM window (runScripts: 'outside-only') with the
* real index.html, so the form is the real App Settings form. Port: none.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import vm from 'node:vm';
import { JSDOM } from 'jsdom';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SettingsUpdateSchema } from '../src/web/schemas.js';
const PUBLIC = join(process.cwd(), 'src/web/public');
const read = (name: string) => readFileSync(join(PUBLIC, name), 'utf8');
const INDEX = read('index.html');
const I18N = read('i18n.js');
const SETTINGS_UI = read('settings-ui.js');
const SESSION_UI = read('session-ui.js');
const RESPAWN_UI = read('respawn-ui.js');
const SCRIPTS = [read('constants.js'), read('app.js'), SETTINGS_UI];
const DESKTOP_KEY = 'codeman-app-settings';
const SAVE_TOAST = 'Saved on this device. Server-wide settings can only be changed by an admin.';
const ADMIN_TOAST = 'Server-wide settings can only be changed by an admin';
type Settings = Record<string, unknown>;
type Who = 'non-admin' | 'admin' | 'single-user' | 'unknown';
/** `unknown`: GET /api/me has not answered yet, so admin-ui.js has set no identity. */
const IDENTITY: Record<Who, Settings | undefined> = {
'non-admin': { username: 'bob', role: 'user', multiUser: true },
admin: { username: 'root', role: 'admin', multiUser: true },
'single-user': { username: 'admin', role: 'admin', multiUser: false },
unknown: undefined,
};
/** What PUT /api/settings answers a non-admin in multi-user mode (system-routes.ts). */
const forbiddenResponse = () => {
const res = {
ok: false,
status: 403,
json: async () => ({ success: false, error: 'You do not have permission', errorCode: 'FORBIDDEN' }),
clone: () => res,
};
return res;
};
/**
* Methods that run for real. Every other method is a no-op: openAppSettings() and
* saveAppSettings() fan out into panels (voice, webhook, tunnel, push, header) this
* file is not about.
*/
const REAL = new Set([
'openAppSettings',
'saveAppSettings',
'applyAppSettings',
'loadAppSettingsFromServer',
'loadAppSettingsFromStorage',
'saveAppSettingsToStorage',
'getSettingsStorageKey',
'getDefaultSettings',
'tabCliLogosEnabled',
'resolveTabArrangement',
'resolveTabStateOrder',
'resolveHeaderStatsStyle',
'resolveSessionSidebarFontSize',
'planUsageChipEnabled',
'planUsageCollectionFlip',
'loadModelConfigForSettings',
'saveModelConfigFromSettings',
'_modelConfigFromSettingsForm',
'_settingsReadOnly',
'_captureSettingsFormBaseline',
'_keepServerSyncedSettings',
'_displaySettingKeySet',
'_noteSettingsWriteRefusal',
'_applyOwnSettings',
'_readOwnSettings',
'_ownSettingsKey',
]);
/** Methods the load/save paths call that other modules add to the prototype. */
const ELSEWHERE = [
'_updateLocalEchoState',
'renderProjectInsightsPanel',
'updateSubagentWindowVisibility',
'showWelcome',
'_applyRunMode',
];
const windows: { close(): void }[] = [];
afterEach(() => {
for (const win of windows.splice(0)) win.close();
});
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
/**
* Boot index.html with constants.js, app.js and settings-ui.js, as `who`, with `stored` as
* this device's blob and `server` as GET /api/settings (merged in by the real loader, which
* is also what publishes the display-key list the non-admin save reads).
*/
async function boot(who: Who, stored: Settings, server: Settings | null) {
const dom = new JSDOM(INDEX, { url: 'http://localhost/', runScripts: 'outside-only' });
const win = dom.window as unknown as Window &
typeof globalThis & { eval(src: string): void; __App: any; __codemanUser: Settings; fetch: unknown };
windows.push(win);
if (win.document.readyState !== 'complete') await new Promise((resolve) => win.addEventListener('load', resolve));
win.setInterval = (() => 0) as unknown as typeof win.setInterval;
win.localStorage.setItem(DESKTOP_KEY, JSON.stringify(stored));
const fetchCalls: { url: string; method: string; body?: string }[] = [];
win.fetch = vi.fn(async (url: string, opts: { method?: string; body?: string } = {}) => {
fetchCalls.push({ url, method: opts.method || 'GET', body: opts.body });
const data = url === '/api/execution/model-config' ? { defaultModel: 'sonnet', showRecommendations: true } : {};
return { ok: true, status: 200, json: async () => ({ success: true, data }) };
});
win.eval(
[
`var MobileDetection = {
getDeviceType: () => 'desktop',
isHandheldDevice: () => false,
isMobile: () => false,
isTouchDevice: () => false,
};
var KeyboardHandler = {}, SwipeHandler = {}, DeepgramProvider = {}, NotificationManager = function () {};
var KeyboardAccessoryBar = { setMode() {} };
var VoiceInput = { _getDeepgramConfig: () => ({}), _saveDeepgramConfig() {}, refreshClaudeStatus: () => Promise.resolve() };
var FocusTrap = function () { this.activate = () => {}; this.deactivate = () => {}; };
var DEFAULT_VOICE_KEYTERMS = '';`,
...SCRIPTS,
'window.__App = CodemanApp;',
].join('\n')
);
win.__codemanUser = IDENTITY[who] as Settings;
const target = Object.create(win.__App.prototype);
target.sessions = new Map();
target.sessionOrder = [];
target._apiPut = vi.fn(async () => ({ ok: true }));
target.showToast = vi.fn();
target.closeAppSettings = vi.fn();
const noop = () => undefined;
for (const name of ELSEWHERE) target[name] = noop;
const app = new Proxy(target, {
get(t, key, receiver) {
const value = Reflect.get(t, key, receiver);
const stubbed =
typeof key === 'string' && typeof value === 'function' && !Object.hasOwn(t, key) && !REAL.has(key);
return stubbed ? noop : value;
},
}) as any;
await app.loadAppSettingsFromServer(Promise.resolve(server));
const doc = win.document;
const box = (id: string) => doc.getElementById(id) as HTMLInputElement;
const storedBlob = () => JSON.parse(win.localStorage.getItem(DESKTOP_KEY) || '{}') as Settings;
const toasts = () => (target.showToast.mock.calls as unknown[][]).map((c) => c[0]);
const puts = () => fetchCalls.filter((c) => c.method === 'PUT');
return { win, doc, app, target, box, storedBlob, toasts, puts };
}
// What the server already holds for this instance: an admin has saved App Settings before.
const SERVER: Settings = { autoNameSessions: false, workspaceHooksEnabled: true, claudeMode: 'auto' };
describe('a non-admin in multi-user mode saving App Settings', () => {
it('keeps the per-device keys, sends no PUT, keeps the synced key at the server value, and toasts once', async () => {
const { app, target, box, storedBlob, toasts, puts } = await boot('non-admin', { ...SERVER }, SERVER);
await open_(app);
box('appSettingsTabTwoRows').checked = true; // per-device (displayKeys)
box('appSettingsAutoNameSessions').checked = true; // synced, server-wide
await app.saveAppSettings();
expect(target._apiPut).not.toHaveBeenCalled();
expect(puts()).toEqual([]);
expect(storedBlob().tabTwoRows).toBe(true);
expect(storedBlob().autoNameSessions).toBe(false);
expect(toasts()).toEqual([SAVE_TOAST]);
});
it('says "Settings saved" (no admin toast) when only per-device keys changed', async () => {
const { app, target, box, storedBlob, toasts, puts } = await boot('non-admin', { ...SERVER }, SERVER);
await open_(app);
box('appSettingsTabTwoRows').checked = true;
await app.saveAppSettings();
expect(target._apiPut).not.toHaveBeenCalled();
expect(puts()).toEqual([]);
expect(storedBlob().tabTwoRows).toBe(true);
expect(toasts()).toEqual(['Settings saved']);
});
it('does not read untouched synced keys no admin ever stored as a change', async () => {
// A fresh instance: the server holds nothing, so the blob has no synced keys while the
// form shows their defaults. Comparing against the blob would toast on every save.
const { app, box, storedBlob, toasts, puts } = await boot('non-admin', {}, {});
await open_(app);
box('appSettingsTabTwoRows').checked = true;
await app.saveAppSettings();
expect(toasts()).toEqual(['Settings saved']);
expect(puts()).toEqual([]);
// The synced keys the form read stay absent (the server default), never the form's copy.
expect('autoNameSessions' in storedBlob()).toBe(false);
expect('claudeMode' in storedBlob()).toBe(false);
expect(storedBlob().tabTwoRows).toBe(true);
// And a real change there still gets the one admin toast.
await open_(app);
box('appSettingsAutoNameSessions').checked = true;
await app.saveAppSettings();
expect(toasts()).toEqual(['Settings saved', SAVE_TOAST]);
expect('autoNameSessions' in storedBlob()).toBe(false);
});
it('does not send the model config either, and folds a change to it into the same one toast', async () => {
const { app, doc, toasts, puts } = await boot('non-admin', { ...SERVER }, SERVER);
await open_(app);
(doc.getElementById('appSettingsDefaultModel') as HTMLSelectElement).value = 'opus';
await app.saveAppSettings();
expect(puts()).toEqual([]);
expect(toasts()).toEqual([SAVE_TOAST]);
});
it('Apply behaves the same: nothing sent, the modal stays open', async () => {
const { app, target, box, toasts, puts } = await boot('non-admin', { ...SERVER }, SERVER);
await open_(app);
box('appSettingsAutoNameSessions').checked = true;
await app.applyAppSettings();
expect(target._apiPut).not.toHaveBeenCalled();
expect(puts()).toEqual([]);
expect(target.closeAppSettings).not.toHaveBeenCalled();
expect(toasts()).toEqual([SAVE_TOAST]);
});
});
describe('a save sent before the role was known, refused as admin-only', () => {
it('ends like a known non-admin save: synced key kept at the server value, one admin toast', async () => {
const { app, target, box, storedBlob, toasts, puts } = await boot('unknown', { ...SERVER }, SERVER);
target._apiPut = vi.fn(async () => forbiddenResponse());
await open_(app);
box('appSettingsTabTwoRows').checked = true; // per-device
box('appSettingsAutoNameSessions').checked = true; // synced
await app.saveAppSettings();
// The one PUT that went out before anything knew better, and nothing after it: the model
// config is not sent, and "Settings saved" (the old answer to a 403) is never shown.
expect(target._apiPut).toHaveBeenCalledTimes(1);
expect(puts()).toEqual([]);
expect(toasts()).toEqual([SAVE_TOAST]);
expect(storedBlob().tabTwoRows).toBe(true);
expect(storedBlob().autoNameSessions).toBe(false);
// Read-only from here on: the next save sends nothing.
expect(app._settingsReadOnly()).toBe(true);
await open_(app);
box('appSettingsAutoNameSessions').checked = true;
await app.saveAppSettings();
expect(target._apiPut).toHaveBeenCalledTimes(1);
expect(toasts()).toEqual([SAVE_TOAST, SAVE_TOAST]);
});
it('the same for a page whose /api/me failed (admin-ui.js falls back to a single-user admin)', async () => {
const { app, target, box, storedBlob, toasts } = await boot('single-user', { ...SERVER }, SERVER);
target._apiPut = vi.fn(async () => forbiddenResponse());
await open_(app);
box('appSettingsAutoNameSessions').checked = true;
await app.saveAppSettings();
expect(toasts()).toEqual([SAVE_TOAST]);
expect(storedBlob().autoNameSessions).toBe(false);
});
it('is not confused with the tunnel no-password 403 (another errorCode): that still prompts', async () => {
const { app, target } = await boot('admin', { ...SERVER }, SERVER);
const res = {
ok: false,
status: 403,
json: async () => ({ success: false, error: 'no password', errorCode: 'OPERATION_FAILED' }),
};
expect(await app._noteSettingsWriteRefusal({ ...res, clone: () => res })).toBe(false);
expect(app._settingsReadOnly()).toBe(false);
expect(target._settingsWriteForbidden).toBeUndefined();
});
});
describe('a non-admin save when GET /api/settings failed at page load', () => {
it('still keeps the synced keys at the stored value and toasts once', async () => {
const { app, box, storedBlob, toasts, puts } = await boot('non-admin', { ...SERVER }, null);
await open_(app);
box('appSettingsTabTwoRows').checked = true;
box('appSettingsAutoNameSessions').checked = true;
await app.saveAppSettings();
expect(puts()).toEqual([]);
expect(storedBlob().tabTwoRows).toBe(true);
expect(storedBlob().autoNameSessions).toBe(false);
expect(toasts()).toEqual([SAVE_TOAST]);
});
});
describe('an admin, and single-user mode, saving App Settings', () => {
for (const who of ['admin', 'single-user'] as const) {
it(`${who}: sends the settings PUT and the model config as before`, async () => {
const { app, target, box, storedBlob, toasts, puts } = await boot(who, { ...SERVER }, SERVER);
await open_(app);
box('appSettingsAutoNameSessions').checked = true;
await app.saveAppSettings();
expect(target._apiPut).toHaveBeenCalledTimes(1);
const [path, body] = target._apiPut.mock.calls[0] as [string, Settings];
expect(path).toBe('/api/settings');
expect(body.autoNameSessions).toBe(true);
expect(SettingsUpdateSchema.safeParse(body).error?.issues ?? []).toEqual([]);
expect(puts().map((p) => p.url)).toEqual(['/api/execution/model-config']);
expect(storedBlob().autoNameSessions).toBe(true);
expect(toasts()).toEqual(['Settings saved']);
});
}
});
/** Open App Settings and let its async model-config read land, as a user would. */
async function open_(app: { openAppSettings(): void }) {
app.openAppSettings();
await tick();
await tick();
}
// ─── Background writes (vm harness: settings-ui.js + session-ui.js + respawn-ui.js) ───
function loadBackground(who: Who, storage = new Map<string, string>()) {
const elements: Record<string, any> = {
quickStartCase: { value: 'mine' },
welcomeTunnelBtn: { classList: { contains: () => false }, disabled: false },
runModeMenu: { classList: { remove: () => {} } },
runBtn: { className: '', nextElementSibling: { className: '' } },
runBtnLabel: { textContent: '' },
};
const CodemanApp = function CodemanApp(this: any) {};
const fetchMock = vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ success: true, data: {} }) }));
const confirmMock = vi.fn(() => true);
const listeners: Record<string, (() => void)[]> = {};
const win: Record<string, unknown> = { __codemanUser: IDENTITY[who] };
const context = vm.createContext({
CodemanApp,
VoiceInput: {},
window: win,
localStorage: {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
},
document: {
getElementById: (id: string) => elements[id] ?? null,
addEventListener: (type: string, fn: () => void) => (listeners[type] ||= []).push(fn),
},
fetch: fetchMock,
confirm: confirmMock,
console,
});
vm.runInContext(SETTINGS_UI, context, { filename: 'settings-ui.js' });
vm.runInContext(SESSION_UI, context, { filename: 'session-ui.js' });
vm.runInContext(RESPAWN_UI, context, { filename: 'respawn-ui.js' });
const app = new (CodemanApp as any)();
app.loadAppSettingsFromStorage = () => ({});
app.saveAppSettingsToStorage = () => {};
app._apiPut = vi.fn(() => Promise.resolve({ ok: true }));
app.showToast = vi.fn();
app.loadQuickStartCases = vi.fn(async () => {});
app.renderCaseManageList = vi.fn();
app.closeTunnelPanel = vi.fn();
app._showTunnelConnecting = vi.fn();
app._pollTunnelStatus = vi.fn();
app._dismissTunnelConnecting = vi.fn();
app._updateWelcomeTunnelBtn = vi.fn();
app._updateTunnelIndicator = vi.fn();
win.app = app;
/** What admin-ui.js does once GET /api/me answers. */
const resolveMe = (identity: Settings) => {
win.__codemanUser = identity;
for (const fn of listeners['codeman:me'] || []) fn();
};
return { app, fetchMock, confirmMock, storage, resolveMe };
}
const settingsPuts = (b: ReturnType<typeof loadBackground>) => [
...b.app._apiPut.mock.calls.filter((c: unknown[]) => c[0] === '/api/settings'),
...b.fetchMock.mock.calls.filter((c: unknown[]) => c[0] === '/api/settings' || c[0] === '/api/cases/order'),
];
describe('background writes', () => {
it('a non-admin sends none of them, and the device keeps its own copy', async () => {
const b = loadBackground('non-admin');
b.app.setRunMode('codex');
expect(b.storage.get('codeman_runMode')).toBe('codex');
b.app.saveRespawnPresets([{ id: 'p', name: 'mine' }]);
expect(JSON.parse(b.storage.get('codeman-respawn-presets')!)).toEqual([{ id: 'p', name: 'mine' }]);
await b.app.saveLastUsedCase('mine');
await b.app.toggleTunnelFromWelcome();
await b.app._tunnelPanelToggle(true);
expect(settingsPuts(b)).toEqual([]);
expect(b.app.showToast.mock.calls.map((c: unknown[]) => c[0])).toEqual([ADMIN_TOAST, ADMIN_TOAST]);
});
it('a non-admin case reorder is not sent: one toast, and the list goes back to the server order', async () => {
const b = loadBackground('non-admin');
await b.app.saveCaseOrder(['b', 'a']);
expect(settingsPuts(b)).toEqual([]);
expect(b.app.showToast.mock.calls.map((c: unknown[]) => c[0])).toEqual([ADMIN_TOAST]);
expect(b.app.loadQuickStartCases).toHaveBeenCalledWith('mine');
expect(b.app.renderCaseManageList).toHaveBeenCalled();
});
it('a non-admin never runs the respawn-preset migration PUT on page load', async () => {
const b = loadBackground('non-admin');
b.storage.set('codeman-respawn-presets', JSON.stringify([{ id: 'p' }]));
await b.app.loadAppSettingsFromServer(Promise.resolve({ autoNameSessions: false }));
expect(settingsPuts(b)).toEqual([]);
});
it('a 403 FORBIDDEN on a tunnel enable is the admin-only answer, not the no-password prompt', async () => {
const b = loadBackground('admin');
const refused = await b.app._handleTunnelEnableRefusal({
ok: false,
status: 403,
json: async () => ({ success: false, error: 'You do not have permission', errorCode: 'FORBIDDEN' }),
});
expect(refused).toBe(true);
expect(b.confirmMock).not.toHaveBeenCalled();
expect(b.fetchMock).not.toHaveBeenCalled();
expect(b.app.showToast.mock.calls.map((c: unknown[]) => c[0])).toEqual([ADMIN_TOAST]);
});
it("a non-admin's own run mode and respawn presets survive a reload over the server's", async () => {
const before = loadBackground('non-admin');
before.app.setRunMode('codex');
before.app.saveRespawnPresets([{ id: 'mine', name: 'mine' }]);
expect(settingsPuts(before)).toEqual([]);
// The next page load, on the same device: the server holds an admin's picks.
const after = loadBackground('non-admin', before.storage);
await after.app.loadAppSettingsFromServer(
Promise.resolve({ runMode: 'shell', respawnPresets: [{ id: 'admin', name: 'admin' }] })
);
expect(after.app.runMode).toBe('codex');
expect(after.storage.get('codeman_runMode')).toBe('codex');
// What loadRespawnPresets() reads before anything else (respawn-ui.js).
expect(after.app._serverRespawnPresets).toEqual([{ id: 'mine', name: 'mine' }]);
expect(JSON.parse(after.storage.get('codeman-respawn-presets')!)).toEqual([{ id: 'mine', name: 'mine' }]);
expect(settingsPuts(after)).toEqual([]);
});
it('they win too when the role resolves only after the settings load (codeman:me)', async () => {
const before = loadBackground('non-admin');
before.app.setRunMode('codex');
const after = loadBackground('unknown', before.storage);
await after.app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'shell' }));
expect(after.app.runMode).toBe('shell');
after.resolveMe(IDENTITY['non-admin'] as Settings);
expect(after.app.runMode).toBe('codex');
expect(after.storage.get('codeman_runMode')).toBe('codex');
});
it("they are the user's own: another user, or an admin, on the same device gets the server's", async () => {
const before = loadBackground('non-admin');
before.app.setRunMode('codex');
const other = loadBackground('unknown', before.storage);
await other.app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'shell' }));
other.resolveMe({ username: 'carol', role: 'user', multiUser: true });
expect(other.app.runMode).toBe('shell');
const admin = loadBackground('admin', before.storage);
await admin.app.loadAppSettingsFromServer(Promise.resolve({ runMode: 'shell' }));
expect(admin.app.runMode).toBe('shell');
});
it('a background PUT refused as admin-only (role not known yet) makes the page read-only', async () => {
const b = loadBackground('unknown');
b.app._apiPut = vi.fn(() => Promise.resolve(forbiddenResponse()));
b.app.setRunMode('codex');
await tick();
await tick();
expect(b.app._settingsReadOnly()).toBe(true);
b.app.saveRespawnPresets([{ id: 'p' }]);
expect(b.app._apiPut).toHaveBeenCalledTimes(1);
});
for (const who of ['admin', 'single-user'] as const) {
it(`${who}: every background write is still sent`, async () => {
const b = loadBackground(who);
b.app.setRunMode('codex');
b.app.saveRespawnPresets([{ id: 'p' }]);
await b.app.saveLastUsedCase('mine');
await b.app.saveCaseOrder(['b', 'a']);
expect(b.app._apiPut.mock.calls.map((c: unknown[]) => c[0])).toEqual(['/api/settings', '/api/settings']);
expect(b.fetchMock.mock.calls.map((c: unknown[]) => c[0])).toEqual(['/api/settings', '/api/cases/order']);
b.storage.set('codeman-respawn-presets', JSON.stringify([{ id: 'p' }]));
b.app._apiPut.mockClear();
await b.app.loadAppSettingsFromServer(Promise.resolve({}));
expect(b.app._apiPut).toHaveBeenCalledWith('/api/settings', { respawnPresets: [{ id: 'p' }] });
});
}
});
// ─── zh-CN ───
describe('the admin-only toasts in zh-CN', () => {
type Api = { t(s: string): string; configure(o: Record<string, string>): void };
function translator(language: string) {
const dom = new JSDOM('<!doctype html><html><body></body></html>', {
runScripts: 'outside-only',
url: 'http://localhost/',
});
windows.push(dom.window);
vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' });
const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n;
api.configure({ language });
return api;
}
it('finds both toasts in the code that shows them', () => {
expect(SETTINGS_UI).toContain(`this.showToast('${SAVE_TOAST}', 'warning')`);
expect(SETTINGS_UI).toContain(`this.showToast('${ADMIN_TOAST}', 'warning')`);
expect(SESSION_UI).toContain(`this.showToast('${ADMIN_TOAST}', 'warning')`);
});
it('translates both, and leaves English as it is', () => {
const zh = translator('zh-CN');
const en = translator('en');
for (const text of [SAVE_TOAST, ADMIN_TOAST]) {
const t = zh.t(text);
expect(t, text).not.toBe(text);
expect(/[A-Za-z]/.test(t), t).toBe(false);
expect(en.t(text)).toBe(text);
}
});
});
+235
View File
@@ -0,0 +1,235 @@
/**
* @fileoverview Behaviour of shortcut rebinding: Alt+[ / Alt+] as registry entries, the
* user-bound gate that keeps a rebound chord out of the PTY, and refusal of duplicate chords.
*
* Real code under test: constants.js + app.js + settings-ui.js in one `vm` context (the
* makeApp() harness of ctrl-w-never-closes.test.ts, with settings-ui.js loaded after app.js).
*/
import { readFileSync } from 'node:fs';
import { performance } from 'node:perf_hooks';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const read = (f: string) => readFileSync(resolve(import.meta.dirname, `../src/web/public/${f}`), 'utf8');
type Shortcut = { id: string; label: string; action?: string; bindings?: unknown[]; disabled?: boolean };
type App = {
getShortcutRegistry(): Shortcut[];
matchesShortcutEvent(e: Record<string, unknown>, s: Shortcut): boolean;
isUserBoundShortcutEvent(e: Record<string, unknown>): boolean;
findShortcutConflict(e: Record<string, unknown>, id: string): string;
onShortcutCaptureKeydown(e: Record<string, unknown>, id: string): void;
toggleShortcutEnabled(id: string, enabled: boolean): void;
resetShortcutOverride(id: string): void;
loadAppSettingsFromStorage: () => Record<string, any>;
saveAppSettingsToStorage: (s: Record<string, any>) => void;
renderShortcutSettingsList: () => void;
showToast: (msg: string, type?: string) => void;
};
function makeApp(initial: Record<string, any> = {}) {
const settings: Record<string, any> = structuredClone(initial);
const context = vm.createContext({
console,
performance,
setInterval: vi.fn(),
clearInterval: vi.fn(),
setTimeout,
clearTimeout,
requestAnimationFrame: vi.fn(),
HTMLCanvasElement: class HTMLCanvasElement {},
WebSocket: { OPEN: 1 },
fetch: vi.fn(),
document: { addEventListener: vi.fn(), getElementById: () => null, querySelector: () => null },
localStorage: { length: 0, key: vi.fn(), getItem: vi.fn(), setItem: vi.fn(), removeItem: vi.fn() },
window: { addEventListener: vi.fn(), removeEventListener: vi.fn() },
MobileDetection: { getDeviceType: () => 'desktop', isMobile: () => false, isTouchDevice: () => false },
});
vm.runInContext(`${read('constants.js')}\n${read('app.js')}\nglobalThis.__CodemanApp = CodemanApp;`, context);
vm.runInContext(read('settings-ui.js'), context, { filename: 'settings-ui.js' });
const CodemanApp = (context as unknown as { __CodemanApp: { prototype: object } }).__CodemanApp;
const app = Object.create(CodemanApp.prototype) as App;
app.loadAppSettingsFromStorage = () => settings;
app.saveAppSettingsToStorage = (s) => {
const next = { ...s };
Object.keys(settings).forEach((k) => delete settings[k]);
Object.assign(settings, next);
};
app.renderShortcutSettingsList = vi.fn();
app.showToast = vi.fn();
return { app, settings };
}
const key = (o: Record<string, unknown>) => ({
type: 'keydown',
key: '',
code: '',
ctrlKey: false,
metaKey: false,
shiftKey: false,
altKey: false,
preventDefault: vi.fn(),
stopPropagation: vi.fn(),
...o,
});
const ownerOf = (app: App, e: Record<string, unknown>) =>
app.getShortcutRegistry().find((s) => !s.disabled && s.action && app.matchesShortcutEvent(e, s))?.action;
describe('Alt+[ and Alt+] are registry entries', () => {
it('resolve to previousSession and nextSession by physical key', () => {
const { app } = makeApp();
expect(ownerOf(app, key({ altKey: true, key: '[', code: 'BracketLeft' }))).toBe('previousSession');
expect(ownerOf(app, key({ altKey: true, key: ']', code: 'BracketRight' }))).toBe('nextSession');
});
it('match the macOS Option layout that prints a curly quote for Option+[', () => {
const { app } = makeApp();
expect(ownerOf(app, key({ altKey: true, key: '“', code: 'BracketLeft' }))).toBe('previousSession');
});
it('keep their Alt/Option+[ labels until rebound', () => {
const { app } = makeApp({
shortcutOverrides: { 'next-session-alt': { bindings: [{ modifiers: ['alt'], key: 'j' }] } },
});
const byId = (id: string) =>
app.getShortcutRegistry().find((s) => s.id === id) as Shortcut & { displayBindings?: string[] };
expect(byId('previous-session').displayBindings).toEqual(['Alt/Option+[']);
expect(byId('next-session-alt').displayBindings).toBeUndefined();
});
});
describe('isUserBoundShortcutEvent', () => {
const altJ = key({ altKey: true, key: 'j', code: 'KeyJ' });
it('is false with no overrides, even for a default chord', () => {
const { app } = makeApp();
expect(app.isUserBoundShortcutEvent(key({ altKey: true, key: '[', code: 'BracketLeft' }))).toBe(false);
expect(app.isUserBoundShortcutEvent(key({ ctrlKey: true, key: 'l', code: 'KeyL' }))).toBe(false);
});
it('is true for a chord the user rebound an action to', () => {
const { app } = makeApp({
shortcutOverrides: { 'previous-session': { bindings: [{ modifiers: ['alt'], key: 'j', code: 'KeyJ' }] } },
});
expect(app.isUserBoundShortcutEvent(altJ)).toBe(true);
});
it('is false for the copy-selection rebind, which has its own dispatch', () => {
const { app } = makeApp({
shortcutOverrides: { 'copy-selection': { bindings: [{ modifiers: ['alt'], key: 'j', code: 'KeyJ' }] } },
});
expect(app.isUserBoundShortcutEvent(altJ)).toBe(false);
});
it('is false for a rebound sidebar toggle, whose own gate lets the chord through', () => {
const { app } = makeApp({
shortcutOverrides: { 'toggle-session-sidebar': { bindings: [{ modifiers: ['alt'], key: 's', code: 'KeyS' }] } },
});
expect(app.isUserBoundShortcutEvent(key({ altKey: true, key: 's', code: 'KeyS' }))).toBe(false);
});
it('is false when the entry was re-captured onto its own default (Ctrl+L still reaches the CLI)', () => {
const { app } = makeApp({
shortcutOverrides: { 'clear-terminal': { bindings: [{ modifiers: ['ctrl'], key: 'l', code: 'KeyL' }] } },
});
expect(app.isUserBoundShortcutEvent(key({ ctrlKey: true, key: 'l', code: 'KeyL' }))).toBe(false);
});
it('is false for a disabled rebound entry', () => {
const { app } = makeApp({
shortcutOverrides: {
'previous-session': { bindings: [{ modifiers: ['alt'], key: 'j', code: 'KeyJ' }], disabled: true },
},
});
expect(app.isUserBoundShortcutEvent(altJ)).toBe(false);
});
});
describe('duplicate chords are refused', () => {
const ctrlL = key({ ctrlKey: true, key: 'l', code: 'KeyL' });
it("a capture onto another action's chord names the owner and stores nothing", () => {
const { app, settings } = makeApp();
app.onShortcutCaptureKeydown(ctrlL, 'next-session-alt');
expect(app.showToast).toHaveBeenCalledWith(expect.stringContaining('already used by Clear Terminal'), 'error');
expect(settings.shortcutOverrides).toBeUndefined();
});
it("a shortcut's own id and a disabled owner are free", () => {
const { app } = makeApp({ shortcutOverrides: { 'previous-session': { disabled: true } } });
expect(app.findShortcutConflict(ctrlL, 'clear-terminal')).toBe('');
expect(app.findShortcutConflict(key({ altKey: true, key: '[', code: 'BracketLeft' }), 'next-session-alt')).toBe('');
});
it.each([
['Alt+1', { altKey: true, key: '¡', code: 'Digit1' }],
['Cmd+Alt+1', { altKey: true, metaKey: true, key: '¡', code: 'Digit1' }],
['Ctrl+Enter', { ctrlKey: true, key: 'Enter', code: 'Enter' }],
['Shift+Enter', { shiftKey: true, key: 'Enter', code: 'Enter' }],
['Ctrl+V', { ctrlKey: true, key: 'v', code: 'KeyV' }],
['Cmd+V', { metaKey: true, key: 'v', code: 'KeyV' }],
])('%s is reserved', (_n, ev) => {
const { app } = makeApp();
expect(app.findShortcutConflict(key(ev), 'previous-session')).not.toBe('');
});
it('no two default shortcuts share a chord', () => {
const { app } = makeApp();
for (const s of app.getShortcutRegistry()) {
for (const b of (s.bindings || []) as { modifiers?: string[]; key?: string; code?: string }[]) {
const m = b.modifiers || [];
const ev = key({
ctrlKey: m.includes('ctrl'),
metaKey: m.includes('meta'),
shiftKey: m.includes('shift'),
altKey: m.includes('alt'),
key: b.key || '',
code: b.code || '',
});
expect({ id: s.id, owner: app.findShortcutConflict(ev, s.id) }).toEqual({ id: s.id, owner: '' });
}
}
});
it('re-enabling a disabled action whose chord was taken is refused and leaves it disabled', () => {
const { app, settings } = makeApp({
shortcutOverrides: {
'clear-terminal': { disabled: true },
'next-session-alt': { bindings: [{ modifiers: ['ctrl'], key: 'l', code: 'KeyL' }] },
},
});
app.toggleShortcutEnabled('clear-terminal', true);
expect(app.showToast).toHaveBeenCalledWith(
expect.stringContaining('already used by Next Session (alternate)'),
'error'
);
expect(settings.shortcutOverrides['clear-terminal'].disabled).toBe(true);
});
it('re-enabling with no clash enables it', () => {
const { app, settings } = makeApp({ shortcutOverrides: { 'clear-terminal': { disabled: true } } });
app.toggleShortcutEnabled('clear-terminal', true);
expect(settings.shortcutOverrides['clear-terminal'].disabled).toBe(false);
});
it('Reset is refused while another action owns the default chord, and works once it is free', () => {
const { app, settings } = makeApp({
shortcutOverrides: {
'clear-terminal': { bindings: [{ modifiers: ['ctrl'], key: 'j', code: 'KeyJ' }] },
'next-session-alt': { bindings: [{ modifiers: ['ctrl'], key: 'l', code: 'KeyL' }] },
},
});
app.resetShortcutOverride('clear-terminal');
expect(app.showToast).toHaveBeenCalledWith(
expect.stringContaining('already used by Next Session (alternate)'),
'error'
);
expect(settings.shortcutOverrides['clear-terminal']).toBeDefined();
app.resetShortcutOverride('next-session-alt');
app.resetShortcutOverride('clear-terminal');
expect(settings.shortcutOverrides['clear-terminal']).toBeUndefined();
});
});
+13 -1
View File
@@ -11,7 +11,8 @@
*
* That contract is load-bearing: assets are served `immutable` for a year, and
* `index.html` must revalidate every time or a deploy leaves browsers on stale
* markup (see `cacheBustAssets` in server.ts). No HTML is left behind the static
* markup (see `cacheBustAssets` in server.ts). The JSON data files the page fetches at
* runtime (whats-new.json, security-notices.json) revalidate too. No HTML is left behind the static
* plugin (every page has its own route), so the HTML half is asserted on the route.
*
* These tests drive a REAL WebServer on purpose. Asserting against an inline
@@ -73,6 +74,17 @@ describe('static asset Cache-Control headers', () => {
expect(res.headers.get('cache-control')).toBe('no-cache, no-store');
});
it('makes the runtime JSON data files revalidate instead of pinning them for a year', async () => {
// whats-new.json is rewritten every release and security-notices.json by hand; neither is
// content-hashed or `?v=`-busted, so an `immutable` copy could outlive the release that
// replaced it, and a security notice added later would never reach that browser.
for (const file of ['security-notices.json', 'whats-new.json', 'manifest.json']) {
const res = await get(`${baseUrl}/${file}`);
expect(res.status, file).toBe(200);
expect(res.headers.get('cache-control'), file).toBe('no-cache');
}
});
it('serves the rendered index with no-cache too, via its own route', async () => {
// Different code path from the above, same contract: index.html is generated per
// request (cacheBustAssets stamps ?v= onto every asset ref), so caching it would
+2
View File
@@ -82,6 +82,8 @@ const REAL = new Set([
// Real so the save builds the body a browser would send (the schema check).
'resolveHeaderStatsStyle',
'resolveSessionSidebarFontSize',
// The display-key list the load merge reads (settings-ui.js keeps it in one method).
'_displaySettingKeySet',
]);
/**
+2 -2
View File
@@ -138,8 +138,8 @@ describe('smart-copy wiring invariants', () => {
// the copy action were reachable from there, Ctrl+C would be swallowed with
// no selection and the user would lose the interrupt key.
const actionsBlock = APP_SOURCE.slice(
APP_SOURCE.indexOf('const SHORTCUT_ACTIONS = {'),
APP_SOURCE.indexOf('// Use capture to handle before terminal')
APP_SOURCE.indexOf('_shortcutActions() {'),
APP_SOURCE.indexOf('isUserBoundShortcutEvent(e) {')
);
expect(actionsBlock.length).toBeGreaterThan(0);
expect(actionsBlock).not.toContain('copyTerminalSelection');
+48
View File
@@ -323,6 +323,31 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~'.repeat(3) }]);
});
it('pages a Copilot pane when the tracking flag is stale, so the wheel never goes dead', () => {
// Copilot pages its own transcript on PageUp/PageDown (three PageUp moved lines 109-140 to
// 010-042 against a 140-line answer in tmux, and PageDown returned). While it tracks the mouse
// the wheel is forwarded as SGR reports; with the flag stale (a server restart) this is the
// gesture's fallback, on an alternate-screen TUI whose local buffer is one screen.
const { app, sent } = hollowApp({ mode: 'copilot', cliMouseTracking: false });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(true);
app._flushWheelSgrQueue();
expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]);
app._maybePageCliTranscript({ shiftKey: false }, 18);
app._flushWheelSgrQueue();
expect(sent[1]).toEqual({ id: 'sess-1', data: '\x1b[6~' });
});
it('does not page a Copilot pane that has real local scrollback', () => {
const { app, sent } = hollowApp({ mode: 'copilot' });
app.terminal.buffer.active.baseY = 200;
expect(app._maybePageCliTranscript({ shiftKey: false }, -18)).toBe(false);
app._flushWheelSgrQueue();
expect(sent).toEqual([]);
});
it('pages an OpenCode pane too, whose TUI never fills the local buffer', () => {
// OpenCode's TUI runs on the ALTERNATE SCREEN (measured on 1.18.31: tmux
// `alternate_on=1`, `history_size=0`), so the browser's normal buffer stays at
@@ -733,6 +758,29 @@ describe('pageKeysForGesture, the pure gesture rules both panes share', () => {
});
});
describe('GitHub Copilot wheel forwarding', () => {
// Copilot's mouse DECSETs are stripped so a drag selects locally, which stops xterm from
// encoding the wheel for it; the gate must therefore forward by hand, but only while tracking.
it('forwards the wheel while the CLI tracks the mouse, with no version gate', () => {
const { app } = hollowApp({ mode: 'copilot', cliMouseTracking: true });
expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true);
});
it('keeps Shift+wheel on local scrollback and falls back to paging when not tracking', () => {
const tracking = hollowApp({ mode: 'copilot', cliMouseTracking: true });
expect(tracking.app._shouldForwardWheelToApp({ shiftKey: true })).toBe(false);
const idle = hollowApp({ mode: 'copilot', cliMouseTracking: false });
expect(idle.app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false);
});
it('keeps unmeasured CLI modes out of native forwarding', () => {
for (const mode of ['opencode', 'grok', 'gemini']) {
const { app } = hollowApp({ mode, cliMouseTracking: true });
expect(app._shouldForwardWheelToApp({ shiftKey: false }), mode).toBe(false);
}
});
});
describe('the paging gates asked for another pane (a TerminalTile)', () => {
it('exports the paging math, and the primary pane runs on it', () => {
const { app, sent, windowRef } = hollowApp();
+1 -1
View File
@@ -351,7 +351,7 @@ describe('TerminalTile wheel paging in a real browser', () => {
const after = await page.evaluate(() => (window as any).app.terminal.buffer.active.viewportY);
expect(after).toBe(before - 1); // local shell behavior matches master
}
for (const mode of ['claude', 'codex']) {
for (const mode of ['claude', 'codex', 'copilot']) {
await page.evaluate(
({ id, mode }) => {
const a = (window as any).app;
+11 -2
View File
@@ -239,6 +239,15 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => {
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: PAGE_DOWN });
});
it('pages a copilot tile with an empty local buffer (the stale-tracking fallback)', async () => {
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'copilot', cliMouseTracking: false } }, 'other');
const { ws, mount } = await connectTile(app, { mode: 'copilot' });
mount.fire('wheel', wheelLines(-12));
expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]);
});
it("reads the TILE's session, not the active one", async () => {
// Active session is a shell; the tile shows opencode: the tile still pages.
const app = makeApp({ other: { mode: 'shell' }, 's-tile': { mode: 'opencode' } }, 'other');
@@ -344,8 +353,8 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports'
expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) });
});
it('forwards an isolated small wheel event and five slow clicks for Claude and tracked Codex', async () => {
for (const mode of ['claude', 'codex']) {
it('forwards an isolated small wheel event and five slow clicks for every tracked CLI', async () => {
for (const mode of ['claude', 'codex', 'copilot']) {
let now = 1000;
clock.now = () => now;
const session = mode === 'claude' ? FULLSCREEN : { mode, cliMouseTracking: true };
+57
View File
@@ -1100,6 +1100,63 @@ describe('terminal link tap', () => {
expect(sent).toHaveLength(1); // the tap still positions the caret via the mouse report
});
it('finds the caret line from the absolute cursor row while the viewport is parked above the bottom', () => {
// A tab switch parks the viewport above baseY when the frame keeps trailing blank
// rows (scrollToLastNonEmptyLine). cursorY is baseY-relative, so read as a screen
// row it points 2 rows too high here: at the history URL, not the composer.
const history = 'docs at https://example.com/guide';
const composer = '❯ summarize https://example.com/typed for me';
const lines = ['', '', '', 'older', 'old', history, 'more', composer, '', ''];
const { app, windowRef } = linkHarness(lines);
Object.assign(app.terminal, { rows: 4 });
Object.assign(app.terminal.buffer.active, { baseY: 6, viewportY: 4, cursorY: 1 });
// Screen row 3 is absolute line 7, the composer: the typed URL stays editable.
expect(app._handleMobileTerminalTap(at(composer.indexOf('https'), 3), true, 'history')).toBe('history');
expect(windowRef.open).not.toHaveBeenCalled();
// Screen row 1 is absolute line 5, scrollback: its URL opens.
expect(app._handleMobileTerminalTap(at(history.indexOf('https'), 1), true, 'history')).toBe('link');
expect(windowRef.open).toHaveBeenCalledWith('https://example.com/guide', '_blank', 'noopener,noreferrer');
});
it('walks a soft-wrapped composer through absolute lines while the viewport is parked', () => {
// The composer spans absolute lines 2-3 (3 is its soft-wrapped tail, where the caret
// sits); the viewport is parked one row above the bottom, so screen row 1 is the
// composer's FIRST row and screen row 0 is scrollback.
const history = 'docs at https://example.com/guide';
const composer = '❯ summarize https://example.com/typed';
const lines = ['older', history, composer, ' for me', '', ''];
const { app, windowRef } = linkHarness(lines);
Object.assign(app.terminal, { rows: 3 });
Object.assign(app.terminal.buffer.active, { baseY: 3, viewportY: 1, cursorY: 0 });
const getLine = app.terminal.buffer.active.getLine;
app.terminal.buffer.active.getLine = (row: number) => {
const line = getLine(row);
return line && row === 3 ? { ...line, isWrapped: true } : line;
};
// The composer's first row is on the caret's logical line: its typed URL stays editable.
expect(app._handleMobileTerminalTap(at(composer.indexOf('https'), 1), true, 'history')).toBe('history');
expect(windowRef.open).not.toHaveBeenCalled();
// Screen row 0 is absolute line 1, scrollback: its URL opens.
expect(app._handleMobileTerminalTap(at(history.indexOf('https'), 0), true, 'history')).toBe('link');
expect(windowRef.open).toHaveBeenCalledWith('https://example.com/guide', '_blank', 'noopener,noreferrer');
});
it('stops the caret-line wrap walk at the buffer end even when every row is wrapped', () => {
// xterm's getLine indexes its circular list modulo capacity, so past the last row it
// returns earlier rows again; with all of them soft-wrapped an unbounded walk never ends.
const { app } = linkHarness(['❯ a', 'b', 'c'], 2);
let reads = 0;
app.terminal.buffer.active.getLine = (row: number) => {
if (++reads > 1000) throw new Error('wrap walk did not stop');
return { isWrapped: true, translateToString: () => ['❯ a', 'b', 'c'][((row % 3) + 3) % 3] };
};
expect(app._tapIsOnCaretLine(at(0, 2).clientX, at(0, 2).clientY)).toBe(true);
});
it('activates a link in a plain shell session, where every tap classifies as input', () => {
// A shell has no TUI to own taps, so _classifyMobileTerminalTap short-circuits to
// 'input' for the whole screen — gating link taps on the intent would leave every URL
+5 -3
View File
@@ -8,8 +8,9 @@
* phase): anywhere over it (a tile, an empty cell, a divider, its padding)
* dragover and drop are cancelled, so the page never navigates, and a drop on
* a tile uploads its files to THAT tile's session, through the same classifier
* (_promptAttachKind: images, videos and documents) and the same "Unsupported
* file type" toast as the single view. Tab and tile drags are the targets' own
* (_promptAttachKind: images, videos and documents) and the same toasts as the
* single view (_admitPromptAttachments: "Unsupported file type", or a count of
* the files skipped). Tab and tile drags are the targets' own
* (_acceptTabDrops, capture phase) and stay untouched.
*
* Real code via the shared vm harness (test/mocks/tile-grid-vm.ts). Port: N/A.
@@ -66,12 +67,13 @@ describe('a file dropped on a tile', () => {
expect(app.activeSessionId).toBe('s-a');
});
it('only the files the classifier admits are uploaded: a document and a video count, an unnamed text part does not', () => {
it('only the files the classifier admits are uploaded, and the skipped one is counted: a document and a video count, an unnamed text part does not', () => {
const app = gridApp();
const pdf = { type: 'application/pdf', name: 'Q3 report.pdf' };
const mov = { type: 'video/quicktime', name: 'IMG_0001.MOV' };
section.dispatch('drop', fileEvent(tileEl('s-c'), [{ type: 'text/plain' }, PNG, pdf, mov]));
expect(app._uploadAndInsertImages).toHaveBeenCalledWith([PNG, pdf, mov], { sessionId: 's-c' });
expect(app.showToast).toHaveBeenCalledWith('1 file skipped: unsupported type', 'info');
});
it('a drop with nothing the classifier admits says so, as the single view does, and uploads nothing', () => {
+9
View File
@@ -246,6 +246,14 @@ async function exercise() {
preventDefault: vi.fn(),
});
harvestAll(app, 'file drop');
// A drop mixing an admitted image with a refused file: the admitted one uploads.
app._uploadAndInsertImages = vi.fn();
section.dispatch('drop', {
target: section.children.find((el) => el.dataset.sessionId === 's-1'),
dataTransfer: { types: ['Files'], files: [{ type: 'image/png' }, { type: 'application/pdf' }] },
preventDefault: vi.fn(),
});
harvestAll(app, 'mixed file drop');
// The toasts of a full grid, by the cap and by the window.
app._joinTileGridFromRun('s-7');
@@ -349,6 +357,7 @@ describe('every tile grid string the code puts on screen translates to zh-CN', (
'This group has no session to show as tiles',
'No sessions to show as tiles',
'Unsupported file type',
'1 file skipped: unsupported type',
'Split: unavailable while tiles are open',
'Tiles: show several sessions side by side (right-click for how many)',
'Tiles: back to a single session (right-click for how many tiles)',
+2 -2
View File
@@ -80,7 +80,7 @@ describe('tmux full-history pane capture (COD-47)', () => {
// up from them, so the two decisions travel together: no geometry, no move,
// and the old trim applies instead.
expect(methodBody).toContain("rawCapture.replace(/\\n$/, '')");
expect(methodBody).toContain("if (!geometry) return normalizeScrollbackEol(rawCapture.replace(/\\n+$/g, ''))");
expect(methodBody).toContain("const trimmed = rawCapture.replace(/\\n+$/g, '')");
});
it('defers to the byte history when the pane holds nothing visible', () => {
@@ -144,7 +144,7 @@ describe('the geometry a capture reports back', () => {
const write = methodBody.indexOf('opts.capturedGeometry = { cols: geometry.cols, rows: geometry.rows }');
// Anchor on the REPLAY branch, not the earlier `if (fullHistory)` that only
// sizes the exec buffer.
const replayBranch = methodBody.indexOf('if (!geometry) return normalizeScrollbackEol(');
const replayBranch = methodBody.indexOf('if (!geometry) {');
const visibleReturn = methodBody.indexOf('if (geometry) return formatPaneSnapshot(');
expect(write).toBeGreaterThan(-1);
expect(replayBranch).toBeGreaterThan(-1);
+58
View File
@@ -0,0 +1,58 @@
import { createRequire } from 'node:module';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { chromium, type Browser } from 'playwright';
import { createStyledPaneCapture } from './fixtures/styled-pane-capture.js';
let browser: Browser;
let capture: Awaited<ReturnType<typeof createStyledPaneCapture>>;
beforeAll(async () => {
capture = await createStyledPaneCapture();
browser = await chromium.launch({ headless: true });
});
afterAll(async () => {
await browser?.close();
});
describe('captured status-bar colors stay out of live writes (#629)', () => {
it.each([
[true, true],
[true, false],
[false, false],
])('fullHistory=%s, geometry=%s', async (fullHistory, geometry) => {
const snapshot = capture(fullHistory, geometry);
expect(snapshot).not.toBeNull();
const page = await browser.newPage();
try {
await page.setContent('<div id="terminal"></div>');
await page.addScriptTag({ path: createRequire(import.meta.url).resolve('@xterm/xterm') });
const result = await page.evaluate(async (snapshot) => {
const terminal = new (window as any).Terminal({ cols: 40, rows: 2 });
terminal.open(document.getElementById('terminal'));
const write = (data: string) => new Promise<void>((resolve) => terminal.write(data, resolve));
await write(snapshot!);
const status = terminal.buffer.active.getLine(1).getCell(0);
const statusBackground = status.getBgColor();
await write('\x1b[1;1Htyped\x1b[K');
const line = terminal.buffer.active.getLine(0);
const result = {
statusBackground,
text: line.translateToString(true),
typedDefault: line.getCell(0).isBgDefault(),
erasedDefault: line.getCell(10).isBgDefault(),
statusAfter: terminal.buffer.active.getLine(1).getCell(0).getBgColor(),
};
terminal.dispose();
return result;
}, snapshot);
expect(result).toEqual({
statusBackground: 2,
text: 'typed',
typedDefault: true,
erasedDefault: true,
statusAfter: 2,
});
} finally {
await page.close();
}
});
});
+26
View File
@@ -0,0 +1,26 @@
import { beforeAll, describe, expect, it } from 'vitest';
import { createStyledPaneCapture, styledCapture } from './fixtures/styled-pane-capture.js';
let capture: Awaited<ReturnType<typeof createStyledPaneCapture>>;
beforeAll(async () => {
capture = await createStyledPaneCapture();
});
describe('capturePaneBuffer style handoff (#629)', () => {
it('resets the captured style before restoring the cursor', () => {
expect(capture(true)).toBe(`${styledCapture.trimEnd().replace(/\n/g, '\r\n')}\x1b[0m\x1b[1A\r`);
});
it.each([true, false])('resets the raw fallback (fullHistory=%s)', (fullHistory) => {
expect(capture(fullHistory, false)).toBe(`${styledCapture.trimEnd().replace(/\n/g, '\r\n')}\x1b[0m`);
});
it.each([true, false])('keeps an empty raw fallback empty (fullHistory=%s)', (fullHistory) => {
expect(capture(fullHistory, false, '\n\n')).toBe('');
expect(capture(fullHistory, false, '')).toBe('');
});
it('keeps an all-blank full-history capture empty', () => {
expect(capture(true, true, '\n\n')).toBe('');
});
});
+180
View File
@@ -0,0 +1,180 @@
/**
* Covers `buildPasteTextCommands()` and `pasteText()`: the text half of a text+Enter
* submit (`sendInput`) goes in as ONE bracketed paste. Also `submitsAsKeys()`: a
* single character is typed instead, since a selection dialog ignores a pasted `3`
* and the Enter then confirms the highlighted option. And
* `escapeTmuxTrailingSemicolon()`, which every literal text path runs through.
*
* Why: `send-keys -l` typed the text as an unmarked burst, and Codex's paste-burst
* heuristic folded the Enter that followed 50 ms later into the paste as a newline,
* so the prompt sat in the composer unsubmitted (always at 1500 chars, sometimes for
* a one-line prompt). With `paste-buffer -p` the paste is marked and the separate
* Enter submits.
*
* Strategy: pure command shape, plus a round trip through a real shell with a fake
* `tmux` that prints its argv, so quoting and tmux's trailing-`;` rule are checked
* on the bytes a shell actually hands over. `pasteText` runs against a recording
* runner. No real tmux.
*
* Port: N/A (no server / no real tmux)
*/
import { execSync } from 'node:child_process';
import { describe, expect, it } from 'vitest';
import { buildPasteTextCommands, escapeTmuxTrailingSemicolon, pasteText, submitsAsKeys } from '../src/tmux-manager.js';
const FAKE_TMUX = `${JSON.stringify(process.execPath)} -e 'console.log(JSON.stringify(process.argv.slice(1)))' --`;
function argvOf(cmd: string): string[] {
return JSON.parse(execSync(cmd, { encoding: 'utf-8' })) as string[];
}
describe('buildPasteTextCommands', () => {
it('fills a buffer, pastes it bracketed into the target and deletes it', () => {
const { set, paste, discard } = buildPasteTextCommands('tmux -L codeman', 'codeman-abc12345', 'hello');
const buffer = /set-buffer -b (\S+) -- /.exec(set)?.[1];
expect(buffer).toMatch(/^codeman-submit-\d+-\d+$/);
expect(set).toBe(`tmux -L codeman set-buffer -b ${buffer} -- 'hello'`);
expect(paste).toBe(`tmux -L codeman paste-buffer -p -d -b ${buffer} -t 'codeman-abc12345'`);
expect(discard).toBe(`tmux -L codeman delete-buffer -b ${buffer}`);
});
it('never types the text with send-keys (the unmarked burst Codex misreads)', () => {
for (const cmd of Object.values(buildPasteTextCommands('tmux', 'codeman-abc12345', 'read your inbox'))) {
expect(cmd).not.toContain('send-keys');
}
});
it('uses a fresh buffer per call, so concurrent sends cannot paste each other', () => {
const a = buildPasteTextCommands('tmux', 't', 'a').set;
const b = buildPasteTextCommands('tmux', 't', 'b').set;
expect(/-b (\S+)/.exec(a)?.[1]).not.toBe(/-b (\S+)/.exec(b)?.[1]);
});
it.each([
['quotes and shell syntax', `it's "quoted" $HOME \`id\` $(id) & | > <`],
['tmux format syntax', '#{session_name} #S'],
['leading dash', '-t other --help'],
['non-ASCII', 'Mailbox lesen — äöü “x”'],
['a long prompt', 'x'.repeat(1500)],
])('hands the text to tmux unchanged through the shell: %s', (_label, text) => {
const { set } = buildPasteTextCommands(FAKE_TMUX, 't', text);
expect(argvOf(set).at(-1)).toBe(text);
});
it.each([
['x;', 'x\\;'],
[';', '\\;'],
['x;;', 'x;\\;'],
['x\\;', 'x\\\\;'],
])('escapes a trailing ";" (tmux command separator): %s', (text, expected) => {
const { set } = buildPasteTextCommands(FAKE_TMUX, 't', text);
expect(argvOf(set).at(-1)).toBe(expected);
});
it('leaves a ";" inside the text alone', () => {
const { set } = buildPasteTextCommands(FAKE_TMUX, 't', 'a; b');
expect(argvOf(set).at(-1)).toBe('a; b');
});
});
describe('submitsAsKeys', () => {
it.each(['3', '9', 'y', 'n', ';', 'ä', '👍'])('types a single character: %s', (text) => {
expect(submitsAsKeys(text)).toBe(true);
});
it.each(['10', 'yes', '/cost', 'read your inbox', 'x'.repeat(1500)])('pastes anything longer: %s', (text) => {
expect(submitsAsKeys(text)).toBe(false);
});
});
describe('escapeTmuxTrailingSemicolon', () => {
it.each([
['x;', 'x\\;'],
[';', '\\;'],
['a; b', 'a; b'],
['plain', 'plain'],
])('%s -> %s', (text, expected) => {
expect(escapeTmuxTrailingSemicolon(text)).toBe(expected);
});
});
describe('pasteText', () => {
const cmds = { set: 'SET', paste: 'PASTE', discard: 'DISCARD' };
function recorder(failOn: string[] = []) {
const ran: string[] = [];
const run = async (cmd: string) => {
ran.push(cmd);
if (failOn.includes(cmd)) throw new Error(`${cmd} failed`);
};
return { ran, run };
}
it('fills the buffer, then pastes it; the paste deletes it, so nothing else runs', async () => {
const { ran, run } = recorder();
await pasteText(run, cmds);
expect(ran).toEqual(['SET', 'PASTE']);
});
it('deletes the buffer when the paste fails, and reports the paste error', async () => {
const { ran, run } = recorder(['PASTE']);
await expect(pasteText(run, cmds)).rejects.toThrow('PASTE failed');
expect(ran).toEqual(['SET', 'PASTE', 'DISCARD']);
});
it('still reports the paste error when the delete fails too', async () => {
const { ran, run } = recorder(['PASTE', 'DISCARD']);
await expect(pasteText(run, cmds)).rejects.toThrow('PASTE failed');
expect(ran).toEqual(['SET', 'PASTE', 'DISCARD']);
});
it('does not paste when filling the buffer failed', async () => {
const { ran, run } = recorder(['SET']);
await expect(pasteText(run, cmds)).rejects.toThrow('SET failed');
expect(ran).toEqual(['SET']);
});
});
describe('sendInput wiring', () => {
// sendInput() returns before touching tmux under vitest (IS_TEST_MODE), so the
// branch that submits text is pinned on the source instead.
async function source(): Promise<string> {
const { readFileSync } = await import('node:fs');
return readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf-8');
}
async function sendInputBody(): Promise<string> {
const src = await source();
return src.slice(src.indexOf('async sendInput('), src.indexOf('// ========== Pane Methods'));
}
it('submits text through pasteText, not send-keys -l', async () => {
const body = await sendInputBody();
const submitBranch = body.slice(
body.indexOf('if (textPart && hasCarriageReturn)'),
body.indexOf('} else if (textPart)')
);
const pasteBranch = submitBranch.slice(submitBranch.indexOf('} else {'));
expect(pasteBranch).toContain('pasteText(');
expect(pasteBranch).toContain('buildPasteTextCommands(');
expect(pasteBranch).not.toMatch(/send-keys[^`]*-l/);
});
it('types a single-character submit with send-keys -l, so a dialog answer selects', async () => {
const body = await sendInputBody();
const keysBranch = body.slice(body.indexOf('if (submitsAsKeys(textPart))'), body.indexOf('} else {'));
expect(keysBranch).toMatch(/send-keys[^`]*-l \$\{shellescape\(escapeTmuxTrailingSemicolon\(textPart\)\)\}/);
expect(keysBranch).not.toContain('pasteText(');
});
it('escapes a trailing ";" on every send-keys -l in sendInput and sendInputToPane', async () => {
const src = await source();
const typed = src.match(/send-keys[^`\n]*-l \$\{[^`\n]*\}/g) ?? [];
const inSend = [await sendInputBody(), src.slice(src.indexOf('sendInputToPane(muxName'))]
.map((b) => b.slice(0, b.indexOf('\n }\n')))
.join('\n');
const typedHere = typed.filter((cmd) => inSend.includes(cmd));
expect(typedHere.length).toBe(4);
for (const cmd of typedHere) expect(cmd).toContain('escapeTmuxTrailingSemicolon(textPart)');
});
});
Loaded 100 of 228 files, more files were not shown because too many files have changed in this diff. Show more