Skip to content

PR A: CLI registry core as a pure internal refactor - #347

Open
opticon454 wants to merge 1 commit into
Ark0N:masterfrom
opticon454:feature/cli-registry-core
Open

PR A: CLI registry core as a pure internal refactor#347
opticon454 wants to merge 1 commit into
Ark0N:masterfrom
opticon454:feature/cli-registry-core

Conversation

@opticon454

Copy link
Copy Markdown

Thanks for the detailed read-through — it made this a much easier thing to scope. This is PR A from your list: the registry core as a pure internal refactor, rebased on current master (1.23.0), opened as a draft as you suggested so you can look at the DeepSeek extension design before I go further.

Behaviour is unchanged. No new endpoints, no new settings keys, no dependency changes, and the spawn command every CLI receives is byte-identical to what the hand-written builders produced.


What this does

Every run mode — claude, shell, opencode, codex, gemini, antigravity, pi, grok, deepseek — is a CliEntry in src/config/cli-registry/. Code that branched on a CLI's name now reads capability flags off that entry.

Per-CLI-id branch sites: ~123 → 32. The 32 that remain are allowlisted individually, each with its reason, by the guard test (details below). src/tmux-manager.ts alone sheds ~570 lines.

What the registry owns: binary discovery (search dirs, version and identity probes), the launch argv template, environment handling (exports, tmux setenv keys, the env-override allowlist), the multi-user privileged-parameter clamps, and the behavioural capabilities the rest of the app reads (isExternalCliMode, isAltScreenStripMode, hooksAvailableForMode, alt-screen strip class, echo policy, transcript format, model source, and friends). codeman doctor's per-CLI rows are generated from the same entries.

New files

File Role
config/cli-registry/types.ts Type definitions; no runtime
config/cli-registry/patterns.ts Named value patterns + the ReDoS-guarded regex compiler
config/cli-registry/profiles.ts Names of code-shaped escape hatches (kept import-free on purpose)
config/cli-registry/schema.ts Zod .strict() validation
config/cli-registry/argv.ts The token → shell-string renderer. Pure
config/cli-registry/stock.ts The nine shipped entries. The only file allowed to name a CLI id
config/cli-registry/registry.ts Load / deep-merge / validate. Read-only
session-cli-registry-bridge.ts Legacy <Mode>Config wire shape → registry params
utils/cli-resolver.ts Registry-driven discovery, layered over the existing resolver
utils/cli-launcher.ts Launcher-profile implementations (DeepSeek)

1. DeepSeek, and the four assumptions it breaks

This is the part I'd most like your eyes on.

The fact The extension
Permission switch is the DSH_PERMISSION_MODE env var, not a flag capabilities.privilegedEnvKeys
hooksAvailableForMode('deepseek') is a per-session question capabilities.hooks widens to 'none' | 'always' | 'supervised'
dsh is a profile launcher — installed ≠ runnable discovery.launcherProfile (+ launcherTargetParam)
Transcript is zstd session files capabilities.transcript gains 'deepseek-zstd'

The env-var privileged param

You flagged that capabilities.privilegedParams can only clamp argv params, so the registry as designed could not express clampEnvOverridesForOwner() — and that merging as-is would make a real multi-user control silently disappear. That is now a separate, deliberately distinct field:

/** Env var names a non-granted multi-user owner may not set at all, DROPPED from envOverrides. */
privilegedEnvKeys: string[];

It is not a variant of privilegedParams because the two reach the CLI by different paths — one becomes an argv flag, the other rides tmux setenv, which no argv clamp can see. ownerClampedEnvKeys() in session-routes.ts now derives its list from every enabled entry's privilegedEnvKeys, and I verified at runtime that it resolves to exactly master's list:

privilegedEnvKeys -> ["DSH_PERMISSION_MODE","DSH_HOME","DEEPSEEK_BASE_URL"]
matches master OWNER_CLAMPED_ENV_KEYS: true

