fix(auth): support remote Codex OAuth manual input (#51631)

* fix(auth): support remote codex oauth manual input

* fix: support remote Codex OAuth manual input (#51631) (thanks @cash-echo-bot)

---------

Co-authored-by: Cash Williams <cashwilliams@gmail.com>
Co-authored-by: Ayaan Zaidi <hi@obviy.us>
This commit is contained in:
Echo
2026-03-21 09:10:54 -05:00
committed by GitHub
parent 59c4059647
commit 11aff6ed72
3 changed files with 56 additions and 31 deletions

View File

@@ -118,6 +118,7 @@ Docs: https://docs.openclaw.ai
- Control UI/chat sessions: show human-readable labels in the grouped session dropdown again, keep unique scoped fallbacks when metadata is missing, and disambiguate duplicate labels only when needed. (#45130) Thanks @luzhidong.
- Control UI: scope persisted session selection per gateway, prevent stale session bleed across tokenized gateway opens, and cap stored gateway session history. (#47453) Thanks @sallyom.
- Control UI/dashboard: preserve structured gateway shutdown reasons across restart disconnects so config-triggered restarts no longer fall back to `disconnected (1006): no reason`. (#46580) Fixes #46532. Thanks @vincentkoc.
- Models/OpenAI Codex OAuth: start the remote manual-input race for Codex login and keep the pasted-input prompt aligned with the actual accepted values, so remote/VPS auth no longer stalls waiting on an unreachable localhost callback. (#51631) Thanks @cash-echo-bot.
- Android/chat: theme the thinking dropdown and TLS trust dialogs explicitly so popup surfaces match the active app theme instead of falling back to mismatched Material defaults.
- Group mention gating: reject invalid and unsafe nested-repetition `mentionPatterns`, reuse the shared safe config-regex compiler across mention stripping and detection, and cache strip-time regex compilation so noisy groups avoid repeated recompiles.
- Browser/profiles: drop the auto-created `chrome-relay` browser profile; users who need the Chrome extension relay must now create their own profile via `openclaw browser create-profile`. (#46596) Fixes #45777. Thanks @odysseus0.

View File

@@ -4,7 +4,6 @@ import type { WizardPrompter } from "../wizard/prompts.js";
const mocks = vi.hoisted(() => ({
loginOpenAICodex: vi.fn(),
createVpsAwareOAuthHandlers: vi.fn(),
runOpenAIOAuthTlsPreflight: vi.fn(),
formatOpenAIOAuthTlsPreflightFix: vi.fn(),
}));
@@ -13,22 +12,19 @@ vi.mock("@mariozechner/pi-ai/oauth", () => ({
loginOpenAICodex: mocks.loginOpenAICodex,
}));
vi.mock("./oauth-flow.js", () => ({
createVpsAwareOAuthHandlers: mocks.createVpsAwareOAuthHandlers,
}));
vi.mock("./oauth-tls-preflight.js", () => ({
vi.mock("../plugins/provider-openai-codex-oauth-tls.js", () => ({
runOpenAIOAuthTlsPreflight: mocks.runOpenAIOAuthTlsPreflight,
formatOpenAIOAuthTlsPreflightFix: mocks.formatOpenAIOAuthTlsPreflightFix,
}));
import { loginOpenAICodexOAuth } from "./openai-codex-oauth.js";
import { loginOpenAICodexOAuth } from "../plugins/provider-openai-codex-oauth.js";
function createPrompter() {
const spin = { update: vi.fn(), stop: vi.fn() };
const prompter: Pick<WizardPrompter, "note" | "progress"> = {
const prompter: Pick<WizardPrompter, "note" | "progress" | "text"> = {
note: vi.fn(async () => {}),
progress: vi.fn(() => spin),
text: vi.fn(async () => "http://localhost:1455/auth/callback?code=test"),
};
return { prompter: prompter as unknown as WizardPrompter, spin };
}
@@ -43,14 +39,17 @@ function createRuntime(): RuntimeEnv {
};
}
async function runCodexOAuth(params: { isRemote: boolean }) {
async function runCodexOAuth(params: {
isRemote: boolean;
openUrl?: (url: string) => Promise<void>;
}) {
const { prompter, spin } = createPrompter();
const runtime = createRuntime();
const result = await loginOpenAICodexOAuth({
prompter,
runtime,
isRemote: params.isRemote,
openUrl: async () => {},
openUrl: params.openUrl ?? (async () => {}),
});
return { result, prompter, spin, runtime };
}
@@ -70,10 +69,6 @@ describe("loginOpenAICodexOAuth", () => {
expires: Date.now() + 60_000,
email: "user@example.com",
};
mocks.createVpsAwareOAuthHandlers.mockReturnValue({
onAuth: vi.fn(),
onPrompt: vi.fn(),
});
mocks.loginOpenAICodex.mockResolvedValue(creds);
const { result, spin, runtime } = await runCodexOAuth({ isRemote: false });
@@ -92,11 +87,6 @@ describe("loginOpenAICodexOAuth", () => {
expires: Date.now() + 60_000,
email: "user@example.com",
};
const onAuthSpy = vi.fn();
mocks.createVpsAwareOAuthHandlers.mockReturnValue({
onAuth: onAuthSpy,
onPrompt: vi.fn(),
});
mocks.loginOpenAICodex.mockImplementation(
async (opts: { onAuth: (event: { url: string }) => Promise<void> }) => {
await opts.onAuth({
@@ -106,20 +96,18 @@ describe("loginOpenAICodexOAuth", () => {
},
);
await runCodexOAuth({ isRemote: false });
const openUrl = vi.fn(async () => {});
const { runtime } = await runCodexOAuth({ isRemote: false, openUrl });
expect(onAuthSpy).toHaveBeenCalledTimes(1);
const event = onAuthSpy.mock.calls[0]?.[0] as { url: string };
expect(event.url).toBe(
expect(openUrl).toHaveBeenCalledWith(
"https://auth.openai.com/oauth/authorize?scope=openid+profile+email+offline_access&state=abc",
);
expect(runtime.log).toHaveBeenCalledWith(
"Open: https://auth.openai.com/oauth/authorize?scope=openid+profile+email+offline_access&state=abc",
);
});
it("reports oauth errors and rethrows", async () => {
mocks.createVpsAwareOAuthHandlers.mockReturnValue({
onAuth: vi.fn(),
onPrompt: vi.fn(),
});
mocks.loginOpenAICodex.mockRejectedValue(new Error("oauth failed"));
const { prompter, spin } = createPrompter();
@@ -141,6 +129,37 @@ describe("loginOpenAICodexOAuth", () => {
);
});
it("passes manual code input hook for remote oauth flows", async () => {
const creds = {
provider: "openai-codex" as const,
access: "access-token",
refresh: "refresh-token",
expires: Date.now() + 60_000,
email: "user@example.com",
};
mocks.loginOpenAICodex.mockImplementation(
async (opts: {
onAuth: (event: { url: string }) => Promise<void>;
onManualCodeInput?: () => Promise<string>;
}) => {
await opts.onAuth({
url: "https://auth.openai.com/oauth/authorize?state=abc",
});
expect(opts.onManualCodeInput).toBeTypeOf("function");
await expect(opts.onManualCodeInput?.()).resolves.toContain("code=test");
return creds;
},
);
const { result, prompter } = await runCodexOAuth({ isRemote: true });
expect(result).toEqual(creds);
expect(prompter.text).toHaveBeenCalledWith({
message: "Paste the authorization code (or full redirect URL):",
validate: expect.any(Function),
});
});
it("continues OAuth flow on non-certificate preflight failures", async () => {
const creds = {
provider: "openai-codex" as const,
@@ -154,10 +173,6 @@ describe("loginOpenAICodexOAuth", () => {
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 { result, prompter, runtime } = await runCodexOAuth({ isRemote: false });

View File

@@ -7,6 +7,8 @@ import {
runOpenAIOAuthTlsPreflight,
} from "./provider-openai-codex-oauth-tls.js";
const manualInputPromptMessage = "Paste the authorization code (or full redirect URL):";
export async function loginOpenAICodexOAuth(params: {
prompter: WizardPrompter;
runtime: RuntimeEnv;
@@ -47,11 +49,18 @@ export async function loginOpenAICodexOAuth(params: {
spin,
openUrl,
localBrowserMessage: localBrowserMessage ?? "Complete sign-in in browser…",
manualPromptMessage: manualInputPromptMessage,
});
const creds = await loginOpenAICodex({
onAuth: baseOnAuth,
onPrompt,
onManualCodeInput: isRemote
? async () =>
await onPrompt({
message: manualInputPromptMessage,
})
: undefined,
onProgress: (msg: string) => spin.update(msg),
});
spin.stop("OpenAI OAuth complete");