refactor(cli-registry): make CLI backends data instead of per-mode branching

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
This commit is contained in:
Devvyn
2026-09-02 08:26:45 +08:00
co-authored by Claude Opus 5
parent 71ffbf18e4
commit 4830e662f9
45 changed files with 5772 additions and 1478 deletions
+3 -4
View File
@@ -1257,7 +1257,7 @@ program
.action(async (options) => {
const { createRealHost, checkAll } = await import('./utils/dependency-checker.js');
const { renderTable, renderJson, computeExitCode } = await import('./utils/dependency-report.js');
const { DEPENDENCY_REGISTRY, TOOL_CATEGORIES } = await import('./config/dependency-registry.js');
const { dependencyRegistry, TOOL_CATEGORIES } = await import('./config/dependency-registry.js');
if (options.category && !(TOOL_CATEGORIES as readonly string[]).includes(options.category)) {
console.error(`Unknown category "${options.category}". Valid categories: ${TOOL_CATEGORIES.join(', ')}`);
@@ -1265,9 +1265,8 @@ program
}
const host = createRealHost();
const registry = options.category
? DEPENDENCY_REGISTRY.filter((t) => t.category === options.category)
: DEPENDENCY_REGISTRY;
const allTools = dependencyRegistry();
const registry = options.category ? allTools.filter((t) => t.category === options.category) : allTools;
const results = checkAll(registry, host);
if (options.json) {
+190
View File
@@ -0,0 +1,190 @@
/**
* @fileoverview The argv rendering engine — turns a `CliLaunch` spec plus a set of resolved
* parameter values into the shell command string that goes into `bash -c "..."`.
*
* SECURITY MODEL (read before touching this file):
*
* 1. Config contains no shell text. There is no `command: "..."` field anywhere in the
* schema. An entry declares a sequence of typed tokens (`ArgSpec`); this module is the
* ONLY place that turns them into a string, and it owns every separator itself: a single
* space between tokens, and ` || ` between fallback variants. Neither can originate from
* config, because config has no field that could hold either.
* 2. Every literal (`lit`, `flag`, `value`) is validated against `SAFE_BARE_TOKEN` — no
* space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens, braces, newline or
* backslash — at LOAD time (see schema.ts), so a bad literal fails registry validation
* rather than reaching this renderer.
* 3. Every `valueFrom` resolves through a declared `ParamSpec`, whose `token` variant names
* a PATTERN rather than accepting one — see patterns.ts. A value that fails its pattern
* causes the WHOLE ArgSpec to be dropped, exactly like the hand-written builders this
* replaces (an invalid `--model` value silently omits `--model`, it does not substitute
* something else).
* 4. Escaping and validation are independent. `renderToken()` always re-checks the resolved
* value against `SAFE_BARE_TOKEN` before emitting it unquoted; anything else is
* single-quote-escaped. So even a value that somehow bypassed pattern validation is still
* quoted, never concatenated raw.
*
* @module config/cli-registry/argv
*/
import type { ArgSpec, CliEntry, CliLaunch, Cond, EngineValue, ParamSpec, QuoteStyle } from './types.js';
import { matchesPattern } from './patterns.js';
import { SAFE_BARE_TOKEN } from './patterns.js';
/** Resolved parameter values, keyed by the name declared in `CliLaunch.params`. */
export type ParamValues = Record<string, string | boolean | undefined>;
/** Values the caller supplies for the reserved engine params. */
export type EngineValues = Partial<Record<EngineValue, string>>;
/**
* POSIX single-quote escaping: end-quote, escaped-literal-quote, restart-quote. Identical in
* shape to the three copies already in the codebase (tmux-manager.ts, remote-hosts.ts,
* docker-hosts.ts) — kept local rather than importing one of them so this module has no
* dependency on the files it is replacing.
*/
function singleQuoteEscape(value: string): string {
return `'${value.replace(/'/g, `'\\''`)}'`;
}
function doubleQuoteEscape(value: string): string {
// Escape the characters that are special inside a double-quoted bash string. SAFE_BARE_TOKEN
// already excludes all of them, so in practice this never fires; kept as defense in depth.
return `"${value.replace(/([$`"\\])/g, '\\$1')}"`;
}
/**
* Render a single resolved value per its requested quote style. `auto` (the default) emits
* bare only when the value is provably safe; every other case single-quotes.
*/
function renderToken(value: string, style: QuoteStyle | undefined): string {
const safe = SAFE_BARE_TOKEN.test(value);
switch (style) {
case 'double':
return doubleQuoteEscape(value);
case 'single':
return singleQuoteEscape(value);
case 'bare':
return safe ? value : singleQuoteEscape(value);
case 'auto':
default:
return safe ? value : singleQuoteEscape(value);
}
}
/** Resolve one parameter to a plain string, or undefined if it is unset / invalid. */
function resolveParam(
name: string,
spec: ParamSpec | undefined,
params: ParamValues,
engineValues: EngineValues
): string | undefined {
if (!spec) return undefined;
if (spec.type === 'engine') return engineValues[spec.source];
const raw = params[name];
if (raw === undefined) return spec.type === 'enum' ? spec.default : undefined;
if (spec.type === 'bool') return typeof raw === 'boolean' ? String(raw) : undefined;
if (spec.type === 'enum') {
const s = String(raw);
return spec.values.includes(s) ? s : spec.default;
}
// token
const s = String(raw);
return matchesPattern(spec.pattern, s) ? s : undefined;
}
/** Is the resolved value "set" for the purposes of a `state` condition? */
function isSet(name: string, params: ParamValues, resolved: (n: string) => string | undefined): boolean {
if (name in params) {
const raw = params[name];
if (typeof raw === 'boolean') return true; // a bool param is always "set" once declared
}
return resolved(name) !== undefined;
}
function evalCond(
cond: Cond | undefined,
params: ParamValues,
resolved: (n: string) => string | undefined,
gatesPassed: ReadonlySet<string>
): boolean {
if (!cond) return true;
if ('allOf' in cond) return cond.allOf.every((c) => evalCond(c, params, resolved, gatesPassed));
if ('anyOf' in cond) return cond.anyOf.some((c) => evalCond(c, params, resolved, gatesPassed));
if ('not' in cond) return !evalCond(cond.not, params, resolved, gatesPassed);
if ('capabilityGate' in cond) return gatesPassed.has(cond.capabilityGate);
if ('state' in cond) {
const set = isSet(cond.param, params, resolved);
return cond.state === 'set' ? set : !set;
}
// { param, is }
const raw = params[cond.param];
if (typeof cond.is === 'boolean') return raw === cond.is;
return resolved(cond.param) === cond.is;
}
function renderArg(
spec: ArgSpec,
params: ParamValues,
resolved: (n: string) => string | undefined,
gatesPassed: ReadonlySet<string>
): string | null {
if (!evalCond(spec.when, params, resolved, gatesPassed)) return null;
if ('lit' in spec) return spec.lit;
if ('flag' in spec && !('value' in spec) && !('valueFrom' in spec)) return spec.flag;
if ('flag' in spec && 'value' in spec) return `${spec.flag} ${renderToken(spec.value, spec.quote)}`;
if ('flag' in spec && 'valueFrom' in spec) {
const v = resolved(spec.valueFrom);
return v === undefined ? null : `${spec.flag} ${renderToken(v, spec.quote)}`;
}
// bare positional
const v = resolved((spec as { valueFrom: string }).valueFrom);
return v === undefined ? null : renderToken(v, (spec as { quote?: QuoteStyle }).quote);
}
/**
* Render one CLI's launch command. Returns the full `bash -c` payload — never a shell
* fragment with embedded newlines or unescaped separators, by construction (see file header).
*
* `gatesPassed` — the set of `capabilities.gates` keys whose version requirement is
* currently satisfied. Callers compute this once per spawn (it depends on a version probe),
* never inside the renderer, keeping this function pure and easy to test byte-for-byte.
*/
export function renderLaunch(
launch: CliLaunch,
params: ParamValues,
engineValues: EngineValues,
gatesPassed: ReadonlySet<string> = new Set()
): string {
const cache = new Map<string, string | undefined>();
const resolved = (name: string): string | undefined => {
if (cache.has(name)) return cache.get(name);
const v = resolveParam(name, launch.params[name], params, engineValues);
cache.set(name, v);
return v;
};
const passing = launch.variants.filter((variant) => evalCond(variant.when, params, resolved, gatesPassed));
const chosen = launch.chain === 'fallback' ? passing : passing.slice(0, 1);
const rendered = chosen.map((variant) =>
variant.args
.map((arg) => renderArg(arg, params, resolved, gatesPassed))
.filter((tok): tok is string => tok !== null)
.join(' ')
);
return rendered.join(' || ');
}
/** Convenience: render an entry's launch command straight from a `CliEntry`. */
export function renderCliCommand(
entry: CliEntry,
params: ParamValues,
engineValues: EngineValues,
gatesPassed?: ReadonlySet<string>
): string {
return renderLaunch(entry.launch, params, engineValues, gatesPassed);
}
+61
View File
@@ -0,0 +1,61 @@
/**
* @fileoverview Barrel for the CLI registry module.
* @module config/cli-registry
*/
export type {
ArgSpec,
CliCapabilities,
CliCredStore,
CliDiscovery,
CliEntry,
CliEnv,
CliId,
CliIdentityProbe,
CliLaunch,
CliOverlays,
CliRegistryFile,
CliVariant,
CliVersionProbe,
Cond,
EngineValue,
ParamSpec,
QuoteStyle,
} from './types.js';
export {
matchesPattern,
TOKEN_PATTERNS,
SAFE_BARE_TOKEN,
compileVersionRegex,
MAX_VERSION_OUTPUT,
} from './patterns.js';
export type { TokenPattern } from './patterns.js';
export { renderLaunch, renderCliCommand } from './argv.js';
export type { EngineValues, ParamValues } from './argv.js';
export { CliEntrySchema } from './schema.js';
export type { ValidatedCliEntry } from './schema.js';
export { STOCK_CLIS } from './stock.js';
export {
asCliId,
cliIds,
enabledCliIds,
enabledClis,
getCli,
listClis,
loadCliRegistry,
reloadCliRegistry,
resolveInstallCommandForPlatform,
resolveRegistry,
} from './registry.js';
export type { LoadResult } from './registry.js';
export {
COMPOSER_ANCHOR_KINDS,
isKnownLauncherProfile,
isKnownPredictProfile,
isKnownSetenvProfile,
LAUNCHER_PROFILE_NAMES,
PREDICT_PROFILES,
SETENV_PROFILE_NAMES,
TRANSCRIPT_READER_NAMES,
} from './profiles.js';
export type { LauncherProfileName, SetenvProfileName } from './profiles.js';
+121
View File
@@ -0,0 +1,121 @@
/**
* @fileoverview Named value patterns for the CLI registry's argv engine.
*
* Config entries select a pattern BY NAME; the regexes themselves live here, in code.
* That is deliberate and is the reason a user-editable `clis.json` cannot widen its own
* validation: there is no field anywhere in the schema that accepts a raw regex for a
* shell token, so no entry can supply `.*` (nor a catastrophically backtracking one).
*
* The sole user-supplied regex in the whole registry is `discovery.version.regex`, which
* is applied to `--version` OUTPUT rather than to a shell token, and goes through
* `compileVersionRegex()` below.
*
* Every pattern here is transcribed from the builder it replaces in tmux-manager.ts, so
* the argv engine accepts and rejects exactly the values the hand-written builders did.
*
* @module config/cli-registry/patterns
*/
/** Names a value pattern. Config may only reference these. */
export type TokenPattern =
| 'model'
| 'model-claude'
| 'model-pi'
| 'id'
| 'id-dotted'
| 'uuid'
| 'slug'
| 'path-segment'
| 'tool-list'
| 'config-kv';
/**
* The patterns, each traced to the builder it came from.
*
* ⚠️ These are ALLOWLISTS (`^...$` over a safe character class), never blocklists — with
* one deliberate exception, `tool-list`, which mirrors the existing `--allowedTools`
* sanitizer. That one is a metacharacter REJECTION because tool specs legitimately contain
* `(`, `)`, `*`, `:` and spaces (`Bash(git:*), Read`), so an allowlist of safe words cannot
* express it. Keeping it byte-identical to the original matters more than making it uniform.
*/
const PATTERNS: Record<TokenPattern, RegExp> = {
// buildOpenCodeCommand / buildCodexCommand / buildGeminiCommand / buildAntigravityCommand
model: /^[a-zA-Z0-9._\-/]+$/,
// buildSpawnCommand's claude branch — `[` and `]` for bracketed model aliases
'model-claude': /^[a-zA-Z0-9._\-[\]]+$/,
// buildPiCommand — `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id`
'model-pi': /^[a-zA-Z0-9._\-/:]+$/,
// opencode --session, codex resume
id: /^[a-zA-Z0-9_-]+$/,
// gemini --resume, antigravity --conversation, pi --session
'id-dotted': /^[a-zA-Z0-9._-]+$/,
// claude --resume / --session-id
uuid: /^[a-f0-9-]+$/,
// pi --provider
slug: /^[a-z0-9-]+$/,
// dsh --profile. Deliberately STRICTER than `id-dotted`: a profile name is both
// interpolated into the shell line AND joined into a filesystem path, so it must be a
// single path segment. Requiring a leading alphanumeric is what rules out `.`, `..` and
// dotfile names, which `id-dotted` would happily accept.
'path-segment': /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/,
// codex --config tui.animations=false
'config-kv': /^[A-Za-z0-9._-]+=[A-Za-z0-9._-]+$/,
// Placeholder; `tool-list` is handled by isSafeToolList() below, not by a match.
'tool-list': /^$/,
};
/**
* Shell metacharacters rejected in an `--allowedTools` value. Transcribed verbatim from
* buildClaudePermissionFlags so the accepted set does not move.
*/
const TOOL_LIST_DANGEROUS = /[;&|$`\\{}<>'"[\]\n\r]/;
/** Does `value` satisfy the named pattern? */
export function matchesPattern(pattern: TokenPattern, value: string): boolean {
if (pattern === 'tool-list') return value.length > 0 && !TOOL_LIST_DANGEROUS.test(value);
return PATTERNS[pattern].test(value);
}
/** Every pattern name, for schema validation and error messages. */
export const TOKEN_PATTERNS = Object.keys(PATTERNS) as TokenPattern[];
/**
* Characters a token may contain and still be emitted UNQUOTED into the `bash -c "..."`
* command string. Intentionally narrower than "what bash tolerates": anything outside it
* gets single-quoted, so the classification can only ever err toward more quoting.
*/
export const SAFE_BARE_TOKEN = /^[A-Za-z0-9._:@=+/,-]+$/;
/**
* Longest `--version` output we will run a user-supplied regex over. A version banner is a
* line or two; anything larger is a misconfiguration, and capping the input is what keeps a
* sloppy (not necessarily malicious) regex from becoming a stall.
*/
export const MAX_VERSION_OUTPUT = 200;
/** Longest permitted `discovery.version.regex` source. */
const MAX_VERSION_REGEX_SOURCE = 200;
/**
* Nested quantifiers — `(a+)+`, `(a*)*`, `(a+)*` and friends — the classic catastrophic
* backtracking shape. Rejected outright rather than analysed: this field exists to pull a
* semver out of a banner, and nothing legitimate for that job needs a nested quantifier.
*/
const NESTED_QUANTIFIER = /\([^)]*[+*][^)]*\)\s*[+*{]/;
/**
* Compile a user-supplied version regex, or return null if it is not one we are willing to
* run. Returning null (rather than throwing) lets the caller degrade to "version unknown",
* which every consumer already handles.
*/
export function compileVersionRegex(source: string): RegExp | null {
if (source.length > MAX_VERSION_REGEX_SOURCE) return null;
if (NESTED_QUANTIFIER.test(source)) return null;
try {
// No `g`: a global regex carries lastIndex state across calls, which is a documented
// footgun in this codebase (see utils/regex-patterns.ts).
return new RegExp(source);
} catch {
return null;
}
}
+100
View File
@@ -0,0 +1,100 @@
/**
* @fileoverview The NAMES of code profiles a `CliEntry` field may select, and the helpers
* that validate them.
*
* A profile is the escape hatch for behaviour that is genuinely code-shaped and cannot be
* expressed as data — codex's predictive write-through echo, deepseek's profile-launcher
* runnability check, deepseek's status bridge — without letting any of that code branch on
* a CLI's id. A registry field names a profile; the implementation lives beside whatever it
* needs, and looks its name up here.
*
* ⚠️ This module is PURE and must stay that way: names, types and predicates only, no
* imports outside this directory. The implementations pull in resolvers and the status
* shim, which in turn reach back into the registry, so holding them here would close an
* import cycle (profiles → deepseek-cli-resolver → cli-resolver → registry → schema →
* profiles). Keeping the names here and the implementations at their call sites is what
* lets `schema.ts` validate a profile name at LOAD time — a custom entry naming a profile
* this build does not implement fails loudly instead of silently failing closed later.
*
* The rule all of this enforces: `test/cli-registry-no-id-branching.test.ts` fails on any
* `mode === '<stock id>'` comparison outside `stock.ts`, so a NEW behavioural special case
* must be added here, named, and referenced from a registry field — never inlined as an id
* check at the call site.
*
* ⚠️ A profile is a LAST resort, not a convenience. Reach for one only when the behaviour
* needs to run code (a side effect, a computed value, a probe); anything that is a list, a
* flag, or a string belongs in the entry as data, where a custom CLI can also use it.
*
* @module config/cli-registry/profiles
*/
/**
* Predictive local-echo profiles, selected via `capabilities.echo.predictProfile`.
*
* Implementation: packages/xterm-zerolag-input/src/predictive-echo-addon.ts.
*
* ⚠️ Unlike the other two registries, an unknown name here degrades to the 'buffer' policy
* rather than failing. Echo is a comfort feature — a worse-but-working overlay beats a
* refused session — which is why `predictProfile` alone is not schema-validated below.
*/
export const PREDICT_PROFILES: Record<string, true> = {
codex: true,
};
/**
* Launcher profiles, selected via `discovery.launcherProfile`.
*
* For a CLI whose binary launches some further target, and so cannot answer two questions
* from the binary alone: is it RUNNABLE (stricter than "is the binary on disk?"), and what
* is the DEFAULT target when the caller names none? A CLI naming no profile is runnable
* exactly when its binary resolves, and has no default target.
*
* Implementation: `src/utils/cli-launcher.ts`.
*/
export const LAUNCHER_PROFILE_NAMES = [
// `dsh` is a launcher over $DSH_HOME/profiles/<name>, and the profiles DeepSeek itself
// ships (web, headless) cannot drive a terminal pane. Binary AND a pane-capable profile.
'deepseek-profile',
] as const;
/**
* Extra `tmux setenv` work, selected via `env.setenvProfile`.
*
* Implementation: `src/tmux-manager.ts`, which already owns every setenv call.
*
* ⚠️ Anything that is merely "forward this name from the server's own env" belongs in
* `env.tmuxSetenvKeys` as data and must NOT be given a profile.
*/
export const SETENV_PROFILE_NAMES = [
// DeepSeek's terminal front door reports idle/working/blocked to a supervisor over the
// generic env-gated Herdr contract; this makes Codeman that supervisor. It needs a
// profile rather than key names because it writes an executable shim to disk and then
// exports that shim's path along with the session's own pane id.
'deepseek-status-bridge',
] as const;
export type LauncherProfileName = (typeof LAUNCHER_PROFILE_NAMES)[number];
export type SetenvProfileName = (typeof SETENV_PROFILE_NAMES)[number];
/**
* Transcript readers, selected via `capabilities.transcript`. Unlike the profile registries
* above this one is closed over the schema enum itself rather than an open string, since
* transcript format is a small, genuinely fixed set — see CliCapabilities['transcript'].
*/
export const TRANSCRIPT_READER_NAMES = ['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'none'] as const;
/** Composer-row finders, selected via `capabilities.echo.anchor.kind`. Also schema-closed. */
export const COMPOSER_ANCHOR_KINDS = ['glyph', 'cursor', 'none'] as const;
/** True when `name` is a predictive-echo profile this build actually implements. */
export function isKnownPredictProfile(name: string | undefined): boolean {
return name !== undefined && Object.prototype.hasOwnProperty.call(PREDICT_PROFILES, name);
}
export function isKnownLauncherProfile(name: string): name is LauncherProfileName {
return (LAUNCHER_PROFILE_NAMES as readonly string[]).includes(name);
}
export function isKnownSetenvProfile(name: string): name is SetenvProfileName {
return (SETENV_PROFILE_NAMES as readonly string[]).includes(name);
}
+222
View File
@@ -0,0 +1,222 @@
/**
* @fileoverview Loads, merges and re-validates the CLI registry.
*
* `~/.codeman/clis.json` holds OVERRIDES and CUSTOM entries only — never a full copy of the
* stock catalog — so a shipped fix to a stock definition actually reaches an existing
* install, and the file stays small enough to hand-edit.
*
* Resolution: start from `STOCK_CLIS` → deep-merge each override by id (objects merge
* key-wise, arrays replace wholesale) → validate every resulting entry. A stock entry that
* fails validation after merge falls back to its pristine stock definition (a fat-fingered
* override cannot brick a shipped CLI); a custom entry that fails is dropped with a warning
* rather than failing the whole load. Stock entries are always emitted, so `shell` and
* `claude` can be disabled but can never go missing — large parts of the app assume at
* minimum that a shell fallback exists.
*
* ⚠️ READ-ONLY. Nothing in this module writes, creates or migrates the file. That is a
* deliberate property, not a missing feature: there is no settings UI and no write API yet,
* so there is nothing to persist, and it means importing the registry — which
* `src/web/schemas.ts` does, transitively, just to validate a request — performs no
* filesystem writes. A `seededStockIds` ratchet belongs with the write API that needs it.
*
* @module config/cli-registry/registry
*/
import { existsSync, readFileSync, renameSync, statSync } from 'node:fs';
import { dataPath } from '../instance.js';
import type { CliEntry, CliId, CliRegistryFile } from './types.js';
import { CliEntrySchema } from './schema.js';
import { STOCK_CLIS } from './stock.js';
/** Construct a validated CliId. Throws if `raw` is not a well-formed id — call at API boundaries. */
export function asCliId(raw: string): CliId {
if (!/^[a-z][a-z0-9-]{0,23}$/.test(raw)) {
throw new Error(`invalid CLI id: ${JSON.stringify(raw)}`);
}
return raw as CliId;
}
function filePath(): string {
return dataPath('clis.json');
}
/**
* Keys that must never be merged out of a hand-editable JSON file.
*
* `JSON.parse` produces `__proto__` as an ORDINARY own property, but `result[key] = …` on a
* plain object walks the setter chain and would set the merged object's PROTOTYPE instead.
* Not exploitable today — every merged entry is spread into `{ ...merged, id, stock }` and
* then Zod-parsed before anything reads it, which drops the effect — but "not exploitable
* because of what a caller happens to do afterwards" is a property that quietly stops
* holding. A `continue` in the loop that reads the file is the cheap end of that trade.
*/
const UNMERGEABLE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
/** Plain-object deep merge: nested objects merge key-wise, arrays and primitives replace. */
function deepMerge<T>(base: T, override: unknown): T {
if (override === null || typeof override !== 'object' || Array.isArray(override)) {
return (override === undefined ? base : (override as T)) ?? base;
}
if (base === null || typeof base !== 'object' || Array.isArray(base)) {
return override as T;
}
const result: Record<string, unknown> = { ...(base as Record<string, unknown>) };
for (const [key, value] of Object.entries(override as Record<string, unknown>)) {
if (UNMERGEABLE_KEYS.has(key)) continue;
result[key] = deepMerge((base as Record<string, unknown>)[key], value);
}
return result as T;
}
export interface LoadResult {
entries: CliEntry[];
warnings: string[];
}
/**
* Refuse a group/world-writable registry file — same posture as the ssh-key discipline.
* This file selects the binaries Codeman spawns, so a writable one is a way to redirect
* every session.
*
* POSIX only: Windows has no meaningful group/world bits on NTFS (Node reports every file
* as mode 0o666 there regardless of its actual ACL), so this check would flag every file on
* Windows and silently ignore all user config. `win32` relies on NTFS ACLs instead, which
* this check cannot see and does not attempt to.
*/
function isUnsafePermissions(path: string): boolean {
if (process.platform === 'win32') return false;
try {
const mode = statSync(path).mode & 0o777;
return (mode & 0o077) !== 0;
} catch {
return false;
}
}
function readRegistryFile(path: string, warnings: string[]): CliRegistryFile | null {
if (!existsSync(path)) return null;
if (isUnsafePermissions(path)) {
warnings.push(`${path} is group/world-writable; ignoring it and falling back to stock CLIs.`);
return null;
}
let raw: string;
try {
raw = readFileSync(path, 'utf-8');
} catch (err) {
warnings.push(`Failed to read ${path}: ${(err as Error).message}. Falling back to stock CLIs.`);
return null;
}
try {
const parsed = JSON.parse(raw) as CliRegistryFile;
if (typeof parsed !== 'object' || parsed === null || typeof parsed.clis !== 'object') {
throw new Error('missing "clis" object');
}
return parsed;
} catch (err) {
// QUARANTINE, never overwrite: 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.
const quarantined = `${path}.invalid-${Date.now()}`;
try {
renameSync(path, quarantined);
warnings.push(`${path} was not valid JSON (${(err as Error).message}); moved to ${quarantined}.`);
} catch {
warnings.push(
`${path} was not valid JSON (${(err as Error).message}); left in place, falling back to stock CLIs.`
);
}
return null;
}
}
/**
* Merge the stock catalog with a (possibly absent) registry file. PURE — no IO, which is
* what lets the load tests drive every merge case directly.
*/
export function resolveRegistry(stock: CliEntry[], file: CliRegistryFile | null, warnings: string[]): LoadResult {
const stockById = new Map(stock.map((e) => [e.id as string, e]));
const overrides = file?.clis ?? {};
const entries: CliEntry[] = [];
for (const stockEntry of stock) {
const id = stockEntry.id as string;
const override = overrides[id];
const merged = override ? deepMerge(stockEntry, override) : stockEntry;
// `stock: true` is forced here rather than read from the merged object, so an override
// can never flip a custom entry's provenance or vice versa.
const parsed = CliEntrySchema.safeParse({ ...merged, id, stock: true });
if (parsed.success) {
entries.push(parsed.data as CliEntry);
} else {
warnings.push(
`Override for stock CLI "${id}" failed validation; using the shipped definition. ${parsed.error.message}`
);
entries.push(stockEntry);
}
}
for (const [id, raw] of Object.entries(overrides)) {
if (stockById.has(id)) continue; // already merged above
// Same forcing in the other direction: a custom entry claiming `stock: true` cannot
// shadow or impersonate a shipped one.
const parsed = CliEntrySchema.safeParse({ ...(raw as object), id, stock: false });
if (parsed.success) {
entries.push(parsed.data as CliEntry);
} else {
warnings.push(`Custom CLI "${id}" failed validation and was dropped. ${parsed.error.message}`);
}
}
entries.sort((a, b) => a.order - b.order);
return { entries, warnings };
}
let cache: LoadResult | null = null;
/**
* Load the effective registry (stock + user overrides). Memoized for the process lifetime;
* `reloadCliRegistry()` invalidates.
*/
export function loadCliRegistry(): LoadResult {
if (cache) return cache;
const warnings: string[] = [];
const existing = readRegistryFile(filePath(), warnings);
cache = resolveRegistry(STOCK_CLIS, existing, warnings);
return cache;
}
/** Drop the memoized registry so the next `loadCliRegistry()` re-reads the file. */
export function reloadCliRegistry(): void {
cache = null;
}
export function listClis(): CliEntry[] {
return loadCliRegistry().entries;
}
export function enabledClis(): CliEntry[] {
return listClis().filter((e) => e.enabled);
}
export function getCli(id: string): CliEntry | undefined {
return listClis().find((e) => (e.id as string) === id);
}
export function cliIds(): string[] {
return listClis().map((e) => e.id as string);
}
/** Every enabled entry's id, in registry order. */
export function enabledCliIds(): string[] {
return enabledClis().map((e) => e.id as string);
}
/**
* Resolve the install command for the current platform, falling back to the linux one (the
* common case for a `curl | bash` or `npm install -g` line) and then to whatever is
* declared. Display text only — never executed. See CliDiscovery.install.command.
*/
export function resolveInstallCommandForPlatform(entry: CliEntry): string | undefined {
const { command } = entry.discovery.install;
const platform = process.platform as 'linux' | 'darwin' | 'win32';
return command[platform] ?? command.linux ?? Object.values(command)[0];
}
+428
View File
@@ -0,0 +1,428 @@
/**
* @fileoverview Zod validation for CLI registry entries.
*
* Every object here is `.strict()`: an unknown key is a hard validation error, not a
* silently-ignored one. That matters for a security-relevant schema — a typo in a field name
* must never degrade to "field absent, so the permissive default applies".
*
* The load-bearing rule enforced here is `SHELL_TOKEN`: it is what makes it impossible for a
* `clis.json` entry to smuggle shell metacharacters into the eventual `bash -c "..."` string
* (see argv.ts's file header for the full model).
*
* @module config/cli-registry/schema
*/
import { z } from 'zod';
import { TOKEN_PATTERNS } from './patterns.js';
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
const cliId = z
.string()
.regex(/^[a-z][a-z0-9-]{0,23}$/, 'id must be lowercase, start with a letter, and be at most 24 chars');
/** An env var name. */
const envName = z
.string()
.regex(/^[A-Z_][A-Z0-9_]*$/, 'env var name must be UPPER_SNAKE_CASE')
.max(64);
/**
* A shell-safe bare word: no space, quote, backtick, `$`, `;`, `&`, `|`, `<`, `>`, parens,
* braces, newline or backslash. Every LITERAL in the launch spec (base command, flag names,
* fixed values) must satisfy this — see argv.ts's file header.
*/
const shellToken = z
.string()
.min(1)
.max(256)
.regex(/^[A-Za-z0-9._:@=+/,-]+$/, 'must be a plain word with no shell metacharacters');
const flagToken = z.string().regex(/^--?[A-Za-z0-9][A-Za-z0-9-]*$/, 'must look like -x or --long-flag');
const quoteStyle = z.enum(['auto', 'bare', 'double', 'single']);
const condSchema: z.ZodType<import('./types.js').Cond> = z.lazy(() =>
z.union([
z.object({ param: z.string(), is: z.union([z.string(), z.boolean()]) }).strict(),
z.object({ param: z.string(), state: z.enum(['set', 'unset']) }).strict(),
z.object({ allOf: z.array(condSchema).min(1).max(8) }).strict(),
z.object({ anyOf: z.array(condSchema).min(1).max(8) }).strict(),
z.object({ not: condSchema }).strict(),
z.object({ capabilityGate: z.string() }).strict(),
])
);
const paramSpecSchema = z.union([
z
.object({ type: z.literal('enum'), values: z.array(z.string()).min(1).max(16), default: z.string().optional() })
.strict(),
z.object({ type: z.literal('bool') }).strict(),
z.object({ type: z.literal('token'), pattern: z.enum(TOKEN_PATTERNS as [string, ...string[]]) }).strict(),
z
.object({
type: z.literal('engine'),
source: z.enum([
'sessionId',
'sessionName',
'muxName',
'effortLevel',
'effortSettingsJson',
'codemanPrefixedSessionId',
'launcherDefaultTarget',
]),
})
.strict(),
]);
const argSpecSchema = z.union([
z.object({ lit: shellToken, when: condSchema.optional() }).strict(),
z.object({ flag: flagToken, when: condSchema.optional() }).strict(),
z.object({ flag: flagToken, value: shellToken, quote: quoteStyle.optional(), when: condSchema.optional() }).strict(),
z
.object({ flag: flagToken, valueFrom: z.string(), quote: quoteStyle.optional(), when: condSchema.optional() })
.strict(),
z.object({ valueFrom: z.string(), quote: quoteStyle.optional(), when: condSchema.optional() }).strict(),
]);
const variantSchema = z
.object({
id: z.string().min(1).max(40),
when: condSchema.optional(),
// min(0): the `shell` entry declares a variant with no args — tmux-manager resolves the
// real login shell in code, since it varies per remote user's /etc/passwd entry.
args: z.array(argSpecSchema).max(32),
})
.strict();
const launchSchema = z
.object({
params: z.record(z.string(), paramSpecSchema),
chain: z.enum(['first', 'fallback']).optional(),
variants: z.array(variantSchema).min(1).max(4),
legacyConfigAliases: z.record(z.string(), z.string()).optional(),
legacyConfigField: z.string().min(1).max(40).optional(),
resumeAppend: z
.union([
z.object({ style: z.literal('flag'), flag: flagToken }).strict(),
z.object({ style: z.literal('positional'), token: shellToken }).strict(),
])
.optional(),
})
.strict()
.superRefine((launch, ctx) => {
const paramNames = new Set(Object.keys(launch.params));
const checkValueFrom = (name: string, path: (string | number)[]) => {
if (!paramNames.has(name)) {
ctx.addIssue({ code: 'custom', message: `valueFrom "${name}" is not a declared param`, path });
}
};
launch.variants.forEach((variant, vi) => {
variant.args.forEach((arg, ai) => {
if ('valueFrom' in arg) checkValueFrom(arg.valueFrom, ['variants', vi, 'args', ai, 'valueFrom']);
});
});
if (launch.chain === 'fallback') {
const last = launch.variants.at(-1);
if (last?.when) {
ctx.addIssue({
code: 'custom',
message: 'the last variant of a fallback chain must have no `when` (it must be the guaranteed terminal case)',
path: ['variants', launch.variants.length - 1, 'when'],
});
}
}
if (launch.legacyConfigAliases) {
for (const paramName of Object.keys(launch.legacyConfigAliases)) {
if (!paramNames.has(paramName)) {
ctx.addIssue({
code: 'custom',
message: `legacyConfigAliases key "${paramName}" is not a declared param`,
path: ['legacyConfigAliases', paramName],
});
}
}
}
});
const versionProbeSchema = z
.object({
arg: shellToken,
regex: z.string().max(200).optional(),
requireVersionMatch: z.boolean().optional(),
retryOnTransientFailure: z.boolean().optional(),
})
.strict();
const identityProbeSchema = z
.object({
arg: shellToken,
// Same 200-char cap as version.regex, and compiled through the same compileVersionRegex()
// guard at use time. This is the second and last config-supplied regex in the registry.
regex: z.string().min(1).max(200),
})
.strict();
const discoverySchema = z
.object({
// min(0): the `shell` entry has no binary of its own (it resolves the login shell in code).
binaries: z.array(shellToken).max(4),
searchDirs: z.array(z.string().max(300)).max(16),
version: versionProbeSchema.optional(),
identity: identityProbeSchema.optional(),
launcherProfile: z.string().max(40).optional(),
launcherTargetParam: z.string().max(40).optional(),
install: z
.object({
// z.record with an enum key type requires every enum member in Zod v4; the install
// command legitimately varies by platform and most entries only need one or two, so
// this is a plain object of optional platform keys instead.
command: z
.object({
linux: z.string().max(500).optional(),
darwin: z.string().max(500).optional(),
wsl: z.string().max(500).optional(),
win32: z.string().max(500).optional(),
})
.strict(),
npmPackage: z.string().max(200).optional(),
docsUrl: z.url().optional(),
})
.strict(),
})
.strict();
const envExportSchema = z
.object({
name: envName,
value: z.union([
shellToken,
z
.object({
engine: z.enum([
'sessionId',
'sessionName',
'muxName',
'effortLevel',
'effortSettingsJson',
'codemanPrefixedSessionId',
'launcherDefaultTarget',
]),
})
.strict(),
]),
when: condSchema.optional(),
})
.strict();
const envSchema = z
.object({
exports: z.array(envExportSchema).max(16),
unset: z.array(envName).max(16),
tmuxSetenvKeys: z.array(envName).max(32),
dockerExecEnvNames: z.array(envName).max(32),
configSetenv: z
.array(z.object({ name: envName, fromParam: z.string().min(1).max(40) }).strict())
.max(8)
.optional(),
allowedPrefixes: z
.array(
z
.string()
.min(3)
.max(32)
.regex(/^[A-Z][A-Z0-9_]*_$/)
)
.max(8),
allowedKeys: z.array(envName).max(8),
configContentVar: envName.optional(),
setenvProfile: z.string().max(40).optional(),
})
.strict();
const echoSchema = z
.object({
policy: z.enum(['buffer', 'predict', 'off']),
anchor: z.union([
z
.object({ kind: z.literal('glyph'), glyph: z.string().min(1).max(4), offset: z.number().int().min(0).max(16) })
.strict(),
z.object({ kind: z.literal('cursor') }).strict(),
z.object({ kind: z.literal('none') }).strict(),
]),
predictProfile: z.string().max(40).optional(),
})
.strict();
const capabilitiesSchema = z
.object({
external: z.boolean(),
requiresMux: z.boolean(),
hooks: z.enum(['none', 'always', 'supervised']),
transcript: z.enum(['claude-jsonl', 'codex-rollout', 'deepseek-zstd', 'omp-jsonl', 'none']),
altScreen: z.enum(['strip-full', 'strip-mux-only', 'preserve']),
echo: echoSchema,
wheelForward: z
.object({ mode: z.enum(['never', 'version-gated']), minVersion: z.string().max(20).optional() })
.strict(),
keyboardAccessory: z.enum(['agent', 'shell']),
privilegedCommandGate: z.boolean(),
startMode: z.enum(['interactive', 'shell']),
stripInkBloat: z.boolean(),
ralph: z.boolean(),
respawn: z.boolean(),
effort: z.boolean(),
agentSkillInjection: z.boolean(),
statusLineTelemetry: z.boolean(),
model: z
.object({ source: z.enum(['flag', 'claude-settings-file', 'none']), param: z.string().optional() })
.strict(),
privilegedParams: z
.array(
z
.object({
param: z.string(),
clampTo: z.union([z.boolean(), z.string()]),
materializeWhenAbsent: z.boolean().optional(),
})
.strict()
)
.max(8),
// Exact env var NAMES, not prefixes: this list is a targeted deny, and a prefix here
// would let one entry silently strip a whole namespace off every owner's overrides.
privilegedEnvKeys: z.array(envName).max(8),
gates: z.record(z.string(), z.object({ minVersion: z.string().max(20), failClosed: z.boolean() }).strict()),
maxFrameBytes: z.number().int().positive().optional(),
})
.strict();
const credStoreSchema = z
.object({
rel: z.string().min(1).max(100),
shareDirs: z.array(z.string().max(100)).optional(),
shareFiles: z.array(z.string().max(100)).optional(),
seedFiles: z.array(z.string().max(100)).optional(),
seedWhole: z.boolean().optional(),
})
.strict();
/**
* A remote/docker default pane command: space-separated bare words from the SAME safe
* charset as `shellToken` (no shell metacharacters), so `claude --dangerously-skip-permissions`
* is expressible while still excluding `;`, `|`, `$`, backticks and quotes — this is not an
* escape hatch into arbitrary shell text, it is one bare command plus bare flags.
*/
const commandLine = z
.string()
.min(1)
.max(200)
.regex(
/^[A-Za-z0-9._:@=+/,-]+( [A-Za-z0-9._:@=+/,-]+)*$/,
'must be space-separated bare words with no shell metacharacters'
);
const overlayTargetSchema = z.union([
z.object({ command: commandLine.optional() }).strict(),
z.object({ disabled: z.literal(true) }).strict(),
]);
const overlaysSchema = z
.object({
remote: overlayTargetSchema.optional(),
docker: overlayTargetSchema.optional(),
credStore: credStoreSchema.optional(),
})
.strict();
export const CliEntrySchema = z
.object({
id: cliId,
label: z.string().min(1).max(60),
shortBadge: z.string().min(1).max(6),
accent: z.string().regex(/^#[0-9a-fA-F]{6}$/, 'accent must be a 6-digit hex colour'),
enabled: z.boolean(),
stock: z.boolean(),
order: z.number().int(),
kind: z.enum(['agent', 'shell']),
discovery: discoverySchema,
launch: launchSchema,
env: envSchema,
capabilities: capabilitiesSchema,
overlays: overlaysSchema,
})
.strict()
.superRefine((entry, ctx) => {
const gateNames = new Set(Object.keys(entry.capabilities.gates));
const walkConds = (cond: import('./types.js').Cond | undefined) => {
if (!cond) return;
if ('capabilityGate' in cond && !gateNames.has(cond.capabilityGate)) {
ctx.addIssue({
code: 'custom',
message: `capabilityGate "${cond.capabilityGate}" is not declared in capabilities.gates`,
});
}
if ('allOf' in cond) cond.allOf.forEach(walkConds);
if ('anyOf' in cond) cond.anyOf.forEach(walkConds);
if ('not' in cond) walkConds(cond.not);
};
for (const variant of entry.launch.variants) {
walkConds(variant.when);
for (const arg of variant.args) walkConds(arg.when);
}
// Reject a profile name this build does not implement, rather than letting it fail
// closed at use time. An unimplemented `launcherProfile` would make the CLI look
// permanently uninstalled, and an unimplemented `setenvProfile` would silently skip
// setup the CLI needs; both are far easier to diagnose as a load-time error naming the
// field. (`echo.predictProfile` is deliberately NOT checked here — see profiles.ts.)
const { launcherProfile } = entry.discovery;
if (launcherProfile !== undefined && !isKnownLauncherProfile(launcherProfile)) {
ctx.addIssue({
code: 'custom',
message: `discovery.launcherProfile "${launcherProfile}" is not a profile this build implements`,
path: ['discovery', 'launcherProfile'],
});
}
// An env var exported from a param that does not exist would silently export nothing,
// and for DSH_PERMISSION_MODE that means silently losing a permission clamp.
const declaredParams = new Set(Object.keys(entry.launch.params));
entry.env.configSetenv?.forEach((mapping, i) => {
if (!declaredParams.has(mapping.fromParam)) {
ctx.addIssue({
code: 'custom',
message: `configSetenv fromParam "${mapping.fromParam}" is not a declared launch param`,
path: ['env', 'configSetenv', i, 'fromParam'],
});
}
});
// Same class of silent failure on the OTHER privileged surface, and this one is a
// security control: `privilegedParams[].param` is the multi-user bypass clamp's only
// handle on a CLI's privilege switch, and a name that is not a declared param clamps
// NOTHING — no load error, no failing test, the clamp simply stops running. The clamp
// resolves the name through `legacyConfigAliases`, so this check is what keeps the two
// in ONE namespace rather than two that merely coincide today: they do not for codex
// (`bypassApprovals` vs `dangerouslyBypassApprovals`), and giving deepseek's
// `permissionMode` an alias later would otherwise have removed its clamp with nothing
// saying so.
entry.capabilities.privilegedParams.forEach((clamp, i) => {
if (!declaredParams.has(clamp.param)) {
ctx.addIssue({
code: 'custom',
message: `privilegedParams param "${clamp.param}" is not a declared launch param`,
path: ['capabilities', 'privilegedParams', i, 'param'],
});
}
});
const { setenvProfile } = entry.env;
if (setenvProfile !== undefined && !isKnownSetenvProfile(setenvProfile)) {
ctx.addIssue({
code: 'custom',
message: `env.setenvProfile "${setenvProfile}" is not a profile this build implements`,
path: ['env', 'setenvProfile'],
});
}
});
export type ValidatedCliEntry = z.infer<typeof CliEntrySchema>;
File diff suppressed because it is too large Load Diff
+515
View File
@@ -0,0 +1,515 @@
/**
* @fileoverview Type definitions for the CLI registry — the single source of truth for
* which agent CLIs Codeman supports and how each one is discovered, launched and treated.
*
* This replaces the hard-coded `SessionMode` union and the ~123 per-mode branches that grew
* out of it. The guiding rule: NO code may branch on a CLI's id. Behaviour that genuinely
* differs between CLIs is expressed either as data here, or as a named PROFILE selected by
* a capability field (see profiles.ts) — never as `mode === 'codex'`.
*
* @module config/cli-registry/types
*/
import type { TokenPattern } from './patterns.js';
/**
* A CLI identifier. Branded so an arbitrary string cannot be passed where a validated id is
* expected; construct with `asCliId()` at the API boundary.
*/
export type CliId = string & { readonly __cliId: unique symbol };
// ---------------------------------------------------------------------------
// Launch argv DSL
// ---------------------------------------------------------------------------
/** Values the ENGINE supplies. Config may reference these by name but never author them. */
export type EngineValue =
| 'sessionId'
| 'sessionName'
| 'muxName'
| 'effortLevel'
| 'effortSettingsJson'
/** `sessionId` prefixed `codeman_<id>` — codex's unique per-pane rollout originator. */
| 'codemanPrefixedSessionId'
/**
* For a launcher CLI (`discovery.launcherProfile`), the target to launch when the caller
* named none — deepseek's default `dsh` profile. Resolved at spawn time, never frozen
* into config, because it depends on what is installed on this machine right now.
*/
| 'launcherDefaultTarget';
/**
* A declared launch parameter. `token` params carry caller-supplied data and are therefore
* the only ones that need a pattern; `engine` params are produced in code.
*/
export type ParamSpec =
| { type: 'enum'; values: string[]; default?: string }
| { type: 'bool' }
| { type: 'token'; pattern: TokenPattern }
| { type: 'engine'; source: EngineValue };
/** A boolean guard over parameter state. */
export type Cond =
| { param: string; is: string | boolean }
| { param: string; state: 'set' | 'unset' }
| { allOf: Cond[] }
| { anyOf: Cond[] }
| { not: Cond }
/** Names an entry in `capabilities.gates`. Fail-closed gates omit when version is unknown. */
| { capabilityGate: string };
/**
* How a token is quoted when emitted into the bash command string.
*
* This exists ONLY to preserve byte-identical output with the hand-written builders being
* replaced (claude wraps its values in double quotes; the other builders emit bare words).
* It is never a safety lever: `renderToken()` verifies the value is metacharacter-free
* before honouring an explicit style, and falls back to single-quote escaping if it is not.
* So the worst a wrong `quote` can do is make output uglier, never unsafe.
*/
export type QuoteStyle = 'auto' | 'bare' | 'double' | 'single';
/** One argv element. */
export type ArgSpec =
/** A bare literal word, e.g. the base binary or codex's `resume` subcommand. */
| { lit: string; when?: Cond }
/** A valueless flag, e.g. `--no-approve`. */
| { flag: string; when?: Cond }
/** A flag with a fixed literal value. */
| { flag: string; value: string; quote?: QuoteStyle; when?: Cond }
/** A flag whose value comes from a declared param. */
| { flag: string; valueFrom: string; quote?: QuoteStyle; when?: Cond }
/** A bare positional value from a param, e.g. codex's `resume <id>`. */
| { valueFrom: string; quote?: QuoteStyle; when?: Cond };
/** One alternative command form. */
export interface CliVariant {
/** Stable name for diagnostics and tests, e.g. 'resume' / 'new'. */
id: string;
when?: Cond;
args: ArgSpec[];
}
export interface CliLaunch {
params: Record<string, ParamSpec>;
/**
* 'first' — emit the first variant whose `when` passes (the usual case).
* 'fallback' — emit EVERY passing variant joined by the engine's own ` || `, which is how
* claude's `--resume X || --session-id Y` shell fallback is expressed without
* config ever containing shell text. The engine owns the operator.
*/
chain?: 'first' | 'fallback';
variants: CliVariant[];
/**
* Maps a declared param name to the field name it arrives under on the legacy
* `POST /api/sessions` wire shape (`OpenCodeConfig.continueSession`, etc — the per-mode
* config objects predate this registry and stay on the wire for compatibility). A param
* with no entry here is looked up under its own name. This is what lets the spawn-command
* bridge (`session-cli-registry-bridge.ts`) stay generic: it reads the raw legacy config
* object through this DATA-declared alias table instead of a per-mode `if (mode === ...)`.
*/
legacyConfigAliases?: Record<string, string>;
/**
* The field on the legacy spawn option bag holding this CLI's `<Mode>Config` object
* (`openCodeConfig`, `codexConfig`, …). Those per-mode objects predate this registry and
* stay on the wire for API compatibility, so SOMETHING has to know which one to read —
* declaring it here as data is what keeps the bridge a generic reader instead of a
* `switch (mode)`.
*
* ABSENT means this CLI's launch fields live at the TOP LEVEL of the option bag rather
* than nested in a config object. That is claude, whose discrete `claudeMode` /
* `allowedTools` / `model` / `resumeSessionId` fields predate the `<Mode>Config` pattern
* entirely — so "read the option bag itself" is not a special case for it, it is just
* the other shape.
*/
legacyConfigField?: string;
/**
* How to APPEND a resume id onto an already-built base command, for the docker in-container
* "tmux was re-created, resume the surviving transcript" path (`appendResumeFlag` in
* tmux-manager.ts) — a narrower, append-only sibling of the full `variants` shape above,
* which builds a whole command from scratch. Absent = this CLI has no resume flag to
* append (shell, opencode: opencode's docker resume goes through its own config object).
*/
resumeAppend?: { style: 'flag'; flag: string } | { style: 'positional'; token: string };
}
// ---------------------------------------------------------------------------
// Discovery
// ---------------------------------------------------------------------------
export interface CliVersionProbe {
arg: string;
/** Serialized regex, applied to `--version` output only. See compileVersionRegex(). */
regex?: string;
/**
* Treat a binary whose version output does not match as ABSENT rather than as
* present-with-unknown-version. For CLIs with short, generic binary names (`pi`), where a
* `which` hit is not by itself evidence the right program is installed.
*/
requireVersionMatch?: boolean;
/** Retry a failed probe with backoff instead of caching the failure (claude's behaviour). */
retryOnTransientFailure?: boolean;
}
/**
* An identity probe: proof that the binary we found is the program we meant, not an
* unrelated one that happens to share the name.
*
* A version probe is not enough on its own. Debian ships a `dsh` (dancer's shell) that
* answers `--version` perfectly happily, and npm carries squatters for `pi` and `grok`.
* `requireVersionMatch` catches a binary whose version output has the WRONG SHAPE; this
* catches one whose output has the right shape but names the wrong program.
*
* Ordering matters and belongs to the resolver, not to config: identity is checked FIRST,
* so an impostor is rejected before its version string is ever parsed.
*/
export interface CliIdentityProbe {
/** Argument that makes the binary describe itself, e.g. `--help`. */
arg: string;
/**
* Serialized regex the output must match. Compiled through `compileVersionRegex()`, so
* it inherits the same length cap and nested-quantifier rejection — this is the second
* (and last) config-supplied regex in the registry, and it runs against truncated
* command output exactly like the first.
*/
regex: string;
}
export interface CliDiscovery {
/**
* Binary name(s), first hit wins.
*
* This is why the registry fixes a live bug: the mode name is NOT always the binary
* name (`antigravity` runs `agy`), and `probeDockerCliVersion` assumed it was.
*/
binaries: string[];
/** Extra directories probed after `which`. A leading `~` expands to homedir; nothing else. */
searchDirs: string[];
version?: CliVersionProbe;
/** Proof the binary is the right program, checked BEFORE the version probe. */
identity?: CliIdentityProbe;
/**
* Names a LAUNCHER profile (profiles.ts): this CLI's binary is a launcher over some
* further target, so two questions the registry normally answers from the binary alone
* have to be asked of that target instead.
*
* - Is it RUNNABLE? Stricter than "is the binary on disk?".
* - What is the DEFAULT target, when the caller names none?
*
* DeepSeek is why this exists and is its only user. `dsh` launches a profile from
* `$DSH_HOME/profiles/<name>`, and the profiles DeepSeek itself ships (`web`,
* `headless`) cannot drive a terminal pane — so a perfectly-installed `dsh` with no
* third-party TUI profile is installed-but-NOT-runnable. The Run button gates on
* runnability while the "add a profile" affordance gates on mere availability;
* collapsing the two would either hide the affordance that fixes the problem or offer a
* run that always fails.
*
* The default target reaches the launch spec as the `launcherDefaultTarget` engine
* value, so it stays a runtime lookup rather than a value frozen into config.
*
* Absent (the normal case) means the binary IS the program, and its presence IS
* runnability.
*/
launcherProfile?: string;
/**
* The launch param naming the target a caller asked for, so the launcher profile can say
* why THAT specific target will not start rather than only whether any will. Meaningless
* without `launcherProfile`.
*/
launcherTargetParam?: string;
install: {
/**
* DISPLAY TEXT ONLY. Shown verbatim in "CLI not found. Install with: ...".
*
* ⚠️ NEVER executed by the server. That is a documented invariant, not an oversight:
* running it would turn a config file into a code-execution surface. A proposal to
* execute this on enable is deliberately deferred to its own change so the trust
* model can be decided on its own merits rather than inside a refactor.
*/
command: Partial<Record<'linux' | 'darwin' | 'wsl' | 'win32', string>>;
/** Package name for an npm-installable CLI. Display/tooling metadata only. */
npmPackage?: string;
docsUrl?: string;
};
}
// ---------------------------------------------------------------------------
// Environment
// ---------------------------------------------------------------------------
export interface CliEnv {
/** `export K=V` in the bash prelude. Values are literals or engine values, never secrets. */
exports: Array<{ name: string; value: string | { engine: EngineValue }; when?: Cond }>;
/** `unset K` — e.g. claude's CLAUDECODE, the truecolor CLIs' NO_COLOR. */
unset: string[];
/**
* NAMES ONLY. Values are read from the server's own process.env and pushed via
* `tmux setenv`, so a secret is structurally unable to reach the command line.
*/
tmuxSetenvKeys: string[];
/** NAMES ONLY, forwarded as `docker exec -e NAME`. */
dockerExecEnvNames: string[];
/**
* Env vars set via `tmux setenv` from a LAUNCH PARAM rather than from the server's own
* environment — for a CLI whose switch is an env var instead of a flag.
*
* DeepSeek's `DSH_PERMISSION_MODE` is the case this exists for. Routing it through a
* declared param (rather than a bespoke configure step) is what lets the ordinary
* `privilegedParams` clamp apply to it: the clamp rewrites the param, and whatever the
* param ends up as is what gets exported.
*
* ⚠️ Values are read from a declared, schema-validated param, never from free text, and
* they reach the pane through `tmux setenv` rather than the command line.
*/
configSetenv?: Array<{ name: string; fromParam: string }>;
/** This entry's contribution to the env-override allowlist. Never widens BLOCKED_ENV_KEYS. */
allowedPrefixes: string[];
allowedKeys: string[];
/**
* Env var carrying a JSON config blob pushed via `tmux setenv` (opencode's
* OPENCODE_CONFIG_CONTENT). Generic so it is not an opencode special case.
*/
configContentVar?: string;
/**
* Names an entry in `SETENV_PROFILES` (profiles.ts): extra `tmux setenv` work that is
* genuinely code-shaped rather than a list of key names.
*
* DeepSeek's status bridge is the only current user. It has to write an executable shim
* to disk (`ensureDeepSeekStatusShim()`), then export the shim's path and this session's
* pane id — a side effect and two computed values, none of which `tmuxSetenvKeys` (a
* list of names forwarded from the server's own env) can express.
*
* Plain secret forwarding stays in `tmuxSetenvKeys` and must NOT move here.
*/
setenvProfile?: string;
}
// ---------------------------------------------------------------------------
// Capabilities
// ---------------------------------------------------------------------------
/**
* The closed set of behavioural switches. Each field replaces an id-check somewhere.
*
* `hooks`, `transcript` and `altScreen` are INDEPENDENT on purpose. The three predicates
* they back (`hooksAvailableForMode`, `isExternalCliMode`, `isAltScreenStripMode`) describe
* three different, deliberately unequal sets, and deriving any one from another has already
* caused a real bug — a `shell` session has no hooks but is not an "external CLI", so
* `!isExternalCliMode()` wrongly accepted `until=stop` on it and hung for the full timeout.
* Keeping them as separate fields makes that invariant structural rather than commented.
*/
export interface CliCapabilities {
/**
* Non-Claude run mode that uses its own TUI and output format (`isExternalCliMode`):
* no Claude transcript, no hooks, no Claude-format token/BashTool parsing. An explicit
* field rather than derived from `hooks`/`kind`, precisely because it must stay
* independent — see this interface's own doc comment.
*/
external: boolean;
/** No direct-PTY fallback: the CLI must run inside tmux (secrets ride tmux setenv). */
requiresMux: boolean;
/**
* Whether `stop`/`blocked` wait signals can ever fire for this CLI.
*
* ⚠️ A TRI-STATE, not a boolean, because for one CLI this is a per-SESSION question:
* 'none' — no hook signals, ever (every external CLI, and `shell`).
* 'always' — the CLI installs Codeman's hooks (claude).
* 'supervised' — the CLI REPORTS its own idle/working/blocked state to a supervisor
* over a generic env-gated contract, and Codeman is that supervisor
* (deepseek, via deepseek-status-shim.ts). Definitive rather than
* inferred, so it earns real signals — but the session can disarm the
* bridge (`deepSeekConfig.statusReporting: false`), and a docker or
* remote session cannot reach it at all.
*
* That last case is why `hooksAvailableForMode()` takes per-session options and why
* every call site must pass `sessionHookOptions(session)`. Answering from the mode alone
* would promise a `stop` that never arrives, which is the infinite-wait-dressed-as-a-
* timeout the predicate exists to prevent.
*/
hooks: 'none' | 'always' | 'supervised';
/**
* Which transcript reader, if any, understands this CLI's on-disk history.
*
* `deepseek-zstd` is the odd one out: dsh writes zstd-compressed session files and
* appends ONE FRAME PER WRITE, so it needs a reader that walks frame headers itself
* rather than the stock decoder. It exists because the pane segmenter served dsh's
* ASCII-art splash as the worker's first answer.
*/
transcript: 'claude-jsonl' | 'codex-rollout' | 'deepseek-zstd' | 'omp-jsonl' | 'none';
/**
* 'strip-full' — alt-screen + erase-scrollback + mouse DECSETs stripped (Ink TUIs).
* 'strip-mux-only' — only tmux's own attach-time smcup (the safe default).
* 'preserve' — leave everything (a direct-PTY shell running vim/less/htop).
*/
altScreen: 'strip-full' | 'strip-mux-only' | 'preserve';
echo: {
policy: 'buffer' | 'predict' | 'off';
/** How the local-echo overlay locates the composer row. */
anchor: { kind: 'glyph'; glyph: string; offset: number } | { kind: 'cursor' } | { kind: 'none' };
/** Names a PREDICT_PROFILES key. Unknown or absent degrades to 'buffer', never to broken. */
predictProfile?: string;
};
/** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */
wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string };
keyboardAccessory: 'agent' | 'shell';
/** Multi-user: this CLI is a raw shell, so its commands need the privileged gate. */
privilegedCommandGate: boolean;
startMode: 'interactive' | 'shell';
stripInkBloat: boolean;
ralph: boolean;
respawn: boolean;
effort: boolean;
agentSkillInjection: boolean;
statusLineTelemetry: boolean;
/** Where a model override is delivered. Claude uniquely writes settings.local.json. */
model: { source: 'flag' | 'claude-settings-file' | 'none'; param?: string };
/**
* Params a non-granted multi-user owner may not set freely, and what they are forced to.
* Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's.
*
* `materializeWhenAbsent` distinguishes two real shapes, not one:
* - only-if-sent (false/omitted; codex, antigravity, grok): the CLI's own
* absent-config default already spawns safe, so the clamp should only touch
* a config the caller actually sent.
* - materialize (true; gemini, pi): the absent-config default is ITSELF unsafe
* for a non-granted owner (gemini defaults to `yolo`; pi's absent default is
* an interactive trust prompt the session user could just answer "yes" to),
* so the clamp must CREATE a config object even when none was sent.
*
* ⚠️ `param` names the LAUNCH PARAM, like every other `param` in this file — never the
* legacy wire field. The clamp translates it through `legacyConfigAliases` on the way out,
* the same hop `env.configSetenv` makes. The two names coincide for most entries and
* DELIBERATELY do not for codex (`bypassApprovals` here, `dangerouslyBypassApprovals` on
* the wire), which is what keeps the distinction visible. `schema.ts` rejects an entry
* naming a param it never declared, because getting this wrong is a SILENT no-op: no load
* error, no failing test, the clamp just stops clamping.
*/
privilegedParams: Array<{ param: string; clampTo: boolean | string; materializeWhenAbsent?: boolean }>;
/**
* Env var names a non-granted multi-user owner may not set at all, DROPPED from
* `envOverrides` before spawn.
*
* ⚠️ This is a second, structurally different privileged surface from `privilegedParams`
* above, and one cannot substitute for the other. `privilegedParams` clamps a field on a
* per-CLI config object, which reaches the CLI as an argv flag. These clamp env vars,
* which reach it through `tmux setenv` — a path no argv clamp can see.
*
* DeepSeek is why this exists. Its permission switch IS an env var
* (`DSH_PERMISSION_MODE`), not a flag, so a config-level clamp alone leaves a real
* multi-user control with nothing enforcing it. Worse, `DSH_*` is an allowlisted
* `envOverrides` prefix and `applyEnvOverrides()` runs AFTER the per-CLI env configure
* step, so a non-granted owner sending that key on the SAME request would land last and
* hand back exactly the privilege the config clamp just removed.
*
* Dropping (rather than rewriting) is deliberate: the value then falls through to what
* the CLI's own env configuration exports, which is already the clamped one.
*
* The other two DeepSeek keys are here for reasons worth keeping written down:
* - `DSH_HOME` points the launcher at a profile tree whose plugin code runs at BOOT,
* before any approval row could apply.
* - `DEEPSEEK_BASE_URL` would redirect the server's OWN forwarded `DEEPSEEK_API_KEY`
* to a host of the caller's choosing.
*
* Every other CLI's bypass is a command-line flag reachable only through its config
* object, which is why `privilegedParams` alone is the whole gate for them.
*/
privilegedEnvKeys: string[];
/** Version gates referenced by `capabilityGate` conditions. */
gates: Record<string, { minVersion: string; failClosed: boolean }>;
/** Cap on a single terminal frame, when this CLI needs a tighter one than the default. */
maxFrameBytes?: number;
}
// ---------------------------------------------------------------------------
// Location overlays (remote SSH / docker)
// ---------------------------------------------------------------------------
/** Docker credential seeding policy — which host dirs are copied or shared into a container. */
export interface CliCredStore {
rel: string;
shareDirs?: string[];
shareFiles?: string[];
seedFiles?: string[];
seedWhole?: boolean;
}
export interface CliOverlays {
/**
* The remote/docker DEFAULT pane command: just the CLI invocation (e.g. `claude
* --dangerously-skip-permissions`), independent of each location's own wrapping
* (remote: login-shell `-c`; docker: `exec`). Absent `command` = the bare
* `discovery.binaries[0]`. `disabled: true` = this location has no story for this CLI at
* all (docker for `shell`) — distinct from "no override", which still gets a default.
*/
remote?: { command?: string } | { disabled: true };
docker?: { command?: string } | { disabled: true };
credStore?: CliCredStore;
}
// ---------------------------------------------------------------------------
// The entry
// ---------------------------------------------------------------------------
/**
* ⚠️ DECLARED-FOR-LATER: fields no code reads yet.
*
* `shortBadge`, `accent`, `capabilities.echo`, `capabilities.wheelForward`,
* `capabilities.keyboardAccessory` and `capabilities.maxFrameBytes` all describe FRONTEND
* behaviour, and the frontend is deliberately untouched by the change that introduced this
* registry — `app.js`, `terminal-ui.js`, `styles.css` and friends keep their own
* hand-authored per-CLI rules, and moving them is its own piece of work with its own way of
* being verified (a mobile/browser suite the CI gate cannot see).
*
* They are declared now because each entry should describe its CLI completely, and because
* transcribing them while the hand-written source is still on screen is when the values are
* actually known. But an unread field is a promise, not a fact: nothing enforces that
* `echo.policy` here matches `_updateLocalEchoState`'s fallthrough, or that `accent` matches
* the gradient CSS paints. Treat every value in this group as TRANSCRIBED, not authoritative,
* and re-measure against the frontend before wiring one up.
*
* The rest of the interface is live: something reads it, and `test/cli-registry-*.test.ts`
* pins what it does with it.
*/
export interface CliEntry {
id: CliId;
label: string;
/** Two-ish character tab badge, e.g. 'OC'. */
shortBadge: string;
/** Single hex colour. CSS derives every per-CLI gradient from it via --cli-accent. */
accent: string;
enabled: boolean;
/** Set by the loader from the shipped catalog; a user entry can never claim it. */
stock: boolean;
order: number;
/** 'shell' unlocks the raw-shell code paths; everything else is an agent CLI. */
kind: 'agent' | 'shell';
discovery: CliDiscovery;
launch: CliLaunch;
env: CliEnv;
capabilities: CliCapabilities;
overlays: CliOverlays;
}
/**
* The on-disk shape of ~/.codeman/clis.json — overrides and custom entries only, never the
* full catalog. Small and hand-readable by design.
*
* ⚠️ READ-ONLY in this build. Nothing here writes this file: there is no settings UI and no
* write API yet, so there is nothing to persist. That also means importing the registry
* (and therefore `schemas.ts`, which validates against it) performs no filesystem writes —
* an import side effect worth not having.
*/
export interface CliRegistryFile {
schemaVersion: number;
/**
* Stock ids already introduced to this install — the ratchet that lets one file both gain
* newly-shipped CLIs on upgrade AND remember that the user disabled one.
*
* Read and IGNORED here, and never written: the ratchet only earns its keep once a CLI
* can be disabled, which needs the write API. Declared now purely so a file written by a
* later version still loads cleanly under this one instead of failing `.strict()`.
*/
seededStockIds?: string[];
/** Keyed by id: a partial override of a stock entry, or a complete custom entry. */
clis: Record<string, unknown>;
}
+159 -204
View File
@@ -7,10 +7,8 @@
* @module config/dependency-registry
*/
import { PI_VERSION_REGEX } from '../utils/pi-cli-resolver.js';
import { GROK_VERSION_REGEX } from '../utils/grok-cli-resolver.js';
import { DEEPSEEK_VERSION_REGEX } from '../utils/deepseek-cli-resolver.js';
import { OMP_VERSION_REGEX } from '../utils/omp-cli-resolver.js';
import { enabledClis } from './cli-registry/registry.js';
import { compileVersionRegex } from './cli-registry/patterns.js';
export type ProbeEnvironment = 'linux' | 'darwin' | 'win32' | 'wsl';
@@ -59,207 +57,164 @@ export interface ToolDependency {
const ALL: ProbeEnvironment[] = ['linux', 'darwin', 'wsl', 'win32'];
export const DEPENDENCY_REGISTRY: ToolDependency[] = [
{
id: 'node',
label: 'Node.js',
category: 'core',
required: true,
minVersion: '22.0.0',
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['node'], versionArg: '--version' } }],
installHint: { linux: 'https://nodejs.org', darwin: 'brew install node', wsl: 'https://nodejs.org' },
},
{
id: 'claude',
label: 'Claude CLI',
category: 'core',
required: false,
usedBy: ['Claude Code sessions (default backend)'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['claude'], versionArg: '--version' } }],
installHint: { linux: 'https://docs.claude.com/claude-code', darwin: 'https://docs.claude.com/claude-code' },
},
{
id: 'tmux',
label: 'tmux',
category: 'core',
required: true,
resolvers: [{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }],
installHint: { linux: 'sudo apt install tmux', darwin: 'brew install tmux', wsl: 'sudo apt install tmux' },
},
{
id: 'opencode',
label: 'OpenCode CLI',
category: 'core',
required: false,
usedBy: ['OpenCode sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['opencode'], versionArg: '--version' } }],
},
{
id: 'codex',
label: 'Codex CLI',
category: 'core',
required: false,
usedBy: ['Codex sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['codex'], versionArg: '--version' } }],
},
{
id: 'gemini',
label: 'Gemini CLI',
category: 'core',
required: false,
usedBy: ['Gemini sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['gemini'], versionArg: '--version' } }],
},
{
id: 'antigravity',
label: 'Antigravity CLI',
category: 'core',
required: false,
usedBy: ['Antigravity sessions'],
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['agy'], versionArg: '--version' } }],
},
{
id: 'pi',
label: 'Pi CLI',
category: 'core',
required: false,
usedBy: ['Pi sessions'],
// The only entry that requires a version match, for the same reason
// pi-cli-resolver.ts probes: `pi` is a short generic name (Raspberry Pi tooling,
// personal scripts), so a `which pi` hit alone is not the coding agent. Both sides
// share PI_VERSION_REGEX, so the doctor and the run mode cannot drift into telling
// the user opposite things about the same binary.
resolvers: [
{
match: ALL,
resolver: {
kind: 'path',
bins: ['pi'],
versionArg: '--version',
versionRegex: PI_VERSION_REGEX,
requireVersionMatch: true,
/**
* The doctor's ROW IDENTITY for a CLI, where it differs from the registry id.
*
* These are two separate contracts and they have never been the same thing: `codeman doctor`
* prints a tool table whose ids predate the registry, and `dsh` names the BINARY while the
* run mode is `deepseek`. Keeping the historical id here means the doctor's output does not
* shift under a refactor that was supposed to change nothing a user can see.
*
* `usedBy` is likewise preserved verbatim rather than generated, because the strings are
* shown to the user and claude's does not follow the pattern.
*/
const DOCTOR_ROW_OVERRIDES: Record<string, { id?: string; label?: string; usedBy: string[] }> = {
claude: { usedBy: ['Claude Code sessions (default backend)'] },
opencode: { usedBy: ['OpenCode sessions'] },
codex: { usedBy: ['Codex sessions'] },
gemini: { usedBy: ['Gemini sessions'] },
antigravity: { usedBy: ['Antigravity sessions'] },
pi: { usedBy: ['Pi sessions'] },
grok: { usedBy: ['Grok sessions'] },
// Both the id and the label are historical: `dsh` names the binary, and the doctor has
// always spelled this row out in full rather than as `${label} CLI`.
deepseek: { id: 'dsh', label: 'DeepSeek Harness CLI', usedBy: ['DeepSeek sessions'] },
};
/**
* Build one `codeman doctor` row per enabled CLI, straight from its registry entry.
*
* This replaces eight hand-written rows that had to be kept in step with the run modes by
* hand — and were not: an earlier draft of this refactor silently dropped the Grok and
* DeepSeek rows, so `codeman doctor` stopped reporting two shipped CLIs at all. Deriving
* the list makes that class of omission impossible.
*
* ⚠️ The version regex is compiled through `compileVersionRegex()`, NOT `new RegExp()`. It
* is a config-supplied pattern, so it goes through the same length cap and
* nested-quantifier rejection the argv engine applies; the doctor runs it over command
* output exactly like the resolver does, and skipping the guard here would leave one
* unguarded path into a user-supplied regex.
*
* ⚠️ Sharing the entry's regex with the resolver is what stops the doctor and the run mode
* telling the user opposite things about the same binary — the Dependencies panel reporting
* "Pi CLI ✓" on a box where Run Pi stays hidden.
*/
function cliDependencyEntries(): ToolDependency[] {
const rows: ToolDependency[] = [];
for (const cli of enabledClis()) {
// `shell` has no binary of its own (the login shell is resolved at spawn time), so
// there is nothing for the doctor to probe.
const bin = cli.discovery.binaries[0];
if (!bin) continue;
const override = DOCTOR_ROW_OVERRIDES[cli.id as string];
const version = cli.discovery.version;
const versionRegex = version?.regex ? (compileVersionRegex(version.regex) ?? undefined) : undefined;
rows.push({
id: override?.id ?? (cli.id as string),
label: override?.label ?? `${cli.label} CLI`,
category: 'core',
required: false,
usedBy: override?.usedBy ?? [`${cli.label} sessions`],
resolvers: [
{
match: ALL,
resolver: {
kind: 'path',
bins: [bin],
versionArg: version?.arg ?? '--version',
versionRegex,
// Only meaningful for a CLI whose binary name is short, generic or squatted
// (pi, grok, dsh): a bare `which` hit there is not evidence of the right
// program, so a version mismatch means MISSING rather than unknown-version.
requireVersionMatch: version?.requireVersionMatch,
},
},
},
],
},
{
id: 'grok',
label: 'Grok CLI',
category: 'core',
required: false,
usedBy: ['Grok sessions'],
// Version match required for the same reason as pi: `grok` has known squatters
// (the unrelated @vibe-kit/grok-cli npm package also installs a `grok` bin), so a
// bare `which grok` hit is not the coding agent. Both sides share
// GROK_VERSION_REGEX, so the doctor and the run mode cannot drift.
resolvers: [
{
match: ALL,
resolver: {
kind: 'path',
bins: ['grok'],
versionArg: '--version',
versionRegex: GROK_VERSION_REGEX,
requireVersionMatch: true,
},
},
],
},
{
id: 'dsh',
label: 'DeepSeek Harness CLI',
category: 'core',
required: false,
usedBy: ['DeepSeek sessions'],
// Version match required, and for a sharper reason than pi or grok: `dsh` is
// not merely a squattable npm name, it is an existing Debian program
// (dancer's shell, `apt install dsh`). The run mode's resolver additionally
// demands the harness's own help banner before it will point a spawn line at
// a candidate; the doctor is advisory and settles for the shared
// DEEPSEEK_VERSION_REGEX, so the two cannot disagree about the VERSION even
// though the resolver is the stricter of the pair about IDENTITY.
resolvers: [
{
match: ALL,
resolver: {
kind: 'path',
bins: ['dsh'],
versionArg: '--version',
versionRegex: DEEPSEEK_VERSION_REGEX,
requireVersionMatch: true,
},
},
],
},
{
id: 'omp',
label: 'OMP CLI',
category: 'core',
required: false,
usedBy: ['OMP sessions'],
// Same version-match discipline as pi: `omp` is a short generic name, so a
// `which omp` hit alone is not the coding agent. Both sides share
// OMP_VERSION_REGEX, so the doctor and the run mode cannot drift into telling
// the user opposite things about the same binary.
resolvers: [
{
match: ALL,
resolver: {
kind: 'path',
bins: ['omp'],
versionArg: '--version',
versionRegex: OMP_VERSION_REGEX,
requireVersionMatch: true,
},
},
],
installHint: { linux: 'curl -fsSL https://omp.sh/install | sh', darwin: 'brew install can1357/tap/omp' },
},
{
id: 'libreoffice',
label: 'LibreOffice',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
resolvers: [
{
match: ['linux', 'darwin', 'wsl'],
resolver: { kind: 'path', bins: ['libreoffice', 'soffice'], versionArg: '--version' },
},
],
installHint: { linux: 'sudo apt install libreoffice', darwin: 'brew install --cask libreoffice' },
},
{
id: 'pdftoppm',
label: 'pdftoppm',
category: 'office',
required: false,
usedBy: ['document preview', 'PDF/Office first-page thumbnails'],
// poppler's pdftoppm prints its version to stderr; presence is what matters here.
resolvers: [
{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['pdftoppm'], versionArg: '-v' } },
],
installHint: {
linux: 'sudo apt install poppler-utils',
darwin: 'brew install poppler',
wsl: 'sudo apt install poppler-utils',
],
installHint: cli.discovery.install.command,
});
}
return rows;
}
/**
* The tools `codeman doctor` probes, resolved AT CALL TIME.
*
* ⚠️ A FUNCTION, not a module-level const, and for the same reason `sessionModeSchema()` and
* `allowedEnvPrefixes()` are functions: `cliDependencyEntries()` reads the CLI registry, and
* a const would have frozen the doctor's rows at first import while every schema resolved
* per parse. A CLI enabled while the server was running — or a `reloadCliRegistry()` — then
* moved the run menu and the validation but never the doctor, which would keep reporting the
* catalog as it stood when something first imported this module. Building the array per call
* costs a handful of object literals on a command that shells out to probe binaries anyway.
*/
export function dependencyRegistry(): ToolDependency[] {
return [
{
id: 'node',
label: 'Node.js',
category: 'core',
required: true,
minVersion: '22.0.0',
resolvers: [{ match: ALL, resolver: { kind: 'path', bins: ['node'], versionArg: '--version' } }],
installHint: { linux: 'https://nodejs.org', darwin: 'brew install node', wsl: 'https://nodejs.org' },
},
},
{
id: 'msoffice',
label: 'MS Office',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
resolvers: [
{
match: ['wsl', 'win32'],
resolver: {
kind: 'windows-side',
appDirs: ['Microsoft Office/root/Office16'],
exes: ['WINWORD.EXE', 'POWERPNT.EXE', 'EXCEL.EXE'],
{
id: 'tmux',
label: 'tmux',
category: 'core',
required: true,
resolvers: [{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['tmux'], versionArg: '-V' } }],
installHint: { linux: 'sudo apt install tmux', darwin: 'brew install tmux', wsl: 'sudo apt install tmux' },
},
...cliDependencyEntries(),
{
id: 'libreoffice',
label: 'LibreOffice',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
resolvers: [
{
match: ['linux', 'darwin', 'wsl'],
resolver: { kind: 'path', bins: ['libreoffice', 'soffice'], versionArg: '--version' },
},
],
installHint: { linux: 'sudo apt install libreoffice', darwin: 'brew install --cask libreoffice' },
},
{
id: 'pdftoppm',
label: 'pdftoppm',
category: 'office',
required: false,
usedBy: ['document preview', 'PDF/Office first-page thumbnails'],
// poppler's pdftoppm prints its version to stderr; presence is what matters here.
resolvers: [
{ match: ['linux', 'darwin', 'wsl'], resolver: { kind: 'path', bins: ['pdftoppm'], versionArg: '-v' } },
],
installHint: {
linux: 'sudo apt install poppler-utils',
darwin: 'brew install poppler',
wsl: 'sudo apt install poppler-utils',
},
],
},
];
},
{
id: 'msoffice',
label: 'MS Office',
category: 'office',
required: false,
usedBy: ['document preview', 'thumbnails'],
resolvers: [
{
match: ['wsl', 'win32'],
resolver: {
kind: 'windows-side',
appDirs: ['Microsoft Office/root/Office16'],
exes: ['WINWORD.EXE', 'POWERPNT.EXE', 'EXCEL.EXE'],
},
},
],
},
];
}
+41 -17
View File
@@ -10,6 +10,8 @@
import { v4 as uuidv4 } from 'uuid';
import { readFile } from 'node:fs/promises';
import { statSync, realpathSync } from 'node:fs';
import { getCli } from '../config/cli-registry/registry.js';
import { resolveCliLaunchError } from '../utils/cli-launcher.js';
import { Session } from '../session.js';
import { applyWorkspaceHooks } from '../hooks-config.js';
import { SseEvent } from '../web/sse-events.js';
@@ -58,9 +60,26 @@ export function clampCronExternalCliConfigs(
ownerGranted: boolean
): { geminiConfig: GeminiConfig | undefined; piConfig: PiConfig | undefined } {
if (ownerGranted) return { geminiConfig: undefined, piConfig: undefined };
// A cron job carries no per-CLI config at all, so ONLY the materialize-when-absent params
// can apply here — an only-if-sent clamp has nothing to clamp. Reading them off the
// registry rather than naming gemini and pi means a future CLI whose bare spawn is unsafe
// is covered the moment its entry says so, instead of silently missing this path.
const entry = getCli(mode);
const aliases = entry?.launch.legacyConfigAliases ?? {};
const materialized: Record<string, unknown> = {};
for (const { param, clampTo, materializeWhenAbsent } of entry?.capabilities.privilegedParams ?? []) {
// Same registry-param → legacy-wire-field hop the HTTP clamp makes. Neither gemini's
// `approvalMode` nor pi's `approveProjectTrust` is aliased today, so this changes nothing
// now — but the two are DIFFERENT namespaces, and writing the raw param here would make
// this path stop clamping the moment one of them gained an alias, silently.
if (materializeWhenAbsent) materialized[aliases[param] ?? param] = clampTo;
}
const has = Object.keys(materialized).length > 0;
const field = entry?.launch.legacyConfigField;
return {
geminiConfig: mode === 'gemini' ? { approvalMode: 'auto_edit' } : undefined,
piConfig: mode === 'pi' ? { approveProjectTrust: false } : undefined,
geminiConfig: has && field === 'geminiConfig' ? (materialized as GeminiConfig) : undefined,
piConfig: has && field === 'piConfig' ? (materialized as PiConfig) : undefined,
};
}
@@ -387,7 +406,7 @@ export class CronService {
// Section 6.3: re-resolve the owner's grant at FIRE time (it may have been revoked
// since create). Gates shell/launchCommand AND clamps the external-CLI bypass below.
const ownerGranted = await canUsernameRunPrivilegedCommands(job.owner);
if ((job.agentType === 'shell' || job.launchCommand) && !ownerGranted) {
if ((getCli(job.agentType)?.capabilities.privilegedCommandGate || job.launchCommand) && !ownerGranted) {
return this.failRun(job, run, 'Owner lacks the can-bypass-permissions grant for shell/launchCommand jobs');
}
@@ -395,23 +414,26 @@ export class CronService {
let session: Session;
try {
const mode = job.agentType;
// Same two-part availability gate the HTTP create paths run: `dsh` is a
// profile LAUNCHER, so without this a job on a box with only the stock
// web/headless profiles spawns a bare `dsh` that boots a profile unable
// to drive a pane, and the prompt is typed into a logging server or a
// dead pane instead of failing the run with the actionable message.
if (mode === 'deepseek') {
const { resolveDeepSeekLaunchError } = await import('../utils/deepseek-cli-resolver.js');
const launchError = resolveDeepSeekLaunchError();
if (launchError) return this.failRun(job, run, launchError);
}
// Refuse a launch the CLI cannot survive, rather than opening a dead pane. The
// launcher CLIs answer with their own specific reason (for dsh: binary missing, no
// pane-capable profile, or the named profile cannot drive a pane); ordinary CLIs
// answer with the resolver's not-found message. Cron sends no per-CLI config, so
// there is no caller-named target to report on.
const cronLaunchError = await resolveCliLaunchError(mode);
if (cronLaunchError) return this.failRun(job, run, cronLaunchError);
const globalNice = await this.deps.getGlobalNiceConfig();
const modelConfig = await this.deps.getModelConfig();
const claudeModeConfig = await this.deps.getClaudeModeConfig();
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, job.owner);
// DeepSeek's model is a composition entry in the profile's config tree,
// not a session flag — mirror the HTTP routes' exclusion.
const model = mode !== 'shell' && mode !== 'deepseek' ? modelConfig?.defaultModel || undefined : undefined;
// Cron carries no per-CLI config object, so the only model it can supply is the global
// default — and only to a CLI that takes one that way. `capabilities.model` is the same
// question the HTTP routes ask; the ladder it replaces named `shell` and `deepseek` by
// hand and had to be edited in step with them (deepseek's model is a profile
// composition entry, not a session flag).
const model =
getCli(mode)?.capabilities.model.source === 'claude-settings-file'
? modelConfig?.defaultModel || undefined
: undefined;
// Section 6.3: materialize the safe default for a non-granted owner (see
// clampCronExternalCliConfigs — cron sends no per-CLI config, so the CLI's own
// spawn default is what would otherwise apply).
@@ -560,7 +582,9 @@ export class CronService {
private sendPromptWhenReady(sessionId: string, prompt: string, job: CronJob, run: CronJobRun): void {
setImmediate(() => {
const poll = async (): Promise<void> => {
if (job.agentType !== 'shell') {
// A shell pane is ready the moment it exists; an agent CLI has a TUI to paint
// first. That is the `kind` the registry already records, not a fact about shell.
if (getCli(job.agentType)?.kind !== 'shell') {
for (let attempt = 0; attempt < CRON_READY_MAX_ATTEMPTS; attempt++) {
await delay(500);
const s = this.deps.sessions.get(sessionId);
+24 -18
View File
@@ -24,6 +24,7 @@
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { getCli } from './config/cli-registry/registry.js';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
import { createHash } from 'node:crypto';
@@ -32,7 +33,6 @@ import { promisify } from 'node:util';
import { dataPath } from './config/instance.js';
import type {
DockerCase,
DockerCommandMode,
DockerEngine,
DockerHost,
DockerNetworkMode,
@@ -134,22 +134,23 @@ export function dockerContainerName(caseName: string): string {
return `${CONTAINER_NAME_PREFIX}${caseName}`;
}
/** Default pane command per CLI mode (mirror of defaultRemoteCommandForMode). */
/**
* Default in-container pane command per CLI mode (mirror of defaultRemoteCommandForMode).
*
* ⚠️ Read from the registry (`overlays.docker`), not from a hardcoded
* `Record<DockerCommandMode, string>`. That table duplicated the registry exactly with
* nothing keeping the two in step. `shell` is the one arm still written here, because it is
* the entry that declares `docker: { disabled: true }` — a container has no per-user login
* shell to resolve, so it gets a plain `bash -l` rather than a CLI invocation.
*/
export function defaultDockerCommandForMode(mode: SessionMode): string {
const commands: Record<DockerCommandMode, string> = {
shell: 'exec bash -l',
// Mirror the LOCAL claude default so the in-container agent runs non-interactively.
claude: 'exec claude --dangerously-skip-permissions',
opencode: 'exec opencode',
codex: 'exec codex',
gemini: 'exec gemini',
antigravity: 'exec agy',
pi: 'exec pi',
grok: 'exec grok',
deepseek: 'exec dsh',
omp: 'exec omp',
};
return commands[mode as DockerCommandMode] || commands.shell;
const entry = getCli(mode);
const overlay = entry?.overlays.docker;
if (!entry || (overlay && 'disabled' in overlay)) return 'exec bash -l';
// Mirrors the LOCAL default for each CLI; claude's carries
// `--dangerously-skip-permissions` so the in-container agent runs non-interactively.
const cli = overlay?.command ?? entry.discovery.binaries[0];
return cli ? `exec ${cli}` : 'exec bash -l';
}
/** `container:/workdir` display string (mirror of remoteDisplayPath's `user@host:path`). */
@@ -1117,8 +1118,13 @@ export async function probeDockerCliVersion(
mode: SessionMode
): Promise<string | undefined> {
if (IS_TEST_MODE) return undefined;
const bin = mode === 'shell' ? null : mode;
if (!bin) return undefined;
// ⚠️ The MODE NAME IS NOT ALWAYS THE BINARY NAME — `antigravity` runs `agy`. This used
// to pass the mode straight through as the command, which would have probed a binary that
// does not exist. Only claude reaches this today (it is the one CLI with a version gate),
// so nothing was actually broken, but the registry is what makes it correct for the next
// CLI that needs a version.
const bin = getCli(mode)?.discovery.binaries[0];
if (!bin) return undefined; // `shell` has no binary of its own
const argv = dockerEngineArgv(docker);
try {
const { stdout } = await execFileAsync(
+64 -46
View File
@@ -4,9 +4,9 @@ import { join } from 'node:path';
import { homedir } from 'node:os';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
import { getCli } from './config/cli-registry/registry.js';
import type {
RemoteCase,
RemoteCommandMode,
RemoteHost,
RemoteSessionInfo,
RemoteSshOptions,
@@ -89,39 +89,54 @@ export function remoteLoginShellCommand(command: string): string {
return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`;
}
/**
* The CLI text a location overlay should launch for `mode`, or null when this build has no
* entry for it. `overlays.<location>.command` when the entry names one, otherwise the bare
* binary — which is what every non-claude CLI wants, and why only claude declares a command.
*
* ⚠️ This returns the CLI INVOCATION only. Each location wraps it its own way (remote: a
* login-shell `-c`; docker: `exec`), which is exactly why the overlay stores the unwrapped
* form rather than a ready-made line.
*/
function overlayCliCommand(mode: SessionMode, location: 'remote' | 'docker'): string | null {
const entry = getCli(mode);
if (!entry) return null;
const overlay = entry.overlays[location];
if (overlay && 'disabled' in overlay) return null;
return overlay?.command ?? entry.discovery.binaries[0] ?? null;
}
/**
* The default remote pane command for `mode`.
*
* Agent CLIs (claude/opencode/codex/gemini/antigravity/…) are typically installed under
* per-user paths like ~/.local/bin or ~/.opencode/bin, added to PATH only by the remote
* user's interactive-login shell startup files (~/.zshrc etc.). ssh's remote-command
* execution is neither interactive nor login, so a bare `exec claude` sees only sshd's
* minimal default PATH and fails with "command not found" (exit 127) — confirmed via
* `tmux capture-pane` on the remain-on-exit-preserved dead pane. Route through
* `$SHELL -i -l -c`, the same fix shell mode uses, so PATH is fully resolved first.
*
* ⚠️ The per-CLI half is now READ FROM THE REGISTRY (`overlays.remote`), not from a
* hardcoded `Record<RemoteCommandMode, string>`. The table it replaces duplicated the
* registry exactly, with nothing keeping the two in step — a capability that is both wrong
* and unread is worse than an absent one, because the next person trusts it. Notes that were
* attached to individual rows and are still true:
* - claude carries `--dangerously-skip-permissions` so the remote agent runs
* non-interactively (no trust-folder prompt nothing on the remote can answer);
* `overlays.remote.command` on the claude entry is where that now lives.
* - `dsh` alone boots nothing — the launcher needs a profile, and the remote box's profile
* inventory is unknown here. The per-host `commands.deepseek` override names one.
* The per-host `commands.*` override remains the escape hatch for every mode.
*/
export function defaultRemoteCommandForMode(mode: SessionMode): string {
// Agent CLIs (claude/opencode/codex/gemini/antigravity) are typically installed
// under per-user paths like ~/.local/bin or ~/.opencode/bin, added to PATH only by
// the remote user's interactive-login shell startup files (~/.zshrc etc.). ssh's
// remote-command execution is neither interactive nor login, so a bare `exec
// claude` sees only sshd's minimal default PATH and fails with "command not
// found" (exit 127) — confirmed via `tmux capture-pane` on the
// remain-on-exit-preserved dead pane. Route through `$SHELL -i -l -c`, the same
// fix already used for shell mode below, so PATH is fully resolved before the
// CLI name is looked up.
const commands: Record<RemoteCommandMode, string> = {
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's
// /etc/passwd entry, so this launches their actual login shell (zsh,
// fish, etc.). -i -l so it sources rc files (~/.zshrc etc.), matching
// the local shell-mode launch.
shell: `exec ${REMOTE_LOGIN_SHELL} -i -l`,
// Mirror the LOCAL claude default so the remote agent runs non-interactively
// (no trust-folder/permission prompt that nothing on the remote answers). The
// per-host `commands.claude` override stays the escape hatch.
claude: remoteLoginShellCommand('claude --dangerously-skip-permissions'),
opencode: remoteLoginShellCommand('opencode'),
codex: remoteLoginShellCommand('codex'),
gemini: remoteLoginShellCommand('gemini'),
antigravity: remoteLoginShellCommand('agy'),
pi: remoteLoginShellCommand('pi'),
grok: remoteLoginShellCommand('grok'),
// `dsh` alone boots nothing: the launcher needs a profile, and the remote box's
// profile inventory is unknown here. The per-host `commands.deepseek` override
// is the escape hatch for naming one.
deepseek: remoteLoginShellCommand('dsh'),
omp: remoteLoginShellCommand('omp'),
};
return commands[mode as RemoteCommandMode] || commands.shell;
// $SHELL, not a hardcoded bash: sshd sets it from the remote user's /etc/passwd entry, so
// this launches their actual login shell (zsh, fish, …). `-i -l` so it sources rc files,
// matching the local shell-mode launch. Not templatable as overlay data: the shell is
// whatever the REMOTE passwd says, which is why `shell` is the one arm still written here.
const shellCommand = `exec ${REMOTE_LOGIN_SHELL} -i -l`;
const cli = overlayCliCommand(mode, 'remote');
return cli === null ? shellCommand : remoteLoginShellCommand(cli);
}
export function remoteSshTarget(host: Pick<RemoteHost, 'username' | 'host'>): string {
@@ -264,19 +279,22 @@ export async function checkRemoteTmuxAvailable(
}
/**
* The CLI binary each session mode runs on the remote host. Antigravity's
* binary is `agy` (the mode name is not the command); shell has no CLI to
* probe, so it is absent.
* The CLI binary a session mode runs on the remote host, read from the registry rather than
* from a hardcoded map. `shell` has no CLI to probe and resolves to undefined, which is what
* makes the probe return null for it.
*
* ⚠️ Deriving this CHANGES BEHAVIOUR, deliberately and in one direction. The map it replaces
* listed claude/opencode/codex/gemini/antigravity/pi/omp and simply omitted `grok` and
* `deepseek` — its own comment said the rule was "every mode except shell", so the two were
* an oversight from when those CLIs were added, not a decision. A remote grok or deepseek
* session therefore reported no version at all. It now probes `grok --version` /
* `dsh --version` through the same login-shell wrapper as its siblings.
*
* (`antigravity` is why this cannot be the mode name: its binary is `agy`.)
*/
const REMOTE_CLI_BIN: Partial<Record<SessionMode, string>> = {
claude: 'claude',
opencode: 'opencode',
codex: 'codex',
gemini: 'gemini',
antigravity: 'agy',
pi: 'pi',
omp: 'omp',
};
function remoteCliBin(mode: SessionMode): string | undefined {
return getCli(mode)?.discovery.binaries[0];
}
/**
* Build the SSH command that reads the remote CLI's version (`claude --version`
@@ -292,7 +310,7 @@ export function buildRemoteCliVersionProbeCommand(
host: Pick<RemoteHost, 'username' | 'host' | 'port'> & RemoteSshOptions,
mode: SessionMode
): string | null {
const bin = REMOTE_CLI_BIN[mode];
const bin = remoteCliBin(mode);
if (!bin) return null;
return [
...buildSshConnectionArgs(host),
+202
View File
@@ -0,0 +1,202 @@
/**
* @fileoverview Bridges the legacy per-mode spawn options (`buildSpawnCommand`'s option bag
* in tmux-manager.ts, unchanged on the wire since before this registry existed) onto the CLI
* registry's generic argv engine (`renderLaunch`).
*
* The per-mode `<Mode>Config` objects on `POST /api/sessions` predate the registry and stay
* on the wire for API compatibility (`docs/versioning-policy.md`), so SOMETHING has to know
* which field holds which CLI's config. That knowledge is DATA — `launch.legacyConfigField`
* and `launch.legacyConfigAliases`, declared once per entry in `config/cli-registry/stock.ts`
* — which is what lets this file stay a generic reader rather than a `switch (mode)`.
*
* An entry declaring NO `legacyConfigField` reads its params straight off the top-level
* option bag. That is claude, whose discrete `claudeMode`/`allowedTools`/`model`/
* `resumeSessionId` fields predate the `<Mode>Config` pattern — not a special case for
* claude, just the other of the two shapes the wire has always had.
*
* @module session-cli-registry-bridge
*/
import type { CliEntry } from './config/cli-registry/types.js';
import { renderLaunch, type EngineValues, type ParamValues } from './config/cli-registry/argv.js';
import { matchesPattern } from './config/cli-registry/patterns.js';
import { buildEffortCliArgs, sanitizeCliSessionName } from './session-cli-builder.js';
import { compareVersions } from './utils/dependency-checker.js';
import { getClaudeCliVersion } from './utils/claude-cli-resolver.js';
import { launcherDefaultTarget } from './utils/cli-launcher.js';
import { getCli } from './config/cli-registry/registry.js';
import type {
AntigravityConfig,
ClaudeMode,
CodexConfig,
DeepSeekConfig,
EffortLevel,
GeminiConfig,
GrokConfig,
OmpConfig,
OpenCodeConfig,
PiConfig,
} from './types/session.js';
export interface SpawnBridgeOptions {
mode: string;
sessionId: string;
model?: string;
claudeMode?: ClaudeMode;
allowedTools?: string;
openCodeConfig?: OpenCodeConfig;
codexConfig?: CodexConfig;
geminiConfig?: GeminiConfig;
antigravityConfig?: AntigravityConfig;
piConfig?: PiConfig;
grokConfig?: GrokConfig;
deepSeekConfig?: DeepSeekConfig;
ompConfig?: OmpConfig;
resumeSessionId?: string;
effort?: EffortLevel;
sessionName?: string;
claudeCliVersion?: string | null;
}
/**
* The raw legacy config object this entry's params should be read from: the declared
* `<Mode>Config` field, or the option bag itself when none is declared.
*/
function legacyConfigFor(entry: CliEntry, options: SpawnBridgeOptions): Record<string, unknown> | undefined {
const field = entry.launch.legacyConfigField;
if (field === undefined) return options as unknown as Record<string, unknown>;
return (options as unknown as Record<string, unknown>)[field] as Record<string, unknown> | undefined;
}
/**
* Same lookup, addressed by mode rather than by entry, for callers holding only a mode and an
* option bag (tmux-manager's env configuration). Returns undefined for an unregistered mode.
*/
export function legacyConfigForMode(
mode: string,
options: Record<string, unknown>
): Record<string, unknown> | undefined {
const entry = getCli(mode);
if (!entry) return undefined;
return legacyConfigFor(entry, options as unknown as SpawnBridgeOptions);
}
/**
* Build `ParamValues` for every declared `token`/`bool`/`enum` param by reading it out of the
* legacy config object through `legacyConfigAliases` (falling back to the param's own name).
* `engine`-sourced params are skipped — those come from `EngineValues`, never legacy config.
*/
function buildParamsFromLegacyConfig(entry: CliEntry, rawConfig: Record<string, unknown> | undefined): ParamValues {
const params: ParamValues = {};
if (!rawConfig) return params;
const aliases = entry.launch.legacyConfigAliases ?? {};
for (const [paramName, spec] of Object.entries(entry.launch.params)) {
if (spec.type === 'engine') continue;
const legacyKey = aliases[paramName] ?? paramName;
const value = rawConfig[legacyKey];
if (value === undefined) continue;
// Anything that is not already a string or boolean is DROPPED rather than coerced: the
// wire shape is Zod-validated upstream, so a surprise here means something is wrong,
// and `String({})` would happily produce a token nobody intended.
if (typeof value === 'string' || typeof value === 'boolean') {
params[paramName] = value;
}
}
return params;
}
/**
* The env vars this CLI declares in `env.configSetenv`, resolved from its legacy config
* object — i.e. the ones whose value comes from the CALLER rather than the server's own
* environment.
*
* ⚠️ Re-validated here against the declared `ParamSpec` even though the wire shape is already
* Zod-checked upstream. These values reach `tmux setenv`, and for DeepSeek the value IS a
* permission level: a builder must never trust its caller on a security-relevant field, and
* the cost of re-checking an enum is nothing.
*
* A value that fails validation is DROPPED, not defaulted — which is the safe direction: the
* var goes unset, and the CLI falls back to its own default (for dsh, `workspace-write`,
* which asks) rather than to something we guessed.
*/
export function configSetenvValues(
entry: CliEntry,
rawConfig: Record<string, unknown> | undefined
): Record<string, string> {
const out: Record<string, string> = {};
const mappings = entry.env.configSetenv;
if (!mappings || !rawConfig) return out;
const aliases = entry.launch.legacyConfigAliases ?? {};
for (const { name, fromParam } of mappings) {
const spec = entry.launch.params[fromParam];
if (!spec) continue; // schema-validated at load; belt and braces
const raw = rawConfig[aliases[fromParam] ?? fromParam];
if (typeof raw !== 'string') continue;
if (spec.type === 'enum' && !spec.values.includes(raw)) continue;
if (spec.type === 'token' && !matchesPattern(spec.pattern, raw)) continue;
out[name] = raw;
}
return out;
}
/**
* Which `capabilities.gates` are currently satisfied. `resolveVersion` is called AT MOST
* ONCE, and only when the entry actually declares a gate — a `--version` subprocess probe
* has no reason to run for an entry with none.
*/
function resolveGatesPassed(entry: CliEntry, resolveVersion: () => string | null): Set<string> {
const passed = new Set<string>();
const gateEntries = Object.entries(entry.capabilities.gates);
if (gateEntries.length === 0) return passed;
const cliVersion = resolveVersion();
if (!cliVersion) return passed; // fail-closed: an unknown version satisfies no gate
for (const [name, gate] of gateEntries) {
if (compareVersions(cliVersion, gate.minVersion) >= 0) passed.add(name);
}
return passed;
}
/**
* Render the spawn command for `entry` from the legacy option bag. Returns `undefined` for a
* `shell`-kind entry (or any entry declaring no launch variants), which callers take as "fall
* back to the local login-shell resolution" — shell has no CLI to template.
*/
export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBridgeOptions): string | undefined {
if (entry.kind === 'shell' || entry.launch.variants.length === 0) return undefined;
const params = buildParamsFromLegacyConfig(entry, legacyConfigFor(entry, options));
const engineValues: EngineValues = {
sessionId: options.sessionId,
// Allowlist-sanitized (Unicode letters/digits + ` . _ : -`, 64 chars), matching
// buildNameCliArgs exactly — sanitizeCliSessionName is the injection guard for this
// value, NOT the `quote: 'double'` escaping on the --name arg (which only makes an
// unsafe value inert, it does not launder one into something meaningful).
sessionName: sanitizeCliSessionName(options.sessionName),
};
// Only a launcher CLI has one, and resolving it means a filesystem scan of the launcher's
// profile tree, so skip the lookup entirely for the eight entries that declare no profile.
if (entry.discovery.launcherProfile !== undefined) {
engineValues.launcherDefaultTarget = launcherDefaultTarget(entry) ?? undefined;
}
// Mirrors buildEffortCliArgs exactly: ultracode carries a fixed settings blob, every other
// level rides a plain `--effort <level>` flag. Reusing the canonical builder here (rather
// than re-deriving the ultracode special case) keeps the EFFORT_LEVELS allowlist and the
// settings-JSON shape single-sourced in session-cli-builder.ts.
const [effortFlag, effortValue] = buildEffortCliArgs(options.effort);
if (effortFlag === '--settings') engineValues.effortSettingsJson = effortValue;
else if (effortFlag === '--effort') engineValues.effortLevel = effortValue;
// Preserves buildSpawnCommand's original fallback exactly: an EXPLICIT `undefined` probes
// the local claude CLI (getClaudeCliVersion, null under vitest); an explicit `null` means
// "known to be unresolvable" and must not probe. The probe only ever runs from
// resolveGatesPassed, and only for an entry that actually declares a gate, so this stays
// generic without spawning a stray `claude --version` for every other CLI's launch.
const gatesPassed = resolveGatesPassed(entry, () =>
options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion()
);
return renderLaunch(entry.launch, params, engineValues, gatesPassed);
}
+71 -80
View File
@@ -105,6 +105,8 @@ import {
} from './config/buffer-limits.js';
import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js';
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
import { getCli } from './config/cli-registry/registry.js';
import { resolveSessionCliVersion } from './utils/cli-resolver.js';
import {
buildInteractiveArgs,
buildPromptArgs,
@@ -175,43 +177,50 @@ const CTRL_L_PATTERN = /\x0c/g;
/** Pattern to split by newlines (CR or LF) */
const NEWLINE_SPLIT_PATTERN = /\r?\n/;
/** True for external-CLI run modes (non-Claude) that use their own TUI and output format. */
/**
* True for external-CLI run modes (non-Claude) that use their own TUI and output format:
* no Claude transcript, no hooks, no Claude-format token/BashTool parsing.
*
* ⚠️ Reads its OWN capability flag rather than being derived from `hooks` or `kind`, and
* that independence is load-bearing. `shell` has no hooks but is NOT external, so a
* predicate derived from hooks would sweep it in here; `deepseek` HAS hooks but IS
* external. Deriving one of these three predicates from another has already shipped a bug
* (see CliCapabilities' own doc comment), which is why they are three separate fields.
*
* An UNREGISTERED mode is treated as external — the conservative answer, since it disables
* Claude-specific parsing rather than pointing it at output that was never Claude's.
*/
export function isExternalCliMode(mode: SessionMode): boolean {
return (
mode === 'opencode' ||
mode === 'codex' ||
mode === 'gemini' ||
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok' ||
mode === 'deepseek' ||
mode === 'omp'
);
return getCli(mode)?.capabilities.external ?? true;
}
/** Display name for a run mode. Falls back to the raw id for an unregistered one. */
function getModeLabel(mode: SessionMode): string {
switch (mode) {
case 'opencode':
return 'OpenCode';
case 'codex':
return 'Codex';
case 'gemini':
return 'Gemini';
case 'antigravity':
return 'Antigravity';
case 'pi':
return 'Pi';
case 'grok':
return 'Grok';
case 'deepseek':
return 'DeepSeek';
case 'omp':
return 'OMP';
case 'shell':
return 'Shell';
case 'claude':
return 'Claude';
}
return getCli(mode)?.label ?? mode;
}
/**
* Does this CLI's launch spec gate anything on its own version?
*
* Only such a CLI needs its version probed at session start — probing one with no gates
* would spawn a `--version` subprocess whose answer nothing reads. Today that is claude
* (the `--name` flag, gated at 2.1.224), which is why the probe used to be written as
* `mode === 'claude'`.
*/
function cliNeedsVersionProbe(mode: SessionMode): boolean {
return Object.keys(getCli(mode)?.capabilities.gates ?? {}).length > 0;
}
/**
* Does this CLI ask for `COLORTERM=truecolor`?
*
* Read off the SAME `env.exports` list that `buildEnvExports()` emits into the tmux
* session, so the attach client and the pane cannot disagree about colour depth. These
* used to be two hand-maintained lists of mode names in two files that had to be edited
* together, with a comment in each asking the next person to remember.
*/
function cliExportsTruecolor(mode: SessionMode): boolean {
return (getCli(mode)?.env.exports ?? []).some((entry) => entry.name === 'COLORTERM' && entry.value === 'truecolor');
}
/**
@@ -240,7 +249,7 @@ function getModeLabel(mode: SessionMode): string {
* vim inside a tmux `shell` session.
*/
export function isAltScreenStripMode(mode: SessionMode): boolean {
return mode === 'codex' || mode === 'claude' || mode === 'gemini';
return getCli(mode)?.capabilities.altScreen === 'strip-full';
}
/**
@@ -1564,16 +1573,11 @@ export class Session extends EventEmitter {
cols: ptyCols,
rows: ptyRows,
cwd: resolveMuxAttachCwd(this.workingDir, this._remote, this._docker),
// COD-75: codex/gemini/antigravity/pi get COLORTERM=truecolor — mirrors buildEnvExports()
// in tmux-manager.ts so the attach client and the tmux session agree.
env: buildMuxAttachEnv(
this.mode === 'codex' ||
this.mode === 'gemini' ||
this.mode === 'antigravity' ||
this.mode === 'pi' ||
this.mode === 'grok' ||
this.mode === 'deepseek'
),
// COD-75: a CLI that declares `export COLORTERM=truecolor` gets it on the ATTACH
// client too. Both sides read the same registry entry, which is what stops the
// attach client and the tmux session from disagreeing — they used to be two
// hand-maintained lists of mode names that had to be edited in lockstep.
env: buildMuxAttachEnv(cliExportsTruecolor(this.mode)),
})
);
} catch (spawnErr) {
@@ -1677,7 +1681,9 @@ export class Session extends EventEmitter {
* session that already carries an explicit id pass through untouched.
*/
private _pinOmpRespawnId(): void {
if (this.mode !== 'omp') return;
// The omp-jsonl transcript reader is what this pin exists to feed, so ask for the
// reader rather than for the CLI's name.
if (getCli(this.mode)?.capabilities.transcript !== 'omp-jsonl') return;
if (this._ompConfig?.resumeSessionId) return;
// Callers MUST call this only immediately before an ACTUAL respawn (a
// confirmed-dead pane, or a genuine remote reattach) — never while merely
@@ -1810,7 +1816,11 @@ export class Session extends EventEmitter {
// `Saved to: file://...` — that scanner (and its relaxed trust policy) is
// only enabled for codex-mode sessions. The web server applies the trust
// boundary for each request source.
const attachmentRequests = parseTerminalAttachmentRequests(data, { codexArtifacts: this.mode === 'codex' });
// Codex is the only CLI that announces generated artifacts in its pane output, and it
// is also the only one whose transcript is a rollout file — one implies the other.
const attachmentRequests = parseTerminalAttachmentRequests(data, {
codexArtifacts: getCli(this.mode)?.capabilities.transcript === 'codex-rollout',
});
for (const request of attachmentRequests) {
const seenKey = `${request.source}:${request.path}`;
if (this._attachmentMagicSeen.has(seenKey)) continue;
@@ -1874,8 +1884,8 @@ export class Session extends EventEmitter {
// repaint/alt-screen mode; issue #154). Remote sessions run claude on
// another host, so a local probe wouldn't reflect their version; they get
// their own over-ssh probe below. Cached process-wide, best-effort.
if (this.mode === 'claude' && !this._remote && !this._docker && !this._cliVersion) {
const probedVersion = getClaudeCliVersion();
if (cliNeedsVersionProbe(this.mode) && !this._remote && !this._docker && !this._cliVersion) {
const probedVersion = resolveSessionCliVersion(this.mode);
if (probedVersion) {
this._cliVersion = probedVersion;
this.emit('cliInfoUpdated', {
@@ -1891,7 +1901,7 @@ export class Session extends EventEmitter {
// reports the HOST claude (wrong version, and leaving cliVersion undefined
// silently disables wheel-forwarding, #154). Probe the IN-CONTAINER version
// instead — deferred so the container is up after the mux attach below.
if (this.mode === 'claude' && this._docker && !this._cliVersion) {
if (cliNeedsVersionProbe(this.mode) && this._docker && !this._cliVersion) {
const dockerMeta = this._docker;
setTimeout(() => {
if (this._isStopped || this._cliVersion) return;
@@ -1917,7 +1927,7 @@ export class Session extends EventEmitter {
// is the unreliable path #154 was filed for, so remote Claude cases silently
// never got wheel-forwarding (noted in the #205 analysis). Probe over ssh,
// deferred so session start never waits on the ssh round-trip.
if (this.mode === 'claude' && this._remote && !this._cliVersion) {
if (cliNeedsVersionProbe(this.mode) && this._remote && !this._cliVersion) {
const remoteMeta = this._remote;
setTimeout(() => {
if (this._isStopped || this._cliVersion) return;
@@ -2036,35 +2046,16 @@ export class Session extends EventEmitter {
// Fallback to direct PTY if mux is not used
if (!this.ptyProcess) {
// OpenCode sessions require tmux for env var injection (API keys via setenv)
if (this.mode === 'opencode') {
throw new Error('OpenCode sessions require tmux. Direct PTY fallback is not supported.');
}
// Codex sessions require tmux for OPENAI_API_KEY injection via setenv
if (this.mode === 'codex') {
throw new Error('Codex sessions require tmux. Direct PTY fallback is not supported.');
}
// Gemini sessions require tmux for Gemini/Google auth env injection via setenv
if (this.mode === 'gemini') {
throw new Error('Gemini sessions require tmux. Direct PTY fallback is not supported.');
}
// Antigravity sessions require tmux for env override injection via setenv
if (this.mode === 'antigravity') {
throw new Error('Antigravity sessions require tmux. Direct PTY fallback is not supported.');
}
// Pi sessions require tmux for env override injection via setenv
if (this.mode === 'pi') {
throw new Error('Pi sessions require tmux. Direct PTY fallback is not supported.');
}
// Grok sessions require tmux for XAI_API_KEY / GROK_* injection via setenv
if (this.mode === 'grok') {
throw new Error('Grok sessions require tmux. Direct PTY fallback is not supported.');
}
// DeepSeek sessions require tmux for DEEPSEEK_API_KEY / DSH_PERMISSION_MODE
// injection via setenv — and for the HERDR_* status-bridge triple, without
// which the mode silently loses its definitive idle/blocked signals.
if (this.mode === 'deepseek') {
throw new Error('DeepSeek Harness sessions require tmux. Direct PTY fallback is not supported.');
// Every external CLI requires tmux and has NO direct-PTY fallback, because its
// secrets are injected with socket-scoped `tmux setenv` and so must never touch a
// spawn command line. DeepSeek additionally needs it for the HERDR_* status-bridge
// triple, without which the mode silently loses its definitive idle/blocked signals.
//
// Refusing is the only safe answer: falling back to a direct PTY would start the CLI
// unauthenticated (or, worse, tempt a future change into passing the key as an
// argument, where every process on the box can read it).
if (getCli(this.mode)?.capabilities.requiresMux) {
throw new Error(`${getModeLabel(this.mode)} sessions require tmux. Direct PTY fallback is not supported.`);
}
try {
// Pass --session-id to use the SAME ID as the Codeman session
@@ -2442,7 +2433,7 @@ export class Session extends EventEmitter {
* this capture or a resume/respawn that already resolved one).
*/
private _maybeCaptureOmpSessionId(): void {
if (this.mode !== 'omp' || this._claudeSessionId !== this.id) return;
if (getCli(this.mode)?.capabilities.transcript !== 'omp-jsonl' || this._claudeSessionId !== this.id) return;
try {
const resolvedId = resolveAndClaimOmpSessionId(this.workingDir);
if (resolvedId) {
+202 -674
View File
@@ -59,7 +59,14 @@ import {
type SessionDocker,
type DockerCommandMode,
} from './types.js';
import { buildEffortCliArgs, buildNameCliArgs } from './session-cli-builder.js';
import { getCli } from './config/cli-registry/registry.js';
import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js';
import {
buildSpawnCommandFromRegistry,
configSetenvValues,
legacyConfigForMode,
} from './session-cli-registry-bridge.js';
import type { CliEntry } from './config/cli-registry/types.js';
import {
buildSshConnectionArgs,
defaultRemoteCommandForMode,
@@ -80,32 +87,7 @@ import {
type DockerMount,
type DockerSeedCopy,
} from './docker-hosts.js';
import {
wrapWithNice,
SAFE_PATH_PATTERN,
findClaudeDir,
getClaudeCliVersion,
getClaudeNotFoundMessage,
resolveOpenCodeDir,
getOpenCodeNotFoundMessage,
resolveCodexDir,
getCodexNotFoundMessage,
resolveGeminiDir,
getGeminiNotFoundMessage,
resolveAntigravityDir,
getAntigravityNotFoundMessage,
resolvePiDir,
getPiNotFoundMessage,
resolveGrokDir,
getGrokNotFoundMessage,
resolveDeepSeekDir,
getDeepSeekNotFoundMessage,
resolveDefaultDeepSeekProfile,
getOmpNotFoundMessage,
resolveOmpDir,
resolveLocalShell,
loginShellArgs,
} from './utils/index.js';
import { wrapWithNice, SAFE_PATH_PATTERN, resolveLocalShell, loginShellArgs } from './utils/index.js';
import type {
TerminalMultiplexer,
MuxSession,
@@ -649,315 +631,17 @@ function buildClaudePermissionFlags(claudeMode?: ClaudeMode, allowedTools?: stri
}
/**
* Build the opencode CLI command with appropriate flags.
*/
function buildOpenCodeCommand(config?: OpenCodeConfig): string {
const parts = ['opencode'];
// Model selection — allow provider/model format (alphanumeric, dots, hyphens, slashes)
if (config?.model) {
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
if (safeModel) parts.push('--model', safeModel);
}
// Continue existing session
if (config?.continueSession) {
const safeId = /^[a-zA-Z0-9_-]+$/.test(config.continueSession) ? config.continueSession : undefined;
if (safeId) parts.push('--session', safeId);
if (safeId && config.forkSession) parts.push('--fork');
}
return parts.join(' ');
}
/**
* Build the codex CLI command with appropriate flags.
* Build the codex CLI command.
*
* Codeman launches Codex's native TUI and handles replay/scrollback by
* stripping destructive terminal sequences before xterm.js sees them.
* Kept as a named wrapper purely because callers (and `test/tmux-manager.test.ts`) reach for
* it directly; the command itself is registry data now, like every other CLI's. The `??`
* fallback covers a registry in which codex has been disabled or removed — this function
* promises a string, so it degrades to the bare binary rather than throwing.
*/
export function buildCodexCommand(config?: CodexConfig): string {
const parts = ['codex'];
if (config?.dangerouslyBypassApprovals) {
parts.push('--dangerously-bypass-approvals-and-sandbox');
}
if (config?.animations !== undefined) {
parts.push('--config', `tui.animations=${config.animations ? 'true' : 'false'}`);
}
if (config?.model) {
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
if (safeModel) parts.push('--model', safeModel);
}
if (config?.resumeSessionId) {
const safeId = /^[a-zA-Z0-9_-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
if (safeId) parts.push('resume', safeId);
}
return parts.join(' ');
}
/**
* Build the Gemini CLI command with appropriate flags.
*
* `--skip-trust` avoids a first-run workspace trust prompt inside Codeman.
* Approval mode defaults to `yolo` for parity with Codeman's Claude default
* of `--dangerously-skip-permissions`; users can override it later through
* Gemini config once Codeman exposes richer Gemini settings.
*/
function buildGeminiCommand(config?: GeminiConfig): string {
const parts = ['gemini', '--skip-trust'];
const approvalMode = config?.approvalMode || 'yolo';
if (['default', 'auto_edit', 'yolo', 'plan'].includes(approvalMode)) {
parts.push('--approval-mode', approvalMode);
}
if (config?.model) {
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
if (safeModel) parts.push('--model', safeModel);
}
if (config?.resumeSession) {
const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeSession) ? config.resumeSession : undefined;
if (safeId) parts.push('--resume', safeId);
}
return parts.join(' ');
}
/**
* Build the Antigravity CLI (agy) command with appropriate flags.
*
* Unlike gemini's yolo default, `--dangerously-skip-permissions` is only added
* when the config explicitly asks for it (the frontend sends it for parity with
* Codeman's Claude default; the multi-user clamp strips it for non-granted owners,
* and an ABSENT config stays at agy's own prompting default — safe like Codex).
*/
function buildAntigravityCommand(config?: AntigravityConfig): string {
const parts = ['agy'];
if (config?.dangerouslySkipPermissions) {
parts.push('--dangerously-skip-permissions');
}
if (config?.model) {
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
if (safeModel) parts.push('--model', safeModel);
}
if (config?.resumeConversationId) {
const safeId = /^[a-zA-Z0-9._-]+$/.test(config.resumeConversationId) ? config.resumeConversationId : undefined;
if (safeId) parts.push('--conversation', safeId);
}
return parts.join(' ');
}
/** Pi's `--thinking` levels. Runtime allowlist — defense in depth beyond the Zod enum. */
const PI_THINKING_LEVELS = new Set(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
/**
* Build the Pi CLI (pi.dev) command with appropriate flags.
*
* Pi has NO permission prompts and no `--dangerously-skip-permissions` analog, so
* there is deliberately nothing bypass-shaped here. The privileged knob is the
* TRI-STATE `approveProjectTrust`: `true` -> `--approve` (trust repo-local `.pi/`
* config, which means loading and EXECUTING repository TypeScript and installing
* missing project packages), `false` -> `--no-approve` (force-deny, used by the
* multi-user clamp so the trust prompt never appears), absent -> pi's own
* `defaultProjectTrust`.
*
* `--api-key` is deliberately NEVER wired: it would put a provider secret on the
* spawn command line (visible in `ps` and tmux state), which is exactly what the
* socket-scoped `tmux setenv` discipline exists to prevent.
*
* Like the sibling builders, every user value is regex-allowlisted and silently
* DROPPED on failure — the result is interpolated into a `bash -c "..."` string.
*/
function buildPiCommand(config?: PiConfig): string {
const parts = ['pi'];
if (config?.approveProjectTrust === true) {
parts.push('--approve');
} else if (config?.approveProjectTrust === false) {
parts.push('--no-approve');
}
if (config?.model) {
// `:` for a thinking suffix (`sonnet:high`), `/` for `provider/id` (`openai/gpt-4o`).
const safeModel = /^[a-zA-Z0-9._\-/:]+$/.test(config.model) ? config.model : undefined;
if (safeModel) parts.push('--model', safeModel);
}
if (config?.provider) {
const safeProvider = /^[a-z0-9-]+$/.test(config.provider) ? config.provider : undefined;
if (safeProvider) parts.push('--provider', safeProvider);
}
if (config?.thinking && PI_THINKING_LEVELS.has(config.thinking)) {
parts.push('--thinking', config.thinking);
}
// --session and -c conflict; a valid explicit session id wins.
const safeSessionId =
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
if (safeSessionId) {
parts.push('--session', safeSessionId);
} else if (config?.continueSession) {
parts.push('-c');
}
return parts.join(' ');
}
/**
* Build the Grok Build CLI (xAI `grok`) command with appropriate flags.
*
* The bypass switch is `--always-approve` ("auto-approve all tool executions",
* grok's `bypassPermissions` permission mode; config-level deny rules still
* apply on top). Absent config spawns bare `grok`, i.e. grok's own default
* ask-mode, which is why the multi-user clamp only needs the only-if-sent
* branch for grok. Flag surface verified against grok 1.0.5.
*
* `XAI_API_KEY` is deliberately never wired as a flag: secrets flow through
* socket-scoped `tmux setenv` (envOverrides), never the spawn command line.
*
* Like the sibling builders, every user value is regex-allowlisted and silently
* DROPPED on failure: the result is interpolated into a `bash -c "..."` string.
*/
function buildGrokCommand(config?: GrokConfig): string {
const parts = ['grok'];
if (config?.alwaysApprove) {
parts.push('--always-approve');
}
if (config?.model) {
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
if (safeModel) parts.push('--model', safeModel);
}
// --resume and -c conflict; a valid explicit session id wins. Ids only:
// grok's --resume also accepts session TITLES, which are arbitrary user
// strings, so the id regex doubles as the no-titles rule here.
const safeSessionId =
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
if (safeSessionId) {
parts.push('--resume', safeSessionId);
} else if (config?.continueSession) {
parts.push('--continue');
}
return parts.join(' ');
}
/**
* Build the DeepSeek Harness (`dsh`) command with appropriate flags.
*
* Unlike every sibling builder, the interesting decision here is not a flag but
* WHICH PROFILE to boot: `dsh` is a launcher over `$DSH_HOME/profiles/<name>`,
* and DeepSeek ships no interactive terminal profile of its own, so the agent a
* pane runs is always one the user installed. An absent `profile` resolves to
* the first pane-capable profile on the box; when there is none we still emit a
* bare `dsh --profile <default>` rather than inventing a name, because the
* availability gate in createSession() has already refused the spawn by then and
* this path only runs for a session that passed it.
*
* There is deliberately NO permission flag: the harness has none. The sandbox
* and approval rows read `DSH_PERMISSION_MODE`, exported through `tmux setenv`
* in buildEnvExports() so it never lands on this command line.
*
* Like the sibling builders, every user value is regex-allowlisted and silently
* DROPPED on failure: the result is interpolated into a `bash -c "..."` string.
*/
function buildDeepSeekCommand(config?: DeepSeekConfig): string {
const parts = ['dsh'];
// A profile name is a single path segment: it is both interpolated into the
// shell line and joined into a filesystem path.
const requested = config?.profile;
const safeProfile =
requested && /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(requested)
? requested
: (resolveDefaultDeepSeekProfile() ?? undefined);
if (safeProfile) parts.push('--profile', safeProfile);
// The launcher forwards everything after its own flags to the profile's app,
// which is where `--resume` is understood. An explicit id wins over the
// most-recent-session form, mirroring the sibling builders.
const safeSessionId =
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
if (safeSessionId) {
parts.push('--resume', safeSessionId);
} else if (config?.resumeSession) {
parts.push('--resume');
}
return parts.join(' ');
}
/**
* Build the OMP CLI command with appropriate flags.
*
* omp reads its model routing and hooks from ~/.omp (agent dir), so no
* trust/permission flags are needed: the CLI's own config governs. The only
* CLI flags passed are the per-session overrides Codeman knows about.
*/
function buildOmpCommand(config?: OmpConfig): string {
const parts = ['omp'];
if (config?.model) {
const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
if (safeModel) parts.push('--model', safeModel);
}
// --resume and --continue conflict; a valid explicit session id wins,
// mirroring the sibling builders (grok/pi/opencode).
const safeId =
config?.resumeSessionId && /^[a-zA-Z0-9._-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
if (safeId) {
parts.push('--resume', safeId);
} else if (config?.continueSession) {
parts.push('--continue');
}
return parts.join(' ');
}
/**
* Build the spawn command for any session mode.
* Shared by createSession() and respawnPane() to avoid duplication.
*/
/**
* Build the shell fragment carrying the effort level as a SOFT default
* (see buildEffortCliArgs — `--effort <level>` for regular levels incl. max,
* `--settings '{"ultracode":true}'` for ultracode; deliberately not the
* CLAUDE_CODE_EFFORT_LEVEL env var, which hard-locks /effort switching).
*
* Injection-safe: effort is validated against the EFFORT_LEVELS allowlist inside
* buildEffortCliArgs, so the single-quoted values contain no user-controlled characters.
*/
function buildEffortSettingsFlag(effort?: EffortLevel): string {
const [flag, value] = buildEffortCliArgs(effort);
return flag && value ? ` ${flag} '${value}'` : '';
}
/**
* Build the ` --name "<session name>"` shell fragment, or '' when it must be
* omitted. Version-gated FAIL-CLOSED in buildNameCliArgs (an older/unknown CLI
* aborts startup on an unknown flag, which would kill every claude spawn), and
* the value is allowlist-sanitized there, so it contains none of the characters
* that are special inside this double-quoted interpolation. The peer name is a
* soft default (in-session /rename still wins), which is why this rides the
* spawn command rather than any persisted config.
*/
function buildClaudeNameFlag(sessionName: string | undefined, cliVersion: string | null): string {
const [flag, value] = buildNameCliArgs(sessionName, cliVersion);
return flag && value ? ` ${flag} "${value}"` : '';
const entry = getCli('codex');
if (!entry) return 'codex';
return buildSpawnCommandFromRegistry(entry, { mode: 'codex', sessionId: '', codexConfig: config }) ?? 'codex';
}
export function buildSpawnCommand(options: {
@@ -986,51 +670,14 @@ export function buildSpawnCommand(options: {
*/
claudeCliVersion?: string | null;
}): string {
if (options.mode === 'claude') {
// Validate model to prevent command injection
const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
const effortFlag = buildEffortSettingsFlag(options.effort);
const nameFlag = buildClaudeNameFlag(
options.sessionName,
options.claudeCliVersion !== undefined ? options.claudeCliVersion : getClaudeCliVersion()
);
// Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
// Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
// fall back to a new session with --session-id so the pane doesn't die.
const safeResumeId =
options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined;
const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools);
if (safeResumeId) {
const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}${nameFlag}`;
const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}${nameFlag}`;
return `${resumeCmd} || ${fallbackCmd}`;
}
return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}${nameFlag}`;
}
if (options.mode === 'opencode') {
return buildOpenCodeCommand(options.openCodeConfig);
}
if (options.mode === 'codex') {
return buildCodexCommand(options.codexConfig);
}
if (options.mode === 'gemini') {
return buildGeminiCommand(options.geminiConfig);
}
if (options.mode === 'antigravity') {
return buildAntigravityCommand(options.antigravityConfig);
}
if (options.mode === 'pi') {
return buildPiCommand(options.piConfig);
}
if (options.mode === 'grok') {
return buildGrokCommand(options.grokConfig);
}
if (options.mode === 'deepseek') {
return buildDeepSeekCommand(options.deepSeekConfig);
}
if (options.mode === 'omp') {
return buildOmpCommand(options.ompConfig);
// Every CLI's command shape is registry DATA, rendered by the argv engine — see
// config/cli-registry/argv.ts for why config can never contain shell text. A `shell`-kind
// entry (or an unregistered mode) renders `undefined` and falls through to the local
// login-shell resolution below, which cannot be templated because it varies per user.
const entry = getCli(options.mode);
if (entry) {
const rendered = buildSpawnCommandFromRegistry(entry, options);
if (rendered !== undefined) return rendered;
}
// #208: NOT the literal '$SHELL'. This string is embedded in the `bash -c "…"`
// argument of the respawn-pane line, which execSync runs through `/bin/sh -c`,
@@ -1237,23 +884,15 @@ const RESUME_ID_SAFE = /^[A-Za-z0-9._-]+$/;
*/
function appendResumeFlag(modeCommand: string, mode: SessionMode, resumeId: string): string {
if (!RESUME_ID_SAFE.test(resumeId)) return modeCommand;
switch (mode) {
case 'gemini':
return `${modeCommand} --resume ${resumeId}`;
case 'codex':
return `${modeCommand} resume ${resumeId}`;
case 'antigravity':
return `${modeCommand} --conversation ${resumeId}`;
case 'pi':
return `${modeCommand} --session ${resumeId}`;
case 'grok':
return `${modeCommand} --resume ${resumeId}`;
case 'deepseek':
case 'omp':
return `${modeCommand} --resume ${resumeId}`;
default:
return modeCommand; // shell / opencode: no resume
}
// The append-only sibling of the full launch spec: this bolts a resume onto an ALREADY
// built command, for the docker "the in-container tmux was re-created" path. An entry with
// no `resumeAppend` has no resume form to append (shell, opencode — opencode's docker
// resume rides its own config object instead).
const append = getCli(mode)?.launch.resumeAppend;
if (!append) return modeCommand;
return append.style === 'flag'
? `${modeCommand} ${append.flag} ${resumeId}`
: `${modeCommand} ${append.token} ${resumeId}`;
}
/**
@@ -1509,12 +1148,7 @@ export function resolveDockerLaunchOptions(
};
// NAME-ONLY exec env forwarded from Codeman's process env (the docker client
// inherits it), so API-key CLIs get their key without it appearing in argv.
const execEnvNames =
mode === 'codex'
? ['OPENAI_API_KEY', 'CODEX_API_KEY']
: mode === 'gemini'
? ['GEMINI_API_KEY', 'GOOGLE_API_KEY']
: [];
const execEnvNames = getCli(mode)?.env.dockerExecEnvNames ?? [];
return { mode, docker, sessionId, resumeSessionId, createContext, execEnv, execEnvNames, seedCopies };
}
@@ -1570,89 +1204,89 @@ function buildRemoteSessionCommand(options: {
}
/**
* Set sensitive environment variables on a tmux session via setenv.
* These are inherited by panes but not visible in ps output or tmux history.
* Push one environment variable into a tmux session with `setenv`.
*
* ⚠️ `setenv` rather than the spawn command line is the whole point: a value set this way is
* inherited by panes but never appears in `ps` output or tmux history, so an API key cannot
* be read by every other process on the box. Nothing that carries a secret may move to the
* command line.
*
* A failure is deliberately swallowed — a key the CLI does not need is not an error, and a
* CLI that does need it will say so far more usefully than a spawn failure here would.
*/
function setOpenCodeEnvVars(tmuxCmd: string, muxName: string): void {
const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
for (const key of sensitiveVars) {
const val = process.env[key];
if (val) {
// Shell-escape: wrap in single quotes, escape any inner single quotes
const escaped = val.replace(/'/g, "'\\''");
try {
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
/* Non-critical — key may not be needed */
}
}
function setTmuxEnvVar(tmuxCmd: string, muxName: string, key: string, value: string): void {
// Shell-escape: wrap in single quotes, escape any inner single quotes.
const escaped = value.replace(/'/g, "'\\''");
try {
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
/* Non-critical — key may not be needed */
}
}
/**
* Set sensitive environment variables for Codex on a tmux session via setenv.
* Codex (OpenAI CLI) needs OPENAI_API_KEY; we also forward CODEX_* keys.
* Forward this CLI's declared sensitive env vars from the SERVER's own environment into the
* tmux session. Names come from `env.tmuxSetenvKeys`; values are never in config.
*
* Was three near-identical per-CLI functions whose only difference was the key list.
*/
function setCodexEnvVars(tmuxCmd: string, muxName: string): void {
const sensitiveVars = ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_HOME'];
for (const key of sensitiveVars) {
function setCliSensitiveEnvVars(tmuxCmd: string, muxName: string, keys: readonly string[]): void {
for (const key of keys) {
const val = process.env[key];
if (val) {
const escaped = val.replace(/'/g, "'\\''");
try {
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
/* Non-critical — key may not be needed */
}
}
if (val) setTmuxEnvVar(tmuxCmd, muxName, key, val);
}
}
/**
* Set sensitive environment variables for Gemini on a tmux session via setenv.
* Gemini Pro/Ultra users usually authenticate via cached Google login; these
* variables cover API-key and Vertex AI paths without putting secrets in ps.
* Implementations of the named profiles a CLI may select via `env.setenvProfile` — the escape
* hatch for setup that genuinely needs to RUN CODE rather than name a list of env keys.
*
* Keyed by PROFILE NAME, never by CLI id: a second launcher-style CLI adds an entry here and
* names it from its registry entry, and nothing else in this file learns about it. The names
* themselves are declared (and schema-validated at load) in `config/cli-registry/profiles.ts`.
*
* Returns the env vars to set; the caller does the actual `tmux setenv` calls.
*/
function setGeminiEnvVars(tmuxCmd: string, muxName: string): void {
const sensitiveVars = [
'GEMINI_API_KEY',
'GEMINI_MODEL',
'GOOGLE_API_KEY',
'GOOGLE_CLOUD_PROJECT',
'GOOGLE_CLOUD_LOCATION',
'GOOGLE_APPLICATION_CREDENTIALS',
'GOOGLE_GENAI_USE_VERTEXAI',
];
for (const key of sensitiveVars) {
const val = process.env[key];
if (val) {
const escaped = val.replace(/'/g, "'\\''");
try {
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
/* Non-critical — key may not be needed */
}
}
}
}
const SETENV_PROFILES: Record<
string,
(sessionId: string, entry: CliEntry, rawConfig?: Record<string, unknown>) => Record<string, string>
> = {
/**
* DeepSeek's Herdr-compatible status bridge.
*
* Pointing `HERDR_BIN_PATH` at our own generated shim is what upgrades this mode from
* output-stabilization guessing to DEFINITIVE idle/working/blocked events (see
* deepseek-status-shim.ts). The pane id IS the Codeman session id, which is how the shim
* attributes a report without trusting anything the agent could influence.
*
* Needs a profile rather than key names because it writes an executable to disk and then
* exports that file's path — neither a name list nor a config value could express it.
*/
'deepseek-status-bridge': (sessionId, entry, rawConfig) => {
// Opt-OUT, not opt-in: an absent flag means the bridge is armed, so a caller who says
// nothing gets the better signals. Only an explicit `false` disarms it, which is exactly
// what `hooksAvailableForMode()` reads to decide whether `stop` can ever fire.
const field = entry.launch.legacyConfigAliases?.statusReporting ?? 'statusReporting';
if (rawConfig?.[field] === false) return {};
const shim = ensureDeepSeekStatusShim();
if (!shim) return {};
const vars: Record<string, string> = { HERDR_ENV: '1', HERDR_BIN_PATH: shim, HERDR_PANE_ID: sessionId };
return vars;
},
};
/**
* Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
* Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
* Set a CLI's JSON config-content env var on a tmux session via setenv.
*
* The var NAME comes from `env.configContentVar` rather than being hardcoded, so this is not
* an opencode special case — but opencode is its only user today. `setenv` (rather than the
* command line) is what keeps user-supplied JSON away from shell metacharacter parsing.
*/
function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, config?: OpenCodeConfig): void {
function setCliConfigContent(tmuxCmd: string, muxName: string, varName: string, config?: OpenCodeConfig): void {
if (!config) return;
let jsonContent: string | undefined;
@@ -1680,18 +1314,7 @@ function setOpenCodeConfigContent(tmuxCmd: string, muxName: string, config?: Ope
}
}
if (jsonContent) {
const escaped = jsonContent.replace(/'/g, "'\\''");
try {
execSync(`${tmuxCmd} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
/* Non-critical */
}
}
if (jsonContent) setTmuxEnvVar(tmuxCmd, muxName, varName, jsonContent);
}
/**
@@ -1860,33 +1483,36 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* command line (visible in `ps`). This also sidesteps shell-metachar injection via keys.
*/
private buildEnvExports(sessionId: string, muxName: string, mode: SessionMode): string[] {
const exports = [
const entry = getCli(mode);
// Per-CLI colour/identity vars, straight from the entry. `unset` before `export` is
// arbitrary: these are independent bash statements joined by ` && `, so nothing here
// depends on another's value and the order carries no semantics.
const cliEnv: string[] = [];
for (const name of entry?.env.unset ?? []) cliEnv.push(`unset ${name}`);
for (const item of entry?.env.exports ?? []) {
// Values are either literals validated against the shell-token pattern at load, or an
// engine value produced here — never free text from config.
const value =
typeof item.value === 'string'
? item.value
: item.value.engine === 'codemanPrefixedSessionId'
? `codeman_${sessionId}`
: item.value.engine === 'sessionId'
? sessionId
: item.value.engine === 'muxName'
? muxName
: undefined;
// A CLI stamping a per-pane originator (codex) is what lets the response viewer find
// THIS pane's rollout exactly; without it, rollouts are matched by cwd+mtime and two
// panes in the same directory bleed into each other.
if (value !== undefined) cliEnv.push(`export ${item.name}=${value}`);
}
return [
'export LANG=en_US.UTF-8',
'export LC_ALL=en_US.UTF-8',
mode === 'codex' ||
mode === 'gemini' ||
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok' ||
mode === 'deepseek' ||
mode === 'omp'
? 'export COLORTERM=truecolor'
: 'unset COLORTERM',
...(mode === 'codex' ||
mode === 'gemini' ||
mode === 'antigravity' ||
mode === 'pi' ||
mode === 'grok' ||
mode === 'deepseek' ||
mode === 'omp'
? ['unset NO_COLOR']
: []),
// Stamp each Codex pane with a unique originator so the response-viewer
// can locate THIS pane's rollout exactly — codex writes the value into
// session_meta.originator of every rollout it creates. Without it,
// rollouts are matched by cwd+mtime and two panes in the same directory
// bleed into each other.
...(mode === 'codex' ? [`export CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_${sessionId}`] : []),
...cliEnv,
'export CODEMAN_MUX=1',
`export CODEMAN_SESSION_ID=${sessionId}`,
`export CODEMAN_MUX_NAME=${muxName}`,
@@ -1899,9 +1525,6 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// execution time, so the COD-54 hook secret stays off the command line.
`export CODEMAN_HOOK_SECRET_FILE="${dataPath('hook-secret')}"`,
];
// Only unset CLAUDECODE for Claude sessions
if (mode === 'claude') exports.splice(2, 0, 'unset CLAUDECODE');
return exports;
}
/**
@@ -1951,127 +1574,65 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
* In createSession(), a missing binary dir throws — the caller handles that separately.
*/
private buildPathExport(mode: SessionMode): { pathExport: string; dir: string | null } {
if (mode === 'claude') {
const dir = findClaudeDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'opencode') {
const dir = resolveOpenCodeDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'codex') {
const dir = resolveCodexDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'gemini') {
const dir = resolveGeminiDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'antigravity') {
const dir = resolveAntigravityDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'pi') {
const dir = resolvePiDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'grok') {
const dir = resolveGrokDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'deepseek') {
const dir = resolveDeepSeekDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
if (mode === 'omp') {
const dir = resolveOmpDir();
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
return { pathExport: '', dir: null };
// Prepending the resolved bin dir is what makes a CLI installed somewhere the server's
// own PATH does not cover (nvm, Homebrew, ~/.local/bin under a systemd unit) reachable
// from inside the pane. `shell` and any unregistered mode resolve to null and get
// nothing prepended.
const dir = resolveCliBinDir(mode);
return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
}
/**
* Configure OpenCode-specific environment on a tmux session.
* Sets sensitive API keys and config content via tmux setenv
* (not visible in ps output or tmux history, inherited by panes).
* Configure this CLI's environment on a tmux session, entirely from registry data.
*
* Four independent pieces, all via `tmux setenv` so they are inherited by the pane without
* ever appearing in `ps`:
*
* 1. `env.tmuxSetenvKeys` — sensitive vars forwarded from the SERVER's own environment
* (API keys, CLI home dirs). Names only ever live in config; values never do.
* 2. `env.configSetenv` — vars whose value comes from the caller's config rather than the
* server env. DeepSeek's `DSH_PERMISSION_MODE` is the case this exists for: its
* permission switch is an env var, not a flag. Routing it through a declared launch
* param is what lets the ordinary multi-user clamp reach it.
* 3. `env.configContentVar` — a JSON config blob (opencode).
* 4. `env.setenvProfile` — genuinely code-shaped setup. DeepSeek's status bridge writes an
* executable shim to disk and exports its path plus this session's pane id, which is
* what upgrades that mode from output-stabilization guessing to definitive hook events.
*
* Called UNCONDITIONALLY for every mode: an entry with no keys, no config var and no
* profile does nothing here, which is a better shape than four `if (mode === ...)` guards
* that each had to be remembered at two separate call sites.
*/
private _configureOpenCode(muxName: string, openCodeConfig?: OpenCodeConfig): void {
private _configureCliEnv(
muxName: string,
sessionId: string,
mode: SessionMode,
rawConfig?: Record<string, unknown>
): void {
const entry = getCli(mode);
if (!entry) return;
const tmuxCmd = this.tmux();
setOpenCodeEnvVars(tmuxCmd, muxName);
setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig);
}
/**
* Configure Codex-specific environment on a tmux session.
* Sets OPENAI_API_KEY (and related keys) via tmux setenv so secrets don't
* appear in the bash command line.
*/
private _configureCodex(muxName: string): void {
setCodexEnvVars(this.tmux(), muxName);
}
setCliSensitiveEnvVars(tmuxCmd, muxName, entry.env.tmuxSetenvKeys);
/**
* Configure Gemini-specific environment on a tmux session.
*/
private _configureGemini(muxName: string): void {
setGeminiEnvVars(this.tmux(), muxName);
}
/**
* Configure DeepSeek Harness environment on a tmux session.
*
* Two independent things, both via `tmux setenv` so they are inherited by the
* pane without appearing in `ps`:
*
* 1. `DSH_PERMISSION_MODE` — the harness's only permission input. Exported
* ONLY when the caller sent one, so an absent config lands on the harness's
* own `workspace-write` default (which asks) rather than on ours. That
* "only if sent" shape is what the multi-user clamp relies on.
* 2. The `HERDR_*` triple — the supervisor contract the terminal front door
* uses to report idle/working/blocked. Pointing `HERDR_BIN_PATH` at our own
* generated shim is what upgrades this mode from output-stabilization
* guessing to definitive hook events (see deepseek-status-shim.ts). The
* pane id IS the Codeman session id, which is how the shim attributes a
* report without trusting anything the agent could influence.
*
* Also forwards DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL from the server env when
* present, matching the codex/gemini precedent for headless auth.
*/
private _configureDeepSeek(muxName: string, sessionId: string, config?: DeepSeekConfig): void {
const tmuxCmd = this.tmux();
const setenv = (key: string, value: string): void => {
const escaped = value.replace(/'/g, "'\\''");
try {
execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
encoding: 'utf8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['pipe', 'pipe', 'pipe'],
});
} catch {
/* Non-critical */
}
};
for (const key of ['DEEPSEEK_API_KEY', 'DEEPSEEK_BASE_URL', 'DSH_HOME']) {
const val = process.env[key];
if (val) setenv(key, val);
for (const [key, value] of Object.entries(configSetenvValues(entry, rawConfig))) {
setTmuxEnvVar(tmuxCmd, muxName, key, value);
}
// Enum-validated at the schema boundary; re-checked here because this value
// reaches a shell line, and a builder must never trust its caller.
if (
config?.permissionMode &&
['read-only', 'workspace-write', 'danger-full-access'].includes(config.permissionMode)
) {
setenv('DSH_PERMISSION_MODE', config.permissionMode);
if (entry.env.configContentVar) {
setCliConfigContent(tmuxCmd, muxName, entry.env.configContentVar, rawConfig as OpenCodeConfig | undefined);
}
if (config?.statusReporting !== false) {
const shim = ensureDeepSeekStatusShim();
if (shim) {
setenv('HERDR_ENV', '1');
setenv('HERDR_BIN_PATH', shim);
setenv('HERDR_PANE_ID', sessionId);
const profileName = entry.env.setenvProfile;
if (profileName) {
const profile = SETENV_PROFILES[profileName];
// A name the schema accepted but this build does not implement: skip rather than
// throw. Losing a status bridge degrades signal quality; failing here would refuse
// the session outright.
if (profile) {
for (const [key, value] of Object.entries(profile(sessionId, entry, rawConfig))) {
setTmuxEnvVar(tmuxCmd, muxName, key, value);
}
}
}
}
@@ -2140,32 +1701,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
// looked — server PATH, login shell, checked directories — instead of just
// asserting the CLI is missing (the classic systemd/launchd PATH trap).
const { pathExport, dir: cliDir } = this.buildPathExport(mode);
if (mode === 'claude' && !cliDir) {
throw new Error(getClaudeNotFoundMessage());
}
if (mode === 'opencode' && !cliDir) {
throw new Error(getOpenCodeNotFoundMessage());
}
if (mode === 'codex' && !cliDir) {
throw new Error(getCodexNotFoundMessage());
}
if (mode === 'gemini' && !cliDir) {
throw new Error(getGeminiNotFoundMessage());
}
if (mode === 'antigravity' && !cliDir) {
throw new Error(getAntigravityNotFoundMessage());
}
if (mode === 'pi' && !cliDir) {
throw new Error(getPiNotFoundMessage());
}
if (mode === 'deepseek' && !cliDir) {
throw new Error(getDeepSeekNotFoundMessage());
}
if (mode === 'grok' && !cliDir) {
throw new Error(getGrokNotFoundMessage());
}
if (mode === 'omp' && !cliDir) {
throw new Error(getOmpNotFoundMessage());
// Refuse the spawn rather than launching a pane that dies on `command not found`.
// `missingCliMessage()` returns null for a mode with no binary to find (`shell`), and
// carries bounded PATH/login-shell/search-dir diagnostics so the error says where we
// actually looked.
if (!cliDir) {
const message = missingCliMessage(mode);
if (message) throw new Error(message);
}
const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
@@ -2241,21 +1783,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
/* Non-critical */
}
// For OpenCode: set sensitive env vars and config via tmux setenv
// (not visible in ps output or tmux history, inherited by panes)
if (mode === 'opencode') {
this._configureOpenCode(muxName, openCodeConfig);
} else if (mode === 'codex') {
this._configureCodex(muxName);
}
// For Gemini: set Gemini/Google auth env vars via tmux setenv
if (mode === 'gemini') {
this._configureGemini(muxName);
}
// For DeepSeek: permission mode + the Herdr-compatible status bridge.
if (mode === 'deepseek') {
this._configureDeepSeek(muxName, sessionId, deepSeekConfig);
}
// Per-CLI env: API keys, config blobs, config-sourced vars, status bridges. All of
// it is registry data, so this is one unconditional call rather than a per-mode ladder.
this._configureCliEnv(
muxName,
sessionId,
mode,
legacyConfigForMode(mode, options as unknown as Record<string, unknown>)
);
// Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
// so secret values stay off the bash command line. Must run before respawn-pane.
@@ -2461,20 +1996,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer {
: localFullCmd;
try {
// For OpenCode: set sensitive env vars via tmux setenv before respawn
if (mode === 'opencode') {
this._configureOpenCode(muxName, openCodeConfig);
} else if (mode === 'codex') {
this._configureCodex(muxName);
}
// For Gemini: set Gemini/Google auth env vars via tmux setenv before respawn
if (mode === 'gemini') {
this._configureGemini(muxName);
}
// For DeepSeek: permission mode + the Herdr-compatible status bridge.
if (mode === 'deepseek') {
this._configureDeepSeek(muxName, sessionId, deepSeekConfig);
}
// Same per-CLI env setup as createSession, re-applied so the respawned pane inherits it.
this._configureCliEnv(
muxName,
sessionId,
mode,
legacyConfigForMode(mode, options as unknown as Record<string, unknown>)
);
// Re-apply user env overrides before respawn so the new shell inherits them.
this.applyEnvOverrides(muxName, envOverrides);
+8 -10
View File
@@ -7,8 +7,8 @@
* @module utils/antigravity-cli-resolver
*/
import { join } from 'node:path';
import { homedir } from 'node:os';
import { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
@@ -16,14 +16,12 @@ import {
} from './cli-executable-resolver.js';
/** Common directories where the Antigravity CLI binary may be installed */
const ANTIGRAVITY_SEARCH_DIRS = [
join(homedir(), '.local', 'bin'),
join(homedir(), '.antigravity', 'bin'),
'/usr/local/bin',
join(homedir(), '.bun', 'bin'),
join(homedir(), '.npm-global', 'bin'),
join(homedir(), 'bin'),
];
/**
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*/
const ANTIGRAVITY_SEARCH_DIRS = (): string[] => (getCli('antigravity')?.discovery.searchDirs ?? []).map(expandHome);
const ANTIGRAVITY_NOT_FOUND =
'Antigravity CLI not found. Install with: curl -fsSL https://antigravity.google/cli/install.sh | bash';
+13 -3
View File
@@ -207,13 +207,23 @@ export function createProductionCliResolverHost(options: ProductionCliResolverHo
export function createCliExecutableResolver<T = undefined>(
options: {
binary: string;
searchDirs: string[];
/**
* Where to look after the process PATH. A THUNK is accepted alongside an array so a
* caller sourcing its dirs from the CLI registry can defer the lookup: passing
* `searchDirs: FOO_SEARCH_DIRS()` evaluates at module import, which froze the dirs
* before a user `clis.json` or a `reloadCliRegistry()` could be seen. Resolved on each
* probe and each `diagnostics()` call — a handful of string ops, and only when a probe
* actually runs.
*/
searchDirs: string[] | (() => string[]);
validateCandidate?: (path: string) => CandidateValidation<T>;
/** Clock injection for tests driving the failure backoff. Defaults to `Date.now`. */
now?: () => number;
},
host: CliResolverHost = createProductionCliResolverHost()
): CliExecutableResolver<T> {
const resolveSearchDirs = (): string[] =>
typeof options.searchDirs === 'function' ? options.searchDirs() : options.searchDirs;
if (!SAFE_BINARY_NAME.test(options.binary)) {
throw new Error(`Unsafe CLI binary name: ${options.binary}`);
}
@@ -248,7 +258,7 @@ export function createCliExecutableResolver<T = undefined>(
cached = accept(host.findOnProcessPath(options.binary), 'process-path');
if (!cached) {
for (const dir of options.searchDirs) {
for (const dir of resolveSearchDirs()) {
cached = accept(join(dir, options.binary), 'common-directory');
if (cached) break;
}
@@ -271,7 +281,7 @@ export function createCliExecutableResolver<T = undefined>(
processPath: host.processPath,
shellPath: host.shellPath,
shellArgs: [...host.shellArgs],
searchDirs: [...options.searchDirs],
searchDirs: [...resolveSearchDirs()],
}),
};
}
+113
View File
@@ -0,0 +1,113 @@
/**
* @fileoverview Implementations of the LAUNCHER profiles named by `discovery.launcherProfile`.
*
* A launcher CLI's binary is not the agent — it boots some further target — so two questions
* the registry normally answers from the binary alone have to be asked of that target:
*
* - `isCliRunnable(id)` — stricter than "is the binary on disk?"
* - `launcherDefaultTarget(entry)` — what to launch when the caller names no target
*
* The profile NAMES and their validation live in `config/cli-registry/profiles.ts`, which is
* kept free of imports so `schema.ts` can validate a name at load time. The implementations
* live here because they reach into resolvers that reach back into the registry, and holding
* them next to the names would close an import cycle.
*
* ⚠️ Everything in this file is keyed by PROFILE NAME, never by CLI id. A new launcher CLI
* adds a profile here and names it from its entry; it does not add a branch anywhere else.
*
* @module utils/cli-launcher
*/
import { isDeepSeekRunnable, resolveDefaultDeepSeekProfile } from './deepseek-cli-resolver.js';
import { getCli } from '../config/cli-registry/registry.js';
import type { CliEntry } from '../config/cli-registry/types.js';
import { missingCliMessage, resolveCliBinDir } from './cli-resolver.js';
interface LauncherProfile {
/** Is the launcher usable, given that its binary resolved? */
isRunnable(): boolean;
/** The target to launch when the caller named none, or null when there is none. */
defaultTarget(): string | null;
/**
* Why a session cannot start, or null when it can — including why a SPECIFICALLY
* requested target will not work, which "is it runnable" alone cannot say.
*/
launchError(requestedTarget?: string): Promise<string | null>;
}
const LAUNCHER_PROFILES: Record<string, LauncherProfile> = {
// `dsh` launches a profile from $DSH_HOME/profiles/<name>. DeepSeek ships only
// `web`/`headless`/`base`, none of which can drive a terminal pane, so the terminal front
// door is always third-party: a perfectly-installed dsh with no TUI profile is installed
// but NOT runnable, and the two questions have genuinely different answers.
'deepseek-profile': {
isRunnable: isDeepSeekRunnable,
defaultTarget: resolveDefaultDeepSeekProfile,
// Three distinct, actionable messages (binary missing / no pane-capable profile /
// the named profile is not pane-capable). Worth keeping distinct: a pane that dies
// instantly is the most confusing failure this mode can produce, and "not installed"
// would send the user to fix the wrong thing.
launchError: async (requestedTarget) => {
const { resolveDeepSeekLaunchError } = await import('./deepseek-cli-resolver.js');
return resolveDeepSeekLaunchError(requestedTarget);
},
},
};
/**
* Why a session in this mode cannot start, or null when it can.
*
* For an ordinary CLI this is just "is the binary there?", answered with the not-found
* message that names where resolution looked. For a launcher CLI it defers to that CLI's own
* profile, which can be far more specific.
*
* `rawConfig` is the caller's per-CLI config object, read for the target the caller named
* (declared as `discovery.launcherTargetParam`) so the error can be about THAT target.
*/
export async function resolveCliLaunchError(mode: string, rawConfig?: Record<string, unknown>): Promise<string | null> {
const entry = getCli(mode);
if (!entry) return null;
const profileName = entry.discovery.launcherProfile;
if (profileName !== undefined) {
const profile = LAUNCHER_PROFILES[profileName];
if (!profile) return `${entry.label} is not runnable: its launcher profile is unavailable in this build.`;
const targetParam = entry.discovery.launcherTargetParam;
const requested = targetParam ? rawConfig?.[targetParam] : undefined;
return profile.launchError(typeof requested === 'string' ? requested : undefined);
}
// No binary to find (`shell`) is never an error.
if (entry.discovery.binaries.length === 0) return null;
return resolveCliBinDir(mode) === null ? missingCliMessage(mode) : null;
}
/**
* Is this CLI actually usable? For an ordinary CLI that is exactly "its binary resolved".
* For a launcher it is that AND whatever its profile demands.
*
* ⚠️ A named-but-unimplemented profile fails CLOSED. In practice `schema.ts` rejects such an
* entry at load time, so this is the second line of defence rather than the first — but the
* direction matters: offering a Run that always fails is worse than reporting unavailable.
*/
export function isCliRunnable(id: string): boolean {
const entry = getCli(id);
if (!entry) return false;
// No binary to find (`shell`): tmux-manager resolves the login shell in code.
const resolved = entry.discovery.binaries.length === 0 ? true : resolveCliBinDir(id) !== null;
const profileName = entry.discovery.launcherProfile;
if (profileName === undefined) return resolved;
const profile = LAUNCHER_PROFILES[profileName];
if (!profile) return false;
return resolved && profile.isRunnable();
}
/**
* The launcher's default target, for the `launcherDefaultTarget` engine value. Null for
* every non-launcher CLI, which is what makes the corresponding launch arg drop out.
*/
export function launcherDefaultTarget(entry: CliEntry): string | null {
const profileName = entry.discovery.launcherProfile;
if (profileName === undefined) return null;
return LAUNCHER_PROFILES[profileName]?.defaultTarget() ?? null;
}
+269
View File
@@ -0,0 +1,269 @@
/**
* @fileoverview Registry-driven CLI binary resolution: look up ANY registered CLI's binary
* directory, version and not-found message from its `CliEntry`, with no per-CLI branch.
*
* This is a LAYER over `cli-executable-resolver.ts`, not a replacement for it. That module
* still owns the lookup chain (process PATH → the entry's search dirs → an interactive
* login shell), the negative cache and its doubling backoff, the marker-fenced login-shell
* parse, the `SIGKILL` timeouts and the vitest hermeticity gate — all of it deliberately
* untouched here, because those guards are load-bearing and separately tested. What this
* module adds is: where the parameters come from (the registry, rather than seven
* hand-written constant blocks) and what makes a candidate acceptable.
*
* CANDIDATE VALIDATION runs in a fixed order, and the order is the point:
*
* 1. IDENTITY (`discovery.identity`) — does the binary say it is the program we meant?
* Checked FIRST, because a version probe cannot tell an impostor from the real thing:
* Debian's `dsh` (dancer's shell) answers `--version` perfectly happily, and npm
* carries squatters for both `pi` and `grok`.
* 2. VERSION (`discovery.version`) — does its version output have the right shape? With
* `requireVersionMatch`, a mismatch means ABSENT rather than present-with-unknown-
* version, which is what a short, generic binary name needs.
*
* Both probes EXECUTE the candidate, which is exactly why both are gated off under vitest:
* a suite must never depend on — let alone run — whatever binary of that name the machine
* running it happens to carry. Tests inject probes instead.
*
* @module utils/cli-resolver
*/
import { execFileSync } from 'node:child_process';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { compileVersionRegex, MAX_VERSION_OUTPUT } from '../config/cli-registry/patterns.js';
import { getCli, resolveInstallCommandForPlatform } from '../config/cli-registry/registry.js';
import { getClaudeCliVersion } from './claude-cli-resolver.js';
import type { CliEntry } from '../config/cli-registry/types.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
type CliExecutableResolver,
type CliResolverHost,
} from './cli-executable-resolver.js';
/** Expand a leading `~` to the home directory. Nothing else is interpreted. */
export function expandHome(dir: string): string {
if (dir === '~') return homedir();
if (dir.startsWith('~/')) return join(homedir(), dir.slice(2));
return dir;
}
/**
* Run `<binPath> <arg>` and return its trimmed output, truncated to the cap a
* config-supplied regex is allowed to see.
*
* Returns null under vitest — see this file's header. This is defense in depth rather than
* the only gate (the shared resolver host is already inert under vitest), and it is what
* makes the "resolve nothing even against a real on-disk fixture" behaviour hold for a
* test that opts back into real filesystem IO.
*/
function probeCommandOutput(binPath: string, arg: string, logPrefix: string): string | null {
if (process.env.VITEST) return null;
try {
return execFileSync(binPath, [arg], {
encoding: 'utf-8',
timeout: EXEC_TIMEOUT_MS,
stdio: ['ignore', 'pipe', 'ignore'],
// execFileSync's `timeout` only SENDS the signal and then keeps waiting. A stuck or
// hostile binary that ignores SIGTERM would survive it and block the server.
killSignal: 'SIGKILL',
})
.trim()
.slice(0, MAX_VERSION_OUTPUT);
} catch (err) {
console.warn(`[${logPrefix}] Ignoring ${binPath}: "${arg}" failed (${(err as Error).message})`);
return null;
}
}
/** What a candidate probe reports back. `version` is undefined when none was declared. */
export interface CliCandidateProbeResult {
accepted: boolean;
version?: string;
}
/** A probe hook, so tests can drive resolution without executing anything. */
export type CliCandidateProbe = (binPath: string, entry: CliEntry) => CliCandidateProbeResult;
/**
* The production probe: identity first, then version. A CLI declaring neither is accepted
* on existence alone, which is the common case (opencode, codex, gemini, antigravity).
*/
export function probeCliCandidate(binPath: string, entry: CliEntry): CliCandidateProbeResult {
const logPrefix = `CliResolver:${entry.id as string}`;
const { identity, version } = entry.discovery;
if (identity) {
const pattern = compileVersionRegex(identity.regex);
if (!pattern) {
console.warn(`[${logPrefix}] identity.regex was rejected as unsafe; refusing every candidate.`);
return { accepted: false };
}
const out = probeCommandOutput(binPath, identity.arg, logPrefix);
if (out === null || !pattern.test(out)) {
console.warn(`[${logPrefix}] Ignoring ${binPath}: "${identity.arg}" did not identify it as ${entry.label}.`);
return { accepted: false };
}
}
if (!version) return { accepted: true };
const out = probeCommandOutput(binPath, version.arg, logPrefix);
const pattern = version.regex ? compileVersionRegex(version.regex) : null;
const found = out !== null && pattern ? (pattern.exec(out)?.[1] ?? undefined) : undefined;
if (found === undefined && version.requireVersionMatch) {
// A `which` hit is not evidence for a short, generic or squatted binary name.
console.warn(`[${logPrefix}] Ignoring ${binPath}: "${version.arg}" printed ${JSON.stringify(out?.slice(0, 80))}`);
return { accepted: false };
}
return { accepted: true, version: found };
}
/**
* A resolver for one registry entry. An entry may declare several binary names (first hit
* wins), so this holds one underlying resolver per name and returns the first that
* resolves — which is also what keeps each name's own negative cache and backoff intact.
*/
interface RegistryResolver {
resolveDir(): string | null;
getVersion(): string | null;
notFoundMessage(base: string): string;
}
function createRegistryResolver(
entry: CliEntry,
probe: CliCandidateProbe = probeCliCandidate,
host?: CliResolverHost,
now?: () => number
): RegistryResolver {
const searchDirs = entry.discovery.searchDirs.map(expandHome);
const perBinary: CliExecutableResolver<string>[] = entry.discovery.binaries.map((binary) =>
createCliExecutableResolver<string>(
{
binary,
searchDirs,
validateCandidate: (binPath) => {
const result = probe(binPath, entry);
return result.accepted ? { accepted: true, metadata: result.version } : { accepted: false };
},
now,
},
host
)
);
const first = () => {
for (const resolver of perBinary) {
const resolution = resolver.resolve();
if (resolution) return resolution;
}
return null;
};
return {
resolveDir: () => first()?.directory ?? null,
getVersion: () => first()?.metadata ?? null,
notFoundMessage: (base) =>
// Diagnostics come from the FIRST declared binary: every name shares the same search
// dirs, PATH and login shell, so the extra copies would say the same thing twice.
perBinary.length > 0 ? formatCliNotFoundMessage(base, perBinary[0].diagnostics()) : base,
};
}
/**
* Build an isolated resolver for `entry` around an injected probe, host and clock — the
* test seam. Omitting `probe` keeps the ambient, VITEST-gated one, which is exactly what
* the hermeticity tests exercise.
*/
export function createCliResolverForTest(
entry: CliEntry,
probe?: CliCandidateProbe,
host?: CliResolverHost,
now?: () => number
): RegistryResolver {
return createRegistryResolver(entry, probe ?? probeCliCandidate, host, now);
}
/**
* One memoized resolver per id, for the process lifetime — the same caching the per-CLI
* modules already do for themselves, just keyed by id so generic code holding only a
* `CliId` string can resolve a CLI it knows nothing else about, custom entries included.
*/
const resolvers = new Map<string, RegistryResolver>();
function resolverFor(id: string): RegistryResolver | null {
const cached = resolvers.get(id);
if (cached) return cached;
const entry = getCli(id);
// `shell` declares no binary: tmux-manager resolves the real login shell in code.
if (!entry || entry.discovery.binaries.length === 0) return null;
const resolver = createRegistryResolver(entry);
resolvers.set(id, resolver);
return resolver;
}
/**
* Drop the memoized resolver for `id` so the next lookup re-probes from scratch instead of
* replaying a cached negative result and waiting out a backoff window already in progress.
*/
export function invalidateCliResolverCache(id?: string): void {
if (id === undefined) resolvers.clear();
else resolvers.delete(id);
}
/** The directory containing this CLI's binary, or null when it cannot be found. */
export function resolveCliBinDir(id: string): string | null {
return resolverFor(id)?.resolveDir() ?? null;
}
/** Is this CLI's binary present? Note: for a launcher CLI this is NOT the same as runnable. */
export function isCliAvailable(id: string): boolean {
return resolveCliBinDir(id) !== null;
}
/** The version the resolved binary reported, or null when unresolved or none was declared. */
export function resolveCliVersion(id: string): string | null {
return resolverFor(id)?.getVersion() ?? null;
}
/**
* "CLI not found" message for `id`, with bounded PATH/login-shell/search-dir diagnostics
* appended so the error names where resolution actually looked. Returns null for an id with
* no binary to find (`shell`) or one that is not registered at all.
*/
export function missingCliMessage(id: string): string | null {
const entry = getCli(id);
if (!entry || entry.discovery.binaries.length === 0) return null;
const install = resolveInstallCommandForPlatform(entry);
const base = install
? `${entry.label} CLI not found. Install with: ${install}`
: `${entry.label} CLI not found (looked for ${entry.discovery.binaries.join(', ')}).`;
return resolverFor(id)?.notFoundMessage(base) ?? base;
}
/**
* The version to stamp on a SESSION in this mode.
*
* ⚠️ Dispatched on DATA, not on an id, and the field it dispatches on is the one that
* describes the difference: `discovery.version.retryOnTransientFailure`.
*
* Claude needs a probe policy no other CLI does. A single failed `claude --version` — a 5s
* timeout, a PATH-starved systemd unit, a transient fs hiccup — used to be cached forever,
* which silently disabled wheel-forwarding to Claude's own transcript for every session
* until the server restarted (the only route to history in repaint mode: a dead wheel on
* every device at once). `getClaudeCliVersion()` caches success forever and retries failure
* with backoff, and that policy has to be preserved exactly, so this routes to it rather
* than reimplementing it generically.
*
* Everything else goes through the ordinary registry resolver, which is the point: the
* caller asks `cliNeedsVersionProbe()` whether this CLI gates anything on its version and
* then asks HERE for that CLI's version. Before this, all three call sites asked
* `cliNeedsVersionProbe()` a generic question and then called `getClaudeCliVersion()`
* unconditionally — so the first non-claude entry to declare a `capabilities.gates` would
* have had CLAUDE's version stamped on its sessions and its gate evaluated against it.
*/
export function resolveSessionCliVersion(mode: string): string | null {
return getCli(mode)?.discovery.version?.retryOnTransientFailure ? getClaudeCliVersion() : resolveCliVersion(mode);
}
+8 -11
View File
@@ -7,21 +7,18 @@
* @module utils/codex-cli-resolver
*/
import { join } from 'node:path';
import { homedir } from 'node:os';
import { spawn } from 'node:child_process';
import { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
import { parseCodexRateLimitsResponse, type StatusTelemetry } from '../usage-telemetry.js';
/** Common directories where the Codex CLI binary may be installed */
const CODEX_SEARCH_DIRS = [
join(homedir(), '.codex', 'bin'), // Default install location
join(homedir(), '.local', 'bin'), // Alternative install location
'/usr/local/bin', // Homebrew / system
join(homedir(), '.bun', 'bin'), // Bun global
join(homedir(), '.npm-global', 'bin'), // npm global
join(homedir(), 'bin'), // User bin
];
/**
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*/
const CODEX_SEARCH_DIRS = (): string[] => (getCli('codex')?.discovery.searchDirs ?? []).map(expandHome);
const CODEX_BINARY = process.platform === 'win32' ? 'codex.exe' : 'codex';
const codexResolver = createCliExecutableResolver({ binary: CODEX_BINARY, searchDirs: CODEX_SEARCH_DIRS });
+8 -6
View File
@@ -35,6 +35,8 @@ import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
@@ -49,12 +51,12 @@ import {
* user's prefix points. `~/.local/bin` heads the list because it is the default
* for a prefix-relocated npm (and is where this box's install landed).
*/
const DEEPSEEK_SEARCH_DIRS = [
join(homedir(), '.local', 'bin'),
'/usr/local/bin',
join(homedir(), '.npm-global', 'bin'),
join(homedir(), 'bin'),
];
/**
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*/
const DEEPSEEK_SEARCH_DIRS = (): string[] => (getCli('deepseek')?.discovery.searchDirs ?? []).map(expandHome);
/**
* A real `dsh --version` prints a bare `0.1.1-rc.2` (measured, 0.1.1-rc.2), so
+8 -10
View File
@@ -7,19 +7,17 @@
* @module utils/gemini-cli-resolver
*/
import { join } from 'node:path';
import { homedir } from 'node:os';
import { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
/** Common directories where the Gemini CLI binary may be installed */
const GEMINI_SEARCH_DIRS = [
join(homedir(), '.gemini', 'bin'),
join(homedir(), '.local', 'bin'),
'/usr/local/bin',
join(homedir(), '.bun', 'bin'),
join(homedir(), '.npm-global', 'bin'),
join(homedir(), 'bin'),
];
/**
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*/
const GEMINI_SEARCH_DIRS = (): string[] => (getCli('gemini')?.discovery.searchDirs ?? []).map(expandHome);
const geminiResolver = createCliExecutableResolver({ binary: 'gemini', searchDirs: GEMINI_SEARCH_DIRS });
const GEMINI_NOT_FOUND = 'Gemini CLI not found. Install with: npm install -g @google/gemini-cli';
+8 -8
View File
@@ -20,9 +20,9 @@
*/
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
@@ -30,12 +30,12 @@ import {
} from './cli-executable-resolver.js';
/** Common directories where the Grok CLI binary may be installed */
const GROK_SEARCH_DIRS = [
join(homedir(), '.grok', 'bin'),
join(homedir(), '.local', 'bin'),
'/usr/local/bin',
join(homedir(), 'bin'),
];
/**
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*/
const GROK_SEARCH_DIRS = (): string[] => (getCli('grok')?.discovery.searchDirs ?? []).map(expandHome);
/**
* A real `grok --version` prints `grok 1.0.5 (5115b46bc9)` (measured, 1.0.5).
+10 -15
View File
@@ -14,9 +14,9 @@
*/
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
@@ -24,20 +24,15 @@ import {
} from './cli-executable-resolver.js';
/**
* Common directories where the OMP CLI binary may be installed. `~/.local/bin`
* leads: omp.sh's installer targets `$HOME/.local/bin` with no `--dir`
* override (verified against a real `--no-cache` Docker build — see
* docker/agent.Dockerfile); `~/.omp/bin` was an unverified guess that turned
* out wrong, kept after `~/.local/bin` only as a defensive fallback.
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*
* `~/.local/bin` still leads, for the reason it always did: omp.sh's installer targets it
* with no `--dir` override (verified against a real `--no-cache` docker build), while
* `~/.omp/bin` was an unverified guess that turned out wrong and is kept as a fallback.
*/
const OMP_SEARCH_DIRS = [
join(homedir(), '.local', 'bin'),
join(homedir(), '.omp', 'bin'),
'/usr/local/bin',
join(homedir(), '.bun', 'bin'),
join(homedir(), '.npm-global', 'bin'),
join(homedir(), 'bin'),
];
const OMP_SEARCH_DIRS = (): string[] => (getCli('omp')?.discovery.searchDirs ?? []).map(expandHome);
/**
* A real `omp --version` prints `omp/<semver>` (e.g. `omp/17.4.0`).
+8 -11
View File
@@ -7,20 +7,17 @@
* @module utils/opencode-cli-resolver
*/
import { join } from 'node:path';
import { homedir } from 'node:os';
import { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
import { createCliExecutableResolver, formatCliNotFoundMessage } from './cli-executable-resolver.js';
/** Common directories where the OpenCode CLI binary may be installed */
const OPENCODE_SEARCH_DIRS = [
join(homedir(), '.opencode', 'bin'), // Default install location
join(homedir(), '.local', 'bin'), // Alternative install location
'/usr/local/bin', // Homebrew / system
join(homedir(), 'go', 'bin'), // Go install
join(homedir(), '.bun', 'bin'), // Bun global
join(homedir(), '.npm-global', 'bin'), // npm global
join(homedir(), 'bin'), // User bin
];
/**
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*/
const OPENCODE_SEARCH_DIRS = (): string[] => (getCli('opencode')?.discovery.searchDirs ?? []).map(expandHome);
const openCodeResolver = createCliExecutableResolver({ binary: 'opencode', searchDirs: OPENCODE_SEARCH_DIRS });
const OPENCODE_NOT_FOUND = 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash';
+8 -9
View File
@@ -16,9 +16,9 @@
*/
import { execFileSync } from 'node:child_process';
import { join } from 'node:path';
import { homedir } from 'node:os';
import { EXEC_TIMEOUT_MS } from '../config/exec-timeout.js';
import { getCli } from '../config/cli-registry/registry.js';
import { expandHome } from './cli-resolver.js';
import {
createCliExecutableResolver,
formatCliNotFoundMessage,
@@ -26,13 +26,12 @@ import {
} from './cli-executable-resolver.js';
/** Common directories where the Pi CLI binary may be installed */
const PI_SEARCH_DIRS = [
join(homedir(), '.local', 'bin'),
'/usr/local/bin',
join(homedir(), '.bun', 'bin'),
join(homedir(), '.npm-global', 'bin'),
join(homedir(), 'bin'),
];
/**
* Directories probed after `which`, read from this CLI's registry entry so the spawn
* path, `codeman doctor` and this resolver cannot disagree about where to look.
* `~` is expanded by `expandHome`; nothing else is interpreted.
*/
const PI_SEARCH_DIRS = (): string[] => (getCli('pi')?.discovery.searchDirs ?? []).map(expandHome);
/**
* A real `pi --version` prints a semver-shaped string (e.g. `0.84.1`).
+3 -2
View File
@@ -7,6 +7,7 @@
*/
import { FastifyInstance } from 'fastify';
import { getCli } from '../../config/cli-registry/registry.js';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { CronJobSchema, CronJobUpdateSchema, CronJobEnabledSchema } from '../schemas.js';
import { canAccessOwned, getAuthUser, isWorkingDirAllowed, ownerFor, parseBody } from '../route-helpers.js';
@@ -45,7 +46,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
// Resolve the owner's grant from the store (AuthUser.role alone can't tell a GRANTED
// regular user from a plain one); mirrors session-routes + the cron fire-time re-check.
if (
(body.agentType === 'shell' || body.launchCommand) &&
(getCli(body.agentType)?.capabilities.privilegedCommandGate || body.launchCommand) &&
!(await canUsernameRunPrivilegedCommands(ownerFor(req)))
) {
return createErrorResponse(
@@ -72,7 +73,7 @@ export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'workingDir is outside your workspace');
}
if (
(body.agentType === 'shell' || body.launchCommand) &&
(getCli(body.agentType ?? 'claude')?.capabilities.privilegedCommandGate || body.launchCommand) &&
!(await canUsernameRunPrivilegedCommands(ownerFor(req)))
) {
return createErrorResponse(
+187 -270
View File
@@ -29,7 +29,7 @@ import {
type DeepSeekConfig,
type OmpConfig,
} from '../../types.js';
import { Session, isAltScreenStripMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import { Session, isAltScreenStripMode, isExternalCliMode, isMuxAltScreenOnlyStripMode } from '../../session.js';
import { SseEvent } from '../sse-events.js';
import {
CreateSessionSchema,
@@ -82,6 +82,9 @@ import {
validatePathWithinBase,
} from '../route-helpers.js';
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../../user-store.js';
import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
import { isMultiUserMode } from '../../config/multiuser.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import {
@@ -356,12 +359,54 @@ export function _resetPasteRateBuckets(): void {
*/
async function clampExternalCliBypassForOwner(
owner: string | undefined,
codexConfig: CodexConfig | undefined,
geminiConfig: GeminiConfig | undefined,
antigravityConfig: AntigravityConfig | undefined,
piConfig: PiConfig | undefined,
grokConfig: GrokConfig | undefined,
deepSeekConfig: DeepSeekConfig | undefined
configs: Record<string, unknown>
): Promise<Record<string, unknown>> {
if (await canUsernameRunPrivilegedCommands(owner)) return configs;
const out = { ...configs };
for (const entry of enabledClis()) {
const field = entry.launch.legacyConfigField;
if (!field) continue;
// `privilegedParams[].param` names the REGISTRY param, so it has to be translated to the
// legacy wire field on the way out — the same `legacyConfigAliases` hop `configSetenvValues`
// already makes. Writing `param` straight through would put it in a DIFFERENT namespace
// from every other `param` in the schema, and a name that is right in one and wrong in the
// other is a SILENT no-op: no load error, no failing test, the clamp simply stops clamping.
// Codex is where the two names differ (`bypassApprovals` vs `dangerouslyBypassApprovals`),
// and `schema.ts` refuses an entry naming a param it never declared.
const aliases = entry.launch.legacyConfigAliases ?? {};
const existing = out[field] as Record<string, unknown> | undefined;
let next = existing;
for (const { param, clampTo, materializeWhenAbsent } of entry.capabilities.privilegedParams) {
// MATERIALIZE vs ONLY-IF-SENT is the whole design of this clamp, and the two are not
// interchangeable — see CliCapabilities.privilegedParams. Materialize where the CLI's
// own absent-config default is ITSELF unsafe (gemini defaults to yolo; pi's default is
// an interactive trust prompt the session user could just answer "yes" to), so a
// caller who sends no config at all still gets clamped.
if (next === undefined && !materializeWhenAbsent) continue;
next = { ...(next ?? {}), [aliases[param] ?? param]: clampTo };
}
if (next !== existing) out[field] = next;
}
return out;
}
/**
* Test hook, and the positional shape the clamp has always been called with in tests.
*
* The clamp itself is now generic over the registry, which is what makes a CUSTOM CLI's
* privileged flag clampable with no code here — previously the five config objects were
* named individually, so `privilegedParams` on anything outside that list was declared but
* unreachable.
*/
export async function _clampExternalCliBypassForOwner(
owner: string | undefined,
codexConfig?: CodexConfig,
geminiConfig?: GeminiConfig,
antigravityConfig?: AntigravityConfig,
piConfig?: PiConfig,
grokConfig?: GrokConfig,
deepSeekConfig?: DeepSeekConfig
): Promise<{
codexConfig: CodexConfig | undefined;
geminiConfig: GeminiConfig | undefined;
@@ -370,34 +415,24 @@ async function clampExternalCliBypassForOwner(
grokConfig: GrokConfig | undefined;
deepSeekConfig: DeepSeekConfig | undefined;
}> {
const granted = await canUsernameRunPrivilegedCommands(owner);
if (granted) return { codexConfig, geminiConfig, antigravityConfig, piConfig, grokConfig, deepSeekConfig };
// Non-granted: force codex/antigravity bypass off (only meaningful when a config was
// sent) and materialize gemini to auto_edit (clamps an explicit 'yolo' and the yolo default)
// and pi to --no-approve (clamps an explicit true AND pi's own "ask" default).
const clampedCodex = codexConfig ? { ...codexConfig, dangerouslyBypassApprovals: false } : codexConfig;
const clampedGemini: GeminiConfig = { ...(geminiConfig ?? {}), approvalMode: 'auto_edit' };
const clampedAntigravity = antigravityConfig
? { ...antigravityConfig, dangerouslySkipPermissions: false }
: antigravityConfig;
const clampedPi: PiConfig = { ...(piConfig ?? {}), approveProjectTrust: false };
const clampedGrok = grokConfig ? { ...grokConfig, alwaysApprove: false } : grokConfig;
const clampedDeepSeek = deepSeekConfig
? { ...deepSeekConfig, permissionMode: 'workspace-write' as const }
: deepSeekConfig;
return {
codexConfig: clampedCodex,
geminiConfig: clampedGemini,
antigravityConfig: clampedAntigravity,
piConfig: clampedPi,
grokConfig: clampedGrok,
deepSeekConfig: clampedDeepSeek,
const out = await clampExternalCliBypassForOwner(owner, {
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
});
return out as {
codexConfig: CodexConfig | undefined;
geminiConfig: GeminiConfig | undefined;
antigravityConfig: AntigravityConfig | undefined;
piConfig: PiConfig | undefined;
grokConfig: GrokConfig | undefined;
deepSeekConfig: DeepSeekConfig | undefined;
};
}
/** Test hook: the clamp is the multi-user safety gate for the external CLIs' privileged flags. */
export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
/**
* Env-var keys a non-granted owner must not be able to set, because each one
* hands back privilege the config clamp above just removed, or redirects a
@@ -414,7 +449,7 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
* - `DSH_HOME` points the launcher at a profile tree, and a profile's plugin code
* executes at BOOT, before any approval row can apply. A user who can write a
* workspace can put a profile in it, so this is the wider of the two.
* - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureDeepSeek()`
* - `DEEPSEEK_BASE_URL` aims the provider endpoint, and `_configureCliEnv()`
* forwards the SERVER's own `DEEPSEEK_API_KEY` into every dsh pane before
* `applyEnvOverrides()` runs — so a non-granted owner who could set the base
* URL would have the operator's API key sent as a bearer credential to a host
@@ -431,25 +466,21 @@ export const _clampExternalCliBypassForOwner = clampExternalCliBypassForOwner;
* Ark0N/Codeman#353 review; omp's own knobs are otherwise mostly `PI_*`,
* already allowlisted for pi and not addressed here — see resolveOmpHome()).
*/
const OWNER_CLAMPED_ENV_KEYS = [
'DSH_PERMISSION_MODE',
'DSH_HOME',
'DEEPSEEK_BASE_URL',
'OMP_AUTH_BROKER_URL',
'OMP_AUTH_BROKER_TOKEN',
] as const;
function ownerClampedEnvKeys(): string[] {
return enabledClis().flatMap((entry) => entry.capabilities.privilegedEnvKeys);
}
/**
* Env-var half of the multi-user bypass clamp.
*
* `clampExternalCliBypassForOwner()` clamps the per-CLI CONFIG, and for every CLI
* but DeepSeek that is the whole story. Here it is not: `applyEnvOverrides()` runs
* AFTER `_configureDeepSeek()` in tmux-manager, so an override sent on the SAME
* AFTER `_configureCliEnv()` in tmux-manager, so an override sent on the SAME
* request lands last and wins, and a non-granted owner could restore
* `danger-full-access` on the very request the config clamp downgraded.
*
* Keys are DROPPED rather than rewritten: dropping falls through to what
* `_configureDeepSeek()` exports, which is the clamped config and the server's own
* `_configureCliEnv()` exports, which is the clamped config and the server's own
* `DSH_HOME`, i.e. exactly the intended state. No-op in single-user mode and for a
* granted owner, like every other clamp here
* (`canUsernameRunPrivilegedCommands()` returns true when `!isMultiUserMode()`),
@@ -460,38 +491,17 @@ async function clampEnvOverridesForOwner(
envOverrides: Record<string, string> | undefined
): Promise<Record<string, string> | undefined> {
if (!envOverrides) return envOverrides;
if (!OWNER_CLAMPED_ENV_KEYS.some((key) => key in envOverrides)) return envOverrides;
const keys = ownerClampedEnvKeys();
if (!keys.some((key) => key in envOverrides)) return envOverrides;
if (await canUsernameRunPrivilegedCommands(owner)) return envOverrides;
const clamped = { ...envOverrides };
for (const key of OWNER_CLAMPED_ENV_KEYS) delete clamped[key];
for (const key of keys) delete clamped[key];
return clamped;
}
/** Test hook: the env-var half of the same multi-user safety gate. */
export const _clampEnvOverridesForOwner = clampEnvOverridesForOwner;
/**
* Why a DeepSeek session cannot start, or null when it can.
*
* Availability for this mode is TWO questions, not one, because `dsh` is a
* profile launcher rather than an agent: the binary must resolve (and prove it
* is the harness and not Debian's dancer's shell), AND a profile that can occupy
* a pane must exist. Reporting only the first would let the Run button spawn a
* pane that dies instantly, which is the single most confusing failure this mode
* can produce, so each half gets its own actionable message.
*
* A profile named EXPLICITLY is checked on both counts: existence, and whether
* it is pane-capable — `web` serves a browser UI and `headless` answers one task
* and exits, so both would present as "the tab immediately died".
*/
async function resolveDeepSeekLaunchError(requestedProfile?: string): Promise<string | null> {
// Thin async wrapper: the implementation moved into the resolver module so
// CRON fires can ask the same question before constructing a Session; the
// dynamic import keeps this file's startup free of the probe machinery.
const { resolveDeepSeekLaunchError: impl } = await import('../../utils/deepseek-cli-resolver.js');
return impl(requestedProfile);
}
// ═══════════════════════════════════════════════════════════════
// Agent wait helpers (shared by GET /wait, GET /wait-output, POST /input)
// ═══════════════════════════════════════════════════════════════
@@ -876,7 +886,10 @@ export function registerSessionRoutes(
// Multi-user: shell mode is arbitrary command execution as the host account,
// gated behind the same grant as bypass (section 6.3). Resolve the owner's grant
// from the store so a GRANTED regular user is not wrongly denied (AuthUser role alone can't tell).
if (body.mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
if (
getCli(body.mode ?? 'claude')?.capabilities.privilegedCommandGate &&
!(await canUsernameRunPrivilegedCommands(owner))
) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
}
@@ -909,15 +922,11 @@ export function registerSessionRoutes(
// repos that POST /api/sessions can target, as those may have hand-authored
// values).
const managedCasesBase = resolveCasesDir(getAuthUser(req));
// `!isExternalCliMode()` is byte-identical to the eight-mode `!==` chain it replaces
// (claude and shell are the two non-external modes) and, unlike the chain, cannot fall
// behind the next CLI added.
const canStripDisk =
body.mode !== 'opencode' &&
body.mode !== 'codex' &&
body.mode !== 'gemini' &&
body.mode !== 'antigravity' &&
body.mode !== 'pi' &&
body.mode !== 'grok' &&
body.mode !== 'deepseek' &&
body.mode !== 'omp' &&
!isExternalCliMode(body.mode ?? 'claude') &&
body.envOverrides &&
Object.keys(body.envOverrides).length > 0 &&
(workingDir.startsWith(CASES_DIR + '/') || workingDir.startsWith(managedCasesBase + '/'));
@@ -969,58 +978,24 @@ export function registerSessionRoutes(
}
}
// Check OpenCode availability if requested. The error text comes from the
// resolver (formatCliNotFoundMessage) so it names where resolution looked —
// server PATH, login shell, common directories — same for the modes below.
if (body.mode === 'opencode') {
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } = await import('../../utils/opencode-cli-resolver.js');
if (!isOpenCodeAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
}
}
// Check Codex availability if requested
if (body.mode === 'codex') {
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
if (!isCodexAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
}
}
// Check Gemini availability if requested
if (body.mode === 'gemini') {
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
if (!isGeminiAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
}
}
if (body.mode === 'antigravity') {
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
await import('../../utils/antigravity-cli-resolver.js');
if (!isAntigravityAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
}
}
if (body.mode === 'pi') {
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
if (!isPiAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
}
}
if (body.mode === 'deepseek') {
const err = await resolveDeepSeekLaunchError(body.deepSeekConfig?.profile);
if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err);
}
if (body.mode === 'grok') {
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
if (!isGrokAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage());
}
}
if (body.mode === 'omp') {
const { isOmpAvailable, getOmpNotFoundMessage } = await import('../../utils/omp-cli-resolver.js');
if (!isOmpAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOmpNotFoundMessage());
// Refuse up front if the requested CLI cannot start, rather than spawning a pane that
// dies on `command not found`. The message comes from the resolver, so it names where
// resolution actually looked (server PATH, login shell, the entry's search dirs); a
// LAUNCHER CLI answers with its own more specific reason instead — for dsh, whether the
// binary is missing, no pane-capable profile exists, or the profile the caller NAMED
// cannot drive a pane, which are three different things to go and fix.
//
// Scoped to EXTERNAL CLIs, matching what this route has always pre-flighted: claude and
// shell deliberately fall through to tmux-manager's own not-found throw instead, and
// pulling them forward here would change which error a missing claude produces.
const requestedMode = body.mode ?? 'claude';
if (getCli(requestedMode)?.capabilities.external) {
const cliLaunchError = await resolveCliLaunchError(
requestedMode,
legacyConfigForMode(requestedMode, body as unknown as Record<string, unknown>)
);
if (cliLaunchError) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, cliLaunchError);
}
}
@@ -1057,27 +1032,25 @@ export function registerSessionRoutes(
const globalNice = await ctx.getGlobalNiceConfig();
const modelConfig = await ctx.getModelConfig();
const mode = body.mode || 'claude';
// Where a model override comes from is a capability, and the three answers are
// genuinely different mechanisms:
// 'flag' — the CLI takes --model, so read the value the caller sent
// in that CLI's own config object.
// 'claude-settings-file' — claude alone, whose model is written to
// <case>/.claude/settings.local.json rather than passed as
// a flag, so the app-wide default applies here.
// 'none' — shell has no model; deepseek's is a composition entry in
// the profile's config tree, not a session field
// (docs/deepseek-integration.md). Both get nothing.
const modelSource = getCli(mode)?.capabilities.model;
const model =
mode === 'opencode'
? body.openCodeConfig?.model
: mode === 'codex'
? body.codexConfig?.model
: mode === 'gemini'
? body.geminiConfig?.model
: mode === 'antigravity'
? body.antigravityConfig?.model
: mode === 'pi'
? body.piConfig?.model
: mode === 'grok'
? body.grokConfig?.model
: mode === 'omp'
? body.ompConfig?.model
: // DeepSeek's model is a composition entry in the profile's config
// tree, not a session flag, so there is deliberately nothing to
// read here (see docs/deepseek-integration.md).
mode !== 'shell' && mode !== 'deepseek'
? modelConfig?.defaultModel || undefined
: undefined;
modelSource?.source === 'flag'
? (legacyConfigForMode(mode, body as unknown as Record<string, unknown>)?.[modelSource.param ?? 'model'] as
| string
| undefined)
: modelSource?.source === 'claude-settings-file'
? modelConfig?.defaultModel || undefined
: undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
// Section 6.3: force non-granted users to a classifier-guarded mode.
const effectiveClaudeMode = await resolveClaudeModeForUsername(claudeModeConfig.claudeMode, owner);
@@ -1089,7 +1062,7 @@ export function registerSessionRoutes(
piConfig: gatedPiConfig,
grokConfig: gatedGrokConfig,
deepSeekConfig: gatedDeepSeekConfig,
} = await clampExternalCliBypassForOwner(
} = await _clampExternalCliBypassForOwner(
owner,
body.codexConfig,
body.geminiConfig,
@@ -1132,7 +1105,7 @@ export function registerSessionRoutes(
await ctx.setupSessionListeners(session);
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
// loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
if (mode === 'claude' && !remote && (await ctx.getAgentSkillEnabled())) {
if (getCli(mode)?.capabilities.agentSkillInjection && !remote && (await ctx.getAgentSkillEnabled())) {
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
);
@@ -1354,20 +1327,21 @@ export function registerSessionRoutes(
}
try {
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
// Ralph tracker is not supported for opencode / codex / gemini / antigravity / pi sessions.
// Keep this list in step with isExternalCliMode(): _processExpensiveParsers() returns early
// for those modes, so a tracker enabled here would never be fed, and the session would
// still report ralphEnabled + Ralph UI state that no other external CLI shows.
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally
// enabled and not explicitly disabled by user).
//
// `isExternalCliMode()` is what the eight-mode `!==` chain this replaces was FOR: its
// own comment asked the next person to keep the list in step with that predicate by
// hand. Calling it instead is byte-identical today (claude and shell are the two
// non-external modes, exactly what the chain admitted) and cannot drift.
//
// ⚠️ Deliberately NOT `capabilities.ralph`, which the quick-start path below reads:
// that capability is claude-only, so using it here would stop auto-enabling Ralph for
// SHELL sessions, which this path has always done. The two paths genuinely disagree
// about shell, and they disagree upstream too — reconciling them is a behaviour change
// and belongs in its own PR, not in a refactor that is meant to change nothing.
if (
session.mode !== 'opencode' &&
session.mode !== 'codex' &&
session.mode !== 'gemini' &&
session.mode !== 'antigravity' &&
session.mode !== 'pi' &&
session.mode !== 'grok' &&
session.mode !== 'deepseek' &&
session.mode !== 'omp' &&
!isExternalCliMode(session.mode) &&
ctx.store.getConfig().ralphEnabled &&
!session.ralphTracker.autoEnableDisabled
) {
@@ -2106,7 +2080,7 @@ export function registerSessionRoutes(
// Codex sessions don't write to ~/.claude/projects — their transcripts
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
// response-viewer works for Codex panes too.
if (session.mode === 'codex') {
if (getCli(session.mode)?.capabilities.transcript === 'codex-rollout') {
const codexQuery = req.query as { context?: string };
return await readCodexLastResponse(session, codexQuery.context === 'full');
}
@@ -2126,7 +2100,7 @@ export function registerSessionRoutes(
// and return "nothing said yet" forever — an agent polling that worker
// would starve on an answer that exists. Those configurations keep the
// pane segmenter below: coarse, but the real conversation.
if (session.mode === 'deepseek' && !session.docker && !session.remote) {
if (getCli(session.mode)?.capabilities.transcript === 'deepseek-zstd' && !session.docker && !session.remote) {
const deepSeekQuery = req.query as { context?: string };
const full = deepSeekQuery.context === 'full';
const transcript = await readDeepSeekLastResponse(session, { blocks: full });
@@ -2269,7 +2243,7 @@ export function registerSessionRoutes(
const WINDOW_MS = 15_000;
const otherSubmits: number[] = [];
for (const s of ctx.sessions.values()) {
if (s.id !== session.id && s.mode === 'codex' && s.lastSubmitAt) {
if (s.id !== session.id && getCli(s.mode)?.capabilities.transcript === 'codex-rollout' && s.lastSubmitAt) {
otherSubmits.push(s.lastSubmitAt);
}
}
@@ -2614,7 +2588,8 @@ export function registerSessionRoutes(
// During long thinking phases, Ink rewrites the same rows thousands of times
// (500KB+). Without stripping, tail mode returns only spinner frames and
// the terminal appears empty when switching tabs.
let strippedBuffer = session.mode === 'shell' ? rawBuffer : stripInkRedrawBloat(rawBuffer);
let strippedBuffer =
getCli(session.mode)?.capabilities.stripInkBloat === false ? rawBuffer : stripInkRedrawBloat(rawBuffer);
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
// streams. xterm.js obeys them by switching to its scrollback-less alt
@@ -2944,7 +2919,7 @@ export function registerSessionRoutes(
// Multi-user: shell mode is arbitrary host-account execution, gated by the grant.
// Resolve the owner's grant from the store so a GRANTED regular user is not wrongly denied.
if (mode === 'shell' && !(await canUsernameRunPrivilegedCommands(owner))) {
if (getCli(mode)?.capabilities.privilegedCommandGate && !(await canUsernameRunPrivilegedCommands(owner))) {
return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Shell sessions require the can-bypass-permissions grant');
}
@@ -3082,73 +3057,29 @@ export function registerSessionRoutes(
dockerResumeId = dockerCase.lastClaudeSessionId;
}
} else {
// Check OpenCode availability if requested. Error text comes from the
// resolver so it carries the resolution diagnostics; same for the modes below.
if (mode === 'opencode') {
const { isOpenCodeAvailable, getOpenCodeNotFoundMessage } =
await import('../../utils/opencode-cli-resolver.js');
if (!isOpenCodeAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getOpenCodeNotFoundMessage());
// Same pre-flight as POST /api/sessions: refuse before spawning a pane that would die
// on `command not found`, with the resolver's own diagnostics, and a launcher CLI's
// more specific reason (dsh: binary vs no pane-capable profile vs the profile the
// caller named). External CLIs only — claude and shell fall through to tmux-manager's
// own not-found throw, exactly as before.
if (getCli(mode)?.capabilities.external) {
const qsLaunchError = await resolveCliLaunchError(
mode,
legacyConfigForMode(mode, {
openCodeConfig,
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
} as unknown as Record<string, unknown>)
);
if (qsLaunchError) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, qsLaunchError);
}
}
// Check Codex availability if requested
if (mode === 'codex') {
const { isCodexAvailable, getCodexNotFoundMessage } = await import('../../utils/codex-cli-resolver.js');
if (!isCodexAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getCodexNotFoundMessage());
}
}
// Check Gemini availability if requested
if (mode === 'gemini') {
const { isGeminiAvailable, getGeminiNotFoundMessage } = await import('../../utils/gemini-cli-resolver.js');
if (!isGeminiAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGeminiNotFoundMessage());
}
}
// Check Antigravity availability if requested
if (mode === 'antigravity') {
const { isAntigravityAvailable, getAntigravityNotFoundMessage } =
await import('../../utils/antigravity-cli-resolver.js');
if (!isAntigravityAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getAntigravityNotFoundMessage());
}
}
// Check Pi availability if requested
if (mode === 'pi') {
const { isPiAvailable, getPiNotFoundMessage } = await import('../../utils/pi-cli-resolver.js');
if (!isPiAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getPiNotFoundMessage());
}
}
// Check OMP availability if requested
if (mode === 'omp') {
const { isOmpAvailable } = await import('../../utils/omp-cli-resolver.js');
if (!isOmpAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'OMP CLI not found. Install with: curl -fsSL https://omp.sh/install | sh'
);
}
}
// Check Grok availability if requested
if (mode === 'grok') {
const { isGrokAvailable, getGrokNotFoundMessage } = await import('../../utils/grok-cli-resolver.js');
if (!isGrokAvailable()) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getGrokNotFoundMessage());
}
}
// Check DeepSeek Harness availability if requested (binary AND a pane-capable profile).
if (mode === 'deepseek') {
const err = await resolveDeepSeekLaunchError(deepSeekConfig?.profile);
if (err) return createErrorResponse(ApiErrorCode.OPERATION_FAILED, err);
}
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
// external project directories are honoured by quick-start just like regular case routes.
@@ -3219,7 +3150,7 @@ export function registerSessionRoutes(
// reads `.claude` hooks, so a shell/codex quick-start should not author a block
// of its own. Skipped for remote cases — resolvedCasePath is a REMOTE path that
// doesn't exist on the local filesystem.
if (mode === 'claude') {
if (getCli(mode)?.capabilities.hooks === 'always') {
await applyWorkspaceHooks(resolvedCasePath, await ctx.getWorkspaceHooksEnabled());
} else {
await refreshStaleCodemanHooks(resolvedCasePath).catch(() => {});
@@ -3231,7 +3162,7 @@ export function registerSessionRoutes(
// (`.claude/skills/` is a Claude Code surface); skipped for remote cases, whose
// casePath lives on another host. Docker cases qualify: hostWorkspacePath is a
// real host dir and the skill crosses the bind mount like the rest of `.claude/`.
if (!remote && mode === 'claude' && (await ctx.getAgentSkillEnabled())) {
if (!remote && getCli(mode)?.capabilities.agentSkillInjection && (await ctx.getAgentSkillEnabled())) {
await injectAgentSkill(resolvedCasePath);
}
@@ -3242,7 +3173,7 @@ export function registerSessionRoutes(
// shell or external-CLI quick-start must not author a block of its own (the same
// rule the existing-case branch above states; this branch used to exclude just
// the five external CLIs and let `shell` through).
if (docker && docker.hooksEnabled && mode === 'claude') {
if (docker && docker.hooksEnabled && getCli(mode)?.capabilities.hooks === 'always') {
try {
if (!existsSync(join(resolvedCasePath, 'CLAUDE.md'))) {
const templatePath = await ctx.getDefaultClaudeMdPath();
@@ -3264,25 +3195,14 @@ export function registerSessionRoutes(
// Model override → <case>/.claude/settings.local.json (claude-mode; local AND
// docker — the docker workspace is a real host dir, so the settings file crosses
// the bind mount and the in-container claude reads it). Remote was rejected above.
if (mode === 'claude' && modelOverride !== undefined) {
if (getCli(mode)?.capabilities.model.source === 'claude-settings-file' && modelOverride !== undefined) {
await updateCaseModel(resolvedCasePath, modelOverride || null);
}
// Strip stale disk entries for keys this request is actively setting (Claude only —
// see POST /api/sessions for full rationale).
if (
mode !== 'opencode' &&
mode !== 'codex' &&
mode !== 'gemini' &&
mode !== 'antigravity' &&
mode !== 'pi' &&
mode !== 'grok' &&
mode !== 'deepseek' &&
mode !== 'omp' &&
!remote &&
envOverrides &&
Object.keys(envOverrides).length > 0
) {
// Same chain, same replacement as the create path above: byte-identical, drift-proof.
if (!isExternalCliMode(mode) && !remote && envOverrides && Object.keys(envOverrides).length > 0) {
await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides));
}
@@ -3290,25 +3210,22 @@ export function registerSessionRoutes(
// Apply global Nice priority config and model config from settings
const niceConfig = await ctx.getGlobalNiceConfig();
const qsModelConfig = await ctx.getModelConfig();
// See the create path for why this is a capability rather than a mode ladder.
const qsModelSource = getCli(mode)?.capabilities.model;
const qsModel =
mode === 'opencode'
? openCodeConfig?.model
: mode === 'codex'
? codexConfig?.model
: mode === 'gemini'
? geminiConfig?.model
: mode === 'antigravity'
? antigravityConfig?.model
: mode === 'pi'
? piConfig?.model
: mode === 'grok'
? grokConfig?.model
: mode === 'omp'
? ompConfig?.model
: // DeepSeek's model lives in the profile's config tree, not here.
mode !== 'shell' && mode !== 'deepseek'
? qsModelConfig?.defaultModel || undefined
: undefined;
qsModelSource?.source === 'flag'
? (legacyConfigForMode(mode, {
openCodeConfig,
codexConfig,
geminiConfig,
antigravityConfig,
piConfig,
grokConfig,
deepSeekConfig,
} as unknown as Record<string, unknown>)?.[qsModelSource.param ?? 'model'] as string | undefined)
: qsModelSource?.source === 'claude-settings-file'
? qsModelConfig?.defaultModel || undefined
: undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const qsEffectiveClaudeMode = await resolveClaudeModeForUsername(qsClaudeModeConfig.claudeMode, owner);
// Section 6.3: clamp Codex/Gemini/Antigravity bypass switches for a non-granted owner (no-op single-user/granted).
@@ -3319,7 +3236,7 @@ export function registerSessionRoutes(
piConfig: qsGatedPiConfig,
grokConfig: qsGatedGrokConfig,
deepSeekConfig: qsGatedDeepSeekConfig,
} = await clampExternalCliBypassForOwner(
} = await _clampExternalCliBypassForOwner(
owner,
codexConfig,
geminiConfig,
@@ -3360,7 +3277,7 @@ export function registerSessionRoutes(
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
// so the initial state already has the phrase configured (only if globally enabled)
if (mode === 'claude' && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
if (getCli(mode)?.capabilities.ralph && !remote && !docker && ctx.store.getConfig().ralphEnabled) {
autoConfigureRalph(session, resolvedCasePath, ctx);
if (!session.ralphTracker.enabled) {
session.ralphTracker.enable();
@@ -3374,7 +3291,7 @@ export function registerSessionRoutes(
await ctx.setupSessionListeners(session);
// Pre-seed the agent skill's preamble cache so its §0 bootstrap is a two-line
// loader (see seedAgentSessionPreamble). Local claude sessions only; best-effort.
if (mode === 'claude' && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
if (getCli(mode)?.capabilities.agentSkillInjection && !remote && !docker && (await ctx.getAgentSkillEnabled())) {
await seedAgentSessionPreamble(session.id).catch((err: unknown) =>
console.warn(`[agent-skill] preamble seed failed for ${session.id}: ${getErrorMessage(err)}`)
);
@@ -3389,7 +3306,7 @@ export function registerSessionRoutes(
// Start in the appropriate mode
try {
if (mode === 'shell') {
if (getCli(mode)?.capabilities.startMode === 'shell') {
await session.startShell();
getLifecycleLog().log({
event: 'started',
+3 -1
View File
@@ -5,6 +5,7 @@
*/
import { FastifyInstance } from 'fastify';
import { getCli } from '../../config/cli-registry/registry.js';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
@@ -1040,7 +1041,8 @@ export function registerSystemRoutes(
if (statusLineTelemetry === true) {
const dirs = new Set<string>();
for (const session of ctx.sessions.values()) {
if (session.mode === 'claude' && session.workingDir) dirs.add(session.workingDir);
if (getCli(session.mode)?.capabilities.statusLineTelemetry && session.workingDir)
dirs.add(session.workingDir);
}
await Promise.all([...dirs].map((dir) => applyStatusLineConfig(dir, true).catch(() => {})));
}
+89 -39
View File
@@ -18,6 +18,8 @@ import {
} from '../config/terminal-history.js';
import { MAX_EDITABLE_BYTES } from '../config/file-editing.js';
import { MIN_MATCH_LENGTH, MAX_MATCH_LENGTH } from '../config/agent-wait.js';
import { enabledCliIds, enabledClis } from '../config/cli-registry/registry.js';
import type { SessionMode } from '../types.js';
// ========== Path Validation ==========
@@ -119,38 +121,84 @@ export const FileWriteSchema = z
})
.strict();
// ========== Env Var Allowlist ==========
/** Allowlisted env var key prefixes */
const ALLOWED_ENV_PREFIXES = [
'CLAUDE_CODE_',
'OPENCODE_',
'CODEX_',
'GEMINI_',
'GOOGLE_',
'ANTIGRAVITY_',
'PI_',
'GROK_',
'XAI_',
// DeepSeek Harness: `DSH_*` carries the launcher's own documented inputs
// (DSH_HOME, DSH_PERMISSION_MODE, DSH_TELEMETRY_MODE, and the DSH_TUI_* knobs
// the terminal front door reads); `DEEPSEEK_*` is the vendor namespace holding
// DEEPSEEK_API_KEY / DEEPSEEK_BASE_URL, the same narrow-vendor reasoning that
// admitted XAI_* for grok. Foreign provider keys stay out: a dsh settings.yaml
// can name ANY env var as a provider credential (apiKeyEnv), which is pi's
// 34-provider-key problem in a new shape, and the answer is the same one.
'DSH_',
'DEEPSEEK_',
'OMP_',
];
/**
* The run-mode ids the API currently accepts: every ENABLED registry entry.
*
* Exported so anything needing the authoritative list derives it from here rather than
* restating the nine names (which is how the old literal enum drifted from the run menu).
*/
export function sessionModeIds(): string[] {
return enabledCliIds();
}
/**
* Allowlisted exact env var keys (checked alongside the prefixes).
* CLAUDE_CONFIG_DIR relocates the Claude CLI's user config (credentials,
* settings, stats) so a case can run on a separate Claude subscription (#255).
* Exact match only — CLAUDE_CONFIG_DIR_EXTRA etc. stay rejected.
* Validation for a run mode, resolved AT PARSE TIME.
*
* ⚠️ Deliberately not a `z.enum([...])`. An enum has to be handed its members when the
* SCHEMA OBJECT is built, which happens once at module import — so a CLI enabled while the
* server was running kept failing validation with INVALID_INPUT until a restart, even
* though the run menu already offered it. Checking membership inside the refinement moves
* the question to when the request is actually validated.
*
* The cast is because callers type this field as `SessionMode`; the runtime check above is
* what actually constrains it.
*/
const ALLOWED_ENV_KEYS = new Set(['CLAUDE_CONFIG_DIR']);
function sessionModeSchema(): z.ZodType<SessionMode> {
return (
z
.string()
// Bounded BEFORE the membership check, and before the failure message quotes the value
// back. `.max(24)` matches the `cliId` pattern in cli-registry/schema.ts — no id longer
// than that can ever be registered, so nothing legitimate is rejected — and it means a
// rejected mode cannot echo a body-limit-sized string into an error string and a log
// line. Without it the only bound on either was the HTTP body limit.
.max(24)
.superRefine((value, ctx) => {
const allowed = sessionModeIds();
if (!allowed.includes(value)) {
ctx.addIssue({
code: 'custom',
message: `Invalid run mode ${JSON.stringify(value)}. Enabled modes: ${allowed.join(', ')}`,
});
}
}) as unknown as z.ZodType<SessionMode>
);
}
// ========== Env Var Allowlist ==========
/**
* Allowlisted env var key prefixes, contributed by the ENABLED CLIs in the registry
* (`env.allowedPrefixes`) — `CLAUDE_CODE_`, `OPENCODE_`, `CODEX_`, `GEMINI_`, `GOOGLE_`,
* `ANTIGRAVITY_`, `PI_`, `GROK_`, `XAI_`, `DSH_`, `DEEPSEEK_` as shipped.
*
* ⚠️ Resolved AT PARSE TIME, not at module load. This used to be a frozen array computed
* once when the module was imported, which meant a CLI enabled while the server was running
* had its env prefix rejected until a restart — validation and the run menu disagreeing
* about which CLIs exist. Reading the registry per call costs a memoized array lookup.
*
* ⚠️ This is ONE GLOBAL LIST applied with no mode context, so admitting a prefix for one CLI
* widens it for every mode at once. That is why an entry only ever contributes its own
* VENDOR namespace: pi's ~34 provider keys (ANTHROPIC_API_KEY, OPENAI_API_KEY, HF_TOKEN, …)
* share no prefix and stay out, and a dsh `settings.yaml` can nominate ANY env var as a
* provider credential — same problem, same answer. Those CLIs authenticate via their own
* `/login` or the server process's own environment.
*/
function allowedEnvPrefixes(): string[] {
return enabledClis().flatMap((entry) => entry.env.allowedPrefixes);
}
/**
* Allowlisted exact env var keys (checked alongside the prefixes), likewise contributed by
* enabled registry entries via `env.allowedKeys`.
*
* As shipped this is claude's CLAUDE_CONFIG_DIR, which relocates the Claude CLI's user
* config (credentials, settings, stats) so a case can run on a separate Claude subscription
* (#255). Exact match only — CLAUDE_CONFIG_DIR_EXTRA etc. stay rejected.
*/
function allowedEnvKeys(): Set<string> {
return new Set(enabledClis().flatMap((entry) => entry.env.allowedKeys));
}
/** Env var keys that are always blocked (security-sensitive) */
const BLOCKED_ENV_KEYS = new Set([
@@ -163,11 +211,17 @@ const BLOCKED_ENV_KEYS = new Set([
'OPENCODE_SERVER_PASSWORD', // Security-sensitive: server auth password
]);
/** Validate that an env var key is allowed */
/**
* Validate that an env var key is allowed.
*
* ⚠️ `BLOCKED_ENV_KEYS` is checked FIRST and is deliberately NOT registry-driven. It is a
* hard floor: a rogue or fat-fingered `allowedPrefixes` entry (say `''`, which prefixes
* everything) still cannot unblock PATH or LD_PRELOAD.
*/
function isAllowedEnvKey(key: string): boolean {
if (BLOCKED_ENV_KEYS.has(key)) return false;
if (ALLOWED_ENV_KEYS.has(key)) return true;
return ALLOWED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix));
if (allowedEnvKeys().has(key)) return true;
return allowedEnvPrefixes().some((prefix) => key.startsWith(prefix));
}
/** Zod schema for env overrides with allowlist enforcement */
@@ -460,9 +514,7 @@ const parentSessionIdSchema = z.string().max(100).optional();
export const CreateSessionSchema = z.object({
workingDir: safePathSchema.optional(),
mode: z
.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp'])
.optional(),
mode: sessionModeSchema().optional(),
name: z.string().max(100).optional(),
/** Session that spawned this one — see parentSessionIdSchema. */
parentSessionId: parentSessionIdSchema,
@@ -892,9 +944,7 @@ export const QuickStartSchema = z.object({
* a real host dir, so the settings file crosses the bind mount); rejected for
* remote cases (the file would be written on the WRONG machine). */
modelOverride: z.string().max(50).optional(),
mode: z
.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp'])
.optional(),
mode: sessionModeSchema().optional(),
openCodeConfig: OpenCodeConfigSchema,
codexConfig: CodexConfigSchema,
geminiConfig: GeminiConfigSchema,
@@ -1436,7 +1486,7 @@ const noNewlines = (v: string) => !/[\r\n]/.test(v);
/** Shared field shape for creating/updating a scheduled job. */
const CronJobBaseSchema = z.object({
name: z.string().min(1).max(200),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini', 'antigravity', 'pi', 'grok', 'deepseek', 'omp']),
agentType: sessionModeSchema(),
workingDir: safePathSchema,
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
promptMode: z.enum(['inline_text', 'prompt_file_path']),
+21 -12
View File
@@ -65,6 +65,7 @@ import {
MAX_SNIPPET_CONTEXT,
} from '../config/agent-wait.js';
import type { SessionMode, SessionStatus } from '../types.js';
import { getCli } from '../config/cli-registry/registry.js';
// ─── Signals ─────────────────────────────────────────────────────────────────
@@ -174,7 +175,7 @@ const HOOK_ONLY_SIGNALS: readonly WaitSignal[] = ['stop', 'blocked'];
export interface HookCapabilityOptions {
/**
* `deepSeekConfig.statusReporting`, verbatim (so `undefined` means "not sent",
* i.e. ON). `false` is the per-session opt-out that stops `_configureDeepSeek()`
* i.e. ON). `false` is the per-session opt-out that stops `_configureCliEnv()`
* exporting the `HERDR_*` triple, which is the ONLY thing that makes a dsh
* session emit hook events at all.
*/
@@ -223,18 +224,26 @@ export interface HookCapabilityOptions {
* function only about hook SIGNALS.
*/
export function hooksAvailableForMode(mode: SessionMode, options: HookCapabilityOptions = {}): boolean {
if (mode === 'claude') return true;
// `deepseek` earns this the same way `claude` does — by emitting DEFINITIVE
// signals rather than having them inferred. The DeepSeek Harness terminal
// front door reports idle/working/blocked to its supervisor, and Codeman is
// that supervisor (see deepseek-status-shim.ts), so a dsh session really can
// deliver `stop` and `blocked` — unless the user turned the bridge off, in
// which case nothing on the box will ever post one. Every other mode is
// output-stabilization guesswork and must keep failing the ask.
if (mode === 'deepseek') {
return options.deepSeekStatusReporting !== false && options.deepSeekBridgeUnreachable !== true;
// A TRI-state capability, not a boolean, because the three answers are genuinely
// different questions — see CliCapabilities.hooks.
switch (getCli(mode)?.capabilities.hooks) {
case 'always':
// The CLI installs Codeman's own hooks block into its workspace (claude), so the
// signals are unconditional.
return true;
case 'supervised':
// The CLI REPORTS its own state to a supervisor and Codeman is that supervisor
// (deepseek, via deepseek-status-shim.ts) — definitive signals rather than inferred
// ones, which is what earns it a yes. But the user can disarm the bridge, and a
// docker/remote session cannot reach it at all; in either case nothing on the box
// will ever post one, so the answer has to come from the SESSION, not the mode.
return options.deepSeekStatusReporting !== false && options.deepSeekBridgeUnreachable !== true;
default:
// 'none', and an unregistered mode. Every other CLI's idle is output-stabilization
// guesswork, and must keep failing the ask rather than promising a signal that never
// arrives.
return false;
}
return false;
}
/**