Auth: gate OpenAI OAuth TLS preflight in doctor

This commit is contained in:
George Pickett
2026-03-02 13:18:17 -08:00
parent dc8a56c857
commit 1f24323583
5 changed files with 112 additions and 46 deletions

View File

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

View File

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

View File

@@ -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();
});
});

View File

@@ -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<void> {
export async function noteOpenAIOAuthTlsPrerequisites(params: {
cfg: OpenClawConfig;
deep?: boolean;
}): Promise<void> {
if (!shouldRunOpenAIOAuthTlsPrerequisites(params)) {
return;
}
const result = await runOpenAIOAuthTlsPreflight({ timeoutMs: 4000 });
if (result.ok || result.kind !== "tls-cert") {
return;

View File

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