mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 13:30:42 +00:00
feat(onboard): support non-interactive GitHub Copilot token auth
Add manifest-owned GitHub Copilot token support for non-interactive onboarding, including documented env fallback, ref-mode tokenRef storage, saved-profile reuse, and default model wiring that preserves existing primary model configuration.
Validation:
- pnpm test extensions/github-copilot/index.test.ts src/plugins/contracts/registry.contract.test.ts src/commands/onboard-non-interactive/local/auth-choice-inference.test.ts
- pnpm check:changed
- CI green on aadac2c8d4
This commit is contained in:
@@ -1,4 +1,11 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
ensureAuthProfileStore,
|
||||
} from "../../src/agents/auth-profiles.js";
|
||||
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
|
||||
|
||||
const resolveCopilotApiTokenMock = vi.hoisted(() => vi.fn());
|
||||
@@ -12,6 +19,19 @@ vi.mock("./register.runtime.js", () => ({
|
||||
|
||||
import plugin from "./index.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
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({});
|
||||
}
|
||||
@@ -116,4 +136,234 @@ describe("github-copilot plugin", () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user