import { afterEach, describe, expect, it, vi } from "vitest"; import { clearRuntimeAuthProfileStoreSnapshots, replaceRuntimeAuthProfileStoreSnapshots, } from "../../agents/auth-profiles/store.js"; import { createNonExitingRuntime } from "../../runtime.js"; import { createCapturedPluginRegistration } from "../../test-utils/plugin-registration.js"; import type { WizardMultiSelectParams, WizardPrompter, WizardProgress, WizardSelectParams, } from "../../wizard/prompts.js"; import type { OpenClawPluginApi, ProviderPlugin } from "../types.js"; type LoginOpenAICodexOAuth = (typeof import("openclaw/plugin-sdk/provider-auth"))["loginOpenAICodexOAuth"]; type LoginQwenPortalOAuth = (typeof import("../../../extensions/qwen-portal-auth/oauth.js"))["loginQwenPortalOAuth"]; type GithubCopilotLoginCommand = (typeof import("openclaw/plugin-sdk/provider-auth"))["githubCopilotLoginCommand"]; type CreateVpsAwareHandlers = (typeof import("../provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"]; const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn()); const loginQwenPortalOAuthMock = vi.hoisted(() => vi.fn()); const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn()); vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loginOpenAICodexOAuth: loginOpenAICodexOAuthMock, githubCopilotLoginCommand: githubCopilotLoginCommandMock, }; }); vi.mock("../../../extensions/qwen-portal-auth/oauth.js", () => ({ loginQwenPortalOAuth: loginQwenPortalOAuthMock, })); const openAIPlugin = (await import("../../../extensions/openai/index.js")).default; const qwenPortalPlugin = (await import("../../../extensions/qwen-portal-auth/index.js")).default; const githubCopilotPlugin = (await import("../../../extensions/github-copilot/index.js")).default; function registerProviders(...plugins: Array<{ register(api: OpenClawPluginApi): void }>) { const captured = createCapturedPluginRegistration(); for (const plugin of plugins) { plugin.register(captured.api); } return captured.providers; } function requireProvider(providers: ProviderPlugin[], providerId: string) { const provider = providers.find((entry) => entry.id === providerId); if (!provider) { throw new Error(`provider ${providerId} missing`); } return provider; } function buildPrompter(): WizardPrompter { const progress: WizardProgress = { update() {}, stop() {}, }; return { intro: async () => {}, outro: async () => {}, note: async () => {}, select: async (params: WizardSelectParams) => { const option = params.options[0]; if (!option) { throw new Error("missing select option"); } return option.value; }, multiselect: async (params: WizardMultiSelectParams) => params.initialValues ?? [], text: async () => "", confirm: async () => false, progress: () => progress, }; } function buildAuthContext() { return { config: {}, prompter: buildPrompter(), runtime: createNonExitingRuntime(), isRemote: false, openUrl: async () => {}, oauth: { createVpsAwareHandlers: vi.fn(), }, }; } describe("provider auth contract", () => { afterEach(() => { loginOpenAICodexOAuthMock.mockReset(); loginQwenPortalOAuthMock.mockReset(); githubCopilotLoginCommandMock.mockReset(); clearRuntimeAuthProfileStoreSnapshots(); }); it("keeps OpenAI Codex OAuth auth results provider-owned", async () => { const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex"); loginOpenAICodexOAuthMock.mockResolvedValueOnce({ email: "user@example.com", refresh: "refresh-token", access: "access-token", expires: 1_700_000_000_000, }); const result = await provider.auth[0]?.run(buildAuthContext() as never); expect(result).toEqual({ profiles: [ { profileId: "openai-codex:user@example.com", credential: { type: "oauth", provider: "openai-codex", access: "access-token", refresh: "refresh-token", expires: 1_700_000_000_000, email: "user@example.com", }, }, ], configPatch: { agents: { defaults: { models: { "openai-codex/gpt-5.4": {}, }, }, }, }, defaultModel: "openai-codex/gpt-5.4", notes: undefined, }); }); it("keeps OpenAI Codex OAuth failures non-fatal at the provider layer", async () => { const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex"); loginOpenAICodexOAuthMock.mockRejectedValueOnce(new Error("oauth failed")); await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({ profiles: [], }); }); it("keeps Qwen portal OAuth auth results provider-owned", async () => { const provider = requireProvider(registerProviders(qwenPortalPlugin), "qwen-portal"); loginQwenPortalOAuthMock.mockResolvedValueOnce({ access: "access-token", refresh: "refresh-token", expires: 1_700_000_000_000, resourceUrl: "portal.qwen.ai", }); const result = await provider.auth[0]?.run(buildAuthContext() as never); expect(result).toMatchObject({ profiles: [ { profileId: "qwen-portal:default", credential: { type: "oauth", provider: "qwen-portal", access: "access-token", refresh: "refresh-token", expires: 1_700_000_000_000, }, }, ], defaultModel: "qwen-portal/coder-model", configPatch: { models: { providers: { "qwen-portal": { baseUrl: "https://portal.qwen.ai/v1", models: [], }, }, }, }, }); expect(result?.notes).toEqual( expect.arrayContaining([ expect.stringContaining("auto-refresh"), expect.stringContaining("Base URL defaults"), ]), ); }); it("keeps GitHub Copilot device auth results provider-owned", async () => { const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot"); replaceRuntimeAuthProfileStoreSnapshots([ { store: { version: 1, profiles: { "github-copilot:github": { type: "token", provider: "github-copilot", token: "github-device-token", }, }, }, }, ]); const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }; const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY"); const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY"); Object.defineProperty(stdin, "isTTY", { configurable: true, enumerable: true, get: () => true, }); try { const result = await provider.auth[0]?.run(buildAuthContext() as never); expect(githubCopilotLoginCommandMock).toHaveBeenCalledWith( { yes: true, profileId: "github-copilot:github" }, expect.any(Object), ); expect(result).toEqual({ profiles: [ { profileId: "github-copilot:github", credential: { type: "token", provider: "github-copilot", token: "github-device-token", }, }, ], defaultModel: "github-copilot/gpt-4o", }); } finally { if (previousIsTTYDescriptor) { Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor); } else if (!hadOwnIsTTY) { delete (stdin as { isTTY?: boolean }).isTTY; } } }); it("keeps GitHub Copilot auth gated on interactive TTYs", async () => { const provider = requireProvider(registerProviders(githubCopilotPlugin), "github-copilot"); const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean }; const hadOwnIsTTY = Object.prototype.hasOwnProperty.call(stdin, "isTTY"); const previousIsTTYDescriptor = Object.getOwnPropertyDescriptor(stdin, "isTTY"); Object.defineProperty(stdin, "isTTY", { configurable: true, enumerable: true, get: () => false, }); try { await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({ profiles: [], }); expect(githubCopilotLoginCommandMock).not.toHaveBeenCalled(); } finally { if (previousIsTTYDescriptor) { Object.defineProperty(stdin, "isTTY", previousIsTTYDescriptor); } else if (!hadOwnIsTTY) { delete (stdin as { isTTY?: boolean }).isTTY; } } }); });