feat(cli-registry): CLI management write API + Settings UI (Phases 1-6) (#476)

* feat(cli-registry): add cliManagementEnabled flag and GET /api/clis

Phases 1-2 of docs/cli-enable-disable-plan.md ("PR C" from the #343
review): a synced, default-OFF master flag gating the upcoming CLI
management surface, plus a read-only GET /api/clis endpoint listing
every registry entry (stock + custom, enabled or not) for the
Settings UI. Non-admins in multi-user mode see an empty list rather
than a 403. Write endpoints, auto-install, custom entry CRUD and the
Settings UI list itself land in later phases.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* feat(cli-registry): Phases 3-6 - write API + custom entries + Settings UI

Completes docs/cli-enable-disable-plan.md ("PR C" from the #343 review).

Phase 3: PUT /api/clis/:id toggles enabled for any EXISTING entry (stock or
custom) via a shallow merge onto its clis.json override; shell/claude are
structurally un-disableable (Decision 4), an unknown id 404s rather than
becoming a creation backdoor.

Phase 4: POST /api/clis/:id/install runs a STOCK entry's already-vetted
install command (shell:true, bounded by timeout, process-group killed on
expiry, output captured, audit-logged). A custom entry's id is refused
outright, independent of anything Phase 5 does (Decision 3: a custom
entry's install text is display-only, never executed).

Phase 5: POST /api/clis (create) / PUT /api/clis/custom/:id (update) /
DELETE /api/clis/:id (custom only) — a deliberately minimal request shape
(id/label/shortBadge/binaries/a simple launch variant), assembled into a
full CliEntry with conservative capability defaults and re-validated
through CliEntrySchema before writing, never a relaxed path for
UI-originated entries. Stock-id collisions, duplicate custom ids, and
edits/deletes against a stock id are all rejected explicitly.

Phase 6: the Settings UI section (App Settings -> Agents & CLIs), gated
independently on cliManagementEnabled AND admin-in-multi-user-mode
(Decision 5), fetching/rendering GET /api/clis and wiring every write
endpoint above.

Every write endpoint answers the same way when the feature is off: 403
FORBIDDEN via one shared requireCliManagementGate() (Phase 1's own
checklist item). registry-writer.ts is a new, deliberately separate write
module so registry.ts itself stays import-side-effect-free, same tmp+
rename+0600 shape as custom-model-hosts.ts.

27 new/updated route tests covering every gate, collision, and cleanup
path; full CI gate green (415/416 files, 7854 tests).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* fix(cli-registry): toggling a CLI off in Settings never hid it anywhere else

window.__codemanCliAvailable — the flag isCliAvailable() reads client-side
to gate the welcome-screen buttons, the Run-menu dropdown and the mobile
overview — was built purely from each CLI's own installed-on-PATH resolver
(isClaudeAvailable() etc.), with no reference to the registry's `enabled`
flag at all. So disabling a CLI via the new Settings UI (or a hand-edited
clis.json) updated the settings row and nothing else: every launch surface
kept offering it, both live and after a full page reload, since even a
fresh render never consulted the registry.

Fixed in two places:

- server.ts: after building `available`, intersect the nine real
  SessionMode ids against `enabledClis()`. git/cloudflared (utility
  binaries, not CLI registry entries) and deepseekBinary (a secondary
  installed-only flag for the "add a profile" affordance) are deliberately
  left alone.
- settings-ui.js: `toggleCliEnabled()` now patches
  `window.__codemanCliAvailable` in place and refreshes the welcome screen,
  the mobile overview and an already-open Run menu, mirroring the existing
  `installDeepSeekProfile()` pattern for the same "injected once, needs an
  explicit patch" reason — without this half, the server-side fix alone
  still left every surface stale until the next reload.

New test in test/render-index-html.test.ts: an installed-but-disabled CLI
(codex, forced via clis.json + reloadCliRegistry()) reads as unavailable,
while an installed-and-enabled one (claude) is unaffected by the override.

Verified on the Debian devbox (codeman-devbox, real tmux — this sandbox has
none and WebServer's constructor hard-requires it): typecheck clean, the
new test passes (17/17 in render-index-html.test.ts), the CLI-registry
suites pass (86/86), and the full CI gate is green (415 test files, 7855
tests, 0 failures).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD

* docs(cli-registry): update the CLI-management plan with status, gotchas, and the Run-menu gap

Phases 1-6 were implemented across two commits (da07b38c, db4557d9) with no
corresponding update to the plan doc itself — every checklist still read
Status: TODO and every box unchecked. Brings the doc in line with the tree:

- A new "Status as of 2026-09-22" section up top: what's actually
  implemented (verified by grepping the routes/schema/UI, not just trusting
  the commit messages), the availability-flag staleness bug found and fixed
  in this session (commit 0c77dd0a) with its devbox verification record, and
  one real outstanding gap.

- The outstanding gap: a custom CLI created via Phase 5's write API has no
  way to actually be launched. The Run menu is static per-mode markup with
  no consumer of window.__codemanCliCatalog, so Phase 6's own "create a
  custom entry, confirm it can be launched" verify step was never actually
  exercised against this. Documented with two candidate fixes, neither
  started.

- Each phase's checklist flipped to [x] where confirmed present in the tree,
  Status lines updated from TODO to DONE, and the two originally-open
  questions (Phase 2's installed source, Phase 5's PUT endpoint shape)
  marked resolved against what actually shipped.

No code changes in this commit — documentation only, so a future session
(or the one already mid-flight on a separate checkout of this same branch)
picks up accurate status instead of a stale plan.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD

* docs: add the CLI-registry deployment plan and the parked Copilot plan

Both were sitting as untracked scratch files in the master checkout,
never committed to any branch. Moving them here rather than leaving them
loose:

- DEPLOYMENT_PLAN.md is the live tracker for the CLI-registry follow-up
  series (PR A #347 merged, PR B #380 merged, PR B2 merged as #458) and
  is where PR C (this branch's own CLI-management work) belongs.
- docs/copilot-integration-plan.md is explicitly PARKED, referenced by
  name in docs/cli-enable-disable-plan.md's own header as a sibling plan
  tracked separately — kept for continuity, not active on this branch.

The other scratch files found alongside these (PRA.md, PRB.md, PR-B2.md
and their review-response counterparts) described PR A/B/B2, all now
merged — deleted from the master checkout as stale rather than committed
anywhere, since their content is superseded by the real merged PRs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N6eadpRyqpA9PD3i139cSD

* fix(cli-registry): render enabled CLIs in launch surfaces

* test(cli-registry): update frontend branch guard

* fix(test): isolate suite from deployment environment

* fix(cli-registry): revise Decision 4 - claude is toggleable, shell stays permanent

shell/claude were both structurally un-disableable in the original plan
(Decision 4). Revised: shell keeps the hard backend guarantee (it is the
one non-agent mode several code paths assume always exists as a raw-
terminal fallback), but claude is now a normal toggleable entry like any
other CLI.

Safe to do because internal session creation (tmux-manager.ts, session.ts,
Ralph, plan-orchestrator) resolves a CLI via getCli(), which does not
check `enabled` at all - only the Run menu and the HTTP-facing
sessionModeSchema() (new session requests through the normal API) key off
it. Disabling claude therefore behaves identically in kind to disabling
any other CLI: no internal fallback path breaks, it just stops being
offered for new sessions until re-enabled.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* fix(cli-registry): hide shell's toggle entirely instead of greying it out

A permanently-disabled switch next to every other row's working toggle
read as broken rather than intentional. shell now renders no switch at
all - a plain "Always available" label - so there is nothing to click
that could look like it should work but doesn't. Backend guard is
unchanged (UNDISABLEABLE_IDS still refuses shell unconditionally); this
is UI-only.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* fix(cli-registry): sort the Installed CLIs list, installed-first then alphabetical

renderCliList() previously rendered in registry order (each entry's fixed
order field). Now sorts installed CLIs first, then not-installed, each
group alphabetical by label - matches how a user actually scans the list
(what's ready to use, then what needs installing).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* style: prettier fixes from the master merge

* fix(cli-registry): install/edit take effect immediately, confirm before install, phone labels

Four gaps found verifying #476 against the #343 review trail:

- Installed or edited CLIs kept reading as missing/stale. Every binary lookup
  (the nine per-CLI resolvers and the generic registry one) caches in its own
  closure, with a negative-cache backoff of up to 5 minutes, and nothing
  cleared them. invalidateCliExecutableResolvers(binaries) now drops those
  caches per binary; install (success or failure), create, edit and delete
  call it plus invalidateCliResolverCache(id). Before this, a CLI installed
  from Settings could fail to launch for minutes, and an edited custom entry
  kept launching its old binary until a restart.
- The Settings "installed" badge for a custom entry used a private `which`,
  ignoring the entry's searchDirs and the login-shell lookup that spawn and
  the Run menu use; it now asks the same generic resolver they do.
- Install ran on a single click. The #343 review asked for auto-install to
  sit behind an explicit confirm; the confirm now names the exact command,
  which GET /api/clis returns for stock entries only (installCommand).
- The phone Run button showed the two-letter tab badge ("CC", "CX") instead
  of the word ("Claude", "Codex"). It uses the registry label again, which is
  identical to the old static table for every stock CLI (now pinned).

14 new tests; 9 of them fail against the previous head and pass here.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* fix(cli-registry): address #476 review — safe serialized writes, no id branches, docs

Must-fix:
- registry-writer: start fresh only on ENOENT; refuse (409) a clis.json that
  does not parse or has group/world permission bits instead of overwriting it
  (isUnsafePermissions now exported from registry.ts)
- mutateRegistryFile(): one promise chain for every mutation, with the
  existence/duplicate checks inside the serialized step, plus a unique tmp
  name per write
- docs: CLAUDE.md, architecture-invariants, cli-registry (new Settings
  section) and api-reference (the six /api/clis routes)
- drop DEPLOYMENT_PLAN.md and docs/copilot-integration-plan.md

Smaller:
- PUT /api/clis/custom/:id keeps the entry's current enabled state when the
  body omits it
- runMode setter falls back to the first enabled catalogue entry, not 'claude'
- shell guard keyed on kind === 'shell' (routes + Settings list); stock probe
  map shared with server.ts via utils/cli-installed-probes.ts
- stock claude label is now 'Claude Code', so the Run menu / phone overview
  label rewrites are gone (doctor row keeps "Claude CLI" via its override)
- welcome buttons are translatable again and read "Run Claude Code" /
  "Run Shell"; zh-CN gains "Run Codex" / "Run OMP"
- install: per-id in-flight guard (409) and CODEMAN_* stripped from its env
- fileoverview / CliEnableSchema comments no longer say stock-only
- test-env isolation changes moved to their own PR

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

* test(cli-registry): pin the #343/#347 findings #476 makes reachable

A CLI toggled or created through the routes is accepted or rejected by
CreateSessionSchema with no restart (#343 finding 2), and a custom CLI created
through the API renders a real local, remote and docker launch command
(#347 finding 5: no more `cd <path> && undefined`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GuHtuPiHXdykq9T6rKQJ9n

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Devvyn
2026-09-24 01:48:26 +02:00
committed by GitHub
co-authored by Claude Opus 5.5
parent c46e87fd7a
commit 0a52a99ca9
34 changed files with 2898 additions and 253 deletions
+44
View File
@@ -8,6 +8,7 @@ import {
createCliExecutableResolver,
createProductionCliResolverHost,
formatCliNotFoundMessage,
invalidateCliExecutableResolvers,
type CliResolverHost,
} from '../src/utils/cli-executable-resolver.js';
@@ -129,6 +130,49 @@ describe('createCliExecutableResolver', () => {
expect(findInLoginShell).toHaveBeenCalledTimes(2);
});
it('invalidation drops a negative-cache backoff immediately (a CLI installed from Settings)', () => {
let now = 0;
const findInLoginShell = vi.fn<() => string | null>().mockReturnValueOnce(null).mockReturnValue('/new/bin/grokx');
const h = host({ findInLoginShell, exists: vi.fn((path) => path === '/new/bin/grokx') });
const resolver = createCliExecutableResolver({ binary: 'grokx', searchDirs: [], now: () => now }, h);
expect(resolver.resolve()).toBeNull();
// Still inside the backoff window: without invalidation this answers from the
// negative cache for up to five minutes after a successful install.
now = 1;
invalidateCliExecutableResolvers(['grokx']);
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/grokx');
expect(findInLoginShell).toHaveBeenCalledTimes(2);
});
it('invalidation drops a cached success too, so an edited binary is re-resolved', () => {
const findOnProcessPath = vi
.fn<() => string | null>()
.mockReturnValueOnce('/old/bin/editedx')
.mockReturnValue('/new/bin/editedx');
const h = host({ findOnProcessPath, exists: vi.fn(() => true) });
const resolver = createCliExecutableResolver({ binary: 'editedx', searchDirs: [] }, h);
expect(resolver.resolve()?.binaryPath).toBe('/old/bin/editedx');
expect(resolver.resolve()?.binaryPath).toBe('/old/bin/editedx');
invalidateCliExecutableResolvers(['editedx']);
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/editedx');
// And the fresh result is cached again rather than re-probed every call.
expect(resolver.resolve()?.binaryPath).toBe('/new/bin/editedx');
expect(findOnProcessPath).toHaveBeenCalledTimes(2);
});
it('invalidation is scoped to the named binaries and leaves every other cache alone', () => {
const findOnProcessPath = vi.fn(() => '/bin/untouchedx');
const h = host({ findOnProcessPath, exists: vi.fn(() => true) });
const resolver = createCliExecutableResolver({ binary: 'untouchedx', searchDirs: [] }, h);
resolver.resolve();
invalidateCliExecutableResolvers(['some-other-binary']);
resolver.resolve();
expect(findOnProcessPath).toHaveBeenCalledTimes(1);
});
it('doubles the retry delay per consecutive miss and caps it at five minutes', () => {
let now = 0;
const findInLoginShell = vi.fn(() => null);
+88
View File
@@ -0,0 +1,88 @@
// Port: none (drives the real settings-ui.js in a vm context — no browser, no server).
//
// The CLI-management rows in App Settings (docs/cli-enable-disable-plan.md, Phase 6).
// Installing runs a command on the server, so it must never happen on a single click:
// the #343 review asked for auto-install to sit "behind an explicit confirm, or off".
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it, vi } from 'vitest';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
function loadSettingsUi(confirmAnswer: boolean) {
const CodemanApp = function CodemanApp(this: any) {};
const rows = { innerHTML: '' };
const confirm = vi.fn(() => confirmAnswer);
const context = vm.createContext({
CodemanApp,
console,
confirm,
window: {},
MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
localStorage: { getItem: () => null, setItem: () => {} },
document: {
getElementById: (id: string) => (id === 'cliListRows' ? rows : null),
createElement: () => ({ style: {}, dataset: {}, setAttribute: () => {}, appendChild: () => {} }),
createElementNS: () => ({ style: {}, dataset: {}, setAttribute: () => {}, appendChild: () => {} }),
querySelector: () => null,
},
});
for (const file of ['constants.js', 'settings-ui.js']) {
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
}
const app = new (CodemanApp as any)();
app._api = vi.fn(async () => ({ ok: true, json: async () => ({ success: true }) }));
app.showToast = vi.fn();
app.loadCliListForSettings = vi.fn(async () => {});
return { app, confirm, rows };
}
const GROK = {
id: 'grok',
label: 'Grok',
shortBadge: 'GK',
stock: true,
installed: false,
enabled: true,
installCommand: 'curl -fsSL https://x.ai/cli/install.sh | bash',
};
describe('CLI management: install confirmation', () => {
it('runs nothing when the confirm is declined', async () => {
const { app, confirm } = loadSettingsUi(false);
app._cliList = [GROK];
await app.installCliEntry('grok');
expect(confirm).toHaveBeenCalledTimes(1);
expect(app._api).not.toHaveBeenCalled();
});
it('names the exact command in the confirm, then installs on accept', async () => {
const { app, confirm } = loadSettingsUi(true);
app._cliList = [GROK];
await app.installCliEntry('grok');
expect(confirm.mock.calls[0][0]).toContain(GROK.installCommand);
expect(app._api).toHaveBeenCalledWith('/api/clis/grok/install', { method: 'POST' });
});
});
describe('CLI management: list rendering', () => {
it('lists installed CLIs first, each group alphabetical, and gives shell no switch', () => {
const { app, rows } = loadSettingsUi(true);
app._cliList = [
{ id: 'pi', label: 'Pi', shortBadge: 'PI', kind: 'agent', stock: true, installed: false, enabled: true },
{ id: 'shell', label: 'Shell', shortBadge: 'SH', kind: 'shell', stock: true, installed: true, enabled: true },
{ id: 'codex', label: 'Codex', shortBadge: 'CX', kind: 'agent', stock: true, installed: true, enabled: true },
{ id: 'grok', label: 'Grok', shortBadge: 'GK', kind: 'agent', stock: true, installed: false, enabled: true },
];
app.renderCliList();
const order = [...rows.innerHTML.matchAll(/data-cli-id="([^"]+)"/g)].map((m) => m[1]);
expect(order).toEqual(['codex', 'shell', 'grok', 'pi']);
const shellRow = rows.innerHTML.split('data-cli-id="shell"')[1].split('data-cli-id=')[0];
expect(shellRow).toContain('Always available');
expect(shellRow).not.toContain('type="checkbox"');
const codexRow = rows.innerHTML.split('data-cli-id="codex"')[1].split('data-cli-id=')[0];
expect(codexRow).toContain('type="checkbox"');
});
});
@@ -314,7 +314,6 @@ describe('declared-for-later fields', () => {
* list — and wiring one up should make its line here fail, which is the good direction.
*/
const DECLARED_FOR_LATER = [
'shortBadge',
'accent',
'capabilities.echo',
'capabilities.wheelForward',
+15 -18
View File
@@ -45,9 +45,9 @@ const SCANNED_FILES = ['session-ui.js', 'mobile-overview.js'];
*/
const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = {
"session-ui.js::mode === 'shell'": {
count: 2,
count: 3,
reason:
'run() dispatch (shell needs no CLI probe at all) and the button-label ternary (pinned exact ' +
'run() dispatch, availability gating (shell needs no CLI probe at all), and the button-label ternary (pinned exact ' +
"text — test/run-mode-ui.test.ts asserts e.g. 'Run OMP', which diverges from CliEntry.shortBadge " +
"for at least omp ('OM' vs the displayed 'OMP'), so a catalogue-driven rewrite would silently " +
'change user-visible text and break that pinned test; the maintainer confirmed leaving this ' +
@@ -55,9 +55,9 @@ const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = {
},
"session-ui.js::mode === 'claude'": {
count: 4,
count: 3,
reason:
'four claude-specific call sites, not one branch: run() dispatch (claude has its own ' +
'three claude-specific call sites, not one branch: run() dispatch (claude has its own ' +
'remote/docker branching and parallel-create path, unlike every RUN_MODE_LAUNCH entry), ' +
'runCustomModelEntry() (restart-vs-one-shot launch mechanism, not a preference — see ' +
"CLAUDE.md's Custom Model Endpoint Profiles section), the Respawn/Ralph section (claude-only " +
@@ -65,22 +65,19 @@ const ALLOWED_BRANCHES: Record<string, { count: number; reason: string }> = {
'validity check',
},
// The 8 external CLIs share the same two call sites and the same reason at
// each: the button-label ternary (see the shell entry above for why it
// stays hardcoded) and the runMode property setter's validity allowlist
// (not a behaviour branch; left hardcoded in Phase 2 since its chain has
// no shell arm at all and no evidence of what callers rely on it).
"session-ui.js::mode === 'opencode'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
"session-ui.js::mode === 'codex'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
"session-ui.js::mode === 'gemini'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
// The 8 external CLIs remain in the button-label ternary only. The runMode
// setter now validates custom entries through the injected registry catalog.
"session-ui.js::mode === 'opencode'": { count: 1, reason: 'button-label ternary' },
"session-ui.js::mode === 'codex'": { count: 1, reason: 'button-label ternary' },
"session-ui.js::mode === 'gemini'": { count: 1, reason: 'button-label ternary' },
"session-ui.js::mode === 'antigravity'": {
count: 2,
reason: 'button-label ternary + runMode setter validity check',
count: 1,
reason: 'button-label ternary',
},
"session-ui.js::mode === 'pi'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
"session-ui.js::mode === 'grok'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
"session-ui.js::mode === 'deepseek'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
"session-ui.js::mode === 'omp'": { count: 2, reason: 'button-label ternary + runMode setter validity check' },
"session-ui.js::mode === 'pi'": { count: 1, reason: 'button-label ternary' },
"session-ui.js::mode === 'grok'": { count: 1, reason: 'button-label ternary' },
"session-ui.js::mode === 'deepseek'": { count: 1, reason: 'button-label ternary' },
"session-ui.js::mode === 'omp'": { count: 1, reason: 'button-label ternary' },
// The docker adopt-preflight status line and the docker link/adopt toast
// both list the agent CLIs probed INSIDE the container and leave `shell`
+53
View File
@@ -9,6 +9,7 @@ import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const PUBLIC = resolve(import.meta.dirname, '../src/web/public');
@@ -538,3 +539,55 @@ describe('mobile overview watching badge', () => {
);
});
});
describe('mobile overview Run picker, driven by the registry catalogue', () => {
function loadRunModes(catalog: unknown[] | undefined) {
const context = vm.createContext({
CodemanApp: function CodemanApp() {},
console,
window: catalog ? { __codemanCliCatalog: catalog } : {},
document: {
getElementById: () => null,
createElement: () => fakeElement(),
createElementNS: () => fakeElement(),
},
MobileDetection: { getDeviceType: () => 'mobile' },
});
for (const file of ['constants.js', 'mobile-overview.js']) {
vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file });
}
return {
modes: JSON.parse(JSON.stringify(vm.runInContext('mobileOverviewRunModes()', context))),
staticTable: JSON.parse(JSON.stringify(vm.runInContext('MOBILE_OVERVIEW_RUN_MODES', context))),
};
}
it('keeps the Run button on its word label, never the two-letter tab badge', () => {
const { modes } = loadRunModes([
{ id: 'claude', label: 'Claude Code', shortBadge: 'CC', kind: 'agent', enabled: true },
{ id: 'codex', label: 'Codex', shortBadge: 'CX', kind: 'agent', enabled: true },
{ id: 'grok', label: 'Grok', shortBadge: 'GK', kind: 'agent', enabled: false },
{ id: 'shell', label: 'Shell', shortBadge: 'SH', kind: 'shell', enabled: true },
]);
expect(modes).toEqual([
{ mode: 'claude', label: 'Claude Code', short: 'Claude Code' },
{ mode: 'codex', label: 'Codex', short: 'Codex' },
{ mode: 'shell', label: 'Terminal / Shell', short: 'Shell' },
]);
});
it('renders every stock CLI exactly as the static fallback table did', () => {
// The catalogue-driven path must be byte-identical to the pre-registry table for the
// shipped CLIs; only a CLI the table never knew (a custom entry) may differ.
const catalog = STOCK_CLIS.map((e) => ({
id: e.id,
label: e.label,
shortBadge: e.shortBadge,
kind: e.kind,
enabled: true,
}));
const { modes, staticTable } = loadRunModes(catalog);
const byMode = (list: Array<{ mode: string }>) => [...list].sort((a, b) => a.mode.localeCompare(b.mode));
expect(byMode(modes)).toEqual(byMode(staticTable));
});
});
+56 -2
View File
@@ -23,8 +23,11 @@ import { isDeepSeekAvailable, isDeepSeekRunnable } from '../src/utils/deepseek-c
import { isOmpAvailable } from '../src/utils/omp-cli-resolver.js';
import { isCloudflaredAvailable } from '../src/utils/cloudflared-resolver.js';
import { isGitAvailable } from '../src/git-clone.js';
import { enabledClis } from '../src/config/cli-registry/registry.js';
import { enabledClis, reloadCliRegistry } from '../src/config/cli-registry/registry.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import { dataPath } from '../src/config/instance.js';
import { mkdirSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
// renderIndexHtml probes the real PATH for every CLI, which would make the
// assertions below depend on whatever happens to be installed on the machine
@@ -207,9 +210,59 @@ describe('WebServer.renderIndexHtml', () => {
omp: true,
cloudflared: true,
git: true,
shell: true,
});
});
it('reads as unavailable for a CLI disabled via the registry, even though it is installed', async () => {
// The bug this guards: a CLI toggled off in Settings (docs/cli-enable-disable-plan.md)
// still offered itself in the welcome screen / Run menu / mobile overview, because
// window.__codemanCliAvailable was built purely from each resolver's own PATH probe —
// it never consulted the registry's `enabled` flag at all. Installed AND enabled must
// both hold for `isCliAvailable()` (the client-side gate every one of those surfaces
// reads) to read true.
vi.mocked(isCodexAvailable).mockReturnValue(true);
vi.mocked(isClaudeAvailable).mockReturnValue(true);
const path = dataPath('clis.json');
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify({ clis: { codex: { enabled: false } } }, null, 2), { mode: 0o600 });
reloadCliRegistry();
try {
const { server } = makeServer({});
const html = await render(server);
const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]);
expect(flags.codex).toBe(false); // installed, but disabled in the registry
expect(flags.claude).toBe(true); // installed and enabled — unaffected by codex's override
} finally {
writeFileSync(path, JSON.stringify({ clis: {} }, null, 2), { mode: 0o600 });
reloadCliRegistry();
}
});
it('injects the full registry catalog for every launch surface, including disabled entries', async () => {
const { server } = makeServer({});
const html = await render(server);
const catalog = JSON.parse(html.match(/window\.__codemanCliCatalog=(\[.*?\]);/)![1]) as Array<{
id: string;
label: string;
shortBadge: string;
order: number;
kind: string;
enabled: boolean;
available: boolean;
}>;
expect(catalog.map((entry) => entry.id)).toEqual(STOCK_CLIS.map((entry) => entry.id));
expect(catalog.find((entry) => entry.id === 'codex')).toMatchObject({ label: 'Codex', kind: 'agent' });
expect(catalog.find((entry) => entry.id === 'shell')).toMatchObject({ enabled: true, available: true });
expect(
catalog.every((entry) =>
Object.keys(entry).every((key) =>
['id', 'label', 'shortBadge', 'order', 'kind', 'enabled', 'available'].includes(key)
)
)
).toBe(true);
});
it('reports which run modes the custom-model Run-menu picker may generate an entry for', async () => {
// Read generically off the CLI registry's own capabilities, not a hardcoded id
// list — antigravity (`unsupported`) and shell (`kind !== 'agent'`) must be
@@ -297,7 +350,7 @@ describe('WebServer.renderIndexHtml', () => {
const html = await render(server);
expect(html).toContain('window.__codemanCliAvailable=');
const flags = JSON.parse(html.match(/window\.__codemanCliAvailable=(\{.*?\});/)![1]);
expect(Object.values(flags).every((v) => v === false)).toBe(true);
expect(Object.entries(flags).every(([key, value]) => key === 'shell' || value === false)).toBe(true);
});
it('skips the probe for a solo window, which has no welcome screen or run menu', async () => {
@@ -305,6 +358,7 @@ describe('WebServer.renderIndexHtml', () => {
const { server } = makeServer({});
const html = await render(server, 'sess-123');
expect(html).not.toContain('__codemanCliAvailable');
expect(html).not.toContain('__codemanCliCatalog');
expect(html).not.toContain('__codemanCustomModelClis');
});
+771
View File
@@ -0,0 +1,771 @@
/**
* @fileoverview Route tests for /api/clis (docs/cli-enable-disable-plan.md, Phases 2-5).
* Mirrors the admin-gating test shape used for other admin/settings surfaces (see
* test/routes/search-routes.test.ts's multi-user block).
*
* ⚠️ test/setup.ts gives the whole FILE one temp HOME, not one per `it()` — a write in
* one test is visible to every test declared after it. Phase 3-5 tests therefore each
* clean up what they create (delete a custom entry, restore a toggled stock flag) so
* later tests, including the Phase 2 "every entry is stock" assumption above, still hold.
*
* Port: N/A (app.inject(), no live server).
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'node:events';
import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync, statSync } from 'node:fs';
import { dirname } from 'node:path';
import { createRouteTestHarness } from './_route-test-utils.js';
import { installEnv, registerCliRegistryRoutes, type CliListItem } from '../../src/web/routes/cli-registry-routes.js';
import { SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { CreateSessionSchema } from '../../src/web/schemas.js';
import { buildSpawnCommandFromRegistry } from '../../src/session-cli-registry-bridge.js';
import { defaultRemoteCommandForMode } from '../../src/remote-hosts.js';
import { defaultDockerCommandForMode } from '../../src/docker-hosts.js';
import {
getCli,
registryFilePath,
reloadCliRegistry,
resolveInstallCommandForPlatform,
} from '../../src/config/cli-registry/registry.js';
// The install route spawns a real shell command, so `spawn` is replaced with a fake
// child (every other child_process export stays real). The two cache invalidators are
// wrapped pass-through spies: the real invalidation still runs, and the tests can see
// WHICH binaries and id each route forgot.
const { spawnMock, invalidateBinariesSpy, invalidateIdSpy } = vi.hoisted(() => ({
spawnMock: vi.fn(),
invalidateBinariesSpy: vi.fn(),
invalidateIdSpy: vi.fn(),
}));
vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>();
return { ...actual, spawn: spawnMock };
});
vi.mock('../../src/utils/cli-executable-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-executable-resolver.js')>();
return {
...actual,
invalidateCliExecutableResolvers: (binaries: readonly string[]) => {
invalidateBinariesSpy([...binaries]);
actual.invalidateCliExecutableResolvers(binaries);
},
};
});
vi.mock('../../src/utils/cli-resolver.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-resolver.js')>();
return {
...actual,
invalidateCliResolverCache: (id?: string) => {
invalidateIdSpy(id);
actual.invalidateCliResolverCache(id);
},
};
});
/** A spawned install that prints one line and exits with `code` on the next tick. */
function fakeInstallChild(code: number): EventEmitter {
const child = new EventEmitter() as EventEmitter & Record<string, unknown>;
const stdout = new EventEmitter();
child.stdout = stdout;
child.stderr = new EventEmitter();
child.kill = vi.fn();
setImmediate(() => {
stdout.emit('data', Buffer.from(code === 0 ? 'installed\n' : 'boom\n'));
child.emit('close', code);
});
return child;
}
/** Every write endpoint requires this on; toggled per-test by writing settings.json directly. */
function enableCliManagement(): void {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ cliManagementEnabled: true }));
}
/**
* The inverse, and load-bearing for every "off" test below: settings.json is shared by
* the whole FILE (one temp HOME, not one per `it()`), so a "should be rejected while off"
* test cannot assume the flag started false — an EARLIER test may have called
* `enableCliManagement()` and left it on.
*/
function disableCliManagement(): void {
mkdirSync(dirname(SETTINGS_PATH), { recursive: true });
writeFileSync(SETTINGS_PATH, JSON.stringify({ cliManagementEnabled: false }));
}
describe('GET /api/clis', () => {
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
});
it('single-user mode: returns every registry entry, disabled stock CLIs included', async () => {
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'GET', url: '/api/clis' });
expect(res.statusCode).toBe(200);
const body = res.json() as { success: true; data: CliListItem[] };
expect(body.success).toBe(true);
const ids = body.data.map((c) => c.id);
expect(ids).toContain('claude');
expect(ids).toContain('shell');
expect(ids.length).toBeGreaterThanOrEqual(9);
});
it('every item has the expected shape and excludes spawn-time fields', async () => {
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'GET', url: '/api/clis' });
const body = res.json() as { success: true; data: CliListItem[] };
for (const cli of body.data) {
expect(typeof cli.id).toBe('string');
expect(typeof cli.label).toBe('string');
expect(typeof cli.shortBadge).toBe('string');
expect(typeof cli.order).toBe('number');
expect(['agent', 'shell']).toContain(cli.kind);
expect(typeof cli.enabled).toBe('boolean');
expect(typeof cli.stock).toBe('boolean');
expect(typeof cli.installed).toBe('boolean');
expect(cli).not.toHaveProperty('launch');
expect(cli).not.toHaveProperty('env');
expect(cli).not.toHaveProperty('capabilities');
expect(cli).not.toHaveProperty('overlays');
expect(cli).not.toHaveProperty('discovery');
}
});
it('every entry is stock: true (no custom entries exist before Phase 5)', async () => {
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'GET', url: '/api/clis' });
const body = res.json() as { success: true; data: CliListItem[] };
expect(body.data.every((c) => c.stock === true)).toBe(true);
});
it('multi-user mode: an admin sees the full list', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
authUser: { username: 'root', role: 'admin' },
});
const res = await app.inject({ method: 'GET', url: '/api/clis' });
const body = res.json() as { success: true; data: CliListItem[] };
expect(body.data.length).toBeGreaterThanOrEqual(9);
});
it('multi-user mode: a non-admin sees an empty list, not a 403', async () => {
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'GET', url: '/api/clis' });
expect(res.statusCode).toBe(200);
const body = res.json() as { success: true; data: CliListItem[] };
expect(body.data).toEqual([]);
});
});
describe('PUT /api/clis/:id (Phase 3: enable/disable)', () => {
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
});
it('rejects when cliManagementEnabled is off — no settings.json write at all', async () => {
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: false } });
expect(res.statusCode).toBe(403);
const body = res.json() as { errorCode: string };
expect(body.errorCode).toBe('FORBIDDEN');
});
it('toggles a stock CLI off then back on, visible with no reload needed', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const off = await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: false } });
expect(off.statusCode).toBe(200);
const afterOff = await app.inject({ method: 'GET', url: '/api/clis' });
const grokOff = (afterOff.json() as { data: CliListItem[] }).data.find((c) => c.id === 'grok');
expect(grokOff?.enabled).toBe(false);
const on = await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: true } });
expect(on.statusCode).toBe(200);
const afterOn = await app.inject({ method: 'GET', url: '/api/clis' });
const grokOn = (afterOn.json() as { data: CliListItem[] }).data.find((c) => c.id === 'grok');
expect(grokOn?.enabled).toBe(true);
});
it('rejects disabling shell, changes nothing', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/shell', payload: { enabled: false } });
// errorCode, not statusCode: this branch returns bare createErrorResponse()
// and relies on server.ts's global preSerialization hook to map it to 400,
// which the lightweight test harness does not register — same convention
// as test/routes/custom-model-routes.test.ts's equivalent checks.
expect(res.json().errorCode).toBe('INVALID_INPUT');
const list = await app.inject({ method: 'GET', url: '/api/clis' });
const entry = (list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'shell');
expect(entry?.enabled).toBe(true);
});
it('allows disabling claude — only shell keeps the hard guarantee (revised 2026-09-23)', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const off = await app.inject({ method: 'PUT', url: '/api/clis/claude', payload: { enabled: false } });
expect(off.statusCode).toBe(200);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
const entry = (list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'claude');
expect(entry?.enabled).toBe(false);
// Restore for any later test in this file that assumes claude's stock default.
await app.inject({ method: 'PUT', url: '/api/clis/claude', payload: { enabled: true } });
});
it('404s an id that does not exist, never creating one', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/nonexistent-id', payload: { enabled: true } });
expect(res.json().errorCode).toBe('NOT_FOUND');
const list = await app.inject({ method: 'GET', url: '/api/clis' });
expect((list.json() as { data: CliListItem[] }).data.some((c) => c.id === 'nonexistent-id')).toBe(false);
});
it('multi-user: non-admin is rejected before the write', async () => {
enableCliManagement();
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: false } });
expect(res.statusCode).toBe(403);
});
it('preserves an unrelated existing override key on a stock entry when toggling enabled', async () => {
enableCliManagement();
// grok's real accent is #f43f5e (stock.ts); overriding it here first proves
// the enabled-only write is a MERGE, not a replace, of that id's override.
mkdirSync(dirname(registryFilePath()), { recursive: true });
writeFileSync(registryFilePath(), JSON.stringify({ schemaVersion: 1, clis: { grok: { accent: '#123456' } } }), {
mode: 0o600,
});
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: false } });
expect(res.statusCode).toBe(200);
// GET /api/clis deliberately excludes `accent` (Phase 2's own response
// shape), so verify the merge server-side through the registry itself.
const grok = getCli('grok');
expect(grok?.enabled).toBe(false);
expect(grok?.accent).toBe('#123456');
// Restore for any later test in this file that assumes grok's stock default.
await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: true } });
});
it('writes clis.json mode 0600 on POSIX', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: true } });
if (process.platform !== 'win32') {
const mode = statSync(registryFilePath()).mode & 0o777;
expect(mode).toBe(0o600);
}
});
});
describe('POST /api/clis/:id/install (Phase 4)', () => {
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
});
it('rejects when cliManagementEnabled is off', async () => {
disableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(res.statusCode).toBe(403);
});
it('rejects a custom entry id — Decision 3: a custom install command is never executed', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-install-guard', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
const res = await app.inject({ method: 'POST', url: '/api/clis/test-install-guard/install' });
expect(res.json().errorCode).toBe('INVALID_INPUT');
await app.inject({ method: 'DELETE', url: '/api/clis/test-install-guard' });
});
it('multi-user: non-admin is rejected before any spawn', async () => {
enableCliManagement();
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(res.statusCode).toBe(403);
});
});
describe('Custom CLI entries (Phase 5)', () => {
afterEach(() => {
delete process.env.CODEMAN_MULTIUSER;
});
it('rejects create when cliManagementEnabled is off', async () => {
disableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-off', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(res.statusCode).toBe(403);
});
it('creates a custom entry, it appears in GET /api/clis with stock:false', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const create = await app.inject({
method: 'POST',
url: '/api/clis',
payload: {
id: 'test-create',
label: 'Test CLI',
shortBadge: 'TC',
binaries: ['test-create-bin'],
argv: ['test-create-bin', '--flag'],
},
});
expect(create.statusCode).toBe(200);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
const entry = (list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'test-create');
expect(entry?.stock).toBe(false);
expect(entry?.label).toBe('Test CLI');
await app.inject({ method: 'DELETE', url: '/api/clis/test-create' });
});
it('rejects a create whose id collides with a stock id', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'claude', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(res.json().errorCode).toBe('ALREADY_EXISTS');
});
it('rejects creating the same custom id twice', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const payload = { id: 'test-dup', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] };
const first = await app.inject({ method: 'POST', url: '/api/clis', payload });
expect(first.statusCode).toBe(200);
const second = await app.inject({ method: 'POST', url: '/api/clis', payload });
expect(second.json().errorCode).toBe('ALREADY_EXISTS');
await app.inject({ method: 'DELETE', url: '/api/clis/test-dup' });
});
it('rejects a literal with shell metacharacters (the schema, not a new bypass)', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-unsafe', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x; rm -rf /'] },
});
expect(res.statusCode).toBe(400);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
expect((list.json() as { data: CliListItem[] }).data.some((c) => c.id === 'test-unsafe')).toBe(false);
});
it('updates an existing custom entry via PUT /api/clis/custom/:id', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-update', label: 'Before', shortBadge: 'BE', binaries: ['x'], argv: ['x'] },
});
const update = await app.inject({
method: 'PUT',
url: '/api/clis/custom/test-update',
payload: { label: 'After', shortBadge: 'AF', binaries: ['y'], argv: ['y', '--z'] },
});
expect(update.statusCode).toBe(200);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
const entry = (list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'test-update');
expect(entry?.label).toBe('After');
await app.inject({ method: 'DELETE', url: '/api/clis/test-update' });
});
it('rejects PUT /api/clis/custom/:id against a stock id', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({
method: 'PUT',
url: '/api/clis/custom/claude',
payload: { label: 'Hijack', shortBadge: 'HJ', binaries: ['x'], argv: ['x'] },
});
expect(res.json().errorCode).toBe('INVALID_INPUT');
const list = await app.inject({ method: 'GET', url: '/api/clis' });
expect((list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'claude')?.label).toBe('Claude Code');
});
it('404s an update against a custom id that does not exist', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({
method: 'PUT',
url: '/api/clis/custom/nonexistent-custom',
payload: { label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(res.json().errorCode).toBe('NOT_FOUND');
});
it('deletes a custom entry; a second delete 404s', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-delete', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
const del = await app.inject({ method: 'DELETE', url: '/api/clis/test-delete' });
expect(del.statusCode).toBe(200);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
expect((list.json() as { data: CliListItem[] }).data.some((c) => c.id === 'test-delete')).toBe(false);
const again = await app.inject({ method: 'DELETE', url: '/api/clis/test-delete' });
expect(again.json().errorCode).toBe('NOT_FOUND');
});
it('refuses to delete a stock CLI', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'DELETE', url: '/api/clis/claude' });
expect(res.json().errorCode).toBe('INVALID_INPUT');
const list = await app.inject({ method: 'GET', url: '/api/clis' });
expect((list.json() as { data: CliListItem[] }).data.some((c) => c.id === 'claude')).toBe(true);
});
it('multi-user: non-admin is rejected on create/update/delete', async () => {
enableCliManagement();
process.env.CODEMAN_MULTIUSER = '1';
const { app } = await createRouteTestHarness(registerCliRegistryRoutes, {
authUser: { username: 'bob', role: 'user' },
});
const create = await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-mu', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(create.statusCode).toBe(403);
const update = await app.inject({
method: 'PUT',
url: '/api/clis/custom/test-mu',
payload: { label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(update.statusCode).toBe(403);
const del = await app.inject({ method: 'DELETE', url: '/api/clis/test-mu' });
expect(del.statusCode).toBe(403);
});
it('a custom entry can also be toggled via the simple Phase 3 endpoint', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-toggle', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'], enabled: true },
});
const off = await app.inject({ method: 'PUT', url: '/api/clis/test-toggle', payload: { enabled: false } });
expect(off.statusCode).toBe(200);
const list = await app.inject({ method: 'GET', url: '/api/clis' });
const entry = (list.json() as { data: CliListItem[] }).data.find((c) => c.id === 'test-toggle');
expect(entry?.enabled).toBe(false);
// The rest of the entry (binaries/argv/label) must survive the shallow
// enabled-only merge — proven indirectly: a second full update still finds
// the row and changes its label, which would fail if the toggle had
// corrupted the stored shape.
const relabel = await app.inject({
method: 'PUT',
url: '/api/clis/custom/test-toggle',
payload: { label: 'Still here', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(relabel.statusCode).toBe(200);
await app.inject({ method: 'DELETE', url: '/api/clis/test-toggle' });
});
});
describe('resolver caches are forgotten when what a CLI resolves to changes', () => {
beforeEach(() => {
spawnMock.mockReset();
invalidateBinariesSpy.mockClear();
invalidateIdSpy.mockClear();
});
it('GET /api/clis names the install command for a stock entry only (for the confirm dialog)', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-cmd', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
const list = (await app.inject({ method: 'GET', url: '/api/clis' })).json() as { data: CliListItem[] };
const grok = list.data.find((c) => c.id === 'grok');
expect(grok?.installCommand).toBe(resolveInstallCommandForPlatform(getCli('grok')!));
expect(list.data.find((c) => c.id === 'test-cmd')).not.toHaveProperty('installCommand');
await app.inject({ method: 'DELETE', url: '/api/clis/test-cmd' });
});
it('a successful install runs the stock command and forgets that CLI’s cached lookups', async () => {
enableCliManagement();
spawnMock.mockImplementation(() => fakeInstallChild(0));
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(res.statusCode).toBe(200);
const grok = getCli('grok')!;
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(spawnMock.mock.calls[0][0]).toBe(resolveInstallCommandForPlatform(grok));
expect(spawnMock.mock.calls[0][1]).toMatchObject({ shell: true, detached: true });
// Without this, the Run menu and a session spawn replayed the pre-install miss
// for up to the 5-minute negative-cache backoff.
expect(invalidateBinariesSpy).toHaveBeenCalledWith(grok.discovery.binaries);
expect(invalidateIdSpy).toHaveBeenCalledWith('grok');
});
it('a failed install still forgets the cached lookups (it may have left a binary behind)', async () => {
enableCliManagement();
spawnMock.mockImplementation(() => fakeInstallChild(1));
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(res.json().errorCode).toBe('OPERATION_FAILED');
expect(invalidateIdSpy).toHaveBeenCalledWith('grok');
});
it('never spawns anything for a custom entry, even though the route exists', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-nospawn', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
await app.inject({ method: 'POST', url: '/api/clis/test-nospawn/install' });
expect(spawnMock).not.toHaveBeenCalled();
await app.inject({ method: 'DELETE', url: '/api/clis/test-nospawn' });
});
it('editing a custom entry forgets BOTH its old and new binaries, and its id', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-rebin', label: 'X', shortBadge: 'X', binaries: ['old-bin'], argv: ['old-bin'] },
});
invalidateBinariesSpy.mockClear();
invalidateIdSpy.mockClear();
const res = await app.inject({
method: 'PUT',
url: '/api/clis/custom/test-rebin',
payload: { label: 'X', shortBadge: 'X', binaries: ['new-bin'], argv: ['new-bin'] },
});
expect(res.statusCode).toBe(200);
expect(invalidateBinariesSpy).toHaveBeenCalledWith(['old-bin', 'new-bin']);
expect(invalidateIdSpy).toHaveBeenCalledWith('test-rebin');
await app.inject({ method: 'DELETE', url: '/api/clis/test-rebin' });
});
it('deleting a custom entry forgets its binaries and id, so a same-named re-create starts clean', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-forget', label: 'X', shortBadge: 'X', binaries: ['gone-bin'], argv: ['gone-bin'] },
});
invalidateBinariesSpy.mockClear();
invalidateIdSpy.mockClear();
await app.inject({ method: 'DELETE', url: '/api/clis/test-forget' });
expect(invalidateBinariesSpy).toHaveBeenCalledWith(['gone-bin']);
expect(invalidateIdSpy).toHaveBeenCalledWith('test-forget');
});
});
/**
* The #476 review's must-fix items for the writer. Each reproduces the failure it reported:
* a corrupt hand-edit overwritten by one toggle, parallel toggles lost to a shared temp file,
* and a file the reader refuses rewritten as trusted 0600 config.
*/
describe('registry writes are serialized and never clobber a file the reader would refuse', () => {
beforeEach(() => {
spawnMock.mockReset();
});
/** Put the override file back to "absent" so later tests start from stock. */
function clearRegistryFile(): void {
rmSync(registryFilePath(), { force: true });
reloadCliRegistry();
}
it('refuses to overwrite a clis.json that does not parse, and leaves it untouched', async () => {
enableCliManagement();
mkdirSync(dirname(registryFilePath()), { recursive: true });
const handEdit = '{ "schemaVersion": 1, "clis": { "grok": { "accent": "#123456" }, }';
writeFileSync(registryFilePath(), handEdit, { mode: 0o600 });
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/pi', payload: { enabled: false } });
expect(res.json().errorCode).toBe('CONFLICT');
expect(res.json().error).toContain('not valid JSON');
expect(readFileSync(registryFilePath(), 'utf-8')).toBe(handEdit);
clearRegistryFile();
});
it.skipIf(process.platform === 'win32')(
'refuses to rewrite a clis.json with group/world permission bits, naming the chmod fix',
async () => {
enableCliManagement();
mkdirSync(dirname(registryFilePath()), { recursive: true });
writeFileSync(registryFilePath(), JSON.stringify({ schemaVersion: 1, clis: {} }));
chmodSync(registryFilePath(), 0o644);
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const res = await app.inject({ method: 'PUT', url: '/api/clis/pi', payload: { enabled: false } });
expect(res.json().errorCode).toBe('CONFLICT');
expect(res.json().error).toContain('chmod 600');
// Still the refused mode: the write did not turn it into trusted config.
expect(statSync(registryFilePath()).mode & 0o777).toBe(0o644);
clearRegistryFile();
}
);
it('keeps every one of several parallel toggles, with no failures', async () => {
enableCliManagement();
clearRegistryFile();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const ids = ['grok', 'pi', 'omp', 'gemini'];
const results = await Promise.all(
ids.map((id) => app.inject({ method: 'PUT', url: `/api/clis/${id}`, payload: { enabled: false } }))
);
expect(results.map((r) => r.statusCode)).toEqual(ids.map(() => 200));
const onDisk = JSON.parse(readFileSync(registryFilePath(), 'utf-8')) as {
clis: Record<string, { enabled?: boolean }>;
};
for (const id of ids) {
expect(onDisk.clis[id]?.enabled).toBe(false);
expect(getCli(id)?.enabled).toBe(false);
}
clearRegistryFile();
});
it('editing a disabled custom entry keeps it disabled when the body omits enabled', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-keep-off', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
await app.inject({ method: 'PUT', url: '/api/clis/test-keep-off', payload: { enabled: false } });
const update = await app.inject({
method: 'PUT',
url: '/api/clis/custom/test-keep-off',
payload: { label: 'Renamed', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(update.statusCode).toBe(200);
expect(getCli('test-keep-off')?.enabled).toBe(false);
expect(getCli('test-keep-off')?.label).toBe('Renamed');
await app.inject({ method: 'DELETE', url: '/api/clis/test-keep-off' });
});
it('answers 409 to a second install of the same CLI while the first is still running', async () => {
enableCliManagement();
let finish: (code: number) => void = () => {};
spawnMock.mockImplementation(() => {
const child = new EventEmitter() as EventEmitter & Record<string, unknown>;
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
finish = (code) => child.emit('close', code);
return child;
});
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
const first = app.inject({ method: 'POST', url: '/api/clis/grok/install' });
await vi.waitFor(() => expect(spawnMock).toHaveBeenCalledTimes(1));
const second = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(second.json().errorCode).toBe('CONFLICT');
expect(spawnMock).toHaveBeenCalledTimes(1);
finish(0);
expect((await first).statusCode).toBe(200);
// The guard is released once the first finishes.
spawnMock.mockImplementation(() => fakeInstallChild(0));
const third = await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
expect(third.statusCode).toBe(200);
});
it('hands the install script an environment with every CODEMAN_* variable stripped', async () => {
enableCliManagement();
process.env.CODEMAN_TEST_SECRET = 'do-not-leak';
try {
spawnMock.mockImplementation(() => fakeInstallChild(0));
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({ method: 'POST', url: '/api/clis/grok/install' });
const env = spawnMock.mock.calls[0][1].env as NodeJS.ProcessEnv;
expect(Object.keys(env).filter((k) => k.startsWith('CODEMAN_'))).toEqual([]);
expect(env.PATH).toBe(process.env.PATH);
} finally {
delete process.env.CODEMAN_TEST_SECRET;
}
expect(installEnv({ CODEMAN_PASSWORD: 'x', HOME: '/h' })).toEqual({ HOME: '/h' });
});
});
/**
* #343 review, finding 2: the run-mode allowlist used to be computed once at import, so a
* CLI toggled on in Settings still failed POST /api/sessions with INVALID_INPUT until a
* restart. Drives the real toggle/create routes and then the real session-create schema.
*/
describe('a toggle or new custom CLI reaches session-create validation with no restart', () => {
it('disabling grok rejects mode grok at once, and re-enabling accepts it again', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
expect(CreateSessionSchema.safeParse({ mode: 'grok' }).success).toBe(true);
await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: false } });
expect(CreateSessionSchema.safeParse({ mode: 'grok' }).success).toBe(false);
await app.inject({ method: 'PUT', url: '/api/clis/grok', payload: { enabled: true } });
expect(CreateSessionSchema.safeParse({ mode: 'grok' }).success).toBe(true);
});
it('a newly created custom CLI is a valid mode immediately, and stops being one when deleted', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
expect(CreateSessionSchema.safeParse({ mode: 'test-live-mode' }).success).toBe(false);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-live-mode', label: 'X', shortBadge: 'X', binaries: ['x'], argv: ['x'] },
});
expect(CreateSessionSchema.safeParse({ mode: 'test-live-mode' }).success).toBe(true);
await app.inject({ method: 'DELETE', url: '/api/clis/test-live-mode' });
expect(CreateSessionSchema.safeParse({ mode: 'test-live-mode' }).success).toBe(false);
});
});
/**
* #347 review, finding 5: a custom CLI was API-acceptable but not survivable downstream (a
* remote pane command came out as `cd <path> && undefined`). #476 makes custom entries
* creatable from Settings, so pin that one created here launches everywhere it can run.
*/
describe('a custom CLI created through the API launches locally, over ssh and in docker', () => {
it('renders its argv locally and its binary for the remote/docker overlays', async () => {
enableCliManagement();
const { app } = await createRouteTestHarness(registerCliRegistryRoutes);
await app.inject({
method: 'POST',
url: '/api/clis',
payload: { id: 'test-launch', label: 'X', shortBadge: 'X', binaries: ['my-agent'], argv: ['my-agent', '--yolo'] },
});
const entry = getCli('test-launch')!;
const mode = 'test-launch' as Parameters<typeof defaultRemoteCommandForMode>[0];
expect(buildSpawnCommandFromRegistry(entry, { mode, sessionId: 'sid' })).toBe('my-agent --yolo');
expect(defaultRemoteCommandForMode(mode)).toContain('my-agent');
expect(defaultRemoteCommandForMode(mode)).not.toContain('undefined');
expect(defaultDockerCommandForMode(mode)).toBe('exec my-agent');
await app.inject({ method: 'DELETE', url: '/api/clis/test-launch' });
});
});
+14 -3
View File
@@ -39,7 +39,7 @@ interface Harness {
externalIds: string[];
}
function loadHarness(): Harness {
function loadHarness(catalog: Array<{ id: string; kind: string; enabled: boolean }> = []): Harness {
const dom = new JSDOM('<!doctype html><body><button id="runBtn"></button></body>', {
url: 'http://localhost/',
runScripts: 'dangerously',
@@ -49,7 +49,9 @@ function loadHarness(): Harness {
document: Document;
CodemanApp: new () => HarnessApp;
__TEST_RUN_MODE_LAUNCH: Record<string, unknown>;
__codemanCliCatalog: Array<{ id: string; kind: string; enabled: boolean }>;
};
win.__codemanCliCatalog = catalog;
win.eval('window.CodemanApp = function CodemanApp() {};');
// The assignment rides in the SAME evaluated string as the module:
// RUN_MODE_LAUNCH is a bare top-level `const`, visible only to this eval call
@@ -106,9 +108,18 @@ describe('run() dispatch (session-ui.js)', () => {
});
}
it('an enabled registry agent reaches the shared launcher', async () => {
const { app } = loadHarness([{ id: 'custom-agent', kind: 'agent', enabled: true }]);
app._runMode = 'custom-agent';
await expect(app.run()).resolves.toBe('cli:custom-agent');
expect(app._runCliMode).toHaveBeenCalledTimes(1);
expect(app._runCliMode).toHaveBeenCalledWith('custom-agent');
expect(app.runClaude).not.toHaveBeenCalled();
});
it('an unknown mode lands on runClaude(), never on the shared launcher', async () => {
// `_runCliMode(mode)` reads `RUN_MODE_LAUNCH[mode].label` unguarded, so an
// unknown id reaching it would throw rather than launch anything.
// A stale localStorage mode must not reach `_runCliMode()`: it has neither
// a stock launch shape nor an enabled registry entry to launch.
for (const mode of ['nope', 'CLAUDE', 'code x']) {
const { app } = loadHarness();
app._runMode = mode;
+106 -75
View File
@@ -376,47 +376,84 @@ describe('Codex quick start settings', () => {
});
describe('CLI availability gating (#200/#201)', () => {
// Drives the REAL settings-ui.js + session-ui.js against stub elements, so an
// added run mode that nobody wires up here is what these are meant to catch.
function loadUi(flags: Record<string, boolean> | undefined) {
const CATALOG = [
{ id: 'claude', label: 'Claude Code', shortBadge: 'CC', kind: 'agent', enabled: true },
{ id: 'opencode', label: 'OpenCode', shortBadge: 'OC', kind: 'agent', enabled: true },
{ id: 'codex', label: 'Codex', shortBadge: 'CX', kind: 'agent', enabled: true },
{ id: 'gemini', label: 'Gemini', shortBadge: 'GM', kind: 'agent', enabled: true },
{ id: 'antigravity', label: 'Antigravity', shortBadge: 'AG', kind: 'agent', enabled: true },
{ id: 'pi', label: 'Pi', shortBadge: 'PI', kind: 'agent', enabled: true },
{ id: 'grok', label: 'Grok', shortBadge: 'GK', kind: 'agent', enabled: true },
{ id: 'deepseek', label: 'DeepSeek', shortBadge: 'DS', kind: 'agent', enabled: true },
{ id: 'omp', label: 'OMP', shortBadge: 'OM', kind: 'agent', enabled: true },
{ id: 'custom-agent', label: 'Custom Agent', shortBadge: 'CA', kind: 'agent', enabled: true },
{ id: 'shell', label: 'Shell', shortBadge: 'SH', kind: 'shell', enabled: true },
];
function element() {
const el: any = {
style: { display: 'PRISTINE' },
dataset: {},
children: [] as any[],
setAttribute: () => {},
appendChild(child: any) {
this.children.push(child);
return child;
},
append(child: any) {
this.children.push(child);
},
replaceChildren(...children: any[]) {
this.children = children;
},
};
return el;
}
// Drives the REAL settings-ui.js + session-ui.js against stub elements, including
// a custom registry entry so a static stock-only list cannot pass this test.
function loadUi(flags: Record<string, boolean> | undefined, catalog = CATALOG) {
const CodemanApp = function CodemanApp(this: any) {};
const welcomeBtns: Record<string, { style: { display: string } }> = {};
for (const id of [
'welcomeClaudeBtn',
'welcomeOpencodeBtn',
'welcomeAntigravityBtn',
'welcomeGeminiBtn',
'welcomePiBtn',
'welcomeGrokBtn',
'welcomeOmpBtn',
'welcomeTunnelBtn',
]) {
welcomeBtns[id] = { style: { display: 'PRISTINE' } };
}
const modeBtns: Record<string, { style: { display: string } }> = {};
for (const mode of ['claude', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'omp', 'shell']) {
modeBtns[mode] = { style: { display: 'PRISTINE' } };
const welcomeCliActions = element();
const tunnelBtn = element();
const runModeCliOptions = element();
const modeBtns: Record<string, any> = {};
for (const cli of catalog) {
modeBtns[cli.id] = element();
modeBtns[cli.id].dataset.mode = cli.id;
}
const menu = {
querySelector: (sel: string) => {
const m = sel.match(/data-mode="([^"]+)"/);
return m ? (modeBtns[m[1]] ?? null) : null;
},
querySelector: (sel: string) =>
sel === '#runModeDeepSeekInstall' || sel === '#runModeDeepSeekWeb' ? null : null,
querySelectorAll: () => Object.values(modeBtns),
};
const context: any = vm.createContext({
CodemanApp,
MobileDetection: { getDeviceType: () => 'desktop', isTouchDevice: () => false, isHandheldDevice: () => false },
localStorage: { getItem: () => null, setItem: () => {} },
document: { getElementById: (id: string) => welcomeBtns[id] ?? null, querySelector: () => null },
document: {
getElementById: (id: string) =>
id === 'welcomeCliActions'
? welcomeCliActions
: id === 'welcomeTunnelBtn'
? tunnelBtn
: id === 'runModeCliOptions'
? runModeCliOptions
: null,
createElement: element,
createElementNS: element,
querySelector: () => null,
},
console,
});
context.window = context;
context.__codemanCliCatalog = catalog;
if (flags !== undefined) context.__codemanCliAvailable = flags;
for (const file of ['settings-ui.js', 'session-ui.js']) {
const src = readFileSync(resolve(import.meta.dirname, `../src/web/public/${file}`), 'utf8');
vm.runInContext(src, context, { filename: file });
}
return { app: new (CodemanApp as any)(), welcomeBtns, modeBtns, menu };
return { app: new (CodemanApp as any)(), welcomeCliActions, tunnelBtn, runModeCliOptions, modeBtns, menu };
}
const ALL_OFF = {
@@ -427,50 +464,28 @@ describe('Codex quick start settings', () => {
antigravity: false,
pi: false,
grok: false,
deepseek: false,
omp: false,
'custom-agent': false,
cloudflared: false,
};
it('hides each welcome button whose tool is missing, including the tunnel', () => {
const { app, welcomeBtns } = loadUi({ ...ALL_OFF, claude: true });
it('renders only enabled and available registry entries on the welcome screen', () => {
const { app, welcomeCliActions, tunnelBtn } = loadUi({ ...ALL_OFF, claude: true, 'custom-agent': true });
app.applyWelcomeCliVisibility();
expect(welcomeBtns.welcomeClaudeBtn.style.display).toBe('flex');
expect(welcomeBtns.welcomeOpencodeBtn.style.display).toBe('none');
expect(welcomeBtns.welcomeAntigravityBtn.style.display).toBe('none');
expect(welcomeBtns.welcomeGeminiBtn.style.display).toBe('none');
const offered = welcomeCliActions.children.map((btn: any) => btn.dataset.mode);
expect(offered).toEqual(['claude', 'custom-agent', 'shell']);
// #200 originally DELETED the tunnel button and its QR outright; it is gated
// on cloudflared instead, so a box that has cloudflared keeps the feature.
expect(welcomeBtns.welcomeTunnelBtn.style.display).toBe('none');
expect(tunnelBtn.style.display).toBe('none');
const withTunnel = loadUi({ ...ALL_OFF, cloudflared: true });
withTunnel.app.applyWelcomeCliVisibility();
expect(withTunnel.welcomeBtns.welcomeTunnelBtn.style.display).toBe('flex');
expect(withTunnel.tunnelBtn.style.display).toBe('flex');
// Pi is gated on `pi` like the rest; the resolver additionally version-probes
// the binary, so a stray `pi` on PATH reports unavailable rather than broken.
const withPi = loadUi({ ...ALL_OFF, pi: true });
withPi.app.applyWelcomeCliVisibility();
expect(withPi.welcomeBtns.welcomePiBtn.style.display).toBe('flex');
// Grok is gated on `grok` like the rest; the resolver additionally
// version-probes the binary, so a stray `grok` on PATH reports unavailable.
const withGrok = loadUi({ ...ALL_OFF, grok: true });
withGrok.app.applyWelcomeCliVisibility();
expect(withGrok.welcomeBtns.welcomeGrokBtn.style.display).toBe('flex');
expect(withGrok.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
expect(withPi.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
// Antigravity is a first-class welcome action, gated on `agy` like the rest.
const withAgy = loadUi({ ...ALL_OFF, antigravity: true });
withAgy.app.applyWelcomeCliVisibility();
expect(withAgy.welcomeBtns.welcomeAntigravityBtn.style.display).toBe('flex');
expect(withAgy.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
// OMP is a first-class welcome action, gated on `omp` like the rest.
const withOmp = loadUi({ ...ALL_OFF, omp: true });
withOmp.app.applyWelcomeCliVisibility();
expect(withOmp.welcomeBtns.welcomeOmpBtn.style.display).toBe('flex');
expect(withOmp.welcomeBtns.welcomeClaudeBtn.style.display).toBe('none');
expect(withPi.welcomeCliActions.children.map((btn: any) => btn.dataset.mode)).toEqual(['pi', 'shell']);
});
it('gates every run mode in the dropdown, antigravity included, and never shell', () => {
@@ -487,34 +502,50 @@ describe('Codex quick start settings', () => {
expect(modeBtns.shell.style.display).toBe('PRISTINE');
});
it('gates every mode the run-mode menu actually offers', () => {
// Catches a sixth run mode being added to index.html without being gated,
// which is exactly how antigravity slipped past #201.
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
const menuHtml = html.slice(html.indexOf('id="runModeMenu"'));
const offered = [...menuHtml.slice(0, menuHtml.indexOf('</div>')).matchAll(/data-mode="([^"]+)"/g)].map(
(m) => m[1]
it('renders each enabled agent registry entry in the Run menu, including custom entries', () => {
const { app, runModeCliOptions } = loadUi({ ...ALL_OFF, claude: true, 'custom-agent': true });
app.renderRegistryRunOptions();
expect(runModeCliOptions.children.map((btn: any) => btn.dataset.mode)).toContain('custom-agent');
expect(runModeCliOptions.children.map((btn: any) => btn.dataset.mode)).not.toContain('shell');
});
it('labels welcome buttons "Run <label>", the strings i18n.js translates ("Run Claude Code")', () => {
const { app, welcomeCliActions } = loadUi({ ...ALL_OFF, claude: true });
app.applyWelcomeCliVisibility();
const texts = welcomeCliActions.children.map((btn: any) =>
btn.children.filter((c: unknown) => typeof c === 'string').join('')
);
expect(offered).toContain('antigravity');
expect(offered).toContain('pi');
expect(offered).toContain('grok');
expect(offered).toContain('omp');
expect(texts).toEqual(['Run Claude Code', 'Run Shell']);
});
it('falls back to the first ENABLED agent when the chosen run mode is disabled, never a hardcoded claude', () => {
const catalog = CATALOG.map((cli) =>
cli.id === 'claude' || cli.id === 'codex' ? { ...cli, enabled: false } : cli
);
const { app } = loadUi(undefined, catalog);
app.runMode = 'codex';
expect(app.runMode).toBe('opencode');
app.runMode = 'claude';
expect(app.runMode).toBe('opencode');
app.runMode = 'gemini';
expect(app.runMode).toBe('gemini');
});
it('builds the run-mode menu from the registry rather than static markup', () => {
const html = readFileSync(resolve(import.meta.dirname, '../src/web/public/index.html'), 'utf8');
expect(html).toContain('id="runModeCliOptions"');
const src = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8');
// Anchor on the DEFINITION, not the earlier call site in toggleRunModeMenu.
const fn = src.slice(src.indexOf('_refreshRunModeAvailability(menu) {'));
const gated = fn.slice(0, fn.indexOf('\n },'));
for (const mode of offered.filter((m) => m !== 'shell')) {
expect(gated).toContain(`'${mode}'`);
}
expect(src).toContain('renderRegistryRunOptions()');
expect(src).not.toContain('data-mode="codex"');
});
it('shows everything when the flags were never injected', () => {
// A cached page from a build without the injection, or a solo popup. Hiding
// every run button on a doubt would leave a working install nothing to click.
const { app, welcomeBtns, modeBtns, menu } = loadUi(undefined);
const { app, welcomeCliActions, modeBtns, menu } = loadUi(undefined);
app.applyWelcomeCliVisibility();
app._refreshRunModeAvailability(menu);
expect(welcomeBtns.welcomeClaudeBtn.style.display).toBe('flex');
expect(welcomeCliActions.children.map((btn: any) => btn.dataset.mode)).toContain('claude');
expect(modeBtns.gemini.style.display).toBe('flex');
});
});
+4 -3
View File
@@ -96,9 +96,9 @@ describe('WebServer index.html <title> templating (#82)', () => {
it('only substitutes the <title> tag — the rest of the template is identical (modulo asset cache-busting)', async () => {
// renderIndexHtml also appends ?v=<mtime> cache-bust params to same-origin
// .js/.css refs, and injects the CLI-availability flags, the custom-model
// Run-menu picker's CLI list and the transcript-gutter widths before </head>;
// strip all so the title remains the only other change.
// .js/.css refs, and injects the CLI-availability flags, launch catalog,
// custom-model Run-menu picker's CLI list and the transcript-gutter widths
// before </head>; strip all so the title remains the only other change.
//
// The flag strips are what keep this test environment-independent. The
// CLI-availability one used to pass here by luck: that script was injected
@@ -109,6 +109,7 @@ describe('WebServer index.html <title> templating (#82)', () => {
const html = (await render('laptop'))
.replace(/(\.(?:js|css))\?v=[^"]*/g, '$1')
.replace(/<script>window\.__codemanCliAvailable=\{.*?\};<\/script>\n/, '')
.replace(/<script>window\.__codemanCliCatalog=\[.*?\];<\/script>\n/, '')
.replace(/<script>window\.__codemanCustomModelClis=\[.*?\];<\/script>\n/, '')
// Injected unconditionally as an object keyed by run mode, empty when no
// enabled CLI declares a gutter, so it needs stripping on every machine.