diff --git a/PR_DRAFT_OAUTH_TLS_PREFLIGHT.md b/PR_DRAFT_OAUTH_TLS_PREFLIGHT.md new file mode 100644 index 000000000..7c0893bae --- /dev/null +++ b/PR_DRAFT_OAUTH_TLS_PREFLIGHT.md @@ -0,0 +1,41 @@ +## 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.fast-path-mocks.ts b/src/commands/doctor.fast-path-mocks.ts index 33be4c188..045d8d21f 100644 --- a/src/commands/doctor.fast-path-mocks.ts +++ b/src/commands/doctor.fast-path-mocks.ts @@ -49,3 +49,7 @@ vi.mock("./doctor-ui.js", () => ({ vi.mock("./doctor-workspace-status.js", () => ({ noteWorkspaceStatus: vi.fn(), })); + +vi.mock("./oauth-tls-preflight.js", () => ({ + noteOpenAIOAuthTlsPrerequisites: vi.fn().mockResolvedValue(undefined), +})); diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index c62560530..3cf71b8ce 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -55,6 +55,7 @@ import { maybeRepairUiProtocolFreshness } from "./doctor-ui.js"; import { maybeOfferUpdateBeforeDoctor } from "./doctor-update.js"; import { noteWorkspaceStatus } from "./doctor-workspace-status.js"; import { MEMORY_SYSTEM_PROMPT, shouldSuggestMemorySystem } from "./doctor-workspace.js"; +import { noteOpenAIOAuthTlsPrerequisites } from "./oauth-tls-preflight.js"; import { applyWizardMetadata, printWizardHeader, randomToken } from "./onboard-helpers.js"; import { ensureSystemdUserLingerInteractive } from "./systemd-linger.js"; @@ -200,6 +201,7 @@ export async function doctorCommand( await noteMacLaunchctlGatewayEnvOverrides(cfg); await noteSecurityWarnings(cfg); + await noteOpenAIOAuthTlsPrerequisites(); 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 new file mode 100644 index 000000000..6f6cb6106 --- /dev/null +++ b/src/commands/oauth-tls-preflight.doctor.test.ts @@ -0,0 +1,50 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const note = vi.hoisted(() => vi.fn()); + +vi.mock("../terminal/note.js", () => ({ + note, +})); + +import { noteOpenAIOAuthTlsPrerequisites } from "./oauth-tls-preflight.js"; + +describe("noteOpenAIOAuthTlsPrerequisites", () => { + beforeEach(() => { + note.mockClear(); + }); + + it("emits OAuth TLS prerequisite guidance when cert chain validation fails", async () => { + const cause = new Error("unable to get local issuer certificate") as Error & { code?: string }; + cause.code = "UNABLE_TO_GET_ISSUER_CERT_LOCALLY"; + const fetchMock = vi.fn(async () => { + throw new TypeError("fetch failed", { cause }); + }); + const originalFetch = globalThis.fetch; + vi.stubGlobal("fetch", fetchMock); + + try { + await noteOpenAIOAuthTlsPrerequisites(); + } finally { + vi.stubGlobal("fetch", originalFetch); + } + + expect(note).toHaveBeenCalledTimes(1); + const [message, title] = note.mock.calls[0] as [string, string]; + expect(title).toBe("OAuth TLS prerequisites"); + expect(message).toContain("brew postinstall ca-certificates"); + }); + + it("stays quiet when preflight succeeds", async () => { + const originalFetch = globalThis.fetch; + vi.stubGlobal( + "fetch", + vi.fn(async () => new Response("", { status: 400 })), + ); + try { + await noteOpenAIOAuthTlsPrerequisites(); + } finally { + vi.stubGlobal("fetch", originalFetch); + } + expect(note).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/oauth-tls-preflight.test.ts b/src/commands/oauth-tls-preflight.test.ts new file mode 100644 index 000000000..892800e3e --- /dev/null +++ b/src/commands/oauth-tls-preflight.test.ts @@ -0,0 +1,46 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + formatOpenAIOAuthTlsPreflightFix, + runOpenAIOAuthTlsPreflight, +} from "./oauth-tls-preflight.js"; + +describe("runOpenAIOAuthTlsPreflight", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns ok when OpenAI auth endpoint is reachable", async () => { + const fetchImpl = vi.fn(async () => new Response("", { status: 400 })); + const result = await runOpenAIOAuthTlsPreflight({ fetchImpl, timeoutMs: 20 }); + expect(result).toEqual({ ok: true }); + }); + + it("classifies TLS trust failures from fetch cause code", async () => { + const tlsFetchImpl = vi.fn(async () => { + const cause = new Error("unable to get local issuer certificate") as Error & { + code?: string; + }; + cause.code = "UNABLE_TO_GET_ISSUER_CERT_LOCALLY"; + throw new TypeError("fetch failed", { cause }); + }); + const result = await runOpenAIOAuthTlsPreflight({ fetchImpl: tlsFetchImpl, timeoutMs: 20 }); + expect(result).toMatchObject({ + ok: false, + kind: "tls-cert", + code: "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + }); + }); +}); + +describe("formatOpenAIOAuthTlsPreflightFix", () => { + it("includes remediation commands for TLS failures", () => { + const text = formatOpenAIOAuthTlsPreflightFix({ + ok: false, + kind: "tls-cert", + code: "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + message: "unable to get local issuer certificate", + }); + expect(text).toContain("brew postinstall ca-certificates"); + expect(text).toContain("brew postinstall openssl@3"); + }); +}); diff --git a/src/commands/oauth-tls-preflight.ts b/src/commands/oauth-tls-preflight.ts new file mode 100644 index 000000000..7ef234f5a --- /dev/null +++ b/src/commands/oauth-tls-preflight.ts @@ -0,0 +1,139 @@ +import path from "node:path"; +import { formatCliCommand } from "../cli/command-format.js"; +import { note } from "../terminal/note.js"; + +const TLS_CERT_ERROR_CODES = new Set([ + "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + "UNABLE_TO_VERIFY_LEAF_SIGNATURE", + "CERT_HAS_EXPIRED", + "DEPTH_ZERO_SELF_SIGNED_CERT", + "SELF_SIGNED_CERT_IN_CHAIN", + "ERR_TLS_CERT_ALTNAME_INVALID", +]); + +const TLS_CERT_ERROR_PATTERNS = [ + /unable to get local issuer certificate/i, + /unable to verify the first certificate/i, + /self[- ]signed certificate/i, + /certificate has expired/i, + /tls/i, +]; + +const OPENAI_AUTH_PROBE_URL = + "https://auth.openai.com/oauth/authorize?response_type=code&client_id=openclaw-preflight&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&scope=openid+profile+email"; + +type PreflightFailureKind = "tls-cert" | "network"; + +export type OpenAIOAuthTlsPreflightResult = + | { ok: true } + | { + ok: false; + kind: PreflightFailureKind; + code?: string; + message: string; + }; + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" ? (value as Record) : null; +} + +function extractFailure(error: unknown): { + code?: string; + message: string; + kind: PreflightFailureKind; +} { + const root = asRecord(error); + const rootCause = asRecord(root?.cause); + const code = typeof rootCause?.code === "string" ? rootCause.code : undefined; + const message = + typeof rootCause?.message === "string" + ? rootCause.message + : typeof root?.message === "string" + ? root.message + : String(error); + const isTlsCertError = + (code ? TLS_CERT_ERROR_CODES.has(code) : false) || + TLS_CERT_ERROR_PATTERNS.some((pattern) => pattern.test(message)); + + return { + code, + message, + kind: isTlsCertError ? "tls-cert" : "network", + }; +} + +function resolveHomebrewPrefixFromExecPath(execPath: string): string | null { + const marker = `${path.sep}Cellar${path.sep}`; + const idx = execPath.indexOf(marker); + if (idx > 0) { + return execPath.slice(0, idx); + } + const envPrefix = process.env.HOMEBREW_PREFIX?.trim(); + return envPrefix ? envPrefix : null; +} + +function resolveCertBundlePath(): string | null { + const prefix = resolveHomebrewPrefixFromExecPath(process.execPath); + if (!prefix) { + return null; + } + return path.join(prefix, "etc", "openssl@3", "cert.pem"); +} + +export async function runOpenAIOAuthTlsPreflight(options?: { + timeoutMs?: number; + fetchImpl?: typeof fetch; +}): Promise { + const timeoutMs = options?.timeoutMs ?? 5000; + const fetchImpl = options?.fetchImpl ?? fetch; + try { + await fetchImpl(OPENAI_AUTH_PROBE_URL, { + method: "GET", + redirect: "manual", + signal: AbortSignal.timeout(timeoutMs), + }); + return { ok: true }; + } catch (error) { + const failure = extractFailure(error); + return { + ok: false, + kind: failure.kind, + code: failure.code, + message: failure.message, + }; + } +} + +export function formatOpenAIOAuthTlsPreflightFix( + result: Exclude, +): string { + if (result.kind !== "tls-cert") { + return [ + "OpenAI OAuth prerequisites check failed due to a network error before the browser flow.", + `Cause: ${result.message}`, + "Verify DNS/firewall/proxy access to auth.openai.com and retry.", + ].join("\n"); + } + const certBundlePath = resolveCertBundlePath(); + const lines = [ + "OpenAI OAuth prerequisites check failed: Node/OpenSSL cannot validate TLS certificates.", + `Cause: ${result.code ? `${result.code} (${result.message})` : result.message}`, + "", + "Fix (Homebrew Node/OpenSSL):", + `- ${formatCliCommand("brew postinstall ca-certificates")}`, + `- ${formatCliCommand("brew postinstall openssl@3")}`, + ]; + if (certBundlePath) { + lines.push(`- Verify cert bundle exists: ${certBundlePath}`); + } + lines.push("- Retry the OAuth login flow."); + return lines.join("\n"); +} + +export async function noteOpenAIOAuthTlsPrerequisites(): Promise { + const result = await runOpenAIOAuthTlsPreflight({ timeoutMs: 4000 }); + if (result.ok || result.kind !== "tls-cert") { + return; + } + note(formatOpenAIOAuthTlsPreflightFix(result), "OAuth TLS prerequisites"); +} diff --git a/src/commands/openai-codex-oauth.test.ts b/src/commands/openai-codex-oauth.test.ts index 968105d35..00d7d48ab 100644 --- a/src/commands/openai-codex-oauth.test.ts +++ b/src/commands/openai-codex-oauth.test.ts @@ -5,6 +5,8 @@ import type { WizardPrompter } from "../wizard/prompts.js"; const mocks = vi.hoisted(() => ({ loginOpenAICodex: vi.fn(), createVpsAwareOAuthHandlers: vi.fn(), + runOpenAIOAuthTlsPreflight: vi.fn(), + formatOpenAIOAuthTlsPreflightFix: vi.fn(), })); vi.mock("@mariozechner/pi-ai", () => ({ @@ -15,6 +17,11 @@ vi.mock("./oauth-flow.js", () => ({ createVpsAwareOAuthHandlers: mocks.createVpsAwareOAuthHandlers, })); +vi.mock("./oauth-tls-preflight.js", () => ({ + runOpenAIOAuthTlsPreflight: mocks.runOpenAIOAuthTlsPreflight, + formatOpenAIOAuthTlsPreflightFix: mocks.formatOpenAIOAuthTlsPreflightFix, +})); + import { loginOpenAICodexOAuth } from "./openai-codex-oauth.js"; function createPrompter() { @@ -39,6 +46,8 @@ function createRuntime(): RuntimeEnv { describe("loginOpenAICodexOAuth", () => { beforeEach(() => { vi.clearAllMocks(); + mocks.runOpenAIOAuthTlsPreflight.mockResolvedValue({ ok: true }); + mocks.formatOpenAIOAuthTlsPreflightFix.mockReturnValue("tls fix"); }); it("returns credentials on successful oauth login", async () => { @@ -95,4 +104,33 @@ describe("loginOpenAICodexOAuth", () => { "OAuth help", ); }); + + it("fails early with actionable message when TLS preflight fails", async () => { + mocks.runOpenAIOAuthTlsPreflight.mockResolvedValue({ + ok: false, + kind: "tls-cert", + code: "UNABLE_TO_GET_ISSUER_CERT_LOCALLY", + message: "unable to get local issuer certificate", + }); + mocks.formatOpenAIOAuthTlsPreflightFix.mockReturnValue("Run brew postinstall openssl@3"); + + const { prompter } = createPrompter(); + const runtime = createRuntime(); + + await expect( + loginOpenAICodexOAuth({ + prompter, + runtime, + isRemote: false, + openUrl: async () => {}, + }), + ).rejects.toThrow("unable to get local issuer certificate"); + + expect(mocks.loginOpenAICodex).not.toHaveBeenCalled(); + expect(runtime.error).toHaveBeenCalledWith("Run brew postinstall openssl@3"); + expect(prompter.note).toHaveBeenCalledWith( + "Run brew postinstall openssl@3", + "OAuth prerequisites", + ); + }); }); diff --git a/src/commands/openai-codex-oauth.ts b/src/commands/openai-codex-oauth.ts index 9032170fa..a9fbc1849 100644 --- a/src/commands/openai-codex-oauth.ts +++ b/src/commands/openai-codex-oauth.ts @@ -3,6 +3,10 @@ import { loginOpenAICodex } from "@mariozechner/pi-ai"; import type { RuntimeEnv } from "../runtime.js"; import type { WizardPrompter } from "../wizard/prompts.js"; import { createVpsAwareOAuthHandlers } from "./oauth-flow.js"; +import { + formatOpenAIOAuthTlsPreflightFix, + runOpenAIOAuthTlsPreflight, +} from "./oauth-tls-preflight.js"; export async function loginOpenAICodexOAuth(params: { prompter: WizardPrompter; @@ -12,6 +16,13 @@ export async function loginOpenAICodexOAuth(params: { localBrowserMessage?: string; }): Promise { const { prompter, runtime, isRemote, openUrl, localBrowserMessage } = params; + const preflight = await runOpenAIOAuthTlsPreflight(); + if (!preflight.ok && preflight.kind === "tls-cert") { + const hint = formatOpenAIOAuthTlsPreflightFix(preflight); + runtime.error(hint); + await prompter.note(hint, "OAuth prerequisites"); + throw new Error(preflight.message); + } await prompter.note( isRemote