Merge 421346a7ac2ea382ac9c087b49c8d63b62231316 into 598f1826d8b2bc969aace2c6459824737667218c

This commit is contained in:
Shawn Kim 2026-03-21 12:27:30 +08:00 committed by GitHub
commit a6bbc4e690
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 244 additions and 5 deletions

View File

@ -0,0 +1,190 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
const mocks = vi.hoisted(() => ({
loginOpenAICodex: vi.fn(),
createVpsAwareOAuthHandlers: vi.fn(),
runOpenAIOAuthTlsPreflight: vi.fn(),
formatOpenAIOAuthTlsPreflightFix: vi.fn(),
tryListenOnPort: vi.fn(),
describePortOwner: vi.fn(),
}));
vi.mock("@mariozechner/pi-ai/oauth", () => ({
loginOpenAICodex: mocks.loginOpenAICodex,
}));
vi.mock("./provider-oauth-flow.js", () => ({
createVpsAwareOAuthHandlers: mocks.createVpsAwareOAuthHandlers,
}));
vi.mock("./provider-openai-codex-oauth-tls.js", () => ({
runOpenAIOAuthTlsPreflight: mocks.runOpenAIOAuthTlsPreflight,
formatOpenAIOAuthTlsPreflightFix: mocks.formatOpenAIOAuthTlsPreflightFix,
}));
vi.mock("../infra/ports-probe.js", () => ({
tryListenOnPort: mocks.tryListenOnPort,
}));
vi.mock("../infra/ports.js", () => ({
describePortOwner: mocks.describePortOwner,
}));
import { loginOpenAICodexOAuth } from "./provider-openai-codex-oauth.js";
function createPrompter() {
const spin = { update: vi.fn(), stop: vi.fn() };
const prompter: Pick<WizardPrompter, "note" | "progress"> = {
note: vi.fn(async () => {}),
progress: vi.fn(() => spin),
};
return { prompter: prompter as unknown as WizardPrompter, spin };
}
function createRuntime(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn((code: number) => {
throw new Error(`exit:${code}`);
}),
};
}
async function runCodexOAuth(params: { isRemote: boolean }) {
const { prompter, spin } = createPrompter();
const runtime = createRuntime();
const result = await loginOpenAICodexOAuth({
prompter,
runtime,
isRemote: params.isRemote,
openUrl: async () => {},
});
return { result, prompter, spin, runtime };
}
describe("loginOpenAICodexOAuth port conflict", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.runOpenAIOAuthTlsPreflight.mockResolvedValue({ ok: true });
mocks.tryListenOnPort.mockResolvedValue(undefined);
mocks.describePortOwner.mockResolvedValue(undefined);
});
it("does not activate manual fallback when the callback port is free", async () => {
const creds = {
provider: "openai-codex" as const,
access: "a",
refresh: "r",
expires: Date.now() + 60_000,
email: "u@example.com",
};
mocks.createVpsAwareOAuthHandlers.mockReturnValue({ onAuth: vi.fn(), onPrompt: vi.fn() });
mocks.loginOpenAICodex.mockResolvedValue(creds);
await runCodexOAuth({ isRemote: false });
expect(mocks.tryListenOnPort).toHaveBeenCalledWith({
port: 1455,
host: "127.0.0.1",
exclusive: true,
});
expect(mocks.loginOpenAICodex.mock.calls[0]?.[0]?.onManualCodeInput).toBeUndefined();
});
it("enables immediate manual fallback when the callback port is occupied with known owner", async () => {
const creds = {
provider: "openai-codex" as const,
access: "a",
refresh: "r",
expires: Date.now() + 60_000,
email: "u@example.com",
};
mocks.tryListenOnPort.mockRejectedValue(
Object.assign(new Error("address in use"), { code: "EADDRINUSE" }),
);
mocks.describePortOwner.mockResolvedValue("Code Helper (Plugin)");
mocks.createVpsAwareOAuthHandlers.mockReturnValue({ onAuth: vi.fn(), onPrompt: vi.fn() });
mocks.loginOpenAICodex.mockResolvedValue(creds);
const { prompter } = await runCodexOAuth({ isRemote: false });
expect(mocks.describePortOwner).toHaveBeenCalledWith(1455);
expect(mocks.loginOpenAICodex.mock.calls[0]?.[0]?.onManualCodeInput).toEqual(
expect.any(Function),
);
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("Code Helper (Plugin)"),
"OpenAI Codex OAuth",
);
});
it("enables manual fallback without owner details when describePortOwner returns undefined", async () => {
const creds = {
provider: "openai-codex" as const,
access: "a",
refresh: "r",
expires: Date.now() + 60_000,
email: "u@example.com",
};
mocks.tryListenOnPort.mockRejectedValue(
Object.assign(new Error("address in use"), { code: "EADDRINUSE" }),
);
mocks.describePortOwner.mockResolvedValue(undefined);
mocks.createVpsAwareOAuthHandlers.mockReturnValue({ onAuth: vi.fn(), onPrompt: vi.fn() });
mocks.loginOpenAICodex.mockResolvedValue(creds);
const { prompter } = await runCodexOAuth({ isRemote: false });
expect(mocks.loginOpenAICodex.mock.calls[0]?.[0]?.onManualCodeInput).toEqual(
expect.any(Function),
);
expect(prompter.note).toHaveBeenCalledWith(
expect.stringContaining("localhost:1455"),
"OpenAI Codex OAuth",
);
expect(prompter.note).not.toHaveBeenCalledWith(
expect.stringContaining("Port listener details:"),
"OpenAI Codex OAuth",
);
});
it("ignores non-EADDRINUSE probe failures and proceeds without manual fallback", async () => {
const creds = {
provider: "openai-codex" as const,
access: "a",
refresh: "r",
expires: Date.now() + 60_000,
email: "u@example.com",
};
mocks.tryListenOnPort.mockRejectedValue(
Object.assign(new Error("address not available"), { code: "EADDRNOTAVAIL" }),
);
mocks.createVpsAwareOAuthHandlers.mockReturnValue({ onAuth: vi.fn(), onPrompt: vi.fn() });
mocks.loginOpenAICodex.mockResolvedValue(creds);
await runCodexOAuth({ isRemote: false });
expect(mocks.describePortOwner).not.toHaveBeenCalled();
expect(mocks.loginOpenAICodex.mock.calls[0]?.[0]?.onManualCodeInput).toBeUndefined();
});
it("skips port preflight in remote mode", async () => {
const creds = {
provider: "openai-codex" as const,
access: "a",
refresh: "r",
expires: Date.now() + 60_000,
email: "u@example.com",
};
mocks.createVpsAwareOAuthHandlers.mockReturnValue({ onAuth: vi.fn(), onPrompt: vi.fn() });
mocks.loginOpenAICodex.mockResolvedValue(creds);
await runCodexOAuth({ isRemote: true });
expect(mocks.tryListenOnPort).not.toHaveBeenCalled();
expect(mocks.describePortOwner).not.toHaveBeenCalled();
});
});

