mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
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:
commit
dbb41c70cf
228 files changed
+15240
-862
No files matched your search
@@ -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
@@ -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/);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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)'),
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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\]/);
|
||||
});
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
|
||||
Vendored
+36
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;');
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -469,6 +469,7 @@ describe('mobile overview run picker (CLI availability gating)', () => {
|
||||
'grok',
|
||||
'deepseek',
|
||||
'omp',
|
||||
'copilot',
|
||||
'shell',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
]) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ==========
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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}`
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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\);/
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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'] },
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
|
||||
/**
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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)',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
@@ -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
Reference in new issue
Block a user