mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 06:29:42 +02:00
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:
+3
-4
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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()],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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
@@ -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',
|
||||
|
||||
@@ -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
@@ -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']),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user