diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 48084b2e7..e2bc028f0 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -3,135 +3,66 @@ display_name: Copilot CLI description: GitHub Copilot CLI agent for AI-powered terminal assistance icon: ../../../../.icons/github.svg verified: false -tags: [agent, copilot, ai, github, tasks, ai-gateway] +tags: [agent, copilot, ai, github, ai-gateway] --- # Copilot -Run [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-copilot-cli) in your workspace for AI-powered coding assistance directly from the terminal. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for task reporting in the Coder UI. +Install and configure the [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-copilot-cli) in your workspace. ```tf module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.4.2" + version = "1.0.0" agent_id = coder_agent.example.id - workdir = "/home/coder/projects" + workdir = "/home/coder/project" } ``` -> [!IMPORTANT] -> This example assumes you have [Coder external authentication](https://coder.com/docs/admin/external-auth) configured with `id = "github"`. If not, you can provide a direct token using the `github_token` variable or provide the correct external authentication id for GitHub by setting `external_auth_id = "my-github"`. - -> [!NOTE] -> By default, this module is configured to run the embedded chat interface as a path-based application. In production, we recommend that you configure a [wildcard access URL](https://coder.com/docs/admin/setup#wildcard-access-url) and set `subdomain = true`. See [here](https://coder.com/docs/tutorials/best-practices/security-best-practices#disable-path-based-apps) for more details. - -## Prerequisites - -- **Node.js v22+** and **npm v10+** -- **[Active Copilot subscription](https://docs.github.com/en/copilot/about-github-copilot/subscription-plans-for-github-copilot)** (GitHub Copilot Pro, Pro+, Business, or Enterprise) -- **GitHub authentication** via one of: - - [Coder external authentication](https://coder.com/docs/admin/external-auth) (recommended) - - Direct token via `github_token` variable - - Interactive login in Copilot +> [!WARNING] +> If upgrading from v0.x of this module: v1 is a major refactor that drops support for Coder Tasks and AgentAPI. The module now only installs and configures Copilot; you launch it yourself with a `coder_app` (see below). `copilot_config` is renamed to `base_copilot_config` and now overwrites [`~/.copilot/settings.json`](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference). ## Examples -### Usage with Tasks +### Launcher app with GitHub external auth -For development environments where you want Copilot to have full access to tools and automatically resume sessions: +This assumes [Coder external authentication](https://coder.com/docs/admin/external-auth) is configured with `id = "github"`. ```tf -data "coder_parameter" "ai_prompt" { - type = "string" - name = "AI Prompt" - default = "" - description = "Initial task prompt for Copilot." - mutable = true +locals { + copilot_workdir = "/home/coder/project" } -module "copilot" { - source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.4.2" - agent_id = coder_agent.example.id - workdir = "/home/coder/projects" - - ai_prompt = data.coder_parameter.ai_prompt.value - copilot_model = "claude-sonnet-4.5" - allow_all_tools = true - resume_session = true - - trusted_directories = ["/home/coder/projects", "/tmp"] +data "coder_external_auth" "github" { + id = "github" } -``` - -### Advanced Configuration -Customize tool permissions, MCP servers, and Copilot settings: - -```tf module "copilot" { - source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.4.2" - agent_id = coder_agent.example.id - workdir = "/home/coder/projects" - - # Version pinning (defaults to "latest", use specific version if desired) - copilot_version = "0.2.3" - - # Tool permissions - allow_tools = ["shell(git)", "shell(npm)", "write"] - trusted_directories = ["/home/coder/projects", "/tmp"] - - # Custom Copilot configuration - copilot_config = jsonencode({ - banner = "never" - theme = "dark" - }) - - # MCP server configuration - mcp_config = jsonencode({ - mcpServers = { - filesystem = { - command = "npx" - args = ["-y", "@modelcontextprotocol/server-filesystem", "/home/coder/projects"] - description = "Provides file system access to the workspace" - name = "Filesystem" - timeout = 3000 - type = "local" - tools = ["*"] - trust = true - } - playwright = { - command = "npx" - args = ["-y", "@playwright/mcp@latest", "--headless", "--isolated"] - description = "Browser automation for testing and previewing changes" - name = "Playwright" - timeout = 5000 - type = "local" - tools = ["*"] - trust = false - } - } - }) + source = "registry.coder.com/coder-labs/copilot/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + workdir = local.copilot_workdir + github_token = data.coder_external_auth.github.access_token +} - # Pre-install Node.js if needed - pre_install_script = <<-EOT +resource "coder_app" "copilot" { + agent_id = coder_agent.example.id + slug = "copilot" + display_name = "Copilot" + icon = "/icon/github-copilot.svg" + open_in = "slim-window" + command = <<-EOT #!/usr/bin/env bash - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - - sudo apt-get install -y nodejs + set -e + cd "${local.copilot_workdir}" + exec copilot --allow-all-tools EOT } ``` -> [!NOTE] -> GitHub Copilot CLI does not automatically install MCP servers. You have two options: -> -> - Use `npx -y` in the MCP config (shown above) to auto-install on each run -> - Pre-install MCP servers in `pre_install_script` for faster startup (e.g., `npm install -g @modelcontextprotocol/server-filesystem`) - -### Direct Token Authentication +### Direct token authentication -Use this example when you want to provide a GitHub Personal Access Token instead of using Coder external auth: +`github_token` is exported as `COPILOT_GITHUB_TOKEN`. ```tf variable "github_token" { @@ -142,32 +73,16 @@ variable "github_token" { module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.4.2" + version = "1.0.0" agent_id = coder_agent.example.id - workdir = "/home/coder/projects" + workdir = "/home/coder/project" github_token = var.github_token } ``` -### Standalone Mode - -Run Copilot as a command-line tool without task reporting or web interface. This installs and configures Copilot, making it available as a CLI app in the Coder agent bar that you can launch to interact with Copilot directly from your terminal. Set `report_tasks = false` to disable integration with Coder Tasks. - -```tf -module "copilot" { - source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.4.2" - agent_id = coder_agent.example.id - workdir = "/home/coder" - report_tasks = false - cli_app = true -} -``` - ### Usage with AI Gateway Proxy [AI Gateway Proxy](https://coder.com/docs/ai-coder/ai-gateway/ai-gateway-proxy) routes Copilot traffic through [AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) for centralized LLM management and governance. -The proxy environment variables are scoped to the Copilot process only and do not affect other workspace traffic. ```tf module "aibridge-proxy" { @@ -178,66 +93,102 @@ module "aibridge-proxy" { } module "copilot" { - source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.4.2" - agent_id = coder_agent.main.id - workdir = "/home/coder/projects" - enable_aibridge_proxy = true - aibridge_proxy_auth_url = module.aibridge-proxy.proxy_auth_url - aibridge_proxy_cert_path = module.aibridge-proxy.cert_path + source = "registry.coder.com/coder-labs/copilot/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + enable_ai_gateway = true + ai_gateway_proxy_auth_url = module.aibridge-proxy.proxy_auth_url + ai_gateway_proxy_cert_path = module.aibridge-proxy.cert_path } ``` -> [!NOTE] -> AI Gateway Proxy is a Premium Coder feature that requires [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance). -> See the [AI Gateway Proxy setup guide](https://coder.com/docs/ai-coder/ai-gateway/ai-gateway-proxy/setup) for details on configuring the proxy on your Coder deployment. -> GitHub authentication is still required for Copilot as the proxy authenticates with AI Gateway using the Coder session token, but does not replace GitHub authentication. +### Advanced configuration -> [!IMPORTANT] -> When using AI Gateway Proxy, enable [startup coordination](https://coder.com/docs/admin/templates/startup-coordination) by setting `CODER_AGENT_SOCKET_SERVER_ENABLED=true` in the workspace container environment. -> This ensures the Copilot module waits for the `aibridge-proxy` module to complete before starting. Without it, the Copilot start script may fail if the AI Gateway Proxy setup has not completed in time. +Customize MCP servers, policy, and Copilot settings: -## Authentication +```tf +module "copilot" { + source = "registry.coder.com/coder-labs/copilot/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + workdir = "/home/coder/project" -The module supports multiple authentication methods (in priority order): + copilot_version = "1.0.88" -1. **[Coder External Auth](https://coder.com/docs/admin/external-auth) (Recommended)** - Automatic if GitHub external auth is configured in Coder -2. **Direct Token** - Pass `github_token` variable (OAuth or Personal Access Token) -3. **Interactive** - Copilot prompts for login via `/login` command if no auth found + managed_settings = { + model = "claude-sonnet-4.5" + permissions = { + deny = ["Shell(rm -rf *)"] + } + } + + base_copilot_config = <<-EOF + { + "banner": "never" + } + EOF + + mcp = <<-EOF + { + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["-y", "@playwright/mcp@latest"], + "type": "local", + "tools": ["*"] + } + } + } + EOF -> [!NOTE] -> OAuth tokens work best with Copilot. Personal Access Tokens may have limited functionality. + pre_install_script = <<-EOT + #!/usr/bin/env bash + npm install -g @playwright/mcp + EOT +} +``` -## Session Resumption +### Serialize a downstream `coder_script` after the install pipeline -By default, the module resumes the latest Copilot session when the workspace restarts. Set `resume_session = false` to always start fresh sessions. +The module exposes the `scripts` output: an ordered list of `coder exp sync` names for the scripts this module creates (pre_install, install, post_install). Scripts that were not configured are absent. -> [!NOTE] -> Session resumption requires persistent storage for the home directory or workspace volume. Without persistent storage, sessions will not resume across workspace restarts. +```tf +module "copilot" { + source = "registry.coder.com/coder-labs/copilot/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + workdir = "/home/coder/project" +} -## Troubleshooting +resource "coder_script" "post_copilot" { + agent_id = coder_agent.example.id + display_name = "Run after Copilot install" + run_on_start = true + script = <<-EOT + #!/usr/bin/env bash + set -euo pipefail + trap 'coder exp sync complete post-copilot' EXIT + coder exp sync want post-copilot ${join(" ", module.copilot.scripts)} + coder exp sync start post-copilot -If you encounter any issues, check the log files in the `~/.copilot-module` directory within your workspace for detailed information. + copilot --version + EOT +} +``` -```bash -# Installation logs -cat ~/.copilot-module/install.log +## Troubleshooting -# Startup logs -cat ~/.copilot-module/agentapi-start.log +Check the log files in `~/.coder-modules/coder-labs/copilot/logs/` for detailed information. -# Pre/post install script logs -cat ~/.copilot-module/pre_install.log -cat ~/.copilot-module/post_install.log +```bash +cat ~/.coder-modules/coder-labs/copilot/logs/install.log +cat ~/.coder-modules/coder-labs/copilot/logs/pre_install.log +cat ~/.coder-modules/coder-labs/copilot/logs/post_install.log ``` -> [!NOTE] -> To use tasks with Copilot, you must have an active GitHub Copilot subscription. -> The `workdir` variable is required and specifies the directory where Copilot will run. - ## References - [GitHub Copilot CLI Documentation](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli) - [Installing GitHub Copilot CLI](https://docs.github.com/en/copilot/how-tos/set-up/install-copilot-cli) -- [AgentAPI Documentation](https://github.com/coder/agentapi) - [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents) diff --git a/registry/coder-labs/modules/copilot/copilot.tftest.hcl b/registry/coder-labs/modules/copilot/copilot.tftest.hcl deleted file mode 100644 index a4fc947f6..000000000 --- a/registry/coder-labs/modules/copilot/copilot.tftest.hcl +++ /dev/null @@ -1,354 +0,0 @@ -run "defaults_are_correct" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - } - - assert { - condition = var.copilot_model == "claude-sonnet-4.5" - error_message = "Default model should be 'claude-sonnet-4.5'" - } - - assert { - condition = var.report_tasks == true - error_message = "Task reporting should be enabled by default" - } - - assert { - condition = var.resume_session == true - error_message = "Session resumption should be enabled by default" - } - - assert { - condition = var.allow_all_tools == false - error_message = "allow_all_tools should be disabled by default" - } - - assert { - condition = resource.coder_env.mcp_app_status_slug.name == "CODER_MCP_APP_STATUS_SLUG" - error_message = "Status slug env var should be created" - } - - assert { - condition = resource.coder_env.mcp_app_status_slug.value == "copilot" - error_message = "Status slug value should be 'copilot'" - } -} - -run "github_token_creates_env_var" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - github_token = "test_github_token_abc123" - } - - assert { - condition = length(resource.coder_env.github_token) == 1 - error_message = "github_token env var should be created when token is provided" - } - - assert { - condition = resource.coder_env.github_token[0].name == "GITHUB_TOKEN" - error_message = "github_token env var name should be 'GITHUB_TOKEN'" - } - - assert { - condition = resource.coder_env.github_token[0].value == "test_github_token_abc123" - error_message = "github_token env var value should match input" - } -} - -run "github_token_not_created_when_empty" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - github_token = "" - } - - assert { - condition = length(resource.coder_env.github_token) == 0 - error_message = "github_token env var should not be created when empty" - } -} - -run "copilot_model_env_var_for_non_default" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - copilot_model = "claude-sonnet-4" - } - - assert { - condition = length(resource.coder_env.copilot_model) == 1 - error_message = "copilot_model env var should be created for non-default model" - } - - assert { - condition = resource.coder_env.copilot_model[0].name == "COPILOT_MODEL" - error_message = "copilot_model env var name should be 'COPILOT_MODEL'" - } - - assert { - condition = resource.coder_env.copilot_model[0].value == "claude-sonnet-4" - error_message = "copilot_model env var value should match input" - } -} - -run "copilot_model_not_created_for_default" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - copilot_model = "claude-sonnet-4.5" - } - - assert { - condition = length(resource.coder_env.copilot_model) == 0 - error_message = "copilot_model env var should not be created for default model" - } -} - -run "copilot_model_accepts_custom_model" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - copilot_model = "o3-pro" - } - - assert { - condition = var.copilot_model == "o3-pro" - error_message = "copilot_model should accept any model string" - } - - assert { - condition = length(resource.coder_env.copilot_model) == 1 - error_message = "copilot_model env var should be created for non-default model" - } -} - -run "copilot_config_merges_with_trusted_directories" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - trusted_directories = ["/workspace", "/data"] - } - - assert { - condition = length(local.final_copilot_config) > 0 - error_message = "final_copilot_config should be computed" - } - - # Verify workdir is trimmed of trailing slash - assert { - condition = local.workdir == "/home/coder/project" - error_message = "workdir should be trimmed of trailing slash" - } -} - -run "custom_copilot_config_overrides_default" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - copilot_config = jsonencode({ - banner = "always" - theme = "dark" - }) - } - - assert { - condition = var.copilot_config != "" - error_message = "Custom copilot config should be set" - } - - assert { - condition = jsondecode(local.final_copilot_config).banner == "always" - error_message = "Custom banner setting should be applied" - } - - assert { - condition = jsondecode(local.final_copilot_config).theme == "dark" - error_message = "Custom theme setting should be applied" - } -} - -run "trusted_directories_merged_with_custom_config" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - copilot_config = jsonencode({ - banner = "always" - theme = "dark" - trusted_folders = ["/custom"] - }) - trusted_directories = ["/workspace", "/data"] - } - - assert { - condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/custom") - error_message = "Custom trusted folder should be included" - } - - assert { - condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/home/coder/project") - error_message = "Workdir should be included in trusted folders" - } - - assert { - condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/workspace") - error_message = "trusted_directories should be merged into config" - } - - assert { - condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/data") - error_message = "All trusted_directories should be merged into config" - } -} - -run "app_slug_is_consistent" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - } - - assert { - condition = local.app_slug == "copilot" - error_message = "app_slug should be 'copilot'" - } - - assert { - condition = local.module_dir_name == ".copilot-module" - error_message = "module_dir_name should be '.copilot-module'" - } -} - -run "aibridge_proxy_defaults" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - } - - assert { - condition = var.enable_aibridge_proxy == false - error_message = "enable_aibridge_proxy should default to false" - } - - assert { - condition = var.aibridge_proxy_auth_url == null - error_message = "aibridge_proxy_auth_url should default to null" - } - - assert { - condition = var.aibridge_proxy_cert_path == null - error_message = "aibridge_proxy_cert_path should default to null" - } -} - -run "aibridge_proxy_enabled" { - command = plan - - variables { - agent_id = "test-agent-aibridge-proxy" - workdir = "/home/coder" - enable_aibridge_proxy = true - aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com" - aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem" - } - - assert { - condition = var.enable_aibridge_proxy == true - error_message = "AI Gateway Proxy should be enabled" - } - - assert { - condition = var.aibridge_proxy_auth_url == "https://coder:mock-token@aiproxy.example.com" - error_message = "AI Gateway Proxy auth URL should match the input variable" - } - - assert { - condition = var.aibridge_proxy_cert_path == "/tmp/aibridge-proxy/ca-cert.pem" - error_message = "AI Gateway Proxy cert path should match the input variable" - } -} - -run "aibridge_proxy_validation_missing_proxy_auth_url" { - command = plan - - variables { - agent_id = "test-agent-validation" - workdir = "/home/coder" - enable_aibridge_proxy = true - aibridge_proxy_auth_url = "" - aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem" - } - - expect_failures = [ - var.enable_aibridge_proxy, - ] -} - -run "aibridge_proxy_validation_missing_cert_path" { - command = plan - - variables { - agent_id = "test-agent-validation" - workdir = "/home/coder" - enable_aibridge_proxy = true - aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com" - aibridge_proxy_cert_path = "" - } - - expect_failures = [ - var.enable_aibridge_proxy, - ] -} - -run "aibridge_proxy_with_copilot_config" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - copilot_model = "gpt-5" - github_token = "ghp_test123" - allow_all_tools = true - enable_aibridge_proxy = true - aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com" - aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem" - } - - assert { - condition = var.enable_aibridge_proxy == true - error_message = "AI Gateway Proxy should be enabled" - } - - assert { - condition = length(resource.coder_env.github_token) == 1 - error_message = "github_token environment variable should be set alongside proxy" - } - - assert { - condition = length(resource.coder_env.copilot_model) == 1 - error_message = "copilot_model environment variable should be set alongside proxy" - } -} diff --git a/registry/coder-labs/modules/copilot/main.test.ts b/registry/coder-labs/modules/copilot/main.test.ts index 1d438e33b..5fbea2f92 100644 --- a/registry/coder-labs/modules/copilot/main.test.ts +++ b/registry/coder-labs/modules/copilot/main.test.ts @@ -1,136 +1,353 @@ -import { describe, expect, it } from "bun:test"; import { - findResourceInstance, + test, + afterEach, + describe, + setDefaultTimeout, + beforeAll, + expect, +} from "bun:test"; +import { + execContainer, + readFileContainer, + removeContainer, + runContainer, runTerraformApply, runTerraformInit, - testRequiredVariables, + TerraformState, } from "~test"; +import { + extractCoderEnvVars, + writeExecutable, +} from "../../../coder/modules/agentapi/test-util"; +import path from "path"; -describe("copilot", async () => { - await runTerraformInit(import.meta.dir); +interface ModuleScripts { + pre_install?: string; + install: string; + post_install?: string; +} - testRequiredVariables(import.meta.dir, { - agent_id: "test-agent", - workdir: "/home/coder", - }); +const SCRIPT_SUFFIXES = [ + "Pre-Install Script", + "Install Script", + "Post-Install Script", +] as const; - it("creates mcp_app_status_slug env var", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "test-agent", - workdir: "/home/coder", - }); +const collectScripts = (state: TerraformState): ModuleScripts => { + const byDisplayName: Record = {}; + for (const resource of state.resources) { + if (resource.type !== "coder_script") continue; + for (const instance of resource.instances) { + const attrs = instance.attributes as Record; + const displayName = attrs.display_name as string | undefined; + const script = attrs.script as string | undefined; + if (displayName && script) { + byDisplayName[displayName] = script; + } + } + } + const scripts: Partial = {}; + for (const suffix of SCRIPT_SUFFIXES) { + const key = `Copilot: ${suffix}`; + if (!(key in byDisplayName)) continue; + switch (suffix) { + case "Pre-Install Script": + scripts.pre_install = byDisplayName[key]; + break; + case "Install Script": + scripts.install = byDisplayName[key]; + break; + case "Post-Install Script": + scripts.post_install = byDisplayName[key]; + break; + } + } + if (!scripts.install) { + throw new Error("install script not found in terraform state"); + } + return scripts as ModuleScripts; +}; - const statusSlugEnv = findResourceInstance( - state, - "coder_env", - "mcp_app_status_slug", - ); - expect(statusSlugEnv).toBeDefined(); - expect(statusSlugEnv.name).toBe("CODER_MCP_APP_STATUS_SLUG"); - expect(statusSlugEnv.value).toBe("copilot"); +let cleanupFunctions: (() => Promise)[] = []; +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; +afterEach(async () => { + const cleanupFnsCopy = cleanupFunctions.slice().reverse(); + cleanupFunctions = []; + for (const cleanup of cleanupFnsCopy) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } +}); + +interface SetupProps { + skipCopilotMock?: boolean; + moduleVariables?: Record; +} + +const projectDir = "/home/coder/project"; + +const setup = async ( + props?: SetupProps, +): Promise<{ + id: string; + coderEnvVars: Record; + scripts: ModuleScripts; +}> => { + const moduleDir = path.resolve(import.meta.dir); + const state = await runTerraformApply(moduleDir, { + agent_id: "foo", + workdir: projectDir, + install_copilot: "false", + ...props?.moduleVariables, }); + const scripts = collectScripts(state); + const coderEnvVars = extractCoderEnvVars(state); - it("creates github_token env var with correct value", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "test-agent", - workdir: "/home/coder", - github_token: "test_token_12345", - }); + const id = await runContainer("codercom/enterprise-node:latest"); + registerCleanup(async () => { + if (process.env["DEBUG"] === "true" || process.env["DEBUG"] === "1") { + console.log(`Not removing container ${id} in debug mode`); + return; + } + await removeContainer(id); + }); - const githubTokenEnv = findResourceInstance( - state, - "coder_env", - "github_token", - ); - expect(githubTokenEnv).toBeDefined(); - expect(githubTokenEnv.name).toBe("GITHUB_TOKEN"); - expect(githubTokenEnv.value).toBe("test_token_12345"); + await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]); + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: "#!/bin/bash\nexit 0\n", }); + if (!props?.skipCopilotMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/copilot", + content: await Bun.file( + path.join(moduleDir, "testdata", "copilot-mock.sh"), + ).text(), + }); + } + return { id, coderEnvVars, scripts }; +}; - it("does not create github_token env var when empty", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "test-agent", - workdir: "/home/coder", - github_token: "", +const runScripts = async ( + id: string, + scripts: ModuleScripts, + env?: Record, +) => { + const entries = env ? Object.entries(env) : []; + const envArgs = + entries.length > 0 + ? entries + .map( + ([key, value]) => `export ${key}="${value.replace(/"/g, '\\"')}"`, + ) + .join(" && ") + " && " + : ""; + const runRenderedScript = async (name: string, script: string) => { + const target = `/tmp/coder-utils-${name}.sh`; + await writeExecutable({ + containerId: id, + filePath: target, + content: script, }); + return execContainer(id, ["bash", "-c", `${envArgs}${target}`]); + }; + const ordered: [string, string | undefined][] = [ + ["pre_install", scripts.pre_install], + ["install", scripts.install], + ["post_install", scripts.post_install], + ]; + for (const [name, script] of ordered) { + if (!script) continue; + const resp = await runRenderedScript(name, script); + if (resp.exitCode !== 0) { + console.log(`script ${name} failed:`); + console.log(resp.stdout); + console.log(resp.stderr); + throw new Error(`coder-utils ${name} script exited ${resp.exitCode}`); + } + } +}; - const githubTokenEnvs = state.resources.filter( - (r) => r.type === "coder_env" && r.name === "github_token", - ); - expect(githubTokenEnvs.length).toBe(0); +const configDir = "/home/coder/.copilot"; +const readConfig = (id: string) => + readFileContainer(id, `${configDir}/config.json`); +const readMcpConfig = (id: string) => + readFileContainer(id, `${configDir}/mcp-config.json`); +const installLog = (id: string) => + readFileContainer( + id, + "/home/coder/.coder-modules/coder-labs/copilot/logs/install.log", + ); + +setDefaultTimeout(60 * 1000); + +describe("copilot", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); }); - it("creates copilot_model env var for non-default models", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "test-agent", - workdir: "/home/coder", - copilot_model: "claude-sonnet-4", - }); + test("happy-path-skips-install-when-present", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const log = await installLog(id); + expect(log).toContain("Validated existing GitHub Copilot CLI"); + expect(log).toContain("Copilot module setup completed."); + }); - const modelEnv = findResourceInstance(state, "coder_env", "copilot_model"); - expect(modelEnv).toBeDefined(); - expect(modelEnv.name).toBe("COPILOT_MODEL"); - expect(modelEnv.value).toBe("claude-sonnet-4"); + test("trusts-workdir-in-config", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + // workdir is auto-trusted in config.json under trustedFolders. + const config = JSON.parse(await readConfig(id)); + expect(config.trustedFolders).toContain(projectDir); + // mcpServers must never be written into config.json. + expect(config.mcpServers).toBeUndefined(); }); - it("does not create copilot_model env var for default model", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "test-agent", - workdir: "/home/coder", - copilot_model: "claude-sonnet-4.5", + test("trusted-folders-union-preserves-existing-state", async () => { + const { id, scripts } = await setup(); + // Seed config.json the way Copilot writes it (JSONC header) with an + // interactively trusted folder and auth-like state. + const configSeed = JSON.stringify({ + trustedFolders: ["/interactively-trusted"], + loggedInUsers: ["octocat"], }); + await execContainer(id, [ + "bash", + "-c", + `mkdir -p /home/coder/.copilot && cat > /home/coder/.copilot/config.json <<'JSON'\n// User settings belong in settings.json.\n// This file is managed automatically.\n${configSeed}\nJSON`, + ]); + await runScripts(id, scripts); + const config = JSON.parse(await readConfig(id)); + // trustedFolders is the union of existing + workdir. + expect(config.trustedFolders).toContain("/interactively-trusted"); + expect(config.trustedFolders).toContain(projectDir); + // Unrelated config.json application state is preserved. + expect(config.loggedInUsers).toContain("octocat"); + }); - const modelEnvs = state.resources.filter( - (r) => r.type === "coder_env" && r.name === "copilot_model", + test("base-config-overwrites-settings", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + base_copilot_config: JSON.stringify({ banner: "never" }), + }, + }); + await execContainer(id, [ + "bash", + "-c", + `mkdir -p /home/coder/.copilot && echo '{"theme":"dim"}' > /home/coder/.copilot/settings.json`, + ]); + await runScripts(id, scripts); + const settings = JSON.parse( + await readFileContainer(id, "/home/coder/.copilot/settings.json"), ); - expect(modelEnvs.length).toBe(0); + expect(settings).toEqual({ banner: "never" }); }); - it("creates coder_script resources via agentapi module", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "test-agent", - workdir: "/home/coder", + test("managed-settings-written", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + managed_settings: JSON.stringify({ + model: "claude-sonnet-4.5", + permissions: { deny: ["Shell(rm -rf *)"] }, + }), + }, }); + await runScripts(id, scripts); + const policy = await execContainer(id, [ + "bash", + "-c", + "cat /etc/github-copilot/managed-settings.json", + ]); + expect(policy.exitCode).toBe(0); + expect(policy.stdout).toContain('"model":"claude-sonnet-4.5"'); + expect(policy.stdout).toContain('"deny":["Shell(rm -rf *)"]'); + const log = await installLog(id); + expect(log).toContain("Wrote Copilot managed settings"); + }); - // The agentapi module should create coder_script resources for install and start - const scripts = state.resources.filter((r) => r.type === "coder_script"); - expect(scripts.length).toBeGreaterThan(0); + test("managed-settings-not-set", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const resp = await execContainer(id, [ + "bash", + "-c", + "test -e /etc/github-copilot/managed-settings.json && echo EXISTS || echo ABSENT", + ]); + expect(resp.stdout.trim()).toBe("ABSENT"); }); - it("validates copilot_model accepts valid values", async () => { - // Test valid models don't throw errors - await expect( - runTerraformApply(import.meta.dir, { - agent_id: "test-agent", - workdir: "/home/coder", - copilot_model: "gpt-5", - }), - ).resolves.toBeDefined(); - - await expect( - runTerraformApply(import.meta.dir, { - agent_id: "test-agent", - workdir: "/home/coder", - copilot_model: "claude-sonnet-4.5", - }), - ).resolves.toBeDefined(); + test("writes-custom-mcp-servers-without-coder-server", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + mcp: JSON.stringify({ + mcpServers: { + filesystem: { + command: "npx", + args: ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"], + type: "local", + tools: ["*"], + }, + }, + }), + }, + }); + await runScripts(id, scripts); + const mcp = JSON.parse(await readMcpConfig(id)); + // The server is written straight into ~/.copilot/mcp-config.json. + expect(mcp.mcpServers.filesystem).toBeDefined(); + expect(mcp.mcpServers.filesystem.command).toBe("npx"); + // The task-reporting "coder" MCP server must not be injected anymore. + expect(mcp.mcpServers.coder).toBeUndefined(); }); - it("merges trusted_directories with custom copilot_config", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "test-agent", - workdir: "/home/coder/project", - trusted_directories: JSON.stringify(["/workspace", "/data"]), - copilot_config: JSON.stringify({ - banner: "always", - theme: "dark", - trusted_folders: ["/custom"], - }), + test("merges-mcp-config-existing-servers-win", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + mcp: JSON.stringify({ + mcpServers: { + filesystem: { command: "module-command", type: "local" }, + extra: { command: "npx", type: "local" }, + }, + }), + }, }); + // Seed an existing config with a conflicting server and an unrelated one. + const seed = JSON.stringify({ + mcpServers: { + filesystem: { command: "existing-command", type: "local" }, + seeded: { command: "seeded-cmd", type: "local" }, + }, + }); + await execContainer(id, [ + "bash", + "-c", + `mkdir -p /home/coder/.copilot && cat > /home/coder/.copilot/mcp-config.json <<'JSON'\n${seed}\nJSON`, + ]); + await runScripts(id, scripts); + const mcp = JSON.parse(await readMcpConfig(id)); + // Existing on-disk server wins on the duplicate key (matches claude-code). + expect(mcp.mcpServers.filesystem.command).toBe("existing-command"); + // Unrelated on-disk server is preserved. + expect(mcp.mcpServers.seeded).toBeDefined(); + // Non-conflicting module server is merged in. + expect(mcp.mcpServers.extra).toBeDefined(); + }); - // Verify that the state was created successfully with the merged config - // The actual merging logic is tested in the .tftest.hcl file - expect(state).toBeDefined(); - expect(state.resources).toBeDefined(); + test("github-token-env-var", async () => { + const token = "ghp_test_token_123"; + const { coderEnvVars } = await setup({ + moduleVariables: { + github_token: token, + }, + }); + expect(coderEnvVars["COPILOT_GITHUB_TOKEN"]).toBe(token); }); }); diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index 32d5c56f8..626ae598c 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -13,147 +13,58 @@ variable "agent_id" { description = "The ID of a Coder agent." } -variable "workdir" { +variable "icon" { type = string - description = "The folder to run Copilot in." + description = "The icon to use for the app." + default = "/icon/github-copilot.svg" } -variable "external_auth_id" { +variable "workdir" { type = string - description = "ID of the GitHub external auth provider configured in Coder." - default = "github" + description = "Optional project directory. When set, the module pre-creates it if missing and adds it to Copilot's config.json trustedFolders." + default = null } variable "github_token" { type = string - description = "GitHub OAuth token or Personal Access Token. If provided, this will be used instead of auto-detecting authentication." + description = "GitHub OAuth token or fine-grained Personal Access Token. When set, exported to the workspace as COPILOT_GITHUB_TOKEN for Copilot authentication." default = "" sensitive = true } variable "copilot_model" { type = string - description = "The model to use for Copilot. Any model supported by GitHub Copilot can be used." - default = "claude-sonnet-4.5" -} - -variable "copilot_config" { - type = string - description = "Custom Copilot configuration as JSON string. Leave empty to use default configuration with banner disabled, theme set to auto, and workdir as trusted folder." + description = "Sets the model for Copilot via the COPILOT_MODEL env var. If empty, Copilot uses its default. Any model supported by GitHub Copilot can be used." default = "" } -variable "ai_prompt" { +variable "base_copilot_config" { type = string - description = "Initial task prompt for programmatic mode." + description = "Copilot user settings as JSON. When set, it overwrites ~/.copilot/settings.json on every start. See https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference" default = "" } -variable "system_prompt" { - type = string - description = "The system prompt to use for the Copilot server. Task reporting instructions are automatically added when report_tasks is enabled." - default = "You are a helpful coding assistant that helps developers write, debug, and understand code. Provide clear explanations, follow best practices, and help solve coding problems efficiently." -} - -variable "trusted_directories" { - type = list(string) - description = "Additional directories to trust for Copilot operations." - default = [] -} - -variable "allow_all_tools" { - type = bool - description = "Allow all tools without prompting (equivalent to --allow-all-tools)." - default = false -} - -variable "allow_tools" { - type = list(string) - description = "Specific tools to allow: shell(command), write, or MCP_SERVER_NAME." - default = [] -} - -variable "deny_tools" { - type = list(string) - description = "Specific tools to deny: shell(command), write, or MCP_SERVER_NAME." - default = [] +variable "managed_settings" { + type = any + description = "Policy settings written to /etc/github-copilot/managed-settings.json. Highest-precedence Copilot config; only supported keys apply (model, permissions, allowedMcpServers, deniedMcpServers, telemetry, sandbox, etc.). See https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference#mdm-managed-settings" + default = null } -variable "mcp_config" { +variable "mcp" { type = string - description = "Custom MCP server configuration as JSON string." + description = "Custom MCP server configuration as a JSON string (in the `{\"mcpServers\": {...}}` shape). Merged into Copilot's `~/.copilot/mcp-config.json`; existing servers on disk win on duplicate names." default = "" } -variable "install_agentapi" { - type = bool - description = "Whether to install AgentAPI." - default = true -} - -variable "agentapi_version" { - type = string - description = "The version of AgentAPI to install." - default = "v0.10.0" -} - variable "copilot_version" { type = string description = "The version of GitHub Copilot CLI to install. Use 'latest' for the latest version or specify a version like '0.0.334'." default = "latest" } -variable "report_tasks" { +variable "install_copilot" { type = bool - description = "Whether to enable task reporting to Coder UI via AgentAPI." - default = true -} - -variable "subdomain" { - type = bool - description = "Whether to use a subdomain for AgentAPI." - default = false -} - -variable "order" { - type = number - description = "The order determines the position of app in the UI presentation." - default = null -} - -variable "group" { - type = string - description = "The name of a group that this app belongs to." - default = null -} - -variable "icon" { - type = string - description = "The icon to use for the app." - default = "/icon/github.svg" -} - -variable "web_app_display_name" { - type = string - description = "Display name for the web app." - default = "Copilot" -} - -variable "cli_app" { - type = bool - description = "Whether to create a CLI app for Copilot." - default = false -} - -variable "cli_app_display_name" { - type = string - description = "Display name for the CLI app." - default = "Copilot" -} - -variable "resume_session" { - type = bool - description = "Whether to automatically resume the latest Copilot session on workspace restart." + description = "Whether to install GitHub Copilot CLI." default = true } @@ -169,90 +80,37 @@ variable "post_install_script" { default = null } -variable "enable_aibridge_proxy" { +variable "enable_ai_gateway" { type = bool description = "Route Copilot traffic through AI Gateway Proxy. See https://coder.com/docs/ai-coder/ai-gateway/ai-gateway-proxy" default = false validation { - condition = !var.enable_aibridge_proxy || (var.aibridge_proxy_auth_url != null && length(var.aibridge_proxy_auth_url) > 0) - error_message = "aibridge_proxy_auth_url is required when enable_aibridge_proxy is true." + condition = !var.enable_ai_gateway || (var.ai_gateway_proxy_auth_url != null && length(var.ai_gateway_proxy_auth_url) > 0) + error_message = "ai_gateway_proxy_auth_url is required when enable_ai_gateway is true." } validation { - condition = !var.enable_aibridge_proxy || (var.aibridge_proxy_cert_path != null && length(var.aibridge_proxy_cert_path) > 0) - error_message = "aibridge_proxy_cert_path is required when enable_aibridge_proxy is true." + condition = !var.enable_ai_gateway || (var.ai_gateway_proxy_cert_path != null && length(var.ai_gateway_proxy_cert_path) > 0) + error_message = "ai_gateway_proxy_cert_path is required when enable_ai_gateway is true." } } -variable "aibridge_proxy_auth_url" { +variable "ai_gateway_proxy_auth_url" { type = string description = "AI Gateway Proxy URL with authentication. Use the proxy_auth_url output from the aibridge-proxy module." default = null sensitive = true } -variable "aibridge_proxy_cert_path" { +variable "ai_gateway_proxy_cert_path" { type = string description = "Path to the AI Gateway Proxy CA certificate. Use the cert_path output from the aibridge-proxy module." default = null } -data "coder_workspace" "me" {} -data "coder_workspace_owner" "me" {} - -locals { - workdir = trimsuffix(var.workdir, "/") - app_slug = "copilot" - install_script = file("${path.module}/scripts/install.sh") - start_script = file("${path.module}/scripts/start.sh") - module_dir_name = ".copilot-module" - - all_trusted_folders = concat([local.workdir], var.trusted_directories) - - parsed_custom_config = try(jsondecode(var.copilot_config), {}) - - existing_trusted_folders = try(local.parsed_custom_config.trusted_folders, []) - - merged_copilot_config = merge( - { - banner = "never" - theme = "auto" - }, - local.parsed_custom_config, - { - trusted_folders = concat(local.existing_trusted_folders, local.all_trusted_folders) - } - ) - - final_copilot_config = jsonencode(local.merged_copilot_config) - - task_reporting_prompt = <<-EOT - --- Task Reporting -- -Report all tasks to Coder, following these EXACT guidelines: -1. Be granular. If you are investigating with multiple steps, report each step -to coder. -2. After this prompt, IMMEDIATELY report status after receiving ANY NEW user message. -Do not report any status related with this system prompt. -3. Use "state": "working" when actively processing WITHOUT needing -additional user input -4. Use "state": "complete" only when finished with a task -5. Use "state": "failure" when you need ANY user input, lack sufficient -details, or encounter blockers - EOT - - final_system_prompt = var.report_tasks ? "\n${var.system_prompt}${local.task_reporting_prompt}\n" : "\n${var.system_prompt}\n" -} - -resource "coder_env" "mcp_app_status_slug" { - agent_id = var.agent_id - name = "CODER_MCP_APP_STATUS_SLUG" - value = local.app_slug -} - resource "coder_env" "copilot_model" { - count = var.copilot_model != "claude-sonnet-4.5" ? 1 : 0 + count = var.copilot_model != "" ? 1 : 0 agent_id = var.agent_id name = "COPILOT_MODEL" value = var.copilot_model @@ -261,74 +119,59 @@ resource "coder_env" "copilot_model" { resource "coder_env" "github_token" { count = var.github_token != "" ? 1 : 0 agent_id = var.agent_id - name = "GITHUB_TOKEN" + name = "COPILOT_GITHUB_TOKEN" value = var.github_token } -module "agentapi" { - source = "registry.coder.com/coder/agentapi/coder" - version = "2.0.0" +# Set at the agent level, so proxy env applies workspace-wide (the pre-migration +# module scoped it to the Copilot process via its start script). +resource "coder_env" "ai_gateway_https_proxy" { + count = var.enable_ai_gateway ? 1 : 0 + agent_id = var.agent_id + name = "HTTPS_PROXY" + value = var.ai_gateway_proxy_auth_url +} + +resource "coder_env" "ai_gateway_node_extra_ca_certs" { + count = var.enable_ai_gateway ? 1 : 0 + agent_id = var.agent_id + name = "NODE_EXTRA_CA_CERTS" + value = var.ai_gateway_proxy_cert_path +} - agent_id = var.agent_id - folder = local.workdir - web_app_slug = local.app_slug - web_app_order = var.order - web_app_group = var.group - web_app_icon = var.icon - web_app_display_name = var.web_app_display_name - cli_app = var.cli_app - cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null - cli_app_icon = var.cli_app ? var.icon : null - cli_app_display_name = var.cli_app ? var.cli_app_display_name : null - agentapi_subdomain = var.subdomain - module_dir_name = local.module_dir_name - install_agentapi = var.install_agentapi - agentapi_version = var.agentapi_version - pre_install_script = var.pre_install_script - post_install_script = var.post_install_script +locals { + workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" - start_script = <<-EOT - #!/usr/bin/env bash - set -o errexit - set -o pipefail - echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh - chmod +x /tmp/start.sh + # workdir is trusted automatically via config.json trustedFolders. + workdir_trusted_folders = local.workdir != "" ? [local.workdir] : [] - ARG_WORKDIR='${local.workdir}' \ - ARG_AI_PROMPT='${base64encode(var.ai_prompt)}' \ - ARG_SYSTEM_PROMPT='${base64encode(local.final_system_prompt)}' \ - ARG_COPILOT_MODEL='${var.copilot_model}' \ - ARG_ALLOW_ALL_TOOLS='${var.allow_all_tools}' \ - ARG_ALLOW_TOOLS='${join(",", var.allow_tools)}' \ - ARG_DENY_TOOLS='${join(",", var.deny_tools)}' \ - ARG_TRUSTED_DIRECTORIES='${join(",", var.trusted_directories)}' \ - ARG_EXTERNAL_AUTH_ID='${var.external_auth_id}' \ - ARG_RESUME_SESSION='${var.resume_session}' \ - ARG_ENABLE_AIBRIDGE_PROXY='${var.enable_aibridge_proxy}' \ - ARG_AIBRIDGE_PROXY_AUTH_URL='${var.aibridge_proxy_auth_url != null ? var.aibridge_proxy_auth_url : ""}' \ - ARG_AIBRIDGE_PROXY_CERT_PATH='${var.aibridge_proxy_cert_path != null ? var.aibridge_proxy_cert_path : ""}' \ - /tmp/start.sh - EOT + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_INSTALL = tostring(var.install_copilot) + ARG_COPILOT_VERSION = var.copilot_version + ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" + ARG_MANAGED_SETTINGS_JSON = var.managed_settings != null ? base64encode(jsonencode(var.managed_settings)) : "" + ARG_TRUSTED_FOLDERS = length(local.workdir_trusted_folders) > 0 ? base64encode(jsonencode(local.workdir_trusted_folders)) : "" + ARG_BASE_CONFIG = var.base_copilot_config != "" ? base64encode(var.base_copilot_config) : "" + ARG_MCP_CONFIG = var.mcp != "" ? base64encode(var.mcp) : "" + }) + + module_dir_name = ".coder-modules/coder-labs/copilot" +} - install_script = <<-EOT - #!/usr/bin/env bash - set -o errexit - set -o pipefail - echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh - chmod +x /tmp/install.sh +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" - ARG_MCP_APP_STATUS_SLUG='${local.app_slug}' \ - ARG_REPORT_TASKS='${var.report_tasks}' \ - ARG_WORKDIR='${local.workdir}' \ - ARG_MCP_CONFIG='${var.mcp_config != "" ? base64encode(var.mcp_config) : ""}' \ - ARG_COPILOT_CONFIG='${base64encode(local.final_copilot_config)}' \ - ARG_EXTERNAL_AUTH_ID='${var.external_auth_id}' \ - ARG_COPILOT_VERSION='${var.copilot_version}' \ - ARG_COPILOT_MODEL='${var.copilot_model}' \ - /tmp/install.sh - EOT + agent_id = var.agent_id + module_directory = "$HOME/${local.module_dir_name}" + display_name_prefix = "Copilot" + icon = var.icon + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + install_script = local.install_script } -output "task_app_id" { - value = module.agentapi.task_app_id +output "scripts" { + description = "Ordered list of coder exp sync names for the coder_script resources this module creates, in run order (pre_install, install, post_install). Scripts that were not configured are absent from the list." + value = module.coder_utils.scripts } diff --git a/registry/coder-labs/modules/copilot/main.tftest.hcl b/registry/coder-labs/modules/copilot/main.tftest.hcl new file mode 100644 index 000000000..24a43df71 --- /dev/null +++ b/registry/coder-labs/modules/copilot/main.tftest.hcl @@ -0,0 +1,215 @@ +run "defaults_are_correct" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = var.copilot_model == "" + error_message = "copilot_model should default to an empty string" + } + + assert { + condition = var.install_copilot == true + error_message = "install_copilot should default to true" + } + + assert { + condition = local.module_dir_name == ".coder-modules/coder-labs/copilot" + error_message = "module_dir_name should be '.coder-modules/coder-labs/copilot'" + } +} + +run "github_token_creates_env_vars" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + github_token = "test_github_token_abc123" + } + + assert { + condition = coder_env.github_token[0].name == "COPILOT_GITHUB_TOKEN" && coder_env.github_token[0].value == "test_github_token_abc123" + error_message = "COPILOT_GITHUB_TOKEN env var should be created with the provided token" + } +} + +run "github_token_not_created_when_empty" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + github_token = "" + } + + assert { + condition = length(coder_env.github_token) == 0 + error_message = "GitHub token env var should not be created when empty" + } +} + +run "copilot_model_env_var_uses_given_model" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + copilot_model = "claude-sonnet-4" + } + + assert { + condition = coder_env.copilot_model[0].name == "COPILOT_MODEL" && coder_env.copilot_model[0].value == "claude-sonnet-4" + error_message = "COPILOT_MODEL env var should be created with the given model" + } +} + +run "copilot_model_env_var_not_created_when_empty" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = length(coder_env.copilot_model) == 0 + error_message = "COPILOT_MODEL env var should not be created when copilot_model is empty" + } +} + +run "workdir_trimmed_and_trusted" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder/project/" + } + + assert { + condition = local.workdir == "/home/coder/project" + error_message = "workdir should be trimmed of trailing slash" + } + + assert { + condition = contains(local.workdir_trusted_folders, "/home/coder/project") + error_message = "workdir should be trusted automatically" + } +} + +run "workdir_optional" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = local.workdir == "" + error_message = "workdir should default to an empty string" + } + + assert { + condition = length(output.scripts) == 1 + error_message = "scripts output should have the install script even without a workdir" + } +} + +run "script_outputs_install_only" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = output.scripts == ["coder-labs-copilot-install_script"] + error_message = "scripts output should list only the install script when pre/post are not configured" + } +} + +run "script_outputs_with_pre_and_post" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + pre_install_script = "echo pre" + post_install_script = "echo post" + } + + assert { + condition = output.scripts == ["coder-labs-copilot-pre_install_script", "coder-labs-copilot-install_script", "coder-labs-copilot-post_install_script"] + error_message = "scripts output should list pre_install, install, post_install in run order" + } +} + +run "ai_gateway_disabled_creates_no_proxy_env" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = length(coder_env.ai_gateway_https_proxy) == 0 && length(coder_env.ai_gateway_node_extra_ca_certs) == 0 + error_message = "No proxy env vars should be created when enable_ai_gateway is false" + } +} + +run "ai_gateway_enabled_sets_proxy_env" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + enable_ai_gateway = true + ai_gateway_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com" + ai_gateway_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem" + } + + assert { + condition = coder_env.ai_gateway_https_proxy[0].name == "HTTPS_PROXY" && coder_env.ai_gateway_https_proxy[0].value == "https://coder:mock-token@aiproxy.example.com" + error_message = "HTTPS_PROXY should be set to ai_gateway_proxy_auth_url" + } + + assert { + condition = coder_env.ai_gateway_node_extra_ca_certs[0].name == "NODE_EXTRA_CA_CERTS" && coder_env.ai_gateway_node_extra_ca_certs[0].value == "/tmp/aibridge-proxy/ca-cert.pem" + error_message = "NODE_EXTRA_CA_CERTS should be set to ai_gateway_proxy_cert_path" + } +} + +run "ai_gateway_requires_auth_url" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + enable_ai_gateway = true + ai_gateway_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem" + } + + expect_failures = [ + var.enable_ai_gateway, + ] +} + +run "ai_gateway_requires_cert_path" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + enable_ai_gateway = true + ai_gateway_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com" + } + + expect_failures = [ + var.enable_ai_gateway, + ] +} diff --git a/registry/coder-labs/modules/copilot/scripts/install.sh b/registry/coder-labs/modules/copilot/scripts/install.sh deleted file mode 100644 index 746231b50..000000000 --- a/registry/coder-labs/modules/copilot/scripts/install.sh +++ /dev/null @@ -1,237 +0,0 @@ -#!/usr/bin/env bash - -if [ -f "$HOME/.bashrc" ]; then - source "$HOME"/.bashrc -fi - -set -euo pipefail - -command_exists() { - command -v "$1" > /dev/null 2>&1 -} - -ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"} -ARG_REPORT_TASKS=${ARG_REPORT_TASKS:-true} -ARG_MCP_APP_STATUS_SLUG=${ARG_MCP_APP_STATUS_SLUG:-} -ARG_MCP_CONFIG=$(echo -n "${ARG_MCP_CONFIG:-}" | base64 -d 2> /dev/null || echo "") -ARG_COPILOT_CONFIG=$(echo -n "${ARG_COPILOT_CONFIG:-}" | base64 -d 2> /dev/null || echo "") -ARG_EXTERNAL_AUTH_ID=${ARG_EXTERNAL_AUTH_ID:-github} -ARG_COPILOT_VERSION=${ARG_COPILOT_VERSION:-0.0.334} -ARG_COPILOT_MODEL=${ARG_COPILOT_MODEL:-claude-sonnet-4.5} - -validate_prerequisites() { - if ! command_exists node; then - echo "ERROR: Node.js not found. Copilot requires Node.js v22+." - echo "Install with: curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - && sudo apt-get install -y nodejs" - exit 1 - fi - - if ! command_exists npm; then - echo "ERROR: npm not found. Copilot requires npm v10+." - exit 1 - fi - - node_version=$(node --version | sed 's/v//' | cut -d. -f1) - if [ "$node_version" -lt 22 ]; then - echo "WARNING: Node.js v$node_version detected. Copilot requires v22+." - fi -} - -install_copilot() { - if ! command_exists copilot; then - echo "Installing GitHub Copilot CLI (version: ${ARG_COPILOT_VERSION})..." - if [ "$ARG_COPILOT_VERSION" = "latest" ]; then - npm install -g @github/copilot - else - npm install -g "@github/copilot@${ARG_COPILOT_VERSION}" - fi - - if ! command_exists copilot; then - echo "ERROR: Failed to install Copilot" - exit 1 - fi - - echo "GitHub Copilot CLI installed successfully" - else - echo "GitHub Copilot CLI already installed" - fi -} - -check_github_authentication() { - echo "Checking GitHub authentication..." - - if [ -n "${GITHUB_TOKEN:-}" ]; then - echo "✓ GitHub token provided via module configuration" - return 0 - fi - - if command_exists coder; then - if coder external-auth access-token "${ARG_EXTERNAL_AUTH_ID:-github}" > /dev/null 2>&1; then - echo "✓ GitHub OAuth authentication via Coder external auth" - return 0 - fi - fi - - if command_exists gh && gh auth status > /dev/null 2>&1; then - echo "✓ GitHub OAuth authentication via GitHub CLI" - return 0 - fi - - echo "⚠ No GitHub authentication detected" - echo " Copilot will prompt for authentication when started" - echo " For seamless experience, configure GitHub external auth in Coder or run 'gh auth login'" - return 0 -} - -setup_copilot_configurations() { - mkdir -p "$ARG_WORKDIR" - - local module_path="$HOME/.copilot-module" - mkdir -p "$module_path" - - setup_copilot_config - - echo "$ARG_WORKDIR" > "$module_path/trusted_directories" -} - -setup_copilot_config() { - export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" - local copilot_config_dir="$XDG_CONFIG_HOME/.copilot" - local copilot_config_file="$copilot_config_dir/config.json" - local mcp_config_file="$copilot_config_dir/mcp-config.json" - - mkdir -p "$copilot_config_dir" - - if [ -n "$ARG_COPILOT_CONFIG" ]; then - echo "Setting up Copilot configuration..." - - if command_exists jq; then - echo "$ARG_COPILOT_CONFIG" | jq 'del(.mcpServers)' > "$copilot_config_file" - else - echo "$ARG_COPILOT_CONFIG" > "$copilot_config_file" - fi - - echo "Setting up MCP server configuration..." - setup_mcp_config "$mcp_config_file" - else - echo "ERROR: No Copilot configuration provided" - exit 1 - fi -} - -setup_mcp_config() { - local mcp_config_file="$1" - - echo '{"mcpServers": {}}' > "$mcp_config_file" - - if [ "$ARG_REPORT_TASKS" = "true" ] && [ -n "$ARG_MCP_APP_STATUS_SLUG" ]; then - echo "Adding Coder MCP server for task reporting..." - setup_coder_mcp_server "$mcp_config_file" - fi - - if [ -n "$ARG_MCP_CONFIG" ]; then - echo "Adding custom MCP servers..." - add_custom_mcp_servers "$mcp_config_file" - fi - - echo "MCP configuration completed: $mcp_config_file" -} - -setup_coder_mcp_server() { - local mcp_config_file="$1" - - local coder_mcp_wrapper_script - coder_mcp_wrapper_script=$( - cat << EOF -#!/usr/bin/env bash -set -e - -export CODER_MCP_APP_STATUS_SLUG="${ARG_MCP_APP_STATUS_SLUG}" -export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" -export CODER_AGENT_URL="${CODER_AGENT_URL}" -export CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN}" - -exec coder exp mcp server -EOF - ) - echo "$coder_mcp_wrapper_script" > "/tmp/coder-mcp-server.sh" - chmod +x /tmp/coder-mcp-server.sh - - local coder_mcp_config - coder_mcp_config=$( - cat << EOF -{ - "mcpServers": { - "coder": { - "command": "/tmp/coder-mcp-server.sh", - "args": [], - "description": "CRITICAL: Call this tool immediately after receiving ANY user message to report task status. Rules: (1) Call FIRST before doing work - report what you will do with state='working'. (2) Be granular - report each step separately. (3) State 'working' = actively processing without needing user input. (4) State 'complete' = task 100% finished. (5) State 'failure' = need user input, missing info, or blocked. Example: User says 'fix the bug' -> call with state='working', description='Investigating authentication bug'. When done -> call with state='complete', description='Fixed token validation'. You MUST report on every interaction.", - "name": "Coder", - "timeout": 3000, - "type": "local", - "tools": ["*"], - "trust": true - } - } -} -EOF - ) - - echo "$coder_mcp_config" > "$mcp_config_file" -} - -add_custom_mcp_servers() { - local mcp_config_file="$1" - - if command_exists jq; then - local custom_servers - custom_servers=$(echo "$ARG_MCP_CONFIG" | jq '.mcpServers // {}') - - local updated_config - updated_config=$(jq --argjson custom "$custom_servers" '.mcpServers += $custom' "$mcp_config_file") - echo "$updated_config" > "$mcp_config_file" - elif command_exists node; then - node -e " - const fs = require('fs'); - const existing = JSON.parse(fs.readFileSync('$mcp_config_file', 'utf8')); - const input = JSON.parse(\`$ARG_MCP_CONFIG\`); - const custom = input.mcpServers || {}; - existing.mcpServers = {...existing.mcpServers, ...custom}; - fs.writeFileSync('$mcp_config_file', JSON.stringify(existing, null, 2)); - " - else - echo "WARNING: jq and node not available, cannot merge custom MCP servers" - fi -} - -configure_copilot_model() { - if [ -n "$ARG_COPILOT_MODEL" ] && [ "$ARG_COPILOT_MODEL" != "claude-sonnet-4.5" ]; then - echo "Setting Copilot model to: $ARG_COPILOT_MODEL" - copilot config model "$ARG_COPILOT_MODEL" || { - echo "WARNING: Failed to set model via copilot config, will use environment variable fallback" - export COPILOT_MODEL="$ARG_COPILOT_MODEL" - } - fi -} - -configure_coder_integration() { - if [ "$ARG_REPORT_TASKS" = "true" ] && [ -n "$ARG_MCP_APP_STATUS_SLUG" ]; then - echo "Configuring Copilot task reporting..." - export CODER_MCP_APP_STATUS_SLUG="$ARG_MCP_APP_STATUS_SLUG" - export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" - echo "✓ Coder MCP server configured for task reporting" - else - echo "Task reporting disabled or no app status slug provided." - export CODER_MCP_APP_STATUS_SLUG="" - export CODER_MCP_AI_AGENTAPI_URL="" - fi -} - -validate_prerequisites -install_copilot -check_github_authentication -setup_copilot_configurations -configure_copilot_model -configure_coder_integration - -echo "Copilot module setup completed." diff --git a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl new file mode 100644 index 000000000..8aea8e5e8 --- /dev/null +++ b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl @@ -0,0 +1,246 @@ +#!/usr/bin/env bash + +set -euo pipefail + +BOLD='\033[0;1m' + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_INSTALL='${ARG_INSTALL}' +ARG_COPILOT_VERSION='${ARG_COPILOT_VERSION}' +ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d) +ARG_MANAGED_SETTINGS_JSON=$(echo -n '${ARG_MANAGED_SETTINGS_JSON}' | base64 -d) +ARG_TRUSTED_FOLDERS=$(echo -n '${ARG_TRUSTED_FOLDERS}' | base64 -d) +ARG_BASE_CONFIG=$(echo -n '${ARG_BASE_CONFIG}' | base64 -d) +ARG_MCP_CONFIG=$(echo -n '${ARG_MCP_CONFIG}' | base64 -d) + +export PATH="$HOME/.local/bin:$PATH" + +echo "--------------------------------" +printf "install_copilot: %s\n" "$${ARG_INSTALL}" +printf "copilot_version: %s\n" "$${ARG_COPILOT_VERSION}" +printf "workdir: %s\n" "$${ARG_WORKDIR}" +echo "--------------------------------" + +function add_path_to_shell_profiles() { + local path_dir="$1" + + for profile in "$HOME/.profile" "$HOME/.bash_profile" "$HOME/.bashrc" "$HOME/.zprofile" "$HOME/.zshrc"; do + if [ -f "$${profile}" ]; then + if ! grep -q "$${path_dir}" "$${profile}" 2> /dev/null; then + echo "export PATH=\"\$PATH:$${path_dir}\"" >> "$${profile}" + echo "Added $${path_dir} to $${profile}" + fi + fi + done + + local fish_config="$HOME/.config/fish/config.fish" + if [ -f "$${fish_config}" ]; then + if ! grep -q "$${path_dir}" "$${fish_config}" 2> /dev/null; then + echo "fish_add_path $${path_dir}" >> "$${fish_config}" + echo "Added $${path_dir} to $${fish_config}" + fi + fi +} + +function ensure_copilot_in_path() { + local COPILOT_BIN="" + if command -v copilot > /dev/null 2>&1; then + COPILOT_BIN=$(command -v copilot) + fi + + if [ -z "$${COPILOT_BIN}" ] || [ ! -x "$${COPILOT_BIN}" ]; then + echo "Copilot binary was not found or is not executable." >&2 + return 1 + fi + + local COPILOT_DIR + COPILOT_DIR=$(dirname "$${COPILOT_BIN}") + + if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/copilot" ]; then + ln -s "$${COPILOT_BIN}" "$${CODER_SCRIPT_BIN_DIR}/copilot" + echo "Created symlink: $${CODER_SCRIPT_BIN_DIR}/copilot -> $${COPILOT_BIN}" + fi + + add_path_to_shell_profiles "$${COPILOT_DIR}" +} + +# Download the official installer to a temp file, validate it, then run it. +# Subshell scopes the temp-file cleanup trap to this function. +install_with_official_installer() ( + local installer_file + installer_file=$(mktemp) + trap 'rm -f "$${installer_file}"' EXIT + + if ! curl --fail --silent --show-error --location \ + --retry 2 --retry-delay 1 --retry-all-errors \ + --connect-timeout 10 --max-time 300 \ + --output "$${installer_file}" https://gh.io/copilot-install; then + echo "GitHub Copilot CLI could not be downloaded after up to 3 attempts." >&2 + return 1 + fi + + if ! bash -n "$${installer_file}"; then + echo "GitHub Copilot CLI installer download was invalid." >&2 + return 1 + fi + + # VERSION accepts 'latest' or a version tag; PREFIX installs under $HOME/.local/bin. + if ! VERSION="$${ARG_COPILOT_VERSION}" PREFIX="$HOME/.local" bash "$${installer_file}"; then + echo "GitHub Copilot CLI installation failed." >&2 + return 1 + fi +) + +install_copilot() { + if [ "$${ARG_INSTALL}" != "true" ]; then + echo "Skipping Copilot installation as per configuration." + if ! ensure_copilot_in_path; then + echo "ERROR: install_copilot is false but a working 'copilot' was not found on PATH." >&2 + exit 1 + fi + printf "%s Validated existing GitHub Copilot CLI\n" "$${BOLD}" + return + fi + + if command_exists copilot; then + echo "GitHub Copilot CLI already installed" + ensure_copilot_in_path + return + fi + + printf "%s Installing GitHub Copilot CLI (version: %s)\n" "$${BOLD}" "$${ARG_COPILOT_VERSION}" + install_with_official_installer + + export PATH="$HOME/.local/bin:$PATH" + if ! ensure_copilot_in_path; then + echo "ERROR: Failed to install Copilot" >&2 + exit 1 + fi + printf "%s Installed GitHub Copilot CLI\n" "$${BOLD}" +} + +setup_workdir() { + if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then + echo "Creating workdir: $${ARG_WORKDIR}" + mkdir -p "$${ARG_WORKDIR}" + fi +} + +# Write policy to Copilot's file-based managed settings (highest precedence). +# Copilot rejects it unless owned by root and not world-writable, so write as +# root with 0644. +write_managed_settings() { + if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then + return + fi + + if ! command_exists jq; then + echo "ERROR: 'jq' is required to configure managed settings but was not found." >&2 + exit 1 + fi + + if ! echo "$${ARG_MANAGED_SETTINGS_JSON}" | jq empty 2> /dev/null; then + echo "Warning: managed_settings is not valid JSON, skipping policy write" + return + fi + + local target="/etc/github-copilot/managed-settings.json" + + if command_exists sudo; then + sudo mkdir -p "$(dirname "$${target}")" + echo "$${ARG_MANAGED_SETTINGS_JSON}" | sudo tee "$${target}" > /dev/null + sudo chmod 0644 "$${target}" + else + mkdir -p "$(dirname "$${target}")" + echo "$${ARG_MANAGED_SETTINGS_JSON}" > "$${target}" + chmod 0644 "$${target}" + fi + + echo "Wrote Copilot managed settings to $${target}" +} + +write_base_config() { + local settings_file="$HOME/.copilot/settings.json" + if [ -z "$${ARG_BASE_CONFIG}" ]; then + return + fi + mkdir -p "$(dirname "$${settings_file}")" + printf '%s\n' "$${ARG_BASE_CONFIG}" > "$${settings_file}" + echo "Wrote Copilot settings to $${settings_file}" +} + +# Trust the workdir by unioning it into config.json trustedFolders. Only that +# key is touched; other config.json state (authentication, plugins) is preserved. +setup_trusted_folders() { + local config_file="$HOME/.copilot/config.json" + + if [ -z "$${ARG_TRUSTED_FOLDERS}" ]; then + echo "No workdir to trust; leaving $${config_file} unchanged." + return + fi + + if ! command_exists jq; then + echo "ERROR: 'jq' is required to configure trusted folders but was not found." >&2 + exit 1 + fi + + mkdir -p "$(dirname "$${config_file}")" + + # Copilot writes config.json with a // comment header, which jq rejects. + local existing='{}' + if [ -f "$${config_file}" ]; then + if ! existing=$(sed -E '/^[[:space:]]*\/\//d' "$${config_file}" | jq -cs 'add // {}'); then + echo "Warning: could not parse $${config_file}; skipping workdir trust." >&2 + return + fi + fi + + if echo "$${existing}" | jq -e --argjson folders "$${ARG_TRUSTED_FOLDERS}" \ + '($folders - (.trustedFolders // [])) | length == 0' > /dev/null; then + echo "Workdir already trusted in $${config_file}" + return + fi + + echo "Trusting workdir in $${config_file}" + local tmp + tmp=$(mktemp "$${config_file}.XXXXXX") + chmod 600 "$${tmp}" + echo "$${existing}" | jq --argjson folders "$${ARG_TRUSTED_FOLDERS}" \ + '.trustedFolders = (((.trustedFolders // []) + $folders) | unique)' > "$${tmp}" + mv "$${tmp}" "$${config_file}" + echo "Trusted folders completed: $${config_file}" +} + +# Servers already in the file win on duplicate names. +setup_mcp_config() { + local mcp_config_file="$HOME/.copilot/mcp-config.json" + if [ -z "$${ARG_MCP_CONFIG}" ]; then + return + fi + if ! command_exists jq; then + echo "ERROR: 'jq' is required to configure MCP servers but was not found." >&2 + exit 1 + fi + mkdir -p "$(dirname "$${mcp_config_file}")" + [ -f "$${mcp_config_file}" ] || echo '{}' > "$${mcp_config_file}" + if ! jq empty "$${mcp_config_file}" > /dev/null 2>&1; then + echo "Warning: $${mcp_config_file} is JSONC or invalid JSON; skipping MCP servers." >&2 + return + fi + jq --argjson mcp "$${ARG_MCP_CONFIG}" '.mcpServers = ($mcp.mcpServers // {}) + (.mcpServers // {})' \ + "$${mcp_config_file}" > "$${mcp_config_file}.tmp" + mv "$${mcp_config_file}.tmp" "$${mcp_config_file}" + echo "Wrote MCP servers to $${mcp_config_file}" +} + +install_copilot +setup_workdir +write_managed_settings +write_base_config +setup_trusted_folders +setup_mcp_config + +echo "Copilot module setup completed." diff --git a/registry/coder-labs/modules/copilot/scripts/start.sh b/registry/coder-labs/modules/copilot/scripts/start.sh deleted file mode 100644 index d82345519..000000000 --- a/registry/coder-labs/modules/copilot/scripts/start.sh +++ /dev/null @@ -1,207 +0,0 @@ -#!/usr/bin/env bash - -if [ -f "$HOME/.bashrc" ]; then - source "$HOME"/.bashrc -fi - -set -euo pipefail - -export PATH="$HOME/.local/bin:$PATH" - -command_exists() { - command -v "$1" > /dev/null 2>&1 -} - -ARG_WORKDIR=${ARG_WORKDIR:-"$HOME"} -ARG_AI_PROMPT=$(echo -n "${ARG_AI_PROMPT:-}" | base64 -d 2> /dev/null || echo "") -ARG_SYSTEM_PROMPT=$(echo -n "${ARG_SYSTEM_PROMPT:-}" | base64 -d 2> /dev/null || echo "") -ARG_COPILOT_MODEL=${ARG_COPILOT_MODEL:-} -ARG_ALLOW_ALL_TOOLS=${ARG_ALLOW_ALL_TOOLS:-false} -ARG_ALLOW_TOOLS=${ARG_ALLOW_TOOLS:-} -ARG_DENY_TOOLS=${ARG_DENY_TOOLS:-} -ARG_TRUSTED_DIRECTORIES=${ARG_TRUSTED_DIRECTORIES:-} -ARG_EXTERNAL_AUTH_ID=${ARG_EXTERNAL_AUTH_ID:-github} -ARG_RESUME_SESSION=${ARG_RESUME_SESSION:-true} -ARG_ENABLE_AIBRIDGE_PROXY=${ARG_ENABLE_AIBRIDGE_PROXY:-false} -ARG_AIBRIDGE_PROXY_AUTH_URL=${ARG_AIBRIDGE_PROXY_AUTH_URL:-} -ARG_AIBRIDGE_PROXY_CERT_PATH=${ARG_AIBRIDGE_PROXY_CERT_PATH:-} - -validate_copilot_installation() { - if ! command_exists copilot; then - echo "ERROR: Copilot not installed. Run: npm install -g @github/copilot" - exit 1 - fi -} - -build_initial_prompt() { - local initial_prompt="" - - if [ -n "$ARG_AI_PROMPT" ]; then - if [ -n "$ARG_SYSTEM_PROMPT" ]; then - initial_prompt="$ARG_SYSTEM_PROMPT - -$ARG_AI_PROMPT" - else - initial_prompt="$ARG_AI_PROMPT" - fi - fi - - echo "$initial_prompt" -} - -build_copilot_args() { - COPILOT_ARGS=() - - if [ "$ARG_ALLOW_ALL_TOOLS" = "true" ]; then - COPILOT_ARGS+=(--allow-all-tools) - fi - - if [ -n "$ARG_ALLOW_TOOLS" ]; then - IFS=',' read -ra ALLOW_ARRAY <<< "$ARG_ALLOW_TOOLS" - for tool in "${ALLOW_ARRAY[@]}"; do - if [ -n "$tool" ]; then - COPILOT_ARGS+=(--allow-tool "$tool") - fi - done - fi - - if [ -n "$ARG_DENY_TOOLS" ]; then - IFS=',' read -ra DENY_ARRAY <<< "$ARG_DENY_TOOLS" - for tool in "${DENY_ARRAY[@]}"; do - if [ -n "$tool" ]; then - COPILOT_ARGS+=(--deny-tool "$tool") - fi - done - fi -} - -check_existing_session() { - if [ "$ARG_RESUME_SESSION" = "true" ]; then - if copilot --help > /dev/null 2>&1; then - local session_dir="$HOME/.copilot/history-session-state" - if [ -d "$session_dir" ] && [ -n "$(ls "$session_dir"/session_*_*.json 2> /dev/null)" ]; then - echo "Found existing Copilot session. Will continue latest session." >&2 - return 0 - fi - fi - fi - return 1 -} - -setup_github_authentication() { - export XDG_CONFIG_HOME="${XDG_CONFIG_HOME:-$HOME/.config}" - echo "Setting up GitHub authentication..." - - if [ -n "${GITHUB_TOKEN:-}" ]; then - export GH_TOKEN="$GITHUB_TOKEN" - echo "✓ Using GitHub token from module configuration" - return 0 - fi - - if command_exists coder; then - local github_token - if github_token=$(coder external-auth access-token "${ARG_EXTERNAL_AUTH_ID:-github}" 2> /dev/null); then - if [ -n "$github_token" ] && [ "$github_token" != "null" ]; then - export GITHUB_TOKEN="$github_token" - export GH_TOKEN="$github_token" - echo "✓ Using Coder external auth OAuth token" - return 0 - fi - fi - fi - - if command_exists gh && gh auth status > /dev/null 2>&1; then - echo "✓ Using GitHub CLI OAuth authentication" - return 0 - fi - - echo "⚠ No GitHub authentication available" - echo " Copilot will prompt for login during first use" - echo " Use the '/login' command in Copilot to authenticate" - return 0 -} - -setup_aibridge_proxy() { - if [ "$ARG_ENABLE_AIBRIDGE_PROXY" != "true" ]; then - return 0 - fi - - echo "Setting up AI Gateway Proxy..." - - # Wait for the aibridge-proxy module to finish. - # Uses startup coordination to block until aibridge-proxy-setup signals completion. - if command -v coder > /dev/null 2>&1; then - coder exp sync want "copilot-aibridge" "aibridge-proxy-setup" > /dev/null 2>&1 || true - coder exp sync start "copilot-aibridge" > /dev/null 2>&1 || true - trap 'coder exp sync complete "copilot-aibridge" > /dev/null 2>&1 || true' EXIT - fi - - if [ -z "$ARG_AIBRIDGE_PROXY_AUTH_URL" ]; then - echo "ERROR: AI Gateway Proxy is enabled but no proxy auth URL provided." - exit 1 - fi - - if [ -z "$ARG_AIBRIDGE_PROXY_CERT_PATH" ]; then - echo "ERROR: AI Gateway Proxy is enabled but no certificate path provided." - exit 1 - fi - - if [ ! -f "$ARG_AIBRIDGE_PROXY_CERT_PATH" ]; then - echo "ERROR: AI Gateway Proxy certificate not found at $ARG_AIBRIDGE_PROXY_CERT_PATH." - echo " Ensure the aibridge-proxy module has successfully completed setup." - exit 1 - fi - - # Set proxy environment variables scoped to this process tree only. - # These are inherited by the agentapi/copilot process below, - # but do not affect other workspace processes, avoiding routing - # unnecessary traffic through the proxy. - export HTTPS_PROXY="$ARG_AIBRIDGE_PROXY_AUTH_URL" - export NODE_EXTRA_CA_CERTS="$ARG_AIBRIDGE_PROXY_CERT_PATH" - - echo "✓ AI Gateway Proxy configured" - echo " CA certificate: $ARG_AIBRIDGE_PROXY_CERT_PATH" -} - -start_agentapi() { - echo "Starting in directory: $ARG_WORKDIR" - cd "$ARG_WORKDIR" - - build_copilot_args - - if check_existing_session; then - echo "Continuing latest Copilot session..." - if [ ${#COPILOT_ARGS[@]} -gt 0 ]; then - echo "Copilot arguments: ${COPILOT_ARGS[*]}" - agentapi server --type copilot --term-width 120 --term-height 40 -- copilot --continue "${COPILOT_ARGS[@]}" - else - agentapi server --type copilot --term-width 120 --term-height 40 -- copilot --continue - fi - else - echo "Starting new Copilot session..." - local initial_prompt - initial_prompt=$(build_initial_prompt) - - if [ -n "$initial_prompt" ]; then - echo "Using initial prompt with system context" - if [ ${#COPILOT_ARGS[@]} -gt 0 ]; then - echo "Copilot arguments: ${COPILOT_ARGS[*]}" - agentapi server -I="$initial_prompt" --type copilot --term-width 120 --term-height 40 -- copilot "${COPILOT_ARGS[@]}" - else - agentapi server -I="$initial_prompt" --type copilot --term-width 120 --term-height 40 -- copilot - fi - else - if [ ${#COPILOT_ARGS[@]} -gt 0 ]; then - echo "Copilot arguments: ${COPILOT_ARGS[*]}" - agentapi server --type copilot --term-width 120 --term-height 40 -- copilot "${COPILOT_ARGS[@]}" - else - agentapi server --type copilot --term-width 120 --term-height 40 -- copilot - fi - fi - fi -} - -setup_github_authentication -setup_aibridge_proxy -validate_copilot_installation -start_agentapi diff --git a/registry/coder-labs/modules/copilot/testdata/copilot-mock.sh b/registry/coder-labs/modules/copilot/testdata/copilot-mock.sh index f1daa15f1..8fb499345 100644 --- a/registry/coder-labs/modules/copilot/testdata/copilot-mock.sh +++ b/registry/coder-labs/modules/copilot/testdata/copilot-mock.sh @@ -1,12 +1,17 @@ #!/bin/bash set -euo pipefail -if [[ "$1" == "--version" ]]; then - echo "GitHub Copilot CLI v1.0.0" - exit 0 -fi - -while true; do - echo "$(date) - Copilot mock running..." - sleep 15 -done +# Minimal mock of the GitHub Copilot CLI used by the module tests. +case "${1:-}" in + --version | -v) + echo "GitHub Copilot CLI v1.0.0" + exit 0 + ;; + config) + # e.g. `copilot config model ` — accept and succeed. + exit 0 + ;; + *) + exit 0 + ;; +esac