diff --git a/extensions/codex/media-understanding-provider.test.ts b/extensions/codex/media-understanding-provider.test.ts index 6479c11bca7e..293d3253adc7 100644 --- a/extensions/codex/media-understanding-provider.test.ts +++ b/extensions/codex/media-understanding-provider.test.ts @@ -5,6 +5,14 @@ import { buildCodexMediaUnderstandingProvider } from "./media-understanding-prov import type { CodexAppServerClient } from "./src/app-server/client.js"; import type { CodexServerNotification, JsonValue } from "./src/app-server/protocol.js"; +const sharedClientMocks = vi.hoisted(() => ({ + createIsolatedCodexAppServerClient: vi.fn(), +})); + +vi.mock("./src/app-server/shared-client.js", () => ({ + createIsolatedCodexAppServerClient: sharedClientMocks.createIsolatedCodexAppServerClient, +})); + function codexModel(inputModalities: string[] = ["text", "image"]) { return { id: "gpt-5.4", @@ -169,6 +177,7 @@ function createFakeClient(options?: { requestHandlers.add(handler); return () => requestHandlers.delete(handler); }, + close: vi.fn(), } as unknown as CodexAppServerClient; return { client, requests, approvalResponses }; @@ -178,13 +187,24 @@ describe("codex media understanding provider", () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); + sharedClientMocks.createIsolatedCodexAppServerClient.mockReset(); }); it("runs image understanding through a bounded Codex app-server turn", async () => { const { client, requests } = createFakeClient(); + const clientFactory = vi.fn( + async (_startOptions, _authProfileId, _agentDir, _config) => client, + ); const provider = buildCodexMediaUnderstandingProvider({ - clientFactory: async () => client, + clientFactory, }); + const cfg = { + auth: { + order: { + openai: ["openai:work"], + }, + }, + }; const result = await provider.describeImage?.({ buffer: Buffer.from("image-bytes"), @@ -194,7 +214,7 @@ describe("codex media understanding provider", () => { model: "gpt-5.4", prompt: "Describe briefly.", timeoutMs: 30_000, - cfg: {}, + cfg, agentDir: "/tmp/openclaw-agent", }); @@ -204,6 +224,12 @@ describe("codex media understanding provider", () => { "thread/start", "turn/start", ]); + expect(clientFactory).toHaveBeenCalledWith( + expect.any(Object), + undefined, + "/tmp/openclaw-agent", + cfg, + ); expect(requests[1]?.params).toEqual({ model: "gpt-5.4", modelProvider: "openai", @@ -236,6 +262,62 @@ describe("codex media understanding provider", () => { }); }); + it("treats a blank agent directory as absent when starting the app-server", async () => { + const { client, requests } = createFakeClient(); + const clientFactory = vi.fn(async () => client); + const provider = buildCodexMediaUnderstandingProvider({ clientFactory }); + const cfg = {}; + + await provider.describeImage?.({ + buffer: Buffer.from("image-bytes"), + fileName: "image.png", + mime: "image/png", + provider: "codex", + model: "gpt-5.4", + timeoutMs: 30_000, + cfg, + agentDir: " ", + }); + + expect(clientFactory).toHaveBeenCalledWith(expect.any(Object), undefined, undefined, cfg); + expect(requests[1]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() })); + expect(requests[2]?.params).toEqual(expect.objectContaining({ cwd: process.cwd() })); + }); + + it("passes the scoped auth store into isolated app-server startup", async () => { + const { client } = createFakeClient(); + sharedClientMocks.createIsolatedCodexAppServerClient.mockResolvedValue(client); + const provider = buildCodexMediaUnderstandingProvider(); + const authStore = { + version: 1, + profiles: { + "openai:scoped": { + type: "oauth" as const, + provider: "openai", + access: "scoped-access", + refresh: "scoped-refresh", + expires: Date.now() + 60_000, + }, + }, + }; + + await provider.describeImage?.({ + buffer: Buffer.from("image-bytes"), + fileName: "image.png", + mime: "image/png", + provider: "codex", + model: "gpt-5.4", + timeoutMs: 30_000, + cfg: {}, + authStore, + agentDir: "/tmp/openclaw-agent", + }); + + expect(sharedClientMocks.createIsolatedCodexAppServerClient).toHaveBeenCalledWith( + expect.objectContaining({ authProfileStore: authStore }), + ); + }); + it("clamps oversized image understanding turn timeouts", async () => { const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout"); try { diff --git a/extensions/codex/media-understanding-provider.ts b/extensions/codex/media-understanding-provider.ts index fda294292f14..8c0406a5394c 100644 --- a/extensions/codex/media-understanding-provider.ts +++ b/extensions/codex/media-understanding-provider.ts @@ -102,6 +102,8 @@ async function describeCodexImages( profile: req.profile, timeoutMs: req.timeoutMs, agentDir: req.agentDir, + authStore: req.authStore, + cfg: req.cfg, options, taskLabel: "image understanding", developerInstructions: @@ -123,6 +125,8 @@ type BoundedCodexVisionTurnParams = { profile?: string; timeoutMs: number; agentDir?: string; + authStore?: ImagesDescriptionRequest["authStore"]; + cfg: ImagesDescriptionRequest["cfg"]; options: CodexMediaUnderstandingProviderOptions; taskLabel: string; developerInstructions: string; @@ -135,17 +139,22 @@ async function runBoundedCodexVisionTurn(params: BoundedCodexVisionTurnParams): pluginConfig: params.options.pluginConfig, }); const timeoutMs = resolveTimerTimeoutMs(params.timeoutMs, 100, 100); + const agentDir = params.agentDir?.trim() || undefined; + const cwd = agentDir ?? process.cwd(); const ownsClient = !params.options.clientFactory; // Tests inject a client factory; production creates an isolated app-server // client so media tasks cannot reuse the interactive attempt session. const client = params.options.clientFactory - ? await params.options.clientFactory(appServer.start, params.profile) + ? await params.options.clientFactory(appServer.start, params.profile, agentDir, params.cfg) : await import("./src/app-server/shared-client.js").then( ({ createIsolatedCodexAppServerClient }) => createIsolatedCodexAppServerClient({ startOptions: appServer.start, timeoutMs, authProfileId: params.profile, + agentDir, + authProfileStore: params.authStore, + config: params.cfg, }), ); const abortController = new AbortController(); @@ -166,7 +175,7 @@ async function runBoundedCodexVisionTurn(params: BoundedCodexVisionTurnParams): { model: params.model, modelProvider: "openai", - cwd: params.agentDir || process.cwd(), + cwd, approvalPolicy: "on-request", sandbox: "read-only", serviceName: "OpenClaw", @@ -193,7 +202,7 @@ async function runBoundedCodexVisionTurn(params: BoundedCodexVisionTurnParams): { threadId: thread.thread.id, input: params.input, - cwd: params.agentDir || process.cwd(), + cwd, approvalPolicy: "on-request", model: params.model, effort: "low", @@ -242,6 +251,8 @@ async function extractCodexStructured( profile: req.profile, timeoutMs: req.timeoutMs, agentDir: req.agentDir, + authStore: req.authStore, + cfg: req.cfg, options, taskLabel: "structured extraction", developerInstructions: diff --git a/extensions/codex/src/app-server/auth-bridge.test.ts b/extensions/codex/src/app-server/auth-bridge.test.ts index 702a5512dfdb..5f5c8c1268ef 100644 --- a/extensions/codex/src/app-server/auth-bridge.test.ts +++ b/extensions/codex/src/app-server/auth-bridge.test.ts @@ -5,6 +5,7 @@ import path from "node:path"; import { clearRuntimeAuthProfileStoreSnapshots, loadAuthProfileStoreForSecretsRuntime, + replaceRuntimeAuthProfileStoreSnapshots, } from "openclaw/plugin-sdk/agent-runtime"; import { upsertAuthProfile } from "openclaw/plugin-sdk/provider-auth"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -14,6 +15,7 @@ import { refreshCodexAppServerAuthTokens, resolveCodexAppServerAuthAccountCacheKey, resolveCodexAppServerAuthProfileId, + resolveCodexAppServerAuthProfileStore, resolveCodexAppServerFallbackApiKeyCacheKey, resolveCodexAppServerHomeDir, resolveCodexAppServerNativeHomeDir, @@ -179,6 +181,39 @@ async function writeCodexCliApiKeyAuthFile(codexHome: string): Promise { } describe("bridgeCodexAppServerStartOptions", () => { + it("preserves persisted provenance when preparing a supplied base store", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const authProfileStore = { version: 1, profiles: {} }; + try { + upsertAuthProfile({ + agentDir, + profileId: "openai:work", + credential: { + type: "oauth", + provider: "openai", + access: "persisted-access", + refresh: "persisted-refresh", + expires: Date.now() + 60_000, + }, + }); + + const prepared = resolveCodexAppServerAuthProfileStore({ + agentDir, + authProfileId: "openai:work", + authProfileStore, + }); + + expect(prepared).not.toBe(authProfileStore); + expect(prepared.runtimePersistedProfileIds).toContain("openai:work"); + expect(prepared.profiles["openai:work"]).toMatchObject({ + access: "persisted-access", + refresh: "persisted-refresh", + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + it("sets agent-owned CODEX_HOME without overriding HOME for local app-server launches", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); const startOptions = createStartOptions(); @@ -576,6 +611,603 @@ describe("bridgeCodexAppServerStartOptions", () => { } }); + it("applies a supplied scoped OAuth profile instead of persisted credentials", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai:work", + credential: { + type: "oauth", + provider: "openai", + access: "persisted-access", + refresh: "persisted-refresh", + expires: Date.now() + 24 * 60 * 60_000, + accountId: "persisted-account", + }, + }); + const authProfileStore: AuthProfileStore = { + version: 1, + profiles: { + "openai:work": { + type: "oauth", + provider: "openai", + access: "scoped-access", + refresh: "scoped-refresh", + expires: Date.now() + 24 * 60 * 60_000, + accountId: "scoped-account", + }, + }, + }; + + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai:work", + authProfileStore, + }); + + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "chatgptAuthTokens", + accessToken: "scoped-access", + chatgptAccountId: "scoped-account", + chatgptPlanType: null, + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it.each([ + { name: "without persisted same-id credentials", persistSameId: false }, + { name: "with persisted same-id credentials", persistSameId: true }, + ])("refreshes an expired scoped OAuth profile $name", async ({ persistSameId }) => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); + oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({ + access: "scoped-refreshed-access", + refresh: "scoped-refreshed-refresh", + expires: Date.now() + 60_000, + accountId: "scoped-refreshed-account", + }); + try { + if (persistSameId) { + upsertAuthProfile({ + agentDir, + profileId: "openai:work", + credential: { + type: "oauth", + provider: "openai", + access: "persisted-access", + refresh: "persisted-refresh", + expires: Date.now() + 24 * 60 * 60_000, + accountId: "persisted-account", + }, + }); + } + const authProfileStore: AuthProfileStore = { + version: 1, + profiles: { + "openai:work": { + type: "oauth", + provider: "openai", + access: "scoped-expired-access", + refresh: "scoped-refresh", + expires: Date.now() - 60_000, + accountId: "scoped-account", + }, + }, + }; + + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai:work", + authProfileStore, + }); + + expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("scoped-refresh"); + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "chatgptAuthTokens", + accessToken: "scoped-refreshed-access", + chatgptAccountId: "scoped-refreshed-account", + chatgptPlanType: null, + }); + expect(authProfileStore.profiles["openai:work"]).toMatchObject({ + access: "scoped-refreshed-access", + accountId: "scoped-refreshed-account", + }); + if (persistSameId) { + expect( + loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["openai:work"], + ).toMatchObject({ + access: "persisted-access", + accountId: "persisted-account", + }); + } + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("routes a supplied persisted OAuth clone through canonical refresh", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); + oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({ + access: "persisted-refreshed-access", + refresh: "persisted-refreshed-refresh", + expires: Date.now() + 60_000, + accountId: "persisted-account", + }); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai:work", + credential: { + type: "oauth", + provider: "openai", + access: "persisted-expired-access", + refresh: "persisted-refresh", + expires: Date.now() - 60_000, + accountId: "persisted-account", + }, + }); + const authProfileStore = loadAuthProfileStoreForSecretsRuntime(agentDir); + expect(authProfileStore.runtimePersistedProfileIds).toContain("openai:work"); + + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai:work", + authProfileStore, + }); + + expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("persisted-refresh"); + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "chatgptAuthTokens", + accessToken: "persisted-refreshed-access", + chatgptAccountId: "persisted-account", + chatgptPlanType: null, + }); + expect(loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["openai:work"]).toMatchObject( + { + access: "persisted-refreshed-access", + refresh: "persisted-refreshed-refresh", + accountId: "persisted-account", + }, + ); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("keeps a prepared persisted store aligned across rotating refresh tokens", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + oauthMocks.refreshOpenAICodexToken + .mockResolvedValueOnce({ + access: "first-rotated-access", + refresh: "first-rotated-refresh", + expires: Date.now() + 60_000, + }) + .mockResolvedValueOnce({ + access: "second-rotated-access", + refresh: "second-rotated-refresh", + expires: Date.now() + 60_000, + }); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai:work", + credential: { + type: "oauth", + provider: "openai", + access: "initial-access", + refresh: "initial-refresh", + expires: Date.now() + 60_000, + }, + }); + const authProfileStore = resolveCodexAppServerAuthProfileStore({ + agentDir, + authProfileId: "openai:work", + authProfileStore: { version: 1, profiles: {} }, + }); + + await refreshCodexAppServerAuthTokens({ + agentDir, + authProfileId: "openai:work", + authProfileStore, + }); + await refreshCodexAppServerAuthTokens({ + agentDir, + authProfileId: "openai:work", + authProfileStore, + }); + + expect(oauthMocks.refreshOpenAICodexToken.mock.calls).toEqual([ + ["initial-refresh"], + ["first-rotated-refresh"], + ]); + expect(authProfileStore.profiles["openai:work"]).toMatchObject({ + access: "second-rotated-access", + refresh: "second-rotated-refresh", + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("does not replace a prepared persisted store changed during refresh", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + let resolveRefresh: + | ((value: { access: string; refresh: string; expires: number }) => void) + | undefined; + oauthMocks.refreshOpenAICodexToken.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai:work", + credential: { + type: "oauth", + provider: "openai", + access: "initial-access", + refresh: "initial-refresh", + expires: Date.now() + 60_000, + }, + }); + const authProfileStore = resolveCodexAppServerAuthProfileStore({ + agentDir, + authProfileId: "openai:work", + authProfileStore: { version: 1, profiles: {} }, + }); + + const refresh = refreshCodexAppServerAuthTokens({ + agentDir, + authProfileId: "openai:work", + authProfileStore, + }); + await vi.waitFor(() => expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledTimes(1)); + authProfileStore.profiles["openai:work"] = { + type: "oauth", + provider: "openai", + access: "replacement-access", + refresh: "replacement-refresh", + expires: Date.now() + 60_000, + accountId: "replacement-account", + }; + resolveRefresh?.({ + access: "rotated-access", + refresh: "rotated-refresh", + expires: Date.now() + 60_000, + }); + + await refresh; + expect(authProfileStore.profiles["openai:work"]).toMatchObject({ + access: "replacement-access", + refresh: "replacement-refresh", + accountId: "replacement-account", + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("keeps a runtime-external same-account OAuth profile scoped", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); + oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({ + access: "scoped-refreshed-access", + refresh: "scoped-refreshed-refresh", + expires: Date.now() + 60_000, + accountId: "shared-account", + }); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai:work", + credential: { + type: "oauth", + provider: "openai", + access: "persisted-access", + refresh: "persisted-refresh", + expires: Date.now() + 24 * 60 * 60_000, + accountId: "shared-account", + }, + }); + const authProfileStore: AuthProfileStore = { + version: 1, + runtimeExternalProfileIds: ["openai:work"], + runtimeExternalProfileIdsAuthoritative: true, + profiles: { + "openai:work": { + type: "oauth", + provider: "openai", + access: "scoped-expired-access", + refresh: "scoped-refresh", + expires: Date.now() - 60_000, + accountId: "shared-account", + }, + }, + }; + + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai:work", + authProfileStore, + }); + + expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("scoped-refresh"); + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "chatgptAuthTokens", + accessToken: "scoped-refreshed-access", + chatgptAccountId: "shared-account", + chatgptPlanType: null, + }); + expect(loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["openai:work"]).toMatchObject( + { + access: "persisted-access", + refresh: "persisted-refresh", + accountId: "shared-account", + }, + ); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("keeps an ambiguous supplied OAuth identity scoped", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); + oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({ + access: "scoped-refreshed-access", + refresh: "scoped-refreshed-refresh", + expires: Date.now() + 60_000, + }); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai:work", + credential: { + type: "oauth", + provider: "openai", + access: "persisted-access", + refresh: "persisted-refresh", + expires: Date.now() + 24 * 60 * 60_000, + accountId: "persisted-account", + }, + }); + const authProfileStore: AuthProfileStore = { + version: 1, + profiles: { + "openai:work": { + type: "oauth", + provider: "openai", + access: "scoped-expired-access", + refresh: "scoped-refresh", + expires: Date.now() - 60_000, + }, + }, + }; + + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai:work", + authProfileStore, + }); + + expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("scoped-refresh"); + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "chatgptAuthTokens", + accessToken: "scoped-refreshed-access", + chatgptAccountId: "openai:work", + chatgptPlanType: null, + }); + expect(loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["openai:work"]).toMatchObject( + { + access: "persisted-access", + refresh: "persisted-refresh", + accountId: "persisted-account", + }, + ); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("routes a same-identity stale persisted clone through canonical persisted auth", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai:work", + credential: { + type: "oauth", + provider: "openai", + access: "stale-access", + refresh: "stale-refresh", + expires: Date.now() - 60_000, + accountId: "persisted-account", + }, + }); + const authProfileStore = loadAuthProfileStoreForSecretsRuntime(agentDir); + expect(authProfileStore.runtimePersistedProfileIds).toContain("openai:work"); + upsertAuthProfile({ + agentDir, + profileId: "openai:work", + credential: { + type: "oauth", + provider: "openai", + access: "current-access", + refresh: "current-refresh", + expires: Date.now() + 24 * 60 * 60_000, + accountId: "persisted-account", + }, + }); + + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai:work", + authProfileStore, + }); + + expect(oauthMocks.refreshOpenAICodexToken).not.toHaveBeenCalled(); + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "chatgptAuthTokens", + accessToken: "current-access", + chatgptAccountId: "persisted-account", + chatgptPlanType: null, + }); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("keeps a changed-identity persisted clone scoped", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); + oauthMocks.refreshOpenAICodexToken.mockResolvedValueOnce({ + access: "account-a-refreshed-access", + refresh: "account-a-refreshed-refresh", + expires: Date.now() + 60_000, + accountId: "account-a", + }); + try { + upsertAuthProfile({ + agentDir, + profileId: "openai:work", + credential: { + type: "oauth", + provider: "openai", + access: "account-a-expired-access", + refresh: "account-a-refresh", + expires: Date.now() - 60_000, + accountId: "account-a", + }, + }); + const authProfileStore = loadAuthProfileStoreForSecretsRuntime(agentDir); + expect(authProfileStore.runtimePersistedProfileIds).toContain("openai:work"); + upsertAuthProfile({ + agentDir, + profileId: "openai:work", + credential: { + type: "oauth", + provider: "openai", + access: "account-b-access", + refresh: "account-b-refresh", + expires: Date.now() + 24 * 60 * 60_000, + accountId: "account-b", + }, + }); + replaceRuntimeAuthProfileStoreSnapshots([{ agentDir, store: authProfileStore }]); + + await applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai:work", + authProfileStore, + }); + + expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledWith("account-a-refresh"); + expect(request).toHaveBeenCalledWith("account/login/start", { + type: "chatgptAuthTokens", + accessToken: "account-a-refreshed-access", + chatgptAccountId: "account-a", + chatgptPlanType: null, + }); + expect(loadAuthProfileStoreForSecretsRuntime(agentDir).profiles["openai:work"]).toMatchObject( + { + access: "account-b-access", + refresh: "account-b-refresh", + accountId: "account-b", + }, + ); + } finally { + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + + it("serializes concurrent refreshes of the same scoped OAuth profile", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + const request = vi.fn(async () => ({ type: "chatgptAuthTokens" })); + let resolveRefresh: + | ((value: { access: string; refresh: string; expires: number; accountId: string }) => void) + | undefined; + oauthMocks.refreshOpenAICodexToken.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveRefresh = resolve; + }), + ); + const authProfileStore: AuthProfileStore = { + version: 1, + profiles: { + "openai:work": { + type: "oauth", + provider: "openai", + access: "scoped-expired-access", + refresh: "scoped-refresh", + expires: Date.now() - 60_000, + accountId: "scoped-account", + }, + }, + }; + try { + const first = applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai:work", + authProfileStore, + }); + const second = applyCodexAppServerAuthProfile({ + client: { request } as never, + agentDir, + authProfileId: "openai:work", + authProfileStore, + }); + await vi.waitFor(() => expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledTimes(1)); + + resolveRefresh?.({ + access: "scoped-refreshed-access", + refresh: "scoped-refreshed-refresh", + expires: Date.now() + 60_000, + accountId: "scoped-refreshed-account", + }); + await Promise.all([first, second]); + + expect(oauthMocks.refreshOpenAICodexToken).toHaveBeenCalledTimes(1); + expect(request).toHaveBeenCalledTimes(2); + expect(request).toHaveBeenNthCalledWith(1, "account/login/start", { + type: "chatgptAuthTokens", + accessToken: "scoped-refreshed-access", + chatgptAccountId: "scoped-refreshed-account", + chatgptPlanType: null, + }); + expect(request).toHaveBeenNthCalledWith(2, "account/login/start", { + type: "chatgptAuthTokens", + accessToken: "scoped-refreshed-access", + chatgptAccountId: "scoped-refreshed-account", + chatgptPlanType: null, + }); + } finally { + resolveRefresh?.({ + access: "cleanup-access", + refresh: "cleanup-refresh", + expires: Date.now() + 60_000, + accountId: "cleanup-account", + }); + await fs.rm(agentDir, { recursive: true, force: true }); + } + }); + it("leaves native app-server auth untouched when auth bridging is disabled", async () => { const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); const request = vi.fn(async () => ({ requiresOpenaiAuth: true })); diff --git a/extensions/codex/src/app-server/auth-bridge.ts b/extensions/codex/src/app-server/auth-bridge.ts index 9a4de09f0322..834a3ce0fb5a 100644 --- a/extensions/codex/src/app-server/auth-bridge.ts +++ b/extensions/codex/src/app-server/auth-bridge.ts @@ -4,9 +4,10 @@ import fsSync from "node:fs"; import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { ensureAuthProfileStore, - ensureAuthProfileStoreWithoutExternalProfiles, + findPersistedAuthProfileCredential, loadAuthProfileStoreForSecretsRuntime, refreshOAuthCredentialForRuntime, resolveAuthProfileOrder, @@ -18,6 +19,7 @@ import { type AuthProfileStore, type OAuthCredential, } from "openclaw/plugin-sdk/agent-runtime"; +import { hasUsableOAuthCredential } from "openclaw/plugin-sdk/provider-auth"; import type { CodexAppServerClient } from "./client.js"; import type { CodexAppServerStartOptions } from "./config.js"; import type { @@ -48,11 +50,16 @@ const CODEX_AUTH_JSON_FILENAME = "auth.json"; const CODEX_HOME_DIRNAME = ".codex"; type AuthProfileOrderConfig = Parameters[0]["cfg"]; +const scopedOAuthRefreshQueues = new WeakMap< + AuthProfileStore, + Map> +>(); export async function bridgeCodexAppServerStartOptions(params: { startOptions: CodexAppServerStartOptions; agentDir: string; authProfileId?: string | null; + authProfileStore?: AuthProfileStore; config?: AuthProfileOrderConfig; }): Promise { if (params.startOptions.transport !== "stdio") { @@ -65,9 +72,10 @@ export async function bridgeCodexAppServerStartOptions(params: { if (params.authProfileId === null) { return isolatedStartOptions; } - const store = ensureCodexAppServerAuthProfileStore({ + const store = resolveCodexAppServerAuthProfileStore({ agentDir: params.agentDir, authProfileId: params.authProfileId, + authProfileStore: params.authProfileStore, config: params.config, }); const authProfileId = resolveCodexAppServerAuthProfileId({ @@ -103,13 +111,15 @@ export function resolveCodexAppServerAuthProfileId(params: { export function resolveCodexAppServerAuthProfileIdForAgent(params: { authProfileId?: string; + authProfileStore?: AuthProfileStore; agentDir?: string; config?: AuthProfileOrderConfig; }): string | undefined { const agentDir = params.agentDir?.trim() || resolveDefaultAgentDir(params.config ?? {}); - const store = ensureCodexAppServerAuthProfileStore({ + const store = resolveCodexAppServerAuthProfileStore({ agentDir, authProfileId: params.authProfileId, + authProfileStore: params.authProfileStore, config: params.config, }); return resolveCodexAppServerAuthProfileId({ @@ -132,7 +142,7 @@ function ensureCodexAppServerAuthProfileStore(params: { }); } -function resolveCodexAppServerAuthProfileStore(params: { +export function resolveCodexAppServerAuthProfileStore(params: { agentDir?: string; authProfileId?: string; authProfileStore?: AuthProfileStore; @@ -163,13 +173,41 @@ function resolveCodexAppServerAuthProfileStore(params: { ...params.authProfileStore.order, } : undefined; + const profiles = { + ...overlaidStore.profiles, + ...params.authProfileStore.profiles, + }; + const suppliedProfileIds = new Set(Object.keys(params.authProfileStore.profiles)); + const mergeRuntimeProfileIds = (overlaidIds?: string[], suppliedIds?: string[]) => [ + ...(overlaidIds ?? []).filter((profileId) => !suppliedProfileIds.has(profileId)), + ...(suppliedIds ?? []), + ]; + const runtimePersistedProfileIds = mergeRuntimeProfileIds( + overlaidStore.runtimePersistedProfileIds, + params.authProfileStore.runtimePersistedProfileIds, + ).filter((profileId) => profiles[profileId]); + const runtimeExternalProfileIds = mergeRuntimeProfileIds( + overlaidStore.runtimeExternalProfileIds, + params.authProfileStore.runtimeExternalProfileIds, + ).filter((profileId) => profiles[profileId]); + const runtimeExternalProfileIdsAuthoritative = + overlaidStore.runtimeExternalProfileIdsAuthoritative === true || + params.authProfileStore.runtimeExternalProfileIdsAuthoritative === true; return { ...params.authProfileStore, ...(order ? { order } : {}), - profiles: { - ...overlaidStore.profiles, - ...params.authProfileStore.profiles, - }, + profiles, + ...(runtimePersistedProfileIds.length > 0 + ? { runtimePersistedProfileIds: [...new Set(runtimePersistedProfileIds)] } + : {}), + ...(runtimeExternalProfileIds.length > 0 || runtimeExternalProfileIdsAuthoritative + ? { + runtimeExternalProfileIds: [...new Set(runtimeExternalProfileIds)], + ...(runtimeExternalProfileIdsAuthoritative + ? { runtimeExternalProfileIdsAuthoritative: true } + : {}), + } + : {}), }; } @@ -339,6 +377,7 @@ export async function applyCodexAppServerAuthProfile(params: { client: CodexAppServerClient; agentDir: string; authProfileId?: string | null; + authProfileStore?: AuthProfileStore; startOptions?: CodexAppServerStartOptions; config?: AuthProfileOrderConfig; }): Promise { @@ -348,6 +387,7 @@ export async function applyCodexAppServerAuthProfile(params: { const loginParams = await resolveCodexAppServerAuthProfileLoginParams({ agentDir: params.agentDir, authProfileId: params.authProfileId, + authProfileStore: params.authProfileStore, config: params.config, }); if (!loginParams) { @@ -371,6 +411,7 @@ export async function applyCodexAppServerAuthProfile(params: { function resolveCodexAppServerAuthProfileLoginParams(params: { agentDir: string; authProfileId?: string; + authProfileStore?: AuthProfileStore; config?: AuthProfileOrderConfig; }): Promise { return resolveCodexAppServerAuthProfileLoginParamsInternal(params); @@ -379,6 +420,7 @@ function resolveCodexAppServerAuthProfileLoginParams(params: { export async function refreshCodexAppServerAuthTokens(params: { agentDir: string; authProfileId?: string; + authProfileStore?: AuthProfileStore; config?: AuthProfileOrderConfig; }): Promise { const loginParams = await resolveCodexAppServerAuthProfileLoginParamsInternal({ @@ -398,12 +440,14 @@ export async function refreshCodexAppServerAuthTokens(params: { async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: { agentDir: string; authProfileId?: string; + authProfileStore?: AuthProfileStore; forceOAuthRefresh?: boolean; config?: AuthProfileOrderConfig; }): Promise { - const store = ensureCodexAppServerAuthProfileStore({ + const store = resolveCodexAppServerAuthProfileStore({ agentDir: params.agentDir, authProfileId: params.authProfileId, + authProfileStore: params.authProfileStore, config: params.config, }); const profileId = resolveCodexAppServerAuthProfileId({ @@ -425,6 +469,8 @@ async function resolveCodexAppServerAuthProfileLoginParamsInternal(params: { } const loginParams = await resolveLoginParamsForCredential(profileId, credential, { agentDir: params.agentDir, + store, + preferStoreCredential: Boolean(params.authProfileStore?.profiles[profileId]), forceOAuthRefresh: params.forceOAuthRefresh === true, config: params.config, }); @@ -509,14 +555,22 @@ function resolveCodexCliAuthFileApiKeyCacheKey(env: NodeJS.ProcessEnv): string | async function resolveLoginParamsForCredential( profileId: string, credential: AuthProfileCredential, - params: { agentDir: string; forceOAuthRefresh: boolean; config?: AuthProfileOrderConfig }, + params: { + agentDir: string; + store: AuthProfileStore; + preferStoreCredential: boolean; + forceOAuthRefresh: boolean; + config?: AuthProfileOrderConfig; + }, ): Promise { // Runtime honors the persisted auth profile type. Shape-based remediation // belongs at credential entry time so request handling does not preemptively // reject opaque provider credentials. if (credential.type === "api_key") { const resolved = await resolveApiKeyForProfile({ - store: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }), + store: params.preferStoreCredential + ? params.store + : ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }), profileId, agentDir: params.agentDir, }); @@ -525,7 +579,9 @@ async function resolveLoginParamsForCredential( } if (credential.type === "token") { const resolved = await resolveApiKeyForProfile({ - store: ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }), + store: params.preferStoreCredential + ? params.store + : ensureAuthProfileStore(params.agentDir, { allowKeychainPrompt: false }), profileId, agentDir: params.agentDir, }); @@ -539,6 +595,8 @@ async function resolveLoginParamsForCredential( } const resolvedCredential = await resolveOAuthCredentialForCodexAppServer(profileId, credential, { agentDir: params.agentDir, + store: params.store, + preferStoreCredential: params.preferStoreCredential, forceRefresh: params.forceOAuthRefresh, config: params.config, }); @@ -551,22 +609,40 @@ async function resolveLoginParamsForCredential( async function resolveOAuthCredentialForCodexAppServer( profileId: string, credential: OAuthCredential, - params: { agentDir: string; forceRefresh: boolean; config?: AuthProfileOrderConfig }, + params: { + agentDir: string; + store: AuthProfileStore; + preferStoreCredential: boolean; + forceRefresh: boolean; + config?: AuthProfileOrderConfig; + }, ): Promise { const ownerAgentDir = resolvePersistedAuthProfileOwnerAgentDir({ agentDir: params.agentDir, profileId, }); - const store = ensureCodexAppServerAuthProfileStore({ + const persistedCredential = findPersistedAuthProfileCredential({ agentDir: ownerAgentDir, - authProfileId: profileId, - config: params.config, + profileId, }); - const persistedStore = ensureAuthProfileStoreWithoutExternalProfiles(ownerAgentDir, { - allowKeychainPrompt: false, - }); - const persistedCredential = persistedStore.profiles[profileId]; + const useScopedCredential = + params.preferStoreCredential && + shouldUseScopedOAuthCredential({ + store: params.store, + profileId, + persistedCredential, + suppliedCredential: credential, + config: params.config, + }); + const store = useScopedCredential + ? params.store + : ensureCodexAppServerAuthProfileStore({ + agentDir: ownerAgentDir, + authProfileId: profileId, + config: params.config, + }); const persistedOAuthCredential = + !useScopedCredential && persistedCredential?.type === "oauth" && isCodexAppServerAuthProvider(persistedCredential.provider, params.config) ? persistedCredential @@ -577,6 +653,14 @@ async function resolveOAuthCredentialForCodexAppServer( isCodexAppServerAuthProvider(ownerCredential.provider, params.config) ? ownerCredential : undefined; + if (useScopedCredential && overlaidOAuthCredential) { + return await resolveScopedOAuthCredential({ + store, + profileId, + credential: overlaidOAuthCredential, + forceRefresh: params.forceRefresh, + }); + } if (params.forceRefresh && !persistedOAuthCredential && overlaidOAuthCredential) { const refreshedRuntimeCredential = await refreshOAuthCredentialForRuntime({ credential: overlaidOAuthCredential, @@ -593,18 +677,111 @@ async function resolveOAuthCredentialForCodexAppServer( agentDir: ownerAgentDir, forceRefresh: params.forceRefresh && Boolean(persistedOAuthCredential), }); - const refreshed = loadAuthProfileStoreForSecretsRuntime(ownerAgentDir).profiles[profileId]; - const storedCredential = store.profiles[profileId]; - const candidate = + const refreshed = useScopedCredential + ? undefined + : loadAuthProfileStoreForSecretsRuntime(ownerAgentDir).profiles[profileId]; + const refreshedOAuthCredential = refreshed?.type === "oauth" && isCodexAppServerAuthProvider(refreshed.provider, params.config) ? refreshed - : storedCredential?.type === "oauth" && - isCodexAppServerAuthProvider(storedCredential.provider, params.config) - ? storedCredential - : credential; + : undefined; + if (refreshedOAuthCredential && isDeepStrictEqual(params.store.profiles[profileId], credential)) { + // Persisted refreshes rotate refresh tokens. Keep an isolated prepared + // store aligned without reverting a concurrent caller-owned replacement. + params.store.profiles[profileId] = refreshedOAuthCredential; + } + const storedCredential = store.profiles[profileId]; + const candidate = refreshedOAuthCredential + ? refreshedOAuthCredential + : storedCredential?.type === "oauth" && + isCodexAppServerAuthProvider(storedCredential.provider, params.config) + ? storedCredential + : credential; return resolved?.apiKey ? { ...candidate, access: resolved.apiKey } : candidate; } +function shouldUseScopedOAuthCredential(params: { + store: AuthProfileStore; + profileId: string; + persistedCredential: AuthProfileCredential | undefined; + suppliedCredential: OAuthCredential; + config?: AuthProfileOrderConfig; +}): boolean { + if (!params.store.runtimePersistedProfileIds?.includes(params.profileId)) { + return true; + } + const persisted = params.persistedCredential; + if (persisted?.type !== "oauth") { + return true; + } + if ( + resolveProviderIdForAuth(persisted.provider, { config: params.config }) !== + resolveProviderIdForAuth(params.suppliedCredential.provider, { config: params.config }) + ) { + return true; + } + return ( + !isDeepStrictEqual(persisted, params.suppliedCredential) && + !hasMatchingOAuthIdentity(persisted, params.suppliedCredential) + ); +} + +function hasMatchingOAuthIdentity(persisted: OAuthCredential, supplied: OAuthCredential): boolean { + const persistedAccountId = persisted.accountId?.trim(); + const suppliedAccountId = supplied.accountId?.trim(); + if (persistedAccountId && suppliedAccountId) { + return persistedAccountId === suppliedAccountId; + } + const persistedEmail = persisted.email?.trim().toLowerCase(); + const suppliedEmail = supplied.email?.trim().toLowerCase(); + return Boolean(persistedEmail && suppliedEmail && persistedEmail === suppliedEmail); +} + +async function resolveScopedOAuthCredential(params: { + store: AuthProfileStore; + profileId: string; + credential: OAuthCredential; + forceRefresh: boolean; +}): Promise { + const existingRefresh = scopedOAuthRefreshQueues.get(params.store)?.get(params.profileId); + if (existingRefresh) { + return await existingRefresh; + } + if (!params.forceRefresh && hasUsableOAuthCredential(params.credential)) { + return params.credential; + } + + const storeRefreshes = scopedOAuthRefreshQueues.get(params.store) ?? new Map(); + scopedOAuthRefreshQueues.set(params.store, storeRefreshes); + const refresh = (async () => { + const current = params.store.profiles[params.profileId]; + const credential = current?.type === "oauth" ? current : params.credential; + if (!params.forceRefresh && hasUsableOAuthCredential(credential)) { + return credential; + } + const refreshed = await refreshOAuthCredentialForRuntime({ credential }); + if (!refreshed?.access?.trim()) { + throw new Error(`Codex app-server auth profile "${params.profileId}" could not refresh.`); + } + if (!isDeepStrictEqual(params.store.profiles[params.profileId], credential)) { + throw new Error( + `Codex app-server auth profile "${params.profileId}" changed while refreshing.`, + ); + } + params.store.profiles[params.profileId] = refreshed; + return refreshed; + })(); + storeRefreshes.set(params.profileId, refresh); + try { + return await refresh; + } finally { + // Scoped stores are process-local; serialize their rotating refresh token + // and release the queue entry with the refresh that owns it. + if (storeRefreshes.get(params.profileId) === refresh) { + storeRefreshes.delete(params.profileId); + } + } +} + function isCodexAppServerAuthProvider(provider: string, config?: AuthProfileOrderConfig): boolean { const resolvedProvider = resolveProviderIdForAuth(provider, { config }); return ( diff --git a/extensions/codex/src/app-server/shared-client.test.ts b/extensions/codex/src/app-server/shared-client.test.ts index 3a0c6963ab77..f120ccb37199 100644 --- a/extensions/codex/src/app-server/shared-client.test.ts +++ b/extensions/codex/src/app-server/shared-client.test.ts @@ -13,6 +13,14 @@ const mocks = vi.hoisted(() => ({ resolveCodexAppServerAuthProfileIdForAgent: vi.fn( (params?: { authProfileId?: string }) => params?.authProfileId, ), + resolveCodexAppServerAuthProfileStore: vi.fn( + (params?: { authProfileStore?: unknown }) => params?.authProfileStore, + ), + refreshCodexAppServerAuthTokens: vi.fn(async () => ({ + accessToken: "refreshed-access", + chatgptAccountId: "refreshed-account", + chatgptPlanType: null, + })), resolveCodexAppServerFallbackApiKeyCacheKey: vi.fn(() => undefined as string | undefined), resolveManagedCodexAppServerStartOptions: vi.fn(async (startOptions) => startOptions), embeddedAgentLog: { debug: vi.fn(), warn: vi.fn() }, @@ -23,6 +31,8 @@ vi.mock("./auth-bridge.js", () => ({ applyCodexAppServerAuthProfile: mocks.applyCodexAppServerAuthProfile, bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions, resolveCodexAppServerAuthProfileIdForAgent: mocks.resolveCodexAppServerAuthProfileIdForAgent, + resolveCodexAppServerAuthProfileStore: mocks.resolveCodexAppServerAuthProfileStore, + refreshCodexAppServerAuthTokens: mocks.refreshCodexAppServerAuthTokens, resolveCodexAppServerFallbackApiKeyCacheKey: mocks.resolveCodexAppServerFallbackApiKeyCacheKey, })); @@ -79,6 +89,7 @@ function bridgeStartOptionsCall() { return firstMockArg(mocks.bridgeCodexAppServerStartOptions, "bridge start options") as { agentDir?: string; authProfileId?: string; + authProfileStore?: unknown; config?: unknown; startOptions: { command?: string; commandSource?: string }; }; @@ -88,6 +99,7 @@ function applyAuthProfileCall() { return firstMockArg(mocks.applyCodexAppServerAuthProfile, "apply auth profile") as { agentDir?: string; authProfileId?: string; + authProfileStore?: unknown; config?: unknown; }; } @@ -96,6 +108,7 @@ function resolveAuthProfileCall() { return firstMockArg(mocks.resolveCodexAppServerAuthProfileIdForAgent, "resolve auth profile") as { agentDir?: string; authProfileId?: string; + authProfileStore?: unknown; config?: unknown; }; } @@ -142,6 +155,11 @@ describe("shared Codex app-server client", () => { mocks.resolveCodexAppServerAuthProfileIdForAgent.mockImplementation( (params?: { authProfileId?: string }) => params?.authProfileId, ); + mocks.resolveCodexAppServerAuthProfileStore.mockClear(); + mocks.resolveCodexAppServerAuthProfileStore.mockImplementation( + (params?: { authProfileStore?: unknown }) => params?.authProfileStore, + ); + mocks.refreshCodexAppServerAuthTokens.mockClear(); mocks.resolveCodexAppServerFallbackApiKeyCacheKey.mockClear(); mocks.resolveCodexAppServerFallbackApiKeyCacheKey.mockReturnValue(undefined); mocks.resolveManagedCodexAppServerStartOptions.mockClear(); @@ -240,6 +258,95 @@ describe("shared Codex app-server client", () => { expect(applyCall?.authProfileId).toBe("openai:work"); }); + it("carries a scoped auth store through isolated app-server startup", async () => { + const harness = createClientHarness(); + vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client); + const authProfileStore = { version: 1, profiles: {} }; + const preparedAuthProfileStore = { + version: 1, + profiles: { + "openai:scoped": { type: "token", provider: "openai", token: "prepared-token" }, + }, + }; + mocks.resolveCodexAppServerAuthProfileIdForAgent.mockReturnValue("openai:scoped"); + mocks.resolveCodexAppServerAuthProfileStore.mockReturnValue(preparedAuthProfileStore); + + const clientPromise = createIsolatedCodexAppServerClient({ + timeoutMs: 1000, + authProfileStore, + }); + await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)"); + + await expect(clientPromise).resolves.toBe(harness.client); + expect(mocks.resolveCodexAppServerAuthProfileStore).toHaveBeenCalledWith({ + agentDir: "/tmp/openclaw-agent", + authProfileId: undefined, + authProfileStore, + config: undefined, + }); + expect(resolveAuthProfileCall().authProfileStore).toBe(preparedAuthProfileStore); + expect(bridgeStartOptionsCall().authProfileStore).toBe(preparedAuthProfileStore); + expect(applyAuthProfileCall().authProfileStore).toBe(preparedAuthProfileStore); + + const priorWriteCount = harness.writes.length; + harness.send({ + id: "refresh-1", + method: "account/chatgptAuthTokens/refresh", + params: { reason: "unauthorized", previousAccountId: "scoped-account" }, + }); + await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThan(priorWriteCount)); + + expect(mocks.refreshCodexAppServerAuthTokens).toHaveBeenCalledWith({ + agentDir: "/tmp/openclaw-agent", + authProfileId: "openai:scoped", + authProfileStore: preparedAuthProfileStore, + config: undefined, + }); + expect(JSON.parse(harness.writes.at(-1) ?? "{}")).toEqual({ + id: "refresh-1", + result: { + accessToken: "refreshed-access", + chatgptAccountId: "refreshed-account", + chatgptPlanType: null, + }, + }); + }); + + it("registers persisted profile refresh for isolated app-server startup", async () => { + const harness = createClientHarness(); + vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client); + + const clientPromise = createIsolatedCodexAppServerClient({ + timeoutMs: 1000, + authProfileId: "openai:persisted", + agentDir: "/tmp/openclaw-persisted-agent", + }); + await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)"); + + await expect(clientPromise).resolves.toBe(harness.client); + const priorWriteCount = harness.writes.length; + harness.send({ + id: "refresh-persisted", + method: "account/chatgptAuthTokens/refresh", + params: { reason: "unauthorized", previousAccountId: "persisted-account" }, + }); + await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThan(priorWriteCount)); + + expect(mocks.refreshCodexAppServerAuthTokens).toHaveBeenCalledWith({ + agentDir: "/tmp/openclaw-persisted-agent", + authProfileId: "openai:persisted", + config: undefined, + }); + expect(JSON.parse(harness.writes.at(-1) ?? "{}")).toEqual({ + id: "refresh-persisted", + result: { + accessToken: "refreshed-access", + chatgptAccountId: "refreshed-account", + chatgptPlanType: null, + }, + }); + }); + it("skips target auth resolution when native source auth is requested", async () => { const harness = createClientHarness(); vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client); diff --git a/extensions/codex/src/app-server/shared-client.ts b/extensions/codex/src/app-server/shared-client.ts index c5b7cd0877b4..0e6fdb7fad58 100644 --- a/extensions/codex/src/app-server/shared-client.ts +++ b/extensions/codex/src/app-server/shared-client.ts @@ -2,11 +2,13 @@ * Owns shared and isolated Codex app-server client startup, auth application, * lease tracking, and teardown. */ -import { resolveDefaultAgentDir } from "openclaw/plugin-sdk/agent-runtime"; +import { resolveDefaultAgentDir, type AuthProfileStore } from "openclaw/plugin-sdk/agent-runtime"; import { applyCodexAppServerAuthProfile, bridgeCodexAppServerStartOptions, + refreshCodexAppServerAuthTokens, resolveCodexAppServerAuthProfileIdForAgent, + resolveCodexAppServerAuthProfileStore, resolveCodexAppServerFallbackApiKeyCacheKey, } from "./auth-bridge.js"; import { CodexAppServerClient } from "./client.js"; @@ -113,26 +115,41 @@ type CodexAppServerClientOptions = { abandonSignal?: AbortSignal; }; +type IsolatedCodexAppServerClientOptions = CodexAppServerClientOptions & { + authProfileStore?: AuthProfileStore; +}; + type ResolvedCodexAppServerClientStartContext = { agentDir: string; usesNativeAuth: boolean; authProfileId: string | undefined; + authProfileStore: AuthProfileStore | undefined; startOptions: CodexAppServerStartOptions; }; async function resolveCodexAppServerClientStartContext( - options?: CodexAppServerClientOptions, + options?: IsolatedCodexAppServerClientOptions, ): Promise { const agentDir = options?.agentDir ?? resolveDefaultAgentDir(options?.config ?? {}); const usesNativeAuth = options?.authProfileId === null; const requestedAuthProfileId = options?.authProfileId === null ? undefined : options?.authProfileId; + const authProfileStore = + !usesNativeAuth && options?.authProfileStore + ? resolveCodexAppServerAuthProfileStore({ + agentDir, + authProfileId: requestedAuthProfileId, + authProfileStore: options.authProfileStore, + config: options.config, + }) + : options?.authProfileStore; const authProfileId = usesNativeAuth ? undefined : resolveCodexAppServerAuthProfileIdForAgent({ authProfileId: requestedAuthProfileId, agentDir, config: options?.config, + ...(authProfileStore ? { authProfileStore } : {}), }); const requestedStartOptions = options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start; @@ -142,8 +159,9 @@ async function resolveCodexAppServerClientStartContext( agentDir, authProfileId: usesNativeAuth ? null : authProfileId, config: options?.config, + ...(authProfileStore ? { authProfileStore } : {}), }); - return { agentDir, usesNativeAuth, authProfileId, startOptions }; + return { agentDir, usesNativeAuth, authProfileId, authProfileStore, startOptions }; } /** Gets or starts a shared Codex app-server client without retaining a lease. */ @@ -269,11 +287,26 @@ async function acquireSharedCodexAppServerClient( /** Starts a non-shared Codex app-server client owned entirely by the caller. */ export async function createIsolatedCodexAppServerClient( - options?: CodexAppServerClientOptions, + options?: IsolatedCodexAppServerClientOptions, ): Promise { - const { agentDir, usesNativeAuth, authProfileId, startOptions } = + const { agentDir, usesNativeAuth, authProfileId, authProfileStore, startOptions } = await resolveCodexAppServerClientStartContext(options); const client = CodexAppServerClient.start(startOptions); + if (authProfileId) { + // Profile-backed Codex auth is ephemeral. Keep the host refresh callback + // available whether the profile came from a scoped store or persisted state. + client.addRequestHandler(async (request) => { + if (request.method !== "account/chatgptAuthTokens/refresh") { + return undefined; + } + return await refreshCodexAppServerAuthTokens({ + agentDir, + authProfileId, + ...(authProfileStore ? { authProfileStore } : {}), + config: options?.config, + }); + }); + } const initialize = client.initialize(); try { await withTimeout(initialize, options?.timeoutMs ?? 0, "codex app-server initialize timed out"); @@ -283,6 +316,7 @@ export async function createIsolatedCodexAppServerClient( authProfileId: usesNativeAuth ? null : authProfileId, startOptions, config: options?.config, + ...(authProfileStore ? { authProfileStore } : {}), }); return client; } catch (error) { diff --git a/src/agents/auth-profiles/oauth-shared.test.ts b/src/agents/auth-profiles/oauth-shared.test.ts index 95480f995490..fdb544592da7 100644 --- a/src/agents/auth-profiles/oauth-shared.test.ts +++ b/src/agents/auth-profiles/oauth-shared.test.ts @@ -126,6 +126,38 @@ describe("overlayRuntimeExternalOAuthProfiles", () => { expect(overlaid.runtimeExternalProfileIdsAuthoritative).toBe(true); }); + it("removes persisted provenance for every externally overlaid profile", () => { + const store: AuthProfileStore = { + version: 1, + runtimePersistedProfileIds: ["openai:default"], + profiles: { + "openai:default": { + type: "oauth", + provider: "openai", + access: "persisted-access", + refresh: "persisted-refresh", + expires: 1, + }, + }, + }; + + const overlaid = overlayRuntimeExternalOAuthProfiles(store, [ + { + profileId: "openai:default", + persistence: "persisted", + credential: { + type: "oauth", + provider: "openai", + access: "external-access", + refresh: "external-refresh", + expires: 2, + }, + }, + ]); + + expect(overlaid.runtimePersistedProfileIds).toBeUndefined(); + }); + it("replaces an existing OAuth credential with an out-of-range expiry", () => { const existing: OAuthCredential = { type: "oauth", diff --git a/src/agents/auth-profiles/oauth-shared.ts b/src/agents/auth-profiles/oauth-shared.ts index 01b7a1ec4314..06378f2cc242 100644 --- a/src/agents/auth-profiles/oauth-shared.ts +++ b/src/agents/auth-profiles/oauth-shared.ts @@ -196,9 +196,16 @@ export function overlayRuntimeExternalOAuthProfiles( ): AuthProfileStore { const externalProfiles = Array.from(profiles); const next = cloneAuthProfileStore(store); + const overlaidProfileIds = new Set(externalProfiles.map((profile) => profile.profileId)); for (const profile of externalProfiles) { next.profiles[profile.profileId] = profile.credential; } + next.runtimePersistedProfileIds = store.runtimePersistedProfileIds + ?.filter((profileId) => next.profiles[profileId] && !overlaidProfileIds.has(profileId)) + .toSorted(); + if (next.runtimePersistedProfileIds?.length === 0) { + next.runtimePersistedProfileIds = undefined; + } const runtimeOnlyProfileIds = new Set( externalProfiles .filter((profile) => profile.persistence !== "persisted") diff --git a/src/agents/auth-profiles/persisted-boundary.test.ts b/src/agents/auth-profiles/persisted-boundary.test.ts index e13d0ae25742..3ff7b05e6240 100644 --- a/src/agents/auth-profiles/persisted-boundary.test.ts +++ b/src/agents/auth-profiles/persisted-boundary.test.ts @@ -220,6 +220,45 @@ describe("persisted auth profile boundary", () => { expect(merged.lastGood?.anthropic).toBe(profileId); }); + it("tracks persisted profile provenance with override precedence", () => { + const merged = mergeAuthProfileStores( + { + version: AUTH_STORE_VERSION, + runtimePersistedProfileIds: ["openai:base", "openai:overridden"], + profiles: { + "openai:base": { + type: "api_key", + provider: "openai", + key: "base-key", + }, + "openai:overridden": { + type: "api_key", + provider: "openai", + key: "old-key", + }, + }, + }, + { + version: AUTH_STORE_VERSION, + runtimePersistedProfileIds: ["openai:added"], + profiles: { + "openai:overridden": { + type: "api_key", + provider: "openai", + key: "scoped-key", + }, + "openai:added": { + type: "api_key", + provider: "openai", + key: "added-key", + }, + }, + }, + ); + + expect(merged.runtimePersistedProfileIds).toEqual(["openai:added", "openai:base"]); + }); + it("preserves config-only order fallbacks during agent-store merges", () => { const merged = mergeAuthProfileStores( { diff --git a/src/agents/auth-profiles/persisted.ts b/src/agents/auth-profiles/persisted.ts index c1f31e811933..2486a5bdd0ad 100644 --- a/src/agents/auth-profiles/persisted.ts +++ b/src/agents/auth-profiles/persisted.ts @@ -592,6 +592,7 @@ export function mergeAuthProfileStores( !override.order && !override.lastGood && !override.usageStats && + override.runtimePersistedProfileIds === undefined && override.runtimeExternalProfileIds === undefined && override.runtimeExternalProfileIdsAuthoritative !== true ) { @@ -651,6 +652,14 @@ export function mergeAuthProfileStores( lastGood, usageStats, }; + const runtimePersistedProfileIds = [ + ...(base.runtimePersistedProfileIds ?? []).filter( + (profileId) => !overrideProfileIds.has(profileId), + ), + ...(override.runtimePersistedProfileIds ?? []), + ] + .filter((profileId) => merged.profiles[profileId]) + .toSorted(); const baseRuntimeExternalProfileIds = override.runtimeExternalProfileIdsAuthoritative === true && options?.preserveBaseRuntimeExternalProfiles !== true @@ -681,6 +690,9 @@ export function mergeAuthProfileStores( override, merged: { ...merged, + ...(runtimePersistedProfileIds.length > 0 + ? { runtimePersistedProfileIds: [...new Set(runtimePersistedProfileIds)] } + : {}), ...runtimeExternalProfileMetadata, }, }); diff --git a/src/agents/auth-profiles/profiles.test.ts b/src/agents/auth-profiles/profiles.test.ts index 2842bb9620fa..f64f1bd01831 100644 --- a/src/agents/auth-profiles/profiles.test.ts +++ b/src/agents/auth-profiles/profiles.test.ts @@ -20,8 +20,10 @@ import { } from "./profiles.js"; import { clearRuntimeAuthProfileStoreSnapshots, + getRuntimeAuthProfileStoreSnapshot, loadAuthProfileStoreForRuntime, loadAuthProfileStoreWithoutExternalProfiles, + replaceRuntimeAuthProfileStoreSnapshots, saveAuthProfileStore, } from "./store.js"; import type { AuthProfileStore } from "./types.js"; @@ -97,6 +99,49 @@ function expectOAuthCredentialFields( } describe("promoteAuthProfileInOrder", () => { + it("marks newly saved runtime snapshot profiles as persisted", async () => { + await withAuthProfileTestState( + "openclaw-auth-profile-runtime-persisted-", + async ({ agentDir }) => { + fs.mkdirSync(agentDir, { recursive: true }); + replaceRuntimeAuthProfileStoreSnapshots([ + { + agentDir, + store: { + version: AUTH_STORE_VERSION, + profiles: {}, + }, + }, + ]); + try { + saveAuthProfileStore( + { + version: AUTH_STORE_VERSION, + profiles: { + "openai:work": { + type: "oauth", + provider: "openai", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + accountId: "account-123", + }, + }, + }, + agentDir, + ); + + expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.runtimePersistedProfileIds).toEqual([ + "openai:work", + ]); + } finally { + clearRuntimeAuthProfileStoreSnapshots(); + } + }, + { clearOAuthDir: true }, + ); + }); + it("normalizes copied secrets when using the locked upsert path", async () => { await withAuthProfileTestState( "openclaw-auth-profile-upsert-", @@ -122,7 +167,11 @@ describe("promoteAuthProfileInOrder", () => { agentDir, }); - const profiles = loadAuthProfileStoreWithoutExternalProfiles(agentDir).profiles; + const store = loadAuthProfileStoreWithoutExternalProfiles(agentDir); + expect(store.runtimePersistedProfileIds).toEqual(["anthropic:key", "openai:manual"]); + expect(store.runtimeExternalProfileIds).toBeUndefined(); + expect(store.runtimeExternalProfileIdsAuthoritative).toBeUndefined(); + const profiles = store.profiles; expect(profiles["openai:manual"]).toMatchObject({ type: "token", provider: "openai", diff --git a/src/agents/auth-profiles/store.ts b/src/agents/auth-profiles/store.ts index 75d14fd6892a..64d5f9504c89 100644 --- a/src/agents/auth-profiles/store.ts +++ b/src/agents/auth-profiles/store.ts @@ -435,6 +435,12 @@ function pruneAuthProfileStoreReferences( Object.entries(store.usageStats).filter(([profileId]) => keptProfileIds.has(profileId)), ) : undefined; + store.runtimePersistedProfileIds = store.runtimePersistedProfileIds + ?.filter((profileId) => keptProfileIds.has(profileId)) + .toSorted(); + if (store.runtimePersistedProfileIds?.length === 0) { + store.runtimePersistedProfileIds = undefined; + } store.runtimeExternalProfileIds = store.runtimeExternalProfileIds ?.filter((profileId) => keptProfileIds.has(profileId)) .toSorted(); @@ -521,22 +527,40 @@ function buildAuthProfileStoreWithoutExternalProfiles(params: { const runtimeExternalProfileIds = new Set(params.store.runtimeExternalProfileIds ?? []); const localStore = cloneAuthProfileStore(params.store); if (runtimeExternalProfileIds.size === 0) { - localStore.runtimeExternalProfileIds = undefined; - localStore.runtimeExternalProfileIdsAuthoritative = undefined; - return localStore; + return stripRuntimeExternalProfileMetadata(localStore); } for (const profileId of runtimeExternalProfileIds) { delete localStore.profiles[profileId]; } const keptProfileIds = new Set(Object.keys(localStore.profiles)); pruneAuthProfileStoreReferences(localStore, keptProfileIds); - localStore.runtimeExternalProfileIds = undefined; - localStore.runtimeExternalProfileIdsAuthoritative = undefined; const persistedStore = loadAuthProfileStoreWithoutExternalProfiles( params.agentDir, params.options, ); - return mergeAuthProfileStores(persistedStore, localStore); + return stripRuntimeExternalProfileMetadata(mergeAuthProfileStores(persistedStore, localStore)); +} + +function stripRuntimeExternalProfileMetadata(store: AuthProfileStore): AuthProfileStore { + const stripped = { ...store }; + delete stripped.runtimeExternalProfileIds; + delete stripped.runtimeExternalProfileIdsAuthoritative; + return stripped; +} + +function markRuntimePersistedProfiles( + store: AuthProfileStore, + persistedStore: AuthProfileStore = store, +): AuthProfileStore { + const profileIds = Object.entries(persistedStore.profiles) + .flatMap(([profileId, credential]) => + isDeepStrictEqual(store.profiles[profileId], credential) ? [profileId] : [], + ) + .toSorted(); + return { + ...store, + runtimePersistedProfileIds: profileIds.length > 0 ? profileIds : undefined, + }; } function buildRuntimeAuthProfileStoreForSave(params: { @@ -743,11 +767,11 @@ export async function updateAuthProfileStoreWithLock(params: { export function loadAuthProfileStore(): AuthProfileStore { const asStore = loadPersistedAuthProfileStore(); if (asStore) { - return overlayExternalAuthProfiles(asStore); + return overlayExternalAuthProfiles(markRuntimePersistedProfiles(asStore)); } const store: AuthProfileStore = { version: AUTH_STORE_VERSION, profiles: {} }; - return overlayExternalAuthProfiles(store); + return overlayExternalAuthProfiles(markRuntimePersistedProfiles(store)); } function loadAuthProfileStoreForAgent( @@ -762,7 +786,7 @@ function loadAuthProfileStoreForAgent( agentDir, options, }); - return synced.store; + return markRuntimePersistedProfiles(synced.store); } const store: AuthProfileStore = { @@ -782,7 +806,7 @@ function loadAuthProfileStoreForAgent( agentDir, options, }); - return synced.store; + return markRuntimePersistedProfiles(synced.store); } /** Loads the effective runtime store for an agent, including inherited main profiles. */ @@ -841,13 +865,15 @@ export function loadAuthProfileStoreWithoutExternalProfiles( const authPath = resolveAuthStorePath(agentDir); const mainAuthPath = resolveAuthStorePath(); if (!agentDir || authPath === mainAuthPath) { - return store; + return stripRuntimeExternalProfileMetadata(store); } const mainStore = loadAuthProfileStoreForAgent(undefined, options); - return mergeAuthProfileStores(mainStore, store, { - preserveBaseRuntimeExternalProfiles: true, - }); + return stripRuntimeExternalProfileMetadata( + mergeAuthProfileStores(mainStore, store, { + preserveBaseRuntimeExternalProfiles: true, + }), + ); } /** Ensure an auth store is available, including runtime/external profile overlays. */ @@ -905,13 +931,15 @@ export function ensureAuthProfileStoreWithoutExternalProfiles( const authPath = resolveAuthStorePath(agentDir); const mainAuthPath = resolveAuthStorePath(); if (!agentDir || authPath === mainAuthPath) { - return store; + return stripRuntimeExternalProfileMetadata(store); } const mainStore = loadAuthProfileStoreForAgent(undefined, effectiveOptions); - return mergeAuthProfileStores(mainStore, store, { - preserveBaseRuntimeExternalProfiles: true, - }); + return stripRuntimeExternalProfileMetadata( + mergeAuthProfileStores(mainStore, store, { + preserveBaseRuntimeExternalProfiles: true, + }), + ); } /** Find a persisted credential in the scoped store, falling back to the main store. */ @@ -1030,7 +1058,10 @@ export function saveAuthProfileStore( } if (hasRuntimeAuthProfileStoreSnapshot(agentDir)) { const existingRuntimeStore = getRuntimeAuthProfileStoreSnapshot(agentDir); - const nextRuntimeStore = buildRuntimeAuthProfileStoreForSave({ store, agentDir, options }); + const nextRuntimeStore = markRuntimePersistedProfiles( + buildRuntimeAuthProfileStoreForSave({ store, agentDir, options }), + localStore, + ); setRuntimeAuthProfileStoreSnapshot( existingRuntimeStore ? mergeRuntimeExternalProfileReferences({ diff --git a/src/agents/auth-profiles/types.ts b/src/agents/auth-profiles/types.ts index 9c89fb62e0e3..a4db58f1c766 100644 --- a/src/agents/auth-profiles/types.ts +++ b/src/agents/auth-profiles/types.ts @@ -139,6 +139,8 @@ export type AuthProfileStateStore = { /** Effective in-memory auth store combining credentials, state, and overlays. */ export type AuthProfileStore = AuthProfileSecretsStore & AuthProfileState & { + /** Runtime-only provenance for credentials cloned from persisted auth stores. */ + runtimePersistedProfileIds?: string[]; /** Runtime-only provenance for external OAuth profiles overlaid onto this store. */ runtimeExternalProfileIds?: string[]; /** True when the runtime external profile set was freshly resolved, even if empty. */ diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts index 38971fe65637..7cb077e788c3 100644 --- a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts +++ b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts @@ -1030,6 +1030,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { mockedGetApiKeyForModel.mockRejectedValueOnce(new Error("generic auth should be skipped")); const codexAuthStore = { version: 1, + runtimePersistedProfileIds: ["anthropic:work", "openai:other", "openai:work", "xai:work"], profiles: { "openai:work": { type: "oauth" as const, @@ -1109,6 +1110,9 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { const forwardedAuthStore = expectRecordFields(harnessParams.authProfileStore, {}); const authProfiles = expectRecordFields(forwardedAuthStore.profiles, {}); expect(Object.keys(authProfiles)).toEqual(["openai:work"]); + expect(forwardedAuthStore.runtimePersistedProfileIds).toEqual(["openai:work"]); + expect(forwardedAuthStore.runtimeExternalProfileIds).toBeUndefined(); + expect(forwardedAuthStore.runtimeExternalProfileIdsAuthoritative).toBeUndefined(); expectRecordFields(authProfiles["openai:work"], { provider: "openai", }); @@ -1216,6 +1220,7 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }); const codexAuthStore = { version: 1 as const, + runtimePersistedProfileIds: ["openai:work"], profiles: { "openai:work": { type: "oauth" as const, @@ -1311,6 +1316,8 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { }); const codexAuthStore = { version: 1 as const, + runtimePersistedProfileIds: ["xai:work"], + runtimeExternalProfileIds: ["openai:default"], profiles: { "openai:default": { type: "oauth" as const, @@ -1431,6 +1438,9 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => { const forwardedAuthStore = expectRecordFields(harnessParams.authProfileStore, {}); const authProfiles = expectRecordFields(forwardedAuthStore.profiles, {}); expect(Object.keys(authProfiles)).toEqual(["openai:default"]); + expect(forwardedAuthStore.runtimePersistedProfileIds).toBeUndefined(); + expect(forwardedAuthStore.runtimeExternalProfileIds).toEqual(["openai:default"]); + expect(forwardedAuthStore.runtimeExternalProfileIdsAuthoritative).toBeUndefined(); expectRecordFields(authProfiles["openai:default"], { provider: "openai", }); diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts index ffad6d330c00..b9c7756dab10 100644 --- a/src/agents/embedded-agent-runner/run.ts +++ b/src/agents/embedded-agent-runner/run.ts @@ -421,10 +421,26 @@ function createScopedAuthProfileStore( return credential ? [[profileId, credential] as const] : []; }), ); + const scopedRuntimeExternalProfileIds = (store.runtimeExternalProfileIds ?? []).filter( + (profileId) => scopedProfiles[profileId], + ); + const scopedRuntimePersistedProfileIds = (store.runtimePersistedProfileIds ?? []).filter( + (profileId) => scopedProfiles[profileId], + ); return Object.keys(scopedProfiles).length > 0 ? { version: store.version, profiles: scopedProfiles, + ...(scopedRuntimePersistedProfileIds.length > 0 + ? { runtimePersistedProfileIds: scopedRuntimePersistedProfileIds } + : {}), + ...(scopedRuntimeExternalProfileIds.length > 0 || + store.runtimeExternalProfileIdsAuthoritative === true + ? { runtimeExternalProfileIds: scopedRuntimeExternalProfileIds } + : {}), + ...(store.runtimeExternalProfileIdsAuthoritative === true + ? { runtimeExternalProfileIdsAuthoritative: true } + : {}), } : createEmptyAuthProfileStore(); } diff --git a/src/agents/tools/image-tool.test.ts b/src/agents/tools/image-tool.test.ts index f1b9169a1343..926cd994329f 100644 --- a/src/agents/tools/image-tool.test.ts +++ b/src/agents/tools/image-tool.test.ts @@ -17,6 +17,7 @@ import type { import { withEnvAsync } from "../../test-utils/env.js"; import { withFetchPreconnect } from "../../test-utils/fetch-mock.js"; import { createOpenClawCodingTools } from "../agent-tools.js"; +import type { AuthProfileCredential, AuthProfileStore } from "../auth-profiles/types.js"; import { minimaxUnderstandImage } from "../minimax-vlm.js"; import type { SandboxFsBridge } from "../sandbox/fs-bridge.js"; import { createHostSandboxFsBridge } from "../test-helpers/host-sandbox-fs-bridge.js"; @@ -161,22 +162,41 @@ vi.mock("../../media/channel-inbound-roots.js", () => ({ }, })); +function readMockAuthProfileStore(agentDir?: string): { + version: number; + profiles: Record; +} { + const fallback = { + version: 1, + profiles: {} as Record, + }; + if (!agentDir) { + return fallback; + } + try { + return JSON.parse(fsSync.readFileSync(path.join(agentDir, "auth-profiles.json"), "utf8")) as { + version: number; + profiles: Record; + }; + } catch { + return fallback; + } +} + vi.mock("../auth-profiles.js", () => ({ - externalCliDiscoveryForProviderAuth: () => undefined, + externalCliDiscoveryForProviderAuth: (params: { provider: string }) => params, ensureAuthProfileStore: (agentDir?: string) => { - if (!agentDir) { - return { version: 1, profiles: {} }; - } - const pathname = path.join(agentDir, "auth-profiles.json"); - try { - return JSON.parse(fsSync.readFileSync(pathname, "utf8")) as { - version?: number; - profiles?: Record; + const store = readMockAuthProfileStore(agentDir); + if (process.env.OPENCLAW_TEST_CODEX_CLI_OAUTH === "1") { + store.profiles["openai:default"] = { + provider: "openai", + type: "oauth", }; - } catch { - return { version: 1, profiles: {} }; } + return store; }, + ensureAuthProfileStoreWithoutExternalProfiles: (agentDir?: string) => + readMockAuthProfileStore(agentDir), hasAnyAuthProfileStoreSource: (agentDir?: string) => { if (!agentDir) { return false; @@ -186,10 +206,82 @@ vi.mock("../auth-profiles.js", () => ({ listProfilesForProvider: ( store: { profiles?: Record }, provider: string, - ) => Object.values(store.profiles ?? {}).filter((profile) => profile?.provider === provider), + ) => + Object.entries(store.profiles ?? {}) + .filter(([, profile]) => profile?.provider === provider) + .map(([profileId]) => profileId), + resolveAuthProfileOrder: (params: { + cfg?: OpenClawConfig; + store: { profiles?: Record }; + provider: string; + }) => { + const profiles = Object.entries(params.store.profiles ?? {}) + .filter(([, profile]) => profile?.provider === params.provider) + .map(([profileId]) => profileId); + const configured = params.cfg?.auth?.order?.[params.provider]; + return configured ? configured.filter((profileId) => profiles.includes(profileId)) : profiles; + }, +})); + +vi.mock("../auth-profiles/external-cli-sync.js", () => ({ + resolveExternalCliAuthProfiles: ( + _store: unknown, + options?: { providerIds?: Iterable }, + ) => { + const providerIds = new Set( + Array.from(options?.providerIds ?? []).map((providerId) => providerId.toLowerCase()), + ); + if ( + process.env.OPENCLAW_TEST_CODEX_CLI_OAUTH !== "1" || + (!providerIds.has("openai") && !providerIds.has("codex")) + ) { + return []; + } + return [ + { + profileId: "openai:default", + credential: { + provider: "openai", + type: "oauth", + access: "oauth-test", + refresh: "refresh-test", + expires: Date.now() + 60_000, + }, + }, + ]; + }, })); vi.mock("../model-auth.js", () => ({ + resolveProviderEntryApiKeyProfileReference: (params: { + cfg?: OpenClawConfig; + provider: string; + store: { profiles?: Record }; + }) => { + const apiKey = params.cfg?.models?.providers?.[params.provider]?.apiKey; + if (typeof apiKey !== "string" || !apiKey.trim()) { + return { kind: "none" }; + } + const profile = params.store.profiles?.[apiKey.trim()]; + if (!profile) { + return { kind: "literal", apiKey: apiKey.trim(), source: "models.json" }; + } + return { kind: "profile", profileId: apiKey.trim(), credential: profile }; + }, + hasRuntimeAvailableProviderAuth: (params: { + provider: string; + cfg?: OpenClawConfig; + modelApi?: string; + }) => { + const providerConfig = params.cfg?.models?.providers?.[params.provider]; + if (params.provider === "codex") { + return process.env.OPENCLAW_TEST_CODEX_ROUTE === "1"; + } + if (params.provider === "openai" && params.modelApi === "openai-responses") { + return Boolean(process.env.OPENAI_API_KEY || providerConfig?.apiKey); + } + return Boolean(providerConfig?.apiKey); + }, hasUsableCustomProviderApiKey: (cfg?: OpenClawConfig, provider?: string) => { const providerConfig = cfg?.models?.providers?.[provider ?? ""]; const apiKey = providerConfig?.apiKey; @@ -261,6 +353,7 @@ async function createOpenClawCodingToolsWithFreshModules(options?: CreateOpenCla ["minimax-cn", "MiniMax-VL-01"], ["minimax-portal", "MiniMax-VL-01"], ["minimax-portal-cn", "MiniMax-VL-01"], + ["codex", "gpt-5.5"], ["openai", "gpt-5.4-mini"], ["opencode", "gpt-5-nano"], ["opencode-go", "kimi-k2.6"], @@ -631,6 +724,7 @@ function installImageUnderstandingProviderDeps( ["minimax-cn", "MiniMax-VL-01"], ["minimax-portal", "MiniMax-VL-01"], ["minimax-portal-cn", "MiniMax-VL-01"], + ["codex", "gpt-5.5"], ["openai", "gpt-5.4-mini"], ["opencode", "gpt-5-nano"], ["opencode-go", "kimi-k2.6"], @@ -827,6 +921,43 @@ function findSchemaUnionKeywords(schema: unknown, pathLocal = "root"): string[] } describe("image tool implicit imageModel config", () => { + type Profiles = AuthProfileStore["profiles"]; + type ImplicitImageRoutingCase = { + name: string; + cfg: OpenClawConfig; + profiles?: Profiles; + codexRoute?: boolean; + openAiApiKey?: boolean; + expected: ReturnType; + }; + + const openAiPrimaryCfg = { + agents: { defaults: { model: { primary: "openai/gpt-5.4" } } }, + } satisfies OpenClawConfig; + const anthropicPrimaryCfg = { + agents: { defaults: { model: { primary: "anthropic/claude-sonnet-4-6" } } }, + } satisfies OpenClawConfig; + const codexImageModel = { primary: "codex/gpt-5.5" }; + const openAiDefaultImageModel = { primary: "openai/gpt-5.4-mini" }; + + const openAiOAuthProfile = (provider = "openai"): AuthProfileCredential => ({ + provider, + type: "oauth" as const, + access: "oauth-test", + refresh: "refresh-test", + expires: Date.now() + 60_000, + }); + + const openAiTokenProfile = (provider = "openai"): AuthProfileCredential => ({ + provider, + type: "token" as const, + token: "token-test", + }); + + const makeAuthStore = (profiles: Profiles): AuthProfileStore => ({ version: 1, profiles }); + const writeProfiles = (agentDir: string, profiles: Profiles) => + writeAuthProfiles(agentDir, makeAuthStore(profiles)); + const priorFetch = global.fetch; registerImageToolEnvReset(priorFetch, [ "OPENAI_API_KEY", @@ -840,6 +971,8 @@ describe("image tool implicit imageModel config", () => { "DASHSCOPE_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY", + "OPENCLAW_TEST_CODEX_CLI_OAUTH", + "OPENCLAW_TEST_CODEX_ROUTE", // Avoid implicit Copilot provider discovery hitting the network in tests. "COPILOT_GITHUB_TOKEN", "GH_TOKEN", @@ -855,13 +988,200 @@ describe("image tool implicit imageModel config", () => { testing.setProviderDepsForTest(); }); + const implicitImageRoutingCases: ImplicitImageRoutingCase[] = [ + { + name: "uses Codex media for implicit OpenAI image defaults on canonical OAuth-only auth", + cfg: openAiPrimaryCfg, + profiles: { "openai:chatgpt": openAiOAuthProfile() }, + codexRoute: true, + expected: codexImageModel, + }, + { + name: "uses Codex media for implicit OpenAI image defaults on canonical token-only auth", + cfg: openAiPrimaryCfg, + profiles: { "openai:token": openAiTokenProfile() }, + codexRoute: true, + expected: codexImageModel, + }, + { + name: "uses Codex media for implicit OpenAI image auto candidates on OAuth-only auth", + cfg: anthropicPrimaryCfg, + profiles: { "openai:chatgpt": openAiOAuthProfile() }, + codexRoute: true, + expected: codexImageModel, + }, + { + name: "drops implicit OpenAI image auto candidates on OAuth-only auth without Codex route", + cfg: anthropicPrimaryCfg, + profiles: { "openai:chatgpt": openAiOAuthProfile() }, + expected: null, + }, + { + name: "keeps implicit OpenAI image auto candidates when direct OpenAI API key auth exists", + cfg: anthropicPrimaryCfg, + openAiApiKey: true, + expected: openAiDefaultImageModel, + }, + { + name: "keeps implicit OpenAI image defaults when direct OpenAI API key auth exists", + cfg: openAiPrimaryCfg, + openAiApiKey: true, + expected: openAiDefaultImageModel, + }, + { + name: "does not treat legacy openai-codex profiles as canonical Codex OAuth", + cfg: openAiPrimaryCfg, + profiles: { "openai-codex:default": openAiOAuthProfile("openai-codex") }, + codexRoute: true, + expected: null, + }, + ]; + it("stays disabled without auth when no pairing is possible", async () => { await withTempAgentDir(async (agentDir) => { + expect(resolveImageModelConfigForTool({ cfg: openAiPrimaryCfg, agentDir })).toBeNull(); + expect(createImageTool({ config: openAiPrimaryCfg, agentDir })).toBeNull(); + }); + }); + + it.each(implicitImageRoutingCases)( + "$name", + async ({ cfg, profiles, codexRoute, openAiApiKey, expected }) => { + if (codexRoute) { + vi.stubEnv("OPENCLAW_TEST_CODEX_ROUTE", "1"); + } + if (openAiApiKey) { + vi.stubEnv("OPENAI_API_KEY", "openai-test"); + } + await withTempAgentDir(async (agentDir) => { + if (profiles) { + await writeProfiles(agentDir, profiles); + } + + const actual = resolveImageModelConfigForTool({ cfg, agentDir }); + if (expected === null) { + expect(actual).toBeNull(); + } else { + expect(actual).toEqual(expected); + } + }); + }, + ); + + it("uses Codex media when OAuth-only OpenAI has configured vision model metadata", async () => { + await withTempAgentDir(async (agentDir) => { + await writeProfiles(agentDir, { "openai:chatgpt": openAiOAuthProfile() }); + vi.stubEnv("OPENCLAW_TEST_CODEX_ROUTE", "1"); const cfg: OpenClawConfig = { - agents: { defaults: { model: { primary: "openai/gpt-5.4" } } }, + ...openAiPrimaryCfg, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [makeModelDefinition("gpt-5.5", ["text", "image"])], + }, + }, + }, }; - expect(resolveImageModelConfigForTool({ cfg, agentDir })).toBeNull(); - expect(createImageTool({ config: cfg, agentDir })).toBeNull(); + + expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ + primary: codexImageModel.primary, + }); + }); + }); + + it("keeps configured OpenAI vision metadata when direct OpenAI API key auth exists", async () => { + vi.stubEnv("OPENAI_API_KEY", "openai-test"); + await withTempAgentDir(async (agentDir) => { + const cfg: OpenClawConfig = { + ...openAiPrimaryCfg, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + models: [makeModelDefinition("gpt-5.5", ["text", "image"])], + }, + }, + }, + }; + + expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ + primary: "openai/gpt-5.5", + fallbacks: [openAiDefaultImageModel.primary], + }); + }); + }); + + it("preserves explicit OpenAI image model config without direct auth", async () => { + await withTempAgentDir(async (agentDir) => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.4" }, + imageModel: { primary: "openai/gpt-5.5" }, + }, + }, + }; + + expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual({ + primary: "openai/gpt-5.5", + }); + }); + }); + + it("preserves explicit Codex image model config", async () => { + await withTempAgentDir(async (agentDir) => { + const cfg: OpenClawConfig = { + agents: { + defaults: { + model: { primary: "openai/gpt-5.4" }, + imageModel: codexImageModel, + }, + }, + }; + + expect(resolveImageModelConfigForTool({ cfg, agentDir })).toEqual(codexImageModel); + }); + }); + + it("lets external CLI Codex OAuth survive the candidate auth filter", async () => { + await withTempAgentDir(async (agentDir) => { + vi.stubEnv("OPENCLAW_TEST_CODEX_CLI_OAUTH", "1"); + vi.stubEnv("OPENCLAW_TEST_CODEX_ROUTE", "1"); + + expect(resolveImageModelConfigForTool({ cfg: openAiPrimaryCfg, agentDir })).toEqual( + codexImageModel, + ); + }); + }); + + it("lets external CLI Codex OAuth survive a supplied scoped auth store", async () => { + await withTempAgentDir(async (agentDir) => { + vi.stubEnv("OPENCLAW_TEST_CODEX_CLI_OAUTH", "1"); + vi.stubEnv("OPENCLAW_TEST_CODEX_ROUTE", "1"); + + expect( + resolveImageModelConfigForTool({ + cfg: openAiPrimaryCfg, + agentDir, + authStore: makeAuthStore({}), + }), + ).toEqual(codexImageModel); + }); + }); + + it("does not re-import persisted OpenAI OAuth when a scoped auth store is supplied", async () => { + await withTempAgentDir(async (agentDir) => { + await writeProfiles(agentDir, { "openai:chatgpt": openAiOAuthProfile() }); + vi.stubEnv("OPENCLAW_TEST_CODEX_ROUTE", "1"); + + expect( + resolveImageModelConfigForTool({ + cfg: openAiPrimaryCfg, + agentDir, + authStore: makeAuthStore({}), + }), + ).toBeNull(); }); }); @@ -930,6 +1250,35 @@ describe("image tool implicit imageModel config", () => { }); }); + it("carries the scoped auth store into image provider execution", async () => { + await withTempAgentDir(async (agentDir) => { + const describeImage = vi.fn(async (params: ImageDescriptionRequest) => ({ + text: "ok", + model: params.model, + })); + installImageUnderstandingProviderStubs({ + id: "codex", + capabilities: ["image"], + describeImage, + }); + const authProfileStore = makeAuthStore({ + "openai:scoped": openAiOAuthProfile(), + }); + const tool = createRequiredImageTool({ + config: { agents: { defaults: { imageModel: { primary: "codex/gpt-5.5" } } } }, + agentDir, + authProfileStore, + }); + + await tool.execute("t1", { + prompt: "Describe this image.", + image: `data:image/png;base64,${ONE_PIXEL_PNG_B64}`, + }); + + expect(firstImageRequest(describeImage).authStore).toBe(authProfileStore); + }); + }); + it("pairs minimax primary with MiniMax-VL-01 (and fallbacks) when auth exists", async () => { await withTempAgentDir(async (agentDir) => { vi.stubEnv("MINIMAX_API_KEY", "minimax-test"); diff --git a/src/agents/tools/image-tool.ts b/src/agents/tools/image-tool.ts index 8a5a506e3d51..d0e43810407c 100644 --- a/src/agents/tools/image-tool.ts +++ b/src/agents/tools/image-tool.ts @@ -74,6 +74,7 @@ import { buildToolModelConfigFromCandidates, hasToolModelConfig, resolveDefaultModelRef, + resolveOpenAiImageMediaCandidate, } from "./model-config.helpers.js"; import { createSandboxBridgeReadFile, @@ -246,6 +247,30 @@ export function resolveImageModelConfigForTool(params: { } const primary = resolveDefaultModelRef(params.cfg); + let verifiedSubstituteProvider: string | undefined; + const resolveCodexImageModel = () => + imageToolProviderDeps.resolveDefaultMediaModel({ + cfg: params.cfg, + workspaceDir: params.workspaceDir, + providerId: "codex", + capability: "image", + includeConfiguredImageModels: false, + }); + const resolveImplicitOpenAiImageCandidate = (openAiModel: string): string | null => { + const decision = resolveOpenAiImageMediaCandidate({ + cfg: params.cfg, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + authStore: params.authStore, + openAiModel, + codexModel: resolveCodexImageModel(), + }); + if (decision.kind === "substitute") { + verifiedSubstituteProvider = decision.provider; + return decision.ref; + } + return decision.kind === "keep" ? decision.ref : null; + }; const providerVisionFromConfig = resolveProviderVisionModelFromConfig({ cfg: params.cfg, @@ -253,6 +278,13 @@ export function resolveImageModelConfigForTool(params: { }); const primaryCandidates = (() => { if (providerVisionFromConfig) { + if (primary.provider === "openai") { + return [ + resolveImplicitOpenAiImageCandidate( + providerVisionFromConfig.slice(providerVisionFromConfig.indexOf("/") + 1), + ), + ]; + } return [providerVisionFromConfig]; } const providerDefault = imageToolProviderDeps.resolveDefaultMediaModel({ @@ -263,6 +295,9 @@ export function resolveImageModelConfigForTool(params: { includeConfiguredImageModels: !isMinimaxVlmProvider(primary.provider), }); if (providerDefault) { + if (primary.provider === "openai") { + return [resolveImplicitOpenAiImageCandidate(providerDefault)]; + } return [`${primary.provider}/${providerDefault}`]; } if (isMinimaxVlmProvider(primary.provider)) { @@ -285,7 +320,12 @@ export function resolveImageModelConfigForTool(params: { capability: "image", includeConfiguredImageModels: !isMinimaxVlmProvider(providerId), }); - return modelId ? `${providerId}/${modelId}` : null; + if (!modelId) { + return null; + } + return providerId === "openai" + ? resolveImplicitOpenAiImageCandidate(modelId) + : `${providerId}/${modelId}`; }); const autoCandidates = rawAutoCandidates.filter( (candidate) => @@ -312,6 +352,8 @@ export function resolveImageModelConfigForTool(params: { agentDir: params.agentDir, authStore: params.authStore, candidates: [...primaryAliasCandidates, ...primaryCandidates, ...remainingAutoCandidates], + isProviderConfigured: (provider) => + verifiedSubstituteProvider && provider === verifiedSubstituteProvider ? true : undefined, }); } @@ -594,6 +636,7 @@ type ImageSandboxConfig = { async function runImagePrompt(params: { cfg?: OpenClawConfig; agentDir: string; + authStore?: AuthProfileStore; imageModelConfig: ImageModelConfig; modelOverride?: string; prompt: string; @@ -642,6 +685,7 @@ async function runImagePrompt(params: { timeoutMs, cfg: providerCfg, agentDir: params.agentDir, + authStore: params.authStore, ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), }); return { text: described.text, provider, model: described.model ?? modelId }; @@ -661,6 +705,7 @@ async function runImagePrompt(params: { timeoutMs, cfg: providerCfg, agentDir: params.agentDir, + authStore: params.authStore, ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), }); return { text: described.text, provider, model: described.model ?? modelId }; @@ -679,6 +724,7 @@ async function runImagePrompt(params: { timeoutMs, cfg: providerCfg, agentDir: params.agentDir, + authStore: params.authStore, ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), }); parts.push(`Image ${index + 1}:\n${described.text.trim()}`); @@ -1010,6 +1056,7 @@ export function createImageTool(options?: { const result = await runImagePrompt({ cfg: options?.config, agentDir, + authStore: options?.authProfileStore, imageModelConfig, modelOverride, prompt: promptRaw, diff --git a/src/agents/tools/model-config.helpers.test.ts b/src/agents/tools/model-config.helpers.test.ts index 4e0c9c56b23d..c408a4630d45 100644 --- a/src/agents/tools/model-config.helpers.test.ts +++ b/src/agents/tools/model-config.helpers.test.ts @@ -2,13 +2,92 @@ // stored agent auth profiles for reusable media tools. import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { hasProviderAuthForTool } from "./model-config.helpers.js"; +import type { AuthProfileCredential, AuthProfileStore } from "../auth-profiles/types.js"; +import { + hasDirectProviderApiKeyAuthForTool, + hasProviderAuthForTool, + resolveOpenAiImageMediaCandidate, +} from "./model-config.helpers.js"; -describe("hasProviderAuthForTool", () => { - afterEach(() => { - vi.unstubAllEnvs(); +vi.mock("../auth-profiles/external-cli-sync.js", () => ({ + resolveExternalCliAuthProfiles: () => [], +})); + +const AGENT_DIR = "/tmp/openclaw-model-config-helper"; +const MODEL = "gpt-5.5"; + +type Decision = ReturnType; +type Profiles = AuthProfileStore["profiles"]; + +const codexSubstitute = { + kind: "substitute", + provider: "codex", + ref: `codex/${MODEL}`, +} satisfies Decision; +const openAiKeep = { kind: "keep", ref: `openai/${MODEL}` } satisfies Decision; +const drop = { kind: "drop" } satisfies Decision; + +const openAiRefCfg: OpenClawConfig = { + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: "openai:default", + models: [], + }, + }, + }, +}; + +const store = (profiles: Profiles): AuthProfileStore => ({ version: 1, profiles }); + +const oauth = (provider: string): AuthProfileCredential => ({ + provider, + type: "oauth", + access: "oauth-test", + refresh: "refresh-test", + expires: Date.now() + 60_000, +}); + +const token = (provider: string): AuthProfileCredential => ({ + provider, + type: "token", + token: "token-test", +}); + +const apiKey = (provider: string, key = "direct-openai-key"): AuthProfileCredential => ({ + provider, + type: "api_key", + key, +}); + +const resolveMedia = ( + overrides: Partial[0]> = {}, +) => + resolveOpenAiImageMediaCandidate({ + agentDir: AGENT_DIR, + authStore: store({}), + openAiModel: MODEL, + codexModel: MODEL, + ...overrides, }); +const hasDirectOpenAiKey = ( + overrides: Partial[0]> = {}, +) => + hasDirectProviderApiKeyAuthForTool({ + provider: "openai", + agentDir: AGENT_DIR, + authStore: store({}), + modelApi: "openai-responses", + ...overrides, + }); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("hasProviderAuthForTool", () => { it("accepts config-backed custom provider auth", () => { const cfg = { models: { @@ -28,24 +107,122 @@ describe("hasProviderAuthForTool", () => { it("keeps auth-store profiles as valid tool auth", () => { // Tool-specific model selection should honor the same stored profile shape // used by agent sessions, not only process env/config keys. - expect( - hasProviderAuthForTool({ + const authStore = store({ + "hatchery:default": { provider: "hatchery", - authStore: { - version: 1, - profiles: { - "hatchery:default": { - provider: "hatchery", - type: "api_key", - key: "sk-profile", // pragma: allowlist secret - }, - }, - }, - }), - ).toBe(true); + type: "api_key", + key: "sk-profile", // pragma: allowlist secret + }, + }); + + expect(hasProviderAuthForTool({ provider: "hatchery", authStore })).toBe(true); }); it("rejects providers without config, env, or profile auth", () => { expect(hasProviderAuthForTool({ provider: "unconfigured-provider" })).toBe(false); }); }); + +describe("resolveOpenAiImageMediaCandidate", () => { + const cases: Array<[string, AuthProfileStore, Decision]> = [ + [ + "canonical OpenAI OAuth-only media auth", + store({ "openai:chatgpt": oauth("openai") }), + codexSubstitute, + ], + [ + "canonical OpenAI token-only media auth", + store({ "openai:token": token("openai") }), + codexSubstitute, + ], + [ + "legacy openai-codex OAuth profiles", + store({ "openai-codex:default": oauth("openai-codex") }), + drop, + ], + [ + "legacy openai-codex token profiles", + store({ "openai-codex:token": token("openai-codex") }), + drop, + ], + ["no direct auth or verified Codex route", store({}), drop], + ]; + + it.each(cases)("resolves %s", (_label, authStore, expected) => { + expect(resolveMedia({ authStore })).toEqual(expected); + }); + + it("keeps OpenAI media when a direct API key profile exists", () => { + const authStore = store({ "openai:api-key": apiKey("openai") }); + + expect(hasDirectOpenAiKey({ authStore })).toBe(true); + expect(resolveMedia({ authStore })).toEqual(openAiKeep); + }); + + it("uses Codex when an ineligible direct API key profile is stale", () => { + const authStore = store({ + "openai:api-key": { provider: "openai", type: "api_key" }, + "openai:chatgpt": oauth("openai"), + }); + + expect(hasDirectOpenAiKey({ authStore })).toBe(false); + expect(resolveMedia({ authStore })).toEqual(codexSubstitute); + }); + + it("honors auth order when choosing between direct OpenAI and Codex media", () => { + const cfg: OpenClawConfig = { + auth: { + order: { + openai: ["openai:chatgpt"], + }, + }, + }; + const authStore = store({ + "openai:api-key": apiKey("openai"), + "openai:chatgpt": oauth("openai"), + }); + + expect(hasDirectOpenAiKey({ cfg, authStore })).toBe(false); + expect(resolveMedia({ cfg, authStore })).toEqual(codexSubstitute); + }); + + it("drops Codex media when auth order excludes subscription-style auth", () => { + const cfg: OpenClawConfig = { + auth: { + order: { + openai: ["openai:api-key"], + }, + }, + }; + const authStore = store({ + "openai:api-key": { provider: "openai", type: "api_key" }, + "openai:chatgpt": oauth("openai"), + }); + + expect(resolveMedia({ cfg, authStore })).toEqual(drop); + }); + + it("does not treat provider apiKey OAuth profile references as direct OpenAI media auth", () => { + const authStore = store({ "openai:default": oauth("openai") }); + + expect(hasDirectOpenAiKey({ cfg: openAiRefCfg, authStore })).toBe(false); + expect(resolveMedia({ cfg: openAiRefCfg, authStore })).toEqual(codexSubstitute); + }); + + it("treats provider apiKey API-key profile references as direct OpenAI media auth", () => { + const authStore = store({ "openai:default": apiKey("openai") }); + + expect(hasDirectOpenAiKey({ cfg: openAiRefCfg, authStore })).toBe(true); + expect(resolveMedia({ cfg: openAiRefCfg, authStore })).toEqual(openAiKeep); + }); + + it("does not treat unresolved provider apiKey profile references as direct auth", () => { + const authStore = store({ + "openai:default": { provider: "openai", type: "api_key" }, + "openai:chatgpt": oauth("openai"), + }); + + expect(hasDirectOpenAiKey({ cfg: openAiRefCfg, authStore })).toBe(false); + expect(resolveMedia({ cfg: openAiRefCfg, authStore })).toEqual(codexSubstitute); + }); +}); diff --git a/src/agents/tools/model-config.helpers.ts b/src/agents/tools/model-config.helpers.ts index 44156aea4b62..608e507a64ff 100644 --- a/src/agents/tools/model-config.helpers.ts +++ b/src/agents/tools/model-config.helpers.ts @@ -17,14 +17,32 @@ import { ensureAuthProfileStoreWithoutExternalProfiles, hasAnyAuthProfileStoreSource, listProfilesForProvider, + resolveAuthProfileOrder, } from "../auth-profiles.js"; +import { evaluateStoredCredentialEligibility } from "../auth-profiles/credential-state.js"; +import { resolveExternalCliAuthProfiles } from "../auth-profiles/external-cli-sync.js"; +import { overlayRuntimeExternalOAuthProfiles } from "../auth-profiles/oauth-shared.js"; import type { AuthProfileCredential, AuthProfileStore } from "../auth-profiles/types.js"; import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; -import { hasUsableCustomProviderApiKey, resolveEnvApiKey } from "../model-auth.js"; +import { + hasRuntimeAvailableProviderAuth, + hasUsableCustomProviderApiKey, + resolveProviderEntryApiKeyProfileReference, + resolveEnvApiKey, +} from "../model-auth.js"; import { resolveConfiguredModelRef } from "../model-selection.js"; export type ToolModelConfig = { primary?: string; fallbacks?: string[]; timeoutMs?: number }; +const OPENAI_PROVIDER_ID = "openai"; +const CODEX_MEDIA_PROVIDER_ID = "codex"; +const OPENAI_RESPONSES_MODEL_API = "openai-responses"; + +export type OpenAiImageMediaCandidateDecision = + | { kind: "keep"; ref: string } + | { kind: "substitute"; ref: string; provider: string } + | { kind: "drop" }; + /** Returns whether a tool model config contains a primary or fallback model ref. */ export function hasToolModelConfig(model: ToolModelConfig | undefined): boolean { return Boolean( @@ -111,6 +129,241 @@ export function hasProviderAuthForTool(params: { return hasUsableCustomProviderApiKey(params.cfg, params.provider); } +function formatProviderModelRef(provider: string, model: string): string { + return `${provider}/${model}`; +} + +function loadAuthStoreForProvider(params: { + provider: string; + cfg?: OpenClawConfig; + agentDir?: string; + authStore?: AuthProfileStore; + includeExternalCli?: boolean; +}): AuthProfileStore | undefined { + if (params.authStore) { + return params.authStore; + } + const agentDir = params.agentDir?.trim(); + if (!agentDir) { + return undefined; + } + return params.includeExternalCli + ? ensureAuthProfileStore(agentDir, { + externalCli: externalCliDiscoveryForProviderAuth({ + provider: params.provider, + cfg: params.cfg, + }), + }) + : ensureAuthProfileStoreWithoutExternalProfiles(agentDir, { + allowKeychainPrompt: false, + }); +} + +function overlayExternalCliAuthStoreForProvider(params: { + provider: string; + authStore: AuthProfileStore; +}): AuthProfileStore { + const profiles = resolveExternalCliAuthProfiles(params.authStore, { + allowKeychainPrompt: false, + providerIds: [params.provider], + }); + if (profiles.length === 0) { + return params.authStore; + } + return overlayRuntimeExternalOAuthProfiles(params.authStore, profiles); +} + +function hasAuthProfileTypeInStore(params: { + provider: string; + cfg?: OpenClawConfig; + store: AuthProfileStore; + type: AuthProfileCredential["type"] | readonly AuthProfileCredential["type"][]; +}): boolean { + const types = Array.isArray(params.type) ? params.type : [params.type]; + return resolveAuthProfileOrder({ + cfg: params.cfg, + store: params.store, + provider: params.provider, + }).some((profileId) => types.includes(params.store.profiles[profileId]?.type)); +} + +function hasAuthProfileTypeForProvider(params: { + provider: string; + cfg?: OpenClawConfig; + agentDir?: string; + authStore?: AuthProfileStore; + includeExternalCli?: boolean; + type: AuthProfileCredential["type"] | readonly AuthProfileCredential["type"][]; +}): boolean { + const store = loadAuthStoreForProvider(params); + if (store && hasAuthProfileTypeInStore({ ...params, store })) { + return true; + } + // Codex-harness tool construction can pass a scoped store with external CLI + // profiles stripped. Keep that store authoritative, but still honor explicit + // includeExternalCli lookups so Codex OAuth-only image routing remains visible. + if (params.includeExternalCli && params.authStore) { + const externalStore = overlayExternalCliAuthStoreForProvider({ + provider: params.provider, + authStore: params.authStore, + }); + return hasAuthProfileTypeInStore({ ...params, store: externalStore }); + } + return false; +} + +/** Returns whether a provider has direct API-key-capable auth for model-backed tools. */ +export function hasDirectProviderApiKeyAuthForTool(params: { + provider: string; + cfg?: OpenClawConfig; + workspaceDir?: string; + agentDir?: string; + authStore?: AuthProfileStore; + modelApi?: string; +}): boolean { + const providerEntryProfileAuth = resolveDirectProviderEntryAuthFromProfileReference(params); + if (providerEntryProfileAuth !== undefined) { + return providerEntryProfileAuth; + } + if ( + hasRuntimeAvailableProviderAuth({ + provider: params.provider, + cfg: params.cfg, + workspaceDir: params.workspaceDir, + modelApi: params.modelApi, + allowPluginSyntheticAuth: false, + }) + ) { + return true; + } + return hasAuthProfileTypeForProvider({ + provider: params.provider, + cfg: params.cfg, + agentDir: params.agentDir, + authStore: params.authStore, + type: "api_key", + }); +} + +function hasCanonicalOpenAiCodexAuthSignal(params: { + cfg?: OpenClawConfig; + agentDir?: string; + authStore?: AuthProfileStore; +}): boolean { + return hasAuthProfileTypeForProvider({ + provider: OPENAI_PROVIDER_ID, + cfg: params.cfg, + agentDir: params.agentDir, + authStore: params.authStore, + includeExternalCli: true, + type: ["oauth", "token"], + }); +} + +function resolveDirectProviderEntryAuthFromProfileReference(params: { + provider: string; + cfg?: OpenClawConfig; + agentDir?: string; + authStore?: AuthProfileStore; +}): boolean | undefined { + const resolveFromStore = (store: AuthProfileStore): boolean | undefined => { + const reference = resolveProviderEntryApiKeyProfileReference({ + cfg: params.cfg, + provider: params.provider, + store, + }); + if (reference.kind === "profile") { + return ( + reference.credential.type === "api_key" && + evaluateStoredCredentialEligibility({ credential: reference.credential }).eligible + ); + } + if (reference.kind === "profile-incompatible") { + return false; + } + return undefined; + }; + + const store = loadAuthStoreForProvider({ + provider: params.provider, + cfg: params.cfg, + agentDir: params.agentDir, + authStore: params.authStore, + includeExternalCli: true, + }); + const storeResult = store ? resolveFromStore(store) : undefined; + if (storeResult !== undefined) { + return storeResult; + } + if (params.authStore) { + const externalStore = overlayExternalCliAuthStoreForProvider({ + provider: params.provider, + authStore: params.authStore, + }); + return resolveFromStore(externalStore); + } + return undefined; +} + +function hasCodexSyntheticMediaRoute(params: { + cfg?: OpenClawConfig; + workspaceDir?: string; +}): boolean { + return hasRuntimeAvailableProviderAuth({ + provider: CODEX_MEDIA_PROVIDER_ID, + cfg: params.cfg, + workspaceDir: params.workspaceDir, + }); +} + +/** Resolves the implicit OpenAI image slot without letting OAuth-only auth pick direct OpenAI. */ +export function resolveOpenAiImageMediaCandidate(params: { + cfg?: OpenClawConfig; + workspaceDir?: string; + agentDir: string; + authStore?: AuthProfileStore; + openAiModel: string; + codexModel?: string; +}): OpenAiImageMediaCandidateDecision { + const openAiModel = params.openAiModel.trim(); + if (!openAiModel) { + return { kind: "drop" }; + } + if ( + hasDirectProviderApiKeyAuthForTool({ + provider: OPENAI_PROVIDER_ID, + cfg: params.cfg, + workspaceDir: params.workspaceDir, + agentDir: params.agentDir, + authStore: params.authStore, + modelApi: OPENAI_RESPONSES_MODEL_API, + }) + ) { + return { + kind: "keep", + ref: formatProviderModelRef(OPENAI_PROVIDER_ID, openAiModel), + }; + } + + const codexModel = params.codexModel?.trim(); + // Codex's bundled synthetic marker only proves the app-server route exists. + // Require canonical OpenAI subscription-style auth too so fresh installs do + // not route to Codex media just because the bundled plugin is present. + if ( + codexModel && + hasCanonicalOpenAiCodexAuthSignal(params) && + hasCodexSyntheticMediaRoute(params) + ) { + return { + kind: "substitute", + provider: CODEX_MEDIA_PROVIDER_ID, + ref: formatProviderModelRef(CODEX_MEDIA_PROVIDER_ID, codexModel), + }; + } + + return { kind: "drop" }; +} + /** Normalizes agent tool model config into a compact runtime shape. */ export function coerceToolModelConfig(model?: AgentToolModelConfig): ToolModelConfig { const primary = resolveAgentModelPrimaryValue(model); @@ -131,7 +384,7 @@ export function buildToolModelConfigFromCandidates(params: { agentDir?: string; authStore?: AuthProfileStore; candidates: Array; - isProviderConfigured?: (provider: string) => boolean; + isProviderConfigured?: (provider: string) => boolean | undefined; }): ToolModelConfig | null { if (hasToolModelConfig(params.explicit)) { return params.explicit;