mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
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
526 lines
20 KiB
TypeScript
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>;
|