From 2d27bfdd669306353fe4841b2c2558602bcdee93 Mon Sep 17 00:00:00 2001 From: roboomp Date: Sat, 18 Jul 2026 15:34:03 +0000 Subject: [PATCH] fix(search): allowed command-backed codex keys - Exposed command-backed provider-key detection from ModelRegistry. - Allowed configured Codex command keys to outrank stored OAuth while preserving the custom-endpoint OAuth guard. - Added resolver-precedence regression coverage. Fixes #6001 --- .../coding-agent/src/config/model-registry.ts | 11 ++++++++ .../src/web/search/providers/codex.ts | 9 +++--- .../model-registry-command-values.test.ts | 2 ++ .../test/tools/web-search-codex.test.ts | 28 +++++++++++++++++++ 4 files changed, 46 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/src/config/model-registry.ts b/packages/coding-agent/src/config/model-registry.ts index bd152630b..c7f16ed9c 100644 --- a/packages/coding-agent/src/config/model-registry.ts +++ b/packages/coding-agent/src/config/model-registry.ts @@ -1986,6 +1986,17 @@ export class ModelRegistry { ); } + /** + * Whether the provider's configured API key is resolved from a command. + * + * Callers use this to distinguish the registry's command-first resolver + * path from lower-priority credentials in {@link authStorage}. + */ + hasCommandBackedApiKey(provider: string): boolean { + const keyConfig = this.#customProviderApiKeys.get(provider); + return isCommandConfigValue(keyConfig); + } + getDiscoverableProviders(): string[] { const disabledProviders = getDisabledProviderIdsFromSettings(); return this.#discoverableProviders diff --git a/packages/coding-agent/src/web/search/providers/codex.ts b/packages/coding-agent/src/web/search/providers/codex.ts index e1b76808a..9b543f0f7 100644 --- a/packages/coding-agent/src/web/search/providers/codex.ts +++ b/packages/coding-agent/src/web/search/providers/codex.ts @@ -625,12 +625,13 @@ export async function searchCodex(params: SearchParams): Promise let result: CodexSearchResult; if (transport.customEndpoint) { - // The resolver draws its bearer from the registry's own storage when a - // registry is supplied, so validate the credential origin against that - // same storage — not a caller-supplied `authStorage` that may differ. + // ModelRegistry resolves command-backed provider keys before consulting + // its AuthStorage, so a lower-priority OAuth origin is irrelevant when + // that command source is configured. const credentialSource = params.modelRegistry?.authStorage ?? params.authStorage; const credentialOrigin = credentialSource.getCredentialOrigin("openai-codex"); - if (credentialOrigin?.kind === "oauth" || credentialOrigin?.kind === "env") { + const hasCommandBackedKey = params.modelRegistry?.hasCommandBackedApiKey("openai-codex") === true; + if (!hasCommandBackedKey && (credentialOrigin?.kind === "oauth" || credentialOrigin?.kind === "env")) { throw new SearchProviderError( "codex", `Refusing to send official Codex OAuth credentials to custom endpoint ${transport.baseUrl}. Configure an API key for provider "openai-codex".`, diff --git a/packages/coding-agent/test/model-registry-command-values.test.ts b/packages/coding-agent/test/model-registry-command-values.test.ts index dde67673b..ac10b648b 100644 --- a/packages/coding-agent/test/model-registry-command-values.test.ts +++ b/packages/coding-agent/test/model-registry-command-values.test.ts @@ -50,6 +50,8 @@ describe("ModelRegistry command-resolved models.yml values", () => { ); const registry = new ModelRegistry(authStorage, modelsPath); + expect(registry.hasCommandBackedApiKey("anthropic")).toBe(true); + expect(registry.hasCommandBackedApiKey("openai")).toBe(false); const models = registry.getAll().filter(model => model.provider === "anthropic"); expect(models.length).toBeGreaterThan(1); diff --git a/packages/coding-agent/test/tools/web-search-codex.test.ts b/packages/coding-agent/test/tools/web-search-codex.test.ts index e1398997a..f4832faf9 100644 --- a/packages/coding-agent/test/tools/web-search-codex.test.ts +++ b/packages/coding-agent/test/tools/web-search-codex.test.ts @@ -219,6 +219,9 @@ describe("searchCodex model selection", () => { getProviderHeaders() { return { "X-Proxy-Tenant": "tenant-1" }; }, + hasCommandBackedApiKey() { + return false; + }, resolver() { return async () => "test-proxy-key"; }, @@ -324,6 +327,31 @@ describe("searchCodex model selection", () => { expect(fetchMock).not.toHaveBeenCalled(); }); + it("prefers a command-backed proxy key over stored OAuth on a custom endpoint", async () => { + process.env.PI_CODEX_WEB_SEARCH_MODEL = "gpt-5.4"; + const commandBackedRegistry = { + ...proxyModelRegistry, + authStorage: oauthOnlyAuthStorage, + hasCommandBackedApiKey(provider: string) { + return provider === "openai-codex"; + }, + resolver() { + return async () => "command-proxy-key"; + }, + } as unknown as ModelRegistry; + + const result = await searchCodex({ + ...makeSearchParams("command proxy key", mockCodexFetch("gpt-5.4")), + authStorage: oauthOnlyAuthStorage, + modelRegistry: commandBackedRegistry, + }); + + const headers = new Headers(capturedRequest?.headers); + expect(headers.get("authorization")).toBe("Bearer command-proxy-key"); + expect(headers.has("chatgpt-account-id")).toBe(false); + expect(result.answer).toBe("Codex answer"); + }); + it("falls back to the default model when PI_CODEX_WEB_SEARCH_MODEL is blank", async () => { process.env.PI_CODEX_WEB_SEARCH_MODEL = " "; const result = await searchCodex(makeSearchParams("blank codex model", mockCodexFetch("gpt-5.6-luna")));