Skip to content

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

Merged
Ark0N merged 3 commits into
Ark0N:masterfrom
opticon454:feature/cli-registry-core
Sep 4, 2026
Merged

PR A: CLI registry core as a pure internal refactor#347
Ark0N merged 3 commits into
Ark0N:masterfrom
opticon454:feature/cli-registry-core

Conversation

@opticon454

Copy link
Copy Markdown
Contributor

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

FileRole
config/cli-registry/types.tsType definitions; no runtime
config/cli-registry/patterns.tsNamed value patterns + the ReDoS-guarded regex compiler
config/cli-registry/profiles.tsNames of code-shaped escape hatches (kept import-free on purpose)
config/cli-registry/schema.tsZod .strict() validation
config/cli-registry/argv.tsThe token → shell-string renderer. Pure
config/cli-registry/stock.tsThe nine shipped entries. The only file allowed to name a CLI id
config/cli-registry/registry.tsLoad / deep-merge / validate. Read-only
session-cli-registry-bridge.tsLegacy <Mode>Config wire shape → registry params
utils/cli-resolver.tsRegistry-driven discovery, layered over the existing resolver
utils/cli-launcher.tsLauncher-profile implementations (DeepSeek)

1. DeepSeek, and the four assumptions it breaks

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

The factThe extension
Permission switch is the DSH_PERMISSION_MODEenv var, not a flagcapabilities.privilegedEnvKeys
hooksAvailableForMode('deepseek') is a per-session questioncapabilities.hooks widens to 'none' | 'always' | 'supervised'
dsh is a profile launcher — installed ≠ runnablediscovery.launcherProfile (+ launcherTargetParam)
Transcript is zstd session filescapabilities.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

@opticon454

Copy link
Copy Markdown
ContributorAuthor

As per #343

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

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
ContributorAuthor

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 👍

@opticon454

Copy link
Copy Markdown
ContributorAuthor

@Ark0N I'm updating right now but unsure how that will fit between our timezones

