diff --git a/docs/.generated/plugin-sdk-api-baseline.sha256 b/docs/.generated/plugin-sdk-api-baseline.sha256 index a4ac4918c98..97d4b9331f7 100644 --- a/docs/.generated/plugin-sdk-api-baseline.sha256 +++ b/docs/.generated/plugin-sdk-api-baseline.sha256 @@ -1,2 +1,2 @@ -0b0cf2ecc30501bb6381671e3704570f405655b026a0b8b6437c3a5677450b9b plugin-sdk-api-baseline.json -cb72d7b5f73005280854654b51501ec82f5a2f23b7ccb915b63c6354300559d5 plugin-sdk-api-baseline.jsonl +445130135f0037ca2f0877428d58deedf7a7f50e588af5505c1ba09d346663ae plugin-sdk-api-baseline.json +147f6f63b835a92e24d6c93b91b0e2adbe1b8fb381d3bd45ef1ae63fd9b3386e plugin-sdk-api-baseline.jsonl diff --git a/extensions/codex/src/app-server/auth-bridge.test.ts b/extensions/codex/src/app-server/auth-bridge.test.ts new file mode 100644 index 00000000000..09b7925380a --- /dev/null +++ b/extensions/codex/src/app-server/auth-bridge.test.ts @@ -0,0 +1,100 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + ensureAuthProfileStore: vi.fn(), +})); + +vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ + ensureAuthProfileStore: mocks.ensureAuthProfileStore, +})); + +let bridgeCodexAppServerStartOptions: typeof import("./auth-bridge.js").bridgeCodexAppServerStartOptions; + +describe("bridgeCodexAppServerStartOptions", () => { + const tempDirs: string[] = []; + + beforeAll(async () => { + ({ bridgeCodexAppServerStartOptions } = await import("./auth-bridge.js")); + }); + + afterEach(async () => { + mocks.ensureAuthProfileStore.mockReset(); + await Promise.all( + tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + + it("bridges canonical OpenClaw oauth into an isolated CODEX_HOME", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + tempDirs.push(agentDir); + mocks.ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: { + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + accountId: "acct-123", + }, + }, + }); + + const result = await bridgeCodexAppServerStartOptions({ + startOptions: { + command: "codex", + args: ["app-server"], + headers: { authorization: "Bearer dev-token" }, + env: { EXISTING: "1" }, + clearEnv: ["FOO"], + }, + agentDir, + }); + + expect(result).toMatchObject({ + env: { + EXISTING: "1", + CODEX_HOME: expect.stringContaining(path.join(agentDir, "harness-auth", "codex")), + }, + clearEnv: expect.arrayContaining(["FOO", "OPENAI_API_KEY"]), + }); + + const authFile = JSON.parse( + await fs.readFile(path.join(result.env?.CODEX_HOME ?? "", "auth.json"), "utf8"), + ); + expect(authFile).toEqual({ + auth_mode: "chatgpt", + tokens: { + access_token: "access-token", + refresh_token: "refresh-token", + account_id: "acct-123", + }, + }); + }); + + it("leaves start options unchanged when canonical oauth is unavailable", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-app-server-")); + tempDirs.push(agentDir); + const startOptions = { + command: "codex", + args: ["app-server"], + headers: { authorization: "Bearer dev-token" }, + }; + mocks.ensureAuthProfileStore.mockReturnValue({ + version: 1, + profiles: {}, + }); + + await expect( + bridgeCodexAppServerStartOptions({ + startOptions, + agentDir, + authProfileId: "openai-codex:missing", + }), + ).resolves.toEqual(startOptions); + }); +}); diff --git a/extensions/codex/src/app-server/auth-bridge.ts b/extensions/codex/src/app-server/auth-bridge.ts new file mode 100644 index 00000000000..62a84b6ad1b --- /dev/null +++ b/extensions/codex/src/app-server/auth-bridge.ts @@ -0,0 +1,76 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { ensureAuthProfileStore, type OAuthCredential } from "openclaw/plugin-sdk/provider-auth"; +import type { CodexAppServerStartOptions } from "./config.js"; + +const DEFAULT_CODEX_AUTH_PROFILE_ID = "openai-codex:default"; +const CODEX_AUTH_ENV_CLEAR_KEYS = ["OPENAI_API_KEY"] as const; + +function isBridgeableCodexOAuthCredential(value: unknown): value is OAuthCredential { + return Boolean( + value && + typeof value === "object" && + value !== null && + "type" in value && + "provider" in value && + "access" in value && + "refresh" in value && + value.type === "oauth" && + value.provider === "openai-codex" && + typeof value.access === "string" && + value.access.trim().length > 0 && + typeof value.refresh === "string" && + value.refresh.trim().length > 0, + ); +} + +function resolveCodexBridgeHome(agentDir: string, profileId: string): string { + const digest = crypto.createHash("sha256").update(profileId).digest("hex").slice(0, 16); + return path.join(agentDir, "harness-auth", "codex", digest); +} + +function buildCodexAuthFile(credential: OAuthCredential): string { + return `${JSON.stringify( + { + auth_mode: "chatgpt", + tokens: { + access_token: credential.access, + refresh_token: credential.refresh, + ...(credential.accountId ? { account_id: credential.accountId } : {}), + }, + }, + null, + 2, + )}\n`; +} + +export async function bridgeCodexAppServerStartOptions(params: { + startOptions: CodexAppServerStartOptions; + agentDir: string; + authProfileId?: string; +}): Promise { + const profileId = params.authProfileId?.trim() || DEFAULT_CODEX_AUTH_PROFILE_ID; + const store = ensureAuthProfileStore(params.agentDir, { + allowKeychainPrompt: false, + }); + const credential = store.profiles[profileId]; + if (!isBridgeableCodexOAuthCredential(credential)) { + return params.startOptions; + } + + const codexHome = resolveCodexBridgeHome(params.agentDir, profileId); + await fs.mkdir(codexHome, { recursive: true }); + await fs.writeFile(path.join(codexHome, "auth.json"), buildCodexAuthFile(credential)); + + return { + ...params.startOptions, + env: { + ...params.startOptions.env, + CODEX_HOME: codexHome, + }, + clearEnv: Array.from( + new Set([...(params.startOptions.clearEnv ?? []), ...CODEX_AUTH_ENV_CLEAR_KEYS]), + ), + }; +} diff --git a/extensions/codex/src/app-server/compact.test.ts b/extensions/codex/src/app-server/compact.test.ts index 2612fc2e3dd..670bfc0e5d0 100644 --- a/extensions/codex/src/app-server/compact.test.ts +++ b/extensions/codex/src/app-server/compact.test.ts @@ -105,6 +105,38 @@ describe("maybeCompactCodexAppServerSession", () => { }, }); }); + + it("reuses the bound auth profile for native compaction", async () => { + const fake = createFakeCodexClient(); + let seenAuthProfileId: string | undefined; + __testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => { + seenAuthProfileId = authProfileId; + return fake.client; + }); + const sessionFile = path.join(tempDir, "session.jsonl"); + await writeCodexAppServerBinding(sessionFile, { + threadId: "thread-1", + cwd: tempDir, + authProfileId: "openai-codex:work", + }); + + const pendingResult = maybeCompactCodexAppServerSession({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile, + workspaceDir: tempDir, + }); + await vi.waitFor(() => { + expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" }); + }); + fake.emit({ + method: "thread/compacted", + params: { threadId: "thread-1", turnId: "turn-1" }, + }); + await pendingResult; + + expect(seenAuthProfileId).toBe("openai-codex:work"); + }); }); function createFakeCodexClient(): { diff --git a/extensions/codex/src/app-server/compact.ts b/extensions/codex/src/app-server/compact.ts index 625e3a74cd8..b6d3927ecb7 100644 --- a/extensions/codex/src/app-server/compact.ts +++ b/extensions/codex/src/app-server/compact.ts @@ -11,6 +11,7 @@ import { getSharedCodexAppServerClient } from "./shared-client.js"; type CodexAppServerClientFactory = ( startOptions?: CodexAppServerStartOptions, + authProfileId?: string, ) => Promise; type CodexNativeCompactionCompletion = { signal: "thread/compacted" | "item/completed"; @@ -25,8 +26,8 @@ type CodexNativeCompactionWaiter = { const DEFAULT_CODEX_COMPACTION_WAIT_TIMEOUT_MS = 5 * 60 * 1000; -let clientFactory: CodexAppServerClientFactory = (startOptions) => - getSharedCodexAppServerClient({ startOptions }); +let clientFactory: CodexAppServerClientFactory = (startOptions, authProfileId) => + getSharedCodexAppServerClient({ startOptions, authProfileId }); export async function maybeCompactCodexAppServerSession( params: CompactEmbeddedPiSessionParams, @@ -38,7 +39,7 @@ export async function maybeCompactCodexAppServerSession( return { ok: false, compacted: false, reason: "no codex app-server thread binding" }; } - const client = await clientFactory(appServer.start); + const client = await clientFactory(appServer.start, binding.authProfileId); const waiter = createCodexNativeCompactionWaiter(client, binding.threadId); let completion: CodexNativeCompactionCompletion; try { @@ -212,6 +213,7 @@ export const __testing = { clientFactory = factory; }, resetCodexAppServerClientFactoryForTests(): void { - clientFactory = (startOptions) => getSharedCodexAppServerClient({ startOptions }); + clientFactory = (startOptions, authProfileId) => + getSharedCodexAppServerClient({ startOptions, authProfileId }); }, } as const; diff --git a/extensions/codex/src/app-server/config.ts b/extensions/codex/src/app-server/config.ts index 3028cbb0d7c..89bdd9f1862 100644 --- a/extensions/codex/src/app-server/config.ts +++ b/extensions/codex/src/app-server/config.ts @@ -12,6 +12,8 @@ export type CodexAppServerStartOptions = { url?: string; authToken?: string; headers: Record; + env?: Record; + clearEnv?: string[]; }; export type CodexAppServerRuntimeOptions = { @@ -158,6 +160,8 @@ export function codexAppServerStartOptionsKey(options: CodexAppServerStartOption headers: Object.entries(options.headers).toSorted(([left], [right]) => left.localeCompare(right), ), + env: Object.entries(options.env ?? {}).toSorted(([left], [right]) => left.localeCompare(right)), + clearEnv: [...(options.clearEnv ?? [])].toSorted(), }); } diff --git a/extensions/codex/src/app-server/models.ts b/extensions/codex/src/app-server/models.ts index 98e504754f8..d39c849a8be 100644 --- a/extensions/codex/src/app-server/models.ts +++ b/extensions/codex/src/app-server/models.ts @@ -28,6 +28,7 @@ export type CodexAppServerListModelsOptions = { includeHidden?: boolean; timeoutMs?: number; startOptions?: CodexAppServerStartOptions; + authProfileId?: string; sharedClient?: boolean; }; @@ -40,10 +41,12 @@ export async function listCodexAppServerModels( ? await getSharedCodexAppServerClient({ startOptions: options.startOptions, timeoutMs, + authProfileId: options.authProfileId, }) : await createIsolatedCodexAppServerClient({ startOptions: options.startOptions, timeoutMs, + authProfileId: options.authProfileId, }); try { const response = await client.request( diff --git a/extensions/codex/src/app-server/request.ts b/extensions/codex/src/app-server/request.ts index 66b0b4bb6a4..9f9d6dd365b 100644 --- a/extensions/codex/src/app-server/request.ts +++ b/extensions/codex/src/app-server/request.ts @@ -8,6 +8,7 @@ export async function requestCodexAppServerJson(param requestParams?: JsonValue; timeoutMs?: number; startOptions?: CodexAppServerStartOptions; + authProfileId?: string; }): Promise { const timeoutMs = params.timeoutMs ?? 60_000; return await withTimeout( @@ -15,6 +16,7 @@ export async function requestCodexAppServerJson(param const client = await getSharedCodexAppServerClient({ startOptions: params.startOptions, timeoutMs, + authProfileId: params.authProfileId, }); return await client.request(params.method, params.requestParams, { timeoutMs }); })(), diff --git a/extensions/codex/src/app-server/run-attempt.test.ts b/extensions/codex/src/app-server/run-attempt.test.ts index ae70d8b425a..92e406f6a46 100644 --- a/extensions/codex/src/app-server/run-attempt.test.ts +++ b/extensions/codex/src/app-server/run-attempt.test.ts @@ -313,6 +313,53 @@ describe("runCodexAppServerAttempt", () => { expect(queueAgentHarnessMessage("session-1", "after timeout")).toBe(false); }); + it("passes the selected auth profile into app-server startup", async () => { + const seenAuthProfileIds: Array = []; + let notify: (notification: CodexServerNotification) => Promise = async () => undefined; + __testing.setCodexAppServerClientFactoryForTests(async (_startOptions, authProfileId) => { + seenAuthProfileIds.push(authProfileId); + return { + request: async (method: string) => { + if (method === "thread/start") { + return { + thread: { id: "thread-1" }, + model: "gpt-5.4-codex", + modelProvider: "openai", + }; + } + if (method === "turn/start") { + return { turn: { id: "turn-1", status: "inProgress" } }; + } + return {}; + }, + addNotificationHandler: (handler: typeof notify) => { + notify = handler; + return () => undefined; + }, + addRequestHandler: () => () => undefined, + } as never; + }); + const params = createParams( + path.join(tempDir, "session.jsonl"), + path.join(tempDir, "workspace"), + ); + params.authProfileId = "openai-codex:work"; + + const run = runCodexAppServerAttempt(params); + await vi.waitFor(() => expect(seenAuthProfileIds).toEqual(["openai-codex:work"])); + await notify({ + method: "turn/completed", + params: { + threadId: "thread-1", + turnId: "turn-1", + turn: { id: "turn-1", status: "completed" }, + }, + }); + await run; + + expect(seenAuthProfileIds).toEqual(["openai-codex:work"]); + }); + it("times out turn start before the active run handle is installed", async () => { const request = vi.fn( async (method: string, _params?: unknown, options?: { timeoutMs?: number }) => { diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts index 241fdd426ff..9a54ea947af 100644 --- a/extensions/codex/src/app-server/run-attempt.ts +++ b/extensions/codex/src/app-server/run-attempt.ts @@ -37,10 +37,11 @@ import { mirrorCodexAppServerTranscript } from "./transcript-mirror.js"; type CodexAppServerClientFactory = ( startOptions?: CodexAppServerStartOptions, + authProfileId?: string, ) => Promise; -let clientFactory: CodexAppServerClientFactory = (startOptions) => - getSharedCodexAppServerClient({ startOptions }); +let clientFactory: CodexAppServerClientFactory = (startOptions, authProfileId) => + getSharedCodexAppServerClient({ startOptions, authProfileId }); export async function runCodexAppServerAttempt( params: EmbeddedRunAttemptParams, @@ -101,7 +102,7 @@ export async function runCodexAppServerAttempt( timeoutMs: params.timeoutMs, signal: runAbortController.signal, operation: async () => { - const startupClient = await clientFactory(appServer.start); + const startupClient = await clientFactory(appServer.start, params.authProfileId); const startupThread = await startOrResumeThread({ client: startupClient, params, @@ -487,6 +488,7 @@ export const __testing = { clientFactory = factory; }, resetCodexAppServerClientFactoryForTests(): void { - clientFactory = (startOptions) => getSharedCodexAppServerClient({ startOptions }); + clientFactory = (startOptions, authProfileId) => + getSharedCodexAppServerClient({ startOptions, authProfileId }); }, } as const; diff --git a/extensions/codex/src/app-server/session-binding.ts b/extensions/codex/src/app-server/session-binding.ts index 7a69ef4f8a7..23bb1d1fec3 100644 --- a/extensions/codex/src/app-server/session-binding.ts +++ b/extensions/codex/src/app-server/session-binding.ts @@ -6,6 +6,7 @@ export type CodexAppServerThreadBinding = { threadId: string; sessionFile: string; cwd: string; + authProfileId?: string; model?: string; modelProvider?: string; dynamicToolsFingerprint?: string; @@ -41,6 +42,7 @@ export async function readCodexAppServerBinding( threadId: parsed.threadId, sessionFile, cwd: typeof parsed.cwd === "string" ? parsed.cwd : "", + authProfileId: typeof parsed.authProfileId === "string" ? parsed.authProfileId : undefined, model: typeof parsed.model === "string" ? parsed.model : undefined, modelProvider: typeof parsed.modelProvider === "string" ? parsed.modelProvider : undefined, dynamicToolsFingerprint: @@ -71,6 +73,7 @@ export async function writeCodexAppServerBinding( sessionFile, threadId: binding.threadId, cwd: binding.cwd, + authProfileId: binding.authProfileId, model: binding.model, modelProvider: binding.modelProvider, dynamicToolsFingerprint: binding.dynamicToolsFingerprint, diff --git a/extensions/codex/src/app-server/shared-client.test.ts b/extensions/codex/src/app-server/shared-client.test.ts index 52fb552eeec..d59e1e9844a 100644 --- a/extensions/codex/src/app-server/shared-client.test.ts +++ b/extensions/codex/src/app-server/shared-client.test.ts @@ -1,14 +1,35 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { CodexAppServerClient, MIN_CODEX_APP_SERVER_VERSION } from "./client.js"; -import { listCodexAppServerModels } from "./models.js"; -import { resetSharedCodexAppServerClientForTests } from "./shared-client.js"; import { createClientHarness } from "./test-support.js"; +const mocks = vi.hoisted(() => ({ + bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions), + resolveOpenClawAgentDir: vi.fn(() => "/tmp/openclaw-agent"), +})); + +vi.mock("./auth-bridge.js", () => ({ + bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions, +})); + +vi.mock("openclaw/plugin-sdk/provider-auth", () => ({ + resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir, +})); + +let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerModels; +let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests; + describe("shared Codex app-server client", () => { + beforeAll(async () => { + ({ listCodexAppServerModels } = await import("./models.js")); + ({ resetSharedCodexAppServerClientForTests } = await import("./shared-client.js")); + }); + afterEach(() => { resetSharedCodexAppServerClientForTests(); vi.useRealTimers(); vi.restoreAllMocks(); + mocks.bridgeCodexAppServerStartOptions.mockClear(); + mocks.resolveOpenClawAgentDir.mockClear(); }); it("closes the shared app-server when the version gate fails", async () => { @@ -18,6 +39,7 @@ describe("shared Codex app-server client", () => { // Model discovery uses the shared-client path, which owns child teardown // when initialize discovers an unsupported app-server. const listPromise = listCodexAppServerModels({ timeoutMs: 1000 }); + await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1)); const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number }; harness.send({ id: initialize.id, @@ -45,6 +67,7 @@ describe("shared Codex app-server client", () => { expect(first.process.kill).toHaveBeenCalledTimes(1); const secondList = listCodexAppServerModels({ timeoutMs: 1000 }); + await vi.waitFor(() => expect(second.writes.length).toBeGreaterThanOrEqual(1)); const initialize = JSON.parse(second.writes[0] ?? "{}") as { id?: number }; second.send({ id: initialize.id, @@ -57,4 +80,30 @@ describe("shared Codex app-server client", () => { await expect(secondList).resolves.toEqual({ models: [] }); expect(startSpy).toHaveBeenCalledTimes(2); }); + + it("passes the selected auth profile through the bridge helper", async () => { + const harness = createClientHarness(); + vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client); + + const listPromise = listCodexAppServerModels({ + timeoutMs: 1000, + authProfileId: "openai-codex:work", + }); + await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1)); + const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number }; + harness.send({ + id: initialize.id, + result: { userAgent: "openclaw/0.118.0 (macOS; test)" }, + }); + await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(3)); + const modelList = JSON.parse(harness.writes[2] ?? "{}") as { id?: number }; + harness.send({ id: modelList.id, result: { data: [] } }); + + await expect(listPromise).resolves.toEqual({ models: [] }); + expect(mocks.bridgeCodexAppServerStartOptions).toHaveBeenCalledWith( + expect.objectContaining({ + authProfileId: "openai-codex:work", + }), + ); + }); }); diff --git a/extensions/codex/src/app-server/shared-client.ts b/extensions/codex/src/app-server/shared-client.ts index 1fd51ee95c3..5dcee5fdecb 100644 --- a/extensions/codex/src/app-server/shared-client.ts +++ b/extensions/codex/src/app-server/shared-client.ts @@ -1,3 +1,5 @@ +import { resolveOpenClawAgentDir } from "openclaw/plugin-sdk/provider-auth"; +import { bridgeCodexAppServerStartOptions } from "./auth-bridge.js"; import { CodexAppServerClient } from "./client.js"; import { codexAppServerStartOptionsKey, @@ -25,9 +27,14 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState { export async function getSharedCodexAppServerClient(options?: { startOptions?: CodexAppServerStartOptions; timeoutMs?: number; + authProfileId?: string; }): Promise { const state = getSharedCodexAppServerClientState(); - const startOptions = options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start; + const startOptions = await bridgeCodexAppServerStartOptions({ + startOptions: options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start, + agentDir: resolveOpenClawAgentDir(), + authProfileId: options?.authProfileId, + }); const key = codexAppServerStartOptionsKey(startOptions); if (state.key && state.key !== key) { clearSharedCodexAppServerClient(); @@ -62,8 +69,13 @@ export async function getSharedCodexAppServerClient(options?: { export async function createIsolatedCodexAppServerClient(options?: { startOptions?: CodexAppServerStartOptions; timeoutMs?: number; + authProfileId?: string; }): Promise { - const startOptions = options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start; + const startOptions = await bridgeCodexAppServerStartOptions({ + startOptions: options?.startOptions ?? resolveCodexAppServerRuntimeOptions().start, + agentDir: resolveOpenClawAgentDir(), + authProfileId: options?.authProfileId, + }); const client = CodexAppServerClient.start(startOptions); const initialize = client.initialize(); try { diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts index a0dd23eeaa3..7f6935a5dd9 100644 --- a/extensions/codex/src/app-server/thread-lifecycle.ts +++ b/extensions/codex/src/app-server/thread-lifecycle.ts @@ -53,6 +53,7 @@ export async function startOrResumeThread(params: { await writeCodexAppServerBinding(params.params.sessionFile, { threadId: response.thread.id, cwd: params.cwd, + authProfileId: params.params.authProfileId, model: params.params.modelId, modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider), dynamicToolsFingerprint, @@ -62,6 +63,7 @@ export async function startOrResumeThread(params: { ...binding, threadId: response.thread.id, cwd: params.cwd, + authProfileId: params.params.authProfileId, model: params.params.modelId, modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider), dynamicToolsFingerprint, @@ -93,6 +95,7 @@ export async function startOrResumeThread(params: { await writeCodexAppServerBinding(params.params.sessionFile, { threadId: response.thread.id, cwd: params.cwd, + authProfileId: params.params.authProfileId, model: response.model ?? params.params.modelId, modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider), dynamicToolsFingerprint, @@ -103,6 +106,7 @@ export async function startOrResumeThread(params: { threadId: response.thread.id, sessionFile: params.params.sessionFile, cwd: params.cwd, + authProfileId: params.params.authProfileId, model: response.model ?? params.params.modelId, modelProvider: response.modelProvider ?? normalizeModelProvider(params.params.provider), dynamicToolsFingerprint, diff --git a/extensions/codex/src/app-server/transport-stdio.ts b/extensions/codex/src/app-server/transport-stdio.ts index 17bc84da453..61b1ed6ae50 100644 --- a/extensions/codex/src/app-server/transport-stdio.ts +++ b/extensions/codex/src/app-server/transport-stdio.ts @@ -3,8 +3,15 @@ import type { CodexAppServerStartOptions } from "./config.js"; import type { CodexAppServerTransport } from "./transport.js"; export function createStdioTransport(options: CodexAppServerStartOptions): CodexAppServerTransport { + const env = { + ...process.env, + ...options.env, + }; + for (const key of options.clearEnv ?? []) { + delete env[key]; + } return spawn(options.command, options.args, { - env: process.env, + env, detached: process.platform !== "win32", stdio: ["pipe", "pipe", "pipe"], }); diff --git a/extensions/openai/cli-backend.ts b/extensions/openai/cli-backend.ts index 7a5125240d6..3aa597b7d1d 100644 --- a/extensions/openai/cli-backend.ts +++ b/extensions/openai/cli-backend.ts @@ -3,6 +3,8 @@ import { CLI_FRESH_WATCHDOG_DEFAULTS, CLI_RESUME_WATCHDOG_DEFAULTS, } from "openclaw/plugin-sdk/cli-backend"; +import { OPENAI_CODEX_DEFAULT_PROFILE_ID } from "./openai-codex-cli-auth.js"; +import { prepareOpenAICodexCliExecution } from "./openai-codex-cli-bridge.js"; const CODEX_CLI_DEFAULT_MODEL_REF = "codex-cli/gpt-5.4"; @@ -20,6 +22,9 @@ export function buildOpenAICodexCliBackend(): CliBackendPlugin { }, bundleMcp: true, bundleMcpMode: "codex-config-overrides", + defaultAuthProfileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, + authEpochMode: "profile-only", + prepareExecution: prepareOpenAICodexCliExecution, config: { command: "codex", args: [ diff --git a/extensions/openai/openai-codex-cli-bridge.test.ts b/extensions/openai/openai-codex-cli-bridge.test.ts new file mode 100644 index 00000000000..673d9cf47af --- /dev/null +++ b/extensions/openai/openai-codex-cli-bridge.test.ts @@ -0,0 +1,77 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { prepareOpenAICodexCliExecution } from "./openai-codex-cli-bridge.js"; + +describe("prepareOpenAICodexCliExecution", () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); + }); + + it("writes a private CODEX_HOME bridge from canonical OpenClaw oauth", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-cli-bridge-")); + tempDirs.push(agentDir); + + const result = await prepareOpenAICodexCliExecution({ + config: undefined, + workspaceDir: agentDir, + agentDir, + provider: "codex-cli", + modelId: "gpt-5.4", + authProfileId: "openai-codex:default", + authCredential: { + type: "oauth", + provider: "openai-codex", + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + accountId: "acct-123", + }, + }); + + expect(result).toMatchObject({ + env: { + CODEX_HOME: expect.stringContaining(path.join(agentDir, "cli-auth", "codex")), + }, + clearEnv: ["OPENAI_API_KEY"], + }); + + const authFile = JSON.parse( + await fs.readFile(path.join(result?.env?.CODEX_HOME ?? "", "auth.json"), "utf8"), + ); + expect(authFile).toEqual({ + auth_mode: "chatgpt", + tokens: { + access_token: "access-token", + refresh_token: "refresh-token", + account_id: "acct-123", + }, + }); + }); + + it("returns null when there is no bridgeable canonical oauth credential", async () => { + const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-cli-bridge-")); + tempDirs.push(agentDir); + + await expect( + prepareOpenAICodexCliExecution({ + config: undefined, + workspaceDir: agentDir, + agentDir, + provider: "codex-cli", + modelId: "gpt-5.4", + authProfileId: "openai-codex:default", + authCredential: { + type: "api_key", + provider: "openai-codex", + key: "sk-test", + }, + }), + ).resolves.toBeNull(); + }); +}); diff --git a/extensions/openai/openai-codex-cli-bridge.ts b/extensions/openai/openai-codex-cli-bridge.ts new file mode 100644 index 00000000000..03460a73853 --- /dev/null +++ b/extensions/openai/openai-codex-cli-bridge.ts @@ -0,0 +1,72 @@ +import crypto from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import type { + CliBackendPreparedExecution, + CliBackendPrepareExecutionContext, +} from "openclaw/plugin-sdk/cli-backend"; +import type { OAuthCredential } from "openclaw/plugin-sdk/provider-auth"; + +const OPENAI_CODEX_PROVIDER_ID = "openai-codex"; +const CODEX_AUTH_ENV_CLEAR_KEYS = ["OPENAI_API_KEY"] as const; + +function isCodexBridgeableOAuthCredential(value: unknown): value is OAuthCredential { + return Boolean( + value && + typeof value === "object" && + value !== null && + "type" in value && + "provider" in value && + "access" in value && + "refresh" in value && + value.type === "oauth" && + value.provider === OPENAI_CODEX_PROVIDER_ID && + typeof value.access === "string" && + value.access.trim().length > 0 && + typeof value.refresh === "string" && + value.refresh.trim().length > 0, + ); +} + +function resolveCodexBridgeHome(agentDir: string, profileId: string): string { + const digest = crypto.createHash("sha256").update(profileId).digest("hex").slice(0, 16); + return path.join(agentDir, "cli-auth", "codex", digest); +} + +function buildCodexAuthFile(credential: OAuthCredential): string { + return `${JSON.stringify( + { + auth_mode: "chatgpt", + tokens: { + access_token: credential.access, + refresh_token: credential.refresh, + ...(credential.accountId ? { account_id: credential.accountId } : {}), + }, + }, + null, + 2, + )}\n`; +} + +export async function prepareOpenAICodexCliExecution( + ctx: CliBackendPrepareExecutionContext, +): Promise { + if ( + !ctx.agentDir || + !ctx.authProfileId || + !isCodexBridgeableOAuthCredential(ctx.authCredential) + ) { + return null; + } + + const codexHome = resolveCodexBridgeHome(ctx.agentDir, ctx.authProfileId); + await fs.mkdir(codexHome, { recursive: true }); + await fs.writeFile(path.join(codexHome, "auth.json"), buildCodexAuthFile(ctx.authCredential)); + + return { + env: { + CODEX_HOME: codexHome, + }, + clearEnv: [...CODEX_AUTH_ENV_CLEAR_KEYS], + }; +} diff --git a/extensions/openai/openai-codex-provider.test.ts b/extensions/openai/openai-codex-provider.test.ts index 9374bfcba52..e56e53f5d0a 100644 --- a/extensions/openai/openai-codex-provider.test.ts +++ b/extensions/openai/openai-codex-provider.test.ts @@ -87,6 +87,17 @@ describe("openai codex provider", () => { ); }); + it("offers explicit browser and one-time Codex CLI import auth methods", () => { + const provider = buildOpenAICodexProviderPlugin(); + + expect(provider.auth?.map((method) => method.id)).toEqual(["oauth", "import-codex-cli"]); + expect(provider.auth?.find((method) => method.id === "import-codex-cli")).toMatchObject({ + label: "Import Codex CLI login", + hint: "Use existing .codex auth once", + kind: "oauth", + }); + }); + it("owns native reasoning output mode for Codex responses", () => { const provider = buildOpenAICodexProviderPlugin(); diff --git a/extensions/openai/openai-codex-provider.ts b/extensions/openai/openai-codex-provider.ts index 17731c6414b..925fdccfc29 100644 --- a/extensions/openai/openai-codex-provider.ts +++ b/extensions/openai/openai-codex-provider.ts @@ -282,6 +282,35 @@ async function runOpenAICodexOAuth(ctx: ProviderAuthContext) { }); } +async function runImportOpenAICodexCliAuth(ctx: ProviderAuthContext) { + const profile = readOpenAICodexCliOAuthProfile({ + env: process.env, + store: ensureAuthProfileStore(ctx.agentDir, { + allowKeychainPrompt: false, + }), + }); + if (!profile) { + throw new Error( + "No compatible Codex CLI OAuth login found. Sign in with `codex` first or use ChatGPT OAuth instead.", + ); + } + + return buildOauthProviderAuthResult({ + providerId: PROVIDER_ID, + defaultModel: OPENAI_CODEX_DEFAULT_MODEL, + access: profile.credential.access, + refresh: profile.credential.refresh, + expires: profile.credential.expires, + email: profile.credential.email, + displayName: profile.credential.displayName, + profilePrefix: "default", + credentialExtra: profile.credential.accountId + ? { accountId: profile.credential.accountId } + : {}, + notes: ["Imported existing Codex CLI login into OpenClaw canonical auth."], + }); +} + function buildOpenAICodexAuthDoctorHint(ctx: { profileId?: string }) { if (ctx.profileId !== CODEX_CLI_PROFILE_ID) { return undefined; @@ -302,6 +331,13 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin { kind: "oauth", run: async (ctx) => await runOpenAICodexOAuth(ctx), }, + { + id: "import-codex-cli", + label: "Import Codex CLI login", + hint: "Use existing .codex auth once", + kind: "oauth", + run: async (ctx) => await runImportOpenAICodexCliAuth(ctx), + }, ], wizard: { setup: { @@ -327,13 +363,6 @@ export function buildOpenAICodexProviderPlugin(): ProviderPlugin { }, resolveDynamicModel: (ctx) => resolveCodexForwardCompatModel(ctx), buildAuthDoctorHint: (ctx) => buildOpenAICodexAuthDoctorHint(ctx), - resolveExternalAuthProfiles: (ctx) => { - const profile = readOpenAICodexCliOAuthProfile({ - env: ctx.env, - store: ctx.store, - }); - return profile ? [{ ...profile, persistence: "runtime-only" }] : undefined; - }, supportsXHighThinking: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_CODEX_XHIGH_MODEL_IDS), isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_CODEX_MODERN_MODEL_IDS), diff --git a/src/agents/auth-health.test.ts b/src/agents/auth-health.test.ts index 1f7dbb1a6ff..bf094128d96 100644 --- a/src/agents/auth-health.test.ts +++ b/src/agents/auth-health.test.ts @@ -108,7 +108,7 @@ describe("buildAuthHealthSummary", () => { expect(statuses["google:no-refresh"]).toBe("expired"); }); - it("prefers fresher imported external OAuth credentials when reporting health", () => { + it("does not let fresh .codex state override expired canonical health", () => { vi.spyOn(Date, "now").mockReturnValue(now); readCodexCliCredentialsCachedMock.mockReturnValue({ type: "oauth", @@ -138,7 +138,7 @@ describe("buildAuthHealthSummary", () => { }); const statuses = profileStatuses(summary); - expect(statuses["openai-codex:default"]).toBe("ok"); + expect(statuses["openai-codex:default"]).toBe("expired"); }); it("keeps healthy local oauth over fresher imported Codex CLI credentials in health status", () => { @@ -198,7 +198,7 @@ describe("buildAuthHealthSummary", () => { expect(profile?.status).toBe("expiring"); }); - it("prefers fresher imported external OAuth when the local credential is near expiry", () => { + it("does not let fresh .codex state override near-expiry canonical health", () => { vi.spyOn(Date, "now").mockReturnValue(now); readCodexCliCredentialsCachedMock.mockReturnValue({ type: "oauth", @@ -227,8 +227,8 @@ describe("buildAuthHealthSummary", () => { }); const profile = summary.profiles.find((entry) => entry.profileId === "openai-codex:default"); - expect(profile?.status).toBe("ok"); - expect(profile?.expiresAt).toBe(now + DEFAULT_OAUTH_WARN_MS + 60_000); + expect(profile?.status).toBe("expiring"); + expect(profile?.expiresAt).toBe(now + 2 * 60_000); }); it("marks token profiles with invalid expires as missing with reason code", () => { diff --git a/src/agents/auth-profiles.external-cli-sync.test.ts b/src/agents/auth-profiles.external-cli-sync.test.ts index ae8461b4fcc..a8620be1932 100644 --- a/src/agents/auth-profiles.external-cli-sync.test.ts +++ b/src/agents/auth-profiles.external-cli-sync.test.ts @@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({ let readManagedExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").readManagedExternalCliCredential; let resolveExternalCliAuthProfiles: typeof import("./auth-profiles/external-cli-sync.js").resolveExternalCliAuthProfiles; let hasUsableOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").hasUsableOAuthCredential; +let isSafeToUseExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").isSafeToUseExternalCliCredential; let shouldBootstrapFromExternalCliCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldBootstrapFromExternalCliCredential; let shouldReplaceStoredOAuthCredential: typeof import("./auth-profiles/external-cli-sync.js").shouldReplaceStoredOAuthCredential; let OPENAI_CODEX_DEFAULT_PROFILE_ID: typeof import("./auth-profiles/constants.js").OPENAI_CODEX_DEFAULT_PROFILE_ID; @@ -48,6 +49,7 @@ describe("external cli oauth resolution", () => { mocks.readMiniMaxCliCredentialsCached.mockReset().mockReturnValue(null); ({ hasUsableOAuthCredential, + isSafeToUseExternalCliCredential, readManagedExternalCliCredential, resolveExternalCliAuthProfiles, shouldBootstrapFromExternalCliCredential, @@ -193,7 +195,7 @@ describe("external cli oauth resolution", () => { ).toBe(true); }); - it("does not bootstrap across different known oauth identities", () => { + it("refuses external oauth usage across different known identities", () => { const imported = makeOAuthCredential({ provider: "openai-codex", access: "fresh-cli-access", @@ -203,8 +205,8 @@ describe("external cli oauth resolution", () => { }); expect( - shouldBootstrapFromExternalCliCredential({ - existing: makeOAuthCredential({ + isSafeToUseExternalCliCredential( + makeOAuthCredential({ provider: "openai-codex", access: "expired-local-access", refresh: "expired-local-refresh", @@ -212,12 +214,12 @@ describe("external cli oauth resolution", () => { accountId: "acct-local", }), imported, - }), + ), ).toBe(false); }); }); - it("reads codex external cli credentials by profile id", () => { + it("does not use codex as a runtime bootstrap source anymore", () => { mocks.readCodexCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "openai-codex", @@ -231,10 +233,7 @@ describe("external cli oauth resolution", () => { credential: makeOAuthCredential({ provider: "openai-codex" }), }); - expect(credential).toMatchObject({ - access: "codex-access-token", - refresh: "codex-refresh-token", - }); + expect(credential).toBeNull(); }); it("returns null when the profile id/provider do not map to the same external source", () => { @@ -250,16 +249,7 @@ describe("external cli oauth resolution", () => { expect(credential).toBeNull(); }); - it("resolves fresher codex and minimax external oauth profiles as runtime overlays", () => { - mocks.readCodexCliCredentialsCached.mockReturnValue( - makeOAuthCredential({ - provider: "openai-codex", - access: "codex-fresh-access", - refresh: "codex-fresh-refresh", - expires: Date.now() + 5 * 24 * 60 * 60_000, - accountId: "acct-codex", - }), - ); + it("resolves fresher minimax external oauth profiles as runtime overlays", () => { mocks.readMiniMaxCliCredentialsCached.mockReturnValue( makeOAuthCredential({ provider: "minimax-portal", @@ -273,13 +263,6 @@ describe("external cli oauth resolution", () => { const profiles = resolveExternalCliAuthProfiles({ version: 1, profiles: { - [OPENAI_CODEX_DEFAULT_PROFILE_ID]: makeOAuthCredential({ - provider: "openai-codex", - access: "codex-stale-access", - refresh: "codex-stale-refresh", - expires: Date.now() - 5_000, - accountId: "acct-codex", - }), [MINIMAX_CLI_PROFILE_ID]: makeOAuthCredential({ provider: "minimax-portal", access: "minimax-stale-access", @@ -293,20 +276,16 @@ describe("external cli oauth resolution", () => { const profilesById = new Map( profiles.map((profile) => [profile.profileId, profile.credential]), ); - expect(profilesById.get(OPENAI_CODEX_DEFAULT_PROFILE_ID)).toMatchObject({ - access: "codex-fresh-access", - refresh: "codex-fresh-refresh", - }); expect(profilesById.get(MINIMAX_CLI_PROFILE_ID)).toMatchObject({ access: "minimax-fresh-access", refresh: "minimax-fresh-refresh", }); }); - it("does not emit runtime overlays when the stored credential is newer", () => { - mocks.readCodexCliCredentialsCached.mockReturnValue( + it("does not emit runtime overlays when the stored minimax credential is newer", () => { + mocks.readMiniMaxCliCredentialsCached.mockReturnValue( makeOAuthCredential({ - provider: "openai-codex", + provider: "minimax-portal", access: "stale-external-access", refresh: "stale-external-refresh", expires: Date.now() - 5_000, @@ -315,9 +294,9 @@ describe("external cli oauth resolution", () => { const profiles = resolveExternalCliAuthProfiles( makeStore( - OPENAI_CODEX_DEFAULT_PROFILE_ID, + MINIMAX_CLI_PROFILE_ID, makeOAuthCredential({ - provider: "openai-codex", + provider: "minimax-portal", access: "fresh-store-access", refresh: "fresh-store-refresh", expires: Date.now() + 5 * 24 * 60 * 60_000, @@ -328,10 +307,10 @@ describe("external cli oauth resolution", () => { expect(profiles).toEqual([]); }); - it("does not overlay fresh external cli oauth over a still-usable local credential", () => { - mocks.readCodexCliCredentialsCached.mockReturnValue( + it("does not overlay fresh minimax oauth over a still-usable local credential", () => { + mocks.readMiniMaxCliCredentialsCached.mockReturnValue( makeOAuthCredential({ - provider: "openai-codex", + provider: "minimax-portal", access: "fresh-cli-access", refresh: "fresh-cli-refresh", expires: Date.now() + 5 * 24 * 60 * 60_000, @@ -340,9 +319,9 @@ describe("external cli oauth resolution", () => { const profiles = resolveExternalCliAuthProfiles( makeStore( - OPENAI_CODEX_DEFAULT_PROFILE_ID, + MINIMAX_CLI_PROFILE_ID, makeOAuthCredential({ - provider: "openai-codex", + provider: "minimax-portal", access: "healthy-local-access", refresh: "healthy-local-refresh", expires: Date.now() + 10 * 60_000, diff --git a/src/agents/auth-profiles/external-cli-sync.ts b/src/agents/auth-profiles/external-cli-sync.ts index 4a30e22a953..302ca45bbbe 100644 --- a/src/agents/auth-profiles/external-cli-sync.ts +++ b/src/agents/auth-profiles/external-cli-sync.ts @@ -1,12 +1,5 @@ -import { - readCodexCliCredentialsCached, - readMiniMaxCliCredentialsCached, -} from "../cli-credentials.js"; -import { - EXTERNAL_CLI_SYNC_TTL_MS, - MINIMAX_CLI_PROFILE_ID, - OPENAI_CODEX_DEFAULT_PROFILE_ID, -} from "./constants.js"; +import { readMiniMaxCliCredentialsCached } from "../cli-credentials.js"; +import { EXTERNAL_CLI_SYNC_TTL_MS, MINIMAX_CLI_PROFILE_ID } from "./constants.js"; import { log } from "./constants.js"; import { areOAuthCredentialsEquivalent, @@ -82,11 +75,6 @@ const EXTERNAL_CLI_SYNC_PROVIDERS: ExternalCliSyncProvider[] = [ provider: "minimax-portal", readCredentials: () => readMiniMaxCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), }, - { - profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID, - provider: "openai-codex", - readCredentials: () => readCodexCliCredentialsCached({ ttlMs: EXTERNAL_CLI_SYNC_TTL_MS }), - }, ]; function resolveExternalCliSyncProvider(params: { diff --git a/src/agents/auth-profiles/oauth-manager.ts b/src/agents/auth-profiles/oauth-manager.ts index 5713f6c2e4e..14532e5d761 100644 --- a/src/agents/auth-profiles/oauth-manager.ts +++ b/src/agents/auth-profiles/oauth-manager.ts @@ -6,7 +6,7 @@ import { OAUTH_REFRESH_LOCK_OPTIONS, log, } from "./constants.js"; -import { resolveTokenExpiryState } from "./credential-state.js"; +import { hasUsableOAuthCredential as hasUsableStoredOAuthCredential } from "./credential-state.js"; import { ensureAuthStoreFile, resolveAuthStorePath, resolveOAuthRefreshLockPath } from "./paths.js"; import { ensureAuthProfileStore, @@ -130,13 +130,7 @@ export function hasUsableOAuthCredential( credential: OAuthCredential | undefined, now = Date.now(), ): boolean { - if (!credential || credential.type !== "oauth") { - return false; - } - if (typeof credential.access !== "string" || credential.access.trim().length === 0) { - return false; - } - return resolveTokenExpiryState(credential.expires, now) === "valid"; + return hasUsableStoredOAuthCredential(credential, { now }); } function normalizeAuthIdentityToken(value: string | undefined): string | undefined { diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index d2679cd82e8..5b6421f675d 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -259,7 +259,7 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { }); }); - it("prefers fresh Codex CLI credentials when the stored default profile is expired", async () => { + it("keeps runtime refresh on canonical auth even when .codex has a fresher token", async () => { const profileId = "openai-codex:default"; saveAuthProfileStore( { @@ -285,6 +285,23 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { expires: Date.now() + 86_400_000, accountId: "acct-cli", }); + refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce( + async (params?: { context?: unknown }) => { + expect(params?.context).toMatchObject({ + access: "expired-access-token", + refresh: "expired-refresh-token", + accountId: "acct-cli", + }); + return { + type: "oauth", + provider: "openai-codex", + access: "rotated-local-access-token", + refresh: "rotated-local-refresh-token", + expires: Date.now() + 86_400_000, + accountId: "acct-cli", + }; + }, + ); await expect( resolveApiKeyForProfile({ @@ -293,12 +310,12 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { agentDir, }), ).resolves.toEqual({ - apiKey: "fresh-cli-access-token", + apiKey: "rotated-local-access-token", provider: "openai-codex", email: undefined, }); - expect(refreshProviderOAuthCredentialWithPluginMock).not.toHaveBeenCalled(); + expect(refreshProviderOAuthCredentialWithPluginMock).toHaveBeenCalledTimes(1); expect(writeCodexCliCredentialsMock).not.toHaveBeenCalled(); }); @@ -588,7 +605,7 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { ); }); - it("adopts a fresher imported refresh token even when its access token is already expired", async () => { + it("keeps the canonical refresh token even when .codex has a fresher but expired refresh token", async () => { const profileId = "openai-codex:default"; saveAuthProfileStore( { @@ -616,8 +633,8 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { refreshProviderOAuthCredentialWithPluginMock.mockImplementationOnce( async (params?: { context?: unknown }) => { expect(params?.context).toMatchObject({ - access: "newer-but-expired-cli-access-token", - refresh: "fresh-cli-refresh-token", + access: "expired-local-access-token", + refresh: "stale-local-refresh-token", }); return { type: "oauth", diff --git a/src/agents/cli-auth-epoch.test.ts b/src/agents/cli-auth-epoch.test.ts index 6f9d860888c..fc2ba01e00d 100644 --- a/src/agents/cli-auth-epoch.test.ts +++ b/src/agents/cli-auth-epoch.test.ts @@ -141,4 +141,55 @@ describe("resolveCliAuthEpoch", () => { expect(second).not.toBe(first); expect(third).not.toBe(second); }); + + it("can ignore local codex state when the backend is profile-owned", async () => { + let localAccess = "local-access-a"; + let profileRefresh = "profile-refresh-a"; + setCliAuthEpochTestDeps({ + readCodexCliCredentialsCached: () => ({ + type: "oauth", + provider: "openai-codex", + access: localAccess, + refresh: "local-refresh", + expires: 1, + accountId: "acct-1", + }), + loadAuthProfileStoreForRuntime: () => ({ + version: 1, + profiles: { + "openai-codex:default": { + type: "oauth", + provider: "openai-codex", + access: "profile-access", + refresh: profileRefresh, + expires: 1, + accountId: "acct-1", + }, + }, + }), + }); + + const first = await resolveCliAuthEpoch({ + provider: "codex-cli", + authProfileId: "openai-codex:default", + skipLocalCredential: true, + }); + localAccess = "local-access-b"; + const second = await resolveCliAuthEpoch({ + provider: "codex-cli", + authProfileId: "openai-codex:default", + skipLocalCredential: true, + }); + profileRefresh = "profile-refresh-b"; + const third = await resolveCliAuthEpoch({ + provider: "codex-cli", + authProfileId: "openai-codex:default", + skipLocalCredential: true, + }); + + expect(first).toBeDefined(); + expect(second).toBe(first); + expect(third).toBeDefined(); + expect(third).not.toBe(second); + }); }); diff --git a/src/agents/cli-auth-epoch.ts b/src/agents/cli-auth-epoch.ts index ae1c6d28173..0242811da79 100644 --- a/src/agents/cli-auth-epoch.ts +++ b/src/agents/cli-auth-epoch.ts @@ -136,14 +136,17 @@ function getAuthProfileCredential( export async function resolveCliAuthEpoch(params: { provider: string; authProfileId?: string; + skipLocalCredential?: boolean; }): Promise { const provider = params.provider.trim(); const authProfileId = normalizeOptionalString(params.authProfileId); const parts: string[] = []; - const localFingerprint = getLocalCliCredentialFingerprint(provider); - if (localFingerprint) { - parts.push(`local:${provider}:${localFingerprint}`); + if (params.skipLocalCredential !== true) { + const localFingerprint = getLocalCliCredentialFingerprint(provider); + if (localFingerprint) { + parts.push(`local:${provider}:${localFingerprint}`); + } } if (authProfileId) { diff --git a/src/agents/cli-backends.test.ts b/src/agents/cli-backends.test.ts index 895cde3344d..fa992013c9e 100644 --- a/src/agents/cli-backends.test.ts +++ b/src/agents/cli-backends.test.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; import type { CliBackendConfig } from "../config/types.js"; -import type { CliBundleMcpMode } from "../plugins/types.js"; +import type { CliBackendAuthEpochMode, CliBundleMcpMode } from "../plugins/types.js"; import { __testing as cliBackendsTesting, resolveCliBackendConfig, @@ -24,6 +24,9 @@ function createBackendEntry(params: { config: CliBackendConfig; bundleMcp?: boolean; bundleMcpMode?: CliBundleMcpMode; + defaultAuthProfileId?: string; + authEpochMode?: CliBackendAuthEpochMode; + prepareExecution?: () => Promise; normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig; }) { return { @@ -34,6 +37,9 @@ function createBackendEntry(params: { config: params.config, ...(params.bundleMcp ? { bundleMcp: params.bundleMcp } : {}), ...(params.bundleMcpMode ? { bundleMcpMode: params.bundleMcpMode } : {}), + ...(params.defaultAuthProfileId ? { defaultAuthProfileId: params.defaultAuthProfileId } : {}), + ...(params.authEpochMode ? { authEpochMode: params.authEpochMode } : {}), + ...(params.prepareExecution ? { prepareExecution: params.prepareExecution } : {}), ...(params.normalizeConfig ? { normalizeConfig: params.normalizeConfig } : {}), liveTest: { defaultModelRef: @@ -233,6 +239,9 @@ beforeEach(() => { id: "codex-cli", bundleMcp: true, bundleMcpMode: "codex-config-overrides", + defaultAuthProfileId: "openai-codex:default", + authEpochMode: "profile-only", + prepareExecution: async () => null, config: { command: "codex", args: [ @@ -764,6 +773,9 @@ describe("resolveCliBackendConfig google-gemini-cli defaults", () => { expect(resolved).not.toBeNull(); expect(resolved?.bundleMcp).toBe(true); expect(resolved?.bundleMcpMode).toBe("codex-config-overrides"); + expect(resolved?.defaultAuthProfileId).toBe("openai-codex:default"); + expect(resolved?.authEpochMode).toBe("profile-only"); + expect(typeof resolved?.prepareExecution).toBe("function"); expect(resolved?.config.systemPromptFileConfigArg).toBe("-c"); expect(resolved?.config.systemPromptFileConfigKey).toBe("model_instructions_file"); expect(resolved?.config.systemPromptWhen).toBe("first"); diff --git a/src/agents/cli-backends.ts b/src/agents/cli-backends.ts index c5e296caf18..e6df69ad937 100644 --- a/src/agents/cli-backends.ts +++ b/src/agents/cli-backends.ts @@ -3,7 +3,12 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveRuntimeCliBackends } from "../plugins/cli-backends.runtime.js"; import { resolvePluginSetupCliBackend } from "../plugins/setup-registry.js"; import { resolveRuntimeTextTransforms } from "../plugins/text-transforms.runtime.js"; -import type { CliBundleMcpMode, CliBackendPlugin, PluginTextTransforms } from "../plugins/types.js"; +import type { + CliBackendAuthEpochMode, + CliBundleMcpMode, + CliBackendPlugin, + PluginTextTransforms, +} from "../plugins/types.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { normalizeProviderId } from "./model-selection.js"; import { mergePluginTextTransforms } from "./plugin-text-transforms.js"; @@ -28,6 +33,9 @@ export type ResolvedCliBackend = { pluginId?: string; transformSystemPrompt?: CliBackendPlugin["transformSystemPrompt"]; textTransforms?: PluginTextTransforms; + defaultAuthProfileId?: string; + authEpochMode?: CliBackendAuthEpochMode; + prepareExecution?: CliBackendPlugin["prepareExecution"]; }; export type ResolvedCliBackendLiveTest = { @@ -45,6 +53,9 @@ type FallbackCliBackendPolicy = { normalizeConfig?: (config: CliBackendConfig) => CliBackendConfig; transformSystemPrompt?: CliBackendPlugin["transformSystemPrompt"]; textTransforms?: PluginTextTransforms; + defaultAuthProfileId?: string; + authEpochMode?: CliBackendAuthEpochMode; + prepareExecution?: CliBackendPlugin["prepareExecution"]; }; const FALLBACK_CLI_BACKEND_POLICIES: Record = {}; @@ -78,6 +89,9 @@ function resolveSetupCliBackendPolicy(provider: string): FallbackCliBackendPolic normalizeConfig: entry.backend.normalizeConfig, transformSystemPrompt: entry.backend.transformSystemPrompt, textTransforms: entry.backend.textTransforms, + defaultAuthProfileId: entry.backend.defaultAuthProfileId, + authEpochMode: entry.backend.authEpochMode, + prepareExecution: entry.backend.prepareExecution, }; } @@ -198,6 +212,9 @@ export function resolveCliBackendConfig( pluginId: registered.pluginId, transformSystemPrompt: registered.transformSystemPrompt, textTransforms: mergePluginTextTransforms(runtimeTextTransforms, registered.textTransforms), + defaultAuthProfileId: registered.defaultAuthProfileId, + authEpochMode: registered.authEpochMode, + prepareExecution: registered.prepareExecution, }; } @@ -223,6 +240,9 @@ export function resolveCliBackendConfig( runtimeTextTransforms, fallbackPolicy.textTransforms, ), + defaultAuthProfileId: fallbackPolicy.defaultAuthProfileId, + authEpochMode: fallbackPolicy.authEpochMode, + prepareExecution: fallbackPolicy.prepareExecution, }; } const mergedFallback = fallbackPolicy?.baseConfig @@ -245,6 +265,9 @@ export function resolveCliBackendConfig( runtimeTextTransforms, fallbackPolicy?.textTransforms, ), + defaultAuthProfileId: fallbackPolicy?.defaultAuthProfileId, + authEpochMode: fallbackPolicy?.authEpochMode, + prepareExecution: fallbackPolicy?.prepareExecution, }; } diff --git a/src/agents/cli-runner.ts b/src/agents/cli-runner.ts index 75164ed43f8..a4babfd378c 100644 --- a/src/agents/cli-runner.ts +++ b/src/agents/cli-runner.ts @@ -52,7 +52,7 @@ export async function runPreparedCliAgent( }, requestShaping: { ...(params.thinkLevel ? { thinking: params.thinkLevel } : {}), - ...(params.authProfileId ? { authMode: "auth-profile" } : {}), + ...(context.effectiveAuthProfileId ? { authMode: "auth-profile" } : {}), }, completion: { finishReason: "stop", @@ -68,7 +68,9 @@ export async function runPreparedCliAgent( ? { cliSessionBinding: { sessionId: resultParams.effectiveCliSessionId, - ...(params.authProfileId ? { authProfileId: params.authProfileId } : {}), + ...(context.effectiveAuthProfileId + ? { authProfileId: context.effectiveAuthProfileId } + : {}), ...(context.authEpoch ? { authEpoch: context.authEpoch } : {}), ...(context.extraSystemPromptHash ? { extraSystemPromptHash: context.extraSystemPromptHash } diff --git a/src/agents/cli-runner/prepare.ts b/src/agents/cli-runner/prepare.ts index 98e6b01cb99..8df69239616 100644 --- a/src/agents/cli-runner/prepare.ts +++ b/src/agents/cli-runner/prepare.ts @@ -3,7 +3,10 @@ import { createMcpLoopbackServerConfig, getActiveMcpLoopbackRuntime, } from "../../gateway/mcp-http.loopback-runtime.js"; +import { resolveOpenClawAgentDir } from "../agent-paths.js"; import { resolveSessionAgentIds } from "../agent-scope.js"; +import { loadAuthProfileStoreForRuntime } from "../auth-profiles/store.js"; +import type { AuthProfileCredential } from "../auth-profiles/types.js"; import { buildBootstrapInjectionStats, buildBootstrapPromptWarning, @@ -73,9 +76,23 @@ export async function prepareCliRunContext( if (!backendResolved) { throw new Error(`Unknown CLI backend: ${params.provider}`); } + const agentDir = resolveOpenClawAgentDir(); + const requestedAuthProfileId = params.authProfileId?.trim() || undefined; + const effectiveAuthProfileId = + requestedAuthProfileId ?? backendResolved.defaultAuthProfileId?.trim() ?? undefined; + let authCredential: AuthProfileCredential | undefined; + if (effectiveAuthProfileId) { + const authStore = loadAuthProfileStoreForRuntime(agentDir, { + readOnly: true, + allowKeychainPrompt: false, + }); + authCredential = authStore.profiles[effectiveAuthProfileId]; + } const authEpoch = await resolveCliAuthEpoch({ provider: params.provider, - authProfileId: params.authProfileId, + authProfileId: effectiveAuthProfileId, + skipLocalCredential: + backendResolved.authEpochMode === "profile-only" && Boolean(effectiveAuthProfileId), }); const extraSystemPrompt = params.extraSystemPrompt?.trim() ?? ""; const extraSystemPromptHash = hashCliSessionText(extraSystemPrompt); @@ -149,13 +166,51 @@ export async function prepareCliRunContext( : undefined, warn: (message) => cliBackendLog.warn(message), }); + const preparedExecution = await backendResolved.prepareExecution?.({ + config: params.config, + workspaceDir, + agentDir, + provider: params.provider, + modelId, + authProfileId: effectiveAuthProfileId, + authCredential, + }); + const preparedBackendEnv = + preparedExecution?.env && Object.keys(preparedExecution.env).length > 0 + ? { ...preparedBackend.env, ...preparedExecution.env } + : preparedBackend.env; + const preparedBackendCleanup = + preparedBackend.cleanup || preparedExecution?.cleanup + ? async () => { + try { + await preparedExecution?.cleanup?.(); + } finally { + await preparedBackend.cleanup?.(); + } + } + : undefined; + const preparedBackendClearEnv = [ + ...(preparedBackend.backend.clearEnv ?? []), + ...(preparedExecution?.clearEnv ?? []), + ]; + const preparedBackendFinal = { + ...preparedBackend, + backend: { + ...preparedBackend.backend, + ...(preparedBackendClearEnv.length > 0 + ? { clearEnv: Array.from(new Set(preparedBackendClearEnv)) } + : {}), + }, + ...(preparedBackendEnv ? { env: preparedBackendEnv } : {}), + ...(preparedBackendCleanup ? { cleanup: preparedBackendCleanup } : {}), + }; const reusableCliSession = params.cliSessionBinding ? resolveCliSessionReuse({ binding: params.cliSessionBinding, - authProfileId: params.authProfileId, + authProfileId: effectiveAuthProfileId, authEpoch, extraSystemPromptHash, - mcpConfigHash: preparedBackend.mcpConfigHash, + mcpConfigHash: preparedBackendFinal.mcpConfigHash, }) : params.cliSessionId ? { sessionId: params.cliSessionId } @@ -240,10 +295,11 @@ export async function prepareCliRunContext( return { params, + effectiveAuthProfileId, started, workspaceDir, backendResolved, - preparedBackend, + preparedBackend: preparedBackendFinal, reusableCliSession, modelId, normalizedModel, diff --git a/src/agents/cli-runner/types.ts b/src/agents/cli-runner/types.ts index 4a6dded8b5a..1c2ac804e12 100644 --- a/src/agents/cli-runner/types.ts +++ b/src/agents/cli-runner/types.ts @@ -54,6 +54,7 @@ export type CliReusableSession = { export type PreparedCliRunContext = { params: RunCliAgentParams; + effectiveAuthProfileId?: string; started: number; workspaceDir: string; backendResolved: ResolvedCliBackend; diff --git a/src/plugin-sdk/cli-backend.ts b/src/plugin-sdk/cli-backend.ts index 192d40f8ce4..ee1ac1ba28f 100644 --- a/src/plugin-sdk/cli-backend.ts +++ b/src/plugin-sdk/cli-backend.ts @@ -1,5 +1,10 @@ export type { CliBackendConfig } from "../config/types.js"; -export type { CliBackendPlugin } from "../plugins/types.js"; +export type { + CliBackendAuthEpochMode, + CliBackendPlugin, + CliBackendPreparedExecution, + CliBackendPrepareExecutionContext, +} from "../plugins/types.js"; export { CLI_FRESH_WATCHDOG_DEFAULTS, CLI_RESUME_WATCHDOG_DEFAULTS, diff --git a/src/plugin-sdk/provider-auth.ts b/src/plugin-sdk/provider-auth.ts index a0d9657a144..8e963e90c2f 100644 --- a/src/plugin-sdk/provider-auth.ts +++ b/src/plugin-sdk/provider-auth.ts @@ -59,6 +59,7 @@ export { createProviderApiKeyAuthMethod } from "../plugins/provider-api-key-auth export { coerceSecretRef, hasConfiguredSecretInput } from "../config/types.secrets.js"; export { resolveDefaultSecretProviderAlias } from "../secrets/ref-contract.js"; export { resolveRequiredHomeDir } from "../infra/home-dir.js"; +export { resolveOpenClawAgentDir } from "../agents/agent-paths.js"; export { normalizeOptionalSecretInput, normalizeSecretInput, diff --git a/src/plugins/cli-backend.types.ts b/src/plugins/cli-backend.types.ts index 2cdb24d085a..4169d614ce9 100644 --- a/src/plugins/cli-backend.types.ts +++ b/src/plugins/cli-backend.types.ts @@ -18,6 +18,24 @@ export type CliBundleMcpMode = | "codex-config-overrides" | "gemini-system-settings"; +export type CliBackendPrepareExecutionContext = { + config?: OpenClawConfig; + workspaceDir: string; + agentDir?: string; + provider: string; + modelId: string; + authProfileId?: string; + authCredential?: unknown; +}; + +export type CliBackendPreparedExecution = { + env?: Record; + clearEnv?: string[]; + cleanup?: () => Promise; +}; + +export type CliBackendAuthEpochMode = "combined" | "profile-only"; + /** Plugin-owned CLI backend defaults used by the text-only CLI runner. */ export type CliBackendPlugin = { /** Provider id used in model refs, for example `claude-cli/opus`. */ @@ -84,4 +102,33 @@ export type CliBackendPlugin = { * `output` applies to parsed/streamed assistant text from the CLI. */ textTransforms?: PluginTextTransforms; + /** + * Preferred auth-profile id when the caller did not explicitly lock one. + * + * Use this when the backend should consume a canonical OpenClaw auth profile + * rather than ambient host auth by default. + */ + defaultAuthProfileId?: string; + /** + * Session/auth epoch source policy. + * + * `combined` keeps the legacy "host credential + auth profile" fingerprint. + * `profile-only` treats the selected OpenClaw auth profile as the sole auth + * owner for session invalidation when one is present. + */ + authEpochMode?: CliBackendAuthEpochMode; + /** + * Backend-owned execution bridge. + * + * Use this on async run paths when the backend needs a generated auth/config + * bridge (for example a private CLI home directory) without teaching the core + * runner about provider-specific file formats. + */ + prepareExecution?: ( + ctx: CliBackendPrepareExecutionContext, + ) => + | Promise + | CliBackendPreparedExecution + | null + | undefined; }; diff --git a/src/plugins/types.ts b/src/plugins/types.ts index b47aff0aa8d..eb8385441f9 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -67,6 +67,9 @@ import type { import type { VideoGenerationProvider } from "../video-generation/types.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import type { + CliBackendAuthEpochMode, + CliBackendPreparedExecution, + CliBackendPrepareExecutionContext, CliBackendPlugin, CliBundleMcpMode, PluginTextReplacement, @@ -141,6 +144,9 @@ export type { PluginConversationBindingResolutionDecision, } from "./conversation-binding.types.js"; export type { + CliBackendAuthEpochMode, + CliBackendPreparedExecution, + CliBackendPrepareExecutionContext, CliBackendPlugin, CliBundleMcpMode, PluginTextReplacement,