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
+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);