fix(cli-registry): codex launch defaults as registry data, ultra footer, schema doc defaults

- Codex footer model detection (c28): the modelDetect.screenLine effort
  alternation is now built from CODEX_REASONING_EFFORTS plus 'default', so
  'ultra' (offered by the codexReasoningEffort App Setting and codex's own
  /model picker) is read and the launch enum and the footer reader cannot
  drift again. Still one capture group, 125 characters, no new quantifier.
  New session-display-model case loops every effort level, ultra included.

- No CLI-id branching for launch defaults (c27): the two mode === 'codex'
  branches the synced codex model/effort defaults added to the create and
  quick-start routes are replaced by a registry capability,
  capabilities.launchDefaults (launch param -> settings key, values from a
  closed enum), declared on the codex entry only. The resolver moved from
  web/codex-launch-defaults.ts to web/launch-defaults.ts as
  applyLaunchDefaults(mode, configs, customEndpoint), filling the entry's
  legacyConfigField object through legacyConfigAliases, still re-validating
  with SettingsUpdateSchema and never overwriting a caller's value. The
  route exclusions are unchanged (create: not remote; quick-start: not
  remote, not Docker, not a custom model endpoint), and quick-start still
  derives the session model from a bag without ompConfig, as before.
  schema.ts refuses an undeclared param, an unknown settings key, an empty
  map, and launchDefaults on an entry with no legacyConfigField.

- The no-id-branching guard now carries an exact occurrence count per
  allowlisted key, so a new copy of an already approved expression fails
  instead of riding the old approval, with a synthetic anti-vacuity case.

- SettingsUpdateSchema JSDoc (c21/c29): 'classic' is the tabArrangement
  default and 'compact' the headerStatsStyle default, matching the
  resolvers and the pre-paint script; state/case/ledger are marked opt-in.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-10-09 09:35:56 +02:00