The reasons for each key are recorded on the field's doc comment, including the one that is easy to lose: applyEnvOverrides() runs after the per-CLI env configure step, so without this a non-granted owner sending DSH_PERMISSION_MODE on the same request would land last and hand back exactly the privilege the config clamp removed.

DSH_PERMISSION_MODE itself is exported through a new env.configSetenv mapping rather than a bespoke configure step, which is what lets the ordinary privilegedParams clamp reach it: the clamp rewrites the param, and whatever the param ends up as is what gets exported. Values are re-validated against the declared ParamSpec before export — the wire shape is already Zod-checked, but this one reaches tmux setenv as a permission level, and a builder should not trust its caller there.

hooks as a tri-state

'supervised' means the CLI reports its own state to a supervisor and Codeman is that supervisor — definitive signals rather than inferred ones — but the session can disarm the bridge and a docker/remote session cannot reach it at all. hooksAvailableForMode(mode, options) keeps its exact signature and sessionHookOptions() is untouched, so every existing call site behaves as before.

A boolean is explicitly rejected by the schema, with a test, because true would have to mean 'always' — which is wrong for a supervised CLI and would promise a stop that never arrives.

Launcher profiles

discovery.launcherProfile names an entry in a small map (utils/cli-launcher.ts) answering two questions the binary alone cannot: is it runnable (stricter than "is the binary on disk") and what is the default target. discovery.launcherTargetParam names the param carrying a caller-requested target, so resolveCliLaunchError() keeps DeepSeek's three distinct actionable messages (binary missing / no pane-capable profile / the profile you named cannot drive a pane) rather than collapsing them to "not installed".

Identity probes

discovery.identity is new and general: proof that the binary found is the program meant, checked before the version probe. requireVersionMatch catches output with the wrong shape; this catches output with the right shape naming the wrong program — Debian's dsh (dancer's shell) answers --version perfectly happily.

Both named tests pass: test/deepseek-mode.test.ts (including its static source scans and the env-half clamp cases) and test/routes/external-cli-bypass-clamp.test.ts (the materialize-vs-only-if-sent split, unchanged).


2. Parse-time resolution

SESSION_MODE_IDS / ALLOWED_ENV_PREFIXES / ALLOWED_ENV_KEYS are gone as module-load constants. sessionModeSchema() is now a refinement that reads enabledClis() when the request is validated, and isAllowedEnvKey() reads the registry per call.

BLOCKED_ENV_KEYS is deliberately not registry-driven and is still checked first, so a pathological allowedPrefixes entry cannot unblock PATH — there is a test for exactly that.

Pinned by a test that disables a CLI, calls reloadCliRegistry(), and asserts POST /api/sessions starts rejecting that mode with no restart, plus the same for an env prefix:

it('stops accepting a mode as soon as its CLI is disabled — no restart', ...)
it('follows the registry for env-prefix allowlisting too', ...)
it('never lets a registry entry unblock a hard-blocked key', ...)

One consequence worth naming: test/agent-skill-mode-lists.test.ts derived its expected mode set by unwrapping the Zod optional to reach .options, which a parse-time refinement no longer has. Rather than restate the list there, schemas.ts now exports sessionModeIds() and the test reads that — it still derives from the runtime source of truth. Its "both endpoints agree" assertion would have become tautological (both now share one validator), so it parses a sample through each schema instead, which still catches the two drifting apart.


3. No Copilot

Removed entirely — no entry, no code, and no stale comments (three referenced it in the grok entry; they are gone). Happy to bring it back as pure registry data once github/copilot-cli#4180 and #4223 close; agreed it would be a good proof of the registry.

4. No write API, no auto-install

No /api/clis routes, no cli-installer.ts. discovery.install.command is display text only — it feeds codeman doctor's install hints and the "CLI not found" message, and is never spawned. Its doc comment states the invariant explicitly rather than pointing at an executor.

