From 5b15fad905d77446d308f1c5947ac537023cbc19 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Sun, 26 Apr 2026 22:20:04 -0500 Subject: [PATCH] feat(onboard): support non-interactive copilot token auth --- CHANGELOG.md | 1 + docs/providers/github-copilot.md | 24 ++- extensions/github-copilot/index.test.ts | 164 ++++++++++++++++++ extensions/github-copilot/index.ts | 136 ++++++++++++++- .../github-copilot/openclaw.plugin.json | 6 +- .../contracts/registry.contract.test.ts | 20 +++ 6 files changed, 343 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf5999054b7..e9cc703c5a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ Docs: https://docs.openclaw.ai ### Fixes - Cron: classify isolated runs as errors from structured embedded-run execution-denial metadata, with final-output marker fallback for `SYSTEM_RUN_DENIED`, `INVALID_REQUEST`, and approval-binding refusals, so blocked commands no longer appear green in cron history. Fixes #67172; carries forward #67186. Thanks @oc-gh-dr, @hclsys, and @1yihui. +- Onboarding/GitHub Copilot: add manifest-owned `--github-copilot-token` support for non-interactive setup, including env fallback, tokenRef storage in ref mode, saved-profile reuse, and current Copilot default-model wiring. Refs #50002 and supersedes #50003. Thanks @scottgl9. - Gateway/install: add a validated `--wrapper`/`OPENCLAW_WRAPPER` service install path that persists executable LaunchAgent/systemd wrappers across forced reinstalls, updates, and doctor repairs instead of falling back to raw node/bun `ProgramArguments`. Fixes #69400. (#72445) Thanks @willtmc. - macOS Gateway: write launchd services with a state-dir `WorkingDirectory`, use a durable state-dir temp path instead of freezing macOS session `TMPDIR`, create that temp directory before bootstrap, and label abort-shaped launchd exits as `SIGABRT/abort` in status output. Fixes #53679 and #70223; refs #71848. Thanks @dlturock, @stammi922, and @palladius. - Exec approvals: accept runtime-owned `source: "allow-always"` and `commandText` allowlist metadata in gateway and node approval-set payloads so Control UI round-trips no longer fail with `unexpected property 'source'`. Fixes #60000; carries forward #60064. Thanks @sd1471123, @sharkqwy, and @luoyanglang. diff --git a/docs/providers/github-copilot.md b/docs/providers/github-copilot.md index 67c46df4ff9..272a1ecd13a 100644 --- a/docs/providers/github-copilot.md +++ b/docs/providers/github-copilot.md @@ -1,5 +1,5 @@ --- -summary: "Sign in to GitHub Copilot from OpenClaw using the device flow" +summary: "Sign in to GitHub Copilot from OpenClaw using the device flow or non-interactive token import" read_when: - You want to use GitHub Copilot as a model provider - You need the `openclaw models auth login-github-copilot` flow @@ -73,6 +73,24 @@ openclaw models auth login-github-copilot --yes openclaw models auth login --provider github-copilot --method device --set-default ``` +## Non-interactive onboarding + +If you already have a GitHub OAuth access token for Copilot, import it during +headless setup with `openclaw onboard --non-interactive`: + +```bash +openclaw onboard --non-interactive --accept-risk \ + --auth-choice github-copilot \ + --github-copilot-token "$COPILOT_GITHUB_TOKEN" \ + --skip-channels --skip-health +``` + +You can also omit `--auth-choice`; passing `--github-copilot-token` infers the +GitHub Copilot provider auth choice. If the flag is omitted, onboarding falls +back to `COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, then `GITHUB_TOKEN`. Use +`--secret-input-mode ref` with `COPILOT_GITHUB_TOKEN` set to store an env-backed +`tokenRef` instead of plaintext in `auth-profiles.json`. + The device-login flow requires an interactive TTY. Run it directly in a @@ -122,8 +140,8 @@ openclaw models auth login --provider github-copilot --method device --set-defau -Requires an interactive TTY. Run the login command directly in a terminal, not -inside a headless script or CI job. +The device-login command requires an interactive TTY. Use non-interactive +onboarding when you need headless setup. ## Memory search embeddings diff --git a/extensions/github-copilot/index.test.ts b/extensions/github-copilot/index.test.ts index bdb15174230..ca89e96f7a9 100644 --- a/extensions/github-copilot/index.test.ts +++ b/extensions/github-copilot/index.test.ts @@ -1,4 +1,12 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach } from "vitest"; import { 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 +20,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 +137,147 @@ 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("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 () => { + runtime.error("resolver error"); + runtime.exit(1); + return null; + }), + toApiKeyCredential: vi.fn(), + }); + + expect(result).toBeNull(); + expect(runtime.error).toHaveBeenCalledTimes(1); + expect(runtime.error).toHaveBeenCalledWith("resolver error"); + }); }); diff --git a/extensions/github-copilot/index.ts b/extensions/github-copilot/index.ts index bf6fcfad2fe..67296eb8f96 100644 --- a/extensions/github-copilot/index.ts +++ b/extensions/github-copilot/index.ts @@ -1,6 +1,18 @@ import { resolvePluginConfigObject, type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; -import { definePluginEntry, type ProviderAuthContext } from "openclaw/plugin-sdk/plugin-entry"; -import { ensureAuthProfileStore } from "openclaw/plugin-sdk/provider-auth"; +import { + definePluginEntry, + type ProviderAuthContext, + type ProviderAuthMethodNonInteractiveContext, +} from "openclaw/plugin-sdk/plugin-entry"; +import { + applyAuthProfileConfig, + coerceSecretRef, + ensureAuthProfileStore, + listProfilesForProvider, + normalizeOptionalSecretInput, + resolveDefaultSecretProviderAlias, + upsertAuthProfileWithLock, +} from "openclaw/plugin-sdk/provider-auth"; import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime"; import { resolveFirstGithubToken } from "./auth.js"; import { githubCopilotMemoryEmbeddingProviderAdapter } from "./embeddings.js"; @@ -9,6 +21,8 @@ import { buildGithubCopilotReplayPolicy } from "./replay-policy.js"; import { wrapCopilotProviderStream } from "./stream.js"; const COPILOT_ENV_VARS = ["COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"]; +const DEFAULT_COPILOT_MODEL = "github-copilot/claude-opus-4.7"; +const DEFAULT_COPILOT_PROFILE_ID = "github-copilot:github"; const COPILOT_XHIGH_MODEL_IDS = ["gpt-5.4", "gpt-5.3-codex", "gpt-5.2", "gpt-5.2-codex"] as const; type GithubCopilotPluginConfig = { @@ -20,6 +34,119 @@ type GithubCopilotPluginConfig = { async function loadGithubCopilotRuntime() { return await import("./register.runtime.js"); } + +function applyCopilotDefaultModel(cfg: OpenClawConfig): OpenClawConfig { + const defaults = cfg.agents?.defaults; + const existingModel = defaults?.model; + const fallbacks = + typeof existingModel === "object" && existingModel !== null && "fallbacks" in existingModel + ? (existingModel as { fallbacks?: string[] }).fallbacks + : undefined; + return { + ...cfg, + agents: { + ...cfg.agents, + defaults: { + ...defaults, + model: { + ...(fallbacks ? { fallbacks } : undefined), + primary: DEFAULT_COPILOT_MODEL, + }, + models: { + ...defaults?.models, + [DEFAULT_COPILOT_MODEL]: defaults?.models?.[DEFAULT_COPILOT_MODEL] ?? {}, + }, + }, + }, + }; +} + +function resolveExistingCopilotTokenProfileId(agentDir?: string): string | undefined { + const authStore = ensureAuthProfileStore(agentDir, { + allowKeychainPrompt: false, + }); + return listProfilesForProvider(authStore, PROVIDER_ID).find((profileId) => { + const profile = authStore.profiles[profileId]; + if (profile?.type !== "token") { + return false; + } + return Boolean( + normalizeOptionalSecretInput(profile.token) || coerceSecretRef(profile.tokenRef)?.id.trim(), + ); + }); +} + +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, + }); + + let profileId = DEFAULT_COPILOT_PROFILE_ID; + if (resolved) { + const useTokenRef = ctx.opts.secretInputMode === "ref" && resolved.source === "env"; + if (useTokenRef && !resolved.envVarName) { + ctx.runtime.error( + [ + '--secret-input-mode ref requires an explicit environment variable for provider "github-copilot".', + "Set COPILOT_GITHUB_TOKEN in env and retry, or use --secret-input-mode plaintext.", + ].join("\n"), + ); + ctx.runtime.exit(1); + return null; + } + await upsertAuthProfileWithLock({ + profileId, + credential: { + type: "token", + provider: PROVIDER_ID, + ...(useTokenRef + ? { + tokenRef: { + source: "env", + provider: resolveDefaultSecretProviderAlias(ctx.baseConfig, "env", { + preferFirstProviderForSource: true, + }), + id: resolved.envVarName!, + }, + } + : { token: resolved.key }), + }, + agentDir: ctx.agentDir, + }); + } else { + if (flagValue && ctx.opts.secretInputMode === "ref") { + return null; + } + const existingProfileId = resolveExistingCopilotTokenProfileId(ctx.agentDir); + if (!existingProfileId) { + ctx.runtime.error( + "Missing --github-copilot-token (or COPILOT_GITHUB_TOKEN / GH_TOKEN / GITHUB_TOKEN env var) for --auth-choice github-copilot.", + ); + ctx.runtime.exit(1); + return null; + } + profileId = existingProfileId; + } + + return applyCopilotDefaultModel( + applyAuthProfileConfig(ctx.config, { + profileId, + provider: PROVIDER_ID, + mode: "token", + }), + ); +} + export default definePluginEntry({ id: "github-copilot", name: "GitHub Copilot Provider", @@ -74,11 +201,11 @@ export default definePluginEntry({ return { profiles: [ { - profileId: "github-copilot:github", + profileId: DEFAULT_COPILOT_PROFILE_ID, credential, }, ], - defaultModel: "github-copilot/claude-opus-4.7", + defaultModel: DEFAULT_COPILOT_MODEL, }; } @@ -96,6 +223,7 @@ export default definePluginEntry({ hint: "Browser device-code flow", kind: "device_code", run: async (ctx) => await runGitHubCopilotAuth(ctx), + runNonInteractive: async (ctx) => await runGitHubCopilotNonInteractiveAuth(ctx), }, ], wizard: { diff --git a/extensions/github-copilot/openclaw.plugin.json b/extensions/github-copilot/openclaw.plugin.json index 01f3f8b3e0b..3a33f97926a 100644 --- a/extensions/github-copilot/openclaw.plugin.json +++ b/extensions/github-copilot/openclaw.plugin.json @@ -17,7 +17,11 @@ "choiceHint": "Device login with your GitHub account", "groupId": "copilot", "groupLabel": "Copilot", - "groupHint": "GitHub + local proxy" + "groupHint": "GitHub + local proxy", + "optionKey": "githubCopilotToken", + "cliFlag": "--github-copilot-token", + "cliOption": "--github-copilot-token ", + "cliDescription": "GitHub Copilot OAuth token" } ], "configSchema": { diff --git a/src/plugins/contracts/registry.contract.test.ts b/src/plugins/contracts/registry.contract.test.ts index f8c38b8125e..6a3573748ff 100644 --- a/src/plugins/contracts/registry.contract.test.ts +++ b/src/plugins/contracts/registry.contract.test.ts @@ -120,6 +120,26 @@ describe("plugin contract registry", () => { } }); + it("exposes the GitHub Copilot non-interactive onboarding token flag from manifest metadata", () => { + const registry = loadPluginManifestRegistry({}); + const plugin = registry.plugins.find( + (entry) => entry.origin === "bundled" && entry.id === "github-copilot", + ); + + expect(plugin?.providerAuthChoices).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + provider: "github-copilot", + method: "device", + choiceId: "github-copilot", + optionKey: "githubCopilotToken", + cliFlag: "--github-copilot-token", + cliOption: "--github-copilot-token ", + }), + ]), + ); + }); + it("covers every bundled speech plugin discovered from manifests", () => { expectRegistryPluginIds({ actualPluginIds: pluginRegistrationContractRegistry