import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { clearRuntimeAuthProfileStoreSnapshots } from "../../../src/agents/auth-profiles/store.js"; import type { AuthProfileStore } from "../../../src/agents/auth-profiles/types.js"; import { registerProviders, requireProvider } from "../../../src/plugins/contracts/testkit.js"; import { createNonExitingRuntime } from "../../../src/runtime.js"; import type { WizardMultiSelectParams, WizardPrompter, WizardProgress, WizardSelectParams, } from "../../../src/wizard/prompts.js"; type LoginOpenAICodexOAuth = (typeof import("openclaw/plugin-sdk/provider-auth-login"))["loginOpenAICodexOAuth"]; type GithubCopilotLoginCommand = (typeof import("openclaw/plugin-sdk/provider-auth-login"))["githubCopilotLoginCommand"]; type CreateVpsAwareHandlers = (typeof import("../../../src/plugins/provider-oauth-flow.js"))["createVpsAwareOAuthHandlers"]; type EnsureAuthProfileStore = typeof import("openclaw/plugin-sdk/provider-auth").ensureAuthProfileStore; type ListProfilesForProvider = typeof import("openclaw/plugin-sdk/provider-auth").listProfilesForProvider; const loginOpenAICodexOAuthMock = vi.hoisted(() => vi.fn()); const githubCopilotLoginCommandMock = vi.hoisted(() => vi.fn()); const ensureAuthProfileStoreMock = vi.hoisted(() => vi.fn()); const listProfilesForProviderMock = vi.hoisted(() => vi.fn()); const providerAuthContractModules = vi.hoisted(() => ({ githubCopilotIndexModuleUrl: new URL( "../../../extensions/github-copilot/index.ts", import.meta.url, ).href, openAIIndexModuleUrl: new URL("../../../extensions/openai/index.ts", import.meta.url).href, })); vi.mock("openclaw/plugin-sdk/provider-auth-login", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loginOpenAICodexOAuth: loginOpenAICodexOAuthMock, githubCopilotLoginCommand: githubCopilotLoginCommandMock, }; }); vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, ensureAuthProfileStore: ensureAuthProfileStoreMock, listProfilesForProvider: listProfilesForProviderMock, }; }); async function importBundledProviderPlugin(moduleUrl: string): Promise { return (await import(`${moduleUrl}?t=${Date.now()}`)) as T; } 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(), }, }; } function createJwt(payload: Record): string { const header = Buffer.from(JSON.stringify({ alg: "none", typ: "JWT" })).toString("base64url"); const body = Buffer.from(JSON.stringify(payload)).toString("base64url"); return `${header}.${body}.signature`; } function buildOpenAICodexOAuthResult(params: { profileId: string; access: string; refresh: string; expires: number; email?: string; }) { return { profiles: [ { profileId: params.profileId, credential: { type: "oauth" as const, provider: "openai-codex", access: params.access, refresh: params.refresh, expires: params.expires, ...(params.email ? { email: params.email } : {}), }, }, ], configPatch: { agents: { defaults: { models: { "openai-codex/gpt-5.4": {}, }, }, }, }, defaultModel: "openai-codex/gpt-5.4", notes: undefined, }; } function installSharedAuthProfileStoreHooks(state: { authStore: AuthProfileStore }) { beforeEach(() => { state.authStore = { version: 1, profiles: {} }; ensureAuthProfileStoreMock.mockReset(); ensureAuthProfileStoreMock.mockImplementation(() => state.authStore); listProfilesForProviderMock.mockReset(); listProfilesForProviderMock.mockImplementation((store, providerId) => Object.entries(store.profiles) .filter(([, credential]) => credential?.provider === providerId) .map(([profileId]) => profileId), ); }); afterEach(() => { loginOpenAICodexOAuthMock.mockReset(); githubCopilotLoginCommandMock.mockReset(); ensureAuthProfileStoreMock.mockReset(); listProfilesForProviderMock.mockReset(); clearRuntimeAuthProfileStoreSnapshots(); }); } export function describeOpenAICodexProviderAuthContract() { const state = { authStore: { version: 1, profiles: {} } as AuthProfileStore, }; describe("openai-codex provider auth contract", () => { installSharedAuthProfileStoreHooks(state); async function expectStableFallbackProfile(params: { access: string; profileId: string }) { const { default: openAIPlugin } = await importBundledProviderPlugin<{ default: Parameters[0]; }>(providerAuthContractModules.openAIIndexModuleUrl); const provider = requireProvider(registerProviders(openAIPlugin), "openai-codex"); loginOpenAICodexOAuthMock.mockResolvedValueOnce({ refresh: "refresh-token", access: params.access, expires: 1_700_000_000_000, }); const result = await provider.auth[0]?.run(buildAuthContext() as never); expect(result).toEqual( buildOpenAICodexOAuthResult({ profileId: params.profileId, access: params.access, refresh: "refresh-token", expires: 1_700_000_000_000, }), ); } async function getProvider() { const { default: openAIPlugin } = await importBundledProviderPlugin<{ default: Parameters[0]; }>(providerAuthContractModules.openAIIndexModuleUrl); return requireProvider(registerProviders(openAIPlugin), "openai-codex"); } it("keeps OAuth auth results provider-owned", async () => { const provider = await getProvider(); 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( buildOpenAICodexOAuthResult({ profileId: "openai-codex:user@example.com", access: "access-token", refresh: "refresh-token", expires: 1_700_000_000_000, email: "user@example.com", }), ); }); it("backfills OAuth email from the JWT profile claim", async () => { const provider = await getProvider(); const access = createJwt({ "https://api.openai.com/profile": { email: "jwt-user@example.com", }, }); loginOpenAICodexOAuthMock.mockResolvedValueOnce({ refresh: "refresh-token", access, expires: 1_700_000_000_000, }); const result = await provider.auth[0]?.run(buildAuthContext() as never); expect(result).toEqual( buildOpenAICodexOAuthResult({ profileId: "openai-codex:jwt-user@example.com", access, refresh: "refresh-token", expires: 1_700_000_000_000, email: "jwt-user@example.com", }), ); }); it("uses a stable fallback id when JWT email is missing", async () => { const access = createJwt({ "https://api.openai.com/auth": { chatgpt_account_user_id: "user-123__acct-456", }, }); const expectedStableId = Buffer.from("user-123__acct-456", "utf8").toString("base64url"); await expectStableFallbackProfile({ access, profileId: `openai-codex:id-${expectedStableId}`, }); }); it("uses iss and sub to build a stable fallback id when auth claims are missing", async () => { const access = createJwt({ iss: "https://accounts.openai.com", sub: "user-abc", }); const expectedStableId = Buffer.from("https://accounts.openai.com|user-abc").toString( "base64url", ); await expectStableFallbackProfile({ access, profileId: `openai-codex:id-${expectedStableId}`, }); }); it("uses sub alone to build a stable fallback id when iss is missing", async () => { const access = createJwt({ sub: "user-abc", }); const expectedStableId = Buffer.from("user-abc").toString("base64url"); await expectStableFallbackProfile({ access, profileId: `openai-codex:id-${expectedStableId}`, }); }); it("falls back to the default profile when JWT parsing yields no identity", async () => { const provider = await getProvider(); loginOpenAICodexOAuthMock.mockResolvedValueOnce({ refresh: "refresh-token", access: "not-a-jwt-token", expires: 1_700_000_000_000, }); const result = await provider.auth[0]?.run(buildAuthContext() as never); expect(result).toEqual( buildOpenAICodexOAuthResult({ profileId: "openai-codex:default", access: "not-a-jwt-token", refresh: "refresh-token", expires: 1_700_000_000_000, }), ); }); it("keeps OAuth failures non-fatal at the provider layer", async () => { const provider = await getProvider(); loginOpenAICodexOAuthMock.mockRejectedValueOnce(new Error("oauth failed")); await expect(provider.auth[0]?.run(buildAuthContext() as never)).resolves.toEqual({ profiles: [], }); }); }); } export function describeGithubCopilotProviderAuthContract() { const state = { authStore: { version: 1, profiles: {} } as AuthProfileStore, }; describe("github-copilot provider auth contract", () => { installSharedAuthProfileStoreHooks(state); async function getProvider() { const { default: githubCopilotPlugin } = await importBundledProviderPlugin<{ default: Parameters[0]; }>(providerAuthContractModules.githubCopilotIndexModuleUrl); return requireProvider(registerProviders(githubCopilotPlugin), "github-copilot"); } it("keeps device auth results provider-owned", async () => { const provider = await getProvider(); state.authStore.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 auth gated on interactive TTYs", async () => { const provider = await getProvider(); 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; } } }); }); }