diff --git a/extensions/github-copilot/index.test.ts b/extensions/github-copilot/index.test.ts index 0470dd5bba3..357142b025d 100644 --- a/extensions/github-copilot/index.test.ts +++ b/extensions/github-copilot/index.test.ts @@ -214,6 +214,92 @@ describe("github-copilot plugin", () => { }); }); + 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]; @@ -267,16 +353,17 @@ describe("github-copilot plugin", () => { }, runtime, agentDir, - resolveApiKey: vi.fn(async () => { - runtime.error("resolver error"); - runtime.exit(1); - return null; - }), + resolveApiKey: vi.fn(async () => null), toApiKeyCredential: vi.fn(), }); expect(result).toBeNull(); expect(runtime.error).toHaveBeenCalledTimes(1); - expect(runtime.error).toHaveBeenCalledWith("resolver error"); + 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"), + ); }); }); diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index 67296eb8f96..39e68b6361f 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -38,6 +38,15 @@ async function loadGithubCopilotRuntime() { function applyCopilotDefaultModel(cfg: OpenClawConfig): OpenClawConfig { const defaults = cfg.agents?.defaults; const existingModel = defaults?.model; + const existingPrimary = + typeof existingModel === "string" + ? existingModel.trim() + : typeof existingModel === "object" && typeof existingModel?.primary === "string" + ? existingModel.primary.trim() + : ""; + if (existingPrimary) { + return cfg; + } const fallbacks = typeof existingModel === "object" && existingModel !== null && "fallbacks" in existingModel ? (existingModel as { fallbacks?: string[] }).fallbacks @@ -76,20 +85,79 @@ function resolveExistingCopilotTokenProfileId(agentDir?: string): string | undef }); } +async function resolveCopilotNonInteractiveToken( + ctx: ProviderAuthMethodNonInteractiveContext, + flagValue: string | undefined, +) { + const resolveFromEnvChain = async () => { + for (const envVar of COPILOT_ENV_VARS) { + const resolved = await ctx.resolveApiKey({ + provider: PROVIDER_ID, + flagName: "--github-copilot-token", + envVar, + envVarName: envVar, + allowProfile: false, + required: false, + }); + if (resolved) { + return resolved; + } + } + return null; + }; + + if (ctx.opts.secretInputMode === "ref") { + const resolved = await resolveFromEnvChain(); + if (resolved) { + return resolved; + } + if (flagValue) { + ctx.runtime.error( + [ + "--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"), + ); + ctx.runtime.exit(1); + } + return null; + } + + const primary = await ctx.resolveApiKey({ + provider: PROVIDER_ID, + flagValue, + flagName: "--github-copilot-token", + envVar: COPILOT_ENV_VARS[0], + envVarName: COPILOT_ENV_VARS[0], + allowProfile: false, + required: false, + }); + if (primary || flagValue) { + return primary; + } + + for (const envVar of COPILOT_ENV_VARS.slice(1)) { + const resolved = await ctx.resolveApiKey({ + provider: PROVIDER_ID, + flagName: "--github-copilot-token", + envVar, + envVarName: envVar, + allowProfile: false, + required: false, + }); + if (resolved) { + return resolved; + } + } + return null; +} + async function runGitHubCopilotNonInteractiveAuth( ctx: ProviderAuthMethodNonInteractiveContext, ): Promise { const opts = ctx.opts as Record | undefined; const flagValue = normalizeOptionalSecretInput(opts?.githubCopilotToken); - const resolved = await ctx.resolveApiKey({ - provider: PROVIDER_ID, - flagValue, - flagName: "--github-copilot-token", - envVar: "COPILOT_GITHUB_TOKEN", - envVarName: "COPILOT_GITHUB_TOKEN", - allowProfile: false, - required: false, - }); + const resolved = await resolveCopilotNonInteractiveToken(ctx, flagValue); let profileId = DEFAULT_COPILOT_PROFILE_ID; if (resolved) {