mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
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
122 lines
5.1 KiB
TypeScript
122 lines
5.1 KiB
TypeScript
/**
|
|
* @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;
|
|
}
|
|
}
|