View File

@ -1,4 +1,7 @@
import { loginOpenAICodex, type OAuthCredentials } from "@mariozechner/pi-ai/oauth";
import { isErrno } from "../infra/errors.js";
import { tryListenOnPort } from "../infra/ports-probe.js";
import { describePortOwner } from "../infra/ports.js";
import type { RuntimeEnv } from "../runtime.js";
import type { WizardPrompter } from "../wizard/prompts.js";
import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js";
@ -7,6 +10,35 @@ import {
runOpenAIOAuthTlsPreflight,
} from "./provider-openai-codex-oauth-tls.js";
const OPENAI_CODEX_CALLBACK_PORT = 1455;
/** Non-null return means the port is occupied; `owner` is the process name when available. */
async function detectOpenAICodexCallbackPortConflict(): Promise<{ owner?: string } | null> {
try {
await tryListenOnPort({
port: OPENAI_CODEX_CALLBACK_PORT,
host: "127.0.0.1",
exclusive: true,
});
return null;
} catch (err) {
if (!isErrno(err) || err.code !== "EADDRINUSE") {
return null;
}
const owner = await describePortOwner(OPENAI_CODEX_CALLBACK_PORT);
return { owner: owner ?? undefined };
}
}
function buildOpenAICodexCallbackPortConflictNote(conflict: { owner?: string }): string {
return [
`Detected another local process already listening on localhost:${OPENAI_CODEX_CALLBACK_PORT}.`,
"OpenAI Codex browser callback will not complete automatically in this state.",
"Finish sign-in in the browser, then paste the full redirect URL back here.",
...(conflict.owner ? ["", "Port listener details:", conflict.owner] : []),
].join("\n");
}
export async function loginOpenAICodexOAuth(params: {
prompter: WizardPrompter;
runtime: RuntimeEnv;
@ -23,6 +55,8 @@ export async function loginOpenAICodexOAuth(params: {
throw new Error(preflight.message);
}
const callbackPortConflict = isRemote ? null : await detectOpenAICodexCallbackPortConflict();
await prompter.note(
isRemote
? [
@ -30,13 +64,21 @@ export async function loginOpenAICodexOAuth(params: {
"A URL will be shown for you to open in your LOCAL browser.",
"After signing in, paste the redirect URL back here.",
].join("\n")
: [
"Browser will open for OpenAI authentication.",
"If the callback doesn't auto-complete, paste the redirect URL.",
"OpenAI OAuth uses localhost:1455 for the callback.",
].join("\n"),
: callbackPortConflict !== null
? "Browser will open for OpenAI authentication."
: [
"Browser will open for OpenAI authentication.",
"If the callback doesn't auto-complete, paste the redirect URL.",
"OpenAI OAuth uses localhost:1455 for the callback.",
].join("\n"),
"OpenAI Codex OAuth",
);
if (callbackPortConflict !== null) {
await prompter.note(
buildOpenAICodexCallbackPortConflictNote(callbackPortConflict),
"OpenAI Codex OAuth",
);
}
const spin = prompter.progress("Starting OAuth flow…");
try {
@ -53,6 +95,13 @@ export async function loginOpenAICodexOAuth(params: {
onAuth: baseOnAuth,
onPrompt,
onProgress: (msg: string) => spin.update(msg),
onManualCodeInput:
callbackPortConflict !== null
? () =>
onPrompt({
message: "Paste the authorization code (or full redirect URL):",
})
: undefined,
});
spin.stop("OpenAI OAuth complete");
return creds ?? null;