fix(models): unify provider runtime selection#71176
Conversation
🔒 Aisle Security AnalysisWe found 3 potential security issue(s) in this PR:
1. 🟠 Legacy codex/* model migration can silently route prompts to non-Codex harness (e.g., embedded PI/OpenAI)
Description
As a result:
Vulnerable code: function ensureCodexEmbeddedHarness(raw: AgentEmbeddedHarnessPatch | undefined) {
...
const runtime = normalizeOptionalLowercaseString(raw.runtime);
if (!runtime || runtime === "auto") {
return { value: { ...raw, runtime: "codex" }, ... };
}
return { value: raw, changed: false }; // non-auto runtime blocks forcing codex
}RecommendationWhen migrating a Safer options:
function ensureCodexEmbeddedHarness(raw: AgentEmbeddedHarnessPatch | undefined) {
if (!isRecord(raw)) return { value: { runtime: "codex" }, changed: true };
const runtime = normalizeOptionalLowercaseString(raw.runtime);
if (runtime !== "codex") {
return { value: { ...raw, runtime: "codex" }, changed: true };
}
return { value: raw, changed: false };
}
Also consider adding a regression test for 2. 🟡 Prototype pollution via user-controlled keys in legacy agent models allowlist migration
Description
Because
Vulnerable code: const next: AgentModelsMapPatch = {};
for (const [rawKey, entry] of Object.entries(rawModels)) {
...
next[rawKey] = mergeModelEntry(entry, next[rawKey]);
}If this polluted object is later merged/spread or used in security-sensitive decisions (e.g., allowlisting models), it can cause incorrect behavior or potentially impact other parts of the process relying on object properties. RecommendationHarden the migration map against special keys. Option A (recommended): use a null-prototype object and reject dangerous keys: const next: Record<string, AgentModelsMapPatch[string]> = Object.create(null);
const isSafeKey = (k: string) => !["__proto__","prototype","constructor"].includes(k);
for (const [rawKey, entry] of Object.entries(rawModels)) {
if (!isSafeKey(rawKey)) continue;
const migratedKey = migrateLegacyCodexModelRef(rawKey);
const key = migratedKey ?? rawKey;
if (!isSafeKey(key)) continue;
next[key] = mergeModelEntry(entry, next[key]);
}Option B: use Also consider validating that model keys match an expected pattern like 3. 🟡 Terminal/log injection (CWE-117) via unsanitized agent IDs in doctor change notes
Description
If
Vulnerable code paths: const agentId = normalizeOptionalString(entry.id) ?? String(index);
const agent = normalizeLegacyCodexAgentContainer(entry, `agents.list.${agentId}`, changes);
// ...
changes.push(`Moved ${path}.model codex/* refs → openai/* ...`);printed later as: note(normalized.changes.join("\n"), "Doctor changes");RecommendationSanitize any user/config-derived content before emitting it to terminal/log output. Option A (preferred): sanitize the entire note payload at the sink import { sanitizeDoctorNote } from "./doctor/emit-notes.js";
// ...
note(sanitizeDoctorNote(normalized.changes.join("\n")), "Doctor changes");Option B: sanitize path components where they are constructed import { sanitizeForLog } from "../../../terminal/ansi.js";
const agentIdRaw = normalizeOptionalString(entry.id) ?? String(index);
const agentId = sanitizeForLog(agentIdRaw);Also consider validating Analyzed PR: #71176 at commit Last updated on: 2026-04-24T18:05:43Z |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 713a3e48b7
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| changed = true; | ||
| codexSelected = true; |
There was a problem hiding this comment.
Do not force Codex harness for migrated fallback refs
Setting codexSelected = true for migrated fallback entries causes normalizeLegacyCodexAgentContainer to pin embeddedHarness.runtime to "codex" for the whole agent, even when the primary model was not a Codex alias. In a config like primary: "openai/..." with a legacy codex/... fallback, migration now changes all turns to forced Codex runtime instead of keeping the previous behavior where only the Codex fallback used that harness; this can change auth/runtime semantics and break primary models that are not intended for the Codex harness.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Fixed in current head b8ebe38e28: fallback-only legacy runtime refs are migrated to canonical provider refs but do not select embeddedHarness.runtime. Only legacy primary/string model refs select the runtime. Covered by does not force Codex harness when only fallback refs are migrated in src/commands/doctor-legacy-config.migrations.test.ts.
Greptile SummaryThis PR cleanly separates the virtual
Confidence Score: 4/5Safe to merge after resolving the order-dependent merge priority bug in the allowlist normalizer, which can silently drop canonical openai/* config values. One P1 correctness defect exists in normalizeLegacyCodexAllowlistModels where the merge outcome depends on JSON key order rather than semantic priority. All other findings are P2 or cosmetic. The picker visibility logic, harness migration, and test coverage are sound. src/commands/doctor/shared/legacy-config-core-normalizers.ts (mergeModelEntry call site in normalizeLegacyCodexAllowlistModels) Prompt To Fix All With AIThis is a comment left during a code review.
Path: src/commands/doctor/shared/legacy-config-core-normalizers.ts
Line: 307-315
Comment:
**Order-dependent merge silently drops canonical `openai/*` values**
`mergeModelEntry(entry, next[migratedKey])` places the incoming entry as `legacyEntry` and whatever is already in `next` as `currentEntry`. Because the spread is `{ ...legacyEntry, ...currentEntry }`, the `currentEntry` wins — but what is in `next` at that moment depends entirely on JSON key order.
When `codex/gpt-5.5` appears before `openai/gpt-5.5` in the user's config, the migration processes the codex key first (places its values into `next`), then processes the openai key as the "legacy" entry — so the codex alias/params overwrite the canonical openai ones. Reversing the order gives the correct result. Users whose JSON has `codex/*` keys ordered before `openai/*` keys will silently lose their canonical `openai/*` overrides after migration.
The fix is to always let the canonical `openai/*` entry win: collect non-codex entries first, then merge migrated codex entries as the lower-priority source.
How can I resolve this? If you propose a fix, please make it concise.
---
This is a comment left during a code review.
Path: src/commands/doctor/shared/legacy-config-core-normalizers.ts
Line: 349-358
Comment:
**Misleading change message when harness is modified then cleaned up in the same pass**
When a codex model is selected and the existing harness is `{ runtime: "auto", fallback: "pi" }`, `ensureCodexEmbeddedHarness` first rewrites it to `{ runtime: "codex", fallback: "pi" }`. Then `normalizeNoopEmbeddedHarness` strips `fallback: "pi"` (a default) and emits `"Removed no-op agents.defaults.embeddedHarness defaults."` The migration log therefore says both "selected Codex harness" and "removed no-op defaults" for the same field in the same run, which can make the audit trail confusing — the `fallback` removal isn't a separate concern but a side-effect of the harness selection step. Consider suppressing the no-op message (or merging it into the harness selection message) when `model.codexSelected` is true.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "Merge branch 'main' into fix/model-harne..." | Re-trigger Greptile |
| for (const [rawKey, entry] of Object.entries(rawModels)) { | ||
| const migratedKey = migrateLegacyCodexModelRef(rawKey); | ||
| if (migratedKey) { | ||
| changed = true; | ||
| next[migratedKey] = mergeModelEntry(entry, next[migratedKey]); | ||
| continue; | ||
| } | ||
| next[rawKey] = mergeModelEntry(entry, next[rawKey]); | ||
| } |
There was a problem hiding this comment.
Order-dependent merge silently drops canonical
openai/* values
mergeModelEntry(entry, next[migratedKey]) places the incoming entry as legacyEntry and whatever is already in next as currentEntry. Because the spread is { ...legacyEntry, ...currentEntry }, the currentEntry wins — but what is in next at that moment depends entirely on JSON key order.
When codex/gpt-5.5 appears before openai/gpt-5.5 in the user's config, the migration processes the codex key first (places its values into next), then processes the openai key as the "legacy" entry — so the codex alias/params overwrite the canonical openai ones. Reversing the order gives the correct result. Users whose JSON has codex/* keys ordered before openai/* keys will silently lose their canonical openai/* overrides after migration.
The fix is to always let the canonical openai/* entry win: collect non-codex entries first, then merge migrated codex entries as the lower-priority source.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/commands/doctor/shared/legacy-config-core-normalizers.ts
Line: 307-315
Comment:
**Order-dependent merge silently drops canonical `openai/*` values**
`mergeModelEntry(entry, next[migratedKey])` places the incoming entry as `legacyEntry` and whatever is already in `next` as `currentEntry`. Because the spread is `{ ...legacyEntry, ...currentEntry }`, the `currentEntry` wins — but what is in `next` at that moment depends entirely on JSON key order.
When `codex/gpt-5.5` appears before `openai/gpt-5.5` in the user's config, the migration processes the codex key first (places its values into `next`), then processes the openai key as the "legacy" entry — so the codex alias/params overwrite the canonical openai ones. Reversing the order gives the correct result. Users whose JSON has `codex/*` keys ordered before `openai/*` keys will silently lose their canonical `openai/*` overrides after migration.
The fix is to always let the canonical `openai/*` entry win: collect non-codex entries first, then merge migrated codex entries as the lower-priority source.
How can I resolve this? If you propose a fix, please make it concise.There was a problem hiding this comment.
Fixed in current head b8ebe38e28: model allowlist migration is two-pass now, so canonical provider keys are collected first and migrated legacy keys merge as lower-priority entries. Covered by keeps canonical OpenAI model entries ahead of migrated Codex aliases in src/commands/doctor-legacy-config.migrations.test.ts.
| const harness = normalizeNoopEmbeddedHarness(next.embeddedHarness as AgentEmbeddedHarnessPatch); | ||
| if (harness.changed) { | ||
| if (harness.value) { | ||
| next.embeddedHarness = harness.value; | ||
| } else { | ||
| delete next.embeddedHarness; | ||
| } | ||
| changed = true; | ||
| changes.push(`Removed no-op ${path}.embeddedHarness defaults.`); | ||
| } |
There was a problem hiding this comment.
Misleading change message when harness is modified then cleaned up in the same pass
When a codex model is selected and the existing harness is { runtime: "auto", fallback: "pi" }, ensureCodexEmbeddedHarness first rewrites it to { runtime: "codex", fallback: "pi" }. Then normalizeNoopEmbeddedHarness strips fallback: "pi" (a default) and emits "Removed no-op agents.defaults.embeddedHarness defaults." The migration log therefore says both "selected Codex harness" and "removed no-op defaults" for the same field in the same run, which can make the audit trail confusing — the fallback removal isn't a separate concern but a side-effect of the harness selection step. Consider suppressing the no-op message (or merging it into the harness selection message) when model.codexSelected is true.
Prompt To Fix With AI
This is a comment left during a code review.
Path: src/commands/doctor/shared/legacy-config-core-normalizers.ts
Line: 349-358
Comment:
**Misleading change message when harness is modified then cleaned up in the same pass**
When a codex model is selected and the existing harness is `{ runtime: "auto", fallback: "pi" }`, `ensureCodexEmbeddedHarness` first rewrites it to `{ runtime: "codex", fallback: "pi" }`. Then `normalizeNoopEmbeddedHarness` strips `fallback: "pi"` (a default) and emits `"Removed no-op agents.defaults.embeddedHarness defaults."` The migration log therefore says both "selected Codex harness" and "removed no-op defaults" for the same field in the same run, which can make the audit trail confusing — the `fallback` removal isn't a separate concern but a side-effect of the harness selection step. Consider suppressing the no-op message (or merging it into the harness selection message) when `model.codexSelected` is true.
How can I resolve this? If you propose a fix, please make it concise.|
Thanks @vincentkoc. Codex review found the direction right, but this exact PR is unsafe to land and the branch is not maintainer-editable. Main blocker: the migration treats any migrated I rewrote this as #71193 with the narrower behavior:
Closing this in favor of #71193. |
Summary
codex/*,codex-cli/*,claude-cli/*,google-gemini-cli/*) back to canonical provider/model refs while preserving the selected runtime separately--runtimeValidation
OPENCLAW_LOCAL_CHECK=0 pnpm check:changedpnpm plugin-sdk:api:check