feat: add openai codex oauth

This commit is contained in:
Peter Steinberger
2026-01-05 06:31:45 +01:00
parent bce62f8c0f
commit f3cb41511d
4 changed files with 143 additions and 1 deletions

View File

@@ -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

View File

@@ -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<Api>;
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<string, unknown>;
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 });
}
});
});

View File

@@ -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<string, OAuthCredentials>;
@@ -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<typeof discoverAuthStorage>,
): 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<Api>,
authStorage: ReturnType<typeof discoverAuthStorage>,
): Promise<string> {
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();

View File

@@ -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(