From f3cb41511d13af13957bd2f67fcd08719cd637ba Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 5 Jan 2026 06:31:45 +0100 Subject: [PATCH] feat: add openai codex oauth --- CHANGELOG.md | 1 + src/agents/model-auth.test.ts | 70 +++++++++++++++++++++++++++++++++++ src/agents/model-auth.ts | 25 ++++++++++++- src/wizard/onboarding.ts | 48 ++++++++++++++++++++++++ 4 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 src/agents/model-auth.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bce0604c..d881f298d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ - macOS: local gateway now connects via tailnet IP when bind mode is `tailnet`/`auto`. - macOS: Settings now use a sidebar layout to avoid toolbar overflow in Connections. - macOS: drop deprecated `afterMs` from agent wait params to match gateway schema. +- Auth: add OpenAI Codex OAuth support and migrate legacy oauth.json into auth.json. - Sandbox: copy inbound media into sandbox workspaces so agent tools can read attachments. ### Maintenance diff --git a/src/agents/model-auth.test.ts b/src/agents/model-auth.test.ts new file mode 100644 index 000000000..1250e6174 --- /dev/null +++ b/src/agents/model-auth.test.ts @@ -0,0 +1,70 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { describe, expect, it, vi } from "vitest"; + +import type { Api, Model } from "@mariozechner/pi-ai"; +import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; + +const oauthFixture = { + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + accountId: "acct_123", +}; + +describe("getApiKeyForModel", () => { + it("migrates legacy oauth.json into auth.json", async () => { + const previousStateDir = process.env.CLAWDBOT_STATE_DIR; + const tempDir = await fs.mkdtemp( + path.join(os.tmpdir(), "clawdbot-oauth-"), + ); + + try { + process.env.CLAWDBOT_STATE_DIR = tempDir; + + const oauthDir = path.join(tempDir, "credentials"); + await fs.mkdir(oauthDir, { recursive: true, mode: 0o700 }); + await fs.writeFile( + path.join(oauthDir, "oauth.json"), + `${JSON.stringify({ "openai-codex": oauthFixture }, null, 2)}\n`, + "utf8", + ); + + const agentDir = path.join(tempDir, "agent"); + await fs.mkdir(agentDir, { recursive: true, mode: 0o700 }); + const authStorage = discoverAuthStorage(agentDir); + + vi.resetModules(); + const { getApiKeyForModel } = await import("./model-auth.js"); + + const model = { + id: "codex-mini-latest", + provider: "openai-codex", + api: "openai-codex-responses", + } as Model; + + const apiKey = await getApiKeyForModel(model, authStorage); + expect(apiKey).toBe(oauthFixture.access); + + const authJson = await fs.readFile( + path.join(agentDir, "auth.json"), + "utf8", + ); + const authData = JSON.parse(authJson) as Record; + expect(authData["openai-codex"]).toMatchObject({ + type: "oauth", + access: oauthFixture.access, + refresh: oauthFixture.refresh, + }); + } finally { + if (previousStateDir === undefined) { + delete process.env.CLAWDBOT_STATE_DIR; + } else { + process.env.CLAWDBOT_STATE_DIR = previousStateDir; + } + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/agents/model-auth.ts b/src/agents/model-auth.ts index 85ce74c61..80c339ddd 100644 --- a/src/agents/model-auth.ts +++ b/src/agents/model-auth.ts @@ -17,6 +17,7 @@ import { CONFIG_DIR, resolveUserPath } from "../utils.js"; const OAUTH_FILENAME = "oauth.json"; const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials"); let oauthStorageConfigured = false; +let oauthStorageMigrated = false; type OAuthStorage = Record; @@ -97,6 +98,26 @@ export function ensureOAuthStorage(): void { importLegacyOAuthIfNeeded(oauthPath); } +function isValidOAuthCredential(entry: OAuthCredentials | undefined): entry is OAuthCredentials { + if (!entry) return false; + return Boolean(entry.access?.trim() && entry.refresh?.trim() && Number.isFinite(entry.expires)); +} + +function migrateOAuthStorageToAuthStorage( + authStorage: ReturnType, +): void { + if (oauthStorageMigrated) return; + oauthStorageMigrated = true; + const oauthPath = resolveClawdbotOAuthPath(); + const storage = loadOAuthStorageAt(oauthPath); + if (!storage) return; + for (const [provider, creds] of Object.entries(storage)) { + if (!isValidOAuthCredential(creds)) continue; + if (authStorage.get(provider)) continue; + authStorage.set(provider, { type: "oauth", ...creds }); + } +} + function isOAuthProvider(provider: string): provider is OAuthProvider { return ( provider === "anthropic" || @@ -104,6 +125,7 @@ function isOAuthProvider(provider: string): provider is OAuthProvider { provider === "google" || provider === "openai" || provider === "openai-compatible" || + provider === "openai-codex" || provider === "github-copilot" || provider === "google-gemini-cli" || provider === "google-antigravity" @@ -114,9 +136,10 @@ export async function getApiKeyForModel( model: Model, authStorage: ReturnType, ): Promise { + ensureOAuthStorage(); + migrateOAuthStorageToAuthStorage(authStorage); const storedKey = await authStorage.getApiKey(model.provider); if (storedKey) return storedKey; - ensureOAuthStorage(); if (model.provider === "anthropic") { const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN; if (oauthEnv?.trim()) return oauthEnv.trim(); diff --git a/src/wizard/onboarding.ts b/src/wizard/onboarding.ts index ca86475d3..94ee98657 100644 --- a/src/wizard/onboarding.ts +++ b/src/wizard/onboarding.ts @@ -1,6 +1,7 @@ import path from "node:path"; import { loginAnthropic, type OAuthCredentials } from "@mariozechner/pi-ai"; +import { discoverAuthStorage } from "@mariozechner/pi-coding-agent"; import { isRemoteEnvironment, loginAntigravityVpsAware, @@ -185,6 +186,7 @@ export async function runOnboardingWizard( message: "Model/auth choice", options: [ { value: "oauth", label: "Anthropic OAuth (Claude Pro/Max)" }, + { value: "openai-codex", label: "OpenAI Codex (ChatGPT OAuth)" }, { value: "antigravity", label: "Google Antigravity (Claude Opus 4.5, Gemini 3, etc.)", @@ -224,6 +226,52 @@ export async function runOnboardingWizard( spin.stop("OAuth failed"); runtime.error(String(err)); } + } else if (authChoice === "openai-codex") { + const isRemote = isRemoteEnvironment(); + await prompter.note( + isRemote + ? [ + "You are running in a remote/VPS environment.", + "A URL will be shown for you to open in your LOCAL browser.", + "After signing in, paste the redirect URL back here.", + ].join("\n") + : [ + "Browser will open for OpenAI authentication.", + "If the callback doesn't auto-complete, paste the redirect URL.", + "OpenAI OAuth uses localhost:1455 for the callback.", + ].join("\n"), + "OpenAI Codex OAuth", + ); + const spin = prompter.progress("Starting OAuth flow…"); + try { + const agentDir = resolveClawdbotAgentDir(); + const authStorage = discoverAuthStorage(agentDir); + await authStorage.login("openai-codex", { + onAuth: async ({ url }) => { + if (isRemote) { + spin.stop("OAuth URL ready"); + runtime.log(`\nOpen this URL in your LOCAL browser:\n\n${url}\n`); + } else { + spin.update("Complete sign-in in browser…"); + await openUrl(url); + runtime.log(`Open: ${url}`); + } + }, + onPrompt: async (prompt) => { + const code = await prompter.text({ + message: prompt.message, + placeholder: prompt.placeholder, + validate: (value) => (value?.trim() ? undefined : "Required"), + }); + return String(code); + }, + onProgress: (msg) => spin.update(msg), + }); + spin.stop("OpenAI OAuth complete"); + } catch (err) { + spin.stop("OpenAI OAuth failed"); + runtime.error(String(err)); + } } else if (authChoice === "antigravity") { const isRemote = isRemoteEnvironment(); await prompter.note(