~/.codeman/clis.json is read on load (deep-merge, .strict() validation, pristine-stock fallback for a bad override, drop-with-warning for a bad custom entry, quarantine-not-overwrite for malformed JSON, group/world-writable refused) but nothing writes it. The seededStockIds ratchet is deferred to PR C along with the write API that needs it — which also means importing the registry, and therefore schemas.ts, performs no filesystem writes. A file written by a later version still loads cleanly here.


5. Your assorted findings

  • GET /api/grok/status — kept. Verified live on a deployed container alongside the other seven; all three distinct response shapes preserved ({available,path}, {available,path,version}, and DeepSeek's seven-field shape). No route registrations added or removed anywhere in this PR.
  • install.sh / docker — untouched, along with config/ and scripts/. No clis.stock.json here; the bash-3.2 fix and the enabled filter belong to PR B.
  • The no-id-branching guardtest/cli-registry-no-id-branching.test.ts now exists. It builds its id list from the live catalog, strips comments before scanning (comments legitimately quote the banned pattern to explain why a branch was removed), has an anti-vacuity check and a sanity check on the scanned file count, and fails on a stale allowlist entry. I confirmed it fails on a real violation by introducing one. The leftover if (this.mode === 'grok') is gone.
  • Parity suites — replaced with literal expected-string pins in test/cli-registry-spawn-golden.test.ts, captured from the hand-written builders before those builders were deleted, so the pins are the surviving record of what they emitted. Grok and DeepSeek are both covered — grok had no parity coverage at all previously, and the drop-don't-escape behaviour for rejected values is pinned too.
  • Resolver tests — all five files are intact and passing, because cli-executable-resolver.ts was layered on rather than replaced. The impostor-rejection tests, the version-regex contracts and the vitest hermeticity pins were never at risk. test/dependency-checker.test.ts now checks the pi/grok/dsh version rules by source rather than object identity, since the doctor compiles the entry's serialized pattern through compileVersionRegex().
  • codeman doctor grok row — present. The rows are generated from the registry, so that class of omission is now structurally impossible; there is also a test asserting a row exists for every CLI with a binary to probe. Live output shows all ten, Grok and DeepSeek included.
  • Prettier over test/** — dropped. Three test files are touched, all with real edits.
  • mobile-overview.jsdeliberately not addressed, and I want to flag it rather than have you notice. The phone picker can only diverge from the desktop menu once enable/disable exists, and PR A adds neither. It belongs with the settings UI in PR C. Say the word if you'd rather have it now.

Two fixes the registry enabled

  • probeDockerCliVersion() derived the in-container binary from the mode name. antigravity runs agy, so that assumption was wrong — though only claude reaches that path today (it is the one CLI with a version gate), so nothing was actually broken. It now reads discovery.binaries[0]. This is the one intentional behaviour change in the PR and I did not want to bury it.
  • compileVersionRegex() in the doctor — the dependency table compiles the entry's version pattern through the same ReDoS guard the argv engine uses, rather than new RegExp().

Security model, unchanged

Config still contains no shell text. Four independent layers, all preserved:

  1. There is no command: "..." field anywhere in the schema; argv.ts owns every separator, including the || between fallback variants.
  2. Every literal is validated against a safe-word pattern at load time, and a bad literal rejects the whole entry — a silently dropped flag would change security-relevant behaviour (losing --no-approve is not cosmetic).
  3. Values resolve through named patterns that live in code, so a clis.json cannot supply its own regex for a shell token and cannot widen its own validation.
  4. Escaping is independent of validation — renderToken() re-checks before emitting bare.

The only config-supplied regexes are discovery.version.regex and discovery.identity.regex; both run against truncated command output, never a shell token, and both go through compileVersionRegex()'s length cap and nested-quantifier rejection.

Named profiles are the escape hatch for behaviour that genuinely needs to run code. Their names live in profiles.ts, which is kept import-free so schema.ts can validate a name at load time — an entry naming a profile this build does not implement fails loudly instead of failing closed later.

Verification

Full CI gate, run inside a Linux container built from this branch:

typecheck=0   lint=0   format:check=0   check:frontend-syntax=0   npm test=0
Test Files  321 passed | 1 skipped (322)
     Tests  6280 passed | 12 skipped (6292)

Plus live behaviour checks on a running instance: one session per installed mode spawned through quick-start, with the real pane commands captured and compared against the golden pins —

claude    claude --dangerously-skip-permissions --session-id "a9c3e0d0-…"
codex     codex                    (+ CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_<id>)
gemini    gemini --skip-trust --approval-mode yolo
opencode  opencode
shell     '/bin/bash' -i -l        (no PATH export, as before)

— all eight per-mode status routes, and the full codeman doctor table.

Notes

  • Adding a CLI is now: add a CliEntry, add a golden spawn pin, add a row to the capability-predicate table. If you find yourself wanting an if, the guard test says so.
  • Unrelated to this PR, but found while verifying it: test/setup.ts scrubs CODEMAN_PASSWORD/CODEMAN_USERNAME/CODEMAN_GESTURE from the environment so a dev box cannot influence results, but not CODEMAN_INSTANCE/CODEMAN_DATA_DIR/CODEMAN_TMUX_SOCKET. Anyone running the suite with those set gets 7 spurious tmux-manager failures on socket names. One-line fix, deliberately kept out of this PR — happy to send it separately.

🤖 Generated with Claude Code

…anching

Every run mode (claude, shell, opencode, codex, gemini, antigravity, pi, grok,
deepseek) becomes a `CliEntry` in src/config/cli-registry/. Code that branched on
a CLI's name now reads capability flags off that entry; branch sites drop from
~123 to 32, and the ones that remain are allowlisted with individual reasons by
test/cli-registry-no-id-branching.test.ts.

Behaviour is unchanged. Spawn commands are pinned as literal strings in
test/cli-registry-spawn-golden.test.ts, captured from the hand-written builders
before those builders were deleted, so the pins are the surviving record of what
they emitted rather than a comparison of the engine with itself.

Schema extensions for DeepSeek, which breaks four assumptions its siblings do not:
- `capabilities.privilegedEnvKeys` -- its permission switch is the
  DSH_PERMISSION_MODE env var, not a flag, so an argv-only clamp cannot reach it.
- `capabilities.hooks` widens to 'none' | 'always' | 'supervised', because hook
  availability is a per-session question for dsh, not a per-mode one.
- `discovery.launcherProfile` -- dsh launches a profile, so "binary installed" is
  not "runnable".
- `capabilities.transcript` gains 'deepseek-zstd'.

Two fixes the registry enables:
- Session-mode and env-prefix validation resolve at request-parse time rather than
  being frozen at module import, so a registry change no longer needs a restart.
- probeDockerCliVersion() derives the in-container binary from the registry instead
  of assuming it equals the mode name (antigravity runs `agy`). Only claude reaches
  that path today, so nothing was broken in practice.

`~/.codeman/clis.json` can override any stock entry or add a custom CLI. It is
read-only here: nothing writes it, so importing the registry has no filesystem side
effects. Config never contains shell text -- entries declare typed argv tokens,
literals are validated against a safe-word pattern at load, and values resolve
through patterns named in code, so a clis.json cannot widen its own validation.

No new endpoints, no new settings, no dependency changes.

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

Copy link
Copy Markdown
Author

As per #343

@opticon454
opticon454 marked this pull request as ready for review August 27, 2026 12:14
@Ark0N

Ark0N commented Aug 27, 2026

Copy link
Copy Markdown
Owner

@opticon454 This is the PR I was hoping for. Every item from the PR A scope came back done, and a few came back better than I asked: the literal-string golden pins captured before the builders were deleted, the identity probe as a general concept rather than a dsh special case, privilegedEnvKeys as a structurally separate field instead of a variant of privilegedParams, and the guard test with an anti-vacuity check and a stale-allowlist check. The hooks tri-state is the right call and your reasoning for rejecting a boolean is the reasoning I would have given.

I read the whole diff and then went looking for seams independently rather than trusting the write-up.

What I verified

Full CI gate on your branch, locally: typecheck 0, lint 0, format:check 0, npm test 321 files / 6280 tests passed, 1 file and 12 tests skipped, exit 0. Matches your numbers exactly.

For the "byte-identical spawn command" claim I did not want to rely on the golden pins alone, since those are the same ~30 cases you chose. So I imported master's buildSpawnCommand and your buildSpawnCommandFromRegistry into one process and diffed them across the full cross product of options for all nine modes: every claude permission mode x allowedTools (including the rejected Bash(x); rm -rf /) x model (including opus[1m] and a backtick injection) x resume id x effort level x session name (including a $(id) and a CJK one) x CLI version, plus every external CLI's config shape with its invalid-value cases.

compared=11587  diffs=0

So the parity claim holds under a much wider net than the pins cover, including the drop-do-not-quote behaviour and claude's || fallback chain. I also checked buildEnvExports by hand: unset CLAUDECODE survives via the entry, and the COLORTERM/NO_COLOR sets are identical per mode (the order inside the block moves, the semantics do not). All seven /api/<cli>/status routes are present, no route registrations added or removed, no Copilot anywhere, no cli-installer.ts, no /api/clis, and install.command genuinely only ever reaches display strings.

Two things before I merge

1. Rebase. Your base is a51563ce (1.23.0); master is 1.23.2 now. There is exactly one conflict, src/utils/codex-cli-resolver.ts, from #346. It is small, but the consequence is the same one that bit #343: GitHub reports 0 check runs on this PR, because a conflicting PR gets no pull_request workflow runs at all. It also is not marked draft, in case that was intended.

2. capabilities.privilegedParams[].param is in a different namespace from every other param in the schema, and nothing validates it.

This is the one finding I would not merge without. launch.params keys and env.configSetenv[].fromParam name the registry param. privilegedParams[].param names the legacy config field, because clampExternalCliBypassForOwner writes [param] straight into the <Mode>Config object. Codex has both in one entry:

legacyConfigAliases: { bypassApprovals: 'dangerouslyBypassApprovals' },
privilegedParams: [{ param: 'dangerouslyBypassApprovals', clampTo: false }],

schema.ts superRefines configSetenv.fromParam against the declared params, but there is no check at all for privilegedParams.param, and no case for it in cli-registry-schema.test.ts. So a wrong name there is a silent no-op: no load-time error, no test failure, the clamp simply stops clamping.

DeepSeek's chain works today only because one name happens to coincide in both namespaces: the clamp writes deepSeekConfig.permissionMode, configSetenvValues reads permissionMode, and that becomes DSH_PERMISSION_MODE. Give permissionMode an alias later and the multi-user permission clamp disappears with nothing saying so, which is precisely the failure mode this whole design exists to prevent, and the one I flagged on #343 as the reason the original schema could not express DeepSeek. Either resolve it through legacyConfigAliases the way configSetenvValues already does, or rename the field to something that says which namespace it is in, and add a superRefine plus a test either way.

Follow-ups, happy for these to ride with PR B

3. The no-id-branching guard only matches ===. BRANCH_PATTERN cannot see !==, switch/case, or [...].includes(mode). There are 36 mode !== '<id>' branches left in src/, 28 of them in session-routes.ts; master has 38, so the refactor converted the === sites and left the negated ones. Two of those matter:

  • session-routes.ts:1268-1274 still auto-enables Ralph from a hand-written seven-mode !== chain, under a comment reading "Keep this list in step with isExternalCliMode()", while the sibling quick-start path at :3215 now reads capabilities.ralph. Flip ralph in the registry and one path honours it and the other ignores it.
  • cron/cron-service.ts:427 keeps mode !== 'shell' && mode !== 'deepseek' ? defaultModel : undefined, the exact model ladder session-routes just replaced with capabilities.model.

Not regressions, they are all pre-existing, but it does mean the "~123 to 32, each allowlisted with its reason" figure is measured only over the shape the regex happens to catch. Widening the pattern is a couple of lines; the branches it surfaces are mostly one-liners.

4. overlays is entirely dead data. Nothing in src/ reads entry.overlays. The live tables are still the hardcoded Record<RemoteCommandMode, string> at remote-hosts.ts:102, Record<DockerCommandMode, string> at docker-hosts.ts:140, and resolveDockerCredentialArtifacts for the credStore equivalent, and none of those files are touched. So each entry's overlays.{remote,docker,credStore} duplicates a live table with nothing keeping the two in sync, while docs/cli-registry.md presents the field as load-bearing. Same story for five capability fields nothing reads: echo, wheelForward, keyboardAccessory, maxFrameBytes and shortBadge (you already flag accent; the frontend being untouched is by design and I agree with that scoping). A capability that is both wrong and unread is worse than an absent one, because the next person will trust it. Either wire them or annotate them explicitly as declared-for-later.

5. A custom CLI is now API-acceptable but not survivable downstream. sessionModeSchema() accepts any enabled registry id, but SessionMode is still the frozen nine-way union, and those exhaustive Record<...> lookups return undefined for an unknown id, so a remote pane command becomes cd <path> && undefined. It takes a hand-edited clis.json to reach, but it is the first thing anyone will try after reading the docs.

Smaller

  • cliNeedsVersionProbe() is capability-shaped (gates non-empty) but all three call sites still call getClaudeCliVersion() or the claude docker/ssh probes, so a second CLI declaring a gate would get claude's version stamped on its session.
  • DEPENDENCY_REGISTRY is a module-level const that calls enabledClis() at import, so the doctor's rows freeze at first import while schemas resolve per parse. That is the opposite of the parse-time principle this PR just established, and reloadCliRegistry() never reaches it. opencode-cli-resolver.ts freezes its search dirs at import for the same reason.
  • Three user-visible changes beyond probeDockerCliVersion, all cosmetic but worth naming since you were careful to name that one: claude's codeman doctor install hint changes from the docs URL to curl -fsSL https://claude.ai/install.sh | bash; opencode, codex, gemini, antigravity and pi gain install hints they never had; and the row order shifts, claude now sorting below tmux.
  • sessionModeSchema() is z.string() with no .max(), and its failure message embeds JSON.stringify(value). Bounded only by the body limit. .max(24) to match the cliId pattern would close it.
  • _configureDeepSeek() is gone (folded into _configureCliEnv, which is the right move) but is still named in four comments in src/ (session-wait-registry.ts:178, session-routes.ts:439/455/460), in CLAUDE.md's DeepSeek paragraph, and in docs/architecture-invariants.md. Your new CLAUDE.md section is good; the paragraph immediately below it now points at a function that does not exist.
  • deepMerge assigns result[key] for keys straight out of JSON.parse, so a __proto__ key in clis.json sets the merged object's prototype. The {...merged, id, stock} spread before Zod neutralises it, so this is not exploitable, but a continue on __proto__/constructor is cheap insurance in a hand-editable file loader.
  • resolveInstallCommandForPlatform (registry.ts) and installHintFor (cli-resolver.ts) are the same three lines twice, and the former has no consumer.

Where this leaves us

Rebase, fix 2, and I will merge it. Items 3 to 5 and the smaller list can come with PR B, or here if you would rather have them in one place; either is fine by me, just tell me which so I know when to look again.

Separately: yes please to the test/setup.ts fix for CODEMAN_INSTANCE/CODEMAN_DATA_DIR/CODEMAN_TMUX_SOCKET. Send it as its own PR and I will take it right away. Good catch, and thank you for keeping it out of this one.

Really good work.

@opticon454

Copy link
Copy Markdown
Author

I'm off on a holiday today for the next 5 days so I'll fix and rebase on whatever version it is next week when I'm back 👍

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants