fix(auth): quiet codex oauth manual fallback

This commit is contained in:
Vincent Koc
2026-05-04 00:03:55 -07:00
parent 826786b114
commit 3c971255fa
3 changed files with 131 additions and 14 deletions

View File

@@ -61,6 +61,7 @@ Docs: https://docs.openclaw.ai
- Discord: clear stale startup probe bot/application status when the async bot probe throws, not just when it returns a degraded probe result. Thanks @vincentkoc.
- Web search: scope explicit bundled `web_search` provider runtime loading through manifest ownership, so selecting DuckDuckGo/Gemini/etc. does not import unrelated bundled providers or log their optional dependency failures. Thanks @vincentkoc.
- Plugins/discovery: demote the source-only TypeScript runtime check on already-installed `origin: "global"` plugin packages from a config-blocking error to a warning and let the runtime fall through to the TypeScript source via jiti, so a single broken installed package no longer blocks `plugins install` for unrelated plugins; install-time rejection of newly-installed source-only packages is unchanged. Thanks @romneyda.
- Providers/OpenAI Codex: stop the OAuth progress spinner before showing the manual redirect paste prompt, so callback timeouts do not spam `Browser callback did not finish` across terminals.
- Release/beta smoke: resolve the dispatched Telegram beta E2E run from `gh run list` when `gh workflow run` returns no run URL, so the maintainer helper does not fail immediately after dispatch. Thanks @vincentkoc.
- Media/images: keep HEIC/HEIF attachments fail-closed when optional Sharp conversion is unavailable instead of sending originals that still need conversion. Thanks @vincentkoc.
- Google Meet: fork the caller's current agent transcript into agent-mode meeting consultant sessions, so Meet replies inherit the context from the tool call that joined the meeting.

View File

