From 30de1cd99dcec10d66ba9daa2060fc6e4e1ab6fb Mon Sep 17 00:00:00 2001 From: John Ku Date: Tue, 2 Jun 2026 16:31:28 +0800 Subject: [PATCH] Add GitHub Copilot agent adapter --- packages/agent-adapters/package.json | 1 + .../src/github-copilot/adapter.ts | 129 +++++++++++++ .../src/github-copilot/configuration-doc.ts | 79 ++++++++ .../src/github-copilot/constants.ts | 18 ++ .../src/github-copilot/errors.ts | 95 ++++++++++ .../src/github-copilot/output.ts | 3 + packages/agent-adapters/src/registry.ts | 18 ++ .../src/types/agent-adapter.types.ts | 11 ++ packages/agent-adapters/src/validation.ts | 13 ++ .../agent-adapters/tests/adapters.test.ts | 8 +- packages/agent-adapters/tests/cursor.test.ts | 2 +- .../tests/github-copilot.test.ts | 171 ++++++++++++++++++ .../agent-adapters/tests/opencode.test.ts | 2 +- .../src/features/config/env-agent-config.ts | 18 +- .../src/features/config/env-normalizers.ts | 2 +- .../features/config/project-agent-config.ts | 38 +++- .../features/config/types/runtime.types.ts | 17 +- .../src/features/onboard/checks-binaries.ts | 28 +++ .../src/features/onboard/checks-collection.ts | 53 ++++++ .../src/features/onboard/checks-helpers.ts | 6 + .../workflow/agents/agent-log-metadata.ts | 3 + .../cli/src/features/workflow/usage-cost.ts | 3 + packages/cli/tests/agent-adapters.test.ts | 14 +- packages/cli/tests/config.test.ts | 39 ++++ packages/cli/tests/onboard.test.ts | 70 ++++++- .../runtimes/runtimes-panel-utils.ts | 3 + .../web/src/lib/agents/use-agent-monitor.ts | 3 + 27 files changed, 838 insertions(+), 9 deletions(-) create mode 100644 packages/agent-adapters/src/github-copilot/adapter.ts create mode 100644 packages/agent-adapters/src/github-copilot/configuration-doc.ts create mode 100644 packages/agent-adapters/src/github-copilot/constants.ts create mode 100644 packages/agent-adapters/src/github-copilot/errors.ts create mode 100644 packages/agent-adapters/src/github-copilot/output.ts create mode 100644 packages/agent-adapters/tests/github-copilot.test.ts diff --git a/packages/agent-adapters/package.json b/packages/agent-adapters/package.json index 24e0f9af..6106ea35 100644 --- a/packages/agent-adapters/package.json +++ b/packages/agent-adapters/package.json @@ -10,6 +10,7 @@ "./claude-code": "./src/claude/index.ts", "./codex": "./src/codex/index.ts", "./cursor": "./src/cursor/index.ts", + "./github-copilot": "./src/github-copilot/adapter.ts", "./opencode": "./src/opencode/adapter.ts", "./codex/config": "./src/codex/config.ts", "./codex/docker": "./src/codex/docker.ts", diff --git a/packages/agent-adapters/src/github-copilot/adapter.ts b/packages/agent-adapters/src/github-copilot/adapter.ts new file mode 100644 index 00000000..8be089c0 --- /dev/null +++ b/packages/agent-adapters/src/github-copilot/adapter.ts @@ -0,0 +1,129 @@ +import { runCommand } from "../shared/execute/shell"; +import { renderAgentPrompt } from "../shared/skills/request-prompt"; +import { emitStreamEvent } from "../shared/streaming/events"; +import type { + AgentAdapter, + AgentAdapterRunRequest, + AgentAdapterRuntimeConfig, + AgentResult, +} from "../types/agent-adapter.types"; +import { + validateAgentAdapterRunRequest, + validateAgentAdapterRuntimeConfig, +} from "../validation"; +import { mapGitHubCopilotError } from "./errors"; +import { extractFinalMessage } from "./output"; + +export class GitHubCopilotAdapter implements AgentAdapter { + constructor(config: AgentAdapterRuntimeConfig) { + this.config = validateAgentAdapterRuntimeConfig(config); + } + + private config: AgentAdapterRuntimeConfig; + + async runPlan(prompt: string): Promise { + return this.runAgent({ role: "planning", prompt }); + } + + async runTaskIntake(prompt: string): Promise { + return this.runAgent({ role: "task-intake", prompt }); + } + + async resume(sessionId: string, prompt: string): Promise { + return this.runAgent({ role: "implementing", prompt, sessionId }); + } + + async runReview(prompt: string): Promise { + return this.runAgent({ role: "review-testing", prompt }); + } + + async runGithubComment(prompt: string): Promise { + return this.runAgent({ role: "github-comment", prompt }); + } + + async runAgent(request: AgentAdapterRunRequest): Promise { + const validatedRequest = validateAgentAdapterRunRequest(request); + const prompt = this.renderPrompt(validatedRequest); + return this.runGitHubCopilot(this.buildArgs(prompt), validatedRequest); + } + + private renderPrompt(request: AgentAdapterRunRequest): string { + const prompt = renderAgentPrompt(request); + if (!request.sessionId) { + return prompt; + } + return [ + `Previous GitHub Copilot session id: ${request.sessionId}`, + "Continue the task using this session id as context.", + "", + prompt, + ].join("\n"); + } + + private buildArgs(prompt: string): string[] { + const args = ["-p", prompt]; + if (this.config.githubCopilot?.allowAllTools) { + args.push("--allow-all-tools"); + } + for (const pattern of this.config.githubCopilot?.allowTools ?? []) { + args.push("--allow-tool", pattern); + } + for (const pattern of this.config.githubCopilot?.denyTools ?? []) { + args.push("--deny-tool", pattern); + } + return args; + } + + private async runGitHubCopilot( + args: string[], + request: AgentAdapterRunRequest, + ): Promise { + const binary = this.config.githubCopilot?.binary ?? "copilot"; + const cwd = this.config.executionPath; + const result = await runCommand(binary, args, { + cwd, + env: this.buildEnv(), + streamStdout: + this.config.githubCopilot?.streamLogs ?? this.config.codex.streamLogs, + streamStderr: + this.config.githubCopilot?.streamLogs ?? this.config.codex.streamLogs, + stdinMode: "ignore", + onStdout: (text) => emitStreamEvent(request, "stdout", text), + onStderr: (text) => emitStreamEvent(request, "stderr", text), + }).catch((error) => { + throw mapGitHubCopilotError( + binary, + args, + { + code: 127, + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + }, + { cwd, request }, + ); + }); + + if (result.code !== 0) { + throw mapGitHubCopilotError(binary, args, result, { cwd, request }); + } + + return { + finalMessage: extractFinalMessage(result.stdout), + stdout: result.stdout, + stderr: result.stderr, + traceId: request.traceId, + backend: "github-copilot", + }; + } + + private buildEnv(): Record | undefined { + const env: Record = {}; + const model = this.config.githubCopilot?.model?.trim(); + if (model && model.toLowerCase() !== "auto") env.COPILOT_MODEL = model; + const copilotHome = this.config.githubCopilot?.copilotHome?.trim(); + if (copilotHome) env.COPILOT_HOME = copilotHome; + const githubToken = this.config.githubCopilot?.githubToken?.trim(); + if (githubToken) env.COPILOT_GITHUB_TOKEN = githubToken; + return Object.keys(env).length ? env : undefined; + } +} diff --git a/packages/agent-adapters/src/github-copilot/configuration-doc.ts b/packages/agent-adapters/src/github-copilot/configuration-doc.ts new file mode 100644 index 00000000..2883ec15 --- /dev/null +++ b/packages/agent-adapters/src/github-copilot/configuration-doc.ts @@ -0,0 +1,79 @@ +import type { AgentConfigurationDoc } from "../types/agent-registry.types"; +import { + GITHUB_COPILOT_AVAILABLE_MODELS, + GITHUB_COPILOT_BACKEND, + GITHUB_COPILOT_DEFAULT_MODEL, + GITHUB_COPILOT_DESCRIPTION, + GITHUB_COPILOT_LABEL, +} from "./constants"; + +export const githubCopilotConfigurationDoc = { + backend: GITHUB_COPILOT_BACKEND, + label: GITHUB_COPILOT_LABEL, + description: GITHUB_COPILOT_DESCRIPTION, + env: [ + field( + "AGENT_BACKEND", + "Agent backend", + "Use 'github-copilot' for GitHub Copilot CLI.", + ), + field("GITHUB_COPILOT_BINARY", "Binary", "GitHub Copilot CLI binary path."), + field("GITHUB_COPILOT_MODEL", "Model", "GitHub Copilot CLI model id."), + field("GITHUB_COPILOT_HOME", "Home", "GitHub Copilot CLI home directory."), + field( + "GITHUB_COPILOT_TOKEN", + "Token", + "GitHub Copilot CLI authentication token.", + ), + field( + "GITHUB_COPILOT_ALLOW_ALL_TOOLS", + "Allow all tools", + "Allow GitHub Copilot CLI to use all tools.", + ), + field( + "GITHUB_COPILOT_ALLOW_TOOLS", + "Allow tools", + "Comma-separated GitHub Copilot CLI tool allow patterns.", + ), + field( + "GITHUB_COPILOT_DENY_TOOLS", + "Deny tools", + "Comma-separated GitHub Copilot CLI tool deny patterns.", + ), + ], + configFields: [ + field("agent.backend", "Agent backend", "Project agent backend."), + field("githubCopilot.binary", "Binary", "GitHub Copilot CLI binary path."), + field("githubCopilot.model", "Model", "GitHub Copilot CLI model id."), + field("githubCopilot.copilotHome", "Home", "GitHub Copilot CLI home."), + field("githubCopilot.githubToken", "Token", "GitHub Copilot token."), + field( + "githubCopilot.allowAllTools", + "Allow all tools", + "Allow GitHub Copilot CLI to use all tools.", + ), + field( + "githubCopilot.allowTools", + "Allow tools", + "GitHub Copilot CLI tool allow patterns.", + ), + field( + "githubCopilot.denyTools", + "Deny tools", + "GitHub Copilot CLI tool deny patterns.", + ), + ], + defaults: { + backend: GITHUB_COPILOT_BACKEND, + model: GITHUB_COPILOT_DEFAULT_MODEL, + }, + availableModels: GITHUB_COPILOT_AVAILABLE_MODELS, +} satisfies AgentConfigurationDoc; + +function field( + name: string, + label: string, + description: string, +): AgentConfigurationDoc["env"][number] { + return { name, label, description }; +} diff --git a/packages/agent-adapters/src/github-copilot/constants.ts b/packages/agent-adapters/src/github-copilot/constants.ts new file mode 100644 index 00000000..7ba6bc1d --- /dev/null +++ b/packages/agent-adapters/src/github-copilot/constants.ts @@ -0,0 +1,18 @@ +import type { AgentBackend } from "../types/agent-adapter.types"; +import type { AgentModelDefinition } from "../types/agent-registry.types"; + +export const GITHUB_COPILOT_BACKEND = "github-copilot" satisfies AgentBackend; +export const GITHUB_COPILOT_LABEL = "GitHub Copilot"; +export const GITHUB_COPILOT_DESCRIPTION = + "GitHub Copilot CLI runtime for local programmatic agent runs."; + +export const GITHUB_COPILOT_DEFAULT_MODEL = "auto"; + +export const GITHUB_COPILOT_AVAILABLE_MODELS = [ + { + id: "auto", + label: "Auto", + description: "Let GitHub Copilot CLI choose the model.", + defaultFor: ["brainstorm", "taskIntake", "plan", "implement", "reviewTest"], + }, +] as const satisfies readonly AgentModelDefinition[]; diff --git a/packages/agent-adapters/src/github-copilot/errors.ts b/packages/agent-adapters/src/github-copilot/errors.ts new file mode 100644 index 00000000..f3c6b8bf --- /dev/null +++ b/packages/agent-adapters/src/github-copilot/errors.ts @@ -0,0 +1,95 @@ +import { AgentAdapterError } from "../adapter-error"; +import type { AgentAdapterRunRequest } from "../types/agent-adapter.types"; + +interface GitHubCopilotErrorContext { + cwd?: string; + request?: AgentAdapterRunRequest; +} + +export function mapGitHubCopilotError( + command: string, + args: string[], + result: { code: number; stdout: string; stderr: string }, + context: GitHubCopilotErrorContext = {}, +): Error { + const output = result.stderr || result.stdout; + const lower = output.toLowerCase(); + const base = `${command} ${args.join(" ")} failed with exit code ${result.code}`; + + if ( + result.code === 127 || + lower.includes("command not found") || + lower.includes("enoent") + ) { + return buildError( + `${base}\nGitHub Copilot CLI binary not found. Install GitHub Copilot CLI or set GITHUB_COPILOT_BINARY.`, + command, + args, + result, + context, + ); + } + if ( + lower.includes("auth") || + lower.includes("token") || + lower.includes("login") || + lower.includes("unauthorized") + ) { + return buildError( + `${base}\nGitHub Copilot CLI authentication failed. Run 'copilot auth' or set GITHUB_COPILOT_TOKEN.`, + command, + args, + result, + context, + ); + } + if (lower.includes("subscription") || lower.includes("policy")) { + return buildError( + `${base}\nGitHub Copilot CLI is unavailable for this account. Check Copilot subscription and organization policy.`, + command, + args, + result, + context, + ); + } + if (lower.includes("model") && lower.includes("not found")) { + return buildError( + `${base}\nThe specified GitHub Copilot model was not found. Check GITHUB_COPILOT_MODEL.`, + command, + args, + result, + context, + ); + } + if (lower.includes("permission") || lower.includes("approval")) { + return buildError( + `${base}\nGitHub Copilot CLI tool permission flow blocked the run. Configure GitHub Copilot tool allow or deny settings.`, + command, + args, + result, + context, + ); + } + + return buildError(`${base}\n${output}`, command, args, result, context); +} + +function buildError( + message: string, + command: string, + args: string[], + result: { code: number; stdout: string; stderr: string }, + context: GitHubCopilotErrorContext, +): AgentAdapterError { + return new AgentAdapterError({ + backend: "github-copilot", + message, + command, + args, + cwd: context.cwd, + code: result.code, + stdout: result.stdout, + stderr: result.stderr, + traceId: context.request?.traceId, + }); +} diff --git a/packages/agent-adapters/src/github-copilot/output.ts b/packages/agent-adapters/src/github-copilot/output.ts new file mode 100644 index 00000000..782a7b87 --- /dev/null +++ b/packages/agent-adapters/src/github-copilot/output.ts @@ -0,0 +1,3 @@ +export function extractFinalMessage(output: string): string { + return output.trim() || output; +} diff --git a/packages/agent-adapters/src/registry.ts b/packages/agent-adapters/src/registry.ts index 31dcd257..c1aca973 100644 --- a/packages/agent-adapters/src/registry.ts +++ b/packages/agent-adapters/src/registry.ts @@ -22,6 +22,15 @@ import { CURSOR_DESCRIPTION, CURSOR_LABEL, } from "./cursor/constants"; +import { GitHubCopilotAdapter } from "./github-copilot/adapter"; +import { githubCopilotConfigurationDoc } from "./github-copilot/configuration-doc"; +import { + GITHUB_COPILOT_AVAILABLE_MODELS, + GITHUB_COPILOT_BACKEND, + GITHUB_COPILOT_DEFAULT_MODEL, + GITHUB_COPILOT_DESCRIPTION, + GITHUB_COPILOT_LABEL, +} from "./github-copilot/constants"; import { OpenCodeAdapter } from "./opencode/adapter"; import { opencodeConfigurationDoc } from "./opencode/configuration-doc"; import { @@ -69,6 +78,15 @@ const agentBackendDefinitions = { configurationDoc: cursorConfigurationDoc, createAdapter: (config) => new CursorAgentAdapter(config), }, + [GITHUB_COPILOT_BACKEND]: { + backend: GITHUB_COPILOT_BACKEND, + label: GITHUB_COPILOT_LABEL, + description: GITHUB_COPILOT_DESCRIPTION, + defaultModel: GITHUB_COPILOT_DEFAULT_MODEL, + availableModels: GITHUB_COPILOT_AVAILABLE_MODELS, + configurationDoc: githubCopilotConfigurationDoc, + createAdapter: (config) => new GitHubCopilotAdapter(config), + }, [OPENCODE_BACKEND]: { backend: OPENCODE_BACKEND, label: OPENCODE_LABEL, diff --git a/packages/agent-adapters/src/types/agent-adapter.types.ts b/packages/agent-adapters/src/types/agent-adapter.types.ts index abb5c707..4eaa52c1 100644 --- a/packages/agent-adapters/src/types/agent-adapter.types.ts +++ b/packages/agent-adapters/src/types/agent-adapter.types.ts @@ -49,6 +49,7 @@ export interface AgentAdapter { export type AgentBackend = | "codex" | "claude-code" + | "github-copilot" | "cursor-agent" | "opencode"; @@ -118,6 +119,16 @@ export interface AgentAdapterRuntimeConfig { attach?: string; dangerouslySkipPermissions?: boolean; }; + githubCopilot?: { + binary: string; + streamLogs: boolean; + model?: string; + copilotHome?: string; + githubToken?: string; + allowAllTools?: boolean; + allowTools?: string[]; + denyTools?: string[]; + }; claude?: { model?: string; maxTurns?: number; diff --git a/packages/agent-adapters/src/validation.ts b/packages/agent-adapters/src/validation.ts index b2da8c1c..b4417234 100644 --- a/packages/agent-adapters/src/validation.ts +++ b/packages/agent-adapters/src/validation.ts @@ -7,6 +7,7 @@ import type { const backendSchema = z.enum([ "codex", "claude-code", + "github-copilot", "cursor-agent", "opencode", ]); @@ -93,6 +94,18 @@ const runtimeConfigSchema = z.object({ dangerouslySkipPermissions: z.boolean().optional(), }) .optional(), + githubCopilot: z + .object({ + binary: z.string().min(1), + streamLogs: z.boolean(), + model: z.string().optional(), + copilotHome: z.string().optional(), + githubToken: z.string().optional(), + allowAllTools: z.boolean().optional(), + allowTools: z.array(z.string()).optional(), + denyTools: z.array(z.string()).optional(), + }) + .optional(), claude: z .object({ model: z.string().optional(), diff --git a/packages/agent-adapters/tests/adapters.test.ts b/packages/agent-adapters/tests/adapters.test.ts index d7082f53..3217df83 100644 --- a/packages/agent-adapters/tests/adapters.test.ts +++ b/packages/agent-adapters/tests/adapters.test.ts @@ -13,6 +13,7 @@ import { ClaudeCodeAdapter } from "../src/claude"; import { CodexAdapter, extractSessionId, extractUsage } from "../src/codex"; import { buildCodexRuntimeInvocation } from "../src/codex/docker"; import { CursorAgentAdapter } from "../src/cursor"; +import { GitHubCopilotAdapter } from "../src/github-copilot/adapter"; import { OpenCodeAdapter } from "../src/opencode/adapter"; import { config } from "./fixtures"; @@ -25,6 +26,9 @@ describe("agent adapter factory", () => { expect( createAgentAdapter({ ...config, agent: { backend: "cursor-agent" } }), ).toBeInstanceOf(CursorAgentAdapter); + expect( + createAgentAdapter({ ...config, agent: { backend: "github-copilot" } }), + ).toBeInstanceOf(GitHubCopilotAdapter); expect( createAgentAdapter({ ...config, agent: { backend: "opencode" } }), ).toBeInstanceOf(OpenCodeAdapter); @@ -53,9 +57,10 @@ describe("agent adapter factory", () => { it("exposes backend definitions from the shared registry", () => { expect(listAgentBackends().map((definition) => definition.backend)).toEqual( - ["codex", "claude-code", "cursor-agent", "opencode"], + ["codex", "claude-code", "cursor-agent", "github-copilot", "opencode"], ); expect(normalizeAgentBackend(" Cursor-Agent ")).toBe("cursor-agent"); + expect(normalizeAgentBackend(" GitHub-Copilot ")).toBe("github-copilot"); expect(normalizeAgentBackend(" Claude-Code ")).toBe("claude-code"); expect(normalizeAgentBackend(" OpenCode ")).toBe("opencode"); const codexDefinition = getAgentBackendDefinition("codex"); @@ -75,6 +80,7 @@ describe("agent adapter factory", () => { agentConfigurationDoc["claude-code"].env.map((field) => field.name), ).toContain("CLAUDE_CODE_MODEL"); expect(agentConfigurationDoc["cursor-agent"].defaults.model).toBe("auto"); + expect(agentConfigurationDoc["github-copilot"].defaults.model).toBe("auto"); expect(agentConfigurationDoc.opencode.defaults.model).toBe( "ollama/qwen2.5-coder:32b", ); diff --git a/packages/agent-adapters/tests/cursor.test.ts b/packages/agent-adapters/tests/cursor.test.ts index 03cc71dd..468e21ca 100644 --- a/packages/agent-adapters/tests/cursor.test.ts +++ b/packages/agent-adapters/tests/cursor.test.ts @@ -33,7 +33,7 @@ describe("cursor agent registry", () => { createAgentAdapter({ ...config, agent: { backend: "cursor-agent" } }), ).toBeInstanceOf(CursorAgentAdapter); expect(listAgentBackends().map((definition) => definition.backend)).toEqual( - ["codex", "claude-code", "cursor-agent", "opencode"], + ["codex", "claude-code", "cursor-agent", "github-copilot", "opencode"], ); expect(normalizeAgentBackend(" Cursor-Agent ")).toBe("cursor-agent"); expect(availableAgentModels["cursor-agent"][0]?.id).toBe("auto"); diff --git a/packages/agent-adapters/tests/github-copilot.test.ts b/packages/agent-adapters/tests/github-copilot.test.ts new file mode 100644 index 00000000..2c768597 --- /dev/null +++ b/packages/agent-adapters/tests/github-copilot.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "bun:test"; +import type { AgentAdapterRuntimeConfig, AgentResult } from "../src"; +import { + agentConfigurationDoc, + availableAgentModels, + createAgentAdapter, + listAgentBackends, + normalizeAgentBackend, +} from "../src"; +import { GitHubCopilotAdapter } from "../src/github-copilot/adapter"; +import { mapGitHubCopilotError } from "../src/github-copilot/errors"; +import { extractFinalMessage } from "../src/github-copilot/output"; + +const config: AgentAdapterRuntimeConfig = { + workspacePath: "/tmp/work", + executionPath: "/tmp/work/repo", + codex: { + binary: "codex", + streamLogs: false, + }, + githubCopilot: { + binary: "copilot", + streamLogs: false, + }, +}; + +describe("github copilot registry", () => { + it("creates github copilot adapters and publishes registry metadata", () => { + expect( + createAgentAdapter({ ...config, agent: { backend: "github-copilot" } }), + ).toBeInstanceOf(GitHubCopilotAdapter); + expect(listAgentBackends().map((definition) => definition.backend)).toEqual( + ["codex", "claude-code", "cursor-agent", "github-copilot", "opencode"], + ); + expect(normalizeAgentBackend(" GitHub-Copilot ")).toBe("github-copilot"); + expect(availableAgentModels["github-copilot"][0]?.id).toBe("auto"); + expect(agentConfigurationDoc["github-copilot"].defaults.model).toBe("auto"); + }); +}); + +describe("github copilot adapter", () => { + it("builds prompt and tool permission command arguments", async () => { + const adapter = new GitHubCopilotAdapter({ + ...config, + githubCopilot: { + binary: "copilot", + streamLogs: false, + allowAllTools: true, + allowTools: ["shell(git)", "file(*)"], + denyTools: ["shell(rm)"], + }, + }); + const calls: string[][] = []; + ( + adapter as unknown as { + runGitHubCopilot: (args: string[]) => Promise; + } + ).runGitHubCopilot = async (args: string[]) => { + calls.push(args); + return { finalMessage: "", stdout: "" }; + }; + + await adapter.runPlan("plan prompt"); + await adapter.resume("session-1", "implement prompt"); + + expect(calls[0]?.slice(0, 2)).toEqual(["-p", "plan prompt"]); + expect(calls[0]).toContain("--allow-all-tools"); + expect(calls[0]).toContain("shell(git)"); + expect(calls[0]).toContain("file(*)"); + expect(calls[0]).toContain("shell(rm)"); + expect(calls[1]?.[1]).toContain("Previous GitHub Copilot session id"); + expect(calls[1]?.[1]).toContain("session-1"); + }); + + it("passes configured environment variables", () => { + const adapter = new GitHubCopilotAdapter({ + ...config, + githubCopilot: { + binary: "copilot", + streamLogs: false, + model: "gpt-5", + copilotHome: "/tmp/copilot", + githubToken: "token-secret", + }, + }); + const env = ( + adapter as unknown as { + buildEnv: () => Record | undefined; + } + ).buildEnv(); + + expect(env).toEqual({ + COPILOT_MODEL: "gpt-5", + COPILOT_HOME: "/tmp/copilot", + COPILOT_GITHUB_TOKEN: "token-secret", + }); + }); + + it("renders structured runAgent prompts and preserves trace context", async () => { + const adapter = new GitHubCopilotAdapter(config); + const calls: { args: string[]; traceId?: string }[] = []; + ( + adapter as unknown as { + runGitHubCopilot: ( + args: string[], + request: { traceId?: string }, + ) => Promise; + } + ).runGitHubCopilot = async (args, request) => { + calls.push({ args, traceId: request.traceId }); + return { finalMessage: "done", stdout: "", traceId: request.traceId }; + }; + + const result = await adapter.runAgent({ + role: "planning", + prompt: "Plan this", + traceId: "trace-1", + agent: { name: "Planner", instructions: "Plan carefully" }, + customInstructions: "Return markers", + skills: [{ name: "plan", path: "skills/plan/SKILL.md" }], + }); + + expect(calls[0]?.args[1]).toContain("Agent instructions:"); + expect(calls[0]?.args[1]).toContain("skills/plan/SKILL.md"); + expect(calls[0]?.traceId).toBe("trace-1"); + expect(result).toMatchObject({ traceId: "trace-1" }); + }); + + it("extracts plain stdout as the final message", () => { + expect(extractFinalMessage("done\n")).toBe("done"); + expect(extractFinalMessage("")).toBe(""); + }); + + it("maps common github copilot failures with actionable hints", () => { + expect( + mapGitHubCopilotError("copilot", ["-p", "x"], { + code: 127, + stdout: "", + stderr: "command not found: copilot", + }).message, + ).toContain("GITHUB_COPILOT_BINARY"); + expect( + mapGitHubCopilotError("copilot", ["-p", "x"], { + code: 1, + stdout: "", + stderr: "authentication token expired", + }).message, + ).toContain("GITHUB_COPILOT_TOKEN"); + expect( + mapGitHubCopilotError("copilot", ["-p", "x"], { + code: 1, + stdout: "", + stderr: "model not found", + }).message, + ).toContain("GITHUB_COPILOT_MODEL"); + }); + + it("maps spawn failures for missing github copilot binaries", async () => { + const adapter = new GitHubCopilotAdapter({ + ...config, + githubCopilot: { + binary: "__missing_github_copilot_binary__", + streamLogs: false, + }, + }); + + await expect(adapter.runPlan("prompt")).rejects.toThrow( + "GitHub Copilot CLI binary not found", + ); + }); +}); diff --git a/packages/agent-adapters/tests/opencode.test.ts b/packages/agent-adapters/tests/opencode.test.ts index 05c85911..a93f7504 100644 --- a/packages/agent-adapters/tests/opencode.test.ts +++ b/packages/agent-adapters/tests/opencode.test.ts @@ -34,7 +34,7 @@ describe("opencode registry", () => { createAgentAdapter({ ...config, agent: { backend: "opencode" } }), ).toBeInstanceOf(OpenCodeAdapter); expect(listAgentBackends().map((definition) => definition.backend)).toEqual( - ["codex", "claude-code", "cursor-agent", "opencode"], + ["codex", "claude-code", "cursor-agent", "github-copilot", "opencode"], ); expect(normalizeAgentBackend(" OpenCode ")).toBe("opencode"); expect(availableAgentModels.opencode[0]?.id).toBe( diff --git a/packages/cli/src/features/config/env-agent-config.ts b/packages/cli/src/features/config/env-agent-config.ts index c1fef376..e39fff2c 100644 --- a/packages/cli/src/features/config/env-agent-config.ts +++ b/packages/cli/src/features/config/env-agent-config.ts @@ -13,7 +13,10 @@ type Env = Record; export function buildEnvAgentConfig( env: Env, streamLogs: boolean, -): Pick { +): Pick< + ProjectRuntimeConfig, + "agent" | "claude" | "cursor" | "githubCopilot" | "opencode" +> { return { cursor: { binary: normalizeOptionalValue(env.CURSOR_AGENT_BINARY) ?? "cursor-agent", @@ -36,6 +39,19 @@ export function buildEnvAgentConfig( "OPENCODE_DANGEROUSLY_SKIP_PERMISSIONS", ), }, + githubCopilot: { + binary: normalizeOptionalValue(env.GITHUB_COPILOT_BINARY) ?? "copilot", + streamLogs, + model: normalizeOptionalValue(env.GITHUB_COPILOT_MODEL), + copilotHome: normalizeOptionalValue(env.GITHUB_COPILOT_HOME), + githubToken: normalizeOptionalValue(env.GITHUB_COPILOT_TOKEN), + allowAllTools: normalizeBooleanEnvValue( + env.GITHUB_COPILOT_ALLOW_ALL_TOOLS, + "GITHUB_COPILOT_ALLOW_ALL_TOOLS", + ), + allowTools: parseCommaList(env.GITHUB_COPILOT_ALLOW_TOOLS), + denyTools: parseCommaList(env.GITHUB_COPILOT_DENY_TOOLS), + }, agent: { backend: normalizeAgentBackend(env.AGENT_BACKEND), }, diff --git a/packages/cli/src/features/config/env-normalizers.ts b/packages/cli/src/features/config/env-normalizers.ts index ad27bd98..5576c2b3 100644 --- a/packages/cli/src/features/config/env-normalizers.ts +++ b/packages/cli/src/features/config/env-normalizers.ts @@ -134,7 +134,7 @@ export function normalizeAgentBackend( return backend; } throw new Error( - `Invalid AGENT_BACKEND value: '${value}'. Must be 'codex', 'claude-code', 'cursor-agent', or 'opencode'.`, + `Invalid AGENT_BACKEND value: '${value}'. Must be 'codex', 'claude-code', 'github-copilot', 'cursor-agent', or 'opencode'.`, ); } diff --git a/packages/cli/src/features/config/project-agent-config.ts b/packages/cli/src/features/config/project-agent-config.ts index 25cbe9ce..402858fe 100644 --- a/packages/cli/src/features/config/project-agent-config.ts +++ b/packages/cli/src/features/config/project-agent-config.ts @@ -6,7 +6,7 @@ import type { type AgentConfig = Pick< ProjectRuntimeConfig, - "agent" | "claude" | "cursor" | "opencode" + "agent" | "claude" | "cursor" | "githubCopilot" | "opencode" >; export function resolveProjectAgentConfig( @@ -67,6 +67,42 @@ export function resolveProjectAgentConfig( rootDefaults.opencode?.dangerouslySkipPermissions ?? base.opencode?.dangerouslySkipPermissions, }, + githubCopilot: { + binary: + project.githubCopilot?.binary ?? + rootDefaults.githubCopilot?.binary ?? + base.githubCopilot?.binary ?? + "copilot", + streamLogs: + project.githubCopilot?.streamLogs ?? + rootDefaults.githubCopilot?.streamLogs ?? + base.githubCopilot?.streamLogs ?? + base.codex.streamLogs, + model: + project.githubCopilot?.model ?? + rootDefaults.githubCopilot?.model ?? + base.githubCopilot?.model, + copilotHome: + project.githubCopilot?.copilotHome ?? + rootDefaults.githubCopilot?.copilotHome ?? + base.githubCopilot?.copilotHome, + githubToken: + project.githubCopilot?.githubToken ?? + rootDefaults.githubCopilot?.githubToken ?? + base.githubCopilot?.githubToken, + allowAllTools: + project.githubCopilot?.allowAllTools ?? + rootDefaults.githubCopilot?.allowAllTools ?? + base.githubCopilot?.allowAllTools, + allowTools: + project.githubCopilot?.allowTools ?? + rootDefaults.githubCopilot?.allowTools ?? + base.githubCopilot?.allowTools, + denyTools: + project.githubCopilot?.denyTools ?? + rootDefaults.githubCopilot?.denyTools ?? + base.githubCopilot?.denyTools, + }, claude: { ...(base.claude ?? {}), ...(rootDefaults.claude ?? {}), diff --git a/packages/cli/src/features/config/types/runtime.types.ts b/packages/cli/src/features/config/types/runtime.types.ts index 85d8de44..730d99e8 100644 --- a/packages/cli/src/features/config/types/runtime.types.ts +++ b/packages/cli/src/features/config/types/runtime.types.ts @@ -104,6 +104,16 @@ export interface ProjectRuntimeConfig { attach?: string; dangerouslySkipPermissions?: boolean; }; + githubCopilot?: { + binary: string; + streamLogs: boolean; + model?: string; + copilotHome?: string; + githubToken?: string; + allowAllTools?: boolean; + allowTools?: string[]; + denyTools?: string[]; + }; claude?: { model?: string; maxTurns?: number; @@ -116,7 +126,12 @@ export interface ProjectRuntimeConfig { | "plan"; }; agent?: { - backend?: "codex" | "claude-code" | "cursor-agent" | "opencode"; + backend?: + | "codex" + | "claude-code" + | "github-copilot" + | "cursor-agent" + | "opencode"; model?: string; maxTurns?: number; allowedTools?: string[]; diff --git a/packages/cli/src/features/onboard/checks-binaries.ts b/packages/cli/src/features/onboard/checks-binaries.ts index d12b2b16..21e1fd57 100644 --- a/packages/cli/src/features/onboard/checks-binaries.ts +++ b/packages/cli/src/features/onboard/checks-binaries.ts @@ -4,6 +4,7 @@ import { commandFailureMessage, formatMissingCursorAgentMessage, formatMissingDockerMessage, + formatMissingGitHubCopilotMessage, formatMissingRtkMessage, safeRun, } from "./checks-helpers"; @@ -160,4 +161,31 @@ export async function addBinaryChecks( }, ); } + + const githubCopilotBackends = config.projects.filter( + (project) => project.agent?.backend === "github-copilot", + ); + if (githubCopilotBackends.length > 0) { + const copilotBinary = + githubCopilotBackends[0]?.githubCopilot?.binary ?? "copilot"; + const copilot = await safeRun( + commandRunner, + copilotBinary, + ["--version"], + commandCwd, + ); + checks.push( + copilot.code === 0 + ? { + name: "GitHub Copilot binary", + status: "pass", + message: `${copilotBinary} is available`, + } + : { + name: "GitHub Copilot binary", + status: "fail", + message: formatMissingGitHubCopilotMessage(copilotBinary), + }, + ); + } } diff --git a/packages/cli/src/features/onboard/checks-collection.ts b/packages/cli/src/features/onboard/checks-collection.ts index 1869c152..a2a3fd15 100644 --- a/packages/cli/src/features/onboard/checks-collection.ts +++ b/packages/cli/src/features/onboard/checks-collection.ts @@ -1,4 +1,6 @@ import { access } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; import { runCommand } from "../../utils/shell"; import { loadConfig, loadResolvedEnv } from "../config"; import type { LoadedConfig } from "../config"; @@ -17,6 +19,7 @@ export async function collectOnboardChecks( const instanceLoader = deps.loadInstanceConfig ?? loadInstanceConfig; const commandRunner = deps.runCommand ?? runCommand; const accessPath = deps.access ?? access; + const readText = deps.readFile ?? readFile; const checks: OnboardCheck[] = []; const { check, config, instanceResult } = await collectConfigFileCheck({ @@ -36,6 +39,7 @@ export async function collectOnboardChecks( ); if (!config) return checks; + await addTrackedConfigSecretCheck(checks, cwd, config, readText); await addProjectPathChecks(checks, config, accessPath); await addSkillChecks(checks, config, accessPath); await addAutoSelectChecks(checks, config, accessPath); @@ -43,6 +47,55 @@ export async function collectOnboardChecks( return checks; } +async function addTrackedConfigSecretCheck( + checks: OnboardCheck[], + cwd: string, + config: LoadedConfig, + readText: NonNullable, +): Promise { + const secretValues = new Set(); + for (const project of config.projects) { + if (project.cursor?.apiKey) secretValues.add(project.cursor.apiKey); + if (project.githubCopilot?.githubToken) { + secretValues.add(project.githubCopilot.githubToken); + } + } + if (config.notifications.email.resendApiKey) { + secretValues.add(config.notifications.email.resendApiKey); + } + + const configPath = path.join(cwd, "devos.config.ts"); + const content = await readOptionalText(configPath, readText); + if (content) { + for (const secret of secretValues) { + if (secret.length >= 8 && content.includes(secret)) { + checks.push({ + name: "Tracked config secrets", + status: "fail", + message: "devos.config.ts contains a configured secret", + }); + return; + } + } + } + checks.push({ + name: "Tracked config secrets", + status: "pass", + message: "no configured secrets found in tracked config files", + }); +} + +async function readOptionalText( + filePath: string, + readText: NonNullable, +): Promise { + try { + return await readText(filePath, "utf8"); + } catch { + return undefined; + } +} + async function loadEnvForChecks( envLoader: NonNullable, cwd: string, diff --git a/packages/cli/src/features/onboard/checks-helpers.ts b/packages/cli/src/features/onboard/checks-helpers.ts index 5cd38217..32981ece 100644 --- a/packages/cli/src/features/onboard/checks-helpers.ts +++ b/packages/cli/src/features/onboard/checks-helpers.ts @@ -42,3 +42,9 @@ export function formatMissingDockerMessage( export function formatMissingCursorAgentMessage(cursorBinary: string): string { return `${cursorBinary} binary not found. Install Cursor Agent CLI and run: cursor-agent login`; } + +export function formatMissingGitHubCopilotMessage( + copilotBinary: string, +): string { + return `${copilotBinary} binary not found. Install GitHub Copilot CLI or set GITHUB_COPILOT_BINARY`; +} diff --git a/packages/cli/src/features/workflow/agents/agent-log-metadata.ts b/packages/cli/src/features/workflow/agents/agent-log-metadata.ts index abd9c61b..56ffbb37 100644 --- a/packages/cli/src/features/workflow/agents/agent-log-metadata.ts +++ b/packages/cli/src/features/workflow/agents/agent-log-metadata.ts @@ -21,6 +21,9 @@ function resolveAgentModel( if (config.agent.model) return config.agent.model; if (config.agent.backend === "claude-code") return config.claude?.model; if (config.agent.backend === "cursor-agent") return config.cursor?.model; + if (config.agent.backend === "github-copilot") { + return config.githubCopilot?.model; + } if (config.agent.backend === "opencode") return config.opencode?.model; return undefined; } diff --git a/packages/cli/src/features/workflow/usage-cost.ts b/packages/cli/src/features/workflow/usage-cost.ts index 7e656909..564728d7 100644 --- a/packages/cli/src/features/workflow/usage-cost.ts +++ b/packages/cli/src/features/workflow/usage-cost.ts @@ -78,6 +78,9 @@ function resolveFallbackModel( if (config.agent?.model) return config.agent.model; if (config.agent?.backend === "claude-code") return config.claude?.model; if (config.agent?.backend === "cursor-agent") return config.cursor?.model; + if (config.agent?.backend === "github-copilot") { + return config.githubCopilot?.model; + } if (config.agent?.backend === "opencode") return config.opencode?.model; return config.codex.model; } diff --git a/packages/cli/tests/agent-adapters.test.ts b/packages/cli/tests/agent-adapters.test.ts index c0503af5..5b05838c 100644 --- a/packages/cli/tests/agent-adapters.test.ts +++ b/packages/cli/tests/agent-adapters.test.ts @@ -3,11 +3,17 @@ import { type AgentBackend, createAgentAdapter } from "adapters"; import { ClaudeCodeAdapter } from "adapters/claude"; import { CodexAdapter } from "adapters/codex"; import { CursorAgentAdapter } from "adapters/cursor"; +import { GitHubCopilotAdapter } from "adapters/github-copilot"; import { OpenCodeAdapter } from "adapters/opencode"; import type { ResolvedProjectConfig } from "../src/features/types"; function createConfig( - backend?: "codex" | "claude-code" | "cursor-agent" | "opencode", + backend?: + | "codex" + | "claude-code" + | "github-copilot" + | "cursor-agent" + | "opencode", ): ResolvedProjectConfig { return { id: "default", @@ -24,6 +30,7 @@ function createConfig( }, codex: { binary: process.execPath, streamLogs: false }, cursor: { binary: "cursor-agent", streamLogs: false }, + githubCopilot: { binary: "copilot", streamLogs: false }, opencode: { binary: "opencode", streamLogs: false }, agent: backend ? { backend } : undefined, skills: { @@ -55,6 +62,11 @@ describe("createAgentAdapter", () => { expect(adapter).toBeInstanceOf(CursorAgentAdapter); }); + it("uses github copilot backend from project config", () => { + const adapter = createAgentAdapter(createConfig("github-copilot")); + expect(adapter).toBeInstanceOf(GitHubCopilotAdapter); + }); + it("uses opencode backend from project config", () => { const adapter = createAgentAdapter(createConfig("opencode")); expect(adapter).toBeInstanceOf(OpenCodeAdapter); diff --git a/packages/cli/tests/config.test.ts b/packages/cli/tests/config.test.ts index 30be499a..1bea4aac 100644 --- a/packages/cli/tests/config.test.ts +++ b/packages/cli/tests/config.test.ts @@ -70,6 +70,13 @@ const envDefaults: Record = { CURSOR_AGENT_MODEL: undefined, CURSOR_AGENT_FORCE: undefined, CURSOR_API_KEY: undefined, + GITHUB_COPILOT_BINARY: undefined, + GITHUB_COPILOT_MODEL: undefined, + GITHUB_COPILOT_HOME: undefined, + GITHUB_COPILOT_TOKEN: undefined, + GITHUB_COPILOT_ALLOW_ALL_TOOLS: undefined, + GITHUB_COPILOT_ALLOW_TOOLS: undefined, + GITHUB_COPILOT_DENY_TOOLS: undefined, OPENCODE_BINARY: undefined, OPENCODE_MODEL: undefined, OPENCODE_AGENT: undefined, @@ -515,6 +522,14 @@ describe("loadConfig", () => { }); }); + it("loads GitHub Copilot backend from AGENT_BACKEND env", async () => { + process.env.AGENT_BACKEND = "github-copilot"; + await withTempConfig(async (tempDir) => { + const config = await loadConfig(tempDir); + expect(config.projects[0]?.agent?.backend).toBe("github-copilot"); + }); + }); + it("defaults agent backend to undefined when not set", async () => { process.env.AGENT_BACKEND = ""; await withTempConfig(async (tempDir) => { @@ -570,6 +585,30 @@ describe("loadConfig", () => { }); }); + it("loads GitHub Copilot settings from env", async () => { + process.env.GITHUB_COPILOT_BINARY = "custom-copilot"; + process.env.GITHUB_COPILOT_MODEL = "gpt-5"; + process.env.GITHUB_COPILOT_HOME = "/tmp/copilot"; + process.env.GITHUB_COPILOT_TOKEN = "copilot_secret"; + process.env.GITHUB_COPILOT_ALLOW_ALL_TOOLS = "true"; + process.env.GITHUB_COPILOT_ALLOW_TOOLS = "shell(git),file(*)"; + process.env.GITHUB_COPILOT_DENY_TOOLS = "shell(rm)"; + + await withTempConfig(async (tempDir) => { + const config = await loadConfig(tempDir); + expect(config.projects[0]?.githubCopilot).toEqual({ + binary: "custom-copilot", + streamLogs: false, + model: "gpt-5", + copilotHome: "/tmp/copilot", + githubToken: "copilot_secret", + allowAllTools: true, + allowTools: ["shell(git)", "file(*)"], + denyTools: ["shell(rm)"], + }); + }); + }); + it("loads Claude Code model from CLAUDE_CODE_MODEL env", async () => { process.env.CLAUDE_CODE_MODEL = "claude-sonnet-4-20250514"; await withTempConfig(async (tempDir) => { diff --git a/packages/cli/tests/onboard.test.ts b/packages/cli/tests/onboard.test.ts index 72af76a4..c8804919 100644 --- a/packages/cli/tests/onboard.test.ts +++ b/packages/cli/tests/onboard.test.ts @@ -699,6 +699,42 @@ describe("onboard helpers", () => { }); }); + it("reports github copilot provider and binary failures", async () => { + const checks = await collectOnboardChecks( + "/tmp/demo", + onboardCheckDeps({ + loadConfig: async () => + loadedConfig({ + linearApiKey: "lin_secret_123", + agentBackend: "github-copilot", + githubCopilotBinary: "custom-copilot", + }), + access: async () => {}, + readFile: async () => "", + runCommand: async (command) => + command === "custom-copilot" + ? { + code: 1, + stdout: "", + stderr: "command not found: custom-copilot", + } + : okCommand(), + }), + ); + + expect(checks).toContainEqual({ + name: "LLM provider", + status: "pass", + message: "configured: github-copilot", + }); + expect(checks).toContainEqual({ + name: "GitHub Copilot binary", + status: "fail", + message: + "custom-copilot binary not found. Install GitHub Copilot CLI or set GITHUB_COPILOT_BINARY", + }); + }); + it("passes onboard checks without tracker API keys", async () => { const checks = await collectOnboardChecks( "/tmp/demo", @@ -795,6 +831,29 @@ describe("onboard helpers", () => { }); }); + it("reports github copilot tokens in tracked config", async () => { + const checks = await collectOnboardChecks( + "/tmp/demo", + onboardCheckDeps({ + loadConfig: async () => + loadedConfig({ + linearApiKey: "lin_secret_123", + githubCopilotToken: "copilot_secret_123", + }), + access: async () => {}, + readFile: async (filePath) => + filePath.endsWith("devos.config.ts") ? "copilot_secret_123" : "", + runCommand: async () => okCommand(), + }), + ); + + expect(checks).toContainEqual({ + name: "Tracked config secrets", + status: "fail", + message: "devos.config.ts contains a configured secret", + }); + }); + it("reports missing rtk binary", async () => { const checks = await collectOnboardChecks( "/tmp/demo", @@ -896,12 +955,16 @@ function loadedConfig({ agentBackend, cursorApiKey, cursorBinary = "cursor-agent", + githubCopilotBinary = "copilot", + githubCopilotToken, }: { linearApiKey?: string; dockerEnabled?: boolean; - agentBackend?: "codex" | "claude-code" | "cursor-agent"; + agentBackend?: "codex" | "claude-code" | "github-copilot" | "cursor-agent"; cursorApiKey?: string; cursorBinary?: string; + githubCopilotBinary?: string; + githubCopilotToken?: string; }): LoadedConfig { return { projects: [ @@ -931,6 +994,11 @@ function loadedConfig({ apiKey: cursorApiKey, streamLogs: false, }, + githubCopilot: { + binary: githubCopilotBinary, + githubToken: githubCopilotToken, + streamLogs: false, + }, github: { useGhCli: true, defaultBugLabel: "bug", diff --git a/packages/web/src/components/runtimes/runtimes-panel-utils.ts b/packages/web/src/components/runtimes/runtimes-panel-utils.ts index 23043bbd..c4b98ce9 100644 --- a/packages/web/src/components/runtimes/runtimes-panel-utils.ts +++ b/packages/web/src/components/runtimes/runtimes-panel-utils.ts @@ -10,6 +10,9 @@ const RUNTIME_LABELS: Record = { codex: "Codex", cursor: "Cursor", gemini: "Gemini", + "github-copilot": "GitHub Copilot", + github_copilot: "GitHub Copilot", + githubcopilot: "GitHub Copilot", opencode: "OpenCode", "open-code": "OpenCode", open_code: "OpenCode", diff --git a/packages/web/src/lib/agents/use-agent-monitor.ts b/packages/web/src/lib/agents/use-agent-monitor.ts index 92ac0668..7cd706e5 100644 --- a/packages/web/src/lib/agents/use-agent-monitor.ts +++ b/packages/web/src/lib/agents/use-agent-monitor.ts @@ -25,6 +25,9 @@ const RUNTIME_LABELS: Record = { claude: "Claude", codex: "Codex", gemini: "Gemini", + "github-copilot": "GitHub Copilot", + github_copilot: "GitHub Copilot", + githubcopilot: "GitHub Copilot", opencode: "OpenCode", "open-code": "OpenCode", open_code: "OpenCode",