Files
Codeman/src/config/cli-registry/schema.ts
T
DevvynandClaude Sonnet 5 e2034177c5 fix(custom-model): root-cause and fix DeepSeek's HTTP_404 (missing /v1)
DeepSeek Harness's own bundled provider module
(@deepseek-ai/dsh-llm-deepseek) builds its request URL as
`${DEEPSEEK_BASE_URL}/chat/completions` with no `/v1` insertion of its
own (its real public API, https://api.deepseek.com, expects the
caller's base URL to already carry any needed prefix), while
llama-swap/llama.cpp only ever serves the OpenAI-conventional
`/v1/chat/completions`.

Confirmed two ways:
- Installed the real @deepseek-ai/dsh package (all its actual
  published dependencies) into a scratch dir purely to read
  dsh-llm-deepseek's source: `fetch(`${connection.baseURL}/chat/
  completions`, ...)`, baseURL read straight from DEEPSEEK_BASE_URL —
  the same grep-the-real-source bar pi/grok's fixes were held to.
- Live against the test-picker's llama-swap: `POST <baseUrl>/chat/
  completions` -> 404, `POST <baseUrl>/v1/chat/completions` -> 200,
  same endpoint. dsh's own error template ("DeepSeek API error (HTTP
  ${status})") reproduces the originally-reported
  "dsh: HTTP_404: DeepSeek API error (HTTP 404)" exactly.

- New registry field `appendV1Suffix` (env kind only, deepseek's entry
  alone — claude/gemini must NOT get it, since claude was already
  confirmed working against the unmodified baseUrl). When set,
  buildCustomModelInjection runs endpoint.baseUrl through the same
  withV1Suffix() helper configDir-kind CLIs (pi/grok/codex) already
  use, instead of writing it verbatim.

Not yet re-run end-to-end through a real dsh binary — no install
available in this environment (not in PATH, and the test-picker
container doesn't bundle it) — so this is source-confirmed and
live-verified at the HTTP level, not yet promoted to "verified"
alongside claude/opencode/pi/grok/omp. Docs (custom-model-endpoints.md,
the plan doc's confidence table, the wiki page, CLAUDE.md) all updated
to reflect this precisely rather than leaving the old "root cause not
identified" claim in place.

2 new/updated tests for the /v1 suffix (including idempotency against
a baseUrl that already ends in /v1) plus a corrected mock-server
contract test. Typecheck/lint clean; full suite shows no new
regressions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqZeHrRS6DYcGcGX2p9EwG
2026-09-17 13:41:19 +08:00

526 lines
20 KiB
TypeScript

/**
* @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 { compileVersionRegex, 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(),
// Requires a `reason` on purpose — see the field's own doc comment in types.ts. A
// dedicated agent-image layer with no stated reason is a silent id-keyed special case
// rebuilding itself inside the data this change moved it out of.
agentImageLayer: z
.object({ kind: z.literal('dedicated'), reason: z.string().min(1).max(300) })
.strict()
.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();
/**
* `capabilities.customModelInjection.launchModel`: the `model` launch-param value that
* selects the injected provider, with `{modelId}` standing for the chosen id. Bounded to
* the characters the `model`/`model-pi` token patterns accept plus the placeholder braces,
* so a template can never smuggle a token the argv engine would have to quote.
*/
const launchModelTemplate = z
.string()
.min(1)
.max(120)
.regex(/^[a-zA-Z0-9._\-/:{}]+$/)
.optional();
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(),
workDetect: z
.object({
promptGlyph: z.string().min(1).max(8),
// Config-supplied regex, so it goes through the same guard as `version.regex`:
// ~/.codeman/clis.json can set this, and the compiled pattern runs on the PTY
// hot path, where a nested quantifier would be a ReDoS against the event loop.
// A broken pattern must also fail at LOAD time rather than inside a data handler.
workingLine: z
.string()
.min(1)
.refine(
(src) => compileVersionRegex(src) !== null,
'workingLine must be a regex compileVersionRegex() accepts: at most 200 characters, no nested quantifiers'
),
})
.strict()
.optional(),
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(),
customModelInjection: z.discriminatedUnion('kind', [
z
.object({
kind: z.literal('env'),
baseUrlVar: envName,
apiKeyVar: envName,
// Empty is valid: deepseek's model routing is a profile-composition concern, not
// an env var, so it declares baseUrl/apiKey injection with no model var at all.
modelVars: z.array(envName).max(8),
launchModel: launchModelTemplate,
// Optional: the env var to carry a discovered per-model context-window size
// (claude's CLAUDE_CODE_MAX_CONTEXT_TOKENS), and/or the env var that isolates
// this session's config/credential directory from the user's real one (claude's
// CLAUDE_CONFIG_DIR) so an injected API key never collides with a stored OAuth
// session. See the customModelInjection doc comment in cli-registry/types.ts.
contextLengthVar: envName.optional(),
configDirVar: envName.optional(),
// Relative path, WITHIN the isolated configDirVar directory, of a trust-dialog
// seed file the CLI itself owns the shape of — claude's `.claude.json`
// `customApiKeyResponses.approved` list, the same field an interactive "Detected
// a custom API key — use it?" prompt writes to on a real terminal. Only makes
// sense alongside configDirVar (an isolated, otherwise-empty directory has none
// of a real profile's prior approvals), and only implemented for the
// 'claude-api-key-responses' shape today — see custom-model-injection-apply.ts.
apiKeyTrustFile: z
.object({ relPath: z.string().min(1).max(80), shape: z.literal('claude-api-key-responses') })
.strict()
.optional(),
// An isolated config directory replays the CLI's whole first-run sequence (theme
// picker, security notes, per-project trust dialog, bypass-permissions warning)
// on every launch, same root cause as apiKeyTrustFile above — this reuses that
// same file to pre-seed the state a real, already-onboarded profile carries. See
// the customModelInjection doc comment in cli-registry/types.ts.
skipFirstRunPrompts: z.boolean().optional(),
// DeepSeek-only, confirmed by reading its own bundled SDK source: it concatenates
// "/chat/completions" onto baseUrlVar's value with no "/v1" of its own, while
// llama-swap/llama.cpp only serves the "/v1/..." path — claude/gemini must NOT
// get this. See the customModelInjection doc comment in cli-registry/types.ts.
appendV1Suffix: z.boolean().optional(),
})
.strict(),
z
.object({
kind: z.literal('configContentEnv'),
envVar: envName,
template: z.literal('opencode-json'),
launchModel: launchModelTemplate,
})
.strict(),
z
.object({
kind: z.literal('configDir'),
dirEnvVar: envName,
fileName: z.string().min(1).max(80),
template: z.enum(['codex-toml', 'pi-models-json', 'omp-models-yml', 'grok-toml']),
launchModel: launchModelTemplate,
})
.strict(),
z.object({ kind: z.literal('unsupported') }).strict(),
]),
})
.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(), rootCommand: 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>;