feat(webui): support remote Codex OAuth login (#5174)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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"] = [
|
||||
|
||||
Reference in New Issue
Block a user