From 9c0cf89ad3b97eb3cf9ee3b1b57f652628507e6a Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Tue, 15 Sep 2026 16:37:06 +0000 Subject: [PATCH 01/23] feat(registry/coder-labs/modules/copilot): migrate off AgentAPI and Coder Tasks Refactor the copilot module to install and configure Copilot only, matching the claude-code and codex v5 migrations: - Replace the agentapi module with coder-utils; drop Tasks/web-app/CLI-app and AgentAPI variables and the start script. - Install Copilot via the official install script (temp file + bash -n validation) instead of npm; no Node.js prerequisite. - Make workdir optional; write config.json and merge mcp_config into ~/.copilot; pass copilot_model through as given. - Add enable_ai_gateway using Copilot BYOK provider env vars; keep github_token (GITHUB_TOKEN + GH_TOKEN). - Output scripts; rewrite README and tests. Generated with Coder Agents. --- registry/coder-labs/modules/copilot/README.md | 285 +++++++------- .../modules/copilot/copilot.tftest.hcl | 354 ------------------ .../coder-labs/modules/copilot/main.test.ts | 350 +++++++++++------ registry/coder-labs/modules/copilot/main.tf | 308 ++++----------- .../modules/copilot/main.tftest.hcl | 257 +++++++++++++ .../modules/copilot/scripts/install.sh | 237 ------------ .../modules/copilot/scripts/install.sh.tftpl | 197 ++++++++++ .../modules/copilot/scripts/start.sh | 207 ---------- .../modules/copilot/testdata/copilot-mock.sh | 23 +- 9 files changed, 930 insertions(+), 1288 deletions(-) delete mode 100644 registry/coder-labs/modules/copilot/copilot.tftest.hcl create mode 100644 registry/coder-labs/modules/copilot/main.tftest.hcl delete mode 100644 registry/coder-labs/modules/copilot/scripts/install.sh create mode 100644 registry/coder-labs/modules/copilot/scripts/install.sh.tftpl delete mode 100644 registry/coder-labs/modules/copilot/scripts/start.sh diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 24fd5b511..758855557 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -3,135 +3,80 @@ 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, aibridge] +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 for AI-powered coding assistance directly from the terminal. ```tf module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.4.1" + 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. +> [!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). Keep using v0.x if you depend on the embedded web app or task reporting. ## 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) +- **`curl`** (or `wget`) available in the workspace for the official install script - **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 + - [Coder external authentication](https://coder.com/docs/admin/external-auth) (recommended), fetched at launch in your `coder_app` + - Direct token via the `github_token` variable + - Interactive login in Copilot (`/login`) ## 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: +The module installs and configures Copilot. To run it, add a `coder_app` that +fetches a fresh GitHub token at launch and starts Copilot. This assumes you have +[Coder external authentication](https://coder.com/docs/admin/external-auth) +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.1" + version = "1.0.0" 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"] + workdir = local.copilot_workdir } -``` - -### Advanced Configuration - -Customize tool permissions, MCP servers, and Copilot settings: - -```tf -module "copilot" { - source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.4.1" - 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 - } - } - }) - # 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.svg" + open_in = "slim-window" + command = <<-EOT #!/bin/bash - curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - - sudo apt-get install -y nodejs + set -e + token="$(coder external-auth access-token github)" && export GITHUB_TOKEN="$token" GH_TOKEN="$token" + 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`) +> Tool permissions (`--allow-all-tools`, `--allow-tool`, `--deny-tool`) and +> session resumption (`--continue`) are session-only Copilot CLI flags, so pass +> them to `copilot` in your `coder_app` command rather than to the module. -### 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: +Provide a GitHub token instead of using Coder external auth. When set, the module +exports it to the workspace as `GITHUB_TOKEN` and `GH_TOKEN`. ```tf variable "github_token" { @@ -142,102 +87,140 @@ variable "github_token" { module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.4.1" + 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 +> [!NOTE] +> OAuth tokens work best with Copilot. Personal Access Tokens may have limited functionality. + +### Usage with AI Gateway -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. +[AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) is a Premium Coder +feature that provides centralized LLM proxy management. When `enable_ai_gateway = true`, +the module points Copilot's [BYOK model provider](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-byok-models) +at Coder's AI Gateway using the workspace owner's session token. ```tf module "copilot" { - source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.4.1" - agent_id = coder_agent.example.id - workdir = "/home/coder" - report_tasks = false - cli_app = true + source = "registry.coder.com/coder-labs/copilot/coder" + version = "1.0.0" + agent_id = coder_agent.example.id + workdir = "/home/coder/project" + enable_ai_gateway = true + copilot_model = "gpt-5" } ``` -### Usage with AI Bridge Proxy +The module sets `COPILOT_PROVIDER_TYPE`, `COPILOT_PROVIDER_BASE_URL`, and +`COPILOT_PROVIDER_API_KEY` in the workspace environment. Set `copilot_model` to a +model id served by your AI Gateway provider. -[AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy) routes Copilot traffic through [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) for centralized LLM management and governance. -The proxy environment variables are scoped to the Copilot process only and do not affect other workspace traffic. +> [!CAUTION] +> `enable_ai_gateway = true` is mutually exclusive with `github_token`. Setting both fails at plan time. AI Gateway authenticates the model provider with the Coder session token; GitHub authentication is only needed if your workflow calls GitHub tools. -```tf -module "aibridge-proxy" { - source = "registry.coder.com/coder/aibridge-proxy/coder" - version = "1.0.0" - agent_id = coder_agent.main.id - proxy_url = "https://aiproxy.example.com" -} +> [!NOTE] +> Copilot CLI cannot send custom request headers and does not route inline completions through a gateway; only chat/agent/CLI model traffic is proxied. + +### Advanced configuration +Customize MCP servers, trusted directories, and Copilot settings: + +```tf module "copilot" { - source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.4.1" - 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.example.id + workdir = "/home/coder/project" + + # Version pinning (defaults to "latest") + copilot_version = "0.0.334" + + trusted_directories = ["/home/coder/project", "/tmp"] + + # Custom Copilot configuration (written to config.json) + copilot_config = jsonencode({ + banner = "never" + theme = "dark" + }) + + # MCP server configuration (merged into ~/.copilot/mcp-config.json) + mcp_config = jsonencode({ + mcpServers = { + filesystem = { + command = "npx" + args = ["-y", "@modelcontextprotocol/server-filesystem", "/home/coder/project"] + type = "local" + tools = ["*"] + } + } + }) + + # Pre-install an MCP server for faster startup + pre_install_script = <<-EOT + #!/bin/bash + npm install -g @modelcontextprotocol/server-filesystem + EOT } ``` > [!NOTE] -> AI Bridge Proxy is a Premium Coder feature that requires [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance). -> See the [AI Bridge Proxy setup guide](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-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 Bridge using the Coder session token, but does not replace GitHub authentication. +> Servers from `mcp_config` are merged into `~/.copilot/mcp-config.json`, Copilot's documented user-level MCP config. GitHub Copilot CLI does not automatically install MCP servers. Either use `npx -y` in the config (shown above) to auto-install on each run, or pre-install MCP servers in `pre_install_script` for faster startup. -> [!IMPORTANT] -> When using AI Bridge 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 Bridge Proxy setup has not completed in time. +### Serialize a downstream `coder_script` after the install pipeline -## Authentication +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. -The module supports multiple authentication methods (in priority order): +```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" +} -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 +resource "coder_script" "post_copilot" { + agent_id = coder_agent.example.id + display_name = "Run after Copilot install" + run_on_start = true + script = <<-EOT + #!/bin/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 -> [!NOTE] -> OAuth tokens work best with Copilot. Personal Access Tokens may have limited functionality. + copilot --version + EOT +} +``` -## Session Resumption +## Authentication -By default, the module resumes the latest Copilot session when the workspace restarts. Set `resume_session = false` to always start fresh sessions. +The module supports multiple GitHub authentication methods: -> [!NOTE] -> Session resumption requires persistent storage for the home directory or workspace volume. Without persistent storage, sessions will not resume across workspace restarts. +1. **[Coder External Auth](https://coder.com/docs/admin/external-auth) (Recommended)** - Fetch a fresh token at launch in your `coder_app` command with `coder external-auth access-token `. +2. **Direct Token** - Pass the `github_token` variable (OAuth or Personal Access Token). Exported as `GITHUB_TOKEN` and `GH_TOKEN`. +3. **Interactive** - Copilot prompts for login via the `/login` command if no auth is found. ## Troubleshooting -If you encounter any issues, check the log files in the `~/.copilot-module` directory within your workspace for detailed information. +Check the log files in `~/.coder-modules/coder-labs/copilot/logs/` for detailed information. ```bash -# Installation logs -cat ~/.copilot-module/install.log - -# Startup logs -cat ~/.copilot-module/agentapi-start.log - -# Pre/post install script logs -cat ~/.copilot-module/pre_install.log -cat ~/.copilot-module/post_install.log +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) +- [Using your own LLM models (BYOK)](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-byok-models) +- [AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) 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 ec5a36680..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 Bridge Proxy should be enabled" - } - - assert { - condition = var.aibridge_proxy_auth_url == "https://coder:mock-token@aiproxy.example.com" - error_message = "AI Bridge Proxy auth URL should match the input variable" - } - - assert { - condition = var.aibridge_proxy_cert_path == "/tmp/aibridge-proxy/ca-cert.pem" - error_message = "AI Bridge 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 Bridge 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..e6872eafd 100644 --- a/registry/coder-labs/modules/copilot/main.test.ts +++ b/registry/coder-labs/modules/copilot/main.test.ts @@ -1,136 +1,276 @@ -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); + } + } +}); - 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", - }); +interface SetupProps { + skipCopilotMock?: boolean; + moduleVariables?: Record; +} - const githubTokenEnv = findResourceInstance( - state, - "coder_env", - "github_token", - ); - expect(githubTokenEnv).toBeDefined(); - expect(githubTokenEnv.name).toBe("GITHUB_TOKEN"); - expect(githubTokenEnv.value).toBe("test_token_12345"); +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("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 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 githubTokenEnvs = state.resources.filter( - (r) => r.type === "coder_env" && r.name === "github_token", - ); - expect(githubTokenEnvs.length).toBe(0); + 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("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", +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 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); - const modelEnv = findResourceInstance(state, "coder_env", "copilot_model"); - expect(modelEnv).toBeDefined(); - expect(modelEnv.name).toBe("COPILOT_MODEL"); - expect(modelEnv.value).toBe("claude-sonnet-4"); +describe("copilot", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); }); - 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("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 modelEnvs = state.resources.filter( - (r) => r.type === "coder_env" && r.name === "copilot_model", - ); - expect(modelEnvs.length).toBe(0); + test("writes-config-without-mcp-servers", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const config = JSON.parse(await readConfig(id)); + expect(config.banner).toBe("never"); + expect(config.theme).toBe("auto"); + expect(config.trusted_folders).toContain(projectDir); + // mcpServers must never be written into config.json. + expect(config.mcpServers).toBeUndefined(); }); - it("creates coder_script resources via agentapi module", async () => { - const state = await runTerraformApply(import.meta.dir, { - agent_id: "test-agent", - workdir: "/home/coder", + test("merges-trusted-directories", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + trusted_directories: JSON.stringify(["/workspace", "/data"]), + }, }); - - // 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); + await runScripts(id, scripts); + const config = JSON.parse(await readConfig(id)); + expect(config.trusted_folders).toContain(projectDir); + expect(config.trusted_folders).toContain("/workspace"); + expect(config.trusted_folders).toContain("/data"); }); - 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_config: 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("github-token-env-vars", async () => { + const token = "ghp_test_token_123"; + const { coderEnvVars } = await setup({ + moduleVariables: { + github_token: token, + }, }); + expect(coderEnvVars["GITHUB_TOKEN"]).toBe(token); + expect(coderEnvVars["GH_TOKEN"]).toBe(token); + }); - // 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("ai-gateway-provider-env-vars", async () => { + const { coderEnvVars } = await setup({ + moduleVariables: { + enable_ai_gateway: "true", + }, + }); + expect(coderEnvVars["COPILOT_PROVIDER_TYPE"]).toBe("openai"); + expect(coderEnvVars["COPILOT_PROVIDER_BASE_URL"]).toContain( + "/api/v2/aibridge/openai/v1", + ); + // COPILOT_MODEL is always set when the gateway is enabled. + expect(coderEnvVars["COPILOT_MODEL"]).toBeDefined(); }); }); diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index 268ba69c4..cb90419ac 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -13,27 +13,31 @@ variable "agent_id" { description = "The ID of a Coder agent." } -variable "workdir" { +data "coder_workspace" "me" {} +data "coder_workspace_owner" "me" {} + +variable "icon" { type = string - description = "The folder to run Copilot in." + description = "The icon to use for the app." + default = "/icon/github.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 as a trusted folder in Copilot's config.json." + 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 Personal Access Token. When set, exported to the workspace as GITHUB_TOKEN and GH_TOKEN." default = "" sensitive = true } variable "copilot_model" { type = string - description = "The model to use for Copilot. Any model supported by GitHub Copilot can be used." + description = "The model to use for Copilot. Any model supported by GitHub Copilot can be used. When enable_ai_gateway is true, this must be a model id served by your AI Gateway provider." default = "claude-sonnet-4.5" } @@ -43,172 +47,102 @@ variable "copilot_config" { default = "" } -variable "ai_prompt" { - type = string - description = "Initial task prompt for programmatic mode." - 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." + description = "Additional directories to trust for Copilot operations. Written to Copilot's config.json trusted_folders." default = [] } variable "mcp_config" { type = string - description = "Custom MCP server configuration as JSON string." + description = "Custom MCP server configuration as JSON string (in the `{\"mcpServers\": {...}}` shape). Merged into Copilot's `~/.copilot/mcp-config.json`." 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." + description = "Whether to install GitHub Copilot CLI." 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" { +variable "pre_install_script" { type = string - description = "The name of a group that this app belongs to." + description = "Custom script to run before installing Copilot." default = null } -variable "icon" { - type = string - description = "The icon to use for the app." - default = "/icon/github.svg" -} - -variable "web_app_display_name" { +variable "post_install_script" { type = string - description = "Display name for the web app." - default = "Copilot" + description = "Custom script to run after installing Copilot." + default = null } -variable "cli_app" { +variable "enable_ai_gateway" { type = bool - description = "Whether to create a CLI app for Copilot." + description = "Use AI Gateway for Copilot. Routes Copilot's model traffic through Coder's AI Gateway using the workspace owner's session token. https://coder.com/docs/ai-coder/ai-gateway" default = false -} -variable "cli_app_display_name" { - type = string - description = "Display name for the CLI app." - default = "Copilot" + validation { + condition = !(var.enable_ai_gateway && length(var.github_token) > 0) + error_message = "github_token cannot be provided when enable_ai_gateway is true. AI Gateway authenticates the model provider using the workspace owner's Coder session token." + } } -variable "resume_session" { - type = bool - description = "Whether to automatically resume the latest Copilot session on workspace restart." - default = true +resource "coder_env" "copilot_model" { + count = var.copilot_model != "" ? 1 : 0 + agent_id = var.agent_id + name = "COPILOT_MODEL" + value = var.copilot_model } -variable "pre_install_script" { - type = string - description = "Custom script to run before configuring Copilot." - default = null +resource "coder_env" "github_token" { + count = var.github_token != "" ? 1 : 0 + agent_id = var.agent_id + name = "GITHUB_TOKEN" + value = var.github_token } -variable "post_install_script" { - type = string - description = "Custom script to run after configuring Copilot." - default = null +resource "coder_env" "gh_token" { + count = var.github_token != "" ? 1 : 0 + agent_id = var.agent_id + name = "GH_TOKEN" + value = var.github_token } -variable "enable_aibridge_proxy" { - type = bool - description = "Route Copilot traffic through AI Bridge Proxy. See https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-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." - } - - 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." - } +# BYOK provider settings that point Copilot at Coder's AI Gateway. The provider +# type is OpenAI-compatible and the API key is the workspace owner's session +# token, which the gateway uses to authenticate and attribute requests. +resource "coder_env" "ai_gateway_provider_type" { + count = var.enable_ai_gateway ? 1 : 0 + agent_id = var.agent_id + name = "COPILOT_PROVIDER_TYPE" + value = "openai" } -variable "aibridge_proxy_auth_url" { - type = string - description = "AI Bridge Proxy URL with authentication. Use the proxy_auth_url output from the aibridge-proxy module." - default = null - sensitive = true +resource "coder_env" "ai_gateway_base_url" { + count = var.enable_ai_gateway ? 1 : 0 + agent_id = var.agent_id + name = "COPILOT_PROVIDER_BASE_URL" + value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1" } -variable "aibridge_proxy_cert_path" { - type = string - description = "Path to the AI Bridge Proxy CA certificate. Use the cert_path output from the aibridge-proxy module." - default = null +resource "coder_env" "ai_gateway_provider_api_key" { + count = var.enable_ai_gateway ? 1 : 0 + agent_id = var.agent_id + name = "COPILOT_PROVIDER_API_KEY" + value = data.coder_workspace_owner.me.session_token } -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" + workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" - all_trusted_folders = concat([local.workdir], var.trusted_directories) + all_trusted_folders = concat(local.workdir != "" ? [local.workdir] : [], var.trusted_directories) parsed_custom_config = try(jsondecode(var.copilot_config), {}) @@ -227,108 +161,32 @@ locals { 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" -} + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_INSTALL = tostring(var.install_copilot) + ARG_COPILOT_VERSION = var.copilot_version + ARG_COPILOT_MODEL = var.copilot_model + ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" + ARG_COPILOT_CONFIG = base64encode(local.final_copilot_config) + ARG_MCP_CONFIG = var.mcp_config != "" ? base64encode(var.mcp_config) : "" + }) -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 - agent_id = var.agent_id - name = "COPILOT_MODEL" - value = var.copilot_model + module_dir_name = ".coder-modules/coder-labs/copilot" } -resource "coder_env" "github_token" { - count = var.github_token != "" ? 1 : 0 - agent_id = var.agent_id - name = "GITHUB_TOKEN" - value = var.github_token -} +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" -module "agentapi" { - source = "registry.coder.com/coder/agentapi/coder" - version = "2.0.0" - - 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 - - start_script = <<-EOT - #!/bin/bash - set -o errexit - set -o pipefail - echo -n '${base64encode(local.start_script)}' | base64 -d > /tmp/start.sh - chmod +x /tmp/start.sh - - 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 = <<-EOT - #!/bin/bash - set -o errexit - set -o pipefail - echo -n '${base64encode(local.install_script)}' | base64 -d > /tmp/install.sh - chmod +x /tmp/install.sh - - 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..da95917a0 --- /dev/null +++ b/registry/coder-labs/modules/copilot/main.tftest.hcl @@ -0,0 +1,257 @@ +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.install_copilot == true + error_message = "install_copilot should default to true" + } + + assert { + condition = var.enable_ai_gateway == false + error_message = "enable_ai_gateway should default to false" + } + + 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 == "GITHUB_TOKEN" && coder_env.github_token[0].value == "test_github_token_abc123" + error_message = "GITHUB_TOKEN env var should be created with the provided token" + } + + assert { + condition = coder_env.gh_token[0].name == "GH_TOKEN" && coder_env.gh_token[0].value == "test_github_token_abc123" + error_message = "GH_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 && length(coder_env.gh_token) == 0 + error_message = "GitHub token env vars 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_is_always_set" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + copilot_model = "claude-sonnet-4.5" + } + + assert { + condition = coder_env.copilot_model[0].name == "COPILOT_MODEL" && coder_env.copilot_model[0].value == "claude-sonnet-4.5" + error_message = "COPILOT_MODEL env var should be set to the model as given, including the default" + } +} + +run "ai_gateway_enabled" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + enable_ai_gateway = true + } + + override_data { + target = data.coder_workspace_owner.me + values = { + session_token = "mock-session-token" + } + } + + assert { + condition = coder_env.ai_gateway_provider_type[0].value == "openai" + error_message = "COPILOT_PROVIDER_TYPE should be 'openai' when ai_gateway is enabled" + } + + assert { + condition = coder_env.ai_gateway_provider_api_key[0].value == data.coder_workspace_owner.me.session_token + error_message = "COPILOT_PROVIDER_API_KEY should use the workspace owner's session token" + } + + assert { + condition = endswith(coder_env.ai_gateway_base_url[0].value, "/api/v2/aibridge/openai/v1") + error_message = "COPILOT_PROVIDER_BASE_URL should point at the AI Gateway OpenAI endpoint" + } + + assert { + condition = length(coder_env.copilot_model) == 1 + error_message = "COPILOT_MODEL should always be set when ai_gateway is enabled" + } +} + +run "ai_gateway_validation_with_github_token" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + enable_ai_gateway = true + github_token = "test-token" + } + + expect_failures = [ + var.enable_ai_gateway, + ] +} + +run "ai_gateway_disabled_creates_no_provider_env" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + enable_ai_gateway = false + } + + assert { + condition = length(coder_env.ai_gateway_provider_type) == 0 && length(coder_env.ai_gateway_base_url) == 0 && length(coder_env.ai_gateway_provider_api_key) == 0 + error_message = "No AI Gateway provider env vars should be created when disabled" + } +} + +run "copilot_config_merges_with_trusted_directories" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder/project/" + trusted_directories = ["/workspace", "/data"] + } + + assert { + condition = local.workdir == "/home/coder/project" + error_message = "workdir should be trimmed of trailing slash" + } + + 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") && contains(jsondecode(local.final_copilot_config).trusted_folders, "/data") + error_message = "trusted_directories should be merged into config" + } +} + +run "custom_copilot_config_overrides_default" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + copilot_config = jsonencode({ + banner = "always" + theme = "dark" + trusted_folders = ["/custom"] + }) + } + + assert { + condition = jsondecode(local.final_copilot_config).banner == "always" && jsondecode(local.final_copilot_config).theme == "dark" + error_message = "Custom banner and theme settings should be applied" + } + + assert { + condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/custom") + error_message = "Custom trusted folder should be preserved" + } +} + +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" + } +} 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 44c480e3a..000000000 --- a/registry/coder-labs/modules/copilot/scripts/install.sh +++ /dev/null @@ -1,237 +0,0 @@ -#!/bin/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..f87c74cad --- /dev/null +++ b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl @@ -0,0 +1,197 @@ +#!/bin/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_COPILOT_MODEL='${ARG_COPILOT_MODEL}' +ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d) +ARG_COPILOT_CONFIG=$(echo -n '${ARG_COPILOT_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 "copilot_model: %s\n" "$${ARG_COPILOT_MODEL}" +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. +# Mirrors the claude-code module's install flow. Run in a subshell so the trap +# that removes the temp file is scoped 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', a 'vX.Y.Z' tag, or a bare 'X.Y.Z' (normalized by + # the installer). PREFIX installs the binary 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 +} + +setup_copilot_config() { + local copilot_config_dir="$HOME/.copilot" + local copilot_config_file="$${copilot_config_dir}/config.json" + + mkdir -p "$${copilot_config_dir}" + + if [ -z "$${ARG_COPILOT_CONFIG}" ]; then + echo "ERROR: No Copilot configuration provided" >&2 + exit 1 + fi + + 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 "Wrote $${copilot_config_file}" +} + +# Write MCP servers to Copilot's user-level mcp-config.json. The mcp_config +# input already uses Copilot's native file schema, so this is a pass-through +# merge into the documented location (~/.copilot/mcp-config.json). +setup_mcp_config() { + local mcp_config_file="$HOME/.copilot/mcp-config.json" + + if [ -z "$${ARG_MCP_CONFIG}" ]; then + echo "No custom MCP servers configured; leaving $${mcp_config_file} unchanged." + return + fi + + mkdir -p "$(dirname "$${mcp_config_file}")" + echo "Writing MCP server configuration to $${mcp_config_file}" + + if command_exists jq; then + local existing='{"mcpServers": {}}' + if [ -f "$${mcp_config_file}" ]; then + existing=$(cat "$${mcp_config_file}") + fi + local custom + custom=$(echo "$${ARG_MCP_CONFIG}" | jq '.mcpServers // {}') + echo "$${existing}" | jq --argjson custom "$${custom}" '.mcpServers = ((.mcpServers // {}) + $custom)' > "$${mcp_config_file}" + else + echo "WARNING: jq not available, writing MCP config verbatim" + echo "$${ARG_MCP_CONFIG}" > "$${mcp_config_file}" + fi + echo "MCP configuration completed: $${mcp_config_file}" +} + +configure_copilot_model() { + if [ -n "$${ARG_COPILOT_MODEL}" ]; then + echo "Setting Copilot model to: $${ARG_COPILOT_MODEL}" + copilot config model "$${ARG_COPILOT_MODEL}" 2> /dev/null \ + || echo "Note: could not persist model via 'copilot config'; the COPILOT_MODEL env var is used as a fallback." + fi +} + +install_copilot +setup_workdir +setup_copilot_config +setup_mcp_config +configure_copilot_model + +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 0aecb1feb..000000000 --- a/registry/coder-labs/modules/copilot/scripts/start.sh +++ /dev/null @@ -1,207 +0,0 @@ -#!/bin/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 Bridge 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 Bridge Proxy is enabled but no proxy auth URL provided." - exit 1 - fi - - if [ -z "$ARG_AIBRIDGE_PROXY_CERT_PATH" ]; then - echo "ERROR: AI Bridge Proxy is enabled but no certificate path provided." - exit 1 - fi - - if [ ! -f "$ARG_AIBRIDGE_PROXY_CERT_PATH" ]; then - echo "ERROR: AI Bridge 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 Bridge 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 From e97afccb9767d452cb05b606f5b2db8896413771 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Wed, 16 Sep 2026 03:22:13 +0000 Subject: [PATCH 02/23] refactor(registry/coder-labs/modules/copilot): remove AI Gateway support Drop the enable_ai_gateway variable, its validation, and the COPILOT_PROVIDER_* env resources, along with the now-unused coder_workspace/coder_workspace_owner data sources. Update README, variable docs, and tests accordingly. Generated with Coder Agents. --- registry/coder-labs/modules/copilot/README.md | 33 +-------- .../coder-labs/modules/copilot/main.test.ts | 14 ---- registry/coder-labs/modules/copilot/main.tf | 40 +---------- .../modules/copilot/main.tftest.hcl | 72 ------------------- 4 files changed, 3 insertions(+), 156 deletions(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 758855557..f61c81300 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -3,7 +3,7 @@ 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, ai-gateway] +tags: [agent, copilot, ai, github] --- # Copilot @@ -97,34 +97,6 @@ module "copilot" { > [!NOTE] > OAuth tokens work best with Copilot. Personal Access Tokens may have limited functionality. -### Usage with AI Gateway - -[AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) is a Premium Coder -feature that provides centralized LLM proxy management. When `enable_ai_gateway = true`, -the module points Copilot's [BYOK model provider](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-byok-models) -at Coder's AI Gateway using the workspace owner's session token. - -```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" - enable_ai_gateway = true - copilot_model = "gpt-5" -} -``` - -The module sets `COPILOT_PROVIDER_TYPE`, `COPILOT_PROVIDER_BASE_URL`, and -`COPILOT_PROVIDER_API_KEY` in the workspace environment. Set `copilot_model` to a -model id served by your AI Gateway provider. - -> [!CAUTION] -> `enable_ai_gateway = true` is mutually exclusive with `github_token`. Setting both fails at plan time. AI Gateway authenticates the model provider with the Coder session token; GitHub authentication is only needed if your workflow calls GitHub tools. - -> [!NOTE] -> Copilot CLI cannot send custom request headers and does not route inline completions through a gateway; only chat/agent/CLI model traffic is proxied. - ### Advanced configuration Customize MCP servers, trusted directories, and Copilot settings: @@ -222,5 +194,4 @@ cat ~/.coder-modules/coder-labs/copilot/logs/post_install.log - [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) -- [Using your own LLM models (BYOK)](https://docs.github.com/en/copilot/how-tos/copilot-cli/customize-copilot/use-byok-models) -- [AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) +- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents) diff --git a/registry/coder-labs/modules/copilot/main.test.ts b/registry/coder-labs/modules/copilot/main.test.ts index e6872eafd..2b6211feb 100644 --- a/registry/coder-labs/modules/copilot/main.test.ts +++ b/registry/coder-labs/modules/copilot/main.test.ts @@ -259,18 +259,4 @@ describe("copilot", async () => { expect(coderEnvVars["GITHUB_TOKEN"]).toBe(token); expect(coderEnvVars["GH_TOKEN"]).toBe(token); }); - - test("ai-gateway-provider-env-vars", async () => { - const { coderEnvVars } = await setup({ - moduleVariables: { - enable_ai_gateway: "true", - }, - }); - expect(coderEnvVars["COPILOT_PROVIDER_TYPE"]).toBe("openai"); - expect(coderEnvVars["COPILOT_PROVIDER_BASE_URL"]).toContain( - "/api/v2/aibridge/openai/v1", - ); - // COPILOT_MODEL is always set when the gateway is enabled. - expect(coderEnvVars["COPILOT_MODEL"]).toBeDefined(); - }); }); diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index cb90419ac..60ce56bbb 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -13,9 +13,6 @@ variable "agent_id" { description = "The ID of a Coder agent." } -data "coder_workspace" "me" {} -data "coder_workspace_owner" "me" {} - variable "icon" { type = string description = "The icon to use for the app." @@ -37,7 +34,7 @@ variable "github_token" { variable "copilot_model" { type = string - description = "The model to use for Copilot. Any model supported by GitHub Copilot can be used. When enable_ai_gateway is true, this must be a model id served by your AI Gateway provider." + description = "The model to use for Copilot. Any model supported by GitHub Copilot can be used." default = "claude-sonnet-4.5" } @@ -83,17 +80,6 @@ variable "post_install_script" { default = null } -variable "enable_ai_gateway" { - type = bool - description = "Use AI Gateway for Copilot. Routes Copilot's model traffic through Coder's AI Gateway using the workspace owner's session token. https://coder.com/docs/ai-coder/ai-gateway" - default = false - - validation { - condition = !(var.enable_ai_gateway && length(var.github_token) > 0) - error_message = "github_token cannot be provided when enable_ai_gateway is true. AI Gateway authenticates the model provider using the workspace owner's Coder session token." - } -} - resource "coder_env" "copilot_model" { count = var.copilot_model != "" ? 1 : 0 agent_id = var.agent_id @@ -115,30 +101,6 @@ resource "coder_env" "gh_token" { value = var.github_token } -# BYOK provider settings that point Copilot at Coder's AI Gateway. The provider -# type is OpenAI-compatible and the API key is the workspace owner's session -# token, which the gateway uses to authenticate and attribute requests. -resource "coder_env" "ai_gateway_provider_type" { - count = var.enable_ai_gateway ? 1 : 0 - agent_id = var.agent_id - name = "COPILOT_PROVIDER_TYPE" - value = "openai" -} - -resource "coder_env" "ai_gateway_base_url" { - count = var.enable_ai_gateway ? 1 : 0 - agent_id = var.agent_id - name = "COPILOT_PROVIDER_BASE_URL" - value = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1" -} - -resource "coder_env" "ai_gateway_provider_api_key" { - count = var.enable_ai_gateway ? 1 : 0 - agent_id = var.agent_id - name = "COPILOT_PROVIDER_API_KEY" - value = data.coder_workspace_owner.me.session_token -} - locals { workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" diff --git a/registry/coder-labs/modules/copilot/main.tftest.hcl b/registry/coder-labs/modules/copilot/main.tftest.hcl index da95917a0..54d5f9420 100644 --- a/registry/coder-labs/modules/copilot/main.tftest.hcl +++ b/registry/coder-labs/modules/copilot/main.tftest.hcl @@ -16,11 +16,6 @@ run "defaults_are_correct" { error_message = "install_copilot should default to true" } - assert { - condition = var.enable_ai_gateway == false - error_message = "enable_ai_gateway should default to false" - } - assert { condition = local.module_dir_name == ".coder-modules/coder-labs/copilot" error_message = "module_dir_name should be '.coder-modules/coder-labs/copilot'" @@ -92,73 +87,6 @@ run "copilot_model_env_var_is_always_set" { } } -run "ai_gateway_enabled" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - enable_ai_gateway = true - } - - override_data { - target = data.coder_workspace_owner.me - values = { - session_token = "mock-session-token" - } - } - - assert { - condition = coder_env.ai_gateway_provider_type[0].value == "openai" - error_message = "COPILOT_PROVIDER_TYPE should be 'openai' when ai_gateway is enabled" - } - - assert { - condition = coder_env.ai_gateway_provider_api_key[0].value == data.coder_workspace_owner.me.session_token - error_message = "COPILOT_PROVIDER_API_KEY should use the workspace owner's session token" - } - - assert { - condition = endswith(coder_env.ai_gateway_base_url[0].value, "/api/v2/aibridge/openai/v1") - error_message = "COPILOT_PROVIDER_BASE_URL should point at the AI Gateway OpenAI endpoint" - } - - assert { - condition = length(coder_env.copilot_model) == 1 - error_message = "COPILOT_MODEL should always be set when ai_gateway is enabled" - } -} - -run "ai_gateway_validation_with_github_token" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - enable_ai_gateway = true - github_token = "test-token" - } - - expect_failures = [ - var.enable_ai_gateway, - ] -} - -run "ai_gateway_disabled_creates_no_provider_env" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - enable_ai_gateway = false - } - - assert { - condition = length(coder_env.ai_gateway_provider_type) == 0 && length(coder_env.ai_gateway_base_url) == 0 && length(coder_env.ai_gateway_provider_api_key) == 0 - error_message = "No AI Gateway provider env vars should be created when disabled" - } -} - run "copilot_config_merges_with_trusted_directories" { command = plan From 186d411e64e8d1c15eee8ca7f0b1010f75a31695 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Wed, 16 Sep 2026 03:41:31 +0000 Subject: [PATCH 03/23] fix(registry/coder-labs/modules/copilot): merge mcp-config.json with existing entries winning Always merge mcp_config into ~/.copilot/mcp-config.json via jq; on duplicate server names the existing on-disk entry takes precedence. Require jq (drop the verbatim-overwrite fallback) and add a test that seeds a conflicting server. Generated with Coder Agents. --- registry/coder-labs/modules/copilot/README.md | 2 +- .../coder-labs/modules/copilot/main.test.ts | 30 +++++++++++++++++ registry/coder-labs/modules/copilot/main.tf | 2 +- .../modules/copilot/scripts/install.sh.tftpl | 32 ++++++++++--------- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index f61c81300..e518f55e2 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -140,7 +140,7 @@ module "copilot" { ``` > [!NOTE] -> Servers from `mcp_config` are merged into `~/.copilot/mcp-config.json`, Copilot's documented user-level MCP config. GitHub Copilot CLI does not automatically install MCP servers. Either use `npx -y` in the config (shown above) to auto-install on each run, or pre-install MCP servers in `pre_install_script` for faster startup. +> Servers from `mcp_config` are merged into `~/.copilot/mcp-config.json`, Copilot's documented user-level MCP config. Existing entries take precedence on duplicate server names. GitHub Copilot CLI does not automatically install MCP servers. Either use `npx -y` in the config (shown above) to auto-install on each run, or pre-install MCP servers in `pre_install_script` for faster startup. ### Serialize a downstream `coder_script` after the install pipeline diff --git a/registry/coder-labs/modules/copilot/main.test.ts b/registry/coder-labs/modules/copilot/main.test.ts index 2b6211feb..ca963675e 100644 --- a/registry/coder-labs/modules/copilot/main.test.ts +++ b/registry/coder-labs/modules/copilot/main.test.ts @@ -249,6 +249,36 @@ describe("copilot", async () => { expect(mcp.mcpServers.coder).toBeUndefined(); }); + test("merges-mcp-config-preferring-existing-servers", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + mcp_config: JSON.stringify({ + mcpServers: { + filesystem: { command: "module-command", type: "local" }, + extra: { command: "npx", type: "local" }, + }, + }), + }, + }); + // Seed an existing config with a conflicting `filesystem` server. + const seed = JSON.stringify({ + mcpServers: { + filesystem: { command: "existing-command", 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 server wins on the duplicate key. + expect(mcp.mcpServers.filesystem.command).toBe("existing-command"); + // Non-conflicting module server is still merged in. + expect(mcp.mcpServers.extra).toBeDefined(); + }); + test("github-token-env-vars", async () => { const token = "ghp_test_token_123"; const { coderEnvVars } = await setup({ diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index 60ce56bbb..923d111ae 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -52,7 +52,7 @@ variable "trusted_directories" { variable "mcp_config" { type = string - description = "Custom MCP server configuration as JSON string (in the `{\"mcpServers\": {...}}` shape). Merged into Copilot's `~/.copilot/mcp-config.json`." + description = "Custom MCP server configuration as JSON string (in the `{\"mcpServers\": {...}}` shape). Merged into Copilot's `~/.copilot/mcp-config.json`; existing entries win on duplicate server names." default = "" } diff --git a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl index f87c74cad..a44106a12 100644 --- a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl @@ -151,9 +151,9 @@ setup_copilot_config() { echo "Wrote $${copilot_config_file}" } -# Write MCP servers to Copilot's user-level mcp-config.json. The mcp_config -# input already uses Copilot's native file schema, so this is a pass-through -# merge into the documented location (~/.copilot/mcp-config.json). +# Merge MCP servers into Copilot's user-level mcp-config.json. The mcp_config +# input uses Copilot's native file schema. Existing entries always take +# precedence: on duplicate server names, what's already in the file wins. setup_mcp_config() { local mcp_config_file="$HOME/.copilot/mcp-config.json" @@ -162,21 +162,23 @@ setup_mcp_config() { 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}")" - echo "Writing MCP server configuration to $${mcp_config_file}" + echo "Merging MCP server configuration into $${mcp_config_file}" - if command_exists jq; then - local existing='{"mcpServers": {}}' - if [ -f "$${mcp_config_file}" ]; then - existing=$(cat "$${mcp_config_file}") - fi - local custom - custom=$(echo "$${ARG_MCP_CONFIG}" | jq '.mcpServers // {}') - echo "$${existing}" | jq --argjson custom "$${custom}" '.mcpServers = ((.mcpServers // {}) + $custom)' > "$${mcp_config_file}" - else - echo "WARNING: jq not available, writing MCP config verbatim" - echo "$${ARG_MCP_CONFIG}" > "$${mcp_config_file}" + local existing='{"mcpServers": {}}' + if [ -f "$${mcp_config_file}" ]; then + existing=$(cat "$${mcp_config_file}") fi + local custom + custom=$(echo "$${ARG_MCP_CONFIG}" | jq '.mcpServers // {}') + # `$custom + existing` merges the two objects with the right operand winning, + # so existing servers override the module-provided ones on duplicate keys. + echo "$${existing}" | jq --argjson custom "$${custom}" '.mcpServers = ($custom + (.mcpServers // {}))' > "$${mcp_config_file}" echo "MCP configuration completed: $${mcp_config_file}" } From ddf95a6553ef2eca3e31f7583cde613da678c80a Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Wed, 16 Sep 2026 04:00:41 +0000 Subject: [PATCH 04/23] refactor(registry/coder-labs/modules/copilot): clear ownership for config and mcp merges Make module inputs authoritative for the keys/servers the module owns while preserving runtime state: - config.json: module keys win, trusted_folders is a union of existing and module lists, other on-disk keys (model, reasoning_effort, ...) are preserved. - mcp-config.json: module-provided servers win on duplicate names; unrelated on-disk servers are preserved. - Route mcpServers from copilot_config into mcp-config.json (mcp_config wins); never write mcpServers into config.json. - Update variable docs, README, and tests. Generated with Coder Agents. --- registry/coder-labs/modules/copilot/README.md | 2 +- .../coder-labs/modules/copilot/main.test.ts | 66 +++++++++++++++++-- registry/coder-labs/modules/copilot/main.tf | 21 ++++-- .../modules/copilot/scripts/install.sh.tftpl | 47 ++++++++----- 4 files changed, 108 insertions(+), 28 deletions(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index e518f55e2..081279b7f 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -140,7 +140,7 @@ module "copilot" { ``` > [!NOTE] -> Servers from `mcp_config` are merged into `~/.copilot/mcp-config.json`, Copilot's documented user-level MCP config. Existing entries take precedence on duplicate server names. GitHub Copilot CLI does not automatically install MCP servers. Either use `npx -y` in the config (shown above) to auto-install on each run, or pre-install MCP servers in `pre_install_script` for faster startup. +> Servers from `mcp_config` are merged into `~/.copilot/mcp-config.json`, Copilot's documented user-level MCP config. Module-provided servers win on duplicate names, while other servers already on disk are preserved. An `mcpServers` key placed in `copilot_config` is also routed here (never written to `config.json`). GitHub Copilot CLI does not automatically install MCP servers. Either use `npx -y` in the config (shown above) to auto-install on each run, or pre-install MCP servers in `pre_install_script` for faster startup. ### Serialize a downstream `coder_script` after the install pipeline diff --git a/registry/coder-labs/modules/copilot/main.test.ts b/registry/coder-labs/modules/copilot/main.test.ts index ca963675e..f0d70219e 100644 --- a/registry/coder-labs/modules/copilot/main.test.ts +++ b/registry/coder-labs/modules/copilot/main.test.ts @@ -225,6 +225,59 @@ describe("copilot", async () => { expect(config.trusted_folders).toContain("/data"); }); + test("config-module-keys-win-and-trusted-folders-union", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + trusted_directories: JSON.stringify(["/data"]), + }, + }); + // Seed an existing config.json with conflicting + runtime-only values. + const seed = JSON.stringify({ + theme: "dark", + model: "user-picked-model", + trusted_folders: ["/interactively-trusted"], + }); + await execContainer(id, [ + "bash", + "-c", + `mkdir -p /home/coder/.copilot && cat > /home/coder/.copilot/config.json <<'JSON'\n${seed}\nJSON`, + ]); + await runScripts(id, scripts); + const config = JSON.parse(await readConfig(id)); + // Module-owned key wins over the existing value. + expect(config.theme).toBe("auto"); + // Module key the existing file lacked is added. + expect(config.banner).toBe("never"); + // Unmanaged on-disk key is preserved. + expect(config.model).toBe("user-picked-model"); + // trusted_folders is the union of existing + module lists. + expect(config.trusted_folders).toContain("/interactively-trusted"); + expect(config.trusted_folders).toContain("/data"); + expect(config.trusted_folders).toContain(projectDir); + }); + + test("routes-mcp-servers-from-copilot-config", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + copilot_config: JSON.stringify({ + theme: "dark", + mcpServers: { + fromcopilot: { command: "npx", type: "local" }, + }, + }), + }, + }); + await runScripts(id, scripts); + const mcp = JSON.parse(await readMcpConfig(id)); + const config = JSON.parse(await readConfig(id)); + // mcpServers in copilot_config are routed to mcp-config.json... + expect(mcp.mcpServers.fromcopilot).toBeDefined(); + // ...and never written into config.json. + expect(config.mcpServers).toBeUndefined(); + // Non-mcp keys from copilot_config still apply to config.json. + expect(config.theme).toBe("dark"); + }); + test("writes-custom-mcp-servers-without-coder-server", async () => { const { id, scripts } = await setup({ moduleVariables: { @@ -249,7 +302,7 @@ describe("copilot", async () => { expect(mcp.mcpServers.coder).toBeUndefined(); }); - test("merges-mcp-config-preferring-existing-servers", async () => { + test("merges-mcp-config-module-servers-win", async () => { const { id, scripts } = await setup({ moduleVariables: { mcp_config: JSON.stringify({ @@ -260,10 +313,11 @@ describe("copilot", async () => { }), }, }); - // Seed an existing config with a conflicting `filesystem` server. + // 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, [ @@ -273,9 +327,11 @@ describe("copilot", async () => { ]); await runScripts(id, scripts); const mcp = JSON.parse(await readMcpConfig(id)); - // Existing server wins on the duplicate key. - expect(mcp.mcpServers.filesystem.command).toBe("existing-command"); - // Non-conflicting module server is still merged in. + // Module-provided server wins on the duplicate key. + expect(mcp.mcpServers.filesystem.command).toBe("module-command"); + // Unrelated on-disk server is preserved. + expect(mcp.mcpServers.seeded).toBeDefined(); + // Non-conflicting module server is merged in. expect(mcp.mcpServers.extra).toBeDefined(); }); diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index 923d111ae..dddc56ce1 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -40,7 +40,7 @@ variable "copilot_model" { 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 = "Custom Copilot configuration as JSON string, written to Copilot's config.json (banner, theme, trusted_folders, etc.). Any mcpServers key is routed to mcp-config.json. Module-set keys are authoritative; other on-disk keys are preserved." default = "" } @@ -52,7 +52,7 @@ variable "trusted_directories" { variable "mcp_config" { type = string - description = "Custom MCP server configuration as JSON string (in the `{\"mcpServers\": {...}}` shape). Merged into Copilot's `~/.copilot/mcp-config.json`; existing entries win on duplicate server names." + description = "Custom MCP server configuration as JSON string (in the `{\"mcpServers\": {...}}` shape). Merged into Copilot's `~/.copilot/mcp-config.json`; these servers win over existing entries on duplicate names." default = "" } @@ -110,26 +110,37 @@ locals { existing_trusted_folders = try(local.parsed_custom_config.trusted_folders, []) + # config.json is owned by copilot_config. mcpServers never belong here (Copilot + # reads MCP only from mcp-config.json), so strip it and route it below. + copilot_config_base = { for k, v in local.parsed_custom_config : k => v if k != "mcpServers" } + merged_copilot_config = merge( { banner = "never" theme = "auto" }, - local.parsed_custom_config, + local.copilot_config_base, { - trusted_folders = concat(local.existing_trusted_folders, local.all_trusted_folders) + trusted_folders = distinct(concat(local.existing_trusted_folders, local.all_trusted_folders)) } ) final_copilot_config = jsonencode(local.merged_copilot_config) + # MCP servers may arrive via copilot_config.mcpServers or mcp_config; both are + # routed to mcp-config.json. mcp_config wins on duplicate server names. + copilot_config_mcp_servers = try(local.parsed_custom_config.mcpServers, {}) + mcp_config_servers = var.mcp_config != "" ? try(jsondecode(var.mcp_config).mcpServers, {}) : {} + combined_mcp_servers = merge(local.copilot_config_mcp_servers, local.mcp_config_servers) + combined_mcp_json = length(local.combined_mcp_servers) > 0 ? jsonencode({ mcpServers = local.combined_mcp_servers }) : "" + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { ARG_INSTALL = tostring(var.install_copilot) ARG_COPILOT_VERSION = var.copilot_version ARG_COPILOT_MODEL = var.copilot_model ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" ARG_COPILOT_CONFIG = base64encode(local.final_copilot_config) - ARG_MCP_CONFIG = var.mcp_config != "" ? base64encode(var.mcp_config) : "" + ARG_MCP_CONFIG = local.combined_mcp_json != "" ? base64encode(local.combined_mcp_json) : "" }) module_dir_name = ".coder-modules/coder-labs/copilot" diff --git a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl index a44106a12..748cc5415 100644 --- a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl @@ -131,29 +131,42 @@ setup_workdir() { fi } +# Merge the module-provided Copilot configuration into ~/.copilot/config.json. +# The module owns the keys it sets (they win), trusted_folders is a union of the +# existing and module lists, and any other on-disk keys Copilot manages at +# runtime (model, reasoning_effort, ...) are preserved. setup_copilot_config() { - local copilot_config_dir="$HOME/.copilot" - local copilot_config_file="$${copilot_config_dir}/config.json" - - mkdir -p "$${copilot_config_dir}" + local copilot_config_file="$HOME/.copilot/config.json" if [ -z "$${ARG_COPILOT_CONFIG}" ]; then - echo "ERROR: No Copilot configuration provided" >&2 + echo "No Copilot configuration provided; leaving $${copilot_config_file} unchanged." + return + fi + + if ! command_exists jq; then + echo "ERROR: 'jq' is required to configure Copilot but was not found." >&2 exit 1 fi - 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}" + mkdir -p "$(dirname "$${copilot_config_file}")" + echo "Merging Copilot configuration into $${copilot_config_file}" + + local existing='{}' + if [ -f "$${copilot_config_file}" ]; then + existing=$(cat "$${copilot_config_file}") fi - echo "Wrote $${copilot_config_file}" + # `$existing * $module` lets module keys win while preserving on-disk-only keys; + # trusted_folders is then set to the union of both lists. + echo "$${existing}" | jq --argjson module "$${ARG_COPILOT_CONFIG}" ' + . as $existing + | ($existing * $module) + | .trusted_folders = ((($existing.trusted_folders // []) + ($module.trusted_folders // [])) | unique) + ' > "$${copilot_config_file}" + echo "Copilot configuration completed: $${copilot_config_file}" } -# Merge MCP servers into Copilot's user-level mcp-config.json. The mcp_config -# input uses Copilot's native file schema. Existing entries always take -# precedence: on duplicate server names, what's already in the file wins. +# Merge MCP servers into Copilot's user-level mcp-config.json. The module-provided +# servers win on duplicate names; any other servers already on disk are preserved. setup_mcp_config() { local mcp_config_file="$HOME/.copilot/mcp-config.json" @@ -176,9 +189,9 @@ setup_mcp_config() { fi local custom custom=$(echo "$${ARG_MCP_CONFIG}" | jq '.mcpServers // {}') - # `$custom + existing` merges the two objects with the right operand winning, - # so existing servers override the module-provided ones on duplicate keys. - echo "$${existing}" | jq --argjson custom "$${custom}" '.mcpServers = ($custom + (.mcpServers // {}))' > "$${mcp_config_file}" + # `(existing + $custom)` merges with the right operand winning, so module + # servers override on duplicate names while other on-disk servers are kept. + echo "$${existing}" | jq --argjson custom "$${custom}" '.mcpServers = ((.mcpServers // {}) + $custom)' > "$${mcp_config_file}" echo "MCP configuration completed: $${mcp_config_file}" } From 8cda4f547e3a44ab1654d4b09ce200ab1f583fec Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Wed, 16 Sep 2026 10:48:33 +0000 Subject: [PATCH 05/23] refactor(registry/coder-labs/modules/copilot): drop unnecessary mcpServers routing from config config.json has no mcpServers key (Copilot reads MCP only from mcp-config.json), so a well-formed copilot_config never carries it. Remove the strip/route handling: copilot_config feeds config.json as-is, mcp_config feeds mcp-config.json. Generated with Coder Agents. --- registry/coder-labs/modules/copilot/README.md | 2 +- .../coder-labs/modules/copilot/main.test.ts | 22 ------------------- registry/coder-labs/modules/copilot/main.tf | 17 +++----------- 3 files changed, 4 insertions(+), 37 deletions(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 081279b7f..34e4acaba 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -140,7 +140,7 @@ module "copilot" { ``` > [!NOTE] -> Servers from `mcp_config` are merged into `~/.copilot/mcp-config.json`, Copilot's documented user-level MCP config. Module-provided servers win on duplicate names, while other servers already on disk are preserved. An `mcpServers` key placed in `copilot_config` is also routed here (never written to `config.json`). GitHub Copilot CLI does not automatically install MCP servers. Either use `npx -y` in the config (shown above) to auto-install on each run, or pre-install MCP servers in `pre_install_script` for faster startup. +> Servers from `mcp_config` are merged into `~/.copilot/mcp-config.json`, Copilot's documented user-level MCP config. Module-provided servers win on duplicate names, while other servers already on disk are preserved. GitHub Copilot CLI does not automatically install MCP servers. Either use `npx -y` in the config (shown above) to auto-install on each run, or pre-install MCP servers in `pre_install_script` for faster startup. ### Serialize a downstream `coder_script` after the install pipeline diff --git a/registry/coder-labs/modules/copilot/main.test.ts b/registry/coder-labs/modules/copilot/main.test.ts index f0d70219e..c16d36113 100644 --- a/registry/coder-labs/modules/copilot/main.test.ts +++ b/registry/coder-labs/modules/copilot/main.test.ts @@ -256,28 +256,6 @@ describe("copilot", async () => { expect(config.trusted_folders).toContain(projectDir); }); - test("routes-mcp-servers-from-copilot-config", async () => { - const { id, scripts } = await setup({ - moduleVariables: { - copilot_config: JSON.stringify({ - theme: "dark", - mcpServers: { - fromcopilot: { command: "npx", type: "local" }, - }, - }), - }, - }); - await runScripts(id, scripts); - const mcp = JSON.parse(await readMcpConfig(id)); - const config = JSON.parse(await readConfig(id)); - // mcpServers in copilot_config are routed to mcp-config.json... - expect(mcp.mcpServers.fromcopilot).toBeDefined(); - // ...and never written into config.json. - expect(config.mcpServers).toBeUndefined(); - // Non-mcp keys from copilot_config still apply to config.json. - expect(config.theme).toBe("dark"); - }); - test("writes-custom-mcp-servers-without-coder-server", async () => { const { id, scripts } = await setup({ moduleVariables: { diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index dddc56ce1..e0c6cf37d 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -40,7 +40,7 @@ variable "copilot_model" { variable "copilot_config" { type = string - description = "Custom Copilot configuration as JSON string, written to Copilot's config.json (banner, theme, trusted_folders, etc.). Any mcpServers key is routed to mcp-config.json. Module-set keys are authoritative; other on-disk keys are preserved." + description = "Custom Copilot configuration as JSON string, written to Copilot's config.json (banner, theme, trusted_folders, etc.). Module-set keys are authoritative; other on-disk keys are preserved. Use mcp_config for MCP servers." default = "" } @@ -110,16 +110,12 @@ locals { existing_trusted_folders = try(local.parsed_custom_config.trusted_folders, []) - # config.json is owned by copilot_config. mcpServers never belong here (Copilot - # reads MCP only from mcp-config.json), so strip it and route it below. - copilot_config_base = { for k, v in local.parsed_custom_config : k => v if k != "mcpServers" } - merged_copilot_config = merge( { banner = "never" theme = "auto" }, - local.copilot_config_base, + local.parsed_custom_config, { trusted_folders = distinct(concat(local.existing_trusted_folders, local.all_trusted_folders)) } @@ -127,20 +123,13 @@ locals { final_copilot_config = jsonencode(local.merged_copilot_config) - # MCP servers may arrive via copilot_config.mcpServers or mcp_config; both are - # routed to mcp-config.json. mcp_config wins on duplicate server names. - copilot_config_mcp_servers = try(local.parsed_custom_config.mcpServers, {}) - mcp_config_servers = var.mcp_config != "" ? try(jsondecode(var.mcp_config).mcpServers, {}) : {} - combined_mcp_servers = merge(local.copilot_config_mcp_servers, local.mcp_config_servers) - combined_mcp_json = length(local.combined_mcp_servers) > 0 ? jsonencode({ mcpServers = local.combined_mcp_servers }) : "" - install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { ARG_INSTALL = tostring(var.install_copilot) ARG_COPILOT_VERSION = var.copilot_version ARG_COPILOT_MODEL = var.copilot_model ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" ARG_COPILOT_CONFIG = base64encode(local.final_copilot_config) - ARG_MCP_CONFIG = local.combined_mcp_json != "" ? base64encode(local.combined_mcp_json) : "" + ARG_MCP_CONFIG = var.mcp_config != "" ? base64encode(var.mcp_config) : "" }) module_dir_name = ".coder-modules/coder-labs/copilot" From 18fcc89df24c8c20135bbb195f342c184b696109 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Wed, 16 Sep 2026 12:16:49 +0000 Subject: [PATCH 06/23] feat(registry/coder-labs/modules/copilot): restore AI Bridge Proxy as ai_gateway Renames the pre-v1 enable_aibridge_proxy feature to ai_gateway. Sets HTTPS_PROXY and NODE_EXTRA_CA_CERTS via agent-level coder_env with variable validation, plus tests and README docs. --- registry/coder-labs/modules/copilot/README.md | 33 +++++++++- registry/coder-labs/modules/copilot/main.tf | 46 +++++++++++++ .../modules/copilot/main.tftest.hcl | 66 +++++++++++++++++++ 3 files changed, 144 insertions(+), 1 deletion(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 34e4acaba..3d7976c59 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -3,7 +3,7 @@ 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] +tags: [agent, copilot, ai, github, ai-gateway] --- # Copilot @@ -97,6 +97,37 @@ module "copilot" { > [!NOTE] > OAuth tokens work best with Copilot. Personal Access Tokens may have limited functionality. +### Usage with AI Gateway (AI Bridge Proxy) + +[AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy) routes Copilot traffic through [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) for centralized LLM management and governance. + +```tf +module "aibridge-proxy" { + source = "registry.coder.com/coder/aibridge-proxy/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + proxy_url = "https://aiproxy.example.com" +} + +module "copilot" { + 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_auth_url = module.aibridge-proxy.proxy_auth_url + ai_gateway_cert_path = module.aibridge-proxy.cert_path +} +``` + +When `enable_ai_gateway = true`, the module sets `HTTPS_PROXY` and `NODE_EXTRA_CA_CERTS` as workspace environment variables so Copilot routes through the proxy. + +> [!NOTE] +> AI Bridge Proxy is a Premium Coder feature that requires the [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance). See the [setup guide](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup) for configuring the proxy on your deployment. GitHub authentication is still required; the proxy does not replace it. + +> [!IMPORTANT] +> Unlike the pre-`v1` module (which scoped the proxy to the Copilot process via its start script), these variables are set at the agent level and therefore apply workspace-wide. Ensure the `aibridge-proxy` module completes before Copilot is launched so the CA certificate exists. For strict process-scoping, set `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` in your own launcher `coder_app` instead. + ### Advanced configuration Customize MCP servers, trusted directories, and Copilot settings: diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index e0c6cf37d..01fbaf26e 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -80,6 +80,35 @@ variable "post_install_script" { default = null } +variable "enable_ai_gateway" { + type = bool + description = "Route Copilot traffic through Coder's AI Gateway (AI Bridge Proxy). https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy" + default = false + + validation { + condition = !var.enable_ai_gateway || (var.ai_gateway_auth_url != null && length(var.ai_gateway_auth_url) > 0) + error_message = "ai_gateway_auth_url is required when enable_ai_gateway is true." + } + + validation { + condition = !var.enable_ai_gateway || (var.ai_gateway_cert_path != null && length(var.ai_gateway_cert_path) > 0) + error_message = "ai_gateway_cert_path is required when enable_ai_gateway is true." + } +} + +variable "ai_gateway_auth_url" { + type = string + description = "AI Gateway (AI Bridge Proxy) URL with authentication. Use the proxy_auth_url output from the aibridge-proxy module." + default = null + sensitive = true +} + +variable "ai_gateway_cert_path" { + type = string + description = "Path to the AI Gateway (AI Bridge Proxy) CA certificate. Use the cert_path output from the aibridge-proxy module." + default = null +} + resource "coder_env" "copilot_model" { count = var.copilot_model != "" ? 1 : 0 agent_id = var.agent_id @@ -101,6 +130,23 @@ resource "coder_env" "gh_token" { value = var.github_token } +# Route Copilot's traffic through the AI Bridge Proxy. The pre-migration module +# scoped these to the Copilot process via its start script; with no start script +# they are set at the agent level, so they apply workspace-wide. +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_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_cert_path +} + locals { workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" diff --git a/registry/coder-labs/modules/copilot/main.tftest.hcl b/registry/coder-labs/modules/copilot/main.tftest.hcl index 54d5f9420..d12961181 100644 --- a/registry/coder-labs/modules/copilot/main.tftest.hcl +++ b/registry/coder-labs/modules/copilot/main.tftest.hcl @@ -183,3 +183,69 @@ run "script_outputs_with_pre_and_post" { 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_auth_url = "https://coder:mock-token@aiproxy.example.com" + ai_gateway_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_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_cert_path" + } +} + +run "ai_gateway_requires_auth_url" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + enable_ai_gateway = true + ai_gateway_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_auth_url = "https://coder:mock-token@aiproxy.example.com" + } + + expect_failures = [ + var.enable_ai_gateway, + ] +} From 350c0f8c0426956f2a09f2af6fb1df844dd592ea Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Thu, 24 Sep 2026 15:36:39 +0000 Subject: [PATCH 07/23] refactor(registry/coder-labs/modules/copilot): align config to settings.json/config.json schema --- registry/coder-labs/modules/copilot/README.md | 15 +++- .../coder-labs/modules/copilot/main.test.ts | 77 ++++++++++++------- registry/coder-labs/modules/copilot/main.tf | 39 ++++------ .../modules/copilot/main.tftest.hcl | 40 ++-------- .../modules/copilot/scripts/install.sh.tftpl | 74 +++++++++++++----- 5 files changed, 135 insertions(+), 110 deletions(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 3d7976c59..aa4371fcf 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -142,12 +142,16 @@ module "copilot" { # Version pinning (defaults to "latest") copilot_version = "0.0.334" - trusted_directories = ["/home/coder/project", "/tmp"] + # Base user settings, merged into ~/.copilot/settings.json + copilot_settings = jsonencode({ + banner = "never" + theme = "dim" + }) - # Custom Copilot configuration (written to config.json) + # Base application config, merged into ~/.copilot/config.json. + # workdir is unioned into trustedFolders automatically. copilot_config = jsonencode({ - banner = "never" - theme = "dark" + trustedFolders = ["/home/coder/project", "/tmp"] }) # MCP server configuration (merged into ~/.copilot/mcp-config.json) @@ -173,6 +177,9 @@ module "copilot" { > [!NOTE] > Servers from `mcp_config` are merged into `~/.copilot/mcp-config.json`, Copilot's documented user-level MCP config. Module-provided servers win on duplicate names, while other servers already on disk are preserved. GitHub Copilot CLI does not automatically install MCP servers. Either use `npx -y` in the config (shown above) to auto-install on each run, or pre-install MCP servers in `pre_install_script` for faster startup. +> [!NOTE] +> Configuration is written to the files Copilot CLI documents for each purpose: `copilot_settings` is merged into user-editable settings at `~/.copilot/settings.json` (`banner`, `theme`, `model`, and similar keys), and `copilot_config` is merged into `~/.copilot/config.json` (for example `trustedFolders`), with `workdir` unioned into `trustedFolders` automatically. Your keys win over existing on-disk keys in each file; unrelated on-disk state such as authentication is preserved. Valid `theme` values are `default`, `github`, `dim`, `high-contrast`, and `colorblind`. + ### Serialize a downstream `coder_script` after the install pipeline The module exposes the `scripts` output: an ordered list of `coder exp sync` diff --git a/registry/coder-labs/modules/copilot/main.test.ts b/registry/coder-labs/modules/copilot/main.test.ts index c16d36113..937b580ec 100644 --- a/registry/coder-labs/modules/copilot/main.test.ts +++ b/registry/coder-labs/modules/copilot/main.test.ts @@ -178,6 +178,8 @@ const runScripts = async ( const configDir = "/home/coder/.copilot"; const readConfig = (id: string) => readFileContainer(id, `${configDir}/config.json`); +const readSettings = (id: string) => + readFileContainer(id, `${configDir}/settings.json`); const readMcpConfig = (id: string) => readFileContainer(id, `${configDir}/mcp-config.json`); const installLog = (id: string) => @@ -201,59 +203,80 @@ describe("copilot", async () => { expect(log).toContain("Copilot module setup completed."); }); - test("writes-config-without-mcp-servers", async () => { - const { id, scripts } = await setup(); + test("writes-settings-and-trusted-folders", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + copilot_settings: JSON.stringify({ banner: "never", theme: "dim" }), + }, + }); await runScripts(id, scripts); + // User-provided base settings land in settings.json. + const settings = JSON.parse(await readSettings(id)); + expect(settings.banner).toBe("never"); + expect(settings.theme).toBe("dim"); + // workdir is auto-trusted in config.json under trustedFolders. const config = JSON.parse(await readConfig(id)); - expect(config.banner).toBe("never"); - expect(config.theme).toBe("auto"); - expect(config.trusted_folders).toContain(projectDir); + expect(config.trustedFolders).toContain(projectDir); // mcpServers must never be written into config.json. expect(config.mcpServers).toBeUndefined(); }); - test("merges-trusted-directories", async () => { + test("base-config-trusted-folders-union-workdir", async () => { const { id, scripts } = await setup({ moduleVariables: { - trusted_directories: JSON.stringify(["/workspace", "/data"]), + copilot_config: JSON.stringify({ + trustedFolders: ["/workspace", "/data"], + }), }, }); await runScripts(id, scripts); const config = JSON.parse(await readConfig(id)); - expect(config.trusted_folders).toContain(projectDir); - expect(config.trusted_folders).toContain("/workspace"); - expect(config.trusted_folders).toContain("/data"); + expect(config.trustedFolders).toContain(projectDir); + expect(config.trustedFolders).toContain("/workspace"); + expect(config.trustedFolders).toContain("/data"); }); - test("config-module-keys-win-and-trusted-folders-union", async () => { + test("settings-and-config-preserve-existing-state", async () => { const { id, scripts } = await setup({ moduleVariables: { - trusted_directories: JSON.stringify(["/data"]), + copilot_settings: JSON.stringify({ banner: "never" }), + copilot_config: JSON.stringify({ trustedFolders: ["/from-var"] }), }, }); - // Seed an existing config.json with conflicting + runtime-only values. - const seed = JSON.stringify({ - theme: "dark", + // Seed settings.json with unmanaged user settings. + const settingsSeed = JSON.stringify({ + theme: "dim", model: "user-picked-model", - trusted_folders: ["/interactively-trusted"], }); + // Seed config.json 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/settings.json <<'JSON'\n${settingsSeed}\nJSON`, + ]); await execContainer(id, [ "bash", "-c", - `mkdir -p /home/coder/.copilot && cat > /home/coder/.copilot/config.json <<'JSON'\n${seed}\nJSON`, + `cat > /home/coder/.copilot/config.json <<'JSON'\n${configSeed}\nJSON`, ]); await runScripts(id, scripts); + const settings = JSON.parse(await readSettings(id)); const config = JSON.parse(await readConfig(id)); - // Module-owned key wins over the existing value. - expect(config.theme).toBe("auto"); - // Module key the existing file lacked is added. - expect(config.banner).toBe("never"); - // Unmanaged on-disk key is preserved. - expect(config.model).toBe("user-picked-model"); - // trusted_folders is the union of existing + module lists. - expect(config.trusted_folders).toContain("/interactively-trusted"); - expect(config.trusted_folders).toContain("/data"); - expect(config.trusted_folders).toContain(projectDir); + // User-provided key wins in settings.json. + expect(settings.banner).toBe("never"); + // Unmanaged settings are preserved. + expect(settings.theme).toBe("dim"); + expect(settings.model).toBe("user-picked-model"); + // trustedFolders is the union of existing + base config + workdir. + expect(config.trustedFolders).toContain("/interactively-trusted"); + expect(config.trustedFolders).toContain("/from-var"); + expect(config.trustedFolders).toContain(projectDir); + // Unrelated config.json application state is preserved. + expect(config.loggedInUsers).toContain("octocat"); }); test("writes-custom-mcp-servers-without-coder-server", async () => { diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index 01fbaf26e..b4972037b 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -21,7 +21,7 @@ variable "icon" { variable "workdir" { type = string - description = "Optional project directory. When set, the module pre-creates it if missing and adds it as a trusted folder in Copilot's config.json." + description = "Optional project directory. When set, the module pre-creates it if missing and adds it to Copilot's config.json trustedFolders." default = null } @@ -38,16 +38,16 @@ variable "copilot_model" { default = "claude-sonnet-4.5" } -variable "copilot_config" { +variable "copilot_settings" { type = string - description = "Custom Copilot configuration as JSON string, written to Copilot's config.json (banner, theme, trusted_folders, etc.). Module-set keys are authoritative; other on-disk keys are preserved. Use mcp_config for MCP servers." + description = "Base Copilot user settings as a JSON string, merged into `~/.copilot/settings.json` (banner, theme, model, etc.). Your keys win over existing on-disk keys; unrelated on-disk keys are preserved. Valid theme values: default, github, dim, high-contrast, colorblind." default = "" } -variable "trusted_directories" { - type = list(string) - description = "Additional directories to trust for Copilot operations. Written to Copilot's config.json trusted_folders." - default = [] +variable "copilot_config" { + type = string + description = "Base Copilot application config as a JSON string, merged into `~/.copilot/config.json` (for example trustedFolders). workdir is unioned into trustedFolders automatically. Your keys win over existing on-disk keys; unrelated on-disk state such as authentication is preserved." + default = "" } variable "mcp_config" { @@ -150,31 +150,18 @@ resource "coder_env" "ai_gateway_node_extra_ca_certs" { locals { workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" - all_trusted_folders = concat(local.workdir != "" ? [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 = distinct(concat(local.existing_trusted_folders, local.all_trusted_folders)) - } - ) - - final_copilot_config = jsonencode(local.merged_copilot_config) + # workdir is trusted automatically; the install script unions it into the + # trustedFolders array in config.json. + workdir_trusted_folders = local.workdir != "" ? [local.workdir] : [] install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { ARG_INSTALL = tostring(var.install_copilot) ARG_COPILOT_VERSION = var.copilot_version ARG_COPILOT_MODEL = var.copilot_model ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" - ARG_COPILOT_CONFIG = base64encode(local.final_copilot_config) + ARG_SETTINGS_CONFIG = var.copilot_settings != "" ? base64encode(var.copilot_settings) : "" + ARG_CONFIG_CONFIG = var.copilot_config != "" ? base64encode(var.copilot_config) : "" + ARG_TRUSTED_FOLDERS = length(local.workdir_trusted_folders) > 0 ? base64encode(jsonencode(local.workdir_trusted_folders)) : "" ARG_MCP_CONFIG = var.mcp_config != "" ? base64encode(var.mcp_config) : "" }) diff --git a/registry/coder-labs/modules/copilot/main.tftest.hcl b/registry/coder-labs/modules/copilot/main.tftest.hcl index d12961181..514d1bbb0 100644 --- a/registry/coder-labs/modules/copilot/main.tftest.hcl +++ b/registry/coder-labs/modules/copilot/main.tftest.hcl @@ -87,13 +87,12 @@ run "copilot_model_env_var_is_always_set" { } } -run "copilot_config_merges_with_trusted_directories" { +run "workdir_trimmed_and_trusted" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder/project/" - trusted_directories = ["/workspace", "/data"] + agent_id = "test-agent" + workdir = "/home/coder/project/" } assert { @@ -102,37 +101,8 @@ run "copilot_config_merges_with_trusted_directories" { } 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") && contains(jsondecode(local.final_copilot_config).trusted_folders, "/data") - error_message = "trusted_directories should be merged into config" - } -} - -run "custom_copilot_config_overrides_default" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - copilot_config = jsonencode({ - banner = "always" - theme = "dark" - trusted_folders = ["/custom"] - }) - } - - assert { - condition = jsondecode(local.final_copilot_config).banner == "always" && jsondecode(local.final_copilot_config).theme == "dark" - error_message = "Custom banner and theme settings should be applied" - } - - assert { - condition = contains(jsondecode(local.final_copilot_config).trusted_folders, "/custom") - error_message = "Custom trusted folder should be preserved" + condition = contains(local.workdir_trusted_folders, "/home/coder/project") + error_message = "workdir should be trusted automatically" } } diff --git a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl index 748cc5415..a656e5d4c 100644 --- a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl @@ -12,7 +12,9 @@ ARG_INSTALL='${ARG_INSTALL}' ARG_COPILOT_VERSION='${ARG_COPILOT_VERSION}' ARG_COPILOT_MODEL='${ARG_COPILOT_MODEL}' ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d) -ARG_COPILOT_CONFIG=$(echo -n '${ARG_COPILOT_CONFIG}' | base64 -d) +ARG_SETTINGS_CONFIG=$(echo -n '${ARG_SETTINGS_CONFIG}' | base64 -d) +ARG_CONFIG_CONFIG=$(echo -n '${ARG_CONFIG_CONFIG}' | base64 -d) +ARG_TRUSTED_FOLDERS=$(echo -n '${ARG_TRUSTED_FOLDERS}' | base64 -d) ARG_MCP_CONFIG=$(echo -n '${ARG_MCP_CONFIG}' | base64 -d) export PATH="$HOME/.local/bin:$PATH" @@ -131,15 +133,45 @@ setup_workdir() { fi } -# Merge the module-provided Copilot configuration into ~/.copilot/config.json. -# The module owns the keys it sets (they win), trusted_folders is a union of the -# existing and module lists, and any other on-disk keys Copilot manages at -# runtime (model, reasoning_effort, ...) are preserved. +# Merge the module-provided user settings into ~/.copilot/settings.json. Copilot +# moved user-editable settings out of config.json into settings.json, so that is +# where banner/theme/model belong. The module owns the keys it sets (they win), +# and any other on-disk keys Copilot manages at runtime are preserved. +setup_copilot_settings() { + local settings_file="$HOME/.copilot/settings.json" + + if [ -z "$${ARG_SETTINGS_CONFIG}" ]; then + echo "No Copilot settings provided; leaving $${settings_file} unchanged." + return + fi + + if ! command_exists jq; then + echo "ERROR: 'jq' is required to configure Copilot but was not found." >&2 + exit 1 + fi + + mkdir -p "$(dirname "$${settings_file}")" + echo "Merging Copilot settings into $${settings_file}" + + # Only reuse the existing file when it is valid JSON; settings.json may contain + # comments (JSONC), which jq cannot parse, so fall back to a fresh object. + local existing='{}' + if [ -f "$${settings_file}" ] && jq empty "$${settings_file}" > /dev/null 2>&1; then + existing=$(cat "$${settings_file}") + fi + # `$existing * $module` lets module keys win while preserving on-disk-only keys. + echo "$${existing}" | jq --argjson module "$${ARG_SETTINGS_CONFIG}" '. * $module' > "$${settings_file}" + echo "Copilot settings completed: $${settings_file}" +} + +# Merge trusted folders into ~/.copilot/config.json. The user-provided base +# config wins per key, workdir is unioned into trustedFolders, and all other +# config.json state (authentication, installed plugins, ...) is preserved. setup_copilot_config() { - local copilot_config_file="$HOME/.copilot/config.json" + local config_file="$HOME/.copilot/config.json" - if [ -z "$${ARG_COPILOT_CONFIG}" ]; then - echo "No Copilot configuration provided; leaving $${copilot_config_file} unchanged." + if [ -z "$${ARG_CONFIG_CONFIG}" ] && [ -z "$${ARG_TRUSTED_FOLDERS}" ]; then + echo "No Copilot config provided; leaving $${config_file} unchanged." return fi @@ -148,21 +180,26 @@ setup_copilot_config() { exit 1 fi - mkdir -p "$(dirname "$${copilot_config_file}")" - echo "Merging Copilot configuration into $${copilot_config_file}" + mkdir -p "$(dirname "$${config_file}")" + echo "Merging Copilot config into $${config_file}" local existing='{}' - if [ -f "$${copilot_config_file}" ]; then - existing=$(cat "$${copilot_config_file}") + if [ -f "$${config_file}" ] && jq empty "$${config_file}" > /dev/null 2>&1; then + existing=$(cat "$${config_file}") fi - # `$existing * $module` lets module keys win while preserving on-disk-only keys; - # trusted_folders is then set to the union of both lists. - echo "$${existing}" | jq --argjson module "$${ARG_COPILOT_CONFIG}" ' + local module="$${ARG_CONFIG_CONFIG}" + [ -z "$${module}" ] && module='{}' + local folders="$${ARG_TRUSTED_FOLDERS}" + [ -z "$${folders}" ] && folders='[]' + + # Base config wins per key; trustedFolders is the union of existing, base, and + # the module-managed workdir list. + echo "$${existing}" | jq --argjson module "$${module}" --argjson folders "$${folders}" ' . as $existing | ($existing * $module) - | .trusted_folders = ((($existing.trusted_folders // []) + ($module.trusted_folders // [])) | unique) - ' > "$${copilot_config_file}" - echo "Copilot configuration completed: $${copilot_config_file}" + | .trustedFolders = ((($existing.trustedFolders // []) + ($module.trustedFolders // []) + $folders) | unique) + ' > "$${config_file}" + echo "Copilot config completed: $${config_file}" } # Merge MCP servers into Copilot's user-level mcp-config.json. The module-provided @@ -205,6 +242,7 @@ configure_copilot_model() { install_copilot setup_workdir +setup_copilot_settings setup_copilot_config setup_mcp_config configure_copilot_model From 94f832ab3c23e15d3a6609e1d4d35b2b82e06bfd Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sat, 26 Sep 2026 09:11:32 +0000 Subject: [PATCH 08/23] refactor(registry/coder-labs/modules/copilot): export only COPILOT_GITHUB_TOKEN --- registry/coder-labs/modules/copilot/README.md | 6 +++--- registry/coder-labs/modules/copilot/main.test.ts | 5 ++--- registry/coder-labs/modules/copilot/main.tf | 11 ++--------- registry/coder-labs/modules/copilot/main.tftest.hcl | 13 ++++--------- 4 files changed, 11 insertions(+), 24 deletions(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 275c93e57..ab3445962 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -61,7 +61,7 @@ resource "coder_app" "copilot" { command = <<-EOT #!/usr/bin/env bash set -e - token="$(coder external-auth access-token github)" && export GITHUB_TOKEN="$token" GH_TOKEN="$token" + token="$(coder external-auth access-token github)" && export COPILOT_GITHUB_TOKEN="$token" cd "${local.copilot_workdir}" exec copilot --allow-all-tools EOT @@ -76,7 +76,7 @@ resource "coder_app" "copilot" { ### Direct token authentication Provide a GitHub token instead of using Coder external auth. When set, the module -exports it to the workspace as `GITHUB_TOKEN` and `GH_TOKEN`. +exports it to the workspace as `COPILOT_GITHUB_TOKEN`. ```tf variable "github_token" { @@ -215,7 +215,7 @@ resource "coder_script" "post_copilot" { The module supports multiple GitHub authentication methods: 1. **[Coder External Auth](https://coder.com/docs/admin/external-auth) (Recommended)** - Fetch a fresh token at launch in your `coder_app` command with `coder external-auth access-token `. -2. **Direct Token** - Pass the `github_token` variable (OAuth or Personal Access Token). Exported as `GITHUB_TOKEN` and `GH_TOKEN`. +2. **Direct Token** - Pass the `github_token` variable (OAuth or Personal Access Token). Exported as `COPILOT_GITHUB_TOKEN`. 3. **Interactive** - Copilot prompts for login via the `/login` command if no auth is found. ## Troubleshooting diff --git a/registry/coder-labs/modules/copilot/main.test.ts b/registry/coder-labs/modules/copilot/main.test.ts index 937b580ec..fc8657027 100644 --- a/registry/coder-labs/modules/copilot/main.test.ts +++ b/registry/coder-labs/modules/copilot/main.test.ts @@ -336,14 +336,13 @@ describe("copilot", async () => { expect(mcp.mcpServers.extra).toBeDefined(); }); - test("github-token-env-vars", async () => { + test("github-token-env-var", async () => { const token = "ghp_test_token_123"; const { coderEnvVars } = await setup({ moduleVariables: { github_token: token, }, }); - expect(coderEnvVars["GITHUB_TOKEN"]).toBe(token); - expect(coderEnvVars["GH_TOKEN"]).toBe(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 6db5ea256..b0230de57 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -27,7 +27,7 @@ variable "workdir" { variable "github_token" { type = string - description = "GitHub OAuth token or Personal Access Token. When set, exported to the workspace as GITHUB_TOKEN and GH_TOKEN." + 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 } @@ -119,14 +119,7 @@ resource "coder_env" "copilot_model" { resource "coder_env" "github_token" { count = var.github_token != "" ? 1 : 0 agent_id = var.agent_id - name = "GITHUB_TOKEN" - value = var.github_token -} - -resource "coder_env" "gh_token" { - count = var.github_token != "" ? 1 : 0 - agent_id = var.agent_id - name = "GH_TOKEN" + name = "COPILOT_GITHUB_TOKEN" value = var.github_token } diff --git a/registry/coder-labs/modules/copilot/main.tftest.hcl b/registry/coder-labs/modules/copilot/main.tftest.hcl index 514d1bbb0..269f9acbc 100644 --- a/registry/coder-labs/modules/copilot/main.tftest.hcl +++ b/registry/coder-labs/modules/copilot/main.tftest.hcl @@ -32,13 +32,8 @@ run "github_token_creates_env_vars" { } assert { - condition = coder_env.github_token[0].name == "GITHUB_TOKEN" && coder_env.github_token[0].value == "test_github_token_abc123" - error_message = "GITHUB_TOKEN env var should be created with the provided token" - } - - assert { - condition = coder_env.gh_token[0].name == "GH_TOKEN" && coder_env.gh_token[0].value == "test_github_token_abc123" - error_message = "GH_TOKEN env var should be created with the provided token" + 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" } } @@ -52,8 +47,8 @@ run "github_token_not_created_when_empty" { } assert { - condition = length(coder_env.github_token) == 0 && length(coder_env.gh_token) == 0 - error_message = "GitHub token env vars should not be created when empty" + condition = length(coder_env.github_token) == 0 + error_message = "GitHub token env var should not be created when empty" } } From 81c7cc98bfaf72b3138c93933531e2016edcdc00 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sun, 27 Sep 2026 10:59:39 +0000 Subject: [PATCH 09/23] refactor(registry/coder-labs/modules/copilot): use managed_settings and align with claude-code Replace copilot_settings/copilot_config with a managed_settings policy input written to /etc/github-copilot/managed-settings.json, keep workdir auto-trust, make MCP merge existing-wins, and rename mcp_config to mcp for claude-code parity. --- registry/coder-labs/modules/copilot/README.md | 27 +++-- .../coder-labs/modules/copilot/main.test.ts | 100 ++++++++---------- registry/coder-labs/modules/copilot/main.tf | 33 +++--- .../modules/copilot/scripts/install.sh.tftpl | 96 ++++++++--------- 4 files changed, 114 insertions(+), 142 deletions(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index ab3445962..64b176761 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -130,7 +130,7 @@ When `enable_ai_gateway = true`, the module sets `HTTPS_PROXY` and `NODE_EXTRA_C ### Advanced configuration -Customize MCP servers, trusted directories, and Copilot settings: +Customize MCP servers, policy, and Copilot settings: ```tf module "copilot" { @@ -142,20 +142,17 @@ module "copilot" { # Version pinning (defaults to "latest") copilot_version = "0.0.334" - # Base user settings, merged into ~/.copilot/settings.json - copilot_settings = jsonencode({ - banner = "never" - theme = "dim" - }) - - # Base application config, merged into ~/.copilot/config.json. - # workdir is unioned into trustedFolders automatically. - copilot_config = jsonencode({ - trustedFolders = ["/home/coder/project", "/tmp"] - }) + # Policy written to /etc/github-copilot/managed-settings.json (highest precedence). + # Only supported keys apply (model, permissions, allowedMcpServers, telemetry, sandbox, ...). + managed_settings = { + model = "claude-sonnet-4.5" + permissions = { + deny = ["Shell(rm -rf *)"] + } + } # MCP server configuration (merged into ~/.copilot/mcp-config.json) - mcp_config = jsonencode({ + mcp = jsonencode({ mcpServers = { filesystem = { command = "npx" @@ -175,10 +172,10 @@ module "copilot" { ``` > [!NOTE] -> Servers from `mcp_config` are merged into `~/.copilot/mcp-config.json`, Copilot's documented user-level MCP config. Module-provided servers win on duplicate names, while other servers already on disk are preserved. GitHub Copilot CLI does not automatically install MCP servers. Either use `npx -y` in the config (shown above) to auto-install on each run, or pre-install MCP servers in `pre_install_script` for faster startup. +> Servers from `mcp` are merged into `~/.copilot/mcp-config.json`, Copilot's documented user-level MCP config. Existing servers already on disk win on duplicate names (matching claude-code's behavior, where `claude mcp add` errors on an existing name and keeps the current one), and module servers only add names not already present. GitHub Copilot CLI does not automatically install MCP servers. Either use `npx -y` in the config (shown above) to auto-install on each run, or pre-install MCP servers in `pre_install_script` for faster startup. > [!NOTE] -> Configuration is written to the files Copilot CLI documents for each purpose: `copilot_settings` is merged into user-editable settings at `~/.copilot/settings.json` (`banner`, `theme`, `model`, and similar keys), and `copilot_config` is merged into `~/.copilot/config.json` (for example `trustedFolders`), with `workdir` unioned into `trustedFolders` automatically. Your keys win over existing on-disk keys in each file; unrelated on-disk state such as authentication is preserved. Valid `theme` values are `default`, `github`, `dim`, `high-contrast`, and `colorblind`. +> Configuration is written to the files Copilot CLI documents for each purpose. `managed_settings` is written verbatim to `/etc/github-copilot/managed-settings.json` — Copilot's highest-precedence policy layer, which supports only a fixed set of keys (`model`, `permissions`, `allowedMcpServers`, `deniedMcpServers`, `telemetry`, `sandbox`, and similar); see the [managed settings reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference#mdm-managed-settings). `mcp` is merged into `~/.copilot/mcp-config.json`, and `workdir` is trusted automatically by unioning it into `trustedFolders` in `~/.copilot/config.json`. The module only touches the keys it owns in each file; unrelated on-disk state such as authentication is preserved. ### Serialize a downstream `coder_script` after the install pipeline diff --git a/registry/coder-labs/modules/copilot/main.test.ts b/registry/coder-labs/modules/copilot/main.test.ts index fc8657027..cbc951002 100644 --- a/registry/coder-labs/modules/copilot/main.test.ts +++ b/registry/coder-labs/modules/copilot/main.test.ts @@ -178,8 +178,6 @@ const runScripts = async ( const configDir = "/home/coder/.copilot"; const readConfig = (id: string) => readFileContainer(id, `${configDir}/config.json`); -const readSettings = (id: string) => - readFileContainer(id, `${configDir}/settings.json`); const readMcpConfig = (id: string) => readFileContainer(id, `${configDir}/mcp-config.json`); const installLog = (id: string) => @@ -203,17 +201,9 @@ describe("copilot", async () => { expect(log).toContain("Copilot module setup completed."); }); - test("writes-settings-and-trusted-folders", async () => { - const { id, scripts } = await setup({ - moduleVariables: { - copilot_settings: JSON.stringify({ banner: "never", theme: "dim" }), - }, - }); + test("trusts-workdir-in-config", async () => { + const { id, scripts } = await setup(); await runScripts(id, scripts); - // User-provided base settings land in settings.json. - const settings = JSON.parse(await readSettings(id)); - expect(settings.banner).toBe("never"); - expect(settings.theme).toBe("dim"); // workdir is auto-trusted in config.json under trustedFolders. const config = JSON.parse(await readConfig(id)); expect(config.trustedFolders).toContain(projectDir); @@ -221,68 +211,64 @@ describe("copilot", async () => { expect(config.mcpServers).toBeUndefined(); }); - test("base-config-trusted-folders-union-workdir", async () => { - const { id, scripts } = await setup({ - moduleVariables: { - copilot_config: JSON.stringify({ - trustedFolders: ["/workspace", "/data"], - }), - }, + test("trusted-folders-union-preserves-existing-state", async () => { + const { id, scripts } = await setup(); + // Seed config.json 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${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); - expect(config.trustedFolders).toContain("/workspace"); - expect(config.trustedFolders).toContain("/data"); + // Unrelated config.json application state is preserved. + expect(config.loggedInUsers).toContain("octocat"); }); - test("settings-and-config-preserve-existing-state", async () => { + test("managed-settings-written", async () => { const { id, scripts } = await setup({ moduleVariables: { - copilot_settings: JSON.stringify({ banner: "never" }), - copilot_config: JSON.stringify({ trustedFolders: ["/from-var"] }), + managed_settings: JSON.stringify({ + model: "claude-sonnet-4.5", + permissions: { deny: ["Shell(rm -rf *)"] }, + }), }, }); - // Seed settings.json with unmanaged user settings. - const settingsSeed = JSON.stringify({ - theme: "dim", - model: "user-picked-model", - }); - // Seed config.json with an interactively trusted folder and auth-like state. - const configSeed = JSON.stringify({ - trustedFolders: ["/interactively-trusted"], - loggedInUsers: ["octocat"], - }); - await execContainer(id, [ + await runScripts(id, scripts); + const policy = await execContainer(id, [ "bash", "-c", - `mkdir -p /home/coder/.copilot && cat > /home/coder/.copilot/settings.json <<'JSON'\n${settingsSeed}\nJSON`, + "cat /etc/github-copilot/managed-settings.json", ]); - await execContainer(id, [ + 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"); + }); + + test("managed-settings-not-set", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const resp = await execContainer(id, [ "bash", "-c", - `cat > /home/coder/.copilot/config.json <<'JSON'\n${configSeed}\nJSON`, + "test -e /etc/github-copilot/managed-settings.json && echo EXISTS || echo ABSENT", ]); - await runScripts(id, scripts); - const settings = JSON.parse(await readSettings(id)); - const config = JSON.parse(await readConfig(id)); - // User-provided key wins in settings.json. - expect(settings.banner).toBe("never"); - // Unmanaged settings are preserved. - expect(settings.theme).toBe("dim"); - expect(settings.model).toBe("user-picked-model"); - // trustedFolders is the union of existing + base config + workdir. - expect(config.trustedFolders).toContain("/interactively-trusted"); - expect(config.trustedFolders).toContain("/from-var"); - expect(config.trustedFolders).toContain(projectDir); - // Unrelated config.json application state is preserved. - expect(config.loggedInUsers).toContain("octocat"); + expect(resp.stdout.trim()).toBe("ABSENT"); }); test("writes-custom-mcp-servers-without-coder-server", async () => { const { id, scripts } = await setup({ moduleVariables: { - mcp_config: JSON.stringify({ + mcp: JSON.stringify({ mcpServers: { filesystem: { command: "npx", @@ -303,10 +289,10 @@ describe("copilot", async () => { expect(mcp.mcpServers.coder).toBeUndefined(); }); - test("merges-mcp-config-module-servers-win", async () => { + test("merges-mcp-config-existing-servers-win", async () => { const { id, scripts } = await setup({ moduleVariables: { - mcp_config: JSON.stringify({ + mcp: JSON.stringify({ mcpServers: { filesystem: { command: "module-command", type: "local" }, extra: { command: "npx", type: "local" }, @@ -328,8 +314,8 @@ describe("copilot", async () => { ]); await runScripts(id, scripts); const mcp = JSON.parse(await readMcpConfig(id)); - // Module-provided server wins on the duplicate key. - expect(mcp.mcpServers.filesystem.command).toBe("module-command"); + // 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. diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index b0230de57..0708850d1 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -38,21 +38,15 @@ variable "copilot_model" { default = "claude-sonnet-4.5" } -variable "copilot_settings" { - type = string - description = "Base Copilot user settings as a JSON string, merged into `~/.copilot/settings.json` (banner, theme, model, etc.). Your keys win over existing on-disk keys; unrelated on-disk keys are preserved. Valid theme values: default, github, dim, high-contrast, colorblind." - default = "" -} - -variable "copilot_config" { - type = string - description = "Base Copilot application config as a JSON string, merged into `~/.copilot/config.json` (for example trustedFolders). workdir is unioned into trustedFolders automatically. Your keys win over existing on-disk keys; unrelated on-disk state such as authentication is preserved." - 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 (in the `{\"mcpServers\": {...}}` shape). Merged into Copilot's `~/.copilot/mcp-config.json`; these servers win over existing entries on duplicate names." + 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 = "" } @@ -148,14 +142,13 @@ locals { workdir_trusted_folders = local.workdir != "" ? [local.workdir] : [] install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { - ARG_INSTALL = tostring(var.install_copilot) - ARG_COPILOT_VERSION = var.copilot_version - ARG_COPILOT_MODEL = var.copilot_model - ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" - ARG_SETTINGS_CONFIG = var.copilot_settings != "" ? base64encode(var.copilot_settings) : "" - ARG_CONFIG_CONFIG = var.copilot_config != "" ? base64encode(var.copilot_config) : "" - ARG_TRUSTED_FOLDERS = length(local.workdir_trusted_folders) > 0 ? base64encode(jsonencode(local.workdir_trusted_folders)) : "" - ARG_MCP_CONFIG = var.mcp_config != "" ? base64encode(var.mcp_config) : "" + ARG_INSTALL = tostring(var.install_copilot) + ARG_COPILOT_VERSION = var.copilot_version + ARG_COPILOT_MODEL = var.copilot_model + 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_MCP_CONFIG = var.mcp != "" ? base64encode(var.mcp) : "" }) module_dir_name = ".coder-modules/coder-labs/copilot" diff --git a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl index a1509c2cb..786c05bb1 100644 --- a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl @@ -12,8 +12,7 @@ ARG_INSTALL='${ARG_INSTALL}' ARG_COPILOT_VERSION='${ARG_COPILOT_VERSION}' ARG_COPILOT_MODEL='${ARG_COPILOT_MODEL}' ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d) -ARG_SETTINGS_CONFIG=$(echo -n '${ARG_SETTINGS_CONFIG}' | base64 -d) -ARG_CONFIG_CONFIG=$(echo -n '${ARG_CONFIG_CONFIG}' | 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_MCP_CONFIG=$(echo -n '${ARG_MCP_CONFIG}' | base64 -d) @@ -133,77 +132,74 @@ setup_workdir() { fi } -# Merge the module-provided user settings into ~/.copilot/settings.json. Copilot -# moved user-editable settings out of config.json into settings.json, so that is -# where banner/theme/model belong. The module owns the keys it sets (they win), -# and any other on-disk keys Copilot manages at runtime are preserved. -setup_copilot_settings() { - local settings_file="$HOME/.copilot/settings.json" - - if [ -z "$${ARG_SETTINGS_CONFIG}" ]; then - echo "No Copilot settings provided; leaving $${settings_file} unchanged." +# Write module-provided policy to Copilot's file-based managed settings. This is +# the highest-precedence Copilot config and only supports policy keys (model, +# permissions, allowedMcpServers, telemetry, sandbox, ...). Copilot rejects this +# file if it is a symlink, not owned by root, or world-writable, so write it as +# root with 0644. Mirrors the claude-code module's managed_settings handling. +write_managed_settings() { + if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then return fi if ! command_exists jq; then - echo "ERROR: 'jq' is required to configure Copilot but was not found." >&2 + echo "ERROR: 'jq' is required to configure managed settings but was not found." >&2 exit 1 fi - mkdir -p "$(dirname "$${settings_file}")" - echo "Merging Copilot settings into $${settings_file}" + 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" - # Only reuse the existing file when it is valid JSON; settings.json may contain - # comments (JSONC), which jq cannot parse, so fall back to a fresh object. - local existing='{}' - if [ -f "$${settings_file}" ] && jq empty "$${settings_file}" > /dev/null 2>&1; then - existing=$(cat "$${settings_file}") + 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 - # `$existing * $module` lets module keys win while preserving on-disk-only keys. - echo "$${existing}" | jq --argjson module "$${ARG_SETTINGS_CONFIG}" '. * $module' > "$${settings_file}" - echo "Copilot settings completed: $${settings_file}" + + echo "Wrote Copilot managed settings to $${target}" } -# Merge trusted folders into ~/.copilot/config.json. The user-provided base -# config wins per key, workdir is unioned into trustedFolders, and all other -# config.json state (authentication, installed plugins, ...) is preserved. -setup_copilot_config() { +# Trust the workdir by unioning it into config.json trustedFolders, so Copilot +# does not prompt on launch. Only the trustedFolders key is touched; all other +# config.json state (authentication, installed plugins, ...) is preserved. This +# is the Copilot equivalent of claude-code auto-accepting the workdir trust. +setup_trusted_folders() { local config_file="$HOME/.copilot/config.json" - if [ -z "$${ARG_CONFIG_CONFIG}" ] && [ -z "$${ARG_TRUSTED_FOLDERS}" ]; then - echo "No Copilot config provided; leaving $${config_file} unchanged." + 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 Copilot but was not found." >&2 + echo "ERROR: 'jq' is required to configure trusted folders but was not found." >&2 exit 1 fi mkdir -p "$(dirname "$${config_file}")" - echo "Merging Copilot config into $${config_file}" + echo "Trusting workdir in $${config_file}" local existing='{}' if [ -f "$${config_file}" ] && jq empty "$${config_file}" > /dev/null 2>&1; then existing=$(cat "$${config_file}") fi - local module="$${ARG_CONFIG_CONFIG}" - [ -z "$${module}" ] && module='{}' - local folders="$${ARG_TRUSTED_FOLDERS}" - [ -z "$${folders}" ] && folders='[]' - - # Base config wins per key; trustedFolders is the union of existing, base, and - # the module-managed workdir list. - echo "$${existing}" | jq --argjson module "$${module}" --argjson folders "$${folders}" ' - . as $existing - | ($existing * $module) - | .trustedFolders = ((($existing.trustedFolders // []) + ($module.trustedFolders // []) + $folders) | unique) - ' > "$${config_file}" - echo "Copilot config completed: $${config_file}" + echo "$${existing}" | jq --argjson folders "$${ARG_TRUSTED_FOLDERS}" \ + '.trustedFolders = (((.trustedFolders // []) + $folders) | unique)' > "$${config_file}" + echo "Trusted folders completed: $${config_file}" } -# Merge MCP servers into Copilot's user-level mcp-config.json. The module-provided -# servers win on duplicate names; any other servers already on disk are preserved. +# Merge MCP servers into Copilot's user-level mcp-config.json. Existing servers +# win on duplicate names and module servers only fill in names not already on +# disk. This matches claude-code, whose `claude mcp add-json` errors on an +# existing name at the same scope and keeps the existing registration. setup_mcp_config() { local mcp_config_file="$HOME/.copilot/mcp-config.json" @@ -226,9 +222,9 @@ setup_mcp_config() { fi local custom custom=$(echo "$${ARG_MCP_CONFIG}" | jq '.mcpServers // {}') - # `(existing + $custom)` merges with the right operand winning, so module - # servers override on duplicate names while other on-disk servers are kept. - echo "$${existing}" | jq --argjson custom "$${custom}" '.mcpServers = ((.mcpServers // {}) + $custom)' > "$${mcp_config_file}" + # `($custom + existing)` merges with the right operand winning, so existing + # on-disk servers win on duplicate names and module servers add new ones only. + echo "$${existing}" | jq --argjson custom "$${custom}" '.mcpServers = ($custom + (.mcpServers // {}))' > "$${mcp_config_file}" echo "MCP configuration completed: $${mcp_config_file}" } @@ -242,8 +238,8 @@ configure_copilot_model() { install_copilot setup_workdir -setup_copilot_settings -setup_copilot_config +write_managed_settings +setup_trusted_folders setup_mcp_config configure_copilot_model From a506f013a392192fc6f2832fd37c46a715ff89c4 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sun, 27 Sep 2026 12:02:02 +0000 Subject: [PATCH 10/23] docs(registry/coder-labs/modules/copilot): trim verbose comments --- registry/coder-labs/modules/copilot/main.tf | 8 ++--- .../modules/copilot/scripts/install.sh.tftpl | 29 +++++++------------ 2 files changed, 13 insertions(+), 24 deletions(-) diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index 0708850d1..34f960195 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -117,9 +117,8 @@ resource "coder_env" "github_token" { value = var.github_token } -# Route Copilot's traffic through the AI Gateway Proxy. The pre-migration module -# scoped these to the Copilot process via its start script; with no start script -# they are set at the agent level, so they apply workspace-wide. +# 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 @@ -137,8 +136,7 @@ resource "coder_env" "ai_gateway_node_extra_ca_certs" { locals { workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" - # workdir is trusted automatically; the install script unions it into the - # trustedFolders array in config.json. + # workdir is trusted automatically via config.json trustedFolders. workdir_trusted_folders = local.workdir != "" ? [local.workdir] : [] install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { diff --git a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl index 786c05bb1..c11956167 100644 --- a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl @@ -69,8 +69,7 @@ function ensure_copilot_in_path() { } # Download the official installer to a temp file, validate it, then run it. -# Mirrors the claude-code module's install flow. Run in a subshell so the trap -# that removes the temp file is scoped to this function. +# Subshell scopes the temp-file cleanup trap to this function. install_with_official_installer() ( local installer_file installer_file=$(mktemp) @@ -89,8 +88,7 @@ install_with_official_installer() ( return 1 fi - # VERSION accepts 'latest', a 'vX.Y.Z' tag, or a bare 'X.Y.Z' (normalized by - # the installer). PREFIX installs the binary under $HOME/.local/bin. + # 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 @@ -132,11 +130,9 @@ setup_workdir() { fi } -# Write module-provided policy to Copilot's file-based managed settings. This is -# the highest-precedence Copilot config and only supports policy keys (model, -# permissions, allowedMcpServers, telemetry, sandbox, ...). Copilot rejects this -# file if it is a symlink, not owned by root, or world-writable, so write it as -# root with 0644. Mirrors the claude-code module's managed_settings handling. +# 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 @@ -167,10 +163,8 @@ write_managed_settings() { echo "Wrote Copilot managed settings to $${target}" } -# Trust the workdir by unioning it into config.json trustedFolders, so Copilot -# does not prompt on launch. Only the trustedFolders key is touched; all other -# config.json state (authentication, installed plugins, ...) is preserved. This -# is the Copilot equivalent of claude-code auto-accepting the workdir trust. +# 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" @@ -196,10 +190,8 @@ setup_trusted_folders() { echo "Trusted folders completed: $${config_file}" } -# Merge MCP servers into Copilot's user-level mcp-config.json. Existing servers -# win on duplicate names and module servers only fill in names not already on -# disk. This matches claude-code, whose `claude mcp add-json` errors on an -# existing name at the same scope and keeps the existing registration. +# Merge MCP servers into Copilot's user-level mcp-config.json; existing servers +# on disk win on duplicate names. setup_mcp_config() { local mcp_config_file="$HOME/.copilot/mcp-config.json" @@ -222,8 +214,7 @@ setup_mcp_config() { fi local custom custom=$(echo "$${ARG_MCP_CONFIG}" | jq '.mcpServers // {}') - # `($custom + existing)` merges with the right operand winning, so existing - # on-disk servers win on duplicate names and module servers add new ones only. + # Right operand wins, so existing on-disk servers win on duplicate names. echo "$${existing}" | jq --argjson custom "$${custom}" '.mcpServers = ($custom + (.mcpServers // {}))' > "$${mcp_config_file}" echo "MCP configuration completed: $${mcp_config_file}" } From efe08bf13e2bb4adf67d83159524b4a19fedaf7e Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sun, 27 Sep 2026 16:33:16 +0000 Subject: [PATCH 11/23] fix(registry/coder-labs/modules/copilot): drop invalid 'copilot config model' call 'copilot config' is not a command; the model is set via the COPILOT_MODEL env var. Remove the dead configure_copilot_model function and the now-unused ARG_COPILOT_MODEL script argument. --- registry/coder-labs/modules/copilot/main.tf | 1 - .../modules/copilot/scripts/install.sh.tftpl | 11 ----------- 2 files changed, 12 deletions(-) diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index 34f960195..c15e23c94 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -142,7 +142,6 @@ locals { install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { ARG_INSTALL = tostring(var.install_copilot) ARG_COPILOT_VERSION = var.copilot_version - ARG_COPILOT_MODEL = var.copilot_model 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)) : "" diff --git a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl index c11956167..5a950d3d6 100644 --- a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl @@ -10,7 +10,6 @@ command_exists() { ARG_INSTALL='${ARG_INSTALL}' ARG_COPILOT_VERSION='${ARG_COPILOT_VERSION}' -ARG_COPILOT_MODEL='${ARG_COPILOT_MODEL}' 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) @@ -21,7 +20,6 @@ export PATH="$HOME/.local/bin:$PATH" echo "--------------------------------" printf "install_copilot: %s\n" "$${ARG_INSTALL}" printf "copilot_version: %s\n" "$${ARG_COPILOT_VERSION}" -printf "copilot_model: %s\n" "$${ARG_COPILOT_MODEL}" printf "workdir: %s\n" "$${ARG_WORKDIR}" echo "--------------------------------" @@ -219,19 +217,10 @@ setup_mcp_config() { echo "MCP configuration completed: $${mcp_config_file}" } -configure_copilot_model() { - if [ -n "$${ARG_COPILOT_MODEL}" ]; then - echo "Setting Copilot model to: $${ARG_COPILOT_MODEL}" - copilot config model "$${ARG_COPILOT_MODEL}" 2> /dev/null \ - || echo "Note: could not persist model via 'copilot config'; the COPILOT_MODEL env var is used as a fallback." - fi -} - install_copilot setup_workdir write_managed_settings setup_trusted_folders setup_mcp_config -configure_copilot_model echo "Copilot module setup completed." From b184220ac909f27a9b3634ab759a7945000ca358 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sun, 27 Sep 2026 16:52:30 +0000 Subject: [PATCH 12/23] docs(registry/coder-labs/modules/copilot): use playwright MCP in README example --- registry/coder-labs/modules/copilot/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 64b176761..54ae280dc 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -154,9 +154,9 @@ module "copilot" { # MCP server configuration (merged into ~/.copilot/mcp-config.json) mcp = jsonencode({ mcpServers = { - filesystem = { + playwright = { command = "npx" - args = ["-y", "@modelcontextprotocol/server-filesystem", "/home/coder/project"] + args = ["-y", "@playwright/mcp@latest"] type = "local" tools = ["*"] } @@ -166,7 +166,7 @@ module "copilot" { # Pre-install an MCP server for faster startup pre_install_script = <<-EOT #!/usr/bin/env bash - npm install -g @modelcontextprotocol/server-filesystem + npm install -g @playwright/mcp EOT } ``` From 0891ffe2f097a2a48334db89de75001f4c0cc940 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sun, 27 Sep 2026 17:19:24 +0000 Subject: [PATCH 13/23] docs(registry/coder-labs/modules/copilot): trim README prerequisites and notes --- registry/coder-labs/modules/copilot/README.md | 25 +------------------ 1 file changed, 1 insertion(+), 24 deletions(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 54ae280dc..23780b432 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -20,16 +20,7 @@ module "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). Keep using v0.x if you depend on the embedded web app or task reporting. - -## Prerequisites - -- **[Active Copilot subscription](https://docs.github.com/en/copilot/about-github-copilot/subscription-plans-for-github-copilot)** (GitHub Copilot Pro, Pro+, Business, or Enterprise) -- **`curl`** (or `wget`) available in the workspace for the official install script -- **GitHub authentication** via one of: - - [Coder external authentication](https://coder.com/docs/admin/external-auth) (recommended), fetched at launch in your `coder_app` - - Direct token via the `github_token` variable - - Interactive login in Copilot (`/login`) +> 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). ## Examples @@ -68,11 +59,6 @@ resource "coder_app" "copilot" { } ``` -> [!NOTE] -> Tool permissions (`--allow-all-tools`, `--allow-tool`, `--deny-tool`) and -> session resumption (`--continue`) are session-only Copilot CLI flags, so pass -> them to `copilot` in your `coder_app` command rather than to the module. - ### Direct token authentication Provide a GitHub token instead of using Coder external auth. When set, the module @@ -94,9 +80,6 @@ module "copilot" { } ``` -> [!NOTE] -> OAuth tokens work best with Copilot. Personal Access Tokens may have limited functionality. - ### 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. @@ -171,12 +154,6 @@ module "copilot" { } ``` -> [!NOTE] -> Servers from `mcp` are merged into `~/.copilot/mcp-config.json`, Copilot's documented user-level MCP config. Existing servers already on disk win on duplicate names (matching claude-code's behavior, where `claude mcp add` errors on an existing name and keeps the current one), and module servers only add names not already present. GitHub Copilot CLI does not automatically install MCP servers. Either use `npx -y` in the config (shown above) to auto-install on each run, or pre-install MCP servers in `pre_install_script` for faster startup. - -> [!NOTE] -> Configuration is written to the files Copilot CLI documents for each purpose. `managed_settings` is written verbatim to `/etc/github-copilot/managed-settings.json` — Copilot's highest-precedence policy layer, which supports only a fixed set of keys (`model`, `permissions`, `allowedMcpServers`, `deniedMcpServers`, `telemetry`, `sandbox`, and similar); see the [managed settings reference](https://docs.github.com/en/copilot/reference/copilot-cli-reference/cli-config-dir-reference#mdm-managed-settings). `mcp` is merged into `~/.copilot/mcp-config.json`, and `workdir` is trusted automatically by unioning it into `trustedFolders` in `~/.copilot/config.json`. The module only touches the keys it owns in each file; unrelated on-disk state such as authentication is preserved. - ### Serialize a downstream `coder_script` after the install pipeline The module exposes the `scripts` output: an ordered list of `coder exp sync` From 38ba4972af65873c7876a1c75cd387e668e1501c Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sun, 27 Sep 2026 17:31:44 +0000 Subject: [PATCH 14/23] docs(registry/coder-labs/modules/copilot): use coder_external_auth data source in README --- registry/coder-labs/modules/copilot/README.md | 28 +++++++++---------- 1 file changed, 13 insertions(+), 15 deletions(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 23780b432..30ae4b4e9 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -26,8 +26,10 @@ module "copilot" { ### Launcher app with GitHub external auth -The module installs and configures Copilot. To run it, add a `coder_app` that -fetches a fresh GitHub token at launch and starts Copilot. This assumes you have +The module installs and configures Copilot. Use the +[`coder_external_auth`](https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/external_auth) +data source to fetch a GitHub token and pass it to the module, then add a +`coder_app` to launch Copilot. This assumes you have [Coder external authentication](https://coder.com/docs/admin/external-auth) configured with `id = "github"`. @@ -36,11 +38,16 @@ locals { copilot_workdir = "/home/coder/project" } +data "coder_external_auth" "github" { + id = "github" +} + module "copilot" { - source = "registry.coder.com/coder-labs/copilot/coder" - version = "1.0.0" - agent_id = coder_agent.example.id - workdir = local.copilot_workdir + 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 } resource "coder_app" "copilot" { @@ -52,7 +59,6 @@ resource "coder_app" "copilot" { command = <<-EOT #!/usr/bin/env bash set -e - token="$(coder external-auth access-token github)" && export COPILOT_GITHUB_TOKEN="$token" cd "${local.copilot_workdir}" exec copilot --allow-all-tools EOT @@ -184,14 +190,6 @@ resource "coder_script" "post_copilot" { } ``` -## Authentication - -The module supports multiple GitHub authentication methods: - -1. **[Coder External Auth](https://coder.com/docs/admin/external-auth) (Recommended)** - Fetch a fresh token at launch in your `coder_app` command with `coder external-auth access-token `. -2. **Direct Token** - Pass the `github_token` variable (OAuth or Personal Access Token). Exported as `COPILOT_GITHUB_TOKEN`. -3. **Interactive** - Copilot prompts for login via the `/login` command if no auth is found. - ## Troubleshooting Check the log files in `~/.coder-modules/coder-labs/copilot/logs/` for detailed information. From c361d36f1dbedfee66c7e8ac5b366fccadd39018 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sun, 27 Sep 2026 17:55:04 +0000 Subject: [PATCH 15/23] docs(registry/coder-labs/modules/copilot): drop proxy-scoping note, bump example version --- registry/coder-labs/modules/copilot/README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 30ae4b4e9..e4bbca6b6 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -114,9 +114,6 @@ When `enable_ai_gateway = true`, the module sets `HTTPS_PROXY` and `NODE_EXTRA_C > [!NOTE] > AI Gateway Proxy is a Premium Coder feature that requires the [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance). See the [setup guide](https://coder.com/docs/ai-coder/ai-gateway/ai-gateway-proxy/setup) for configuring the proxy on your deployment. GitHub authentication is still required; the proxy does not replace it. -> [!IMPORTANT] -> Unlike the pre-`v1` module (which scoped the proxy to the Copilot process via its start script), these variables are set at the agent level and therefore apply workspace-wide. Ensure the `aibridge-proxy` module completes before Copilot is launched so the CA certificate exists. For strict process-scoping, set `HTTPS_PROXY`/`NODE_EXTRA_CA_CERTS` in your own launcher `coder_app` instead. - ### Advanced configuration Customize MCP servers, policy, and Copilot settings: @@ -129,7 +126,7 @@ module "copilot" { workdir = "/home/coder/project" # Version pinning (defaults to "latest") - copilot_version = "0.0.334" + copilot_version = "1.0.88" # Policy written to /etc/github-copilot/managed-settings.json (highest precedence). # Only supported keys apply (model, permissions, allowedMcpServers, telemetry, sandbox, ...). From f5db206ff55ff52969cedd53d2d6977d4f0def7c Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Sun, 27 Sep 2026 17:59:25 +0000 Subject: [PATCH 16/23] refactor(registry/coder-labs/modules/copilot): default copilot_model to empty --- registry/coder-labs/modules/copilot/main.tf | 4 ++-- .../coder-labs/modules/copilot/main.tftest.hcl | 15 +++++++-------- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index c15e23c94..b7e2f2562 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -34,8 +34,8 @@ variable "github_token" { 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" + 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 "managed_settings" { diff --git a/registry/coder-labs/modules/copilot/main.tftest.hcl b/registry/coder-labs/modules/copilot/main.tftest.hcl index 269f9acbc..531e63c5c 100644 --- a/registry/coder-labs/modules/copilot/main.tftest.hcl +++ b/registry/coder-labs/modules/copilot/main.tftest.hcl @@ -7,8 +7,8 @@ run "defaults_are_correct" { } assert { - condition = var.copilot_model == "claude-sonnet-4.5" - error_message = "Default model should be 'claude-sonnet-4.5'" + condition = var.copilot_model == "" + error_message = "copilot_model should default to an empty string" } assert { @@ -67,18 +67,17 @@ run "copilot_model_env_var_uses_given_model" { } } -run "copilot_model_env_var_is_always_set" { +run "copilot_model_env_var_not_created_when_empty" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder" - copilot_model = "claude-sonnet-4.5" + agent_id = "test-agent" + workdir = "/home/coder" } assert { - condition = coder_env.copilot_model[0].name == "COPILOT_MODEL" && coder_env.copilot_model[0].value == "claude-sonnet-4.5" - error_message = "COPILOT_MODEL env var should be set to the model as given, including the default" + condition = length(coder_env.copilot_model) == 0 + error_message = "COPILOT_MODEL env var should not be created when copilot_model is empty" } } From 1874ad9a59d7820601be0100b235536d1ff3a646 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Mon, 28 Sep 2026 16:40:32 +0000 Subject: [PATCH 17/23] fix(registry/coder-labs/modules/copilot): apply review suggestions Use /icon/github-copilot.svg default and rename ai_gateway_auth_url to ai_gateway_proxy_auth_url and ai_gateway_cert_path to ai_gateway_proxy_cert_path. --- registry/coder-labs/modules/copilot/README.md | 16 +++++----- registry/coder-labs/modules/copilot/main.tf | 18 +++++------ .../modules/copilot/main.tftest.hcl | 30 +++++++++---------- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index e4bbca6b6..75a331ed5 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -54,7 +54,7 @@ resource "coder_app" "copilot" { agent_id = coder_agent.example.id slug = "copilot" display_name = "Copilot" - icon = "/icon/github.svg" + icon = "/icon/github-copilot.svg" open_in = "slim-window" command = <<-EOT #!/usr/bin/env bash @@ -99,13 +99,13 @@ module "aibridge-proxy" { } module "copilot" { - 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_auth_url = module.aibridge-proxy.proxy_auth_url - ai_gateway_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 } ``` diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index b7e2f2562..c8f1bae23 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -16,7 +16,7 @@ variable "agent_id" { variable "icon" { type = string description = "The icon to use for the app." - default = "/icon/github.svg" + default = "/icon/github-copilot.svg" } variable "workdir" { @@ -80,24 +80,24 @@ variable "enable_ai_gateway" { default = false validation { - condition = !var.enable_ai_gateway || (var.ai_gateway_auth_url != null && length(var.ai_gateway_auth_url) > 0) - error_message = "ai_gateway_auth_url is required when enable_ai_gateway 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_ai_gateway || (var.ai_gateway_cert_path != null && length(var.ai_gateway_cert_path) > 0) - error_message = "ai_gateway_cert_path is required when enable_ai_gateway 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 "ai_gateway_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 "ai_gateway_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 @@ -123,14 +123,14 @@ 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_auth_url + 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_cert_path + value = var.ai_gateway_proxy_cert_path } locals { diff --git a/registry/coder-labs/modules/copilot/main.tftest.hcl b/registry/coder-labs/modules/copilot/main.tftest.hcl index 531e63c5c..24a43df71 100644 --- a/registry/coder-labs/modules/copilot/main.tftest.hcl +++ b/registry/coder-labs/modules/copilot/main.tftest.hcl @@ -166,21 +166,21 @@ run "ai_gateway_enabled_sets_proxy_env" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder" - enable_ai_gateway = true - ai_gateway_auth_url = "https://coder:mock-token@aiproxy.example.com" - ai_gateway_cert_path = "/tmp/aibridge-proxy/ca-cert.pem" + 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_auth_url" + 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_cert_path" + error_message = "NODE_EXTRA_CA_CERTS should be set to ai_gateway_proxy_cert_path" } } @@ -188,10 +188,10 @@ run "ai_gateway_requires_auth_url" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder" - enable_ai_gateway = true - ai_gateway_cert_path = "/tmp/aibridge-proxy/ca-cert.pem" + agent_id = "test-agent" + workdir = "/home/coder" + enable_ai_gateway = true + ai_gateway_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem" } expect_failures = [ @@ -203,10 +203,10 @@ run "ai_gateway_requires_cert_path" { command = plan variables { - agent_id = "test-agent" - workdir = "/home/coder" - enable_ai_gateway = true - ai_gateway_auth_url = "https://coder:mock-token@aiproxy.example.com" + 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 = [ From 37b66735cdf33736239561737f00942c275b40eb Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Wed, 14 Oct 2026 11:19:26 +0000 Subject: [PATCH 18/23] feat(registry/coder-labs/modules/copilot): add base_copilot_config Restore copilot_config as base_copilot_config. When set it overwrites ~/.copilot/settings.json, the current user settings file, on every start. MCP servers stay in mcp-config.json. --- registry/coder-labs/modules/copilot/README.md | 7 ++++++- .../coder-labs/modules/copilot/main.test.ts | 18 ++++++++++++++++++ registry/coder-labs/modules/copilot/main.tf | 7 +++++++ .../modules/copilot/scripts/install.sh.tftpl | 14 ++++++++++++++ 4 files changed, 45 insertions(+), 1 deletion(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 75a331ed5..7753615ad 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -20,7 +20,7 @@ module "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). +> 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 @@ -137,6 +137,11 @@ module "copilot" { } } + # Overwrites ~/.copilot/settings.json on every start. + base_copilot_config = jsonencode({ + banner = "never" + }) + # MCP server configuration (merged into ~/.copilot/mcp-config.json) mcp = jsonencode({ mcpServers = { diff --git a/registry/coder-labs/modules/copilot/main.test.ts b/registry/coder-labs/modules/copilot/main.test.ts index cbc951002..70155e298 100644 --- a/registry/coder-labs/modules/copilot/main.test.ts +++ b/registry/coder-labs/modules/copilot/main.test.ts @@ -232,6 +232,24 @@ describe("copilot", async () => { expect(config.loggedInUsers).toContain("octocat"); }); + 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(settings).toEqual({ banner: "never" }); + }); + test("managed-settings-written", async () => { const { id, scripts } = await setup({ moduleVariables: { diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index c8f1bae23..22d516e0f 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -38,6 +38,12 @@ variable "copilot_model" { default = "" } +variable "base_copilot_config" { + type = string + 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 "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" @@ -145,6 +151,7 @@ locals { 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) : "" }) diff --git a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl index 5a950d3d6..5ac568cc3 100644 --- a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl @@ -13,6 +13,7 @@ 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" @@ -161,6 +162,18 @@ write_managed_settings() { 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 "Copilot settings written: $${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() { @@ -220,6 +233,7 @@ setup_mcp_config() { install_copilot setup_workdir write_managed_settings +write_base_config setup_trusted_folders setup_mcp_config From 9c0d5e09168b68f86b7dca64b4fafd7d93a6f902 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Wed, 14 Oct 2026 11:40:04 +0000 Subject: [PATCH 19/23] refactor(registry/coder-labs/modules/copilot): simplify base config and mcp handling --- .../modules/copilot/scripts/install.sh.tftpl | 29 +++++++------------ 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl index 5ac568cc3..355df22e1 100644 --- a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl @@ -164,14 +164,12 @@ write_managed_settings() { 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 "Copilot settings written: $${settings_file}" + echo "Wrote Copilot settings to $${settings_file}" } # Trust the workdir by unioning it into config.json trustedFolders. Only that @@ -201,33 +199,26 @@ setup_trusted_folders() { echo "Trusted folders completed: $${config_file}" } -# Merge MCP servers into Copilot's user-level mcp-config.json; existing servers -# on disk win on duplicate names. +# 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 - echo "No custom MCP servers configured; leaving $${mcp_config_file} unchanged." 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}")" - echo "Merging MCP server configuration into $${mcp_config_file}" - - local existing='{"mcpServers": {}}' - if [ -f "$${mcp_config_file}" ]; then - existing=$(cat "$${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 - local custom - custom=$(echo "$${ARG_MCP_CONFIG}" | jq '.mcpServers // {}') - # Right operand wins, so existing on-disk servers win on duplicate names. - echo "$${existing}" | jq --argjson custom "$${custom}" '.mcpServers = ($custom + (.mcpServers // {}))' > "$${mcp_config_file}" - echo "MCP configuration completed: $${mcp_config_file}" + 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 From 6c7793881aac6d7ba5f8141d1e088d9ee36938d5 Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Wed, 14 Oct 2026 11:54:24 +0000 Subject: [PATCH 20/23] docs(registry/coder-labs/modules/copilot): trim README to examples and doc links --- registry/coder-labs/modules/copilot/README.md | 27 +++---------------- 1 file changed, 4 insertions(+), 23 deletions(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 7753615ad..4fbdb5167 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -8,7 +8,7 @@ tags: [agent, copilot, ai, github, ai-gateway] # Copilot -Install and configure the [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. +Install and configure the [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-copilot-cli) in your workspace. ```tf module "copilot" { @@ -26,12 +26,7 @@ module "copilot" { ### Launcher app with GitHub external auth -The module installs and configures Copilot. Use the -[`coder_external_auth`](https://registry.terraform.io/providers/coder/coder/latest/docs/data-sources/external_auth) -data source to fetch a GitHub token and pass it to the module, then add a -`coder_app` to launch Copilot. This assumes you have -[Coder external authentication](https://coder.com/docs/admin/external-auth) -configured with `id = "github"`. +This assumes [Coder external authentication](https://coder.com/docs/admin/external-auth) is configured with `id = "github"`. ```tf locals { @@ -67,8 +62,7 @@ resource "coder_app" "copilot" { ### Direct token authentication -Provide a GitHub token instead of using Coder external auth. When set, the module -exports it to the workspace as `COPILOT_GITHUB_TOKEN`. +`github_token` is exported as `COPILOT_GITHUB_TOKEN`. ```tf variable "github_token" { @@ -109,11 +103,6 @@ module "copilot" { } ``` -When `enable_ai_gateway = true`, the module sets `HTTPS_PROXY` and `NODE_EXTRA_CA_CERTS` as workspace environment variables so Copilot routes through the proxy. - -> [!NOTE] -> AI Gateway Proxy is a Premium Coder feature that requires the [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance). See the [setup guide](https://coder.com/docs/ai-coder/ai-gateway/ai-gateway-proxy/setup) for configuring the proxy on your deployment. GitHub authentication is still required; the proxy does not replace it. - ### Advanced configuration Customize MCP servers, policy, and Copilot settings: @@ -125,11 +114,8 @@ module "copilot" { agent_id = coder_agent.example.id workdir = "/home/coder/project" - # Version pinning (defaults to "latest") copilot_version = "1.0.88" - # Policy written to /etc/github-copilot/managed-settings.json (highest precedence). - # Only supported keys apply (model, permissions, allowedMcpServers, telemetry, sandbox, ...). managed_settings = { model = "claude-sonnet-4.5" permissions = { @@ -137,12 +123,10 @@ module "copilot" { } } - # Overwrites ~/.copilot/settings.json on every start. base_copilot_config = jsonencode({ banner = "never" }) - # MCP server configuration (merged into ~/.copilot/mcp-config.json) mcp = jsonencode({ mcpServers = { playwright = { @@ -154,7 +138,6 @@ module "copilot" { } }) - # Pre-install an MCP server for faster startup pre_install_script = <<-EOT #!/usr/bin/env bash npm install -g @playwright/mcp @@ -164,9 +147,7 @@ module "copilot" { ### Serialize a downstream `coder_script` after the install pipeline -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. +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. ```tf module "copilot" { From f418e16b6e5b43d24076bfc3387831f374d92ecd Mon Sep 17 00:00:00 2001 From: 35C4n0r Date: Wed, 14 Oct 2026 11:58:29 +0000 Subject: [PATCH 21/23] docs(registry/coder-labs/modules/copilot): use heredoc JSON in README examples --- registry/coder-labs/modules/copilot/README.md | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 4fbdb5167..e2bc028f0 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -123,20 +123,24 @@ module "copilot" { } } - base_copilot_config = jsonencode({ - banner = "never" - }) - - mcp = jsonencode({ - mcpServers = { - playwright = { - command = "npx" - args = ["-y", "@playwright/mcp@latest"] - type = "local" - tools = ["*"] + base_copilot_config = <<-EOF + { + "banner": "never" + } + EOF + + mcp = <<-EOF + { + "mcpServers": { + "playwright": { + "command": "npx", + "args": ["-y", "@playwright/mcp@latest"], + "type": "local", + "tools": ["*"] } } - }) + } + EOF pre_install_script = <<-EOT #!/usr/bin/env bash From a918633fb9b876e4f32d4282417c554f4dc92eca Mon Sep 17 00:00:00 2001 From: DevelopmentCats Date: Wed, 14 Oct 2026 14:57:02 -0500 Subject: [PATCH 22/23] docs(registry/coder-labs/modules/copilot): describe pre/post scripts as configuring Copilot install_copilot can be false, in which case the module only configures Copilot. --- registry/coder-labs/modules/copilot/main.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index 22d516e0f..626ae598c 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -70,13 +70,13 @@ variable "install_copilot" { variable "pre_install_script" { type = string - description = "Custom script to run before installing Copilot." + description = "Custom script to run before configuring Copilot." default = null } variable "post_install_script" { type = string - description = "Custom script to run after installing Copilot." + description = "Custom script to run after configuring Copilot." default = null } From 5b4c23a23639f77ea72a807e2c9464218618f9bd Mon Sep 17 00:00:00 2001 From: DevelopmentCats Date: Wed, 14 Oct 2026 15:19:38 -0500 Subject: [PATCH 23/23] fix(registry/coder-labs/modules/copilot): preserve config.json when trusting workdir Copilot writes config.json with a // comment header, which jq rejected, so the script fell back to {} and overwrote auth and plugin state on every start. Strip comments before parsing, skip when the workdir is already trusted or the file cannot be parsed, and write atomically with mode 0600. --- .../coder-labs/modules/copilot/main.test.ts | 5 ++-- .../modules/copilot/scripts/install.sh.tftpl | 23 +++++++++++++++---- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/registry/coder-labs/modules/copilot/main.test.ts b/registry/coder-labs/modules/copilot/main.test.ts index 70155e298..5fbea2f92 100644 --- a/registry/coder-labs/modules/copilot/main.test.ts +++ b/registry/coder-labs/modules/copilot/main.test.ts @@ -213,7 +213,8 @@ describe("copilot", async () => { test("trusted-folders-union-preserves-existing-state", async () => { const { id, scripts } = await setup(); - // Seed config.json with an interactively trusted folder and auth-like state. + // 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"], @@ -221,7 +222,7 @@ describe("copilot", async () => { await execContainer(id, [ "bash", "-c", - `mkdir -p /home/coder/.copilot && cat > /home/coder/.copilot/config.json <<'JSON'\n${configSeed}\nJSON`, + `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)); diff --git a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl index 355df22e1..8aea8e5e8 100644 --- a/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/copilot/scripts/install.sh.tftpl @@ -188,14 +188,29 @@ setup_trusted_folders() { fi mkdir -p "$(dirname "$${config_file}")" - echo "Trusting workdir in $${config_file}" + # Copilot writes config.json with a // comment header, which jq rejects. local existing='{}' - if [ -f "$${config_file}" ] && jq empty "$${config_file}" > /dev/null 2>&1; then - existing=$(cat "$${config_file}") + 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)' > "$${config_file}" + '.trustedFolders = (((.trustedFolders // []) + $folders) | unique)' > "$${tmp}" + mv "$${tmp}" "$${config_file}" echo "Trusted folders completed: $${config_file}" }