import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore, } from "openclaw/plugin-sdk/agent-runtime"; import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api"; import { afterEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ githubCopilotLoginCommand: vi.fn(), resolveCopilotApiToken: vi.fn(), })); vi.mock("./register.runtime.js", () => ({ DEFAULT_COPILOT_API_BASE_URL: "https://api.githubcopilot.test", resolveCopilotApiToken: mocks.resolveCopilotApiToken, githubCopilotLoginCommand: mocks.githubCopilotLoginCommand, fetchCopilotUsage: vi.fn(), })); import plugin from "./index.js"; const tempDirs: string[] = []; afterEach(async () => { vi.clearAllMocks(); clearRuntimeAuthProfileStoreSnapshots(); await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); }); async function createAgentDir() { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-github-copilot-test-")); tempDirs.push(dir); return dir; } function _registerProvider() { return registerProviderWithPluginConfig({}); } function registerProviderWithPluginConfig(pluginConfig: Record) { const registerProviderMock = vi.fn(); plugin.register( createTestPluginApi({ id: "github-copilot", name: "GitHub Copilot", source: "test", config: {}, pluginConfig, runtime: {} as never, registerProvider: registerProviderMock, }), ); expect(registerProviderMock).toHaveBeenCalledTimes(1); return registerProviderMock.mock.calls[0]?.[0]; } describe("github-copilot plugin", () => { it("registers embedding provider", () => { const registerMemoryEmbeddingProviderMock = vi.fn(); plugin.register( createTestPluginApi({ id: "github-copilot", name: "GitHub Copilot", source: "test", config: {}, pluginConfig: {}, runtime: {} as never, registerProvider: vi.fn(), registerMemoryEmbeddingProvider: registerMemoryEmbeddingProviderMock, }), ); expect(registerMemoryEmbeddingProviderMock).toHaveBeenCalledTimes(1); const adapter = registerMemoryEmbeddingProviderMock.mock.calls[0]?.[0]; expect(adapter.id).toBe("github-copilot"); }); it("skips catalog discovery when plugin discovery is disabled", async () => { const provider = registerProviderWithPluginConfig({ discovery: { enabled: false } }); const result = await provider.catalog.run({ config: { plugins: { entries: { "github-copilot": { config: { discovery: { enabled: false }, }, }, }, }, }, agentDir: "/tmp/agent", env: { GH_TOKEN: "gh_test_token" }, resolveProviderApiKey: () => ({ apiKey: "gh_test_token" }), } as never); expect(result).toBeNull(); expect(mocks.resolveCopilotApiToken).not.toHaveBeenCalled(); }); it("uses live plugin config to re-enable discovery after startup disable", async () => { mocks.resolveCopilotApiToken.mockResolvedValueOnce({ token: "copilot_api_token", baseUrl: "https://api.githubcopilot.live", }); const provider = registerProviderWithPluginConfig({ discovery: { enabled: false } }); const result = await provider.catalog.run({ config: { plugins: { entries: { "github-copilot": { config: { discovery: { enabled: true }, }, }, }, }, }, agentDir: "/tmp/agent", env: { GH_TOKEN: "gh_test_token" }, resolveProviderApiKey: () => ({ apiKey: "gh_test_token" }), } as never); expect(mocks.resolveCopilotApiToken).toHaveBeenCalledWith({ githubToken: "gh_test_token", env: { GH_TOKEN: "gh_test_token" }, }); expect(result).toEqual({ provider: { baseUrl: "https://api.githubcopilot.live", models: [], }, }); }); it("offers to reuse an existing token profile during interactive onboarding", async () => { const provider = registerProviderWithPluginConfig({}); const method = provider.auth[0]; const agentDir = await createAgentDir(); await fs.writeFile( path.join(agentDir, "auth-profiles.json"), JSON.stringify({ version: 1, profiles: { "github-copilot:github": { type: "token", provider: "github-copilot", token: "existing-token", }, }, }), ); const prompter = { confirm: vi.fn(async () => false), note: vi.fn(), }; const result = await method.run({ config: {}, env: {}, agentDir, workspaceDir: "/tmp/workspace", prompter, runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, opts: {}, secretInputMode: "plaintext", allowSecretRefPrompt: false, isRemote: false, openUrl: vi.fn(), oauth: { createVpsAwareHandlers: vi.fn() }, } as never); expect(prompter.confirm).toHaveBeenCalledWith({ message: "GitHub Copilot auth already exists. Re-run login?", initialValue: false, }); expect(mocks.githubCopilotLoginCommand).not.toHaveBeenCalled(); expect(result).toEqual({ profiles: [ { profileId: "github-copilot:github", credential: { type: "token", provider: "github-copilot", token: "existing-token", }, }, ], defaultModel: "github-copilot/claude-opus-4.7", }); }); it("can refresh an existing token profile during interactive onboarding", async () => { const provider = registerProviderWithPluginConfig({}); const method = provider.auth[0]; const agentDir = await createAgentDir(); await fs.writeFile( path.join(agentDir, "auth-profiles.json"), JSON.stringify({ version: 1, profiles: { "github-copilot:github": { type: "token", provider: "github-copilot", token: "existing-token", }, }, }), ); const fetchMock = vi.fn(async (input: unknown) => { const target = typeof input === "string" ? input : input instanceof URL ? input.toString() : input instanceof Request ? input.url : String(input); if (target === "https://github.com/login/device/code") { return new Response( JSON.stringify({ device_code: "device-code-stub", user_code: "ABCD-1234", verification_uri: "https://github.com/login/device", expires_in: 900, interval: 0, }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } if (target === "https://github.com/login/oauth/access_token") { return new Response( JSON.stringify({ access_token: "refreshed-token", token_type: "bearer" }), { status: 200, headers: { "Content-Type": "application/json" } }, ); } throw new Error(`unexpected fetch in github-copilot refresh test: ${target}`); }); vi.stubGlobal("fetch", fetchMock); const prompter = { confirm: vi.fn(async () => true), note: vi.fn(), }; const isTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY"); Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true, }); try { const result = await method.run({ config: {}, env: {}, agentDir, workspaceDir: "/tmp/workspace", prompter, runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() }, opts: {}, secretInputMode: "plaintext", allowSecretRefPrompt: false, isRemote: false, openUrl: vi.fn(), oauth: { createVpsAwareHandlers: vi.fn() }, } as never); expect(prompter.confirm).toHaveBeenCalledWith({ message: "GitHub Copilot auth already exists. Re-run login?", initialValue: false, }); expect(mocks.githubCopilotLoginCommand).not.toHaveBeenCalled(); expect(result.profiles[0]?.credential).toEqual({ type: "token", provider: "github-copilot", token: "refreshed-token", }); } finally { vi.unstubAllGlobals(); if (isTtyDescriptor) { Object.defineProperty(process.stdin, "isTTY", isTtyDescriptor); } else { delete (process.stdin as { isTTY?: boolean }).isTTY; } } }); it("stores GitHub Copilot token from non-interactive onboarding", async () => { const provider = registerProviderWithPluginConfig({}); const method = provider.auth[0]; const agentDir = await createAgentDir(); const runtime = { error: vi.fn(), exit: vi.fn() }; const result = await method.runNonInteractive({ authChoice: "github-copilot", config: {}, baseConfig: {}, opts: { githubCopilotToken: "ghu_test\r\n123" }, runtime, agentDir, resolveApiKey: vi.fn(async () => ({ key: "ghu_test123", source: "flag" as const, })), toApiKeyCredential: vi.fn(), }); expect(runtime.error).not.toHaveBeenCalled(); expect(result?.auth?.profiles?.["github-copilot:github"]).toEqual({ provider: "github-copilot", mode: "token", }); expect(result?.agents?.defaults?.model).toEqual({ primary: "github-copilot/claude-opus-4.7", }); expect(result?.agents?.defaults?.models?.["github-copilot/claude-opus-4.7"]).toEqual({}); const profile = ensureAuthProfileStore(agentDir).profiles["github-copilot:github"]; expect(profile).toEqual({ type: "token", provider: "github-copilot", token: "ghu_test123", }); }); it("stores env-backed token refs for non-interactive onboarding ref mode", async () => { const provider = registerProviderWithPluginConfig({}); const method = provider.auth[0]; const agentDir = await createAgentDir(); const runtime = { error: vi.fn(), exit: vi.fn() }; const result = await method.runNonInteractive({ authChoice: "github-copilot", config: { agents: { defaults: { model: { fallbacks: ["openai/gpt-5.4"] } } } }, baseConfig: {}, opts: { secretInputMode: "ref" }, runtime, agentDir, resolveApiKey: vi.fn(async () => ({ key: "ghu_from_env", source: "env" as const, envVarName: "COPILOT_GITHUB_TOKEN", })), toApiKeyCredential: vi.fn(), }); expect(runtime.error).not.toHaveBeenCalled(); expect(result?.agents?.defaults?.model).toEqual({ fallbacks: ["openai/gpt-5.4"], primary: "github-copilot/claude-opus-4.7", }); const profile = ensureAuthProfileStore(agentDir).profiles["github-copilot:github"]; expect(profile).toEqual({ type: "token", provider: "github-copilot", tokenRef: { source: "env", provider: "default", id: "COPILOT_GITHUB_TOKEN", }, }); }); it("falls back to GH_TOKEN during non-interactive onboarding", async () => { const provider = registerProviderWithPluginConfig({}); const method = provider.auth[0]; const agentDir = await createAgentDir(); const runtime = { error: vi.fn(), exit: vi.fn() }; const resolveApiKey = vi.fn(async ({ envVar }: { envVar?: string }) => envVar === "GH_TOKEN" ? { key: "ghu_from_gh_token", source: "env" as const, envVarName: "GH_TOKEN", } : null, ); const result = await method.runNonInteractive({ authChoice: "github-copilot", config: {}, baseConfig: {}, opts: {}, runtime, agentDir, resolveApiKey, toApiKeyCredential: vi.fn(), }); expect(runtime.error).not.toHaveBeenCalled(); expect(resolveApiKey).toHaveBeenCalledWith( expect.objectContaining({ envVar: "COPILOT_GITHUB_TOKEN" }), ); expect(resolveApiKey).toHaveBeenCalledWith(expect.objectContaining({ envVar: "GH_TOKEN" })); expect(result?.auth?.profiles?.["github-copilot:github"]).toEqual({ provider: "github-copilot", mode: "token", }); const profile = ensureAuthProfileStore(agentDir).profiles["github-copilot:github"]; expect(profile).toEqual({ type: "token", provider: "github-copilot", token: "ghu_from_gh_token", }); }); it("preserves an existing primary model during non-interactive onboarding", async () => { const provider = registerProviderWithPluginConfig({}); const method = provider.auth[0]; const agentDir = await createAgentDir(); const runtime = { error: vi.fn(), exit: vi.fn() }; const result = await method.runNonInteractive({ authChoice: "github-copilot", config: { agents: { defaults: { model: { primary: "github-copilot/gpt-5.4", fallbacks: ["openai/gpt-5.4"], }, models: { "github-copilot/gpt-5.4": { label: "Existing" }, }, }, }, }, baseConfig: {}, opts: { githubCopilotToken: "ghu_test" }, runtime, agentDir, resolveApiKey: vi.fn(async () => ({ key: "ghu_test", source: "flag" as const, })), toApiKeyCredential: vi.fn(), }); expect(runtime.error).not.toHaveBeenCalled(); expect(result?.agents?.defaults?.model).toEqual({ primary: "github-copilot/gpt-5.4", fallbacks: ["openai/gpt-5.4"], }); expect(result?.agents?.defaults?.models).toEqual({ "github-copilot/gpt-5.4": { label: "Existing" }, }); }); it("reuses an existing token profile during non-interactive onboarding", async () => { const provider = registerProviderWithPluginConfig({}); const method = provider.auth[0]; const agentDir = await createAgentDir(); const runtime = { error: vi.fn(), exit: vi.fn() }; await fs.writeFile( path.join(agentDir, "auth-profiles.json"), JSON.stringify({ version: 1, profiles: { "github-copilot:github": { type: "token", provider: "github-copilot", token: "existing-token", }, }, }), ); const result = await method.runNonInteractive({ authChoice: "github-copilot", config: {}, baseConfig: {}, opts: {}, runtime, agentDir, resolveApiKey: vi.fn(async () => null), toApiKeyCredential: vi.fn(), }); expect(runtime.error).not.toHaveBeenCalled(); expect(result?.auth?.profiles?.["github-copilot:github"]).toEqual({ provider: "github-copilot", mode: "token", }); }); it("does not emit a second missing-token error after ref-mode flag validation fails", async () => { const provider = registerProviderWithPluginConfig({}); const method = provider.auth[0]; const agentDir = await createAgentDir(); const runtime = { error: vi.fn(), exit: vi.fn() }; const result = await method.runNonInteractive({ authChoice: "github-copilot", config: {}, baseConfig: {}, opts: { githubCopilotToken: "ghu_secret", secretInputMode: "ref", }, runtime, agentDir, resolveApiKey: vi.fn(async () => null), toApiKeyCredential: vi.fn(), }); expect(result).toBeNull(); expect(runtime.error).toHaveBeenCalledTimes(1); expect(runtime.error).toHaveBeenCalledWith( [ "--github-copilot-token cannot be used with --secret-input-mode ref unless COPILOT_GITHUB_TOKEN, GH_TOKEN, or GITHUB_TOKEN is set in env.", "Set one of those env vars and omit --github-copilot-token, or use --secret-input-mode plaintext.", ].join("\n"), ); }); });