PR A: CLI registry core as a pure internal refactor - #347
Conversation
…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
|
As per #343 |
|
@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, I read the whole diff and then went looking for seams independently rather than trusting the write-up. What I verifiedFull CI gate on your branch, locally: 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 So the parity claim holds under a much wider net than the pins cover, including the drop-do-not-quote behaviour and claude's Two things before I merge1. Rebase. Your base is 2. This is the one finding I would not merge without. legacyConfigAliases: { bypassApprovals: 'dangerouslyBypassApprovals' },
privilegedParams: [{ param: 'dangerouslyBypassApprovals', clampTo: false }],
DeepSeek's chain works today only because one name happens to coincide in both namespaces: the clamp writes Follow-ups, happy for these to ride with PR B3. The no-id-branching guard only matches
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. 5. A custom CLI is now API-acceptable but not survivable downstream. Smaller
Where this leaves usRebase, 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 Really good work. |
|
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 👍 |
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 aCliEntryinsrc/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.tsalone sheds ~570 lines.What the registry owns: binary discovery (search dirs, version and identity probes), the launch argv template, environment handling (exports,
tmux setenvkeys, 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
config/cli-registry/types.tsconfig/cli-registry/patterns.tsconfig/cli-registry/profiles.tsconfig/cli-registry/schema.ts.strict()validationconfig/cli-registry/argv.tsconfig/cli-registry/stock.tsconfig/cli-registry/registry.tssession-cli-registry-bridge.ts<Mode>Configwire shape → registry paramsutils/cli-resolver.tsutils/cli-launcher.ts1. DeepSeek, and the four assumptions it breaks
This is the part I'd most like your eyes on.
DSH_PERMISSION_MODEenv var, not a flagcapabilities.privilegedEnvKeyshooksAvailableForMode('deepseek')is a per-session questioncapabilities.hookswidens to'none' | 'always' | 'supervised'dshis a profile launcher — installed ≠ runnablediscovery.launcherProfile(+launcherTargetParam)capabilities.transcriptgains'deepseek-zstd'The env-var privileged param
You flagged that
capabilities.privilegedParamscan only clamp argv params, so the registry as designed could not expressclampEnvOverridesForOwner()— and that merging as-is would make a real multi-user control silently disappear. That is now a separate, deliberately distinct field:It is not a variant of
privilegedParamsbecause the two reach the CLI by different paths — one becomes an argv flag, the other ridestmux setenv, which no argv clamp can see.ownerClampedEnvKeys()insession-routes.tsnow derives its list from every enabled entry'sprivilegedEnvKeys, and I verified at runtime that it resolves to exactly master's list: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 sendingDSH_PERMISSION_MODEon the same request would land last and hand back exactly the privilege the config clamp removed.DSH_PERMISSION_MODEitself is exported through a newenv.configSetenvmapping rather than a bespoke configure step, which is what lets the ordinaryprivilegedParamsclamp reach it: the clamp rewrites the param, and whatever the param ends up as is what gets exported. Values are re-validated against the declaredParamSpecbefore export — the wire shape is already Zod-checked, but this one reachestmux setenvas a permission level, and a builder should not trust its caller there.hooksas 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 andsessionHookOptions()is untouched, so every existing call site behaves as before.A boolean is explicitly rejected by the schema, with a test, because
truewould have to mean'always'— which is wrong for a supervised CLI and would promise astopthat never arrives.Launcher profiles
discovery.launcherProfilenames 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.launcherTargetParamnames the param carrying a caller-requested target, soresolveCliLaunchError()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.identityis new and general: proof that the binary found is the program meant, checked before the version probe.requireVersionMatchcatches output with the wrong shape; this catches output with the right shape naming the wrong program — Debian'sdsh(dancer's shell) answers--versionperfectly happily.Both named tests pass:
test/deepseek-mode.test.ts(including its static source scans and the env-half clamp cases) andtest/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_KEYSare gone as module-load constants.sessionModeSchema()is now a refinement that readsenabledClis()when the request is validated, andisAllowedEnvKey()reads the registry per call.BLOCKED_ENV_KEYSis deliberately not registry-driven and is still checked first, so a pathologicalallowedPrefixesentry cannot unblockPATH— there is a test for exactly that.Pinned by a test that disables a CLI, calls
reloadCliRegistry(), and assertsPOST /api/sessionsstarts rejecting that mode with no restart, plus the same for an env prefix:One consequence worth naming:
test/agent-skill-mode-lists.test.tsderived 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.tsnow exportssessionModeIds()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/clisroutes, nocli-installer.ts.discovery.install.commandis display text only — it feedscodeman 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.jsonis 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. TheseededStockIdsratchet is deferred to PR C along with the write API that needs it — which also means importing the registry, and thereforeschemas.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 withconfig/andscripts/. Noclis.stock.jsonhere; the bash-3.2 fix and theenabledfilter belong to PR B.test/cli-registry-no-id-branching.test.tsnow 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 leftoverif (this.mode === 'grok')is gone.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.cli-executable-resolver.tswas 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.tsnow checks the pi/grok/dsh version rules by source rather than object identity, since the doctor compiles the entry's serialized pattern throughcompileVersionRegex().codeman doctorgrok 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.test/**— dropped. Three test files are touched, all with real edits.mobile-overview.js— deliberately 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.antigravityrunsagy, so that assumption was wrong — though onlyclaudereaches that path today (it is the one CLI with a version gate), so nothing was actually broken. It now readsdiscovery.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 thannew RegExp().Security model, unchanged
Config still contains no shell text. Four independent layers, all preserved:
command: "..."field anywhere in the schema;argv.tsowns every separator, including the||between fallback variants.--no-approveis not cosmetic).clis.jsoncannot supply its own regex for a shell token and cannot widen its own validation.renderToken()re-checks before emitting bare.The only config-supplied regexes are
discovery.version.regexanddiscovery.identity.regex; both run against truncated command output, never a shell token, and both go throughcompileVersionRegex()'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 soschema.tscan 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:
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 —— all eight per-mode status routes, and the full
codeman doctortable.Notes
CliEntry, add a golden spawn pin, add a row to the capability-predicate table. If you find yourself wanting anif, the guard test says so.test/setup.tsscrubsCODEMAN_PASSWORD/CODEMAN_USERNAME/CODEMAN_GESTUREfrom the environment so a dev box cannot influence results, but notCODEMAN_INSTANCE/CODEMAN_DATA_DIR/CODEMAN_TMUX_SOCKET. Anyone running the suite with those set gets 7 spurioustmux-managerfailures on socket names. One-line fix, deliberately kept out of this PR — happy to send it separately.🤖 Generated with Claude Code