From 1f24323583519c41d6e2a261f07248a946868506 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Mon, 2 Mar 2026 13:18:17 -0800 Subject: [PATCH] Auth: gate OpenAI OAuth TLS preflight in doctor --- PR_DRAFT_OAUTH_TLS_PREFLIGHT.md | 41 ---------------- src/commands/doctor.ts | 5 +- .../oauth-tls-preflight.doctor.test.ts | 49 ++++++++++++++++++- src/commands/oauth-tls-preflight.ts | 30 +++++++++++- src/commands/openai-codex-oauth.test.ts | 33 +++++++++++++ 5 files changed, 112 insertions(+), 46 deletions(-) delete mode 100644 PR_DRAFT_OAUTH_TLS_PREFLIGHT.md diff --git a/PR_DRAFT_OAUTH_TLS_PREFLIGHT.md b/PR_DRAFT_OAUTH_TLS_PREFLIGHT.md deleted file mode 100644 index 7c0893bae97..00000000000 --- a/PR_DRAFT_OAUTH_TLS_PREFLIGHT.md +++ /dev/null @@ -1,41 +0,0 @@ -## Summary - -Add an OpenAI OAuth TLS preflight to detect local certificate-chain problems early and provide actionable remediation, instead of surfacing only `TypeError: fetch failed`. - -### Changes - -- Add `runOpenAIOAuthTlsPreflight()` and remediation formatter in `src/commands/oauth-tls-preflight.ts`. -- Run TLS preflight before `loginOpenAICodex()` in `src/commands/openai-codex-oauth.ts`. -- Add doctor check via `noteOpenAIOAuthTlsPrerequisites()` in `src/commands/doctor.ts`. -- Keep doctor fast-path tests deterministic by mocking preflight in `src/commands/doctor.fast-path-mocks.ts`. - -### User-visible behavior - -- During OpenAI Codex OAuth, TLS trust failures now produce actionable guidance, including: - - `brew postinstall ca-certificates` - - `brew postinstall openssl@3` - - expected cert bundle location when Homebrew prefix is detectable. -- `openclaw doctor` now reports an `OAuth TLS prerequisites` warning when TLS trust is broken for OpenAI auth calls. - -## Why - -On some Homebrew Node/OpenSSL setups, missing or broken cert bundle links cause OAuth failures like: - -- `OpenAI OAuth failed` -- `TypeError: fetch failed` -- `UNABLE_TO_GET_ISSUER_CERT_LOCALLY` - -This change turns that failure mode into an explicit prerequisite check with concrete fixes. - -## Tests - -Ran: - -```bash -corepack pnpm vitest run \ - src/commands/openai-codex-oauth.test.ts \ - src/commands/oauth-tls-preflight.test.ts \ - src/commands/oauth-tls-preflight.doctor.test.ts -``` - -All passed. diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 3cf71b8ce02..0f5fb199f80 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -201,7 +201,10 @@ export async function doctorCommand( await noteMacLaunchctlGatewayEnvOverrides(cfg); await noteSecurityWarnings(cfg); - await noteOpenAIOAuthTlsPrerequisites(); + await noteOpenAIOAuthTlsPrerequisites({ + cfg, + deep: options.deep === true, + }); if (cfg.hooks?.gmail?.model?.trim()) { const hooksModelRef = resolveHooksGmailModel({ diff --git a/src/commands/oauth-tls-preflight.doctor.test.ts b/src/commands/oauth-tls-preflight.doctor.test.ts index 6f6cb6106fb..bf4107cce22 100644 --- a/src/commands/oauth-tls-preflight.doctor.test.ts +++ b/src/commands/oauth-tls-preflight.doctor.test.ts @@ -1,4 +1,5 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/config.js"; const note = vi.hoisted(() => vi.fn()); @@ -8,6 +9,20 @@ vi.mock("../terminal/note.js", () => ({ import { noteOpenAIOAuthTlsPrerequisites } from "./oauth-tls-preflight.js"; +function buildOpenAICodexOAuthConfig(): OpenClawConfig { + return { + auth: { + profiles: { + "openai-codex:user@example.com": { + provider: "openai-codex", + mode: "oauth", + email: "user@example.com", + }, + }, + }, + }; +} + describe("noteOpenAIOAuthTlsPrerequisites", () => { beforeEach(() => { note.mockClear(); @@ -23,7 +38,7 @@ describe("noteOpenAIOAuthTlsPrerequisites", () => { vi.stubGlobal("fetch", fetchMock); try { - await noteOpenAIOAuthTlsPrerequisites(); + await noteOpenAIOAuthTlsPrerequisites({ cfg: buildOpenAICodexOAuthConfig() }); } finally { vi.stubGlobal("fetch", originalFetch); } @@ -41,10 +56,40 @@ describe("noteOpenAIOAuthTlsPrerequisites", () => { vi.fn(async () => new Response("", { status: 400 })), ); try { - await noteOpenAIOAuthTlsPrerequisites(); + await noteOpenAIOAuthTlsPrerequisites({ cfg: buildOpenAICodexOAuthConfig() }); } finally { vi.stubGlobal("fetch", originalFetch); } expect(note).not.toHaveBeenCalled(); }); + + it("skips probe when OpenAI Codex OAuth is not configured", async () => { + const fetchMock = vi.fn(async () => new Response("", { status: 400 })); + const originalFetch = globalThis.fetch; + vi.stubGlobal("fetch", fetchMock); + + try { + await noteOpenAIOAuthTlsPrerequisites({ cfg: {} }); + } finally { + vi.stubGlobal("fetch", originalFetch); + } + + expect(fetchMock).not.toHaveBeenCalled(); + expect(note).not.toHaveBeenCalled(); + }); + + it("runs probe in deep mode even without OpenAI Codex OAuth profile", async () => { + const fetchMock = vi.fn(async () => new Response("", { status: 400 })); + const originalFetch = globalThis.fetch; + vi.stubGlobal("fetch", fetchMock); + + try { + await noteOpenAIOAuthTlsPrerequisites({ cfg: {}, deep: true }); + } finally { + vi.stubGlobal("fetch", originalFetch); + } + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(note).not.toHaveBeenCalled(); + }); }); diff --git a/src/commands/oauth-tls-preflight.ts b/src/commands/oauth-tls-preflight.ts index 43297801a4e..bf9e69b0519 100644 --- a/src/commands/oauth-tls-preflight.ts +++ b/src/commands/oauth-tls-preflight.ts @@ -1,5 +1,6 @@ import path from "node:path"; import { formatCliCommand } from "../cli/command-format.js"; +import type { OpenClawConfig } from "../config/config.js"; import { note } from "../terminal/note.js"; const TLS_CERT_ERROR_CODES = new Set([ @@ -53,7 +54,6 @@ function extractFailure(error: unknown): { const isTlsCertError = (code ? TLS_CERT_ERROR_CODES.has(code) : false) || TLS_CERT_ERROR_PATTERNS.some((pattern) => pattern.test(message)); - return { code, message, @@ -79,6 +79,26 @@ function resolveCertBundlePath(): string | null { return path.join(prefix, "etc", "openssl@3", "cert.pem"); } +function hasOpenAICodexOAuthProfile(cfg: OpenClawConfig): boolean { + const profiles = cfg.auth?.profiles; + if (!profiles) { + return false; + } + return Object.values(profiles).some( + (profile) => profile.provider === "openai-codex" && profile.mode === "oauth", + ); +} + +function shouldRunOpenAIOAuthTlsPrerequisites(params: { + cfg: OpenClawConfig; + deep?: boolean; +}): boolean { + if (params.deep === true) { + return true; + } + return hasOpenAICodexOAuthProfile(params.cfg); +} + export async function runOpenAIOAuthTlsPreflight(options?: { timeoutMs?: number; fetchImpl?: typeof fetch; @@ -129,7 +149,13 @@ export function formatOpenAIOAuthTlsPreflightFix( return lines.join("\n"); } -export async function noteOpenAIOAuthTlsPrerequisites(): Promise { +export async function noteOpenAIOAuthTlsPrerequisites(params: { + cfg: OpenClawConfig; + deep?: boolean; +}): Promise { + if (!shouldRunOpenAIOAuthTlsPrerequisites(params)) { + return; + } const result = await runOpenAIOAuthTlsPreflight({ timeoutMs: 4000 }); if (result.ok || result.kind !== "tls-cert") { return; diff --git a/src/commands/openai-codex-oauth.test.ts b/src/commands/openai-codex-oauth.test.ts index 00d7d48ab7c..cae7fb79459 100644 --- a/src/commands/openai-codex-oauth.test.ts +++ b/src/commands/openai-codex-oauth.test.ts @@ -105,6 +105,39 @@ describe("loginOpenAICodexOAuth", () => { ); }); + it("continues OAuth flow on non-certificate preflight failures", async () => { + const creds = { + provider: "openai-codex" as const, + access: "access-token", + refresh: "refresh-token", + expires: Date.now() + 60_000, + email: "user@example.com", + }; + mocks.runOpenAIOAuthTlsPreflight.mockResolvedValue({ + ok: false, + kind: "network", + message: "Client network socket disconnected before secure TLS connection was established", + }); + mocks.createVpsAwareOAuthHandlers.mockReturnValue({ + onAuth: vi.fn(), + onPrompt: vi.fn(), + }); + mocks.loginOpenAICodex.mockResolvedValue(creds); + + const { prompter } = createPrompter(); + const runtime = createRuntime(); + const result = await loginOpenAICodexOAuth({ + prompter, + runtime, + isRemote: false, + openUrl: async () => {}, + }); + + expect(result).toEqual(creds); + expect(mocks.loginOpenAICodex).toHaveBeenCalledOnce(); + expect(runtime.error).not.toHaveBeenCalledWith("tls fix"); + expect(prompter.note).not.toHaveBeenCalledWith("tls fix", "OAuth prerequisites"); + }); it("fails early with actionable message when TLS preflight fails", async () => { mocks.runOpenAIOAuthTlsPreflight.mockResolvedValue({ ok: false,