mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 20:49:41 +02:00
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
229 lines
11 KiB
TypeScript
229 lines
11 KiB
TypeScript
/**
|
|
* @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);
|
|
});
|
|
});
|