@@ -34,12 +34,13 @@ type CodexLoginOptions = {
function createPrompter() {
const spin = { update: vi.fn(), stop: vi.fn() };
const text = vi.fn(async () => "http://localhost:1455/auth/callback?code=test");
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"),
text,
};
return { prompter: prompter as unknown as WizardPrompter, spin };
return { prompter: prompter as unknown as WizardPrompter, spin, text };
}
function createRuntime(): RuntimeEnv {
@@ -222,7 +223,7 @@ describe("loginOpenAICodexOAuth", () => {
it("waits briefly before prompting for manual input after the local browser flow starts", async () => {
vi.useFakeTimers();
const { prompter } = createPrompter();
const { prompter, spin, text } = createPrompter();
const runtime = createRuntime();
mocks.loginOpenAICodex.mockImplementation(async (opts: CodexLoginOptions) => {
await startCodexAuth(opts);
@@ -252,6 +253,54 @@ describe("loginOpenAICodexOAuth", () => {
message: "Paste the authorization code (or full redirect URL):",
validate: expect.any(Function),
});
expect(spin.stop).toHaveBeenCalledWith("Manual OAuth entry required");
expect(spin.stop.mock.invocationCallOrder[0]).toBeLessThan(
text.mock.invocationCallOrder[0] ?? 0,
);
expect(runtime.log).toHaveBeenCalledWith(
"OpenAI Codex OAuth callback did not arrive within 15000ms; switching to manual entry (callback_timeout).",
);
vi.useRealTimers();
});
it("reuses one local manual prompt when the oauth helper repeats fallback calls", async () => {
vi.useFakeTimers();
const { prompter, spin, text } = createPrompter();
const runtime = createRuntime();
mocks.loginOpenAICodex.mockImplementation(async (opts: CodexLoginOptions) => {
await startCodexAuth(opts);
const firstManualPromise = opts.onManualCodeInput?.();
const secondManualPromise = opts.onManualCodeInput?.();
await vi.advanceTimersByTimeAsync(16_000);
const [firstManualCode, secondManualCode] = await Promise.all([
firstManualPromise,
secondManualPromise,
]);
expect(secondManualCode).toBe(firstManualCode);
return createCodexCredentials({ manualCode: firstManualCode });
});
await expect(
loginOpenAICodexOAuth({
prompter,
runtime,
isRemote: false,
openUrl: async () => {},
}),
).resolves.toMatchObject({
access: "access-token",
refresh: "refresh-token",
});
expect(text).toHaveBeenCalledOnce();
expect(spin.stop).toHaveBeenCalledWith("Manual OAuth entry required");
expect(
spin.update.mock.calls.filter(
([message]) =>
message === "Browser callback did not finish. Paste the redirect URL to continue…",
),
).toHaveLength(1);
expect(runtime.log).toHaveBeenCalledTimes(2);
expect(runtime.log).toHaveBeenCalledWith(
"OpenAI Codex OAuth callback did not arrive within 15000ms; switching to manual entry (callback_timeout).",
);
@@ -326,7 +375,7 @@ describe("loginOpenAICodexOAuth", () => {
it("prompts for manual input immediately when the local callback flow never starts", async () => {
vi.useFakeTimers();
const { prompter } = createPrompter();
const { prompter, spin, text } = createPrompter();
const runtime = createRuntime();
mocks.loginOpenAICodex.mockImplementation(
async (opts: { onManualCodeInput?: () => Promise<string> }) => {
@@ -352,6 +401,49 @@ describe("loginOpenAICodexOAuth", () => {
message: "Paste the authorization code (or full redirect URL):",
validate: expect.any(Function),
});
expect(spin.stop).toHaveBeenCalledWith("Manual OAuth entry required");
expect(spin.stop.mock.invocationCallOrder[0]).toBeLessThan(
text.mock.invocationCallOrder[0] ?? 0,
);
expect(vi.getTimerCount()).toBe(0);
vi.useRealTimers();
});
it("reuses one immediate manual prompt when the local callback flow never starts", async () => {
vi.useFakeTimers();
const { prompter, spin, text } = createPrompter();
const runtime = createRuntime();
mocks.loginOpenAICodex.mockImplementation(async (opts: CodexLoginOptions) => {
expect(opts.onManualCodeInput).toBeTypeOf("function");
const [firstManualCode, secondManualCode] = await Promise.all([
opts.onManualCodeInput?.(),
opts.onManualCodeInput?.(),
]);
expect(secondManualCode).toBe(firstManualCode);
return createCodexCredentials({ manualCode: firstManualCode });
});
await expect(
loginOpenAICodexOAuth({
prompter,
runtime,
isRemote: false,
openUrl: async () => {},
}),
).resolves.toMatchObject({
access: "access-token",
refresh: "refresh-token",
});
expect(text).toHaveBeenCalledOnce();
expect(spin.stop).toHaveBeenCalledWith("Manual OAuth entry required");
expect(
spin.update.mock.calls.filter(
([message]) =>
message === "Local OAuth callback was unavailable. Paste the redirect URL to continue…",
),
).toHaveLength(1);
expect(runtime.log).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
vi.useRealTimers();
});

View File

@@ -77,25 +77,30 @@ function createManualCodeInputHandler(params: {
isRemote: boolean;
onPrompt: (prompt: OAuthPrompt) => Promise<string>;
runtime: RuntimeEnv;
spin: ReturnType<WizardPrompter["progress"]>;
updateProgress: (message: string) => void;
stopProgress: (message?: string) => void;
waitForLoginToSettle: Promise<void>;
hasBrowserAuthStarted: () => boolean;
}): (() => Promise<string>) | undefined {
let manualFallbackPromise: Promise<string> | undefined;
if (params.isRemote) {
return async () =>
await params.onPrompt({
return async () => {
manualFallbackPromise ??= params.onPrompt({
message: manualInputPromptMessage,
});
return await manualFallbackPromise;
};
}
return async () => {
const runLocalManualFallback = async () => {
if (!params.hasBrowserAuthStarted()) {
params.spin.update(
params.updateProgress(
"Local OAuth callback was unavailable. Paste the redirect URL to continue…",
);
params.runtime.log(
"OpenAI Codex OAuth local callback did not start; switching to manual entry immediately.",
);
params.stopProgress("Manual OAuth entry required");
return await params.onPrompt({
message: manualInputPromptMessage,
});
@@ -121,14 +126,20 @@ function createManualCodeInputHandler(params: {
return await createNeverSettlingPromptResult();
}
params.spin.update("Browser callback did not finish. Paste the redirect URL to continue…");
params.updateProgress("Browser callback did not finish. Paste the redirect URL to continue…");
params.runtime.log(
`OpenAI Codex OAuth callback did not arrive within ${localManualFallbackDelayMs}ms; switching to manual entry (callback_timeout).`,
);
params.stopProgress("Manual OAuth entry required");
return await params.onPrompt({
message: manualInputPromptMessage,
});
};
return async () => {
manualFallbackPromise ??= runLocalManualFallback();
return await manualFallbackPromise;
};
}
export async function loginOpenAICodexOAuth(params: {
@@ -166,6 +177,18 @@ export async function loginOpenAICodexOAuth(params: {
);
const spin = prompter.progress("Starting OAuth flow…");
let progressActive = true;
const updateProgress = (message: string) => {
if (progressActive) {
spin.update(message);
}
};
const stopProgress = (message?: string) => {
if (progressActive) {
progressActive = false;
spin.stop(message);
}
};
let browserAuthStarted = false;
let markLoginSettled!: () => void;
const waitForLoginToSettle = new Promise<void>((resolve) => {
@@ -194,16 +217,17 @@ export async function loginOpenAICodexOAuth(params: {
isRemote,
onPrompt,
runtime,
spin,
updateProgress,
stopProgress,
waitForLoginToSettle,
hasBrowserAuthStarted: () => browserAuthStarted,
}),
onProgress: (msg: string) => spin.update(msg),
onProgress: (msg: string) => updateProgress(msg),
});
spin.stop("OpenAI OAuth complete");
stopProgress("OpenAI OAuth complete");
return creds ?? null;
} catch (err) {
spin.stop("OpenAI OAuth failed");
stopProgress("OpenAI OAuth failed");
const rewrittenError = rewriteOpenAICodexOAuthError(err);
runtime.error(String(rewrittenError));
await prompter.note("Trouble with OAuth? See https://docs.openclaw.ai/start/faq", "OAuth help");