parent 82c87f56d1
commit ecd577157b
12 changed files with 456 additions and 135 deletions
+3
View File
@@ -51,6 +51,9 @@ interface CliEntry {
// that ended waiting for workers it will resume from // that ended waiting for workers it will resume from
// .modelDetect?: { screenLine, screenLines? } // .modelDetect?: { screenLine, screenLines? }
// (where this CLI's own chrome names the model it runs: SessionState.displayModel) // (where this CLI's own chrome names the model it runs: SessionState.displayModel)
// .launchDefaults?: { [launchParam]: settingsKey }
// (synced App Settings that seed a LOCAL launch's params the caller left unset;
// codex's model and reasoning effort, via src/web/launch-defaults.ts)
overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store overlays: CliOverlays; // remote-SSH / Docker pane commands, credential store
} }
``` ```
+36 -1
View File
@@ -15,7 +15,7 @@
import { z } from 'zod'; import { z } from 'zod';
import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js'; import { compileVersionRegex, countCaptureGroups, TOKEN_PATTERNS } from './patterns.js';
import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js'; import { isKnownLauncherProfile, isKnownSetenvProfile } from './profiles.js';
import type { McpConfigFormat, ModelConfigResolverName } from './types.js'; import type { LaunchDefaultSettingKey, McpConfigFormat, ModelConfigResolverName } from './types.js';
/** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */ /** A bare CLI id: lowercase, starts with a letter, at most 24 chars. Also used as a CSS/URL token. */
const cliId = z const cliId = z
@@ -409,6 +409,17 @@ const capabilitiesSchema = z
'rejectWords has nothing to filter without a screenLine' 'rejectWords has nothing to filter without a screenLine'
) )
.optional(), .optional(),
// Launch param -> synced App Settings key. The values are a closed enum, like
// configResolver: a clis.json override names one of the settings this build knows
// how to validate, never an arbitrary key. Params are checked against the declared
// ones in the superRefine below.
launchDefaults: z
.record(
z.string(),
z.enum(['codexModel', 'codexReasoningEffort'] as const satisfies readonly LaunchDefaultSettingKey[])
)
.refine((v) => Object.keys(v).length >= 1 && Object.keys(v).length <= 8, 'launchDefaults takes 1 to 8 params')
.optional(),
privilegedParams: z privilegedParams: z
.array( .array(
z z
@@ -627,6 +638,30 @@ export const CliEntrySchema = z
} }
}); });
// Same silent-no-op class again: a launch default for a param the entry never declared
// would be filled into the config object and then read by nothing. And without a
// `legacyConfigField` the entry's params are read off the request body itself, where a
// filled `model` would be a different field (claude's per-session one), so refuse it.
const { launchDefaults } = entry.capabilities;
if (launchDefaults !== undefined) {
if (entry.launch.legacyConfigField === undefined) {
ctx.addIssue({
code: 'custom',
message: 'launchDefaults needs launch.legacyConfigField to fill',
path: ['capabilities', 'launchDefaults'],
});
}
for (const param of Object.keys(launchDefaults)) {
if (!declaredParams.has(param)) {
ctx.addIssue({
code: 'custom',
message: `launchDefaults param "${param}" is not a declared launch param`,
path: ['capabilities', 'launchDefaults', param],
});
}
}
}
const { setenvProfile } = entry.env; const { setenvProfile } = entry.env;
if (setenvProfile !== undefined && !isKnownSetenvProfile(setenvProfile)) { if (setenvProfile !== undefined && !isKnownSetenvProfile(setenvProfile)) {
ctx.addIssue({ ctx.addIssue({
+10 -1
View File
@@ -671,6 +671,11 @@ const CODEX: CliEntry = {
// popup or a bare line of prose does not have that shape. A footer without an effort // popup or a bare line of prose does not have that shape. A footer without an effort
// word (a model with no reasoning setting) is not read, and the session keeps its // word (a model with no reasoning setting) is not read, and the session keeps its
// last known or launch model. // last known or launch model.
// The effort words are built from CODEX_REASONING_EFFORTS, the same list the
// `reasoningEffort` launch param above admits, plus `default` (what codex prints when
// no effort is configured). A hand-kept copy once left out `ultra`, so a session at
// that level never named its model. Every word is plain letters, so the join adds no
// quantifier and only a few characters to the 200-character compileVersionRegex cap.
// ⚠️ It is not always the LAST row. 0.162.0 (measured 2026-10-09) adds a hint row // ⚠️ It is not always the LAST row. 0.162.0 (measured 2026-10-09) adds a hint row
// under it at rest, ` ← for agents · ? for shortcuts` or ` ? for shortcuts`, and // under it at rest, ` ← for agents · ? for shortcuts` or ` ? for shortcuts`, and
// drops it again while a prompt is being typed. With a one-row window the footer was // drops it again while a prompt is being typed. With a one-row window the footer was
@@ -681,9 +686,13 @@ const CODEX: CliEntry = {
// and the `›` composer, and a forged footer-shaped transcript line is not followed by // and the `›` composer, and a forged footer-shaped transcript line is not followed by
// an indented row, so it is not read. // an indented row, so it is not read.
modelDetect: { modelDetect: {
screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:none|minimal|low|medium|high|xhigh|max|default) · [^\n]*(?:\n {2}[^\n]*)?$`, screenLine: String.raw`(?:^|\n) {2}([A-Za-z0-9][\w.:/@+-]{0,79}) (?:${[...CODEX_REASONING_EFFORTS, 'default'].join('|')}) · [^\n]*(?:\n {2}[^\n]*)?$`,
screenLines: 2, screenLines: 2,
}, },
// App Settings → Codex model / reasoning effort (synced), filled into a LOCAL launch's
// codexConfig wherever the caller left the field unset. Launch-only: nothing writes
// codex's own config.toml. Read by applyLaunchDefaults() in src/web/launch-defaults.ts.
launchDefaults: { model: 'codexModel', reasoningEffort: 'codexReasoningEffort' },
// Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠` // Two columns, like claude's, measured on a live 0.154.0 answer: the `•`/`›`/`⚠`
// markers sit in the gutter, prose continuations sit at 2, and a nested YAML block // markers sit in the gutter, prose continuations sit at 2, and a nested YAML block
// the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120, // the model wrote rendered at 2/4/6/8 for its own 0/2/4/6. Replayed at 100, 120,
+22
View File
@@ -96,6 +96,14 @@ export type NewlineSequence = 'line-feed' | 'esc-enter';
/** The config readers `capabilities.modelDetect.configResolver` may name (src/model-config-resolvers.ts). */ /** The config readers `capabilities.modelDetect.configResolver` may name (src/model-config-resolvers.ts). */
export type ModelConfigResolverName = 'deepseek-route'; export type ModelConfigResolverName = 'deepseek-route';
/**
* The synced App Settings keys `capabilities.launchDefaults` may name (src/web/launch-defaults.ts).
* A closed list rather than any settings key, so a clis.json override cannot feed an
* arbitrary setting onto a command line; each name must also be a `SettingsUpdateSchema`
* key, which the resolver's typing enforces.
*/
export type LaunchDefaultSettingKey = 'codexModel' | 'codexReasoningEffort';
/** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */ /** The MCP config dialects `src/mcp-sync.ts` has an adapter for. */
export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json'; export type McpConfigFormat = 'claude-json' | 'gemini-json' | 'codex-toml' | 'opencode-json' | 'antigravity-json';
@@ -529,6 +537,20 @@ export interface CliCapabilities {
rejectWords?: string[]; rejectWords?: string[];
configResolver?: ModelConfigResolverName; configResolver?: ModelConfigResolverName;
}; };
/**
* Synced App Settings that seed this CLI's launch params when the caller left them unset,
* keyed by LAUNCH PARAM name (`{ model: 'codexModel' }`), never the legacy wire name; the
* resolver translates through `launch.legacyConfigAliases` like every other `param`.
*
* Filled into the entry's `launch.legacyConfigField` object at create time by
* `applyLaunchDefaults()` (src/web/launch-defaults.ts), which re-validates each value
* with `SettingsUpdateSchema` and never overwrites a value the caller sent. Which
* launches get it is the CALLER's decision (local ones only: never remote, Docker or a
* custom model endpoint). `schema.ts` refuses an undeclared param, and an entry without
* a `legacyConfigField`, whose params would otherwise be read off the request body itself.
* Absent = no launch defaults.
*/
launchDefaults?: Record<string, LaunchDefaultSettingKey>;
/** /**
* Params a non-granted multi-user owner may not set freely, and what they are forced to. * 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. * Data-driven so a CUSTOM CLI's bypass flag is clampable exactly like codex's.
-35
View File
@@ -1,35 +0,0 @@
/**
* @fileoverview Launch-time defaults for Codex sessions.
*
* Resolves the synced App Settings `codexModel` / `codexReasoningEffort` into the
* `codexConfig` a launch uses, filling ONLY the fields the caller left unset.
* Persisted values are re-validated with `SettingsUpdateSchema`, so a hand-edited
* settings.json can never smuggle an unchecked value onto the codex command line.
*
* Scope is the caller's decision: the create and quick-start routes apply it to
* local launches only, never to remote, Docker or custom-endpoint launches.
* Nothing here writes Codex's own config files.
*/
import type { CodexConfig } from '../types.js';
import { SettingsUpdateSchema } from './schemas.js';
import { readJsonConfig, SETTINGS_PATH } from './route-helpers.js';
/** Resolve launch-only defaults without changing Codex's own configuration files. */
export async function resolveCodexLaunchDefaults(
config: CodexConfig | undefined,
customEndpoint = false
): Promise<CodexConfig | undefined> {
if (customEndpoint) return config;
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'Codex launch defaults', {});
const model = SettingsUpdateSchema.shape.codexModel.safeParse(settings.codexModel);
const effort = SettingsUpdateSchema.shape.codexReasoningEffort.safeParse(settings.codexReasoningEffort);
const defaultModel = model.success ? model.data || undefined : undefined;
const defaultEffort = effort.success ? effort.data || undefined : undefined;
if (!defaultModel && !defaultEffort) return config;
return {
...config,
model: config?.model ?? defaultModel,
reasoningEffort: config?.reasoningEffort ?? defaultEffort,
};
}
+49
View File
@@ -0,0 +1,49 @@
/**
* @fileoverview Launch-time defaults from synced App Settings, driven by registry data.
*
* A CLI entry declares `capabilities.launchDefaults` (launch param -> settings key; today
* only codex, `{ model: 'codexModel', reasoningEffort: 'codexReasoningEffort' }`), and
* `applyLaunchDefaults()` fills those settings into the entry's `launch.legacyConfigField`
* object, setting ONLY the fields the caller left unset. Persisted values are re-validated
* with `SettingsUpdateSchema`, so a hand-edited settings.json can never smuggle an
* unchecked value onto the command line.
*
* Scope is the caller's decision: the create and quick-start routes apply it to local
* launches only, never to remote, Docker or custom-endpoint launches. Nothing here writes
* a CLI's own config files.
*/
import { getCli } from '../config/cli-registry/registry.js';
import { SettingsUpdateSchema } from './schemas.js';
import { readJsonConfig, SETTINGS_PATH } from './route-helpers.js';
/**
* Return `configs` with the launch defaults of `mode`'s registry entry filled into its
* legacy config object (e.g. `codexConfig`). Every other field of `configs` is passed
* through untouched, and `configs` itself comes back unchanged (same object) when the entry
* declares no defaults, `customEndpoint` is set, or no setting names a value.
*/
export async function applyLaunchDefaults<T extends object>(
mode: string,
configs: T,
customEndpoint = false
): Promise<T> {
const entry = getCli(mode);
const declared = entry?.capabilities.launchDefaults;
const field = entry?.launch.legacyConfigField;
if (customEndpoint || !declared || field === undefined) return configs;
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'CLI launch defaults', {});
const aliases = entry.launch.legacyConfigAliases ?? {};
const current = (configs as Record<string, unknown>)[field] as Record<string, unknown> | undefined;
const defaults: Record<string, unknown> = {};
for (const [param, settingKey] of Object.entries(declared)) {
const parsed = SettingsUpdateSchema.shape[settingKey].safeParse(settings[settingKey]);
// '' is the settings' "leave it to the CLI" value, the same as unset.
const value = parsed.success ? parsed.data || undefined : undefined;
const wireKey = aliases[param] ?? param;
if (value !== undefined && (current?.[wireKey] ?? undefined) === undefined) defaults[wireKey] = value;
}
if (Object.keys(defaults).length === 0) return configs;
return { ...configs, [field]: { ...current, ...defaults } };
}
+43 -34
View File
@@ -116,7 +116,7 @@ import { clampEnvOverridesForOwner } from '../../session-env-clamp.js';
import { enabledClis, getCli } from '../../config/cli-registry/registry.js'; import { enabledClis, getCli } from '../../config/cli-registry/registry.js';
import type { NewlineSequence } from '../../config/cli-registry/types.js'; import type { NewlineSequence } from '../../config/cli-registry/types.js';
import { resolveCliLaunchError } from '../../utils/cli-launcher.js'; import { resolveCliLaunchError } from '../../utils/cli-launcher.js';
import { resolveCodexLaunchDefaults } from '../codex-launch-defaults.js'; import { applyLaunchDefaults } from '../launch-defaults.js';
import { legacyConfigForMode } from '../../session-cli-registry-bridge.js'; import { legacyConfigForMode } from '../../session-cli-registry-bridge.js';
import { isMultiUserMode } from '../../config/multiuser.js'; import { isMultiUserMode } from '../../config/multiuser.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
@@ -1150,8 +1150,10 @@ export function registerSessionRoutes(
const globalNice = await ctx.getGlobalNiceConfig(); const globalNice = await ctx.getGlobalNiceConfig();
const modelConfig = await ctx.getModelConfig(); const modelConfig = await ctx.getModelConfig();
const mode = body.mode || 'claude'; const mode = body.mode || 'claude';
const launchCodexConfig = // Synced App Settings launch defaults (capabilities.launchDefaults, codex's model and
mode === 'codex' && !remote ? await resolveCodexLaunchDefaults(body.codexConfig) : body.codexConfig; // effort today) fill the CLI's own config object where the caller left it unset.
// Local launches only: a remote attach runs whatever the remote pane already runs.
const launchBody = remote ? body : await applyLaunchDefaults(mode, body);
// Where a model override comes from is a capability, and the three answers are // Where a model override comes from is a capability, and the three answers are
// genuinely different mechanisms: // genuinely different mechanisms:
// 'flag' — the CLI takes --model, so read the value the caller sent // 'flag' — the CLI takes --model, so read the value the caller sent
@@ -1166,10 +1168,9 @@ export function registerSessionRoutes(
const modelSource = getCli(mode)?.capabilities.model; const modelSource = getCli(mode)?.capabilities.model;
const model = const model =
modelSource?.source === 'flag' modelSource?.source === 'flag'
? (legacyConfigForMode(mode, { ...body, codexConfig: launchCodexConfig } as unknown as Record< ? (legacyConfigForMode(mode, launchBody as unknown as Record<string, unknown>)?.[
string, modelSource.param ?? 'model'
unknown ] as string | undefined)
>)?.[modelSource.param ?? 'model'] as string | undefined)
: modelSource?.source === 'claude-settings-file' : modelSource?.source === 'claude-settings-file'
? body.model || modelConfig?.defaultModel || undefined ? body.model || modelConfig?.defaultModel || undefined
: undefined; : undefined;
@@ -1186,12 +1187,12 @@ export function registerSessionRoutes(
deepSeekConfig: gatedDeepSeekConfig, deepSeekConfig: gatedDeepSeekConfig,
} = await _clampExternalCliBypassForOwner( } = await _clampExternalCliBypassForOwner(
owner, owner,
launchCodexConfig, launchBody.codexConfig,
body.geminiConfig, launchBody.geminiConfig,
body.antigravityConfig, launchBody.antigravityConfig,
body.piConfig, launchBody.piConfig,
body.grokConfig, launchBody.grokConfig,
body.deepSeekConfig launchBody.deepSeekConfig
); );
const terminalHistoryConfig = await ctx.getTerminalHistoryConfig(); const terminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({ const session = new Session({
@@ -1204,14 +1205,14 @@ export function registerSessionRoutes(
model, model,
claudeMode: effectiveClaudeMode, claudeMode: effectiveClaudeMode,
allowedTools: claudeModeConfig.allowedTools, allowedTools: claudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined, openCodeConfig: mode === 'opencode' ? launchBody.openCodeConfig : undefined,
codexConfig: mode === 'codex' ? gatedCodexConfig : undefined, codexConfig: mode === 'codex' ? gatedCodexConfig : undefined,
geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined, geminiConfig: mode === 'gemini' ? gatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined, antigravityConfig: mode === 'antigravity' ? gatedAntigravityConfig : undefined,
piConfig: mode === 'pi' ? gatedPiConfig : undefined, piConfig: mode === 'pi' ? gatedPiConfig : undefined,
grokConfig: mode === 'grok' ? gatedGrokConfig : undefined, grokConfig: mode === 'grok' ? gatedGrokConfig : undefined,
deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined, deepSeekConfig: mode === 'deepseek' ? gatedDeepSeekConfig : undefined,
ompConfig: resolveOmpConfigForCreate(mode, workingDir, body.ompConfig), ompConfig: resolveOmpConfigForCreate(mode, workingDir, launchBody.ompConfig),
resumeSessionId: validatedResumeId, resumeSessionId: validatedResumeId,
envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides), envOverrides: await clampEnvOverridesForOwner(owner, body.envOverrides),
effort: body.effort, effort: body.effort,
@@ -3875,23 +3876,31 @@ export function registerSessionRoutes(
// Apply global Nice priority config and model config from settings // Apply global Nice priority config and model config from settings
const niceConfig = await ctx.getGlobalNiceConfig(); const niceConfig = await ctx.getGlobalNiceConfig();
const qsModelConfig = await ctx.getModelConfig(); const qsModelConfig = await ctx.getModelConfig();
const qsLaunchCodexConfig = // Synced App Settings launch defaults, as on the create path: local launches only, so
mode === 'codex' && !remote && !docker // never a remote or Docker case, and never a custom model endpoint launch.
? await resolveCodexLaunchDefaults(codexConfig, !!customModel) const qsRequestConfigs = {
: codexConfig;
// See the create path for why this is a capability rather than a mode ladder.
const qsModelSource = getCli(mode)?.capabilities.model;
const qsModel =
qsModelSource?.source === 'flag'
? (legacyConfigForMode(mode, {
openCodeConfig, openCodeConfig,
codexConfig: qsLaunchCodexConfig, codexConfig,
geminiConfig, geminiConfig,
antigravityConfig, antigravityConfig,
piConfig, piConfig,
grokConfig, grokConfig,
deepSeekConfig, deepSeekConfig,
} as unknown as Record<string, unknown>)?.[qsModelSource.param ?? 'model'] as string | undefined) ompConfig,
};
const qsLaunchConfigs =
remote || docker ? qsRequestConfigs : await applyLaunchDefaults(mode, qsRequestConfigs, !!customModel);
// ⚠️ The model is read from a bag WITHOUT ompConfig, as it always was here: quick-start
// has never taken omp's session model from ompConfig (the create path does). Kept as
// found rather than changed in passing.
const { ompConfig: qsLaunchOmpConfig, ...qsModelConfigs } = qsLaunchConfigs;
// See the create path for why this is a capability rather than a mode ladder.
const qsModelSource = getCli(mode)?.capabilities.model;
const qsModel =
qsModelSource?.source === 'flag'
? (legacyConfigForMode(mode, qsModelConfigs as unknown as Record<string, unknown>)?.[
qsModelSource.param ?? 'model'
] as string | undefined)
: qsModelSource?.source === 'claude-settings-file' : qsModelSource?.source === 'claude-settings-file'
? qsModelConfig?.defaultModel || undefined ? qsModelConfig?.defaultModel || undefined
: undefined; : undefined;
@@ -3907,16 +3916,16 @@ export function registerSessionRoutes(
deepSeekConfig: qsGatedDeepSeekConfig, deepSeekConfig: qsGatedDeepSeekConfig,
} = await _clampExternalCliBypassForOwner( } = await _clampExternalCliBypassForOwner(
owner, owner,
qsLaunchCodexConfig, qsLaunchConfigs.codexConfig,
geminiConfig, qsLaunchConfigs.geminiConfig,
antigravityConfig, qsLaunchConfigs.antigravityConfig,
piConfig, qsLaunchConfigs.piConfig,
grokConfig, qsLaunchConfigs.grokConfig,
deepSeekConfig qsLaunchConfigs.deepSeekConfig
); );
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig(); const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides); const qsGatedEnvOverrides = await clampEnvOverridesForOwner(owner, envOverrides);
const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, ompConfig); const qsResolvedOmpConfig = resolveOmpConfigForCreate(mode, resolvedCasePath, qsLaunchOmpConfig);
// Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md) // Custom Model Endpoint Profiles, applied AT CREATE TIME (docs/custom-model-endpoints-plan.md)
// rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom- // rather than via the dedicated restart-in-place route (POST /api/sessions/:id/custom-
@@ -4073,7 +4082,7 @@ export function registerSessionRoutes(
claudeMode: qsEffectiveClaudeMode, claudeMode: qsEffectiveClaudeMode,
allowedTools: qsClaudeModeConfig.allowedTools, allowedTools: qsClaudeModeConfig.allowedTools,
owner, owner,
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined, openCodeConfig: mode === 'opencode' ? qsLaunchConfigs.openCodeConfig : undefined,
codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined, codexConfig: mode === 'codex' ? qsGatedCodexConfig : undefined,
geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined, geminiConfig: mode === 'gemini' ? qsGatedGeminiConfig : undefined,
antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined, antigravityConfig: mode === 'antigravity' ? qsGatedAntigravityConfig : undefined,
+7 -6
View File
@@ -1406,14 +1406,14 @@ export const SettingsUpdateSchema = z
/** /**
* Tab layout, the arrangement of the tab list (Discussion #426). Display key * Tab layout, the arrangement of the tab list (Discussion #426). Display key
* (per-device). * (per-device).
* 'classic' = one flat list in tab order, as before. The default.
* 'state' = a row per state in the header strip (needs you, waiting, * 'state' = a row per state in the header strip (needs you, waiting,
* working, idle; option C), sections in the flat side rail and * working, idle; option C), sections in the flat side rail and
* the sidebar. The default. * the sidebar. Opt-in.
* 'case' = one cluster per case (option A): a labelled box in the strip, * 'case' = one cluster per case (option A): a labelled box in the strip,
* a section in the side rail and the sidebar. * a section in the side rail and the sidebar. Opt-in.
* 'ledger' = the flat list on an aligned column grid with a status bar * 'ledger' = the flat list on an aligned column grid with a status bar
* per cell (option B). Header strip on desktop only. * per cell (option B). Header strip on desktop only. Opt-in.
* 'classic' = one flat list in tab order, as before.
*/ */
tabArrangement: z.enum(['state', 'case', 'ledger', 'classic']).optional(), tabArrangement: z.enum(['state', 'case', 'ledger', 'classic']).optional(),
/** /**
@@ -1457,8 +1457,9 @@ export const SettingsUpdateSchema = z
* How the header draws its WS / CPU / MEM / plan-usage cluster. Display key * How the header draws its WS / CPU / MEM / plan-usage cluster. Display key
* (per-device), desktop only (the cluster is hidden below 768px). * (per-device), desktop only (the cluster is hidden below 768px).
* 'classic' = the bars and the 5H · 7D chip, as before * 'classic' = the bars and the 5H · 7D chip, as before
* 'compact' = two pills (WS/CPU/MEM, the plan windows), a ring beside every value * 'compact' = two pills (WS/CPU/MEM, the plan windows), a ring beside every value.
* 'tiles' = label over value with a bar underneath, no icons. The default. * The default.
* 'tiles' = label over value with a bar underneath, no icons
*/ */
headerStatsStyle: z.enum(['classic', 'compact', 'tiles']).optional(), headerStatsStyle: z.enum(['classic', 'compact', 'tiles']).optional(),
showTokenCount: z.boolean().optional(), showTokenCount: z.boolean().optional(),
+172 -49
View File
@@ -33,6 +33,13 @@
* CAN DO, it belongs in `CliCapabilities` instead — and if it needs to run code, in * CAN DO, it belongs in `CliCapabilities` instead — and if it needs to run code, in
* `config/cli-registry/profiles.ts` as a named profile. * `config/cli-registry/profiles.ts` as a named profile.
* *
* ⚠️ Each entry also carries how many times its expression occurs in that file, compared
* EXACTLY. The key is only `<file>::<expression>`, so without a count an approved branch
* approved every later copy of the same text in the same file: the codex launch defaults
* added two more `mode === 'codex'` branches to session-routes.ts and passed silently,
* because the legacy-plumbing entry already covered that string. A new copy now fails as
* an unapproved branch would, and a removed one fails as stale until the count drops.
*
* Port: none (pure static analysis). * Port: none (pure static analysis).
*/ */
@@ -67,47 +74,67 @@ const EXEMPT_FILES = new Set(
].map((p) => p.split('/').join(sep)) ].map((p) => p.split('/').join(sep))
); );
/** One approved branch: how many copies of it the file holds, and why it is not a capability. */
interface Allowance {
count: number;
reason: string;
}
const allow = (count: number, reason: string): Allowance => ({ count, reason });
/** /**
* Specific surviving branches, each with the reason it is not a capability. * Specific surviving branches, each with the reason it is not a capability and the exact
* number of times the expression occurs in that file (see the header).
* Keyed `<relative path>::<the matched expression>`. * Keyed `<relative path>::<the matched expression>`.
*/ */
const ALLOWED_BRANCHES: Record<string, string> = { const ALLOWED_BRANCHES: Record<string, Allowance> = {
// --- Legacy <Mode>Config plumbing (public wire shape, see the header) --- // --- Legacy <Mode>Config plumbing (public wire shape, see the header) ---
"web/routes/session-routes.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing', "web/routes/session-routes.ts::mode === 'opencode'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'codex'": 'legacy <Mode>Config plumbing', "web/routes/session-routes.ts::mode === 'codex'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing', "web/routes/session-routes.ts::mode === 'gemini'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing', "web/routes/session-routes.ts::mode === 'antigravity'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'pi'": 'legacy <Mode>Config plumbing', "web/routes/session-routes.ts::mode === 'pi'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'grok'": 'legacy <Mode>Config plumbing', "web/routes/session-routes.ts::mode === 'grok'": allow(2, 'legacy <Mode>Config plumbing'),
"web/routes/session-routes.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing', "web/routes/session-routes.ts::mode === 'deepseek'": allow(2, 'legacy <Mode>Config plumbing'),
"web/server.ts::mode === 'opencode'": 'legacy <Mode>Config plumbing (session recovery)', "web/server.ts::mode === 'opencode'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'codex'": 'legacy <Mode>Config plumbing (session recovery)', "web/server.ts::mode === 'codex'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'gemini'": 'legacy <Mode>Config plumbing (session recovery)', "web/server.ts::mode === 'gemini'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'antigravity'": 'legacy <Mode>Config plumbing (session recovery)', "web/server.ts::mode === 'antigravity'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'pi'": 'legacy <Mode>Config plumbing (session recovery)', "web/server.ts::mode === 'pi'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'grok'": 'legacy <Mode>Config plumbing (session recovery)', "web/server.ts::mode === 'grok'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'deepseek'": 'legacy <Mode>Config plumbing (session recovery)', "web/server.ts::mode === 'deepseek'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
"web/server.ts::mode === 'omp'": 'legacy <Mode>Config plumbing (session recovery)', "web/server.ts::mode === 'omp'": allow(1, 'legacy <Mode>Config plumbing (session recovery)'),
// --- Claude's remote/docker command construction --- // --- Claude's remote/docker command construction ---
"tmux-manager.ts::mode === 'claude'": "tmux-manager.ts::mode === 'claude'": allow(
2,
"claude's remote pane command carries per-session permission flags, and its docker form is " + "claude's remote pane command carries per-session permission flags, and its docker form is " +
'`--session-id … || resume`; neither fits a static overlays.command string', '`--session-id … || resume`; neither fits a static overlays.command string'
"tmux-manager.ts::mode === 'omp'": ),
"tmux-manager.ts::mode === 'omp'": allow(
1,
'remote omp respawn needs the pinned/continue --resume override threaded through ' + 'remote omp respawn needs the pinned/continue --resume override threaded through ' +
'(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' + '(resumeSessionId/ompConfig), which the static overlays.remote.command string has no ' +
'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' + 'room for; the command itself is still rendered through buildSpawnCommandFromRegistry, ' +
'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode', 'the same mode-agnostic engine local/docker spawns use — only the BRANCH is per-mode'
),
// --- Per-CLI prose and launch handling not yet generalised --- // --- Per-CLI prose and launch handling not yet generalised ---
"web/session-wait-registry.ts::mode === 'deepseek'": "web/session-wait-registry.ts::mode === 'deepseek'": allow(
'an error message explaining why THIS mode in particular will never deliver a stop signal', 1,
"web/routes/approval-routes.ts::mode === 'deepseek'": 'an error message explaining why THIS mode in particular will never deliver a stop signal'
'the DeepSeek status bridge is the only non-claude source of approval items', ),
"cron/cron-service.ts::mode === 'claude'": 'cron launch handling, not yet generalised', "web/routes/approval-routes.ts::mode === 'deepseek'": allow(
"cron/cron-service.ts::mode === 'shell'": 'cron launch handling, not yet generalised', 1,
"web/routes/session-routes.ts::mode === 'claude'": 'docker case bookkeeping keyed on the claude conversation id', 'the DeepSeek status bridge is the only non-claude source of approval items'
"cli.ts::mode === 'shell'": 'a CLI-table label, not behaviour', ),
"cron/cron-service.ts::mode === 'claude'": allow(1, 'cron launch handling, not yet generalised'),
"cron/cron-service.ts::mode === 'shell'": allow(1, 'cron launch handling, not yet generalised'),
"web/routes/session-routes.ts::mode === 'claude'": allow(
2,
'docker case bookkeeping keyed on the claude conversation id'
),
"cli.ts::mode === 'shell'": allow(1, 'a CLI-table label, not behaviour'),
// --- Negated forms surfaced when BRANCH_PATTERN widened past `===` (see its comment) --- // --- Negated forms surfaced when BRANCH_PATTERN widened past `===` (see its comment) ---
// //
@@ -121,43 +148,58 @@ const ALLOWED_BRANCHES: Record<string, string> = {
// there, the shared predicate silently widened both to a mode with no transcript to read. // there, the shared predicate silently widened both to a mode with no transcript to read.
// CLAUDE.md documents this as deliberate and `test/deepseek-mode.test.ts` pins it, so a // CLAUDE.md documents this as deliberate and `test/deepseek-mode.test.ts` pins it, so a
// capability here would be actively wrong. // capability here would be actively wrong.
"web/routes/readmymind-routes.ts::mode !== 'claude'": "web/routes/readmymind-routes.ts::mode !== 'claude'": allow(
'deliberately mode-not-capability; pinned by deepseek-mode.test.ts', 1,
"web/server.ts::mode !== 'claude'": 'deliberately mode-not-capability; pinned by deepseek-mode.test.ts'
),
"web/server.ts::mode !== 'claude'": allow(
2,
"intent capture reads Claude's own transcript, and the recovered-workspace hook sweep " + "intent capture reads Claude's own transcript, and the recovered-workspace hook sweep " +
'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)', 'writes .claude hooks — both are claude questions, not capability ones (see CLAUDE.md)'
),
// The TUI is a CLIENT of the server, and these two are about what it can offer for a row: // The TUI is a CLIENT of the server, and these two are about what it can offer for a row:
// resume builds a `claude --resume`, and the mode badge is suppressed for the default mode // resume builds a `claude --resume`, and the mode badge is suppressed for the default mode
// purely so the common case reads clean. The badge one is cosmetic and not a capability at // purely so the common case reads clean. The badge one is cosmetic and not a capability at
// all; the resume one would need a "resumable from a claude transcript" field that nothing // all; the resume one would need a "resumable from a claude transcript" field that nothing
// else would read. // else would read.
"tui/tui-app.ts::mode !== 'claude'": 'TUI resume builds a claude --resume; claude-transcript-only by construction', "tui/tui-app.ts::mode !== 'claude'": allow(
"tui/tui-render.ts::mode !== 'claude'": 'cosmetic: suppress the mode badge for the default mode', 1,
'TUI resume builds a claude --resume; claude-transcript-only by construction'
),
"tui/tui-render.ts::mode !== 'claude'": allow(1, 'cosmetic: suppress the mode badge for the default mode'),
// Push approve/deny BUTTONS are withheld for dsh because the answer route refuses // Push approve/deny BUTTONS are withheld for dsh because the answer route refuses
// keystrokes for its dialogs (third-party TUI, unmeasured contract) — a button whose // keystrokes for its dialogs (third-party TUI, unmeasured contract) — a button whose
// answer would be refused is worse than none. Arguably wants an "answerable dialogs" // answer would be refused is worse than none. Arguably wants an "answerable dialogs"
// capability; deliberately not invented here. // capability; deliberately not invented here.
"web/routes/hook-event-routes.ts::mode !== 'deepseek'": "web/routes/hook-event-routes.ts::mode !== 'deepseek'": allow(
'push buttons withheld where the answer route refuses keystrokes', 1,
'push buttons withheld where the answer route refuses keystrokes'
),
// Legacy <Mode>Config plumbing, same category as the `===` entries above. // Legacy <Mode>Config plumbing, same category as the `===` entries above.
"web/routes/session-routes.ts::mode !== 'omp'": 'legacy <Mode>Config plumbing (resolveOmpConfigForCreate)', "web/routes/session-routes.ts::mode !== 'omp'": allow(
2,
'legacy <Mode>Config plumbing (resolveOmpConfigForCreate), and one link of the scaffolded-case hooks ' +
'chain (see the opencode entry below)'
),
// ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its // ⚠️ Scaffolded-case hooks. This chain excludes seven CLIs but NOT `deepseek`, while its
// own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a // own comment says DeepSeek uses its own system — so a scaffolded deepseek case gets a
// Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this // Claude hooks block written into it. That inconsistency is UPSTREAM's and predates this
// change; expressing the chain as a capability would have to pick a side and would // change; expressing the chain as a capability would have to pick a side and would
// therefore be a behaviour change. Left exactly as found, and named here so it is visible. // therefore be a behaviour change. Left exactly as found, and named here so it is visible.
"web/routes/session-routes.ts::mode !== 'opencode'": "web/routes/session-routes.ts::mode !== 'opencode'": allow(
2,
'scaffolded-case hooks + the COD-91 self-heal skip; the chain omits deepseek upstream, ' + 'scaffolded-case hooks + the COD-91 self-heal skip; the chain omits deepseek upstream, ' +
'so any capability form would change behaviour — see PR discussion', 'so any capability form would change behaviour — see PR discussion'
"web/routes/session-routes.ts::mode !== 'codex'": 'scaffolded-case hooks (see the opencode entry)', ),
"web/routes/session-routes.ts::mode !== 'gemini'": 'scaffolded-case hooks (see the opencode entry)', "web/routes/session-routes.ts::mode !== 'codex'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'antigravity'": 'scaffolded-case hooks (see the opencode entry)', "web/routes/session-routes.ts::mode !== 'gemini'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'pi'": 'scaffolded-case hooks (see the opencode entry)', "web/routes/session-routes.ts::mode !== 'antigravity'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'grok'": 'scaffolded-case hooks (see the opencode entry)', "web/routes/session-routes.ts::mode !== 'pi'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
"web/routes/session-routes.ts::mode !== 'grok'": allow(1, 'scaffolded-case hooks (see the opencode entry)'),
}; };
/** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */ /** Every stock CLI id, derived rather than restated so a new entry is covered automatically. */
@@ -246,6 +288,43 @@ function scan(): { findings: Finding[]; filesScanned: number } {
const { findings, filesScanned } = scan(); const { findings, filesScanned } = scan();
interface CountMismatch {
key: string;
allowed: number;
found: number;
lines: string[];
}
/**
* Allowlisted keys whose occurrence count differs from the approved one: `grown` holds the
* keys with MORE copies than approved (a new branch riding an old approval), `shrunk` the
* ones with fewer (a stale approval that would let the next copy back in unseen).
* Unallowlisted keys are not this function's business; the offenders check covers them.
*/
function countMismatches(
found: Finding[],
allowed: Record<string, Allowance>
): { grown: CountMismatch[]; shrunk: CountMismatch[] } {
const byKey = new Map<string, Finding[]>();
for (const f of found) byKey.set(f.key, [...(byKey.get(f.key) ?? []), f]);
const grown: CountMismatch[] = [];
const shrunk: CountMismatch[] = [];
for (const [key, allowance] of Object.entries(allowed)) {
const hits = byKey.get(key) ?? [];
const mismatch = {
key,
allowed: allowance.count,
found: hits.length,
lines: hits.map((f) => `${f.file}:${f.line}`),
};
if (hits.length > allowance.count) grown.push(mismatch);
else if (hits.length < allowance.count) shrunk.push(mismatch);
}
return { grown, shrunk };
}
const { grown, shrunk } = countMismatches(findings, ALLOWED_BRANCHES);
describe('no CLI-id branching outside the stock catalog', () => { describe('no CLI-id branching outside the stock catalog', () => {
it('scans a meaningful number of source files (sanity)', () => { it('scans a meaningful number of source files (sanity)', () => {
// If this collapses toward zero the walker or the exemption list drifted and every // If this collapses toward zero the walker or the exemption list drifted and every
@@ -293,12 +372,56 @@ describe('no CLI-id branching outside the stock catalog', () => {
).toEqual([]); ).toEqual([]);
}); });
it('has no new copy of an allowlisted branch', () => {
// An approval covers the copies that were reviewed, not every later line that happens
// to spell the same expression in the same file.
const detail = grown
.map((m) => ` ${m.key}: ${m.found} found, ${m.allowed} approved\n ${m.lines.join('\n ')}`)
.join('\n');
expect(
grown,
grown.length === 0
? ''
: `Found more copies of an allowlisted CLI-id branch than were approved:\n${detail}\n\n` +
'Express the new copy as a CliCapabilities field (or a named profile) rather than raising ' +
"the count. Raise it only for another branch of the SAME kind, and read this file's header first."
).toEqual([]);
});
it('has no stale allowlist entries', () => { it('has no stale allowlist entries', () => {
// An allowlisted branch that no longer exists is a lie about the codebase, and the next // An allowlisted branch that no longer exists is a lie about the codebase, and the next
// person to reintroduce that exact branch would sail straight through. // person to reintroduce that exact branch would sail straight through. The same holds
const present = new Set(findings.map((f) => f.key)); // for an approved count above what the file still has.
const stale = Object.keys(ALLOWED_BRANCHES).filter((key) => !present.has(key)); const stale = shrunk.map((m) => `${m.key}: ${m.found} found, ${m.allowed} approved`);
expect(stale, `ALLOWED_BRANCHES entries no longer present — delete them:\n ${stale.join('\n ')}`).toEqual([]); expect(
stale,
`ALLOWED_BRANCHES entries no longer (fully) present; delete them or lower the count:\n ${stale.join('\n ')}`
).toEqual([]);
});
it('counts copies per key, so one extra copy of an approved branch fails (anti-vacuity)', () => {
const at = (line: number): Finding => ({
file: 'web/example.ts',
expression: "mode === 'codex'",
line,
key: "web/example.ts::mode === 'codex'",
});
const approved = { "web/example.ts::mode === 'codex'": allow(1, 'synthetic') };
expect(countMismatches([at(10)], approved)).toEqual({ grown: [], shrunk: [] });
const extra = countMismatches([at(10), at(42)], approved);
expect(extra.grown).toEqual([
{
key: "web/example.ts::mode === 'codex'",
allowed: 1,
found: 2,
lines: ['web/example.ts:10', 'web/example.ts:42'],
},
]);
expect(countMismatches([], approved).shrunk.map((m) => m.key)).toEqual(["web/example.ts::mode === 'codex'"]);
// Every live entry carries a positive whole count, or the comparison means nothing.
for (const [key, { count }] of Object.entries(ALLOWED_BRANCHES)) {
expect(Number.isInteger(count) && count > 0, key).toBe(true);
}
}); });
}); });
+51
View File
@@ -291,6 +291,57 @@ describe('cross-field integrity', () => {
}); });
}); });
describe('capabilities.launchDefaults', () => {
/** Codex with its shipped launch defaults replaced by `launchDefaults` (or with them unchanged). */
function codexWith(mutate: (entry: Record<string, unknown>) => void): boolean {
const entry = baseEntry('codex');
mutate(entry);
return CliEntrySchema.safeParse(entry).success;
}
const setDefaults = (value: unknown) => (e: Record<string, unknown>) => {
(e.capabilities as Record<string, unknown>).launchDefaults = value;
};
it('ships on codex alone, keyed by launch param', () => {
const declaring = STOCK_CLIS.filter((e) => e.capabilities.launchDefaults !== undefined).map((e) => e.id);
expect(declaring).toEqual(['codex']);
expect(codexWith(() => {})).toBe(true);
});
it('rejects a param the entry never declared', () => {
// Filled into the config object and then read by nothing: a silent no-op, like a
// privilegedParams clamp naming the wrong param.
expect(codexWith(setDefaults({ effort: 'codexReasoningEffort' }))).toBe(false);
});
it('rejects a settings key outside the closed list', () => {
// An override must not be able to pour an arbitrary setting onto a command line.
expect(codexWith(setDefaults({ model: 'claudeModel' }))).toBe(false);
expect(codexWith(setDefaults({ model: 'codexmodel' }))).toBe(false);
});
it('rejects an empty map', () => {
expect(codexWith(setDefaults({}))).toBe(false);
});
it('refuses an entry with no legacyConfigField to fill', () => {
// Without one the params are read off the request body itself, where `model` is
// claude's per-session field, not this CLI's.
expect(
codexWith((e) => {
delete (e.launch as Record<string, unknown>).legacyConfigField;
})
).toBe(false);
// The same entry without launch defaults is fine: the refusal is about the pair.
expect(
codexWith((e) => {
delete (e.launch as Record<string, unknown>).legacyConfigField;
delete (e.capabilities as Record<string, unknown>).launchDefaults;
})
).toBe(true);
});
});
describe('the env allowlist cannot be widened by config', () => { describe('the env allowlist cannot be widened by config', () => {
it('requires a prefix to end with an underscore', () => { it('requires a prefix to end with an underscore', () => {
expectRejected((e) => { expectRejected((e) => {
@@ -7,18 +7,26 @@ import { createRouteTestHarness, type RouteTestHarness } from './_route-test-uti
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
import { registerSystemRoutes } from '../../src/web/routes/system-routes.js'; import { registerSystemRoutes } from '../../src/web/routes/system-routes.js';
import { CASES_DIR, SETTINGS_PATH } from '../../src/web/route-helpers.js'; import { CASES_DIR, SETTINGS_PATH } from '../../src/web/route-helpers.js';
import { resolveCodexLaunchDefaults } from '../../src/web/codex-launch-defaults.js'; import { applyLaunchDefaults } from '../../src/web/launch-defaults.js';
import { buildCodexCommand } from '../../src/tmux-manager.js'; import { buildCodexCommand } from '../../src/tmux-manager.js';
import { Session } from '../../src/session.js'; import { Session } from '../../src/session.js';
import { safeRmHomeTree } from '../mocks/index.js'; import { safeRmHomeTree } from '../mocks/index.js';
import { getDataDir } from '../../src/config/instance.js'; import { getDataDir } from '../../src/config/instance.js';
import { SettingsUpdateSchema } from '../../src/web/schemas.js'; import { SettingsUpdateSchema } from '../../src/web/schemas.js';
import { getCli } from '../../src/config/cli-registry/registry.js';
import { STOCK_CLIS } from '../../src/config/cli-registry/stock.js';
import type { CodexConfig } from '../../src/types.js';
vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => { vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>(); const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>();
return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) }; return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) };
}); });
/** The codexConfig a launch gets from the synced defaults alone (the caller sent `config`). */
async function resolveCodexDefaults(config: CodexConfig | undefined, customEndpoint = false) {
return (await applyLaunchDefaults('codex', { codexConfig: config }, customEndpoint)).codexConfig;
}
describe('Codex launch defaults', () => { describe('Codex launch defaults', () => {
let harness: RouteTestHarness; let harness: RouteTestHarness;
const workingDir = join(homedir(), 'codex-default-test'); const workingDir = join(homedir(), 'codex-default-test');
@@ -76,14 +84,45 @@ describe('Codex launch defaults', () => {
{ codexModel: 'bad;command', codexReasoningEffort: 'invalid' }, { codexModel: 'bad;command', codexReasoningEffort: 'invalid' },
]) { ]) {
await writeFile(SETTINGS_PATH, JSON.stringify(settings)); await writeFile(SETTINGS_PATH, JSON.stringify(settings));
expect(await resolveCodexLaunchDefaults(undefined)).toBeUndefined(); expect(await resolveCodexDefaults(undefined)).toBeUndefined();
expect(buildCodexCommand(await resolveCodexLaunchDefaults(undefined))).toBe('codex'); expect(buildCodexCommand(await resolveCodexDefaults(undefined))).toBe('codex');
} }
}); });
it('keeps defaults out of custom endpoint launches', async () => { it('keeps defaults out of custom endpoint launches', async () => {
const config = { model: 'local-model', animations: false }; const config = { model: 'local-model', animations: false };
expect(await resolveCodexLaunchDefaults(config, true)).toBe(config); expect(await resolveCodexDefaults(config, true)).toBe(config);
});
it('is driven by the registry: codex declares the defaults, and a CLI without them is untouched', async () => {
// The routes call applyLaunchDefaults for every mode and never ask which CLI it is, so
// what codex gets is exactly what its entry declares.
expect(getCli('codex')!.capabilities.launchDefaults).toEqual({
model: 'codexModel',
reasoningEffort: 'codexReasoningEffort',
});
for (const entry of STOCK_CLIS) {
if ((entry.id as string) === 'codex') continue;
expect(entry.capabilities.launchDefaults, entry.id).toBeUndefined();
const configs = { codexConfig: undefined, geminiConfig: { model: 'g' }, piConfig: undefined };
expect(await applyLaunchDefaults(entry.id, configs), entry.id).toBe(configs);
}
// Only the entry's own config object is filled; the rest of the bag passes through.
const bag = { codexConfig: { animations: false }, geminiConfig: { model: 'g' }, name: 'n' };
const filled = await applyLaunchDefaults('codex', bag);
expect(filled).toEqual({
codexConfig: { animations: false, model: 'gpt-6.1', reasoningEffort: 'high' },
geminiConfig: { model: 'g' },
name: 'n',
});
expect(filled.geminiConfig).toBe(bag.geminiConfig);
expect(bag.codexConfig).toEqual({ animations: false });
// A field the caller sent is never overwritten, one at a time.
expect(await resolveCodexDefaults({ model: 'mine' })).toEqual({ model: 'mine', reasoningEffort: 'high' });
expect(await resolveCodexDefaults({ reasoningEffort: 'low' })).toEqual({
model: 'gpt-6.1',
reasoningEffort: 'low',
});
}); });
it('does not record unused defaults for a Docker quick-start', async () => { it('does not record unused defaults for a Docker quick-start', async () => {
@@ -150,7 +189,7 @@ describe('Codex launch defaults', () => {
expect((await put({ codexReasoningEffort: 'bogus' })).statusCode).toBe(400); expect((await put({ codexReasoningEffort: 'bogus' })).statusCode).toBe(400);
expect((await put({ codexModel: 'bad;command' })).statusCode).toBe(400); expect((await put({ codexModel: 'bad;command' })).statusCode).toBe(400);
expect((await put({ codexModel: '', codexReasoningEffort: '' })).statusCode).toBe(200); expect((await put({ codexModel: '', codexReasoningEffort: '' })).statusCode).toBe(200);
expect(await resolveCodexLaunchDefaults(undefined)).toBeUndefined(); expect(await resolveCodexDefaults(undefined)).toBeUndefined();
} finally { } finally {
await system.app.close(); await system.app.close();
} }
+15
View File
@@ -25,6 +25,7 @@ import {
sanitizeModelName, sanitizeModelName,
} from '../src/session-display-model.js'; } from '../src/session-display-model.js';
import { IDLE_SILENCE_MS } from '../src/session-activity.js'; import { IDLE_SILENCE_MS } from '../src/session-activity.js';
import { CODEX_REASONING_EFFORTS } from '../src/types/session.js';
const detectOf = (mode: string) => getCli(mode)!.capabilities.modelDetect!; const detectOf = (mode: string) => getCli(mode)!.capabilities.modelDetect!;
const DSH = compileVersionRegex(detectOf('deepseek').screenLine)!; const DSH = compileVersionRegex(detectOf('deepseek').screenLine)!;
@@ -223,6 +224,20 @@ describe('readScreenModel', () => {
expect(readScreenModel(codexPane(null, [], ' ← for agents · ? for shortcuts'), CODEX, CODEX_ROWS)).toBeUndefined(); expect(readScreenModel(codexPane(null, [], ' ← for agents · ? for shortcuts'), CODEX, CODEX_ROWS)).toBeUndefined();
}); });
it("reads codex's model at every reasoning effort Codeman can launch it with, ultra included", () => {
// `ultra` is offered by the codexReasoningEffort App Setting and codex's own /model
// picker; a hand-kept effort list in the pattern once left it out, so an ultra
// session's header never named its model. The footer prints the lowercase level.
const ultra = codexPane(' GPT-6-Astra ultra · ~/codeman-cases/testcase', [], ' ? for shortcuts');
expect(readScreenModel(ultra, CODEX, CODEX_ROWS)).toBe('GPT-6-Astra');
for (const effort of [...CODEX_REASONING_EFFORTS, 'default']) {
const atRest = codexPane(` GPT-6-Astra ${effort} · ~/codeman-cases/testcase`, [], ' ? for shortcuts');
expect(readScreenModel(atRest, CODEX, CODEX_ROWS), effort).toBe('GPT-6-Astra');
const typing = codexPane(` GPT-6-Astra ${effort} · ~/codeman-cases/testcase`);
expect(readScreenModel(typing, CODEX, CODEX_ROWS), effort).toBe('GPT-6-Astra');
}
});
it('never takes a transcript line shaped like the footer', () => { it('never takes a transcript line shaped like the footer', () => {
// The agent printed a line exactly like each CLI's footer, and the real footer is // The agent printed a line exactly like each CLI's footer, and the real footer is
// hidden (a dsh status bar switched off; a codex popup over its last row). The // hidden (a dsh status bar switched off; a codex popup over its last row). The