mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-18 17:57:26 +00:00
Auth: gate OpenAI OAuth TLS preflight in doctor
This commit is contained in:
@@ -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.
|
||||
@@ -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({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user