mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-28 05:16:23 -06:00
53c0ca9754
* fix(github-copilot): honor configured request identity Honor existing provider request headers consistently across model discovery, setup, inference, and embeddings. Preserve the default identity and partition the live catalog by the configured identity. Co-authored-by: Finn763 <165816600+Finn763@users.noreply.github.com> * test(github-copilot): real-behavior wire-trace proof for #127965 * fix(github-copilot proof): print on-disk wire-trace.json sha256 * chore(github-copilot proof): drop pr-body.md from PR diff * fix(github-copilot): finish identity checks and consolidate proof Complete the typed provider fixture and keep the identity constant private. Retain canonical owner tests and real provider evidence; remove parallel copied mock implementations and generated PR-only traces while preserving the diagnostic commits in ancestry. Co-authored-by: Finn763 <165816600+Finn763@users.noreply.github.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com> Co-authored-by: Finn763 <Finn763@users.noreply.github.com>
2196 lines
72 KiB
TypeScript
2196 lines
72 KiB
TypeScript
// Github Copilot tests cover index plugin behavior.
|
|
import fs from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { expectDefined } from "@openclaw/normalization-core";
|
|
import {
|
|
clearRuntimeAuthProfileStoreSnapshots,
|
|
ensureAuthProfileStore,
|
|
saveAuthProfileStore,
|
|
} from "openclaw/plugin-sdk/agent-runtime";
|
|
import { MAX_DATE_TIMESTAMP_MS, MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
|
import type {
|
|
OpenClawConfig,
|
|
OpenClawPluginApi,
|
|
ProviderAuthResult,
|
|
ProviderCatalogResult,
|
|
UnifiedModelCatalogEntry,
|
|
} from "openclaw/plugin-sdk/plugin-entry";
|
|
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
|
import type { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
|
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
|
import { runGitHubCopilotDeviceFlow } from "./login.js";
|
|
import manifest from "./openclaw.plugin.json" with { type: "json" };
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
fetchWithSsrFGuard: vi.fn<typeof fetchWithSsrFGuard>(async (params) => ({
|
|
response: await fetch(params.url, params.init),
|
|
finalUrl: params.url,
|
|
release: vi.fn(async () => {}),
|
|
})),
|
|
resolveCopilotRuntimeAuth: vi.fn(),
|
|
resolveCopilotStarterModel: vi.fn(async () => "github-copilot/claude-sonnet-5"),
|
|
}));
|
|
|
|
function requireAuthMethod<T>(methods: readonly T[], index: number): T {
|
|
return expectDefined(methods[index], `GitHub Copilot auth method ${index}`);
|
|
}
|
|
|
|
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
|
|
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/ssrf-runtime")>(
|
|
"openclaw/plugin-sdk/ssrf-runtime",
|
|
);
|
|
return {
|
|
...actual,
|
|
fetchWithSsrFGuard: mocks.fetchWithSsrFGuard,
|
|
};
|
|
});
|
|
|
|
vi.mock("./register.runtime.js", () => ({
|
|
DEFAULT_COPILOT_API_BASE_URL: "https://api.githubcopilot.test",
|
|
resolveCopilotRuntimeAuth: mocks.resolveCopilotRuntimeAuth,
|
|
resolveCopilotStarterModel: mocks.resolveCopilotStarterModel,
|
|
fetchCopilotUsage: vi.fn(),
|
|
}));
|
|
|
|
import plugin from "./index.js";
|
|
|
|
const tempDirs: string[] = [];
|
|
type RegisteredEmbeddingProvider = Parameters<OpenClawPluginApi["registerEmbeddingProvider"]>[0];
|
|
type RegisteredProvider = Parameters<OpenClawPluginApi["registerProvider"]>[0];
|
|
type GithubCopilotTestProvider = RegisteredProvider & {
|
|
auth: Array<{
|
|
id: string;
|
|
run: (ctx: unknown) => Promise<ProviderAuthResult | null>;
|
|
runNonInteractive: (ctx: unknown) => Promise<OpenClawConfig | null>;
|
|
}>;
|
|
catalog: {
|
|
run: (ctx: unknown) => Promise<ProviderCatalogResult>;
|
|
};
|
|
prepareDynamicModel: NonNullable<RegisteredProvider["prepareDynamicModel"]>;
|
|
resolveDynamicModel: NonNullable<RegisteredProvider["resolveDynamicModel"]>;
|
|
preferRuntimeResolvedModel: NonNullable<RegisteredProvider["preferRuntimeResolvedModel"]>;
|
|
prepareRuntimeAuth: NonNullable<RegisteredProvider["prepareRuntimeAuth"]>;
|
|
resolveThinkingProfile: NonNullable<RegisteredProvider["resolveThinkingProfile"]>;
|
|
};
|
|
type GithubCopilotTestModelCatalogProvider = {
|
|
liveCatalog: (ctx: unknown) => Promise<readonly UnifiedModelCatalogEntry[] | null | undefined>;
|
|
};
|
|
|
|
afterEach(async () => {
|
|
vi.clearAllMocks();
|
|
vi.unstubAllGlobals();
|
|
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
|
response: await fetch(params.url, params.init),
|
|
finalUrl: params.url,
|
|
release: vi.fn(async () => {}),
|
|
}));
|
|
clearRuntimeAuthProfileStoreSnapshots();
|
|
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
afterAll(() => {
|
|
vi.doUnmock("./register.runtime.js");
|
|
vi.resetModules();
|
|
});
|
|
|
|
async function createAgentDir() {
|
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-github-copilot-test-"));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
function createModelRegistry() {
|
|
return {
|
|
getAll: vi.fn(() => []),
|
|
getAvailable: vi.fn(() => []),
|
|
find: vi.fn(() => undefined),
|
|
hasConfiguredAuth: vi.fn(() => false),
|
|
};
|
|
}
|
|
|
|
function writeExistingCopilotTokenProfile(agentDir: string) {
|
|
saveAuthProfileStore(
|
|
{
|
|
version: 1,
|
|
profiles: {
|
|
"github-copilot:github": {
|
|
type: "token",
|
|
provider: "github-copilot",
|
|
token: "existing-token",
|
|
},
|
|
},
|
|
},
|
|
agentDir,
|
|
{ filterExternalAuthProfiles: false, syncExternalCli: false },
|
|
);
|
|
}
|
|
|
|
function requireFirstMockArg<T>(
|
|
mock: { mock: { calls: Array<[T, ...unknown[]]> } },
|
|
label: string,
|
|
) {
|
|
const [call] = mock.mock.calls;
|
|
if (!call) {
|
|
throw new Error(`Expected ${label}`);
|
|
}
|
|
return call[0];
|
|
}
|
|
|
|
function registerProviderAndCatalogWithPluginConfig(pluginConfig: Record<string, unknown>) {
|
|
const registerProviderMock = vi.fn<OpenClawPluginApi["registerProvider"]>();
|
|
const registerModelCatalogProviderMock =
|
|
vi.fn<OpenClawPluginApi["registerModelCatalogProvider"]>();
|
|
|
|
plugin.register(
|
|
createTestPluginApi({
|
|
id: "github-copilot",
|
|
name: "GitHub Copilot",
|
|
source: "test",
|
|
config: {},
|
|
pluginConfig,
|
|
runtime: {} as never,
|
|
registerProvider: registerProviderMock,
|
|
registerModelCatalogProvider: registerModelCatalogProviderMock,
|
|
}),
|
|
);
|
|
|
|
expect(registerProviderMock).toHaveBeenCalledTimes(1);
|
|
expect(registerModelCatalogProviderMock).toHaveBeenCalledTimes(1);
|
|
return {
|
|
provider: requireFirstMockArg(
|
|
registerProviderMock,
|
|
"provider registration",
|
|
) as GithubCopilotTestProvider,
|
|
modelCatalogProvider: requireFirstMockArg(
|
|
registerModelCatalogProviderMock,
|
|
"model catalog provider registration",
|
|
) as GithubCopilotTestModelCatalogProvider,
|
|
};
|
|
}
|
|
|
|
function registerProviderWithPluginConfig(pluginConfig: Record<string, unknown>) {
|
|
return registerProviderAndCatalogWithPluginConfig(pluginConfig).provider;
|
|
}
|
|
|
|
describe("github-copilot plugin", () => {
|
|
it("formats legacy OAuth profiles with the durable GitHub credential", () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
|
|
expect(
|
|
provider.formatApiKey?.({
|
|
type: "oauth",
|
|
provider: "github-copilot",
|
|
access: "short-lived-copilot-token",
|
|
refresh: " durable-github-token ",
|
|
expires: Date.now() + 60_000,
|
|
}),
|
|
).toBe("durable-github-token");
|
|
});
|
|
|
|
it("normalizes legacy OAuth profiles without losing tenant metadata", async () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const credential = {
|
|
type: "oauth" as const,
|
|
provider: "github-copilot",
|
|
access: "short-lived-copilot-token",
|
|
refresh: "durable-github-token",
|
|
expires: 1,
|
|
enterpriseUrl: "acme.ghe.com",
|
|
};
|
|
|
|
await expect(provider.refreshOAuth?.(credential)).resolves.toEqual({
|
|
...credential,
|
|
access: "durable-github-token",
|
|
expires: MAX_DATE_TIMESTAMP_MS,
|
|
});
|
|
expect(credential).toEqual({
|
|
type: "oauth",
|
|
provider: "github-copilot",
|
|
access: "short-lived-copilot-token",
|
|
refresh: "durable-github-token",
|
|
expires: 1,
|
|
enterpriseUrl: "acme.ghe.com",
|
|
});
|
|
});
|
|
|
|
it("rejects unsafe legacy OAuth tenants before formatting or refresh", async () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const credential = {
|
|
type: "oauth" as const,
|
|
provider: "github-copilot",
|
|
access: "short-lived-copilot-token",
|
|
refresh: "durable-github-token",
|
|
expires: 1,
|
|
enterpriseUrl: "attacker.example",
|
|
};
|
|
|
|
expect(() => provider.formatApiKey?.(credential)).toThrow(/attacker\.example/);
|
|
await expect(provider.refreshOAuth?.(credential)).rejects.toThrow(/attacker\.example/);
|
|
});
|
|
|
|
it("moves unsupported legacy OAuth doctor guidance into the provider", async () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const store = {
|
|
version: 1,
|
|
profiles: {
|
|
"github-copilot:default": {
|
|
type: "oauth" as const,
|
|
provider: "github-copilot",
|
|
access: "fake",
|
|
refresh: "fake",
|
|
expires: 0,
|
|
enterpriseUrl: "attacker.example",
|
|
},
|
|
},
|
|
};
|
|
|
|
expect(
|
|
await provider.buildAuthDoctorHint?.({
|
|
store,
|
|
provider: "github-copilot",
|
|
profileId: "github-copilot:default",
|
|
}),
|
|
).toContain("unsupported enterprise domain");
|
|
store.profiles["github-copilot:default"].enterpriseUrl = "acme.ghe.com";
|
|
expect(
|
|
await provider.buildAuthDoctorHint?.({
|
|
store,
|
|
provider: "github-copilot",
|
|
profileId: "github-copilot:default",
|
|
}),
|
|
).toBeUndefined();
|
|
});
|
|
|
|
it("preserves the source token supplied by the auth layer for runtime auth", async () => {
|
|
mocks.resolveCopilotRuntimeAuth.mockResolvedValueOnce({
|
|
apiKey: "github-source-token",
|
|
baseUrl: "https://api.individual.githubcopilot.com",
|
|
});
|
|
const provider = registerProviderWithPluginConfig({});
|
|
|
|
const prepared = await provider.prepareRuntimeAuth({
|
|
config: {},
|
|
env: {},
|
|
provider: "github-copilot",
|
|
modelId: "gpt-5-mini",
|
|
model: { id: "gpt-5-mini", provider: "github-copilot" },
|
|
apiKey: "github-source-token",
|
|
authMode: "oauth",
|
|
} as never);
|
|
|
|
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenCalledWith({
|
|
githubToken: "github-source-token",
|
|
env: {},
|
|
githubDomain: "github.com",
|
|
});
|
|
expect(prepared).toEqual({
|
|
apiKey: "github-source-token",
|
|
baseUrl: "https://api.individual.githubcopilot.com",
|
|
request: {
|
|
headers: {
|
|
"Accept-Encoding": "identity",
|
|
"Copilot-Integration-Id": "copilot-developer-cli",
|
|
"Editor-Plugin-Version": "copilot-chat/0.35.0",
|
|
"Editor-Version": "vscode/1.107.0",
|
|
"Openai-Organization": "github-copilot",
|
|
"User-Agent": "GitHubCopilotChat/0.35.0",
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("carries a legacy OAuth tenant into request-time routing", async () => {
|
|
mocks.resolveCopilotRuntimeAuth.mockResolvedValueOnce({
|
|
apiKey: "durable-github-token",
|
|
baseUrl: "https://copilot-api.acme.ghe.com",
|
|
});
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const apiKey = provider.formatApiKey?.({
|
|
type: "oauth",
|
|
provider: "github-copilot",
|
|
access: "short-lived-copilot-token",
|
|
refresh: "durable-github-token",
|
|
expires: MAX_DATE_TIMESTAMP_MS,
|
|
enterpriseUrl: "acme.ghe.com",
|
|
});
|
|
|
|
await provider.prepareRuntimeAuth({
|
|
config: {},
|
|
env: {},
|
|
provider: "github-copilot",
|
|
modelId: "gpt-5-mini",
|
|
model: { id: "gpt-5-mini", provider: "github-copilot" },
|
|
apiKey,
|
|
authMode: "oauth",
|
|
} as never);
|
|
|
|
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenCalledWith({
|
|
githubToken: "durable-github-token",
|
|
env: {},
|
|
githubDomain: "acme.ghe.com",
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
{ headers: undefined, expected: "vscode-chat" },
|
|
{ headers: { "COPILOT-INTEGRATION-ID": "model-identity" }, expected: "model-identity" },
|
|
])(
|
|
"honors the existing provider integration header during runtime authentication: $expected",
|
|
async ({ headers, expected }) => {
|
|
mocks.resolveCopilotRuntimeAuth.mockResolvedValueOnce({
|
|
apiKey: "github-source-token",
|
|
baseUrl: "https://copilot-api.acme.ghe.com",
|
|
});
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const prepared = await provider.prepareRuntimeAuth({
|
|
config: {
|
|
models: {
|
|
providers: {
|
|
"github-copilot": {
|
|
headers: { "Copilot-Integration-Id": "copilot-developer-cli" },
|
|
request: { headers: { "copilot-integration-id": "vscode-chat" } },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
env: {},
|
|
provider: "github-copilot",
|
|
modelId: "claude-sonnet-5",
|
|
model: { id: "claude-sonnet-5", provider: "github-copilot", headers },
|
|
apiKey: "github-source-token",
|
|
authMode: "token",
|
|
} as never);
|
|
|
|
expect(new Headers(prepared?.request?.headers).get("copilot-integration-id")).toBe(expected);
|
|
},
|
|
);
|
|
|
|
it("rejects an unresolved integration SecretRef before catalog fallback or inference", async () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const agentDir = await createAgentDir();
|
|
const config = {
|
|
models: {
|
|
providers: {
|
|
"github-copilot": {
|
|
request: {
|
|
headers: {
|
|
"Copilot-Integration-Id": { source: "env", provider: "default", id: "IDENTITY" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
mocks.resolveCopilotRuntimeAuth.mockResolvedValue({
|
|
apiKey: "github-source-token",
|
|
baseUrl: "https://copilot-api.acme.ghe.com",
|
|
});
|
|
const fetchMock = vi.fn<typeof fetch>();
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
|
|
await expect(
|
|
provider.catalog.run({
|
|
agentDir,
|
|
config,
|
|
env: { COPILOT_GITHUB_TOKEN: "github-source-token" },
|
|
}),
|
|
).rejects.toMatchObject({ name: "UnresolvedSecretInputError" });
|
|
await expect(
|
|
provider.prepareRuntimeAuth({
|
|
config,
|
|
env: {},
|
|
provider: "github-copilot",
|
|
modelId: "claude-sonnet-5",
|
|
model: { id: "claude-sonnet-5", provider: "github-copilot" },
|
|
apiKey: "github-source-token",
|
|
authMode: "token",
|
|
} as never),
|
|
).rejects.toMatchObject({ name: "UnresolvedSecretInputError" });
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("isolates live catalog results by the configured integration header", async () => {
|
|
const agentDir = await createAgentDir();
|
|
writeExistingCopilotTokenProfile(agentDir);
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const modelRegistry = createModelRegistry();
|
|
mocks.resolveCopilotRuntimeAuth.mockResolvedValue({
|
|
apiKey: "identity-catalog-token",
|
|
baseUrl: "https://copilot-api.acme.ghe.com",
|
|
});
|
|
const observedHeaders: Headers[] = [];
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn<typeof fetch>(async (_url, init) => {
|
|
const headers = new Headers(init?.headers);
|
|
observedHeaders.push(headers);
|
|
return Response.json({
|
|
data: [
|
|
{
|
|
id: "gpt-5-mini",
|
|
model_picker_enabled: true,
|
|
policy: { state: "enabled" },
|
|
capabilities: {
|
|
type: "chat",
|
|
supports: { streaming: true, tool_calls: true },
|
|
limits: {
|
|
max_context_window_tokens:
|
|
headers.get("copilot-integration-id") === "vscode-chat" ? 100_000 : 200_000,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
});
|
|
}),
|
|
);
|
|
|
|
for (const identity of ["vscode-chat", "copilot-developer-cli"]) {
|
|
const config = {
|
|
models: {
|
|
providers: {
|
|
"github-copilot": {
|
|
baseUrl: "https://copilot-api.acme.ghe.com",
|
|
models: [],
|
|
headers: {
|
|
"copilot-integration-id": identity,
|
|
"X-Private-Header": "not-for-catalog",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
};
|
|
const result = await provider.catalog.run({
|
|
agentDir,
|
|
env: { COPILOT_GITHUB_TOKEN: "identity-catalog-token" },
|
|
config,
|
|
});
|
|
const contextWindow = identity === "vscode-chat" ? 100_000 : 200_000;
|
|
expect(result && "provider" in result ? result.provider.models : []).toMatchObject([
|
|
{ id: "gpt-5-mini", contextWindow },
|
|
]);
|
|
const context = {
|
|
config,
|
|
agentDir,
|
|
modelRegistry,
|
|
provider: "github-copilot",
|
|
modelId: "gpt-5-mini",
|
|
authProfileId: "github-copilot:github",
|
|
};
|
|
await provider.prepareDynamicModel(context);
|
|
expect(provider.resolveDynamicModel(context)).toMatchObject({
|
|
id: "gpt-5-mini",
|
|
contextWindow,
|
|
});
|
|
}
|
|
expect(observedHeaders).toHaveLength(2);
|
|
expect(observedHeaders.every((headers) => !headers.has("x-private-header"))).toBe(true);
|
|
});
|
|
|
|
it("owns session-bound replay thinking cleanup", () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const messages = [
|
|
{
|
|
role: "assistant",
|
|
content: [
|
|
{ type: "thinking", thinking: "private", thinkingSignature: "sig" },
|
|
{ type: "redacted_thinking", data: "opaque" },
|
|
{ type: "text", text: "visible" },
|
|
],
|
|
},
|
|
];
|
|
|
|
expect(
|
|
provider.buildReplayPolicy?.({
|
|
modelId: "claude-haiku-4.5",
|
|
modelApi: "anthropic-messages",
|
|
} as never),
|
|
).toMatchObject({
|
|
dropThinkingBlocks: true,
|
|
validateAnthropicTurns: true,
|
|
});
|
|
expect(
|
|
provider.sanitizeReplayHistory?.({
|
|
modelId: "claude-haiku-4.5",
|
|
modelApi: "anthropic-messages",
|
|
messages,
|
|
} as never),
|
|
).toEqual([
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "visible" }],
|
|
},
|
|
]);
|
|
expect(
|
|
provider.sanitizeReplayHistory?.({
|
|
modelApi: "openai-responses",
|
|
modelId: "gpt-5.4",
|
|
messages,
|
|
} as never),
|
|
).toEqual([
|
|
{
|
|
role: "assistant",
|
|
content: [{ type: "text", text: "visible" }],
|
|
},
|
|
]);
|
|
expect(
|
|
provider.sanitizeReplayHistory?.({
|
|
modelApi: "openai-completions",
|
|
modelId: "gpt-5.4",
|
|
messages,
|
|
} as never),
|
|
).toBe(messages);
|
|
});
|
|
|
|
it("registers embedding provider", () => {
|
|
const registerEmbeddingProviderMock = vi.fn<OpenClawPluginApi["registerEmbeddingProvider"]>();
|
|
|
|
plugin.register(
|
|
createTestPluginApi({
|
|
id: "github-copilot",
|
|
name: "GitHub Copilot",
|
|
source: "test",
|
|
config: {},
|
|
pluginConfig: {},
|
|
runtime: {} as never,
|
|
registerProvider: vi.fn(),
|
|
registerEmbeddingProvider: registerEmbeddingProviderMock,
|
|
}),
|
|
);
|
|
|
|
expect(registerEmbeddingProviderMock).toHaveBeenCalledTimes(1);
|
|
const adapter = requireFirstMockArg<RegisteredEmbeddingProvider>(
|
|
registerEmbeddingProviderMock,
|
|
"embedding provider registration",
|
|
);
|
|
expect(adapter.id).toBe("github-copilot");
|
|
});
|
|
|
|
it.each([
|
|
{
|
|
label: "a stored token account",
|
|
profile: {
|
|
type: "token" as const,
|
|
provider: "github-copilot",
|
|
token: "preferred-token",
|
|
},
|
|
expectedToken: "preferred-token",
|
|
expectedDomain: "github.com",
|
|
},
|
|
{
|
|
label: "a public OAuth account with stale enterprise configuration",
|
|
profile: {
|
|
type: "oauth" as const,
|
|
provider: "github-copilot",
|
|
access: "short-lived-copilot-token",
|
|
refresh: "durable-github-token",
|
|
expires: Date.now() + 60_000,
|
|
},
|
|
expectedToken: "durable-github-token",
|
|
expectedDomain: "github.com",
|
|
configuredDomain: "other.ghe.com",
|
|
},
|
|
{
|
|
label: "an enterprise OAuth account",
|
|
profile: {
|
|
type: "oauth" as const,
|
|
provider: "github-copilot",
|
|
access: "short-lived-copilot-token",
|
|
refresh: "durable-github-token",
|
|
expires: Date.now() + 60_000,
|
|
enterpriseUrl: "acme.ghe.com",
|
|
},
|
|
expectedToken: "durable-github-token",
|
|
expectedDomain: "acme.ghe.com",
|
|
configuredDomain: "other.ghe.com",
|
|
},
|
|
{
|
|
label: "an enterprise OAuth account with an explicit environment domain",
|
|
profile: {
|
|
type: "oauth" as const,
|
|
provider: "github-copilot",
|
|
access: "short-lived-copilot-token",
|
|
refresh: "durable-github-token",
|
|
expires: Date.now() + 60_000,
|
|
enterpriseUrl: "acme.ghe.com",
|
|
},
|
|
expectedToken: "durable-github-token",
|
|
expectedDomain: "override.ghe.com",
|
|
configuredDomain: "other.ghe.com",
|
|
envDomain: "override.ghe.com",
|
|
},
|
|
])("uses $label when discovering its live Copilot model catalog", async (testCase) => {
|
|
const agentDir = await createAgentDir();
|
|
saveAuthProfileStore(
|
|
{
|
|
version: 1,
|
|
profiles: {
|
|
"github-copilot:first": {
|
|
type: "token",
|
|
provider: "github-copilot",
|
|
token: "first-token",
|
|
},
|
|
"github-copilot:preferred": testCase.profile,
|
|
},
|
|
},
|
|
agentDir,
|
|
{ filterExternalAuthProfiles: false, syncExternalCli: false },
|
|
);
|
|
mocks.resolveCopilotRuntimeAuth.mockResolvedValueOnce({
|
|
apiKey: "preferred-copilot-token",
|
|
baseUrl: "https://api.githubcopilot.preferred",
|
|
});
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(
|
|
async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
data: [
|
|
{
|
|
id: "gpt-5.4",
|
|
name: "GPT-5.4",
|
|
model_picker_enabled: true,
|
|
policy: { state: "enabled" },
|
|
capabilities: {
|
|
type: "chat",
|
|
limits: { max_context_window_tokens: 200_000, max_output_tokens: 64_000 },
|
|
supports: { streaming: true, tool_calls: true },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
),
|
|
),
|
|
);
|
|
const provider = registerProviderWithPluginConfig({});
|
|
|
|
const env = testCase.envDomain ? { COPILOT_GITHUB_DOMAIN: testCase.envDomain } : {};
|
|
const result = await provider.catalog.run({
|
|
config: {
|
|
auth: { order: { "github-copilot": ["github-copilot:preferred"] } },
|
|
...(testCase.configuredDomain
|
|
? {
|
|
models: {
|
|
providers: {
|
|
"github-copilot": {
|
|
baseUrl: "https://api.githubcopilot.com",
|
|
models: [],
|
|
params: { githubDomain: testCase.configuredDomain },
|
|
},
|
|
},
|
|
},
|
|
}
|
|
: {}),
|
|
},
|
|
agentDir,
|
|
env,
|
|
});
|
|
|
|
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenCalledWith({
|
|
githubToken: testCase.expectedToken,
|
|
env,
|
|
githubDomain: testCase.expectedDomain,
|
|
});
|
|
expect(
|
|
result && "provider" in result ? result.provider.models.map((model) => model.id) : [],
|
|
).toEqual(["gpt-5.4"]);
|
|
});
|
|
|
|
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.resolveCopilotRuntimeAuth).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not exchange auth or discover models for an unavailable direct SecretRef", async () => {
|
|
const agentDir = await createAgentDir();
|
|
const provider = registerProviderWithPluginConfig({});
|
|
|
|
await expect(
|
|
provider.catalog.run({
|
|
config: {
|
|
models: {
|
|
providers: {
|
|
"github-copilot": {
|
|
apiKey: {
|
|
source: "env",
|
|
provider: "default",
|
|
id: "OPENCLAW_MISSING_COPILOT_CATALOG_TOKEN",
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
agentDir,
|
|
env: { COPILOT_GITHUB_TOKEN: "ambient-token" },
|
|
}),
|
|
).rejects.toThrow("models.providers.github-copilot.apiKey");
|
|
|
|
expect(mocks.resolveCopilotRuntimeAuth).not.toHaveBeenCalled();
|
|
expect(mocks.fetchWithSsrFGuard).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("exposes xhigh thinking for catalog-supported Copilot reasoning efforts", () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
|
|
const profile = provider.resolveThinkingProfile({
|
|
provider: "github-copilot",
|
|
modelId: "claude-opus-4.7-1m-internal",
|
|
compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh"] },
|
|
});
|
|
|
|
expect(profile?.levels.map((level) => level.id)).toContain("xhigh");
|
|
});
|
|
|
|
it("exposes xhigh and max thinking for the bundled Claude Opus 5 model", () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const model = expectDefined(
|
|
manifest.modelCatalog.providers["github-copilot"].models.find(
|
|
(candidate) => candidate.id === "claude-opus-5",
|
|
),
|
|
"bundled GitHub Copilot Claude Opus 5 model",
|
|
);
|
|
|
|
const profile = provider.resolveThinkingProfile({
|
|
provider: "github-copilot",
|
|
modelId: model.id,
|
|
compat: model.compat,
|
|
});
|
|
|
|
expect(profile?.levels.map((level) => level.id)).toEqual(
|
|
expect.arrayContaining(["xhigh", "max"]),
|
|
);
|
|
});
|
|
|
|
it("exposes max thinking for catalog-supported Copilot reasoning efforts", () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
|
|
const profile = provider.resolveThinkingProfile({
|
|
provider: "github-copilot",
|
|
modelId: "claude-fable-5",
|
|
compat: { supportedReasoningEfforts: ["low", "medium", "high", "max"] },
|
|
});
|
|
|
|
expect(profile?.levels.map((level) => level.id)).toContain("max");
|
|
});
|
|
|
|
it("does not expose max for non-adaptive Claude Copilot models", () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
|
|
const profile = provider.resolveThinkingProfile({
|
|
provider: "github-copilot",
|
|
modelId: "claude-opus-4-5",
|
|
compat: { supportedReasoningEfforts: ["low", "medium", "high", "max"] },
|
|
});
|
|
|
|
expect(profile?.levels.map((level) => level.id)).not.toContain("max");
|
|
});
|
|
|
|
it("exposes xhigh thinking for non-Claude Copilot models with catalog xhigh effort", () => {
|
|
// Regression for #59416: mini-family models (e.g. gpt-5.4-mini) are
|
|
// entitled to xhigh per live /models, but the static xhigh allowlist only
|
|
// contains gpt-5.4 and gpt-5.3-codex. When live metadata wins, the
|
|
// resolved compat must drive xhigh for these non-Claude ids as well.
|
|
const provider = registerProviderWithPluginConfig({});
|
|
|
|
const profile = provider.resolveThinkingProfile({
|
|
provider: "github-copilot",
|
|
modelId: "gpt-5.4-mini",
|
|
compat: { supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh"] },
|
|
});
|
|
|
|
expect(profile?.levels.map((level) => level.id)).toContain("xhigh");
|
|
});
|
|
|
|
it("omits xhigh for non-Claude Copilot models whose catalog effort lacks it", () => {
|
|
// Negative half of the #59416 regression: live-first must not over-grant.
|
|
// gpt-5-mini reports only [low, medium, high] live, so xhigh must stay off
|
|
// even though the reporter asked for the whole mini family to gain it.
|
|
const provider = registerProviderWithPluginConfig({});
|
|
|
|
const profile = provider.resolveThinkingProfile({
|
|
provider: "github-copilot",
|
|
modelId: "gpt-5-mini",
|
|
compat: { supportedReasoningEfforts: ["low", "medium", "high"] },
|
|
});
|
|
|
|
expect(profile?.levels.map((level) => level.id)).not.toContain("xhigh");
|
|
});
|
|
|
|
it("uses live plugin config to re-enable discovery after startup disable", async () => {
|
|
mocks.resolveCopilotRuntimeAuth.mockResolvedValueOnce({
|
|
apiKey: "gh_test_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.resolveCopilotRuntimeAuth).toHaveBeenCalledWith({
|
|
githubToken: "gh_test_token",
|
|
env: { GH_TOKEN: "gh_test_token" },
|
|
githubDomain: "github.com",
|
|
});
|
|
expect(result).toEqual({
|
|
provider: {
|
|
baseUrl: "https://api.githubcopilot.live",
|
|
models: [],
|
|
},
|
|
});
|
|
});
|
|
|
|
it("publishes only picker-visible, policy-enabled tool models in the live catalog", async () => {
|
|
mocks.resolveCopilotRuntimeAuth.mockResolvedValueOnce({
|
|
apiKey: "catalog-policy-token",
|
|
baseUrl: "https://api.githubcopilot.policy-test",
|
|
});
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi.fn(
|
|
async () =>
|
|
new Response(
|
|
JSON.stringify({
|
|
data: [
|
|
{
|
|
id: "eligible",
|
|
name: "Eligible",
|
|
model_picker_enabled: true,
|
|
model_picker_category: "versatile",
|
|
policy: { state: "enabled" },
|
|
capabilities: {
|
|
type: "chat",
|
|
limits: { max_context_window_tokens: 200_000, max_output_tokens: 64_000 },
|
|
supports: { streaming: true, tool_calls: true },
|
|
},
|
|
},
|
|
{
|
|
id: "disabled",
|
|
name: "Disabled",
|
|
model_picker_enabled: true,
|
|
policy: { state: "disabled" },
|
|
capabilities: {
|
|
type: "chat",
|
|
supports: { streaming: true, tool_calls: true },
|
|
},
|
|
},
|
|
{
|
|
id: "hidden",
|
|
name: "Hidden",
|
|
model_picker_enabled: false,
|
|
policy: { state: "enabled" },
|
|
capabilities: {
|
|
type: "chat",
|
|
supports: { streaming: true, tool_calls: true },
|
|
},
|
|
},
|
|
{
|
|
id: "chat-only",
|
|
name: "Chat only",
|
|
model_picker_enabled: true,
|
|
policy: { state: "enabled" },
|
|
capabilities: {
|
|
type: "chat",
|
|
supports: { streaming: false, tool_calls: false },
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
),
|
|
),
|
|
);
|
|
const provider = registerProviderWithPluginConfig({});
|
|
|
|
const result = await provider.catalog.run({
|
|
config: {},
|
|
agentDir: "/tmp/agent",
|
|
env: { GH_TOKEN: "catalog-source-token" },
|
|
} as never);
|
|
|
|
expect(
|
|
result && "provider" in result ? result.provider.models.map((model) => model.id) : [],
|
|
).toEqual(["eligible", "chat-only"]);
|
|
});
|
|
|
|
it("dual-publishes unified live catalog rows with existing discovery semantics", async () => {
|
|
mocks.resolveCopilotRuntimeAuth.mockResolvedValueOnce({
|
|
apiKey: "gh_test_token",
|
|
baseUrl: "https://api.githubcopilot.live",
|
|
});
|
|
const { modelCatalogProvider } = registerProviderAndCatalogWithPluginConfig({
|
|
discovery: { enabled: false },
|
|
});
|
|
|
|
const result = await modelCatalogProvider.liveCatalog({
|
|
config: {
|
|
plugins: {
|
|
entries: {
|
|
"github-copilot": {
|
|
config: {
|
|
discovery: { enabled: true },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
agentDir: "/tmp/agent",
|
|
env: { GH_TOKEN: "gh_test_token" },
|
|
resolveProviderApiKey: () => ({ apiKey: "gh_test_token" }),
|
|
resolveProviderAuth: () => ({
|
|
apiKey: "gh_test_token",
|
|
mode: "token",
|
|
source: "env",
|
|
}),
|
|
} as never);
|
|
|
|
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenCalledWith({
|
|
githubToken: "gh_test_token",
|
|
env: { GH_TOKEN: "gh_test_token" },
|
|
githubDomain: "github.com",
|
|
});
|
|
expect(result).toEqual([]);
|
|
});
|
|
|
|
it("offers to reuse an existing token profile during interactive onboarding", async () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const method = requireAuthMethod(provider.auth, 0);
|
|
const agentDir = await createAgentDir();
|
|
writeExistingCopilotTokenProfile(agentDir);
|
|
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(result).toEqual({
|
|
profiles: [
|
|
{
|
|
profileId: "github-copilot:github",
|
|
credential: {
|
|
type: "token",
|
|
provider: "github-copilot",
|
|
token: "existing-token",
|
|
},
|
|
},
|
|
],
|
|
defaultModel: "github-copilot/claude-sonnet-5",
|
|
});
|
|
expect(mocks.resolveCopilotStarterModel).toHaveBeenCalledWith({
|
|
githubToken: "existing-token",
|
|
env: {},
|
|
githubDomain: "github.com",
|
|
config: {},
|
|
});
|
|
});
|
|
|
|
it("keeps valid interactive auth when live starter-model discovery is unavailable", async () => {
|
|
mocks.resolveCopilotStarterModel.mockRejectedValueOnce(new Error("catalog unavailable"));
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const method = requireAuthMethod(provider.auth, 0);
|
|
const agentDir = await createAgentDir();
|
|
writeExistingCopilotTokenProfile(agentDir);
|
|
|
|
const result = await method.run({
|
|
config: {},
|
|
env: {},
|
|
agentDir,
|
|
workspaceDir: "/tmp/workspace",
|
|
prompter: {
|
|
confirm: vi.fn(async () => false),
|
|
note: vi.fn(),
|
|
},
|
|
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(result).toMatchObject({
|
|
profiles: [
|
|
{
|
|
profileId: "github-copilot:github",
|
|
credential: { type: "token", provider: "github-copilot", token: "existing-token" },
|
|
},
|
|
],
|
|
notes: [expect.stringContaining("authentication succeeded")],
|
|
});
|
|
expect(result?.defaultModel).toBeUndefined();
|
|
});
|
|
|
|
describe("github-copilot dynamic model resolution", () => {
|
|
it("uses live catalog metadata for request-time model resolution", async () => {
|
|
const agentDir = await createAgentDir();
|
|
saveAuthProfileStore(
|
|
{
|
|
version: 1,
|
|
profiles: {
|
|
"github-copilot:first": {
|
|
type: "token",
|
|
provider: "github-copilot",
|
|
token: "first",
|
|
},
|
|
"github-copilot:selected": {
|
|
type: "token",
|
|
provider: "github-copilot",
|
|
token: "chosen",
|
|
},
|
|
},
|
|
},
|
|
agentDir,
|
|
{ filterExternalAuthProfiles: false, syncExternalCli: false },
|
|
);
|
|
mocks.resolveCopilotRuntimeAuth
|
|
.mockResolvedValueOnce({
|
|
apiKey: "chosen",
|
|
baseUrl: "https://api.githubcopilot.live",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
apiKey: "first",
|
|
baseUrl: "https://api.githubcopilot.first",
|
|
});
|
|
const catalogResponse = (contextWindow: number, promptTokens: number) =>
|
|
new Response(
|
|
JSON.stringify({
|
|
data: [
|
|
{
|
|
id: "gpt-5.6-sol",
|
|
name: "GPT-5.6 Sol",
|
|
object: "model",
|
|
vendor: "OpenAI",
|
|
capabilities: {
|
|
type: "chat",
|
|
limits: {
|
|
max_context_window_tokens: contextWindow,
|
|
max_prompt_tokens: promptTokens,
|
|
max_output_tokens: 128_000,
|
|
},
|
|
supports: {
|
|
vision: true,
|
|
reasoning_effort: ["none", "low", "medium", "high", "xhigh"],
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi
|
|
.fn()
|
|
.mockResolvedValueOnce(catalogResponse(1_050_000, 922_000))
|
|
.mockResolvedValueOnce(catalogResponse(400_000, 272_000)),
|
|
);
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const modelRegistry = createModelRegistry();
|
|
const selectedContext = {
|
|
config: {},
|
|
agentDir,
|
|
provider: "github-copilot",
|
|
modelId: "gpt-5.6-sol",
|
|
modelRegistry,
|
|
authProfileId: "github-copilot:selected",
|
|
} as Parameters<typeof provider.prepareDynamicModel>[0];
|
|
const firstContext = {
|
|
...selectedContext,
|
|
authProfileId: "github-copilot:first",
|
|
};
|
|
|
|
await provider.prepareDynamicModel(selectedContext);
|
|
await provider.prepareDynamicModel(firstContext);
|
|
|
|
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenNthCalledWith(1, {
|
|
githubToken: "chosen",
|
|
env: process.env,
|
|
githubDomain: "github.com",
|
|
});
|
|
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenNthCalledWith(2, {
|
|
githubToken: "first",
|
|
env: process.env,
|
|
githubDomain: "github.com",
|
|
});
|
|
expect(provider.preferRuntimeResolvedModel(selectedContext)).toBe(true);
|
|
expect(provider.resolveDynamicModel(selectedContext)).toMatchObject({
|
|
id: "gpt-5.6-sol",
|
|
provider: "github-copilot",
|
|
baseUrl: "https://api.githubcopilot.live",
|
|
contextWindow: 1_050_000,
|
|
contextTokens: 922_000,
|
|
maxTokens: 128_000,
|
|
});
|
|
expect(provider.resolveDynamicModel(firstContext)).toMatchObject({
|
|
id: "gpt-5.6-sol",
|
|
provider: "github-copilot",
|
|
baseUrl: "https://api.githubcopilot.first",
|
|
contextWindow: 400_000,
|
|
contextTokens: 272_000,
|
|
maxTokens: 128_000,
|
|
});
|
|
});
|
|
|
|
it("rematerializes direct-config metadata after a profile fallback", async () => {
|
|
const agentDir = await createAgentDir();
|
|
saveAuthProfileStore(
|
|
{
|
|
version: 1,
|
|
profiles: {
|
|
"github-copilot:first": {
|
|
type: "token",
|
|
provider: "github-copilot",
|
|
token: "test-auth-token",
|
|
},
|
|
},
|
|
},
|
|
agentDir,
|
|
{ filterExternalAuthProfiles: false, syncExternalCli: false },
|
|
);
|
|
mocks.resolveCopilotRuntimeAuth
|
|
.mockResolvedValueOnce({
|
|
apiKey: "test-auth-token",
|
|
baseUrl: "https://api.githubcopilot.profile",
|
|
})
|
|
.mockResolvedValueOnce({
|
|
apiKey: "test-token-placeholder",
|
|
baseUrl: "https://api.githubcopilot.direct",
|
|
});
|
|
const catalogResponse = (contextWindow: number, promptTokens: number) =>
|
|
new Response(
|
|
JSON.stringify({
|
|
data: [
|
|
{
|
|
id: "gpt-5.6-sol",
|
|
name: "GPT-5.6 Sol",
|
|
object: "model",
|
|
vendor: "OpenAI",
|
|
capabilities: {
|
|
type: "chat",
|
|
limits: {
|
|
max_context_window_tokens: contextWindow,
|
|
max_prompt_tokens: promptTokens,
|
|
max_output_tokens: 128_000,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
{ status: 200, headers: { "content-type": "application/json" } },
|
|
);
|
|
vi.stubGlobal(
|
|
"fetch",
|
|
vi
|
|
.fn()
|
|
.mockResolvedValueOnce(catalogResponse(200_000, 150_000))
|
|
.mockResolvedValueOnce(catalogResponse(1_050_000, 922_000)),
|
|
);
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const modelRegistry = createModelRegistry();
|
|
const config = {
|
|
models: {
|
|
providers: {
|
|
"github-copilot": {
|
|
apiKey: "test-token-placeholder",
|
|
baseUrl: "https://api.githubcopilot.test",
|
|
models: [],
|
|
},
|
|
},
|
|
},
|
|
} as OpenClawConfig;
|
|
const profileContext = {
|
|
config,
|
|
agentDir,
|
|
provider: "github-copilot",
|
|
modelId: "gpt-5.6-sol",
|
|
modelRegistry,
|
|
authProfileId: "github-copilot:first",
|
|
} as Parameters<typeof provider.prepareDynamicModel>[0];
|
|
const directContext = {
|
|
...profileContext,
|
|
authProfileId: undefined,
|
|
authProfileMode: "api_key" as const,
|
|
};
|
|
|
|
// The first profile's credential can fail later during runtime auth. The
|
|
// prepared direct fallback must then replace its account-scoped limits.
|
|
await provider.prepareDynamicModel(profileContext);
|
|
await provider.prepareDynamicModel(directContext);
|
|
|
|
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenNthCalledWith(1, {
|
|
githubToken: "test-auth-token",
|
|
env: process.env,
|
|
githubDomain: "github.com",
|
|
});
|
|
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenNthCalledWith(2, {
|
|
githubToken: "test-token-placeholder",
|
|
env: process.env,
|
|
githubDomain: "github.com",
|
|
});
|
|
expect(provider.resolveDynamicModel(profileContext)).toMatchObject({
|
|
baseUrl: "https://api.githubcopilot.profile",
|
|
contextWindow: 200_000,
|
|
contextTokens: 150_000,
|
|
});
|
|
expect(provider.resolveDynamicModel(directContext)).toMatchObject({
|
|
baseUrl: "https://api.githubcopilot.direct",
|
|
contextWindow: 1_050_000,
|
|
contextTokens: 922_000,
|
|
});
|
|
});
|
|
});
|
|
|
|
it("can refresh an existing token profile during interactive onboarding", async () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const method = requireAuthMethod(provider.auth, 0);
|
|
const agentDir = await createAgentDir();
|
|
writeExistingCopilotTokenProfile(agentDir);
|
|
const fetchMock = vi.fn(async (input: unknown, _init?: RequestInit) => {
|
|
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);
|
|
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
|
response: await fetchMock(params.url, params.init),
|
|
finalUrl: params.url,
|
|
release: async () => {},
|
|
}));
|
|
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,
|
|
});
|
|
if (!result) {
|
|
throw new Error("Expected GitHub Copilot auth result");
|
|
}
|
|
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;
|
|
}
|
|
}
|
|
});
|
|
|
|
function buildDeviceFlowFetchMock(domain: string, accessToken: string) {
|
|
return vi.fn(async (input: unknown, _init?: RequestInit) => {
|
|
const target =
|
|
typeof input === "string"
|
|
? input
|
|
: input instanceof URL
|
|
? input.toString()
|
|
: input instanceof Request
|
|
? input.url
|
|
: String(input);
|
|
if (target === `https://${domain}/login/device/code`) {
|
|
return new Response(
|
|
JSON.stringify({
|
|
device_code: "device-code-stub",
|
|
user_code: "ABCD-1234",
|
|
verification_uri: `https://${domain}/login/device`,
|
|
expires_in: 900,
|
|
interval: 0,
|
|
}),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
);
|
|
}
|
|
if (target === `https://${domain}/login/oauth/access_token`) {
|
|
return new Response(JSON.stringify({ access_token: accessToken, token_type: "bearer" }), {
|
|
status: 200,
|
|
headers: { "Content-Type": "application/json" },
|
|
});
|
|
}
|
|
throw new Error(`unexpected fetch in github-copilot device flow test: ${target}`);
|
|
});
|
|
}
|
|
|
|
async function withTty<T>(fn: () => Promise<T>): Promise<T> {
|
|
const isTtyDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
|
|
Object.defineProperty(process.stdin, "isTTY", { configurable: true, value: true });
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
vi.unstubAllGlobals();
|
|
if (isTtyDescriptor) {
|
|
Object.defineProperty(process.stdin, "isTTY", isTtyDescriptor);
|
|
} else {
|
|
delete (process.stdin as { isTTY?: boolean }).isTTY;
|
|
}
|
|
}
|
|
}
|
|
|
|
it("forces re-login and clears the domain when switching from a tenant back to github.com", async () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const method = requireAuthMethod(provider.auth, 0);
|
|
const agentDir = await createAgentDir();
|
|
writeExistingCopilotTokenProfile(agentDir);
|
|
const fetchMock = buildDeviceFlowFetchMock("github.com", "public-fresh-token");
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
|
response: await fetchMock(params.url, params.init),
|
|
finalUrl: params.url,
|
|
release: async () => {},
|
|
}));
|
|
const prompter = {
|
|
confirm: vi.fn(async () => false),
|
|
text: vi.fn(async () => ""),
|
|
note: vi.fn(),
|
|
};
|
|
|
|
const result = await withTty(
|
|
async () =>
|
|
await method.run({
|
|
config: {
|
|
models: {
|
|
providers: { "github-copilot": { params: { githubDomain: "acme.ghe.com" } } },
|
|
},
|
|
},
|
|
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),
|
|
);
|
|
if (!result) {
|
|
throw new Error("Expected GitHub Copilot auth result");
|
|
}
|
|
|
|
// Domain switch must not offer to reuse the tenant-scoped token.
|
|
expect(prompter.confirm).not.toHaveBeenCalled();
|
|
expect(prompter.note).toHaveBeenCalled();
|
|
expect(result.profiles[0]?.credential).toEqual({
|
|
type: "token",
|
|
provider: "github-copilot",
|
|
token: "public-fresh-token",
|
|
});
|
|
const params = (
|
|
result.configPatch as {
|
|
models?: { providers?: Record<string, { params?: Record<string, unknown> }> };
|
|
}
|
|
)?.models?.providers?.["github-copilot"]?.params;
|
|
expect(params && Object.hasOwn(params, "githubDomain")).toBe(true);
|
|
expect(params?.githubDomain).toBeUndefined();
|
|
});
|
|
|
|
it("forces re-login when switching from github.com to a tenant domain", async () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const method = requireAuthMethod(provider.auth, 1);
|
|
const agentDir = await createAgentDir();
|
|
writeExistingCopilotTokenProfile(agentDir);
|
|
const fetchMock = buildDeviceFlowFetchMock("acme.ghe.com", "tenant-fresh-token");
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
|
response: await fetchMock(params.url, params.init),
|
|
finalUrl: params.url,
|
|
release: async () => {},
|
|
}));
|
|
const prompter = {
|
|
confirm: vi.fn(async () => false),
|
|
text: vi.fn(async () => "acme.ghe.com"),
|
|
note: vi.fn(),
|
|
};
|
|
|
|
const result = await withTty(
|
|
async () =>
|
|
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),
|
|
);
|
|
if (!result) {
|
|
throw new Error("Expected GitHub Copilot auth result");
|
|
}
|
|
|
|
// Existing public token must not be reused for the tenant endpoint.
|
|
expect(prompter.confirm).not.toHaveBeenCalled();
|
|
expect(result.profiles[0]?.credential).toEqual({
|
|
type: "token",
|
|
provider: "github-copilot",
|
|
token: "tenant-fresh-token",
|
|
});
|
|
const params = (
|
|
result.configPatch as {
|
|
models?: { providers?: Record<string, { params?: Record<string, unknown> }> };
|
|
}
|
|
)?.models?.providers?.["github-copilot"]?.params;
|
|
expect(params?.githubDomain).toBe("acme.ghe.com");
|
|
});
|
|
|
|
it("forces re-login when an existing public profile meets an env-only tenant domain", async () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const method = requireAuthMethod(provider.auth, 1);
|
|
const agentDir = await createAgentDir();
|
|
// Stored profile was minted for public github.com; config has no tenant.
|
|
writeExistingCopilotTokenProfile(agentDir);
|
|
// Tenant comes ONLY from the env override. The reuse gate must derive the
|
|
// previous domain from persisted config (github.com), not from the env, so
|
|
// the domain change is detected and the public token is not reused.
|
|
const fetchMock = buildDeviceFlowFetchMock("acme.ghe.com", "tenant-fresh-token");
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
|
response: await fetchMock(params.url, params.init),
|
|
finalUrl: params.url,
|
|
release: async () => {},
|
|
}));
|
|
const prompter = {
|
|
confirm: vi.fn(async () => false),
|
|
text: vi.fn(async () => "acme.ghe.com"),
|
|
note: vi.fn(),
|
|
};
|
|
|
|
const result = await withTty(
|
|
async () =>
|
|
await method.run({
|
|
config: {},
|
|
env: { COPILOT_GITHUB_DOMAIN: "acme.ghe.com" },
|
|
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),
|
|
);
|
|
if (!result) {
|
|
throw new Error("Expected GitHub Copilot auth result");
|
|
}
|
|
|
|
// Env is authoritative for the chosen domain, so the interactive prompt is
|
|
// skipped; the stale public token must NOT be reused for the tenant.
|
|
expect(prompter.text).not.toHaveBeenCalled();
|
|
expect(prompter.confirm).not.toHaveBeenCalled();
|
|
expect(result.profiles[0]?.credential).toEqual({
|
|
type: "token",
|
|
provider: "github-copilot",
|
|
token: "tenant-fresh-token",
|
|
});
|
|
const params = (
|
|
result.configPatch as {
|
|
models?: { providers?: Record<string, { params?: Record<string, unknown> }> };
|
|
}
|
|
)?.models?.providers?.["github-copilot"]?.params;
|
|
expect(params?.githubDomain).toBe("acme.ghe.com");
|
|
});
|
|
|
|
it("still offers to reuse the token when re-running enterprise login for the same tenant", async () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const method = requireAuthMethod(provider.auth, 1);
|
|
const agentDir = await createAgentDir();
|
|
writeExistingCopilotTokenProfile(agentDir);
|
|
const prompter = {
|
|
confirm: vi.fn(async () => false),
|
|
text: vi.fn(async () => "acme.ghe.com"),
|
|
note: vi.fn(),
|
|
};
|
|
|
|
const result = await method.run({
|
|
config: {
|
|
models: { providers: { "github-copilot": { params: { githubDomain: "acme.ghe.com" } } } },
|
|
},
|
|
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);
|
|
if (!result) {
|
|
throw new Error("Expected GitHub Copilot auth result");
|
|
}
|
|
|
|
expect(prompter.confirm).toHaveBeenCalledWith({
|
|
message: "GitHub Copilot auth already exists. Re-run login?",
|
|
initialValue: false,
|
|
});
|
|
expect(result.profiles[0]?.credential).toEqual({
|
|
type: "token",
|
|
provider: "github-copilot",
|
|
token: "existing-token",
|
|
});
|
|
const params = (
|
|
result.configPatch as {
|
|
models?: { providers?: Record<string, { params?: Record<string, unknown> }> };
|
|
}
|
|
)?.models?.providers?.["github-copilot"]?.params;
|
|
expect(params?.githubDomain).toBe("acme.ghe.com");
|
|
});
|
|
|
|
it("honors COPILOT_GITHUB_DOMAIN over a divergent prompt value during enterprise login", async () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const method = requireAuthMethod(provider.auth, 1);
|
|
const agentDir = await createAgentDir();
|
|
// Device flow is mocked for the env tenant only; if login used the typed
|
|
// prompt value instead, the fetch mock would throw on an unexpected host.
|
|
const fetchMock = buildDeviceFlowFetchMock("env-tenant.ghe.com", "env-tenant-token");
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => ({
|
|
response: await fetchMock(params.url, params.init),
|
|
finalUrl: params.url,
|
|
release: async () => {},
|
|
}));
|
|
const prompter = {
|
|
confirm: vi.fn(async () => false),
|
|
text: vi.fn(async () => "typed-tenant.ghe.com"),
|
|
note: vi.fn(),
|
|
};
|
|
|
|
const result = await withTty(
|
|
async () =>
|
|
await method.run({
|
|
config: {},
|
|
env: { COPILOT_GITHUB_DOMAIN: "env-tenant.ghe.com" },
|
|
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),
|
|
);
|
|
if (!result) {
|
|
throw new Error("Expected GitHub Copilot auth result");
|
|
}
|
|
|
|
// The interactive domain prompt must be skipped entirely when the env
|
|
// override is set, so a typed value can never diverge from it.
|
|
expect(prompter.text).not.toHaveBeenCalled();
|
|
expect(result.profiles[0]?.credential).toEqual({
|
|
type: "token",
|
|
provider: "github-copilot",
|
|
token: "env-tenant-token",
|
|
});
|
|
const params = (
|
|
result.configPatch as {
|
|
models?: { providers?: Record<string, { params?: Record<string, unknown> }> };
|
|
}
|
|
)?.models?.providers?.["github-copilot"]?.params;
|
|
expect(params?.githubDomain).toBe("env-tenant.ghe.com");
|
|
});
|
|
|
|
it("rejects unsafe GitHub device code lifetimes before polling", async () => {
|
|
const release = vi.fn(async () => {});
|
|
mocks.fetchWithSsrFGuard.mockImplementation(async () => ({
|
|
response: new Response(
|
|
'{"device_code":"device-code-stub","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":1e309,"interval":0}',
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
finalUrl: "https://github.com/login/device/code",
|
|
release,
|
|
}));
|
|
|
|
const showCode = vi.fn();
|
|
await expect(runGitHubCopilotDeviceFlow({ showCode })).rejects.toThrow(
|
|
"GitHub device code response missing fields",
|
|
);
|
|
expect(showCode).not.toHaveBeenCalled();
|
|
expect(release).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("rejects GitHub device code expiries outside the Date timestamp range before polling", async () => {
|
|
const release = vi.fn(async () => {});
|
|
const nowSpy = vi.spyOn(Date, "now").mockReturnValue(MAX_DATE_TIMESTAMP_MS);
|
|
mocks.fetchWithSsrFGuard.mockImplementation(async () => ({
|
|
response: new Response(
|
|
'{"device_code":"device-code-stub","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":1,"interval":0}',
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
finalUrl: "https://github.com/login/device/code",
|
|
release,
|
|
}));
|
|
|
|
const showCode = vi.fn();
|
|
try {
|
|
await expect(runGitHubCopilotDeviceFlow({ showCode })).rejects.toThrow(
|
|
"GitHub device code response missing fields",
|
|
);
|
|
} finally {
|
|
nowSpy.mockRestore();
|
|
}
|
|
expect(showCode).not.toHaveBeenCalled();
|
|
expect(release).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("bounds oversized GitHub device polling intervals before waiting", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const release = vi.fn(async () => {});
|
|
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
|
let accessTokenPolls = 0;
|
|
mocks.fetchWithSsrFGuard.mockImplementation(async (params) => {
|
|
if (params.url === "https://github.com/login/device/code") {
|
|
return {
|
|
response: new Response(
|
|
'{"device_code":"device-code-stub","user_code":"ABCD-1234","verification_uri":"https://github.com/login/device","expires_in":3000010,"interval":3000000}',
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
finalUrl: params.url,
|
|
release,
|
|
};
|
|
}
|
|
accessTokenPolls += 1;
|
|
return {
|
|
response: new Response(
|
|
JSON.stringify({ access_token: "refreshed-token", token_type: "bearer" }),
|
|
{ status: 200, headers: { "Content-Type": "application/json" } },
|
|
),
|
|
finalUrl: params.url,
|
|
release,
|
|
};
|
|
});
|
|
|
|
const flow = runGitHubCopilotDeviceFlow({ showCode: vi.fn(async () => {}) });
|
|
await vi.waitFor(() =>
|
|
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS),
|
|
);
|
|
await vi.advanceTimersByTimeAsync(MAX_TIMER_TIMEOUT_MS);
|
|
expect(accessTokenPolls).toBe(0);
|
|
|
|
await vi.advanceTimersByTimeAsync(3_000_000_000 - MAX_TIMER_TIMEOUT_MS);
|
|
await expect(flow).resolves.toEqual({
|
|
status: "authorized",
|
|
accessToken: "refreshed-token",
|
|
});
|
|
expect(accessTokenPolls).toBe(1);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
it("stores GitHub Copilot token from non-interactive onboarding", async () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const method = requireAuthMethod(provider.auth, 0);
|
|
const choice = expectDefined(
|
|
manifest.providerAuthChoices.find((entry) => entry.choiceId === "github-copilot"),
|
|
"GitHub Copilot manifest auth choice",
|
|
);
|
|
const optionKey = expectDefined(choice.optionKey, "GitHub Copilot option key");
|
|
const setupProvider = expectDefined(
|
|
manifest.setup.providers.find((entry) => entry.id === choice.provider),
|
|
"GitHub Copilot setup provider",
|
|
);
|
|
const envVar = expectDefined(setupProvider.envVars[0], "GitHub Copilot setup env var");
|
|
const agentDir = await createAgentDir();
|
|
const runtime = { error: vi.fn(), exit: vi.fn() };
|
|
const resolveApiKey = vi.fn(async () => ({
|
|
key: "ghu_test123",
|
|
source: "flag" as const,
|
|
}));
|
|
|
|
const result = await method.runNonInteractive({
|
|
authChoice: choice.choiceId,
|
|
config: {},
|
|
baseConfig: {},
|
|
opts: { [optionKey]: "ghu_test\r\n123" },
|
|
runtime,
|
|
agentDir,
|
|
resolveApiKey,
|
|
toApiKeyCredential: vi.fn(),
|
|
});
|
|
|
|
expect(provider.id).toBe(choice.provider);
|
|
expect(method.id).toBe(choice.method);
|
|
expect(provider.envVars).toEqual(setupProvider.envVars);
|
|
expect(resolveApiKey).toHaveBeenCalledWith({
|
|
provider: choice.provider,
|
|
flagValue: "ghu_test123",
|
|
flagName: choice.cliFlag,
|
|
envVar,
|
|
envVarName: envVar,
|
|
allowProfile: false,
|
|
required: false,
|
|
});
|
|
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-sonnet-5",
|
|
});
|
|
expect(result?.agents?.defaults?.models?.["github-copilot/claude-sonnet-5"]).toStrictEqual({});
|
|
|
|
const profile = ensureAuthProfileStore(agentDir).profiles["github-copilot:github"];
|
|
expect(profile).toEqual({
|
|
type: "token",
|
|
provider: "github-copilot",
|
|
token: "ghu_test123",
|
|
});
|
|
});
|
|
|
|
it("does not persist a new token when non-interactive starter-model discovery fails", async () => {
|
|
mocks.resolveCopilotStarterModel.mockRejectedValueOnce(new Error("no eligible models"));
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const method = requireAuthMethod(provider.auth, 0);
|
|
const agentDir = await createAgentDir();
|
|
|
|
await expect(
|
|
method.runNonInteractive({
|
|
authChoice: "github-copilot",
|
|
config: {},
|
|
baseConfig: {},
|
|
opts: { githubCopilotToken: "ghu_invalid_for_catalog" },
|
|
runtime: { error: vi.fn(), exit: vi.fn() },
|
|
agentDir,
|
|
resolveApiKey: vi.fn(async () => ({
|
|
key: "ghu_invalid_for_catalog",
|
|
source: "flag" as const,
|
|
})),
|
|
toApiKeyCredential: vi.fn(),
|
|
}),
|
|
).rejects.toThrow("no eligible models");
|
|
|
|
expect(ensureAuthProfileStore(agentDir).profiles["github-copilot:github"]).toBeUndefined();
|
|
});
|
|
|
|
it("persists COPILOT_GITHUB_DOMAIN during non-interactive onboarding", async () => {
|
|
vi.stubEnv("COPILOT_GITHUB_DOMAIN", "acme.ghe.com");
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const method = requireAuthMethod(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_test123" },
|
|
runtime,
|
|
agentDir,
|
|
resolveApiKey: vi.fn(async () => ({
|
|
key: "ghu_test123",
|
|
source: "flag" as const,
|
|
})),
|
|
toApiKeyCredential: vi.fn(),
|
|
});
|
|
|
|
expect(runtime.error).not.toHaveBeenCalled();
|
|
expect(result?.models?.providers?.["github-copilot"]?.params?.githubDomain).toBe(
|
|
"acme.ghe.com",
|
|
);
|
|
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_test123",
|
|
});
|
|
});
|
|
|
|
it("clears a persisted enterprise domain during public non-interactive onboarding", async () => {
|
|
vi.stubEnv("COPILOT_GITHUB_DOMAIN", "github.com");
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const method = requireAuthMethod(provider.auth, 0);
|
|
const agentDir = await createAgentDir();
|
|
const runtime = { error: vi.fn(), exit: vi.fn() };
|
|
|
|
const result = await method.runNonInteractive({
|
|
authChoice: "github-copilot",
|
|
config: {
|
|
models: { providers: { "github-copilot": { params: { githubDomain: "acme.ghe.com" } } } },
|
|
},
|
|
baseConfig: {},
|
|
opts: { githubCopilotToken: "ghu_public" },
|
|
runtime,
|
|
agentDir,
|
|
resolveApiKey: vi.fn(async () => ({
|
|
key: "ghu_public",
|
|
source: "flag" as const,
|
|
})),
|
|
toApiKeyCredential: vi.fn(),
|
|
});
|
|
|
|
expect(runtime.error).not.toHaveBeenCalled();
|
|
expect(result?.models?.providers?.["github-copilot"]?.params?.githubDomain).toBeUndefined();
|
|
});
|
|
|
|
it("stores env-backed token refs for non-interactive onboarding ref mode", async () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const method = requireAuthMethod(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-sonnet-5",
|
|
});
|
|
|
|
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 = requireAuthMethod(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).toHaveBeenCalledTimes(2);
|
|
expect(resolveApiKey.mock.calls.map(([params]) => params)).toEqual([
|
|
{
|
|
provider: "github-copilot",
|
|
flagName: "--github-copilot-token",
|
|
envVar: "COPILOT_GITHUB_TOKEN",
|
|
envVarName: "COPILOT_GITHUB_TOKEN",
|
|
allowProfile: false,
|
|
required: false,
|
|
},
|
|
{
|
|
provider: "github-copilot",
|
|
flagName: "--github-copilot-token",
|
|
envVar: "GH_TOKEN",
|
|
envVarName: "GH_TOKEN",
|
|
allowProfile: false,
|
|
required: false,
|
|
},
|
|
]);
|
|
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 () => {
|
|
mocks.resolveCopilotRuntimeAuth.mockResolvedValueOnce({
|
|
apiKey: "ghu_test",
|
|
baseUrl: "https://api.individual.githubcopilot.com",
|
|
});
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const method = requireAuthMethod(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" },
|
|
});
|
|
expect(mocks.resolveCopilotStarterModel).not.toHaveBeenCalled();
|
|
expect(mocks.resolveCopilotRuntimeAuth).toHaveBeenCalledWith({
|
|
githubToken: "ghu_test",
|
|
env: process.env,
|
|
githubDomain: "github.com",
|
|
config: {
|
|
agents: {
|
|
defaults: {
|
|
model: {
|
|
primary: "github-copilot/gpt-5.4",
|
|
fallbacks: ["openai/gpt-5.4"],
|
|
},
|
|
models: {
|
|
"github-copilot/gpt-5.4": { label: "Existing" },
|
|
},
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("does not overwrite stored auth when a fresh token fails explicit-model validation", async () => {
|
|
mocks.resolveCopilotRuntimeAuth.mockRejectedValueOnce(new Error("invalid credential"));
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const method = requireAuthMethod(provider.auth, 0);
|
|
const agentDir = await createAgentDir();
|
|
writeExistingCopilotTokenProfile(agentDir);
|
|
|
|
await expect(
|
|
method.runNonInteractive({
|
|
authChoice: "github-copilot",
|
|
config: { agents: { defaults: { model: { primary: "github-copilot/gpt-5.4" } } } },
|
|
baseConfig: {},
|
|
opts: { githubCopilotToken: "fresh-invalid-token" },
|
|
runtime: { error: vi.fn(), exit: vi.fn() },
|
|
agentDir,
|
|
resolveApiKey: vi.fn(async () => ({
|
|
key: "fresh-invalid-token",
|
|
source: "flag" as const,
|
|
})),
|
|
toApiKeyCredential: vi.fn(),
|
|
}),
|
|
).rejects.toThrow("invalid credential");
|
|
|
|
expect(ensureAuthProfileStore(agentDir).profiles["github-copilot:github"]).toMatchObject({
|
|
token: "existing-token",
|
|
});
|
|
});
|
|
|
|
it("reuses an existing token profile during non-interactive onboarding", async () => {
|
|
const provider = registerProviderWithPluginConfig({});
|
|
const method = requireAuthMethod(provider.auth, 0);
|
|
const agentDir = await createAgentDir();
|
|
const runtime = { error: vi.fn(), exit: vi.fn() };
|
|
writeExistingCopilotTokenProfile(agentDir);
|
|
|
|
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 = requireAuthMethod(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"),
|
|
);
|
|
});
|
|
});
|
|
/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */
|