Merge 421346a7ac2ea382ac9c087b49c8d63b62231316 into 598f1826d8b2bc969aace2c6459824737667218c
This commit is contained in:
commit
a6bbc4e690
190
src/plugins/provider-openai-codex-oauth.test.ts
Normal file
190
src/plugins/provider-openai-codex-oauth.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user