feat(webui): support remote Codex OAuth login (#5174)

This commit is contained in:
chengyongru
2026-07-30 15:06:34 +08:00
committed by GitHub
parent e2563e2e74
commit 606ac56e8f
22 changed files with 1233 additions and 150 deletions
+25
View File
@@ -651,6 +651,14 @@ describe("webui API helpers", () => {
}),
);
await loginProviderOAuth("tok", "openai_codex", "", true);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
await completeProviderOAuth("tok", "xai_grok", "flow-123");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
@@ -675,6 +683,23 @@ describe("webui API helpers", () => {
}),
);
await completeProviderOAuth(
"tok",
"openai_codex",
"flow-codex",
"http://localhost:1455/auth/callback?code=secret&state=test",
);
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex",
expect.objectContaining({
headers: {
Authorization: "Bearer tok",
"X-Nanobot-OAuth-Callback":
"http://localhost:1455/auth/callback?code=secret&state=test",
},
}),
);
await logoutProviderOAuth("tok", "openai_codex");
expect(fetch).toHaveBeenCalledWith(
"/api/settings/provider/oauth-logout?provider=openai_codex",
+213
View File
@@ -2753,6 +2753,219 @@ describe("SettingsView Apps catalog", () => {
}
});
it("polls local OpenAI Codex sign-in until the loopback callback completes", async () => {
const base = settingsPayload();
const codexProvider = {
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://chatgpt.com/backend-api",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [codexProvider] };
const signedIn: SettingsPayload = {
...payload,
providers: [{ ...codexProvider, configured: true, oauth_account: "acct-codex" }],
};
const authorization = {
status: "authorization_required",
provider: "openai_codex",
flow_id: "flow-codex-local",
authorization_url: "https://auth.openai.com/oauth/authorize?state=local",
expires_in: 600,
completion_input: "callback_url",
};
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (url === "/api/settings/provider/oauth-login?provider=openai_codex") {
return jsonResponse(authorization);
}
if (
url ===
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex-local"
) {
expect(init?.headers).not.toHaveProperty("X-Nanobot-OAuth-Callback");
return jsonResponse(signedIn);
}
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
const openMock = vi.fn();
vi.stubGlobal("open", openMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("OpenAI Codex");
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
const dialog = await screen.findByRole("dialog");
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login?provider=openai_codex",
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
);
expect(openMock).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
"Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, copy the full localhost callback URL from the address bar and paste it below.",
),
).toBeInTheDocument();
expect(within(dialog).getByText("Waiting for the browser callback…")).toBeInTheDocument();
expect(
within(dialog).queryByText("Paste the callback URL to continue."),
).not.toBeInTheDocument();
expect(
await screen.findByText("Signed in as acct-codex", {}, { timeout: 2500 }),
).toBeInTheDocument();
});
it("completes remote OpenAI Codex sign-in with the full callback URL", async () => {
const happyWindow = window as typeof window & {
happyDOM: { setURL: (url: string) => void };
};
const originalUrl = window.location.href;
happyWindow.happyDOM.setURL("http://203.0.113.10:18887/#/settings?section=models");
try {
const base = settingsPayload();
const codexProvider = {
name: "openai_codex",
label: "OpenAI Codex",
configured: false,
auth_type: "oauth" as const,
api_key_required: false,
api_key_hint: null,
api_base: null,
default_api_base: "https://chatgpt.com/backend-api",
model_catalog: "builtin",
oauth_account: null,
oauth_expires_at: null,
oauth_login_supported: true,
};
const payload: SettingsPayload = { ...base, providers: [codexProvider] };
const signedIn: SettingsPayload = {
...payload,
providers: [{ ...codexProvider, configured: true, oauth_account: "acct-codex" }],
};
const authorization = {
status: "authorization_required",
provider: "openai_codex",
flow_id: "flow-codex",
authorization_url: "https://auth.openai.com/oauth/authorize?state=test",
expires_in: 600,
completion_input: "callback_url",
};
const callbackUrl =
"http://localhost:1455/auth/callback?code=secret&state=test";
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url === "/api/settings") return jsonResponse(payload);
if (
url ===
"/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true"
) {
return jsonResponse(authorization);
}
if (
url ===
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex"
) {
const headers = init?.headers as Record<string, string>;
if (headers?.["X-Nanobot-OAuth-Callback"]) {
expect(headers["X-Nanobot-OAuth-Callback"]).toBe(callbackUrl);
return jsonResponse(signedIn);
}
return jsonResponse({
status: "pending",
provider: "openai_codex",
flow_id: "flow-codex",
});
}
if (url === "/api/settings/cli-apps") {
return jsonResponse({ apps: [], installed_count: 0 });
}
if (url === "/api/settings/mcp-presets") {
return jsonResponse({ presets: [], installed_count: 0 });
}
return jsonResponse({});
});
vi.stubGlobal("fetch", fetchMock);
const popup = {
opener: window,
location: { href: "about:blank" },
close: vi.fn(),
};
const openMock = vi.fn(() => popup);
vi.stubGlobal("open", openMock);
renderSettingsView({ initialSection: "models", initialSettings: payload });
await chooseProviderToConfigure("OpenAI Codex");
expect(
screen.getByText(
"Sign in through this browser, then paste the full localhost callback URL back into nanobot.",
),
).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
const dialog = await screen.findByRole("dialog");
expect(openMock).not.toHaveBeenCalled();
expect(
within(dialog).getByText(
"Open ChatGPT in this browser and finish signing in. When the localhost page fails to load, copy the full URL from the address bar and paste it below.",
),
).toBeInTheDocument();
expect(within(dialog).getByText("Paste the callback URL to continue.")).toBeInTheDocument();
const callbackInput = within(dialog).getByRole("textbox", {
name: "Full callback URL",
});
expect(callbackInput).toHaveAttribute(
"placeholder",
"http://localhost:1455/auth/callback?code=…&state=…",
);
fireEvent.click(within(dialog).getByRole("button", { name: "Open ChatGPT" }));
expect(openMock).toHaveBeenCalledWith(
authorization.authorization_url,
"_blank",
"noopener,noreferrer",
);
expect(popup.opener).toBeNull();
fireEvent.change(callbackInput, { target: { value: callbackUrl } });
fireEvent.click(within(dialog).getByRole("button", { name: "Finish sign-in" }));
await waitFor(() =>
expect(fetchMock).toHaveBeenCalledWith(
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex",
expect.objectContaining({
headers: expect.objectContaining({
"X-Nanobot-OAuth-Callback": callbackUrl,
}),
}),
),
);
expect(await screen.findByText("Signed in as acct-codex")).toBeInTheDocument();
} finally {
happyWindow.happyDOM.setURL(originalUrl);
}
});
it("saves scoped proxies for xAI and OpenAI Codex OAuth providers", async () => {
const base = settingsPayload();
const providers: SettingsPayload["providers"] = [