…anching
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
@opticon454
opticon454force-pushed the feature/cli-registry-core branch from f43e72a to 4830e66CompareSeptember 2, 2026 00:27
opticon454and others added 2 commits September 2, 2026 08:49
CI caught three cron-service failures. Both are mine, from converting cron's
per-mode ladders to capability reads without checking what each ladder's scope
actually was.
**The pre-flight.** cron only ever pre-flighted `deepseek` — dsh is a profile
LAUNCHER, so "installed" is not "runnable" and a bare `dsh` can boot a profile
that cannot drive a pane. I replaced that with an unscoped
`resolveCliLaunchError(mode)`, which pre-flights EVERY mode, so a claude cron
job on a box with no claude binary now failed with "Claude CLI not found"
instead of reaching tmux-manager's own throw. Three tests assert the latter.
It is now gated on `discovery.launcherProfile !== undefined`, which is
byte-identical to the `mode === 'deepseek'` check it replaces and generalises to
the next launcher. The equivalent HTTP-route conversion was already scoped (to
`capabilities.external`, matching what that route has always pre-flighted); I
simply failed to carry the same reasoning across.
**The model.** cron's ladder was `mode !== 'shell' && mode !== 'deepseek'`, and
I read it as `capabilities.model.source === 'claude-settings-file'` — which is
the HTTP route's question, not cron's. There, every external CLI reads its model
from its own config object earlier in the chain, so only claude reaches the
global default; cron has no such config, so the same expression silently
narrowed the default model from eight modes to one. Now `!== 'none'`, which is
exactly the two entries the ladder excluded. Not caught by a test — found by
re-deriving each ladder's scope after the first failure.
Also names a fourth deliberate behaviour change in the changeset, found while
tracing these: `session.ts` carried a hand-written list of modes with no
direct-PTY fallback and OMP was missing from it, though CLAUDE.md's own text
says "all eight require tmux". `requiresMux` comes off the entry now, so an omp
session whose mux creation fails refuses instead of silently starting outside
tmux.
Verified by diffing failing tests BY NAME against an upstream/master baseline,
rather than by file as before — which is how the regression slipped through: the
three new failures landed inside a file already failing for unrelated
Windows-path reasons, and the aggregate count happened to collide.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQkoi1cNegqVwZHgzx5SbJ
Review item 4 named THREE live tables duplicating registry data. Two are now
read from the entry (`defaultRemoteCommandForMode`, `defaultDockerCommandForMode`);
the third, `resolveDockerCredentialArtifacts`, is not — and it was left neither
wired nor annotated, which is the state that item explicitly rules out.
It is not wired because the shape cannot express the live table: `credStore` is
ONE store per CLI, and `CRED_STORES` needs two for gemini (`.gemini` for the
CLI's own auth plus `.config/gcloud` for Vertex), while deepseek's entry declares
none at all even though `.dsh` is seeded. Wiring it means making the field an
array and correcting those two entries — a change to credential seeding, which
is at once the worst thing in that file to get wrong and the least covered by
tests, since every docker IO path is no-op'd under vitest. It belongs in its own
change, measured against a real container.
So it is annotated instead, at the field, in the type's declared-for-later
header, in docs/cli-registry.md, and in the pinned DECLARED_FOR_LATER list — the
last of which means wiring it later makes a test fail rather than leaving a
stale comment behind.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WQkoi1cNegqVwZHgzx5SbJ
@opticon454

Copy link
Copy Markdown
ContributorAuthor

Back, rebased, and everything from your review is in this PR rather than a PR B — so there's nothing left to look at elsewhere. Answering your question first, then what changed since you read it.

Checks are green (both jobs). Base is now 71ffbf18 (1.24.4), and upstream/master is an ancestor of the head, so it's a fast-forward with no conflict.

1 — Rebase

Master had moved further than 1.23.2 by the time I got back, so this is rebased onto 1.24.4. The codex-cli-resolver.ts conflict you found was the small half; the larger one was OMP, which landed in the meantime as a tenth hand-written builder plus the usual eight per-mode ladders.

I brought it in as a registry entry rather than re-adding the shape this PR removes, 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. Its OMP_AUTH_BROKER_URL/OMP_AUTH_BROKER_TOKEN clamp survives as capabilities.privilegedEnvKeys.

2 — privilegedParams[].param (the blocking one)

You were right, and the DeepSeek coincidence you spotted was the real hazard.

param now names the launch param like every other param in the schema, and the clamp translates it through legacyConfigAliases on the way out — the same hop configSetenvValues already made. Codex declares bypassApprovals and still writes dangerouslyBypassApprovals to the wire, which is exactly why it's the entry that catches a regression. schema.ts gained a superRefine rejecting any entry naming a param it never declared, on both configSetenv.fromParam and privilegedParams.param.

Two tests: one rejection case, and one asserting codex is on the param side of the line while the wire-field spelling is refused at load.

cron-service.ts had the same latent bug in its materialize-only clamp — gemini's approvalMode and pi's approveProjectTrust simply aren't aliased today — so it got the same fix.

3 — The guard only matched ===

Widened to all four shapes: ===, !==, case '<id>':, and [...].includes(mode). It also blanks comment lines instead of dropping them, because the reported line numbers were shifted by however many comments preceded a finding.

That surfaced 44 branches. Both you named are fixed:

  • The Ralph chain now calls isExternalCliMode() — which is what its own comment asked the next person to track by hand. Deliberately notcapabilities.ralph: that capability is claude-only, so it would stop auto-enabling Ralph for shell sessions, which this path has always done. The two paths genuinely disagree about shell, and they disagree on master too, so reconciling them is a behaviour change for its own PR rather than something to slip into this one.
  • cron's model ladder now reads capabilities.model.

Also converted: cron's launch pre-flight and readiness poll, two stripCaseEnvKeys chains, and omp's two session-id branches. !== count is 36 → 16; the remainder are allowlisted with reasons, including the ones where mode === 'claude' is genuinely right (Read My Mind reads Claude's own transcript).

One I left alone and want to flag rather than bury: the scaffolded-case hooks chain excludes seven CLIs but not deepseek, while its own comment says DeepSeek uses its own system. That inconsistency is on master and predates this PR; any capability form would have to pick a side, so it stays as found and is named in the allowlist.

4 — overlays dead data

overlays.remote and overlays.docker are now what defaultRemoteCommandForMode() and defaultDockerCommandForMode() read; both hardcoded Record<…CommandMode, string> tables are gone. test/location-overlay-commands.test.ts pins all 20 resulting commands as literal strings, transcribed from those tables before deleting them.

overlays.credStore is not wired, and that's a deliberate stop rather than an oversight: the shape allows one store per CLI, and CRED_STORES needs two for gemini (.gemini plus .config/gcloud for Vertex), while deepseek's entry declares none even though .dsh is seeded. Wiring it means making the field an array and correcting those two entries — a change to credential seeding, which is at once the worst thing in that file to get wrong and the least covered, since every docker IO path is no-op'd under vitest. So it's annotated as declared-for-later at the field, in the type header, in the docs, and in the pinned list.

The five capability fields are annotated the same way (echo, wheelForward, keyboardAccessory, maxFrameBytes, shortBadge, plus accent) — described as transcribed, not authoritative, since nothing enforces that echo.policy matches _updateLocalEchoState's fallthrough. A test pins that list, so it can't quietly grow and wiring one up fails a line rather than leaving a stale comment.

5 — Custom CLI not survivable downstream

The two exhaustive Record lookups that returned undefined for an unknown id are exactly the two tables now read from the registry, both falling back to shell. Pinned with an unregistered-id case in the same golden test. sessionModeSchema() also picked up .max(24).

Smaller

All done. cliNeedsVersionProbe() now feeds resolveSessionCliVersion(), dispatched on discovery.version.retryOnTransientFailure — data, not an id — which preserves claude's retry-with-backoff cache policy exactly; a generic resolver would have lost it, and that policy fixed a real bug. The docker and remote probes derive their binary from the registry too.

DEPENDENCY_REGISTRY is now dependencyRegistry(); eight resolvers take a search-dir thunk instead of evaluating at import (createCliExecutableResolver accepts either). _configureDeepSeek references are gone from src/, CLAUDE.md and architecture-invariants.md — the two left in docs/deepseek-integration-plan.md are describing that design as it was built, so I left them. deepMerge skips __proto__/constructor/prototype, and the duplicate install-hint helper is gone.

Behaviour changes, named

You were careful to name probeDockerCliVersion, so: there are now four, all in the changeset.

  1. probeDockerCliVersion() derives the in-container binary from the registry (antigravity runs agy).
  2. The remote CLI version probe now covers grok and deepseek, which the hardcoded map omitted while its own comment said the rule was "every mode except shell" — a remote session in either mode reported no version at all.
  3. codeman doctor's CLI rows are generated, so claude's install hint, five new hints, and the row order shift — the three cosmetic ones you listed.
  4. OMP now requires tmux like its seven siblings.session.ts carried a hand-written list of modes with no direct-PTY fallback and omp was missing from it, though CLAUDE.md's own text says "all eight require tmux". requiresMux comes off the entry now, so an omp session whose mux creation fails refuses instead of silently starting outside tmux.

One regression I introduced and fixed

Worth recording since it's the sharpest lesson here. Converting cron's ladders, I replaced a deepseek-only launch pre-flight with an unscopedresolveCliLaunchError(mode), so a claude cron job on a box with no claude binary failed with "Claude CLI not found" instead of reaching tmux-manager's own throw. Three cron tests caught it on CI. It's now gated on discovery.launcherProfile, byte-identical to the check it replaced.

Re-deriving every ladder afterwards turned up a second one the tests didn't cover: cron's model ladder had become === 'claude-settings-file', which is the HTTP route's question — there every external CLI reads its model from its own config earlier in the chain, so only claude reaches the global default. Cron has no such config, so the same expression silently narrowed the default model from eight modes to one. Now !== 'none', which is exactly the two entries the original ladder excluded.

Separately

The test/setup.ts fix is up as its own branch, fix/test-env-instance-isolation, ready to open whenever suits you. CODEMAN_DATA_DIR turned out to be the one that matters — it's an absolute override read in getDataDir(), so it bypasses the temp HOME entirely and has the suite reading and writing a real state.json. It ships with a test in two halves, because asserting the vars are unset passes trivially on a machine that never set them; the static half reads setup.ts and fails everywhere.

@Ark0N
Ark0N merged commit 850b005 into Ark0N:masterSep 4, 2026
2 checks passed
@Ark0N

Ark0N commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Merged, thank you. Before pressing the button I re-ran everything: the full gate locally (typecheck, lint, format, 331 test files / 6468 tests green), the same on a trial merge with current master, and the spawn-command diff against master's builders across 11,602 option combinations for all ten modes, shell and an unregistered id included: 0 diffs. Three separate audits over the multi-user clamps, the guard test plus overlays, and runtime drift in session/tmux/cron/schemas/resolvers all came back with no semantic difference for the ten stock modes.

Everything from the first review is in. What is left is follow-up grade, none of it blocking, roughly in order of interest:

  • clis.json permissions.isUnsafePermissions tests mode & 0o077, so a file created with a normal umask (0644) is refused, the warning said "group/world-writable", and nothing in src/ logged LoadResult.warnings, so the refusal was silent. I pushed a small follow-up behind the merge that logs the loader's warnings once on first load, makes the message say what the check tests (0600, with the chmod to run), and documents it in docs/cli-registry.md. Whether the check itself should relax to writable bits only (0o022, the ssh posture for config files) is your call.
  • Overrides can weaken the security data. A stock entry's privilegedParams, privilegedEnvKeys, env.allowedPrefixes, env.allowedKeys and env.tmuxSetenvKeys can all be overridden from clis.json with no warning, clampTo is not validated against the param's type, and a widened prefix list applies to every mode's envOverrides (verified: HERDR_, BASH_ENV and HOME all became acceptable). Same trust boundary as users.json, so not a hole, but master's allowlist was code-only. I would refuse those keys on stock overrides.
  • The loader renames a malformed clis.json to .invalid-<ts> on first use, while the header, types.ts and the changeset said nothing writes. I corrected the header; leaving the file in place and warning would make the claim true instead.
  • probeCliCandidate now runs <bin> --version while resolving the directory for claude, opencode, codex, gemini and antigravity, whose resolvers never executed the binary before, and claude --version runs twice on the first claude spawn. Skipping the probe when neither an identity check nor requireVersionMatch needs it would restore that.
  • Two of the four announced behaviour changes are unreachable today: the version probe is gated on capabilities.gates and only claude declares any, so the grok/deepseek remote probe and the antigravity/deepseek docker probe never run. Correct in the builders, no runtime change. A fifth, unannounced one is real and good: an omp session's attach client now gets COLORTERM=truecolor.
  • The guard's allowlist is keyed per file plus expression rather than per site, so a second copy of an allowlisted branch in the same file passes; a per-key occurrence count would close that. It also cannot see Set.has(mode) (response-viewer-transcript.ts:14 is one, pre-existing and already missing omp) or a loose ==.
  • Knip: ~37 newly unused exports (the per-CLI get*NotFoundMessage / resolve*Dir helpers and a few registry accessors) plus an unimported cli-registry/index.ts barrel.

Ships in the next release, going out today.

@opticon454

Copy link
Copy Markdown
ContributorAuthor

Thankyou for making an awesome app and integrating everyone's ideas :)

Ark0N pushed a commit that referenced this pull request Sep 5, 2026
…iner
feat(docker): attach a case to an already-running container
Conflicts came from work that landed after the PR was opened, and each is
resolved onto the newer abstraction rather than by keeping the older code:
- `defaultDockerCommandForMode` is registry-driven since #347, so the PR's
`runsAsRoot` arm became `overlays.docker.rootCommand` (claude only). Claude
Code still refuses `--dangerously-skip-permissions` as root in 2.1.261 and the
refusal is visible only inside the container, so an adopted root container
otherwise just shows a dead pane. Which flag to drop is a per-CLI fact, and
`test/cli-registry-no-id-branching.test.ts` forbids expressing it as a branch.
- The probe's mode list and its mode -> binary table both duplicated the
registry. They now read `enabledCliIds()` / `discovery.binaries[0]`, which is
also what fixes the merge's silent regression: the hand-written list predates
`omp`, and the run menu gates every docker case on this probe, so owned
containers would have lost that mode. `shell` needs no arm — it declares no
binary, so it is dropped from the lookup and reported available regardless.
- The per-mode `mode === 'claude' && !cliDir` chain in `tmux-manager.ts` is one
`missingCliMessage(mode)` gate since #347; the PR's docker exemption moved onto
it. Its test now pins the single gate instead of counting seven arms.
- The create arm keeps #349's swap-limit warning filter, which the adopted arm
never reaches; the run-mode list gains `omp` from #353.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TecFD9hvPYJ1mkkMtBQbT1
Sign up for freeto 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

@opticon454@Ark0N