From 88199435f0fbce425bef41231159503800cc70c9 Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Fri, 31 Jul 2026 15:46:06 +0530 Subject: [PATCH] fix(runtime): don't inherit a foreign model across runtimes (v0.283.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found on the first live attachable Codex session. The fleet runtime default is model="opus"; codex-scout pins no model of its own; so the launcher handed `opus` to Codex, which replied "The 'opus' model is not supported when using Codex with a ChatGPT account." Two holes: 1. The cross-runtime guard only matched full ids (^claude), so bare ALIASES — opus/sonnet/haiku/fable — passed. Patterns are now word-boundary anchored and cover aliases both directions. 2. Nothing validated INHERITANCE. The per-agent config route rejects a foreign model, but the workspace default spans every runtime and so cannot be correct for all of them at once. resolveRuntimeTuning now takes the runtime and DROPS a model that runtime can't run, falling back to the CLI's own default — a working session instead of a dead one. 7 conformance fixtures pin the inheritance matrix. 137/137. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 16 ++++++ package-lock.json | 4 +- package.json | 2 +- scripts/governance-conformance.cjs | 14 ++++- src/terminal.ts | 2 +- src/types.ts | 27 ++++++++-- test/governance/conformance.json | 83 ++++++++++++++++++++++++++++++ 7 files changed, 139 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 233c67a..80c12de 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,22 @@ new version heading in the same commit. ## [Unreleased] +## [0.283.0] — 2026-07-31 +### Fixed +- **A Codex run silently inherited a Claude model from the workspace default and died.** Found on the + first live attachable Codex session: the fleet default is `model: "opus"`, `codex-scout` pins no model + of its own, so the launcher passed `opus` to Codex, which answered *"The 'opus' model is not supported + when using Codex with a ChatGPT account."* Two holes, both now closed: + - The cross-runtime model guard only matched full ids (`^claude`), so bare **aliases** — `opus`, + `sonnet`, `haiku`, `fable` — sailed through. The patterns are now anchored on a word boundary and + cover aliases both ways (`gpt`/`o`/`codex`/`glm`/`kimi`/`deepseek` are refused for Claude Code). + - Nothing validated **inheritance**. `PUT /api/agents/:id/config` rejects a foreign model, but the + workspace default spans every runtime and therefore *cannot* be right for all of them at once — an + agent with no model of its own picked one up silently. `resolveRuntimeTuning` now takes the runtime + and **drops** a model that runtime can't run, so the session falls back to the CLI's own default (a + working run) instead of failing outright. + 7 new conformance fixtures pin the inheritance matrix; suite is 137/137. + ## [0.282.0] — 2026-07-31 ### Added - **Cockpit `action` tier now *executes* — a governed operator carries it out (auto-router Phase 3).** diff --git a/package-lock.json b/package-lock.json index fe4c5f7..2a18e43 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.282.0", + "version": "0.283.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.282.0", + "version": "0.283.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index f99553a..d694daa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.282.0", + "version": "0.283.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/scripts/governance-conformance.cjs b/scripts/governance-conformance.cjs index 46dbb82..5b72a2c 100644 --- a/scripts/governance-conformance.cjs +++ b/scripts/governance-conformance.cjs @@ -49,6 +49,7 @@ if (newestSrc > newestDist) { const { enrichArgs, autoClearsApproval } = require(path.join(ROOT, 'dist/governance/enricher')); const { JsonPolicyEngine } = require(path.join(ROOT, 'dist/governance/policy')); +const { resolveRuntimeTuning } = require(path.join(ROOT, 'dist/types.js')); const { fileGovernanceDecision } = require(path.join(ROOT, 'dist/governance/file-guard.js')); const { hostGovernanceDecision, stricterDecision } = require(path.join(ROOT, 'dist/governance/host-match')); @@ -131,7 +132,18 @@ for (const c of fixture.context) { const riskyChecks = fixture.decisions.filter((c) => typeof c.expectRisky === 'boolean').length; const factChecks = fixture.decisions.reduce((n, c) => n + (c.expectFacts ? Object.keys(c.expectFacts).length : 0), 0); -const total = fixture.decisions.length + fixture.context.length + riskyChecks + factChecks; +// Runtime tuning: a model belonging to another runtime must never reach a CLI. The per-agent route +// rejects one outright; INHERITANCE from the workspace default (which spans every runtime and so can't +// be right for all of them) silently drops it instead, so the run falls back to the CLI default rather +// than dying. Regression-guards a live failure where a Codex run inherited `opus`. +for (const c of fixture.tuning || []) { + const got = resolveRuntimeTuning(c.agent || {}, c.defaults || {}, c.override, c.runtime); + const want = c.expectModel === null ? undefined : c.expectModel; + if (got.model === want) pass++; + else failures.push(`tuning ✗ ${c.name}\n expected model=${want}, got ${got.model}`); +} + +const total = fixture.decisions.length + fixture.context.length + riskyChecks + factChecks + (fixture.tuning || []).length; if (failures.length) { console.error(`\nGOVERNANCE CONFORMANCE: ${pass}/${total} passed, ${failures.length} FAILED\n`); for (const f of failures) console.error(' ' + f); diff --git a/src/terminal.ts b/src/terminal.ts index 2adf569..545e069 100644 --- a/src/terminal.ts +++ b/src/terminal.ts @@ -1645,7 +1645,7 @@ export class TerminalManager { // we don't wrap the shell in Seatbelt/bubblewrap. Real OS containment is the Linux uid-isolation path. // Per-agent model / effort / permission-mode fall back to the workspace default; the launcher maps // them onto `--model`/`--effort`/`--permission-mode` (permission-mode on the interactive lane only). - const tuning = resolveRuntimeTuning(manifest, this.os.settings.runtimeDefaults(), o.tuning); + const tuning = resolveRuntimeTuning(manifest, this.os.settings.runtimeDefaults(), o.tuning, runtime); if (runtime === 'codex') { // Codex reads model/effort from the config.toml the launcher generates; it has no // --permission-mode (Agent OS is the sole authority there via approval_policy = "never"), so diff --git a/src/types.ts b/src/types.ts index e88e406..13c07b1 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1132,7 +1132,7 @@ export const CODING_RUNTIMES: Readonly` / `codex fork `), so the id is // captured after launch rather than pinned. The unattended lane is `codex exec`, which exits at @@ -1543,9 +1547,24 @@ export function sanitizeSvgIcon(input: string): string | undefined { * own model/effort), else the agent's own value, else the workspace default, else undefined (CLI * default) — except `permissionMode`, whose floor is `auto` (the interactive lane always runs with a * mode; the built-in default is `auto`, not the CLI's own). Pure — used by the terminal launcher. */ -export function resolveRuntimeTuning(agent: RuntimeTuning, defaults: RuntimeTuning, override?: RuntimeTuning): RuntimeTuning { +export function resolveRuntimeTuning( + agent: RuntimeTuning, + defaults: RuntimeTuning, + override?: RuntimeTuning, + /** The runtime this tuning will actually run under. When given, a resolved model that belongs to a + * DIFFERENT runtime is dropped rather than passed to the CLI. */ + runtime?: RuntimeId, +): RuntimeTuning { + let model = override?.model ?? agent.model ?? defaults.model; + // The workspace default spans every runtime, so it CANNOT be correct for all of them at once: a + // fleet default of `opus` is right for Claude Code and fatal for Codex, which answers + // `The 'opus' model is not supported`. The per-agent config route already rejects a cross-family + // model, but nothing validated INHERITANCE — an agent with no model of its own silently picked up a + // foreign one. Drop it here instead, so the run falls back to the CLI's own default (a working + // session on a sensible model) rather than failing outright. Found live on a Codex run. + if (model && runtime && validateModelForRuntime(runtime, model)) model = undefined; return { - model: override?.model ?? agent.model ?? defaults.model, + model, effort: override?.effort ?? agent.effort ?? defaults.effort, permissionMode: override?.permissionMode ?? agent.permissionMode ?? defaults.permissionMode ?? 'auto', }; diff --git a/test/governance/conformance.json b/test/governance/conformance.json index 8c6f5f5..13bda35 100644 --- a/test/governance/conformance.json +++ b/test/governance/conformance.json @@ -1630,5 +1630,88 @@ }, "expectAutoClear": false } + ], + "tuning": [ + { + "name": "tuning: codex does not inherit a Claude workspace model", + "runtime": "codex", + "agent": {}, + "defaults": { + "model": "opus", + "effort": "high", + "permissionMode": "auto" + }, + "expectModel": null + }, + { + "name": "tuning: claude-code does inherit the workspace model", + "runtime": "claude-code", + "agent": {}, + "defaults": { + "model": "opus", + "effort": "high", + "permissionMode": "auto" + }, + "expectModel": "opus" + }, + { + "name": "tuning: codex keeps its own valid model", + "runtime": "codex", + "agent": { + "model": "gpt-5-codex" + }, + "defaults": { + "model": "opus", + "effort": "high", + "permissionMode": "auto" + }, + "expectModel": "gpt-5-codex" + }, + { + "name": "tuning: a stale per-agent Claude model is dropped for codex", + "runtime": "codex", + "agent": { + "model": "claude-opus-4-8" + }, + "defaults": { + "model": "opus", + "effort": "high", + "permissionMode": "auto" + }, + "expectModel": null + }, + { + "name": "tuning: claude-code drops an inherited codex model", + "runtime": "claude-code", + "agent": {}, + "defaults": { + "model": "gpt-5-codex" + }, + "expectModel": null + }, + { + "name": "tuning: a per-launch override still wins when valid", + "runtime": "codex", + "agent": {}, + "defaults": { + "model": "opus", + "effort": "high", + "permissionMode": "auto" + }, + "override": { + "model": "gpt-5.6-sol" + }, + "expectModel": "gpt-5.6-sol" + }, + { + "name": "tuning: no runtime given leaves the model untouched (back-compat)", + "agent": {}, + "defaults": { + "model": "opus", + "effort": "high", + "permissionMode": "auto" + }, + "expectModel": "opus" + } ] }