diff --git a/electron/utils/openclaw-auth.ts b/electron/utils/openclaw-auth.ts index 5bafcf8..7e7cbac 100644 --- a/electron/utils/openclaw-auth.ts +++ b/electron/utils/openclaw-auth.ts @@ -1469,6 +1469,31 @@ function applyPinnedAgentRuntime( nextProvider.agentRuntime = { id: pinnedRuntimeId }; } +function applyOpenClawProviderAgentRuntimePinsToConfig(config: Record): string[] { + const models = (config.models || {}) as Record; + const providers = (models.providers || {}) as Record; + const pinned: string[] = []; + + for (const provider of Object.keys(OPENCLAW_PROVIDER_PINNED_AGENT_RUNTIME)) { + const entry = providers[provider]; + if (!isPlainRecord(entry)) continue; + const before = entry.agentRuntime; + applyPinnedAgentRuntime(provider, entry); + const after = entry.agentRuntime; + if (before !== after) { + providers[provider] = entry; + pinned.push(provider); + } + } + + if (pinned.length > 0) { + models.providers = providers; + config.models = models; + } + + return pinned; +} + function upsertOpenClawProviderEntry( config: Record, provider: string, @@ -1552,29 +1577,12 @@ function upsertOpenClawProviderEntry( * Returns the list of provider keys that received a runtime pin, for logging. */ export async function ensureOpenClawProviderAgentRuntimePins(): Promise { - const pinned: string[] = []; + let pinned: string[] = []; await withConfigLock(async () => { const config = await readOpenClawJson(); - const models = (config.models || {}) as Record; - const providers = (models.providers || {}) as Record; - let modified = false; + pinned = applyOpenClawProviderAgentRuntimePinsToConfig(config); - for (const [provider, runtimeId] of Object.entries(OPENCLAW_PROVIDER_PINNED_AGENT_RUNTIME)) { - const entry = providers[provider]; - if (!isPlainRecord(entry)) continue; - const existing = (entry as Record).agentRuntime; - if (isPlainRecord(existing) && typeof existing.id === 'string' && existing.id.trim()) { - continue; - } - (entry as Record).agentRuntime = { id: runtimeId }; - providers[provider] = entry; - pinned.push(provider); - modified = true; - } - - if (modified) { - models.providers = providers; - config.models = models; + if (pinned.length > 0) { await writeOpenClawJson(config); } }); @@ -2257,6 +2265,12 @@ export async function batchSyncConfigFields(token: string): Promise { modified = true; } + const pinnedProviderRuntimes = applyOpenClawProviderAgentRuntimePinsToConfig(config); + if (pinnedProviderRuntimes.length > 0) { + modified = true; + console.log(`[batch-sync] Pinned embedded agent runtime for models.providers entries: ${pinnedProviderRuntimes.join(', ')}`); + } + // ── Session idle minutes ── const session = ( config.session && typeof config.session === 'object' @@ -3062,6 +3076,12 @@ export async function sanitizeOpenClawConfig(): Promise { } } + const pinnedProviderRuntimes = applyOpenClawProviderAgentRuntimePinsToConfig(config); + if (pinnedProviderRuntimes.length > 0) { + modified = true; + console.log(`[sanitize] Pinned embedded agent runtime for models.providers entries: ${pinnedProviderRuntimes.join(', ')}`); + } + if (healAnthropicMessagesMaxTokensInConfig(config)) { modified = true; } diff --git a/harness/specs/tasks/openai-api-key-pin-pi-agent-runtime.md b/harness/specs/tasks/openai-api-key-pin-pi-agent-runtime.md index 5af6fb8..c555f93 100644 --- a/harness/specs/tasks/openai-api-key-pin-pi-agent-runtime.md +++ b/harness/specs/tasks/openai-api-key-pin-pi-agent-runtime.md @@ -1,18 +1,20 @@ --- id: openai-api-key-pin-pi-agent-runtime -title: Pin the embedded "pi" agent runtime on OpenAI API-key provider entries to avoid the unbundled "codex" harness +title: Pin the embedded "pi" agent runtime on OpenAI provider entries to avoid the unbundled "codex" harness scenario: gateway-backend-communication taskType: runtime-bridge -intent: Stop OpenClaw from auto-routing API-key OpenAI provider entries (api.openai.com baseUrl) to the externally-bundled "codex" agent harness, which is not registered in the shipped OpenClaw distribution and causes every chat to fail with `Requested agent harness "codex" is not registered.`. Pin `agentRuntime.id = "pi"` on every `models.providers.openai` entry ClawX writes, and self-heal existing on-disk entries before the next default-provider switch so upgrading users do not have to re-save their provider manually. +intent: Stop OpenClaw from auto-routing OpenAI provider entries (api.openai.com baseUrl) to the externally-bundled "codex" agent harness, which is not registered in the shipped ClawX/OpenClaw distribution and causes chats/heartbeats to fail with `Requested agent harness "codex" is not registered.`. Pin `agentRuntime.id = "pi"` on every `models.providers.openai` and `models.providers.openai-codex` entry ClawX writes, and self-heal existing on-disk entries before provider switches and before Gateway launch so upgrading users do not have to re-save their provider manually. touchedAreas: - harness/specs/tasks/openai-api-key-pin-pi-agent-runtime.md - electron/utils/openclaw-auth.ts - electron/services/providers/provider-runtime-sync.ts + - electron/gateway/config-sync.ts - tests/unit/openclaw-auth.test.ts expectedUserBehavior: - Configuring OpenAI with an API key (default `https://api.openai.com/v1` baseUrl) and starting a chat succeeds without `Requested agent harness "codex" is not registered.` from the Gateway. - - Upgrading from an earlier ClawX build that wrote an `openai` provider entry without `agentRuntime` and then switching default provider (or back to OpenAI) self-heals the entry so the next Gateway reload boots cleanly. - - OAuth-based OpenAI Codex accounts (which target the separate `openai-codex` runtime key) keep their existing routing — their `models.providers.openai-codex` entry is never auto-pinned. + - OpenAI browser-OAuth/Codex accounts that use the runtime provider key `openai-codex` also run through the embedded `pi` runtime unless the user has explicitly configured a different installed harness. + - Upgrading from an earlier ClawX build that wrote `openai` or `openai-codex` provider entries without `agentRuntime` self-heals those entries on the next provider switch and during pre-launch config sanitization before Gateway reads the config. + - A pre-existing user-supplied non-empty `agentRuntime.id` is preserved; the ClawX pin only fills missing runtime policy. requiredProfiles: - fast - comms @@ -24,12 +26,13 @@ requiredRules: requiredTests: - tests/unit/openclaw-auth.test.ts acceptance: - - electron/utils/openclaw-auth.ts exports `ensureOpenClawProviderAgentRuntimePins` and pins `agentRuntime: { id: 'pi' }` on every `models.providers.openai` write via `upsertOpenClawProviderEntry`. - - electron/services/providers/provider-runtime-sync.ts invokes `ensureOpenClawProviderAgentRuntimePins` inside `syncDefaultProviderToRuntime` right after `pruneInvalidApiProviderEntries`, before either the OAuth or non-OAuth branch runs, so a switch to any healthy provider repairs legacy openai entries. - - The pin policy only targets the `openai` provider key (the API-key path). `openai-codex` (the OAuth runtime key) is never auto-pinned. - - A pre-existing user-supplied `agentRuntime.id` on the `openai` entry is preserved on both the write path and the self-heal path — the pin only fills in a missing value. + - `electron/utils/openclaw-auth.ts` applies a shared OpenAI runtime-pin helper from all ClawX write/self-heal paths and pins `agentRuntime: { id: 'pi' }` on `models.providers.openai` and `models.providers.openai-codex` entries that lack a non-empty runtime id. + - `syncProviderConfigToOpenClaw(...)`, `setOpenClawDefaultModel(...)`, and `setOpenClawDefaultModelWithOverride(...)` continue to pin OpenAI provider entries through `upsertOpenClawProviderEntry`. + - `ensureOpenClawProviderAgentRuntimePins()` still repairs legacy on-disk entries and is invoked inside `syncDefaultProviderToRuntime` right after `pruneInvalidApiProviderEntries`, before either the OAuth or non-OAuth branch runs. + - `sanitizeOpenClawConfig()` repairs missing OpenAI runtime pins during Gateway pre-launch sanitization so a stale config cannot boot into the unregistered `codex` harness path. + - `batchSyncConfigFields()` also applies the same shared helper during pre-launch config writes, covering the fallback path if sanitization skipped or failed. - Renderer does not add new direct ipcRenderer or Gateway HTTP calls. - - Unit tests cover (a) the write-path pin via `syncProviderConfigToOpenClaw('openai', ...)`, (b) the OAuth-path non-pin via `syncProviderConfigToOpenClaw('openai-codex', ...)`, (c) preservation of a user-supplied override on both paths, and (d) the self-heal helper for legacy on-disk openai entries. + - Unit tests cover (a) the write-path pin via `syncProviderConfigToOpenClaw('openai', ...)`, (b) the OAuth-path pin via `syncProviderConfigToOpenClaw('openai-codex', ...)`, (c) preservation of a user-supplied override, (d) the self-heal helper for legacy on-disk entries, and (e) `sanitizeOpenClawConfig()` pre-launch repair. docs: required: false --- @@ -37,61 +40,43 @@ docs: ## Background OpenClaw 2026.5+ ships a provider-routing policy -([node_modules/openclaw/dist/policy-AKMwD9k5.js](node_modules/openclaw/dist/policy-AKMwD9k5.js), -[node_modules/openclaw/dist/openai-codex-routing-kS7Ub1vB.js](node_modules/openclaw/dist/openai-codex-routing-kS7Ub1vB.js)) -that auto-routes every `models.providers.openai` entry whose `baseUrl` is the -official `https://api.openai.com/v1` endpoint through the `codex` agent -harness. The intent is to give OpenAI Codex OAuth accounts a richer -trajectory, but the heuristic does not distinguish API-key vs OAuth setups — -it always picks `codex` when the baseUrl matches. +([node_modules/openclaw/dist/policy-B5E74dCu.js](node_modules/openclaw/dist/policy-B5E74dCu.js), +[node_modules/openclaw/dist/openai-codex-routing-qYpDQzyG.js](node_modules/openclaw/dist/openai-codex-routing-qYpDQzyG.js)) +that can route OpenAI-compatible official endpoints through a separate +`codex` agent harness. The intent is to give OpenAI/Codex accounts a richer +trajectory, but the shipped ClawX distribution does not register an agent +harness with id `"codex"`. -The bundled OpenClaw distribution we ship does not register any agent harness -with id `"codex"` (only `cliBackends: ["codex-cli"]` is declared by the OpenAI -plugin manifest at -`node_modules/openclaw/dist/extensions/openai/openclaw.plugin.json`). As a -result, every API-key OpenAI chat fails inside -[node_modules/openclaw/dist/selection-61FIEezO.js](node_modules/openclaw/dist/selection-61FIEezO.js) -with: +When an OpenAI provider entry lacks an explicit runtime pin, affected chat and +heartbeat runs can fail inside OpenClaw harness selection with: ``` Requested agent harness "codex" is not registered. ``` -Provider-side validation passes (the API key is valid; the protocol is in the -allow-list); the failure is purely about agent harness selection. - -The fix is to make ClawX write an explicit `agentRuntime: { id: "pi" }` on -every `models.providers.openai` entry. OpenClaw's policy resolver honours an -explicit `agentRuntime.id` before falling into the codex auto-routing -heuristic, so the API-key path is rescued from the unbundled harness without -disturbing OAuth users (whose entry lives under the separate -`models.providers.openai-codex` key). +Provider-side validation can still pass (credentials and protocol are valid); +the failure is about agent harness selection. ClawX therefore writes an +explicit `agentRuntime: { id: "pi" }` for OpenAI provider entries that it owns. +OpenClaw's policy resolver honours explicit provider/model runtime policy +before falling into the codex auto-routing heuristic. ## Scope -- Add an `OPENCLAW_PROVIDER_PINNED_AGENT_RUNTIME` map in - `electron/utils/openclaw-auth.ts` (currently `{ openai: 'pi' }`) and apply - it inside `upsertOpenClawProviderEntry` so every write of a pinned provider - carries `agentRuntime: { id: }` when the entry does not already - specify one. -- Export `ensureOpenClawProviderAgentRuntimePins()` as a self-heal helper - that walks existing `models.providers.*` entries on disk and writes the pin - in place when missing — mirroring `pruneInvalidApiProviderEntries`. -- Call the helper in `syncDefaultProviderToRuntime` immediately after - `pruneInvalidApiProviderEntries`, before any OAuth/non-OAuth branching, so - the repair happens once per default-provider switch. -- Cover both the write-path pin and the self-heal path in - `tests/unit/openclaw-auth.test.ts`. Verify that the OAuth `openai-codex` - entry is untouched and that any user-supplied `agentRuntime` override is - preserved on both paths. +- Maintain the `OPENCLAW_PROVIDER_PINNED_AGENT_RUNTIME` map in + `electron/utils/openclaw-auth.ts` (`openai` and `openai-codex` currently map + to `pi`). Future providers can be added to the map without changing the + plumbing. +- Use one shared helper to apply the map to `models.providers.*` entries. The + helper must preserve non-empty user-provided `agentRuntime.id` values. +- Call the helper from provider write paths, explicit self-heal, and Gateway + pre-launch config sanitation (`sanitizeOpenClawConfig` plus the batched + pre-launch config write). +- Cover the write path, self-heal path, override-preservation path, and + pre-launch sanitize path in unit tests. ## Out of scope -- Upstream changes to OpenClaw's policy resolver so it would only auto-route - to `codex` when an `openai-codex` harness is actually registered. -- Pin policy for any other provider key besides `openai`. Future providers - that need the same defense can be added to the map without further - plumbing. -- Renderer-side UI surface for picking a different agent runtime per - provider — there is no user-visible UI change in this task. -- README updates (no user-visible UI change). +- Upstream changes to OpenClaw's policy resolver so it would only auto-route to + `codex` when a `codex` harness is actually registered. +- UI for choosing an agent runtime per provider. +- README updates (no user-visible UI flow change). diff --git a/tests/unit/openclaw-auth.test.ts b/tests/unit/openclaw-auth.test.ts index e7d0a31..ed959fb 100644 --- a/tests/unit/openclaw-auth.test.ts +++ b/tests/unit/openclaw-auth.test.ts @@ -1850,6 +1850,33 @@ describe('ensureOpenClawProviderAgentRuntimePins', () => { expect(codex.agentRuntime).toEqual({ id: 'pi' }); }); + it('pins legacy OpenAI entries during sanitizeOpenClawConfig before Gateway launch', async () => { + await writeOpenClawJson({ + models: { + providers: { + openai: { + baseUrl: 'https://api.openai.com/v1', + api: 'openai-responses', + models: [{ id: 'gpt-5.5', name: 'gpt-5.5' }], + }, + 'openai-codex': { + baseUrl: 'https://api.openai.com/v1', + api: 'openai-codex-responses', + models: [{ id: 'gpt-5.5', name: 'gpt-5.5' }], + }, + }, + }, + }); + + const { sanitizeOpenClawConfig } = await import('@electron/utils/openclaw-auth'); + await sanitizeOpenClawConfig(); + + const result = await readOpenClawJson(); + const providers = (result.models as Record).providers as Record; + expect((providers.openai as Record).agentRuntime).toEqual({ id: 'pi' }); + expect((providers['openai-codex'] as Record).agentRuntime).toEqual({ id: 'pi' }); + }); + it('leaves entries untouched when the openai entry already has any agentRuntime.id', async () => { const initial = { models: {