From 4688e4c1a7f8c68271e525150ccfb4d652e76fee Mon Sep 17 00:00:00 2001 From: "blinkagent[bot]" <237617714+blinkagent[bot]@users.noreply.github.com> Date: Mon, 4 May 2026 15:17:50 -0500 Subject: [PATCH 1/7] fix(filebrowser): require agent_name when subdomain is false (#877) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Fixes [REG-4](https://linear.app/codercom/issue/REG-4/filebrowser-appends-workspace-path-twice-in-url): the `filebrowser` module opens to a non-existent URL with the workspace path appended a second time when `subdomain = false` and `agent_name` is not provided, e.g.: ``` https:///@//apps/filebrowser/files/@/./apps/filebrowser/ ``` ### Root cause Coder's frontend always builds path-based app URLs as `/@/./apps//` (it always includes `.`, even for single-agent templates): https://github.com/coder/coder/blob/main/site/src/modules/apps/apps.ts ```ts return `${path}/@${workspace.owner_name}/${workspace.name}.${agent.name}/apps/${app.slug}/`; ``` The filebrowser module, however, only includes the agent segment in `local.server_base_path` (which becomes filebrowser's `--baseURL`) when the user explicitly passes `agent_name`. The variable description and the README both said `agent_name` was "only required if the template uses multiple agents", which is incorrect. When the URLs disagree, filebrowser's reverse-proxy `stripPrefix` cannot strip the prefix, the path falls through filebrowser's `/:catchAll(.*)*` Vue route, and the router redirects to `/files/${catchAll}` — producing the duplicated path the user reported. ### Fix - Add a `lifecycle.precondition` on `coder_script.filebrowser` that fails `terraform apply` with a clear, actionable error when `subdomain = false` and `agent_name == null`. - Update the `agent_name` variable description to state it is required whenever `subdomain` is `false`. - Update the `README.md` example for the path-based config to call out the requirement explicitly. - Bump the module version from `1.1.4` → `1.1.5`. - Add a TS test covering the new precondition. This avoids the silent misconfiguration that produces the duplicated URL, without breaking anyone whose existing template already sets `agent_name` (or uses `subdomain = true`). ## Type of Change - [ ] New module - [ ] New template - [x] Bug fix - [ ] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information **Path:** `registry/coder/modules/filebrowser` **New version:** `v1.1.5` **Breaking change:** [ ] Yes [x] No ## Testing & Validation - [x] `bun test main.test.ts` — 8 pass, 0 fail (includes new precondition test) - [x] `terraform fmt -recursive` - [x] `terraform validate` - [x] `bun x prettier --check` - [x] Manually verified the precondition fires with a minimal repro and passes when `agent_name` is supplied or `subdomain = true`. ## Related Issues - Linear: [REG-4](https://linear.app/codercom/issue/REG-4/filebrowser-appends-workspace-path-twice-in-url) --- Created on behalf of @matifali. Generated with Blink. Co-authored-by: blink-so[bot] <211532188+blink-so[bot]@users.noreply.github.com> Co-authored-by: DevCats --- registry/coder/modules/filebrowser/README.md | 10 ++++++---- registry/coder/modules/filebrowser/main.test.ts | 15 +++++++++++++++ registry/coder/modules/filebrowser/main.tf | 9 ++++++++- 3 files changed, 29 insertions(+), 5 deletions(-) diff --git a/registry/coder/modules/filebrowser/README.md b/registry/coder/modules/filebrowser/README.md index 80ab3e85..ab668537 100644 --- a/registry/coder/modules/filebrowser/README.md +++ b/registry/coder/modules/filebrowser/README.md @@ -14,7 +14,7 @@ A file browser for your workspace. module "filebrowser" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/filebrowser/coder" - version = "1.1.4" + version = "1.1.5" agent_id = coder_agent.main.id } ``` @@ -29,7 +29,7 @@ module "filebrowser" { module "filebrowser" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/filebrowser/coder" - version = "1.1.4" + version = "1.1.5" agent_id = coder_agent.main.id folder = "/home/coder/project" } @@ -41,7 +41,7 @@ module "filebrowser" { module "filebrowser" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/filebrowser/coder" - version = "1.1.4" + version = "1.1.5" agent_id = coder_agent.main.id database_path = ".config/filebrowser.db" } @@ -49,11 +49,13 @@ module "filebrowser" { ### Serve from the same domain (no subdomain) +When `subdomain = false`, you must also set `agent_name` to the name of your `coder_agent` resource. Coder serves path-based apps at `/@/./apps//`, so the agent name is required to build a base URL that matches the URL the user is actually browsing. If `agent_name` is omitted in this mode, `terraform apply` will fail with an explanatory error. + ```tf module "filebrowser" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/filebrowser/coder" - version = "1.1.4" + version = "1.1.5" agent_id = coder_agent.main.id agent_name = "main" subdomain = false diff --git a/registry/coder/modules/filebrowser/main.test.ts b/registry/coder/modules/filebrowser/main.test.ts index 1d925c35..56beb998 100644 --- a/registry/coder/modules/filebrowser/main.test.ts +++ b/registry/coder/modules/filebrowser/main.test.ts @@ -102,4 +102,19 @@ describe("filebrowser", async () => { testBaseLine(output); }, 15000); + + it("fails when subdomain=false and agent_name is not provided", async () => { + let caught: Error | undefined; + try { + await runTerraformApply(import.meta.dir, { + agent_id: "foo", + subdomain: false, + }); + } catch (e) { + caught = e as Error; + } + expect(caught).toBeDefined(); + expect(caught!.message).toContain("agent_name"); + expect(caught!.message).toContain("subdomain"); + }, 15000); }); diff --git a/registry/coder/modules/filebrowser/main.tf b/registry/coder/modules/filebrowser/main.tf index 498682dd..e5285d0a 100644 --- a/registry/coder/modules/filebrowser/main.tf +++ b/registry/coder/modules/filebrowser/main.tf @@ -20,7 +20,7 @@ data "coder_workspace_owner" "me" {} variable "agent_name" { type = string - description = "The name of the coder_agent resource. (Only required if subdomain is false and the template uses multiple agents.)" + description = "The name of the coder_agent resource. Required when `subdomain` is `false` so the path-based base URL matches the URL Coder serves." default = null } @@ -102,6 +102,13 @@ resource "coder_script" "filebrowser" { SERVER_BASE_PATH : local.server_base_path }) run_on_start = true + + lifecycle { + precondition { + condition = var.subdomain || var.agent_name != null + error_message = "`agent_name` is required when `subdomain` is `false`. Coder always builds path-based app URLs as `/@/./apps//`, so the filebrowser base URL must include the agent name to match. Set `agent_name = \"\"` (e.g. `\"main\"`)." + } + } } resource "coder_app" "filebrowser" { From c4661ae3655c4faf5994e20732997f2488de945c Mon Sep 17 00:00:00 2001 From: 35C4n0r <70096901+35C4n0r@users.noreply.github.com> Date: Tue, 5 May 2026 20:40:34 +0530 Subject: [PATCH 2/7] refactor(registry/coder-labs/modules/codex)!: remove agentapi, tasks and start logic (#879) Closes #878 ## What Major refactor of the `coder-labs/codex` module to mirror the `coder/claude-code` v5 changes from #861. ## Changes ### Structural - Replace `module "agentapi"` with `module "coder_utils"` (`registry.coder.com/coder/coder-utils/coder v0.0.1`) - Replace `scripts/install.sh` with `scripts/install.sh.tftpl` (Terraform templatefile) - Delete `scripts/start.sh` - Module dir changed from `.codex-module` to `.coder-modules/coder-labs/codex` - Output changed from `task_app_id` to `scripts` (ordered list of coder exp sync names) - Extracted shared test helpers (`collectScripts`, `runScripts`) into `agentapi/coder-utils-test-helpers.ts` ### Removed variables All AgentAPI pass-throughs, boundary, and start-script-only variables: `order`, `group`, `report_tasks`, `subdomain`, `cli_app`, `web_app_display_name`, `cli_app_display_name`, `install_agentapi`, `agentapi_version`, `ai_prompt`, `continue`, `enable_state_persistence`, `codex_system_prompt`, `enable_boundary`, `boundary_config_path`, `boundary_version`, `compile_boundary_from_source`, `use_boundary_directly`, `codex_model` ### Retained `install_codex` (toggle for skipping npm install when CLI is pre-installed) ### Renamed - `enable_aibridge` -> `enable_ai_gateway` ### Changed - `workdir`: now optional (`default = null`) - `openai_api_key`: conditional env var with `count`, marked `sensitive = true` - `base_config_toml`: heredoc description documenting generated defaults; notes that `model_reasoning_effort` and workdir trust are only applied in default config - Default `config.toml`: stripped `sandbox_mode`, `approval_policy`, `sandbox_workspace_write`, `notice.model_migrations` - Install script: removed Node.js/NVM bootstrap (assumes npm pre-installed), sources NVM if present, fails with actionable error if npm missing - `ARG_CODEX_VERSION` and `ARG_WORKDIR` base64-encoded to prevent shell/TOML injection - Duplicate `[model_providers.aibridge]` guarded with grep before appending - Debug header uses user-facing variable names ### Tests - Terraform: 11 pass - Bun: 15 pass (rewritten to shared `collectScripts`/`runScripts` pattern) - Added: `model-reasoning-effort-standalone`, `ai-gateway-with-custom-base-config`, `ai-gateway-custom-config-no-duplicate-provider`, `install-codex-latest`, `workdir-trusted-project`, `no-workdir-no-project-section` - Negative assertions on `minimal-default-config` ### Docs - Migration guide (v4 to v5) in README - Quoted path in coder_app example - AI Gateway note about custom `base_config_toml` requiring manual `model_provider` > [!WARNING] > Breaking change. Drops support for Coder Tasks and Boundary. Keep using v4.x.x if you depend on them. --- *This PR was authored by Coder Agents.* --------- Co-authored-by: Jay Kumar Co-authored-by: DevCats --- registry/coder-labs/modules/codex/README.md | 226 +++--- .../coder-labs/modules/codex/main.test.ts | 727 ++++++++---------- registry/coder-labs/modules/codex/main.tf | 313 +++----- .../coder-labs/modules/codex/main.tftest.hcl | 344 ++++----- .../modules/codex/scripts/install.sh | 228 ------ .../modules/codex/scripts/install.sh.tftpl | 195 +++++ .../coder-labs/modules/codex/scripts/start.sh | 229 ------ .../modules/codex/testdata/codex-mock.sh | 33 +- 8 files changed, 869 insertions(+), 1426 deletions(-) delete mode 100644 registry/coder-labs/modules/codex/scripts/install.sh create mode 100644 registry/coder-labs/modules/codex/scripts/install.sh.tftpl delete mode 100644 registry/coder-labs/modules/codex/scripts/start.sh diff --git a/registry/coder-labs/modules/codex/README.md b/registry/coder-labs/modules/codex/README.md index 16786d02..e524e632 100644 --- a/registry/coder-labs/modules/codex/README.md +++ b/registry/coder-labs/modules/codex/README.md @@ -1,148 +1,106 @@ --- display_name: Codex CLI icon: ../../../../.icons/openai.svg -description: Run Codex CLI in your workspace with AgentAPI integration +description: Install and configure the Codex CLI in your workspace. verified: true -tags: [agent, codex, ai, openai, tasks, aibridge] +tags: [agent, codex, ai, openai, ai-gateway] --- # Codex CLI -Run Codex CLI in your workspace to access OpenAI's models through the Codex interface, with custom pre/post install scripts. This module integrates with [AgentAPI](https://github.com/coder/agentapi) for Coder Tasks compatibility. +Install and configure the [Codex CLI](https://github.com/openai/codex) in your workspace. ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.3.1" - agent_id = coder_agent.example.id + version = "5.0.0" + agent_id = coder_agent.main.id openai_api_key = var.openai_api_key - workdir = "/home/coder/project" } ``` -## Prerequisites - -- OpenAI API key for Codex access +> [!WARNING] +> If upgrading from v4.x.x of this module: v5 is a major refactor that drops support for [Coder Tasks](https://coder.com/docs/ai-coder/tasks) and [Boundary](https://coder.com/docs/ai-coder/agent-firewall). v5 also assumes npm is pre-installed; it no longer bootstraps Node.js. Keep using v4.x.x if you depend on them. See the [PR description](https://github.com/coder/registry/pull/879) for a full migration guide. ## Examples -### Run standalone +### Standalone mode with a launcher app ```tf -module "codex" { - count = data.coder_workspace.me.start_count - source = "registry.coder.com/coder-labs/codex/coder" - version = "4.3.1" - agent_id = coder_agent.example.id - openai_api_key = "..." - workdir = "/home/coder/project" - report_tasks = false +locals { + codex_workdir = "/home/coder/project" } -``` - -### Usage with AI Bridge - -[AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) is a Premium Coder feature that provides centralized LLM proxy management. To use AI Bridge, set `enable_aibridge = true`. Requires Coder version 2.30+ - -For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage with Tasks](#usage-with-tasks) example below. - -#### Standalone usage with AI Bridge - -```tf -module "codex" { - source = "registry.coder.com/coder-labs/codex/coder" - version = "4.3.1" - agent_id = coder_agent.example.id - workdir = "/home/coder/project" - enable_aibridge = true -} -``` - -When `enable_aibridge = true`, the module: - -- Configures Codex to use the aibridge model_provider with `base_url` pointing to `${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1` and `env_key` pointing to the workspace owner's session token - -```toml -model_provider = "aibridge" - -[model_providers.aibridge] -name = "AI Bridge" -base_url = "https://example.coder.com/api/v2/aibridge/openai/v1" -env_key = "CODER_AIBRIDGE_SESSION_TOKEN" -wire_api = "responses" -``` - -This allows Codex to route API requests through Coder's AI Bridge instead of directly to OpenAI's API. -Template build will fail if `openai_api_key` is provided alongside `enable_aibridge = true`. - -### Usage with Tasks - -This example shows how to configure Codex with Coder tasks. - -```tf -resource "coder_ai_task" "task" { - count = data.coder_workspace.me.start_count - app_id = module.codex.task_app_id -} - -data "coder_task" "me" {} module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.3.1" - agent_id = coder_agent.example.id - openai_api_key = "..." - ai_prompt = data.coder_task.me.prompt - workdir = "/home/coder/project" - - # Optional: route through AI Bridge (Premium feature) - # enable_aibridge = true + version = "5.0.0" + agent_id = coder_agent.main.id + workdir = local.codex_workdir + openai_api_key = var.openai_api_key } -``` -### Usage with Agent Boundaries - -This example shows how to configure the Codex module to run the agent behind a process-level boundary that restricts its network access. - -By default, when `enable_boundary = true`, the module uses `coder boundary` subcommand (provided by Coder) without requiring any installation. - -```tf -module "codex" { - source = "registry.coder.com/coder-labs/codex/coder" - version = "4.3.1" - agent_id = coder_agent.main.id - openai_api_key = var.openai_api_key - workdir = "/home/coder/project" - enable_boundary = true +resource "coder_app" "codex" { + agent_id = coder_agent.main.id + slug = "codex" + display_name = "Codex" + icon = "/icon/openai.svg" + open_in = "slim-window" + command = <<-EOT + #!/bin/bash + set -e + cd "${local.codex_workdir}" + codex + EOT } ``` > [!NOTE] -> For developers: The module also supports installing boundary from a release version (`use_boundary_directly = true`) or compiling from source (`compile_boundary_from_source = true`). These are escape hatches for development and testing purposes. +> The `coder_app` command re-executes on every pane reconnect. This works for interactive `codex` (which stays alive), but one-shot commands like `codex exec` will re-run each time. For one-shot prompts, use a `coder_script` (runs once at startup) and a `coder_app` that attaches to the existing session (e.g. via tmux/screen). + +### 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. Requires Coder >= 2.30.0. + +```tf +module "codex" { + source = "registry.coder.com/coder-labs/codex/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + enable_ai_gateway = true +} +``` + +When `enable_ai_gateway = true`, the module configures Codex to use the `aigateway` model provider in `config.toml` with the workspace owner's session token for authentication. + +> [!CAUTION] +> `enable_ai_gateway = true` is mutually exclusive with `openai_api_key`. Setting both fails at plan time. + +> [!NOTE] +> If you provide a custom `base_config_toml`, the module writes it verbatim and does not inject `model_provider = "aigateway"` automatically. Add it to your config yourself: +> +> ```toml +> model_provider = "aigateway" +> ``` ### Advanced Configuration -This example shows additional configuration options for custom models, MCP servers, and base configuration. - ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.3.1" - agent_id = coder_agent.example.id - openai_api_key = "..." + version = "5.0.0" + agent_id = coder_agent.main.id workdir = "/home/coder/project" + openai_api_key = var.openai_api_key - codex_version = "0.1.0" # Pin to a specific version - codex_model = "gpt-4o" # Custom model + codex_version = "0.128.0" - # Override default configuration base_config_toml = <<-EOT sandbox_mode = "danger-full-access" approval_policy = "never" preferred_auth_method = "apikey" EOT - # Add extra MCP servers additional_mcp_servers = <<-EOT [mcp_servers.GitHub] command = "npx" @@ -152,61 +110,49 @@ module "codex" { } ``` -> [!WARNING] -> This module configures Codex with a `workspace-write` sandbox that allows AI tasks to read/write files in the specified workdir. While the sandbox provides security boundaries, Codex can still modify files within the workspace. Use this module _only_ in trusted environments and be aware of the security implications. +### Serialize a downstream `coder_script` after the install pipeline -## How it Works - -- **Install**: The module installs Codex CLI and sets up the environment -- **System Prompt**: If `codex_system_prompt` is set, writes the prompt to `AGENTS.md` in the `~/.codex/` directory -- **Start**: Launches Codex CLI in the specified directory, wrapped by AgentAPI -- **Configuration**: Sets `OPENAI_API_KEY` environment variable and passes `--model` flag to Codex CLI (if variables provided) -- **Session Continuity**: When `continue = true` (default), the module automatically tracks task sessions in `~/.codex-module/.codex-task-session`. On workspace restart, it resumes the existing session with full conversation history. Set `continue = false` to always start fresh sessions. - -## State Persistence - -AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Codex CLI session) by also preserving the AgentAPI-level context. Enabled by default, requires agentapi >= v0.12.0 (older versions skip it with a warning). - -To disable: +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 "codex" { - # ... other config - enable_state_persistence = false + source = "registry.coder.com/coder-labs/codex/coder" + version = "5.0.0" + agent_id = coder_agent.main.id + openai_api_key = var.openai_api_key +} + +resource "coder_script" "post_codex" { + agent_id = coder_agent.main.id + display_name = "Run after Codex install" + run_on_start = true + script = <<-EOT + #!/bin/bash + set -euo pipefail + trap 'coder exp sync complete post-codex' EXIT + coder exp sync want post-codex ${join(" ", module.codex.scripts)} + coder exp sync start post-codex + + codex --version + EOT } ``` ## Configuration -### Default Configuration - -When no custom `base_config_toml` is provided, the module uses these secure defaults: - -```toml -sandbox_mode = "workspace-write" -approval_policy = "never" -preferred_auth_method = "apikey" - -[sandbox_workspace_write] -network_access = true -``` - -> [!NOTE] -> If no custom configuration is provided, the module uses secure defaults. The Coder MCP server is always included automatically. For containerized workspaces (Docker/Kubernetes), you may need `sandbox_mode = "danger-full-access"` to avoid permission issues. For advanced options, see [Codex config docs](https://github.com/openai/codex/blob/main/codex-rs/config.md). +When no custom `base_config_toml` is provided, the module uses a minimal default with `preferred_auth_method = "apikey"`. For advanced options, see [Codex config docs](https://developers.openai.com/codex/config-advanced). ## Troubleshooting -- Check installation and startup logs in `~/.codex-module/` -- Ensure your OpenAI API key has access to the specified model +Check the log files in `~/.coder-modules/coder-labs/codex/logs/` for detailed information. -> [!IMPORTANT] -> To use tasks with Codex CLI, ensure you have the `openai_api_key` variable set. [Tasks Template Example](https://registry.coder.com/templates/coder-labs/tasks-docker). -> The module automatically configures Codex with your API key and model preferences. -> workdir is a required variable for the module to function correctly. +```bash +cat ~/.coder-modules/coder-labs/codex/logs/install.log +cat ~/.coder-modules/coder-labs/codex/logs/pre_install.log +cat ~/.coder-modules/coder-labs/codex/logs/post_install.log +``` ## References - [Codex CLI Documentation](https://github.com/openai/codex) -- [AgentAPI Documentation](https://github.com/coder/agentapi) -- [Coder AI Agents Guide](https://coder.com/docs/tutorials/ai-agents) -- [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) +- [AI Gateway](https://coder.com/docs/ai-coder/ai-gateway) diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts index 13055867..8e7e514c 100644 --- a/registry/coder-labs/modules/codex/main.test.ts +++ b/registry/coder-labs/modules/codex/main.test.ts @@ -6,15 +6,67 @@ import { beforeAll, expect, } from "bun:test"; -import { execContainer, readFileContainer, runTerraformInit } from "~test"; import { - loadTestFile, + execContainer, + readFileContainer, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + TerraformState, +} from "~test"; +import { + extractCoderEnvVars, writeExecutable, - setup as setupUtil, - execModuleScript, - expectAgentAPIStarted, } from "../../../coder/modules/agentapi/test-util"; -import dedent from "dedent"; +import path from "path"; + +interface ModuleScripts { + pre_install?: string; + install: string; + post_install?: string; +} + +const SCRIPT_SUFFIXES = [ + "Pre-Install Script", + "Install Script", + "Post-Install Script", +] as const; + +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 = `Codex: ${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; +}; let cleanupFunctions: (() => Promise)[] = []; const registerCleanup = (cleanup: () => Promise) => { @@ -33,36 +85,90 @@ afterEach(async () => { }); interface SetupProps { - skipAgentAPIMock?: boolean; skipCodexMock?: boolean; moduleVariables?: Record; - agentapiMockScript?: string; } -const setup = async (props?: SetupProps): Promise<{ id: string }> => { +const setup = async ( + props?: SetupProps, +): Promise<{ + id: string; + coderEnvVars: Record; + scripts: ModuleScripts; +}> => { const projectDir = "/home/coder/project"; - const { id } = await setupUtil({ - moduleDir: import.meta.dir, - moduleVariables: { - install_codex: props?.skipCodexMock ? "true" : "false", - install_agentapi: props?.skipAgentAPIMock ? "true" : "false", - codex_model: "gpt-4-turbo", - workdir: "/home/coder", - ...props?.moduleVariables, - }, - registerCleanup, - projectDir, - skipAgentAPIMock: props?.skipAgentAPIMock, - agentapiMockScript: props?.agentapiMockScript, + const moduleDir = path.resolve(import.meta.dir); + const state = await runTerraformApply(moduleDir, { + agent_id: "foo", + workdir: projectDir, + install_codex: "false", + ...props?.moduleVariables, + }); + const scripts = collectScripts(state); + const coderEnvVars = extractCoderEnvVars(state); + + 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); + }); + + await execContainer(id, ["bash", "-c", `mkdir -p '${projectDir}'`]); + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: "#!/bin/bash\nexit 0\n", }); if (!props?.skipCodexMock) { await writeExecutable({ containerId: id, filePath: "/usr/bin/codex", - content: await loadTestFile(import.meta.dir, "codex-mock.sh"), + content: await Bun.file( + path.join(moduleDir, "testdata", "codex-mock.sh"), + ).text(), }); } - return { id }; + return { id, coderEnvVars, scripts }; +}; + +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 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 target = `/tmp/coder-utils-${name}.sh`; + await writeExecutable({ + containerId: id, + filePath: target, + content: script, + }); + const resp = await execContainer(id, ["bash", "-c", `${envArgs}${target}`]); + 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}`); + } + } }; setDefaultTimeout(60 * 1000); @@ -73,444 +179,269 @@ describe("codex", async () => { }); test("happy-path", async () => { - const { id } = await setup(); - await execModuleScript(id); - await expectAgentAPIStarted(id); + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const installLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder-labs/codex/logs/install.log", + ); + expect(installLog).toContain("Skipping Codex installation"); }); test("install-codex-version", async () => { - const version_to_install = "0.10.0"; - const { id } = await setup({ + const version = "0.10.0"; + const { id, coderEnvVars, scripts } = await setup({ skipCodexMock: true, moduleVariables: { install_codex: "true", - codex_version: version_to_install, + codex_version: version, }, }); - await execModuleScript(id); - const resp = await execContainer(id, [ - "bash", - "-c", - `cat /home/coder/.codex-module/install.log`, - ]); - expect(resp.stdout).toContain(version_to_install); + await runScripts(id, scripts, coderEnvVars); + const installLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder-labs/codex/logs/install.log", + ); + expect(installLog).toContain(version); }); - test("check-latest-codex-version-works", async () => { - const { id } = await setup({ - skipCodexMock: true, - skipAgentAPIMock: true, - moduleVariables: { - install_codex: "true", - }, - }); - await execModuleScript(id); - await expectAgentAPIStarted(id); - }); - - test("base-config-toml", async () => { - const baseConfig = dedent` - sandbox_mode = "danger-full-access" - approval_policy = "never" - preferred_auth_method = "apikey" - - [custom_section] - new_feature = true - `.trim(); - const { id } = await setup({ - moduleVariables: { - base_config_toml: baseConfig, - }, - }); - await execModuleScript(id); - const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); - expect(resp).toContain('sandbox_mode = "danger-full-access"'); - expect(resp).toContain('preferred_auth_method = "apikey"'); - expect(resp).toContain("[custom_section]"); - expect(resp).toContain("[mcp_servers.Coder]"); - }); - - test("codex-api-key", async () => { + test("openai-api-key", async () => { const apiKey = "test-api-key-123"; - const { id } = await setup({ + const { coderEnvVars } = await setup({ moduleVariables: { openai_api_key: apiKey, }, }); - await execModuleScript(id); - - const resp = await readFileContainer( - id, - "/home/coder/.codex-module/agentapi-start.log", - ); - expect(resp).toContain("OpenAI API Key: Provided"); + expect(coderEnvVars["OPENAI_API_KEY"]).toBe(apiKey); }); - test("pre-post-install-scripts", async () => { - const { id } = await setup({ + test("base-config-toml", async () => { + const baseConfig = [ + 'sandbox_mode = "danger-full-access"', + 'approval_policy = "never"', + 'preferred_auth_method = "apikey"', + "", + "[custom_section]", + "new_feature = true", + ].join("\n"); + const { id, scripts } = await setup({ moduleVariables: { - pre_install_script: "#!/bin/bash\necho 'pre-install-script'", - post_install_script: "#!/bin/bash\necho 'post-install-script'", + base_config_toml: baseConfig, }, }); - await execModuleScript(id); - const preInstallLog = await readFileContainer( - id, - "/home/coder/.codex-module/pre_install.log", - ); - expect(preInstallLog).toContain("pre-install-script"); - const postInstallLog = await readFileContainer( - id, - "/home/coder/.codex-module/post_install.log", - ); - expect(postInstallLog).toContain("post-install-script"); - }); - - test("workdir-variable", async () => { - const workdir = "/tmp/codex-test-workdir"; - const { id } = await setup({ - skipCodexMock: false, - moduleVariables: { - workdir, - }, - }); - await execModuleScript(id); - const resp = await readFileContainer( - id, - "/home/coder/.codex-module/install.log", - ); - expect(resp).toContain(workdir); + await runScripts(id, scripts); + const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); + expect(resp).toContain('sandbox_mode = "danger-full-access"'); + expect(resp).toContain('preferred_auth_method = "apikey"'); + expect(resp).toContain("[custom_section]"); }); test("additional-mcp-servers", async () => { - const additional = dedent` - [mcp_servers.GitHub] - command = "npx" - args = ["-y", "@modelcontextprotocol/server-github"] - type = "stdio" - description = "GitHub integration" - - [mcp_servers.FileSystem] - command = "npx" - args = ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"] - type = "stdio" - description = "File system access" - `.trim(); - const { id } = await setup({ + const additional = [ + "[mcp_servers.GitHub]", + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-github"]', + 'type = "stdio"', + 'description = "GitHub integration"', + ].join("\n"); + const { id, scripts } = await setup({ moduleVariables: { additional_mcp_servers: additional, }, }); - await execModuleScript(id); + await runScripts(id, scripts); const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); expect(resp).toContain("[mcp_servers.GitHub]"); - expect(resp).toContain("[mcp_servers.FileSystem]"); - expect(resp).toContain("[mcp_servers.Coder]"); expect(resp).toContain("GitHub integration"); }); - test("full-custom-config", async () => { - const baseConfig = dedent` - sandbox_mode = "read-only" - approval_policy = "untrusted" - preferred_auth_method = "chatgpt" - custom_setting = "test-value" - - [advanced_settings] - timeout = 30000 - debug = true - logging_level = "verbose" - `.trim(); - - const additionalMCP = dedent` - [mcp_servers.CustomTool] - command = "/usr/local/bin/custom-tool" - args = ["--serve", "--port", "8080"] - type = "stdio" - description = "Custom development tool" - - [mcp_servers.DatabaseMCP] - command = "python" - args = ["-m", "database_mcp_server"] - type = "stdio" - description = "Database query interface" - `.trim(); - - const { id } = await setup({ - moduleVariables: { - base_config_toml: baseConfig, - additional_mcp_servers: additionalMCP, - }, - }); - await execModuleScript(id); - const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); - - // Check base config - expect(resp).toContain('sandbox_mode = "read-only"'); - expect(resp).toContain('preferred_auth_method = "chatgpt"'); - expect(resp).toContain('custom_setting = "test-value"'); - expect(resp).toContain("[advanced_settings]"); - expect(resp).toContain('logging_level = "verbose"'); - - // Check MCP servers - expect(resp).toContain("[mcp_servers.Coder]"); - expect(resp).toContain("[mcp_servers.CustomTool]"); - expect(resp).toContain("[mcp_servers.DatabaseMCP]"); - expect(resp).toContain("Custom development tool"); - expect(resp).toContain("Database query interface"); - }); - test("minimal-default-config", async () => { - const { id } = await setup({ - moduleVariables: { - // No base_config_toml or additional_mcp_servers - should use defaults - }, - }); - await execModuleScript(id); + const { id, scripts } = await setup(); + await runScripts(id, scripts); const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); - - // Check default base config - expect(resp).toContain('sandbox_mode = "workspace-write"'); - expect(resp).toContain('approval_policy = "never"'); - expect(resp).toContain("[sandbox_workspace_write]"); - expect(resp).toContain("network_access = true"); - - // Check only Coder MCP server is present - expect(resp).toContain("[mcp_servers.Coder]"); - expect(resp).toContain("Report ALL tasks and statuses"); - - // Ensure no additional MCP servers - const mcpServerCount = (resp.match(/\[mcp_servers\./g) || []).length; - expect(mcpServerCount).toBe(1); + expect(resp).toContain('preferred_auth_method = "apikey"'); + expect(resp).not.toContain("model_provider"); + expect(resp).not.toContain("[model_providers."); + expect(resp).not.toContain("model_reasoning_effort"); }); - test("codex-system-prompt", async () => { - const prompt = "This is a system prompt for Codex."; - const { id } = await setup({ + test("pre-post-install-scripts", async () => { + const { id, scripts } = await setup({ moduleVariables: { - codex_system_prompt: prompt, + pre_install_script: "#!/bin/bash\necho 'codex-pre-install-script'", + post_install_script: "#!/bin/bash\necho 'codex-post-install-script'", }, }); - await execModuleScript(id); - const resp = await readFileContainer(id, "/home/coder/.codex/AGENTS.md"); - expect(resp).toContain(prompt); - }); + await runScripts(id, scripts); - test("codex-system-prompt-skip-append-if-exists", async () => { - const prompt_1 = "This is a system prompt for Codex."; - const prompt_2 = "This is a system prompt for Goose."; - const prompt_3 = dedent` - This is a system prompt for Codex. - This is a system prompt for Gemini. - `.trim(); - const pre_install_script = dedent` - #!/bin/bash - mkdir -p /home/coder/.codex - echo -e "${prompt_3}" >> /home/coder/.codex/AGENTS.md - `.trim(); - - const { id } = await setup({ - moduleVariables: { - pre_install_script, - codex_system_prompt: prompt_2, - }, - }); - await execModuleScript(id); - const resp = await readFileContainer(id, "/home/coder/.codex/AGENTS.md"); - expect(resp).toContain(prompt_1); - expect(resp).toContain(prompt_2); - - // Re-run with a prompt that already exists, it should not append again - const { id: id_2 } = await setup({ - moduleVariables: { - pre_install_script, - codex_system_prompt: prompt_1, - }, - }); - await execModuleScript(id_2); - const resp_2 = await readFileContainer( - id_2, - "/home/coder/.codex/AGENTS.md", - ); - expect(resp_2).toContain(prompt_1); - const count = (resp_2.match(new RegExp(prompt_1, "g")) || []).length; - expect(count).toBe(1); - }); - - test("codex-ai-task-prompt", async () => { - const prompt = "This is a system prompt for Codex."; - const { id } = await setup({ - moduleVariables: { - ai_prompt: prompt, - }, - }); - await execModuleScript(id); - const resp = await execContainer(id, [ - "bash", - "-c", - `cat /home/coder/.codex-module/agentapi-start.log`, - ]); - expect(resp.stdout).toContain(prompt); - }); - - test("start-without-prompt", async () => { - const { id } = await setup({ - moduleVariables: { - codex_system_prompt: "", // Explicitly disable system prompt - }, - }); - await execModuleScript(id); - const prompt = await execContainer(id, [ - "ls", - "-l", - "/home/coder/.codex/AGENTS.md", - ]); - expect(prompt.exitCode).not.toBe(0); - expect(prompt.stderr).toContain("No such file or directory"); - }); - - test("codex-continue-capture-new-session", async () => { - const { id } = await setup({ - moduleVariables: { - continue: "true", - ai_prompt: "test task", - }, - }); - - const workdir = "/home/coder"; - const expectedSessionId = "019a1234-5678-9abc-def0-123456789012"; - const sessionsDir = "/home/coder/.codex/sessions"; - const sessionFile = `${sessionsDir}/${expectedSessionId}.jsonl`; - - await execContainer(id, ["mkdir", "-p", sessionsDir]); - await execContainer(id, [ - "bash", - "-c", - `echo '{"id":"${expectedSessionId}","cwd":"${workdir}","created":"2024-10-24T20:00:00Z","model":"gpt-4-turbo"}' > ${sessionFile}`, - ]); - - await execModuleScript(id); - - await expectAgentAPIStarted(id); - - const trackingFile = "/home/coder/.codex-module/.codex-task-session"; - const maxAttempts = 30; - let trackingFileContents = ""; - for (let attempt = 0; attempt < maxAttempts; attempt++) { - const result = await execContainer(id, [ - "bash", - "-c", - `cat ${trackingFile} 2>/dev/null || echo ""`, - ]); - if (result.stdout.trim().length > 0) { - trackingFileContents = result.stdout; - break; - } - await new Promise((resolve) => setTimeout(resolve, 500)); - } - - expect(trackingFileContents).toContain(`${workdir}|${expectedSessionId}`); - - const startLog = await readFileContainer( + const preInstallLog = await readFileContainer( id, - "/home/coder/.codex-module/agentapi-start.log", + "/home/coder/.coder-modules/coder-labs/codex/logs/pre_install.log", ); - expect(startLog).toContain("Capturing new session ID"); - expect(startLog).toContain("Session tracked"); - expect(startLog).toContain(expectedSessionId); + expect(preInstallLog).toContain("codex-pre-install-script"); + + const postInstallLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder-labs/codex/logs/post_install.log", + ); + expect(postInstallLog).toContain("codex-post-install-script"); }); - test("codex-continue-resume-existing-session", async () => { - const { id } = await setup({ + test("workdir-variable", async () => { + const workdir = "/home/coder/codex-test-folder"; + const { id, scripts } = await setup({ moduleVariables: { - continue: "true", - ai_prompt: "test prompt", + workdir, }, }); - - const workdir = "/home/coder"; - const mockSessionId = "019a1234-5678-9abc-def0-123456789012"; - const trackingFile = "/home/coder/.codex-module/.codex-task-session"; - - await execContainer(id, ["mkdir", "-p", "/home/coder/.codex-module"]); - await execContainer(id, [ - "bash", - "-c", - `echo "${workdir}|${mockSessionId}" > ${trackingFile}`, - ]); - - await execModuleScript(id); - - const startLog = await execContainer(id, [ - "bash", - "-c", - "cat /home/coder/.codex-module/agentapi-start.log", - ]); - expect(startLog.stdout).toContain("Found existing task session"); - expect(startLog.stdout).toContain(mockSessionId); - expect(startLog.stdout).toContain("Resuming existing session"); - expect(startLog.stdout).toContain( - `Starting Codex with arguments: --model gpt-4-turbo resume ${mockSessionId}`, + await runScripts(id, scripts); + const installLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder-labs/codex/logs/install.log", ); - expect(startLog.stdout).not.toContain("test prompt"); + expect(installLog).toContain(workdir); }); - test("codex-with-aibridge", async () => { - const { id } = await setup({ + test("codex-with-ai-gateway", async () => { + const { id, coderEnvVars, scripts } = await setup({ moduleVariables: { - enable_aibridge: "true", + enable_ai_gateway: "true", model_reasoning_effort: "none", }, }); - - await execModuleScript(id); + await runScripts(id, scripts, coderEnvVars); const configToml = await readFileContainer( id, "/home/coder/.codex/config.toml", ); - expect(configToml).toContain('model_provider = "aibridge"'); + expect(configToml).toContain('model_provider = "aigateway"'); + expect(configToml).toContain('model_reasoning_effort = "none"'); + expect(configToml).toContain("[model_providers.aigateway]"); }); - test("boundary-enabled", async () => { - const { id } = await setup({ + test("model-reasoning-effort-standalone", async () => { + const { id, scripts } = await setup({ moduleVariables: { - enable_boundary: "true", - boundary_config_path: "/tmp/test-boundary.yaml", + model_reasoning_effort: "high", }, }); - // Write boundary config - await execContainer(id, [ - "bash", - "-c", - `cat > /tmp/test-boundary.yaml <<'EOF' -jail_type: landjail -proxy_port: 8087 -log_level: warn -allowlist: - - "domain=api.openai.com" -EOF`, - ]); - // Add mock coder binary for boundary setup - await writeExecutable({ - containerId: id, - filePath: "/usr/bin/coder", - content: `#!/bin/bash -if [ "$1" = "boundary" ]; then - if [ "$2" = "--help" ]; then - echo "boundary help" - exit 0 - fi - shift; shift; exec "$@" -fi -echo "mock coder"`, - }); - await execModuleScript(id); - await expectAgentAPIStarted(id); - // Verify boundary wrapper was used in start script - const startLog = await readFileContainer( + await runScripts(id, scripts); + const configToml = await readFileContainer( id, - "/home/coder/.codex-module/agentapi-start.log", + "/home/coder/.codex/config.toml", ); - expect(startLog).toContain("boundary"); + expect(configToml).toContain('model_reasoning_effort = "high"'); + expect(configToml).not.toContain("model_provider"); + }); + + test("workdir-trusted-project", async () => { + const workdir = "/home/coder/trusted-project"; + const { id, scripts } = await setup({ + moduleVariables: { + workdir, + }, + }); + await runScripts(id, scripts); + const configToml = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + expect(configToml).toContain(`[projects."${workdir}"]`); + expect(configToml).toContain('trust_level = "trusted"'); + }); + + test("no-workdir-no-project-section", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + workdir: "", + }, + }); + await runScripts(id, scripts); + const configToml = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + expect(configToml).not.toContain("[projects."); + }); + + test("ai-gateway-with-custom-base-config", async () => { + const baseConfig = [ + 'sandbox_mode = "danger-full-access"', + 'model_provider = "aigateway"', + ].join("\n"); + const { id, coderEnvVars, scripts } = await setup({ + moduleVariables: { + enable_ai_gateway: "true", + base_config_toml: baseConfig, + }, + }); + await runScripts(id, scripts, coderEnvVars); + const configToml = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + expect(configToml).toContain('model_provider = "aigateway"'); + expect(configToml).toContain("[model_providers.aigateway]"); + }); + + test("ai-gateway-custom-config-no-duplicate-provider", async () => { + const baseConfig = [ + 'model_provider = "aigateway"', + "", + "[model_providers.aigateway]", + 'name = "Custom AI Bridge"', + 'base_url = "https://custom.example.com"', + 'env_key = "CODER_AIBRIDGE_SESSION_TOKEN"', + 'wire_api = "responses"', + ].join("\n"); + const { id, coderEnvVars, scripts } = await setup({ + moduleVariables: { + enable_ai_gateway: "true", + base_config_toml: baseConfig, + }, + }); + await runScripts(id, scripts, coderEnvVars); + const configToml = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + const matches = configToml.match(/\[model_providers\.aigateway\]/g) || []; + expect(matches.length).toBe(1); + expect(configToml).toContain("Custom AI Bridge"); + }); + + test("install-codex-latest", async () => { + const { id, coderEnvVars, scripts } = await setup({ + skipCodexMock: true, + moduleVariables: { + install_codex: "true", + }, + }); + await runScripts(id, scripts, coderEnvVars); + const installLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder-labs/codex/logs/install.log", + ); + expect(installLog).toContain("Installed Codex CLI"); + }); + + test("custom-config-drops-reasoning-effort", async () => { + const baseConfig = [ + 'sandbox_mode = "danger-full-access"', + 'preferred_auth_method = "apikey"', + ].join("\n"); + const { id, scripts } = await setup({ + moduleVariables: { + base_config_toml: baseConfig, + model_reasoning_effort: "high", + }, + }); + await runScripts(id, scripts); + const configToml = await readFileContainer( + id, + "/home/coder/.codex/config.toml", + ); + expect(configToml).toContain('sandbox_mode = "danger-full-access"'); + expect(configToml).not.toContain("model_reasoning_effort"); }); }); diff --git a/registry/coder-labs/modules/codex/main.tf b/registry/coder-labs/modules/codex/main.tf index b5f71cb3..7aebd487 100644 --- a/registry/coder-labs/modules/codex/main.tf +++ b/registry/coder-labs/modules/codex/main.tf @@ -18,18 +18,6 @@ data "coder_workspace" "me" {} data "coder_workspace_owner" "me" {} -variable "order" { - type = number - description = "The order determines the position of app in the UI presentation. The lowest order is shown first and apps with equal order are sorted by name (ascending order)." - default = null -} - -variable "group" { - type = string - description = "The name of a group that this app belongs to." - default = null -} - variable "icon" { type = string description = "The icon to use for the app." @@ -38,106 +26,8 @@ variable "icon" { variable "workdir" { type = string - description = "The folder to run Codex in." -} - -variable "report_tasks" { - type = bool - description = "Whether to enable task reporting to Coder UI via AgentAPI" - default = true -} - -variable "subdomain" { - type = bool - description = "Whether to use a subdomain for AgentAPI." - default = false -} - -variable "cli_app" { - type = bool - description = "Whether to create a CLI app for Codex" - default = false -} - -variable "web_app_display_name" { - type = string - description = "Display name for the web app" - default = "Codex" -} - -variable "cli_app_display_name" { - type = string - description = "Display name for the CLI app" - default = "Codex CLI" -} - -variable "enable_aibridge" { - type = bool - description = "Use AI Bridge for Codex. https://coder.com/docs/ai-coder/ai-bridge" - default = false - - validation { - condition = !(var.enable_aibridge && length(var.openai_api_key) > 0) - error_message = "openai_api_key cannot be provided when enable_aibridge is true. AI Bridge automatically authenticates the client using Coder credentials." - } -} - -variable "model_reasoning_effort" { - type = string - description = "The reasoning effort for the model. One of: none, low, medium, high. https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort" - default = "" - validation { - condition = contains(["", "none", "minimal", "low", "medium", "high", "xhigh"], var.model_reasoning_effort) - error_message = "model_reasoning_effort must be one of: none, low, medium, high." - } -} - -variable "install_codex" { - type = bool - description = "Whether to install Codex." - default = true -} - -variable "codex_version" { - type = string - description = "The version of Codex to install." - default = "" # empty string means the latest available version -} - -variable "base_config_toml" { - type = string - description = "Complete base TOML configuration for Codex (without mcp_servers section). If empty, uses minimal default configuration with workspace-write sandbox mode and never approval policy. For advanced options, see https://github.com/openai/codex/blob/main/codex-rs/config.md" - default = "" -} - -variable "additional_mcp_servers" { - type = string - description = "Additional MCP servers configuration in TOML format. These will be merged with the required Coder MCP server in the [mcp_servers] section." - default = "" -} - -variable "openai_api_key" { - type = string - description = "OpenAI API key for Codex CLI" - 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.12.1" -} - -variable "codex_model" { - type = string - description = "The model for Codex to use. Defaults to gpt-5.3-codex." - default = "gpt-5.4" + description = "Optional project directory. When set, the module pre-creates it if missing and adds it as a trusted project in Codex config.toml." + default = null } variable "pre_install_script" { @@ -152,158 +42,127 @@ variable "post_install_script" { default = null } -variable "ai_prompt" { - type = string - description = "Initial task prompt for Codex CLI when launched via Tasks" - default = "" -} - -variable "continue" { +variable "install_codex" { type = bool - description = "Automatically continue existing sessions on workspace restart. When true, resumes existing conversation if found, otherwise runs prompt or starts new session. When false, always starts fresh (ignores existing sessions)." + description = "Whether to install Codex." default = true } -variable "enable_state_persistence" { - type = bool - description = "Enable AgentAPI conversation state persistence across restarts." - default = true -} - -variable "codex_system_prompt" { +variable "codex_version" { type = string - description = "System instructions written to AGENTS.md in the ~/.codex directory" - default = "You are a helpful coding assistant. Start every response with `Codex says:`" -} - -variable "enable_boundary" { - type = bool - description = "Enable coder boundary for network filtering." - default = false -} - -variable "boundary_config_path" { - type = string - description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var." + description = "The version of Codex to install. Empty string installs the latest available version." default = "" } -variable "boundary_version" { +variable "openai_api_key" { type = string - description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release." - default = "latest" + description = "OpenAI API key for Codex CLI." + sensitive = true + default = "" } -variable "compile_boundary_from_source" { - type = bool - description = "Whether to compile boundary from source instead of using the official install script." - default = false +variable "base_config_toml" { + type = string + description = <<-EOT + Complete base TOML configuration for Codex (without mcp_servers section). + When empty, the module generates a minimal default: + + preferred_auth_method = "apikey" + # model_provider = "aigateway" (sets the default profile, when enable_ai_gateway = true) + # model_reasoning_effort = "" (sets the reasoning effort, when model_reasoning_effort is set) + + [projects.""] (when workdir is set) + trust_level = "trusted" + + When non-empty, the value is written verbatim as the base of config.toml; + additional_mcp_servers and AI Gateway sections are still appended after it. + Note: model_reasoning_effort and workdir trust are only applied in the + default config. Include them in your custom config if needed. + EOT + default = "" } -variable "use_boundary_directly" { +variable "additional_mcp_servers" { + type = string + description = "Additional MCP servers configuration in TOML format." + default = "" +} + +variable "model_reasoning_effort" { + type = string + description = "The reasoning effort for the model. One of: none, minimal, low, medium, high, xhigh. See https://platform.openai.com/docs/guides/latest-model#lower-reasoning-effort" + default = "" + validation { + condition = contains(["", "none", "minimal", "low", "medium", "high", "xhigh"], var.model_reasoning_effort) + error_message = "model_reasoning_effort must be one of: none, minimal, low, medium, high, xhigh." + } +} + +variable "enable_ai_gateway" { type = bool - description = "Whether to use boundary binary directly instead of coder boundary subcommand." + description = "Use AI Gateway for Codex. https://coder.com/docs/ai-coder/ai-gateway" default = false + + validation { + condition = !(var.enable_ai_gateway && length(var.openai_api_key) > 0) + error_message = "openai_api_key cannot be provided when enable_ai_gateway is true. AI Gateway automatically authenticates the client using Coder credentials." + } } resource "coder_env" "openai_api_key" { + count = var.openai_api_key != "" ? 1 : 0 agent_id = var.agent_id name = "OPENAI_API_KEY" value = var.openai_api_key } -resource "coder_env" "coder_aibridge_session_token" { - count = var.enable_aibridge ? 1 : 0 +# Authenticates the client against Coder's AI Gateway using the workspace +# owner's session token. Referenced by config.toml model_providers.aigateway. +resource "coder_env" "ai_gateway_session_token" { + count = var.enable_ai_gateway ? 1 : 0 agent_id = var.agent_id name = "CODER_AIBRIDGE_SESSION_TOKEN" value = data.coder_workspace_owner.me.session_token } locals { - workdir = trimsuffix(var.workdir, "/") - app_slug = "codex" - install_script = file("${path.module}/scripts/install.sh") - start_script = file("${path.module}/scripts/start.sh") - module_dir_name = ".codex-module" - latest_codex_model = "gpt-5.4" - aibridge_config = <<-EOF - [model_providers.aibridge] - name = "AI Bridge" + workdir = var.workdir != null ? trimsuffix(var.workdir, "/") : "" + aibridge_config = <<-EOF + [model_providers.aigateway] + name = "AI Gateway" base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1" env_key = "CODER_AIBRIDGE_SESSION_TOKEN" wire_api = "responses" EOF + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + ARG_INSTALL = tostring(var.install_codex) + ARG_CODEX_VERSION = var.codex_version != "" ? base64encode(var.codex_version) : "" + ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" + ARG_BASE_CONFIG_TOML = var.base_config_toml != "" ? base64encode(var.base_config_toml) : "" + ARG_ADDITIONAL_MCP_SERVERS = var.additional_mcp_servers != "" ? base64encode(var.additional_mcp_servers) : "" + ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway) + ARG_AIBRIDGE_CONFIG = var.enable_ai_gateway ? base64encode(local.aibridge_config) : "" + ARG_MODEL_REASONING_EFFORT = var.model_reasoning_effort + ARG_OPENAI_API_KEY = var.openai_api_key != "" ? base64encode(var.openai_api_key) : "" + }) + module_dir_name = ".coder-modules/coder-labs/codex" } -module "agentapi" { - source = "registry.coder.com/coder/agentapi/coder" - version = "2.3.0" +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" - 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_display_name = var.cli_app ? var.cli_app_display_name : null - module_dir_name = local.module_dir_name - install_agentapi = var.install_agentapi - agentapi_subdomain = var.subdomain - agentapi_version = var.agentapi_version - enable_state_persistence = var.enable_state_persistence - pre_install_script = var.pre_install_script - post_install_script = var.post_install_script - enable_boundary = var.enable_boundary - boundary_config_path = var.boundary_config_path - boundary_version = var.boundary_version - compile_boundary_from_source = var.compile_boundary_from_source - use_boundary_directly = var.use_boundary_directly - 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_OPENAI_API_KEY='${var.openai_api_key}' \ - ARG_REPORT_TASKS='${var.report_tasks}' \ - ARG_CODEX_MODEL='${var.codex_model}' \ - ARG_CODEX_START_DIRECTORY='${local.workdir}' \ - ARG_CODEX_TASK_PROMPT='${base64encode(var.ai_prompt)}' \ - ARG_CONTINUE='${var.continue}' \ - ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \ - /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_OPENAI_API_KEY='${var.openai_api_key}' \ - ARG_REPORT_TASKS='${var.report_tasks}' \ - ARG_CODEX_MODEL='${var.codex_model}' \ - ARG_LATEST_CODEX_MODEL='${local.latest_codex_model}' \ - ARG_INSTALL='${var.install_codex}' \ - ARG_CODEX_VERSION='${var.codex_version}' \ - ARG_BASE_CONFIG_TOML='${base64encode(var.base_config_toml)}' \ - ARG_ENABLE_AIBRIDGE='${var.enable_aibridge}' \ - ARG_AIBRIDGE_CONFIG='${base64encode(var.enable_aibridge ? local.aibridge_config : "")}' \ - ARG_ADDITIONAL_MCP_SERVERS='${base64encode(var.additional_mcp_servers)}' \ - ARG_CODER_MCP_APP_STATUS_SLUG='${local.app_slug}' \ - ARG_CODEX_START_DIRECTORY='${local.workdir}' \ - ARG_MODEL_REASONING_EFFORT='${var.model_reasoning_effort}' \ - ARG_CODEX_INSTRUCTION_PROMPT='${base64encode(var.codex_system_prompt)}' \ - /tmp/install.sh - EOT + agent_id = var.agent_id + module_directory = "$HOME/${local.module_dir_name}" + display_name_prefix = "Codex" + 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/codex/main.tftest.hcl b/registry/coder-labs/modules/codex/main.tftest.hcl index 1237df5d..3bcd681a 100644 --- a/registry/coder-labs/modules/codex/main.tftest.hcl +++ b/registry/coder-labs/modules/codex/main.tftest.hcl @@ -1,187 +1,185 @@ run "test_codex_basic" { command = plan - variables { - agent_id = "test-agent" - workdir = "/home/coder" - openai_api_key = "test-key" - } - - assert { - condition = var.agent_id == "test-agent" - error_message = "Agent ID should be set correctly" - } - - assert { - condition = var.workdir == "/home/coder" - error_message = "Workdir should be set correctly" - } - - assert { - condition = var.install_codex == true - error_message = "install_codex should default to true" - } - - assert { - condition = var.install_agentapi == true - error_message = "install_agentapi should default to true" - } - - assert { - condition = var.report_tasks == true - error_message = "report_tasks should default to true" - } - - assert { - condition = var.continue == true - error_message = "continue should default to true" - } -} - -run "test_enable_state_persistence_default" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - openai_api_key = "test-key" - } - - assert { - condition = var.enable_state_persistence == true - error_message = "enable_state_persistence should default to true" - } -} - -run "test_disable_state_persistence" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - openai_api_key = "test-key" - enable_state_persistence = false - } - - assert { - condition = var.enable_state_persistence == false - error_message = "enable_state_persistence should be false when explicitly disabled" - } -} - -run "test_codex_with_aibridge" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - enable_aibridge = true - } - - assert { - condition = var.enable_aibridge == true - error_message = "enable_aibridge should be set to true" - } -} - -run "test_aibridge_disabled_with_api_key" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder" - openai_api_key = "test-key" - enable_aibridge = false - } - - assert { - condition = var.enable_aibridge == false - error_message = "enable_aibridge should be false" - } - - assert { - condition = coder_env.openai_api_key.value == "test-key" - error_message = "OpenAI API key should be set correctly" - } -} - -run "test_custom_options" { - command = plan - - variables { - agent_id = "test-agent" - workdir = "/home/coder/project" - openai_api_key = "test-key" - order = 5 - group = "ai-tools" - icon = "/icon/custom.svg" - web_app_display_name = "Custom Codex" - cli_app = true - cli_app_display_name = "Codex Terminal" - subdomain = true - report_tasks = false - continue = false - codex_model = "gpt-4o" - codex_version = "0.1.0" - agentapi_version = "v0.12.0" - } - - assert { - condition = var.order == 5 - error_message = "Order should be set to 5" - } - - assert { - condition = var.group == "ai-tools" - error_message = "Group should be set to 'ai-tools'" - } - - assert { - condition = var.icon == "/icon/custom.svg" - error_message = "Icon should be set to custom icon" - } - - assert { - condition = var.cli_app == true - error_message = "cli_app should be enabled" - } - - assert { - condition = var.subdomain == true - error_message = "subdomain should be enabled" - } - - assert { - condition = var.report_tasks == false - error_message = "report_tasks should be disabled" - } - - assert { - condition = var.continue == false - error_message = "continue should be disabled" - } - - assert { - condition = var.codex_model == "gpt-4o" - error_message = "codex_model should be set to 'gpt-4o'" - } -} - -run "test_no_api_key_no_aibridge" { - command = plan - variables { agent_id = "test-agent" workdir = "/home/coder" } assert { - condition = var.openai_api_key == "" - error_message = "openai_api_key should be empty when not provided" + condition = var.install_codex == true + error_message = "install_codex should default to true" + } +} + +run "test_codex_with_api_key" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + openai_api_key = "test-key" } assert { - condition = var.enable_aibridge == false - error_message = "enable_aibridge should default to false" + condition = coder_env.openai_api_key[0].value == "test-key" + error_message = "OpenAI API key should be set correctly" + } +} + +run "test_codex_custom_options" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder/project" + icon = "/icon/custom.svg" + codex_version = "0.128.0" + } + + assert { + condition = length(output.scripts) > 0 + error_message = "scripts output should be non-empty with custom options" + } +} + +run "test_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_session_token[0].name == "CODER_AIBRIDGE_SESSION_TOKEN" + error_message = "CODER_AIBRIDGE_SESSION_TOKEN should be set" + } + + assert { + condition = coder_env.ai_gateway_session_token[0].value == data.coder_workspace_owner.me.session_token + error_message = "Session token should use workspace owner's token" + } + + assert { + condition = length(coder_env.openai_api_key) == 0 + error_message = "OPENAI_API_KEY should not be created when ai_gateway is enabled" + } +} + +run "test_ai_gateway_validation_with_api_key" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + enable_ai_gateway = true + openai_api_key = "test-key" + } + + expect_failures = [ + var.enable_ai_gateway, + ] +} + +run "test_ai_gateway_disabled_with_api_key" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + enable_ai_gateway = false + openai_api_key = "test-key-xyz" + } + + assert { + condition = coder_env.openai_api_key[0].value == "test-key-xyz" + error_message = "OPENAI_API_KEY should use the provided API key" + } + + assert { + condition = length(coder_env.ai_gateway_session_token) == 0 + error_message = "Session token should not be set when ai_gateway is disabled" + } +} + +run "test_no_api_key_no_env" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = length(coder_env.openai_api_key) == 0 + error_message = "OPENAI_API_KEY should not be created when no API key is provided" + } +} + +run "test_codex_with_scripts" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + pre_install_script = "echo 'Pre-install script'" + post_install_script = "echo 'Post-install script'" + } + + assert { + condition = length(output.scripts) == 3 + error_message = "scripts output should have 3 entries when pre/post are configured" + } +} + +run "test_script_outputs_install_only" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = length(output.scripts) == 1 && output.scripts[0] == "coder-labs-codex-install_script" + error_message = "scripts output should list only the install script when pre/post are not configured" + } +} + +run "test_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-codex-pre_install_script", "coder-labs-codex-install_script", "coder-labs-codex-post_install_script"] + error_message = "scripts output should list pre_install, install, post_install in run order" + } +} + +run "test_workdir_optional" { + command = plan + + variables { + agent_id = "test-agent" + } + + assert { + condition = length(output.scripts) == 1 + error_message = "scripts output should have install script even without workdir" } } diff --git a/registry/coder-labs/modules/codex/scripts/install.sh b/registry/coder-labs/modules/codex/scripts/install.sh deleted file mode 100644 index 9a191a02..00000000 --- a/registry/coder-labs/modules/codex/scripts/install.sh +++ /dev/null @@ -1,228 +0,0 @@ -#!/bin/bash -source "$HOME"/.bashrc - -BOLD='\033[0;1m' - -command_exists() { - command -v "$1" > /dev/null 2>&1 -} -set -o errexit -set -o pipefail -set -o nounset - -ARG_BASE_CONFIG_TOML=$(echo -n "$ARG_BASE_CONFIG_TOML" | base64 -d) -ARG_ADDITIONAL_MCP_SERVERS=$(echo -n "$ARG_ADDITIONAL_MCP_SERVERS" | base64 -d) -ARG_CODEX_INSTRUCTION_PROMPT=$(echo -n "$ARG_CODEX_INSTRUCTION_PROMPT" | base64 -d) -ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false} -ARG_AIBRIDGE_CONFIG=$(echo -n "$ARG_AIBRIDGE_CONFIG" | base64 -d) - -echo "=== Codex Module Configuration ===" -printf "Install Codex: %s\n" "$ARG_INSTALL" -printf "Codex Version: %s\n" "$ARG_CODEX_VERSION" -printf "App Slug: %s\n" "$ARG_CODER_MCP_APP_STATUS_SLUG" -printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}" -printf "Latest Codex Model: %s\n" "${ARG_LATEST_CODEX_MODEL}" -printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY" -printf "Has Base Config: %s\n" "$([ -n "$ARG_BASE_CONFIG_TOML" ] && echo "Yes" || echo "No")" -printf "Has Additional MCP: %s\n" "$([ -n "$ARG_ADDITIONAL_MCP_SERVERS" ] && echo "Yes" || echo "No")" -printf "Has System Prompt: %s\n" "$([ -n "$ARG_CODEX_INSTRUCTION_PROMPT" ] && echo "Yes" || echo "No")" -printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")" -printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS" -printf "Enable Coder AI Bridge: %s\n" "$ARG_ENABLE_AIBRIDGE" -echo "======================================" - -set +o nounset - -function install_node() { - if ! command_exists npm; then - printf "npm not found, checking for Node.js installation...\n" - if ! command_exists node; then - printf "Node.js not found, installing Node.js via NVM...\n" - export NVM_DIR="$HOME/.nvm" - if [ ! -d "$NVM_DIR" ]; then - mkdir -p "$NVM_DIR" - curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash - [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - else - [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - fi - - nvm install --lts - nvm use --lts - nvm alias default node - - printf "Node.js installed: %s\n" "$(node --version)" - printf "npm installed: %s\n" "$(npm --version)" - else - printf "Node.js is installed but npm is not available. Please install npm manually.\n" - exit 1 - fi - fi -} - -function install_codex() { - if [ "${ARG_INSTALL}" = "true" ]; then - install_node - - if ! command_exists nvm; then - printf "which node: %s\n" "$(which node)" - printf "which npm: %s\n" "$(which npm)" - - mkdir -p "$HOME"/.npm-global - - npm config set prefix "$HOME/.npm-global" - - export PATH="$HOME/.npm-global/bin:$PATH" - - if ! grep -q "export PATH=$HOME/.npm-global/bin:\$PATH" ~/.bashrc; then - echo "export PATH=$HOME/.npm-global/bin:\$PATH" >> ~/.bashrc - fi - fi - - printf "%s Installing Codex CLI\n" "${BOLD}" - - if [ -n "$ARG_CODEX_VERSION" ]; then - npm install -g "@openai/codex@$ARG_CODEX_VERSION" - else - npm install -g "@openai/codex" - fi - printf "%s Successfully installed Codex CLI. Version: %s\n" "${BOLD}" "$(codex --version)" - fi -} - -write_minimal_default_config() { - local config_path="$1" - - ARG_OPTIONAL_TOP_LEVEL_CONFIG="" - - if [[ "${ARG_ENABLE_AIBRIDGE}" = "true" ]]; then - ARG_OPTIONAL_TOP_LEVEL_CONFIG='model_provider = "aibridge"' - fi - - if [[ "${ARG_MODEL_REASONING_EFFORT}" != "" ]]; then - ARG_OPTIONAL_TOP_LEVEL_CONFIG+=$'\n'"model_reasoning_effort = \"${ARG_MODEL_REASONING_EFFORT}\"" - fi - - cat << EOF > "$config_path" -# Minimal Default Codex Configuration -sandbox_mode = "workspace-write" -approval_policy = "never" -preferred_auth_method = "apikey" -${ARG_OPTIONAL_TOP_LEVEL_CONFIG} - -[sandbox_workspace_write] -network_access = true - -[notice.model_migrations] -"${ARG_CODEX_MODEL}" = "${ARG_LATEST_CODEX_MODEL}" - -[projects."${ARG_CODEX_START_DIRECTORY}"] -trust_level = "trusted" - -EOF -} - -append_mcp_servers_section() { - local config_path="$1" - - if [ "${ARG_REPORT_TASKS}" == "false" ]; then - ARG_CODER_MCP_APP_STATUS_SLUG="" - CODER_MCP_AI_AGENTAPI_URL="" - else - CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" - fi - - cat << EOF >> "$config_path" - -# MCP Servers Configuration -[mcp_servers.Coder] -command = "coder" -args = ["exp", "mcp", "server"] -env = { "CODER_MCP_APP_STATUS_SLUG" = "${ARG_CODER_MCP_APP_STATUS_SLUG}", "CODER_MCP_AI_AGENTAPI_URL" = "${CODER_MCP_AI_AGENTAPI_URL}" , "CODER_AGENT_URL" = "${CODER_AGENT_URL}", "CODER_AGENT_TOKEN" = "${CODER_AGENT_TOKEN}", "CODER_MCP_ALLOWED_TOOLS" = "coder_report_task" } -description = "Report ALL tasks and statuses (in progress, done, failed) you are working on." -type = "stdio" - -EOF - - if [ -n "$ARG_ADDITIONAL_MCP_SERVERS" ]; then - printf "Adding additional MCP servers\n" - echo "$ARG_ADDITIONAL_MCP_SERVERS" >> "$config_path" - fi -} - -append_aibridge_config_section() { - local config_path="$1" - - if [ -n "$ARG_AIBRIDGE_CONFIG" ]; then - printf "Adding AI Bridge configuration\n" - echo -e "\n# AI Bridge Configuration\n$ARG_AIBRIDGE_CONFIG" >> "$config_path" - fi -} - -function populate_config_toml() { - CONFIG_PATH="$HOME/.codex/config.toml" - mkdir -p "$(dirname "$CONFIG_PATH")" - - if [ -n "$ARG_BASE_CONFIG_TOML" ]; then - printf "Using provided base configuration\n" - echo "$ARG_BASE_CONFIG_TOML" > "$CONFIG_PATH" - else - printf "Using minimal default configuration\n" - write_minimal_default_config "$CONFIG_PATH" - fi - - append_mcp_servers_section "$CONFIG_PATH" - - if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then - printf "AI Bridge is enabled\n" - append_aibridge_config_section "$CONFIG_PATH" - fi -} - -function add_instruction_prompt_if_exists() { - if [ -n "${ARG_CODEX_INSTRUCTION_PROMPT:-}" ]; then - AGENTS_PATH="$HOME/.codex/AGENTS.md" - printf "Creating AGENTS.md in .codex directory: %s\\n" "${AGENTS_PATH}" - - mkdir -p "$HOME/.codex" - - if [ -f "${AGENTS_PATH}" ] && grep -Fq "${ARG_CODEX_INSTRUCTION_PROMPT}" "${AGENTS_PATH}"; then - printf "AGENTS.md already contains the instruction prompt. Skipping append.\n" - else - printf "Appending instruction prompt to AGENTS.md in .codex directory\n" - echo -e "\n${ARG_CODEX_INSTRUCTION_PROMPT}" >> "${AGENTS_PATH}" - fi - - if [ ! -d "${ARG_CODEX_START_DIRECTORY}" ]; then - printf "Creating start directory '%s'\\n" "${ARG_CODEX_START_DIRECTORY}" - mkdir -p "${ARG_CODEX_START_DIRECTORY}" || { - printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}" - exit 1 - } - fi - else - printf "AGENTS.md instruction prompt is not set.\n" - fi -} - -function add_auth_json() { - AUTH_JSON_PATH="$HOME/.codex/auth.json" - mkdir -p "$(dirname "$AUTH_JSON_PATH")" - AUTH_JSON=$( - cat << EOF -{ - "OPENAI_API_KEY": "${ARG_OPENAI_API_KEY}" -} -EOF - ) - echo "$AUTH_JSON" > "$AUTH_JSON_PATH" -} - -install_codex -codex --version -populate_config_toml -add_instruction_prompt_if_exists - -if [ "$ARG_ENABLE_AIBRIDGE" = "false" ]; then - add_auth_json -fi diff --git a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl new file mode 100644 index 00000000..887efd9f --- /dev/null +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -0,0 +1,195 @@ +#!/bin/bash + +set -euo pipefail + +BOLD='\033[0;1m' + +command_exists() { + command -v "$1" > /dev/null 2>&1 +} + +ARG_INSTALL='${ARG_INSTALL}' +ARG_CODEX_VERSION=$(echo -n '${ARG_CODEX_VERSION}' | base64 -d) +ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d) +ARG_BASE_CONFIG_TOML=$(echo -n '${ARG_BASE_CONFIG_TOML}' | base64 -d) +ARG_ADDITIONAL_MCP_SERVERS=$(echo -n '${ARG_ADDITIONAL_MCP_SERVERS}' | base64 -d) +ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}' +ARG_AIBRIDGE_CONFIG=$(echo -n '${ARG_AIBRIDGE_CONFIG}' | base64 -d) +ARG_MODEL_REASONING_EFFORT='${ARG_MODEL_REASONING_EFFORT}' +ARG_OPENAI_API_KEY=$(echo -n '${ARG_OPENAI_API_KEY}' | base64 -d) + +echo "--------------------------------" +printf "codex_version: %s\n" "$${ARG_CODEX_VERSION}" +printf "workdir: %s\n" "$${ARG_WORKDIR}" +printf "enable_ai_gateway: %s\n" "$${ARG_ENABLE_AI_GATEWAY}" +printf "install_codex: %s\n" "$${ARG_INSTALL}" +printf "model_reasoning_effort: %s\n" "$${ARG_MODEL_REASONING_EFFORT}" +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_codex_in_path() { + local CODEX_BIN="" + if command -v codex > /dev/null 2>&1; then + CODEX_BIN=$(command -v codex) + elif [ -x "$HOME/.npm-global/bin/codex" ]; then + CODEX_BIN="$HOME/.npm-global/bin/codex" + fi + + if [ -z "$${CODEX_BIN}" ] || [ ! -x "$${CODEX_BIN}" ]; then + echo "Warning: Could not find codex binary after install" + return + fi + + local CODEX_DIR + CODEX_DIR=$(dirname "$${CODEX_BIN}") + + if [ -n "$${CODER_SCRIPT_BIN_DIR:-}" ] && [ ! -e "$${CODER_SCRIPT_BIN_DIR}/codex" ]; then + ln -s "$${CODEX_BIN}" "$${CODER_SCRIPT_BIN_DIR}/codex" + echo "Created symlink: $${CODER_SCRIPT_BIN_DIR}/codex -> $${CODEX_BIN}" + fi + + add_path_to_shell_profiles "$${CODEX_DIR}" +} + +function install_codex() { + if [ "$${ARG_INSTALL}" != "true" ]; then + echo "Skipping Codex installation as per configuration." + ensure_codex_in_path + return + fi + + if [ -s "$HOME/.nvm/nvm.sh" ]; then + export NVM_DIR="$HOME/.nvm" + . "$NVM_DIR/nvm.sh" + fi + + # Detect a package manager for global installs. + if command_exists npm; then + PKG_INSTALL="npm install -g" + if ! command_exists nvm; then + mkdir -p "$HOME/.npm-global" + npm config set prefix "$HOME/.npm-global" + export PATH="$HOME/.npm-global/bin:$PATH" + fi + elif command_exists pnpm; then + PKG_INSTALL="pnpm add -g" + elif command_exists bun; then + PKG_INSTALL="bun add -g" + else + echo "Error: npm, pnpm, or bun is required to install Codex. Install one of them first or set install_codex = false." + exit 1 + fi + + printf "%s Installing Codex CLI\n" "$${BOLD}" + + if [ -n "$${ARG_CODEX_VERSION}" ]; then + $PKG_INSTALL "@openai/codex@$${ARG_CODEX_VERSION}" + else + $PKG_INSTALL "@openai/codex" + fi + printf "%s Installed Codex CLI: %s\n" "$${BOLD}" "$(codex --version)" + ensure_codex_in_path +} + +function write_minimal_default_config() { + local config_path="$1" + local optional_config="" + + if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ]; then + optional_config='model_provider = "aigateway"' + fi + + if [ -n "$${ARG_MODEL_REASONING_EFFORT}" ]; then + optional_config+=$'\n'"model_reasoning_effort = \"$${ARG_MODEL_REASONING_EFFORT}\"" + fi + + cat << EOF > "$${config_path}" +preferred_auth_method = "apikey" +$${optional_config} + +EOF + + if [ -n "$${ARG_WORKDIR}" ]; then + cat << EOF >> "$${config_path}" +[projects."$${ARG_WORKDIR}"] +trust_level = "trusted" + +EOF + fi +} + +function populate_config_toml() { + local config_path="$HOME/.codex/config.toml" + mkdir -p "$(dirname "$${config_path}")" + + if [ -n "$${ARG_BASE_CONFIG_TOML}" ]; then + printf "Using provided base configuration\n" + echo "$${ARG_BASE_CONFIG_TOML}" > "$${config_path}" + else + printf "Using minimal default configuration\n" + write_minimal_default_config "$${config_path}" + fi + + if [ -n "$${ARG_ADDITIONAL_MCP_SERVERS}" ]; then + printf "Adding additional MCP servers\n" + echo "$${ARG_ADDITIONAL_MCP_SERVERS}" >> "$${config_path}" + fi + + if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] && [ -n "$${ARG_AIBRIDGE_CONFIG}" ]; then + if ! grep -q '\[model_providers\.aigateway\]' "$${config_path}" 2>/dev/null; then + printf "Adding AI Gateway configuration\n" + echo -e "\n$${ARG_AIBRIDGE_CONFIG}" >> "$${config_path}" + else + printf "AI Gateway provider already defined in config, skipping append\n" + fi + fi +} + +function setup_workdir() { + if [ -n "$${ARG_WORKDIR}" ] && [ ! -d "$${ARG_WORKDIR}" ]; then + echo "Creating workdir: $${ARG_WORKDIR}" + mkdir -p "$${ARG_WORKDIR}" + fi +} + +function add_auth_json() { + if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] || [ -z "$${ARG_OPENAI_API_KEY}" ]; then + return + fi + + local auth_path="$HOME/.codex/auth.json" + mkdir -p "$(dirname "$${auth_path}")" + + cat << EOF > "$${auth_path}" +{ + "auth_mode": "apikey", + "OPENAI_API_KEY": "$${ARG_OPENAI_API_KEY}" +} +EOF + echo "Seeded auth.json with API key" +} + +install_codex +populate_config_toml +setup_workdir +add_auth_json diff --git a/registry/coder-labs/modules/codex/scripts/start.sh b/registry/coder-labs/modules/codex/scripts/start.sh deleted file mode 100644 index bac0cb45..00000000 --- a/registry/coder-labs/modules/codex/scripts/start.sh +++ /dev/null @@ -1,229 +0,0 @@ -#!/bin/bash - -source "$HOME"/.bashrc -set -o errexit -set -o pipefail - -command_exists() { - command -v "$1" > /dev/null 2>&1 -} - -if [ -f "$HOME/.nvm/nvm.sh" ]; then - source "$HOME"/.nvm/nvm.sh -else - export PATH="$HOME/.npm-global/bin:$PATH" -fi - -printf "Version: %s\n" "$(codex --version)" -set -o nounset -ARG_CODEX_TASK_PROMPT=$(echo -n "$ARG_CODEX_TASK_PROMPT" | base64 -d) -ARG_CONTINUE=${ARG_CONTINUE:-true} -ARG_ENABLE_AIBRIDGE=${ARG_ENABLE_AIBRIDGE:-false} - -echo "=== Codex Launch Configuration ===" -printf "OpenAI API Key: %s\n" "$([ -n "$ARG_OPENAI_API_KEY" ] && echo "Provided" || echo "Not provided")" -printf "Codex Model: %s\n" "${ARG_CODEX_MODEL:-"Default"}" -printf "Start Directory: %s\n" "$ARG_CODEX_START_DIRECTORY" -printf "Has Task Prompt: %s\n" "$([ -n "$ARG_CODEX_TASK_PROMPT" ] && echo "Yes" || echo "No")" -printf "Report Tasks: %s\n" "$ARG_REPORT_TASKS" -printf "Continue Sessions: %s\n" "$ARG_CONTINUE" -printf "Enable Coder AI Bridge: %s\n" "$ARG_ENABLE_AIBRIDGE" -echo "======================================" -set +o nounset - -SESSION_TRACKING_FILE="$HOME/.codex-module/.codex-task-session" - -find_session_for_directory() { - local target_dir="$1" - - if [ ! -f "$SESSION_TRACKING_FILE" ]; then - return 1 - fi - - local session_id - session_id=$(grep "^$target_dir|" "$SESSION_TRACKING_FILE" | cut -d'|' -f2 | head -1) - - if [ -n "$session_id" ]; then - echo "$session_id" - return 0 - fi - - return 1 -} - -store_session_mapping() { - local dir="$1" - local session_id="$2" - - mkdir -p "$(dirname "$SESSION_TRACKING_FILE")" - - if [ -f "$SESSION_TRACKING_FILE" ]; then - grep -v "^$dir|" "$SESSION_TRACKING_FILE" > "$SESSION_TRACKING_FILE.tmp" 2> /dev/null || true - mv "$SESSION_TRACKING_FILE.tmp" "$SESSION_TRACKING_FILE" - fi - - echo "$dir|$session_id" >> "$SESSION_TRACKING_FILE" -} - -find_recent_session_file() { - local target_dir="$1" - local sessions_dir="$HOME/.codex/sessions" - - if [ ! -d "$sessions_dir" ]; then - return 1 - fi - - local latest_file="" - local latest_time=0 - - while IFS= read -r session_file; do - local file_time - file_time=$(stat -c %Y "$session_file" 2> /dev/null || stat -f %m "$session_file" 2> /dev/null || echo "0") - local first_line - first_line=$(head -n 1 "$session_file" 2> /dev/null) - local session_cwd - session_cwd=$(echo "$first_line" | grep -o '"cwd":"[^"]*"' | cut -d'"' -f4) - - if [ "$session_cwd" = "$target_dir" ] && [ "$file_time" -gt "$latest_time" ]; then - latest_file="$session_file" - latest_time="$file_time" - fi - done < <(find "$sessions_dir" -type f -name "*.jsonl" 2> /dev/null) - - if [ -n "$latest_file" ]; then - local first_line - first_line=$(head -n 1 "$latest_file") - local session_id - session_id=$(echo "$first_line" | grep -o '"id":"[^"]*"' | cut -d'"' -f4) - if [ -n "$session_id" ]; then - echo "$session_id" - return 0 - fi - fi - - return 1 -} - -wait_for_session_file() { - local target_dir="$1" - local max_attempts=20 - local attempt=0 - - while [ $attempt -lt $max_attempts ]; do - local session_id - session_id=$(find_recent_session_file "$target_dir" 2> /dev/null || echo "") - if [ -n "$session_id" ]; then - echo "$session_id" - return 0 - fi - sleep 0.5 - attempt=$((attempt + 1)) - done - - return 1 -} - -validate_codex_installation() { - if command_exists codex; then - printf "Codex is installed\n" - else - printf "Error: Codex is not installed. Please enable install_codex or install it manually\n" - exit 1 - fi -} - -setup_workdir() { - if [ -d "${ARG_CODEX_START_DIRECTORY}" ]; then - printf "Directory '%s' exists. Changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}" - cd "${ARG_CODEX_START_DIRECTORY}" || { - printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}" - exit 1 - } - else - printf "Directory '%s' does not exist. Creating and changing to it.\\n" "${ARG_CODEX_START_DIRECTORY}" - mkdir -p "${ARG_CODEX_START_DIRECTORY}" || { - printf "Error: Could not create directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}" - exit 1 - } - cd "${ARG_CODEX_START_DIRECTORY}" || { - printf "Error: Could not change to directory '%s'.\\n" "${ARG_CODEX_START_DIRECTORY}" - exit 1 - } - fi -} - -build_codex_args() { - CODEX_ARGS=() - - if [[ -n "${ARG_CODEX_MODEL}" ]]; then - CODEX_ARGS+=("--model" "${ARG_CODEX_MODEL}") - fi - - if [ "$ARG_CONTINUE" = "true" ]; then - existing_session=$(find_session_for_directory "$ARG_CODEX_START_DIRECTORY" 2> /dev/null || echo "") - - if [ -n "$existing_session" ]; then - printf "Found existing task session for this directory: %s\n" "$existing_session" - printf "Resuming existing session...\n" - CODEX_ARGS+=("resume" "$existing_session") - else - printf "No existing task session found for this directory\n" - printf "Starting new task session...\n" - - if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then - if [ "${ARG_REPORT_TASKS}" == "true" ]; then - PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT" - else - PROMPT="Your task at hand: $ARG_CODEX_TASK_PROMPT" - fi - CODEX_ARGS+=("$PROMPT") - fi - fi - else - printf "Continue disabled, starting fresh session\n" - - if [ -n "$ARG_CODEX_TASK_PROMPT" ]; then - if [ "${ARG_REPORT_TASKS}" == "true" ]; then - PROMPT="Complete the task at hand in one go. Every step of the way, report your progress using Coder.coder_report_task tool with proper summary and statuses. Your task at hand: $ARG_CODEX_TASK_PROMPT" - else - PROMPT="Your task at hand: $ARG_CODEX_TASK_PROMPT" - fi - CODEX_ARGS+=("$PROMPT") - fi - fi -} - -capture_session_id() { - if [ "$ARG_CONTINUE" = "true" ] && [ -z "$existing_session" ]; then - printf "Capturing new session ID...\n" - new_session=$(wait_for_session_file "$ARG_CODEX_START_DIRECTORY" || echo "") - - if [ -n "$new_session" ]; then - store_session_mapping "$ARG_CODEX_START_DIRECTORY" "$new_session" - printf "✓ Session tracked: %s\n" "$new_session" - printf "This session will be automatically resumed on next restart\n" - else - printf "⚠ Could not capture session ID after 10s timeout\n" - fi - fi -} - -start_codex() { - printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}" - # AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh when - # enable_boundary=true. It points to a wrapper script that runs the command - # through coder boundary, sandboxing only the agent process. - if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then - printf "Starting with coder boundary enabled\n" - agentapi server --type codex --term-width 67 --term-height 1190 -- \ - "${AGENTAPI_BOUNDARY_PREFIX}" codex "${CODEX_ARGS[@]}" & - else - agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" & - fi - capture_session_id -} - -validate_codex_installation -setup_workdir -build_codex_args -start_codex diff --git a/registry/coder-labs/modules/codex/testdata/codex-mock.sh b/registry/coder-labs/modules/codex/testdata/codex-mock.sh index fe8f3806..a73b70b5 100644 --- a/registry/coder-labs/modules/codex/testdata/codex-mock.sh +++ b/registry/coder-labs/modules/codex/testdata/codex-mock.sh @@ -1,38 +1,9 @@ #!/bin/bash -# Handle --version flag if [[ "$1" == "--version" ]]; then - echo "HELLO: $(bash -c env)" echo "codex version v1.0.0" exit 0 fi -set -e - -SESSION_ID="" -IS_RESUME=false - -while [[ $# -gt 0 ]]; do - case $1 in - resume) - IS_RESUME=true - SESSION_ID="$2" - shift 2 - ;; - *) - shift - ;; - esac -done - -if [ "$IS_RESUME" = false ]; then - SESSION_ID="019a1234-5678-9abc-def0-123456789012" - echo "Created new session: $SESSION_ID" -else - echo "Resuming session: $SESSION_ID" -fi - -while true; do - echo "$(date) - codex-mock (session: $SESSION_ID)" - sleep 15 -done +echo "codex invoked with: $*" +exit 0 From 6b8d89dabab8c390acf625e689557ad4b98b857b Mon Sep 17 00:00:00 2001 From: DevCats Date: Tue, 5 May 2026 12:31:09 -0500 Subject: [PATCH 3/7] fix(registry/coder-labs/modules/codex): align variable names with claude-code v5 (#885) Aligns codex module variable names with the claude-code v5 conventions established in #861 and #879. - Rename `additional_mcp_servers` to `mcp` to match claude-code's variable name. - Change `codex_version` default from `""` to `"latest"` to match `claude_code_version`. ## Type of Change - [ ] New module - [ ] New template - [x] Bug fix - [ ] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information **Path:** `registry/coder-labs/modules/codex` **Breaking change:** [x] Yes [ ] No > [!WARNING] > Breaking change for anyone referencing `additional_mcp_servers` by name. Since v5.0.0 was released and deleted on the same day (#879), this should have zero downstream impact. ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun fmt`) - [x] Changes tested locally ## Related Issues - Follow-up to #879 - Filed #886 to track adding `mcp_config_remote_path` support to codex --- *This PR was authored by Coder Agents.* --- registry/coder-labs/modules/codex/README.md | 2 +- registry/coder-labs/modules/codex/main.test.ts | 2 +- registry/coder-labs/modules/codex/main.tf | 12 ++++++------ .../modules/codex/scripts/install.sh.tftpl | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/registry/coder-labs/modules/codex/README.md b/registry/coder-labs/modules/codex/README.md index e524e632..08701fb1 100644 --- a/registry/coder-labs/modules/codex/README.md +++ b/registry/coder-labs/modules/codex/README.md @@ -101,7 +101,7 @@ module "codex" { preferred_auth_method = "apikey" EOT - additional_mcp_servers = <<-EOT + mcp = <<-EOT [mcp_servers.GitHub] command = "npx" args = ["-y", "@modelcontextprotocol/server-github"] diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts index 8e7e514c..f6180772 100644 --- a/registry/coder-labs/modules/codex/main.test.ts +++ b/registry/coder-labs/modules/codex/main.test.ts @@ -246,7 +246,7 @@ describe("codex", async () => { ].join("\n"); const { id, scripts } = await setup({ moduleVariables: { - additional_mcp_servers: additional, + mcp: additional, }, }); await runScripts(id, scripts); diff --git a/registry/coder-labs/modules/codex/main.tf b/registry/coder-labs/modules/codex/main.tf index 7aebd487..c23129bc 100644 --- a/registry/coder-labs/modules/codex/main.tf +++ b/registry/coder-labs/modules/codex/main.tf @@ -50,8 +50,8 @@ variable "install_codex" { variable "codex_version" { type = string - description = "The version of Codex to install. Empty string installs the latest available version." - default = "" + description = "The version of Codex to install." + default = "latest" } variable "openai_api_key" { @@ -75,16 +75,16 @@ variable "base_config_toml" { trust_level = "trusted" When non-empty, the value is written verbatim as the base of config.toml; - additional_mcp_servers and AI Gateway sections are still appended after it. + mcp and AI Gateway sections are still appended after it. Note: model_reasoning_effort and workdir trust are only applied in the default config. Include them in your custom config if needed. EOT default = "" } -variable "additional_mcp_servers" { +variable "mcp" { type = string - description = "Additional MCP servers configuration in TOML format." + description = "MCP server configurations in TOML format. When set, servers are appended to the Codex config.toml." default = "" } @@ -140,7 +140,7 @@ locals { ARG_CODEX_VERSION = var.codex_version != "" ? base64encode(var.codex_version) : "" ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" ARG_BASE_CONFIG_TOML = var.base_config_toml != "" ? base64encode(var.base_config_toml) : "" - ARG_ADDITIONAL_MCP_SERVERS = var.additional_mcp_servers != "" ? base64encode(var.additional_mcp_servers) : "" + ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : "" ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway) ARG_AIBRIDGE_CONFIG = var.enable_ai_gateway ? base64encode(local.aibridge_config) : "" ARG_MODEL_REASONING_EFFORT = var.model_reasoning_effort diff --git a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl index 887efd9f..584c978b 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh.tftpl +++ b/registry/coder-labs/modules/codex/scripts/install.sh.tftpl @@ -12,7 +12,7 @@ ARG_INSTALL='${ARG_INSTALL}' ARG_CODEX_VERSION=$(echo -n '${ARG_CODEX_VERSION}' | base64 -d) ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d) ARG_BASE_CONFIG_TOML=$(echo -n '${ARG_BASE_CONFIG_TOML}' | base64 -d) -ARG_ADDITIONAL_MCP_SERVERS=$(echo -n '${ARG_ADDITIONAL_MCP_SERVERS}' | base64 -d) +ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d) ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}' ARG_AIBRIDGE_CONFIG=$(echo -n '${ARG_AIBRIDGE_CONFIG}' | base64 -d) ARG_MODEL_REASONING_EFFORT='${ARG_MODEL_REASONING_EFFORT}' @@ -150,9 +150,9 @@ function populate_config_toml() { write_minimal_default_config "$${config_path}" fi - if [ -n "$${ARG_ADDITIONAL_MCP_SERVERS}" ]; then - printf "Adding additional MCP servers\n" - echo "$${ARG_ADDITIONAL_MCP_SERVERS}" >> "$${config_path}" + if [ -n "$${ARG_MCP}" ]; then + printf "Adding MCP servers\n" + echo "$${ARG_MCP}" >> "$${config_path}" fi if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] && [ -n "$${ARG_AIBRIDGE_CONFIG}" ]; then From bce0897099e7cca4817f6ea4c75dd710fec209ac Mon Sep 17 00:00:00 2001 From: Harsh Singh Panwar Date: Thu, 7 May 2026 00:20:20 +0530 Subject: [PATCH 4/7] Fix(gemini): the Coder MCP server configuration (#882) ## Description Fixed the Coder MCP server configuration * Added the full path to the coder binary for Gemini * Removed unnecessary configuration fields Screenshot 2026-05-04 120727 Screenshot 2026-05-04 120836 ## Type of Change - [ ] New module - [ ] New template - [x] Bug fix - [ ] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information **Path:** `registry/coder-labs/modules/gemini` **New version:** `v1.0.0` **Breaking change:** [ ] Yes [ ] No ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun fmt`) - [x] Changes tested locally ## Related Issues fix: #881 --- registry/coder-labs/modules/gemini/README.md | 24 +++++++++++++++---- registry/coder-labs/modules/gemini/main.tf | 10 ++------ .../modules/gemini/scripts/install.sh | 21 ++++++++++++++++ 3 files changed, 43 insertions(+), 12 deletions(-) diff --git a/registry/coder-labs/modules/gemini/README.md b/registry/coder-labs/modules/gemini/README.md index d0a113a0..57842ac6 100644 --- a/registry/coder-labs/modules/gemini/README.md +++ b/registry/coder-labs/modules/gemini/README.md @@ -13,7 +13,7 @@ Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace ```tf module "gemini" { source = "registry.coder.com/coder-labs/gemini/coder" - version = "3.0.0" + version = "3.0.1" agent_id = coder_agent.main.id folder = "/home/coder/project" } @@ -46,7 +46,7 @@ variable "gemini_api_key" { module "gemini" { source = "registry.coder.com/coder-labs/gemini/coder" - version = "3.0.0" + version = "3.0.1" agent_id = coder_agent.main.id gemini_api_key = var.gemini_api_key folder = "/home/coder/project" @@ -94,7 +94,7 @@ data "coder_parameter" "ai_prompt" { module "gemini" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder-labs/gemini/coder" - version = "3.0.0" + version = "3.0.1" agent_id = coder_agent.main.id gemini_api_key = var.gemini_api_key gemini_model = "gemini-2.5-flash" @@ -105,6 +105,22 @@ module "gemini" { You are a helpful coding assistant. Always explain your code changes clearly. YOU MUST REPORT ALL TASKS TO CODER. EOT + pre_install_script = <<-EOT + #!/bin/bash + set -e + + echo "Installing Node.js via NodeSource..." + + sudo apt-get update -qq && sudo apt-get install -y curl ca-certificates + + curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo bash - + + sudo apt-get install -y nodejs + + echo "Node version: $(node -v)" + echo "npm version: $(npm -v)" + echo "Node install complete." + EOT } ``` @@ -118,7 +134,7 @@ For enterprise users who prefer Google's Vertex AI platform: ```tf module "gemini" { source = "registry.coder.com/coder-labs/gemini/coder" - version = "3.0.0" + version = "3.0.1" agent_id = coder_agent.main.id gemini_api_key = var.gemini_api_key folder = "/home/coder/project" diff --git a/registry/coder-labs/modules/gemini/main.tf b/registry/coder-labs/modules/gemini/main.tf index dbc81bc7..336c112f 100644 --- a/registry/coder-labs/modules/gemini/main.tf +++ b/registry/coder-labs/modules/gemini/main.tf @@ -148,22 +148,16 @@ locals { base_extensions = <<-EOT { "coder": { + "command": "coder", "args": [ "exp", "mcp", "server" ], - "command": "coder", - "description": "Report ALL tasks and statuses (in progress, done, failed) you are working on.", - "enabled": true, "env": { "CODER_MCP_APP_STATUS_SLUG": "${local.app_slug}", "CODER_MCP_AI_AGENTAPI_URL": "http://localhost:3284" - }, - "name": "Coder", - "timeout": 3000, - "type": "stdio", - "trust": true + } } } EOT diff --git a/registry/coder-labs/modules/gemini/scripts/install.sh b/registry/coder-labs/modules/gemini/scripts/install.sh index 7b70a6af..ce44beb9 100644 --- a/registry/coder-labs/modules/gemini/scripts/install.sh +++ b/registry/coder-labs/modules/gemini/scripts/install.sh @@ -17,6 +17,7 @@ echo "--------------------------------" printf "gemini_config: %s\n" "$ARG_GEMINI_CONFIG" printf "install: %s\n" "$ARG_INSTALL" printf "gemini_version: %s\n" "$ARG_GEMINI_VERSION" +printf "BASE_EXTENSIONS: %s\n" "$BASE_EXTENSIONS" echo "--------------------------------" set +o nounset @@ -140,6 +141,25 @@ function add_system_prompt_if_exists() { fi } +function patch_coder_mcp_command() { + CODER_BIN=$(which coder) + SETTINGS_PATH="$HOME/.gemini/settings.json" + + if [ -z "$CODER_BIN" ]; then + printf "Warning: could not find coder binary, MCP command path not patched.\n" + return + fi + + printf "Patching coder MCP command path to: %s\n" "$CODER_BIN" + + TMP_SETTINGS=$(mktemp) + jq --arg bin "$CODER_BIN" \ + '.mcpServers.coder.command = $bin' \ + "$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH" + + printf "Patch complete.\n" +} + function configure_mcp() { export CODER_MCP_APP_STATUS_SLUG="gemini" export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" @@ -149,4 +169,5 @@ function configure_mcp() { install_gemini populate_settings_json add_system_prompt_if_exists +patch_coder_mcp_command configure_mcp From 297b07190f548d417e6466d478459ed21feb79d2 Mon Sep 17 00:00:00 2001 From: ikkz Date: Sun, 10 May 2026 06:00:43 +0800 Subject: [PATCH 5/7] feat(git-clone): add pre_clone_script parameter (#887) ## Summary Add `pre_clone_script` parameter to the git-clone module, allowing users to run custom scripts before cloning a repository. ## Use Case This solves SSH host key verification issues (e.g., "Host key verification failed") by enabling users to configure SSH settings before the clone operation, such as adding known hosts or setting `StrictHostKeyChecking no`. ```tf module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" version = "1.3.0" agent_id = coder_agent.example.id url = "git@github.com:org/repo.git" pre_clone_script = <<-EOT #!/bin/bash mkdir -p ~/.ssh echo -e "Host github.com\n StrictHostKeyChecking no\n" > ~/.ssh/config chmod 600 ~/.ssh/config EOT } ``` Ref: https://discord.com/channels/747933592273027093/1447777180695396452/1447777180695396452 ## Type of Change - [ ] New module - [ ] New template - [ ] Bug fix - [x] Feature/enhancement - [ ] Documentation - [ ] Other ## Module Information **Path:** `registry/coder/modules/git-clone` **New version:** `v1.3.0` **Breaking change:** [ ] Yes [x] No ## Testing & Validation - [x] Tests pass (`bun test`) - [x] Code formatted (`bun fmt`) - [x] Changes tested locally Co-authored-by: DevCats --- registry/coder/modules/git-clone/README.md | 45 ++++++++++++++----- registry/coder/modules/git-clone/main.test.ts | 12 +++++ registry/coder/modules/git-clone/main.tf | 9 ++++ registry/coder/modules/git-clone/run.sh | 11 +++++ 4 files changed, 66 insertions(+), 11 deletions(-) diff --git a/registry/coder/modules/git-clone/README.md b/registry/coder/modules/git-clone/README.md index b4f2a75c..9c61941c 100644 --- a/registry/coder/modules/git-clone/README.md +++ b/registry/coder/modules/git-clone/README.md @@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.3" + version = "1.3.0" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" } @@ -28,7 +28,7 @@ module "git-clone" { module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.3" + version = "1.3.0" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" base_dir = "~/projects/coder" @@ -43,7 +43,7 @@ To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-prov module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.3" + version = "1.3.0" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" } @@ -70,7 +70,7 @@ data "coder_parameter" "git_repo" { module "git_clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.3" + version = "1.3.0" agent_id = coder_agent.example.id url = data.coder_parameter.git_repo.value } @@ -105,7 +105,7 @@ Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `g module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.3" + version = "1.3.0" agent_id = coder_agent.example.id url = "https://github.example.com/coder/coder/tree/feat/example" git_providers = { @@ -125,7 +125,7 @@ To GitLab clone with a specific branch like `feat/example` module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.3" + version = "1.3.0" agent_id = coder_agent.example.id url = "https://gitlab.com/coder/coder/-/tree/feat/example" } @@ -137,7 +137,7 @@ Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com` module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.3" + version = "1.3.0" agent_id = coder_agent.example.id url = "https://gitlab.example.com/coder/coder/-/tree/feat/example" git_providers = { @@ -159,7 +159,7 @@ For example, to clone the `feat/example` branch: module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.3" + version = "1.3.0" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" branch_name = "feat/example" @@ -177,7 +177,7 @@ For example, this will clone into the `~/projects/coder/coder-dev` folder: module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.3" + version = "1.3.0" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" folder_name = "coder-dev" @@ -196,13 +196,36 @@ If not defined, the default, `0`, performs a full clone. module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.3" + version = "1.3.0" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" depth = 1 } ``` +## Pre-clone script + +Run a custom script before cloning the repository by setting the `pre_clone_script` variable. +This is useful for preparing the environment or validating prerequisites before cloning. + +```tf +module "git-clone" { + count = data.coder_workspace.me.start_count + source = "registry.coder.com/coder/git-clone/coder" + version = "1.3.0" + agent_id = coder_agent.example.id + url = "https://github.com/coder/coder" + pre_clone_script = <<-EOT + #!/bin/bash + echo "Preparing to clone repository..." + # Check prerequisites + command -v npm >/dev/null 2>&1 || { echo "npm is required but not installed."; exit 1; } + # Set up environment + export NODE_ENV=development + EOT +} +``` + ## Post-clone script Run a custom script after cloning the repository by setting the `post_clone_script` variable. @@ -212,7 +235,7 @@ This is useful for running initialization tasks like installing dependencies or module "git-clone" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/git-clone/coder" - version = "1.2.3" + version = "1.3.0" agent_id = coder_agent.example.id url = "https://github.com/coder/coder" post_clone_script = <<-EOT diff --git a/registry/coder/modules/git-clone/main.test.ts b/registry/coder/modules/git-clone/main.test.ts index 0ae0a8db..922f4028 100644 --- a/registry/coder/modules/git-clone/main.test.ts +++ b/registry/coder/modules/git-clone/main.test.ts @@ -261,4 +261,16 @@ describe("git-clone", async () => { expect(output.stdout).toContain("Running post-clone script..."); expect(output.stdout).toContain("Post-clone script executed"); }); + + it("runs pre-clone script", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + url: "fake-url", + pre_clone_script: "echo 'Pre-clone script executed'", + }); + const output = await executeScriptInContainer(state, "alpine/git"); + expect(output.stdout).toContain("Running pre-clone script..."); + expect(output.stdout).toContain("Pre-clone script executed"); + expect(output.stdout).toContain("Cloning fake-url to ~/fake-url..."); + }); }); diff --git a/registry/coder/modules/git-clone/main.tf b/registry/coder/modules/git-clone/main.tf index 2d547ad2..1fb28a4d 100644 --- a/registry/coder/modules/git-clone/main.tf +++ b/registry/coder/modules/git-clone/main.tf @@ -68,6 +68,12 @@ variable "post_clone_script" { default = null } +variable "pre_clone_script" { + description = "Custom script to run before cloning the repository. Runs before git clone, even if the repository already exists." + type = string + default = null +} + locals { # Remove query parameters and fragments from the URL url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "") @@ -89,6 +95,8 @@ locals { web_url = startswith(local.clone_url, "git@") ? replace(replace(local.clone_url, ":", "/"), "git@", "https://") : local.clone_url # Encode the post_clone_script for passing to the shell script encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : "" + # Encode the pre_clone_script for passing to the shell script + encoded_pre_clone_script = var.pre_clone_script != null ? base64encode(var.pre_clone_script) : "" } output "repo_dir" { @@ -129,6 +137,7 @@ resource "coder_script" "git_clone" { BRANCH_NAME : local.branch_name, DEPTH = var.depth, POST_CLONE_SCRIPT : local.encoded_post_clone_script, + PRE_CLONE_SCRIPT : local.encoded_pre_clone_script, }) display_name = "Git Clone" icon = "/icon/git.svg" diff --git a/registry/coder/modules/git-clone/run.sh b/registry/coder/modules/git-clone/run.sh index c088e4d0..03050349 100644 --- a/registry/coder/modules/git-clone/run.sh +++ b/registry/coder/modules/git-clone/run.sh @@ -7,6 +7,7 @@ BRANCH_NAME="${BRANCH_NAME}" CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}" DEPTH="${DEPTH}" POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}" +PRE_CLONE_SCRIPT="${PRE_CLONE_SCRIPT}" # Check if the variable is empty... if [ -z "$REPO_URL" ]; then @@ -33,6 +34,16 @@ if [ ! -d "$CLONE_PATH" ]; then mkdir -p "$CLONE_PATH" fi +# Run pre-clone script if provided +if [ -n "$PRE_CLONE_SCRIPT" ]; then + echo "Running pre-clone script..." + PRE_CLONE_TMP=$(mktemp) + echo "$PRE_CLONE_SCRIPT" | base64 -d > "$PRE_CLONE_TMP" + chmod +x "$PRE_CLONE_TMP" + $PRE_CLONE_TMP + rm "$PRE_CLONE_TMP" +fi + # Check if the directory is empty # and if it is, clone the repo, otherwise skip cloning if [ -z "$(ls -A "$CLONE_PATH")" ]; then From 99510a1f75688ed387184fad515bd93fd1fc1e95 Mon Sep 17 00:00:00 2001 From: 35C4n0r <70096901+35C4n0r@users.noreply.github.com> Date: Sun, 10 May 2026 11:53:37 +0530 Subject: [PATCH 6/7] feat(coder/modules/boundary): add agent-firewall module (#840) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Description Extracts boundary installation and wrapper logic into a standalone `coder/agent-firewall` module, decoupling it from `agentapi`. ### Why Boundary is currently embedded inside `agentapi` (`scripts/boundary.sh`) and duplicated in `claude-code`. This couples network isolation to the AI/Tasks stack, but boundary is a general-purpose primitive — users running a plain agent with no agentapi or tasks should be able to use it too. ### What this adds `registry/coder/modules/agent-firewall/` — a new first-class module that: * Installs boundary via one of three strategies: 1. `coder boundary` subcommand (default, zero-install) 2. Direct binary from release (`use_agent_firewall_directly = true`) 3. Compiled from source (`compile_agent_firewall_from_source = true`) * Ships a comprehensive [default allowlist config](registry/coder/modules/agent-firewall/config.yaml.tftpl) (Anthropic, OpenAI, VCS, package managers, cloud platforms, etc.) * Auto-fills the Coder deployment domain via `data.coder_workspace.me.access_url` * Supports inline config (`agent_firewall_config`) or external file (`agent_firewall_config_path`), mutually exclusive with cross-variable validation * Creates a wrapper script at `$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh` * Strips `CAP_NET_ADMIN` from the coder binary (copies to `coder-no-caps`) to allow execution inside network namespaces without `sys_admin` * Supports `pre_install_script` / `post_install_script` hooks * Exposes `agent_firewall_wrapper_path`, `agent_firewall_config_path`, and `scripts` outputs for script coordination * No env vars exported — everything is output-only ### Usage ```tf module "agent-firewall" { source = "registry.coder.com/coder/agent-firewall/coder" version = "0.0.1" agent_id = coder_agent.main.id } ``` Works standalone with any agent — no agentapi dependency required. ### Testing * 8 Terraform plan tests (`agent-firewall.tftest.hcl`): default outputs, compile from source, use directly, custom hooks, custom module directory, inline config, external config path, mutual exclusion validation * TypeScript integration tests (`main.test.ts`): state verification, coder subcommand happy path, inline config, config path skip, custom hooks, env var absence, wrapper execution, idempotent installation ## Type of Change - [X] New module ## Module Information **Path:** `registry/coder/modules/agent-firewall`
**New version:** `v0.0.1`
**Breaking change:** No ## Related Issues Closes coder/registry#844 🤖 Generated by Coder Agents --------- Co-authored-by: Jay Kumar --- .../coder/modules/agent-firewall/README.md | 146 +++++++ .../agent-firewall/agent-firewall.tftest.hcl | 157 ++++++++ .../modules/agent-firewall/config.yaml.tftpl | 218 ++++++++++ .../coder/modules/agent-firewall/main.test.ts | 376 ++++++++++++++++++ registry/coder/modules/agent-firewall/main.tf | 128 ++++++ .../agent-firewall/scripts/install.sh.tftpl | 131 ++++++ .../agent-firewall/testdata/coder-mock.sh | 38 ++ 7 files changed, 1194 insertions(+) create mode 100644 registry/coder/modules/agent-firewall/README.md create mode 100644 registry/coder/modules/agent-firewall/agent-firewall.tftest.hcl create mode 100644 registry/coder/modules/agent-firewall/config.yaml.tftpl create mode 100644 registry/coder/modules/agent-firewall/main.test.ts create mode 100644 registry/coder/modules/agent-firewall/main.tf create mode 100644 registry/coder/modules/agent-firewall/scripts/install.sh.tftpl create mode 100644 registry/coder/modules/agent-firewall/testdata/coder-mock.sh diff --git a/registry/coder/modules/agent-firewall/README.md b/registry/coder/modules/agent-firewall/README.md new file mode 100644 index 00000000..42cd2b82 --- /dev/null +++ b/registry/coder/modules/agent-firewall/README.md @@ -0,0 +1,146 @@ +--- +display_name: Agent Firewall +description: Configures agent-firewall for network isolation in Coder workspaces +icon: ../../../../.icons/coder.svg +verified: true +tags: [agent-firewall, ai, agents, firewall, boundary] +--- + +# Agent Firewall + +Installs [agent-firewall](https://coder.com/docs/ai-coder/agent-firewall) for network isolation in Coder workspaces. + +This module: + +- Installs agent-firewall (via coder subcommand, direct installation, or compilation from source) +- Creates a wrapper script at `$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh` +- Writes a [default agent-firewall config](https://github.com/coder/registry/blob/main/registry/coder/modules/agent-firewall/config.yaml.tftpl) to `$HOME/.coder-modules/coder/agent-firewall/config/config.yaml` (customizable) +- Provides the wrapper path, config path, and script names via outputs +- Uses coder-utils and output `scripts` for synchronization. https://registry.coder.com/modules/coder/coder-utils?tab=outputs + +```tf +module "agent-firewall" { + source = "registry.coder.com/coder/agent-firewall/coder" + version = "0.0.1" + agent_id = coder_agent.main.id +} +``` + +## Examples + +Use the `agent_firewall_wrapper_path` output to access the wrapper path and `agent_firewall_config_path` to access config path in Terraform and pass it to scripts that should run commands in network isolation. + +### With Claude Code + +Use agent-firewall alongside the `claude-code` module to run Claude in a +network-isolated environment. + +#### As an automated task + +```tf +module "agent-firewall" { + source = "registry.coder.com/coder/agent-firewall/coder" + version = "0.0.1" + agent_id = coder_agent.main.id +} + +resource "coder_script" "claude_with_agent_firewall" { + agent_id = coder_agent.main.id + display_name = "Claude (Agent Firewall)" + run_on_start = true + script = <<-EOT + #!/bin/bash + set -e + coder exp sync want claude-agent-firewall \ + ${join(" ", module.agent-firewall.scripts)} \ + ${join(" ", module.claude-code.scripts)} + coder exp sync start claude-agent-firewall + "${module.agent-firewall.agent_firewall_wrapper_path}" --config="${module.agent-firewall.agent_firewall_config_path}" -- claude -p "Fix issue #840 from coder/coder" + EOT +} +``` + +#### As a Coder app + +```tf +module "agent-firewall" { + source = "registry.coder.com/coder/agent-firewall/coder" + version = "0.0.1" + agent_id = coder_agent.main.id +} + +resource "coder_app" "claude_with_agent_firewall" { + agent_id = coder_agent.main.id + display_name = "Claude Code" + slug = "claude-code" + command = <<-EOT + #!/bin/bash + set -e + exec tmux new-session -A -s claude-code \ + '"${module.agent-firewall.agent_firewall_wrapper_path}" --config="${module.agent-firewall.agent_firewall_config_path}" -- claude' + EOT +} +``` + +## Configuration + +The module ships with a comprehensive default config based on the +[Coder dogfood allowlist](https://github.com/coder/coder/blob/main/dogfood/coder/boundary-config.yaml). It covers Anthropic services, +OpenAI services, version control, package managers, container registries, +cloud platforms, and common development tools. + +The Coder deployment domain is automatically added to the allowlist using +`data.coder_workspace.me.access_url`. + +By default the config is written to +`$HOME/.coder-modules/coder/agent-firewall/config/config.yaml`. You can +access the resolved path via the `agent_firewall_config_path` output. Override +it in two ways: + +### Inline config + +Pass the full YAML content directly: + +```tf +module "agent-firewall" { + source = "registry.coder.com/coder/agent-firewall/coder" + version = "0.0.1" + agent_id = coder_agent.main.id + + agent_firewall_config = <<-YAML + allowlist: + - domain=your-deployment.coder.com + - domain=api.anthropic.com + - domain=api.openai.com + log_dir: /tmp/agent_firewall_logs + proxy_port: 8087 + log_level: warn + YAML +} +``` + +### External config file + +Point to an existing config file in the workspace. The module will not +write any config and the `agent_firewall_config_path` output will point to +your path. The file must exist on disk before agent-firewall starts. + +```tf +module "agent-firewall" { + source = "registry.coder.com/coder/agent-firewall/coder" + version = "0.0.1" + agent_id = coder_agent.main.id + + agent_firewall_config_path = "/workspace/my-agent-firewall-config.yaml" +} +``` + +> **Note:** `agent_firewall_config` and `agent_firewall_config_path` are mutually +> exclusive, setting both produces a validation error. + +See the [Agent Firewall docs](https://coder.com/docs/ai-coder/agent-firewall) +for the full config reference. + +## References + +- [Agent Firewall Documentation](https://coder.com/docs/ai-coder/agent-firewall) diff --git a/registry/coder/modules/agent-firewall/agent-firewall.tftest.hcl b/registry/coder/modules/agent-firewall/agent-firewall.tftest.hcl new file mode 100644 index 00000000..3b4b0d9e --- /dev/null +++ b/registry/coder/modules/agent-firewall/agent-firewall.tftest.hcl @@ -0,0 +1,157 @@ +# Test for agent-firewall module + +run "plan_with_required_vars" { + command = plan + + variables { + agent_id = "test-agent-id" + } + + # Verify the agent_firewall_wrapper_path output + assert { + condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh" + error_message = "agent_firewall_wrapper_path output should be correct" + } + + # Verify agent_firewall_config_path output defaults to the managed path + assert { + condition = output.agent_firewall_config_path == "$HOME/.coder-modules/coder/agent-firewall/config/config.yaml" + error_message = "agent_firewall_config_path output should default to managed config path" + } + + # Verify the scripts output contains the install script name + assert { + condition = contains(output.scripts, "coder-agent-firewall-install_script") + error_message = "scripts should contain the install script name" + } +} + +run "plan_with_compile_from_source" { + command = plan + + variables { + agent_id = "test-agent-id" + compile_agent_firewall_from_source = true + agent_firewall_version = "main" + } + + assert { + condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh" + error_message = "agent_firewall_wrapper_path output should be correct" + } + + assert { + condition = contains(output.scripts, "coder-agent-firewall-install_script") + error_message = "scripts should contain the install script name" + } +} + +run "plan_with_use_directly" { + command = plan + + variables { + agent_id = "test-agent-id" + use_agent_firewall_directly = true + agent_firewall_version = "latest" + } + + assert { + condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh" + error_message = "agent_firewall_wrapper_path output should be correct" + } + + assert { + condition = contains(output.scripts, "coder-agent-firewall-install_script") + error_message = "scripts should contain the install script name" + } +} + +run "plan_with_custom_hooks" { + command = plan + + variables { + agent_id = "test-agent-id" + pre_install_script = "echo 'Before install'" + post_install_script = "echo 'After install'" + } + + assert { + condition = contains(output.scripts, "coder-agent-firewall-install_script") + error_message = "scripts should contain the install script name" + } + + # Verify pre and post install script names are set + assert { + condition = contains(output.scripts, "coder-agent-firewall-pre_install_script") + error_message = "scripts should contain the pre_install script name" + } + + assert { + condition = contains(output.scripts, "coder-agent-firewall-post_install_script") + error_message = "scripts should contain the post_install script name" + } +} + +run "plan_with_custom_module_directory" { + command = plan + + variables { + agent_id = "test-agent-id" + module_directory = "$HOME/.coder-modules/custom/agent-firewall" + } + + assert { + condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/custom/agent-firewall/scripts/agent-firewall-wrapper.sh" + error_message = "agent_firewall_wrapper_path output should use custom module directory" + } + + # Config path should also follow the module directory + assert { + condition = output.agent_firewall_config_path == "$HOME/.coder-modules/custom/agent-firewall/config/config.yaml" + error_message = "agent_firewall_config_path output should use custom module directory" + } +} + +run "plan_with_inline_config" { + command = plan + + variables { + agent_id = "test-agent-id" + agent_firewall_config = "allowlist:\n - domain=example.com\nlog_level: debug\n" + } + + # Inline config should still point to the managed path. + assert { + condition = output.agent_firewall_config_path == "$HOME/.coder-modules/coder/agent-firewall/config/config.yaml" + error_message = "agent_firewall_config_path output should point to managed config path" + } +} + +run "plan_with_config_path" { + command = plan + + variables { + agent_id = "test-agent-id" + agent_firewall_config_path = "/workspace/my-boundary-config.yaml" + } + + # agent_firewall_config_path output should point to the user-provided path. + assert { + condition = output.agent_firewall_config_path == "/workspace/my-boundary-config.yaml" + error_message = "agent_firewall_config_path output should point to user-provided path" + } +} + +run "plan_with_both_configs_should_fail" { + command = plan + + variables { + agent_id = "test-agent-id" + agent_firewall_config = "allowlist: []" + agent_firewall_config_path = "/workspace/config.yaml" + } + + expect_failures = [ + var.agent_firewall_config, + ] +} diff --git a/registry/coder/modules/agent-firewall/config.yaml.tftpl b/registry/coder/modules/agent-firewall/config.yaml.tftpl new file mode 100644 index 00000000..6ffea4a1 --- /dev/null +++ b/registry/coder/modules/agent-firewall/config.yaml.tftpl @@ -0,0 +1,218 @@ +allowlist: + - domain=${CODER_DOMAIN} + + # Anthropic Services + - domain=api.anthropic.com + - domain=statsig.anthropic.com + - domain=claude.ai + + # OpenAI Services + - domain=api.openai.com + - domain=platform.openai.com + - domain=openai.com + - domain=chatgpt.com + - domain=*.oaiusercontent.com + - domain=*.oaistatic.com + + # Version Control + - domain=github.com + - domain=www.github.com + - domain=api.github.com + - domain=raw.githubusercontent.com + - domain=objects.githubusercontent.com + - domain=codeload.github.com + - domain=avatars.githubusercontent.com + - domain=camo.githubusercontent.com + - domain=gist.github.com + - domain=gitlab.com + - domain=www.gitlab.com + - domain=registry.gitlab.com + - domain=bitbucket.org + - domain=www.bitbucket.org + - domain=api.bitbucket.org + + # Container Registries + - domain=registry-1.docker.io + - domain=auth.docker.io + - domain=index.docker.io + - domain=hub.docker.com + - domain=www.docker.com + - domain=production.cloudflare.docker.com + - domain=download.docker.com + - domain=*.gcr.io + - domain=ghcr.io + - domain=mcr.microsoft.com + - domain=*.data.mcr.microsoft.com + + # Cloud Platforms + - domain=cloud.google.com + - domain=accounts.google.com + - domain=gcloud.google.com + - domain=*.googleapis.com + - domain=storage.googleapis.com + - domain=compute.googleapis.com + - domain=container.googleapis.com + - domain=azure.com + - domain=portal.azure.com + - domain=microsoft.com + - domain=www.microsoft.com + - domain=*.microsoftonline.com + - domain=packages.microsoft.com + - domain=dotnet.microsoft.com + - domain=dot.net + - domain=visualstudio.com + - domain=dev.azure.com + - domain=oracle.com + - domain=www.oracle.com + - domain=java.com + - domain=www.java.com + - domain=java.net + - domain=www.java.net + - domain=download.oracle.com + - domain=yum.oracle.com + + # Package Managers - JavaScript/Node + - domain=registry.npmjs.org + - domain=www.npmjs.com + - domain=www.npmjs.org + - domain=npmjs.com + - domain=npmjs.org + - domain=yarnpkg.com + - domain=registry.yarnpkg.com + + # Package Managers - Python + - domain=pypi.org + - domain=www.pypi.org + - domain=files.pythonhosted.org + - domain=pythonhosted.org + - domain=test.pypi.org + - domain=pypi.python.org + - domain=pypa.io + - domain=www.pypa.io + + # Package Managers - Ruby + - domain=rubygems.org + - domain=www.rubygems.org + - domain=api.rubygems.org + - domain=index.rubygems.org + - domain=ruby-lang.org + - domain=www.ruby-lang.org + - domain=rubyforge.org + - domain=www.rubyforge.org + - domain=rubyonrails.org + - domain=www.rubyonrails.org + - domain=rvm.io + - domain=get.rvm.io + + # Package Managers - Rust + - domain=crates.io + - domain=www.crates.io + - domain=static.crates.io + - domain=rustup.rs + - domain=static.rust-lang.org + - domain=www.rust-lang.org + + # Package Managers - Go + - domain=proxy.golang.org + - domain=sum.golang.org + - domain=index.golang.org + - domain=golang.org + - domain=www.golang.org + - domain=go.dev + - domain=dl.google.com + - domain=goproxy.io + - domain=pkg.go.dev + + # Package Managers - JVM + - domain=maven.org + - domain=repo.maven.org + - domain=central.maven.org + - domain=repo1.maven.org + - domain=jcenter.bintray.com + - domain=gradle.org + - domain=www.gradle.org + - domain=services.gradle.org + - domain=spring.io + - domain=repo.spring.io + + # Package Managers - Other Languages + - domain=packagist.org + - domain=www.packagist.org + - domain=repo.packagist.org + - domain=nuget.org + - domain=www.nuget.org + - domain=api.nuget.org + - domain=pub.dev + - domain=api.pub.dev + - domain=hex.pm + - domain=www.hex.pm + - domain=cpan.org + - domain=www.cpan.org + - domain=metacpan.org + - domain=www.metacpan.org + - domain=api.metacpan.org + - domain=cocoapods.org + - domain=www.cocoapods.org + - domain=cdn.cocoapods.org + - domain=haskell.org + - domain=www.haskell.org + - domain=hackage.haskell.org + - domain=swift.org + - domain=www.swift.org + + # Linux Distributions + - domain=archive.ubuntu.com + - domain=security.ubuntu.com + - domain=ubuntu.com + - domain=www.ubuntu.com + - domain=*.ubuntu.com + - domain=ppa.launchpad.net + - domain=launchpad.net + - domain=www.launchpad.net + + # Development Tools & Platforms + - domain=dl.k8s.io + - domain=pkgs.k8s.io + - domain=k8s.io + - domain=www.k8s.io + - domain=releases.hashicorp.com + - domain=apt.releases.hashicorp.com + - domain=rpm.releases.hashicorp.com + - domain=archive.releases.hashicorp.com + - domain=hashicorp.com + - domain=www.hashicorp.com + - domain=repo.anaconda.com + - domain=conda.anaconda.org + - domain=anaconda.org + - domain=www.anaconda.com + - domain=anaconda.com + - domain=continuum.io + - domain=apache.org + - domain=www.apache.org + - domain=archive.apache.org + - domain=downloads.apache.org + - domain=eclipse.org + - domain=www.eclipse.org + - domain=download.eclipse.org + - domain=nodejs.org + - domain=www.nodejs.org + + # Cloud Services & Monitoring + - domain=statsig.com + - domain=www.statsig.com + - domain=api.statsig.com + - domain=*.sentry.io + + # Content Delivery & Mirrors + - domain=*.sourceforge.net + - domain=packagecloud.io + - domain=*.packagecloud.io + + # Schema & Configuration + - domain=json-schema.org + - domain=www.json-schema.org + - domain=json.schemastore.org + - domain=www.schemastore.org +log_dir: ${BOUNDARY_LOG_DIR} +log_level: warn +proxy_port: 8087 diff --git a/registry/coder/modules/agent-firewall/main.test.ts b/registry/coder/modules/agent-firewall/main.test.ts new file mode 100644 index 00000000..3b189fbb --- /dev/null +++ b/registry/coder/modules/agent-firewall/main.test.ts @@ -0,0 +1,376 @@ +import { + test, + afterEach, + describe, + setDefaultTimeout, + beforeAll, + expect, +} from "bun:test"; +import { + execContainer, + readFileContainer, + runTerraformInit, + runTerraformApply, + testRequiredVariables, + runContainer, + removeContainer, +} from "~test"; +import { + loadTestFile, + writeExecutable, + execModuleScript, + extractCoderEnvVars, +} from "../agentapi/test-util"; + +let cleanupFunctions: (() => Promise)[] = []; +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; +afterEach(async () => { + const cleanupFnsCopy = cleanupFunctions.slice().reverse(); + cleanupFunctions = []; + for (const cleanup of cleanupFnsCopy) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } +}); + +interface SetupProps { + moduleVariables?: Record; + skipCoderMock?: boolean; +} + +const MODULE_DIR = "/home/coder/.coder-modules/coder/agent-firewall"; +const CONFIG_PATH = `${MODULE_DIR}/config/config.yaml`; +const WRAPPER_PATH = `${MODULE_DIR}/scripts/agent-firewall-wrapper.sh`; + +const setup = async ( + props?: SetupProps, +): Promise<{ id: string; coderEnvVars: Record }> => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + ...props?.moduleVariables, + }); + + const coderEnvVars = extractCoderEnvVars(state); + const id = await runContainer("codercom/enterprise-node:latest"); + registerCleanup(async () => { + await removeContainer(id); + }); + + await execContainer(id, ["bash", "-c", "mkdir -p /home/coder/project"]); + + // Create a mock coder binary with boundary subcommand and exp sync support + if (!props?.skipCoderMock) { + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: await loadTestFile(import.meta.dir, "coder-mock.sh"), + }); + } + + // Extract ALL coder_scripts from the state (coder-utils creates multiple) + const allScripts = state.resources + .filter((r) => r.type === "coder_script") + .map((r) => ({ + name: r.name, + script: r.instances[0].attributes.script as string, + })); + + // Run scripts in lifecycle order + const executionOrder = [ + "pre_install_script", + "install_script", + "post_install_script", + ]; + const orderedScripts = executionOrder + .map((name) => allScripts.find((s) => s.name === name)) + .filter((s): s is NonNullable => s != null); + + // Write each script individually and create a combined runner + const scriptPaths: string[] = []; + for (const s of orderedScripts) { + const scriptPath = `/home/coder/${s.name}.sh`; + await writeExecutable({ + containerId: id, + filePath: scriptPath, + content: s.script, + }); + scriptPaths.push(scriptPath); + } + + const combinedScript = [ + "#!/bin/bash", + "set -o errexit", + "set -o pipefail", + ...scriptPaths.map((p) => `bash "${p}"`), + ].join("\n"); + + await writeExecutable({ + containerId: id, + filePath: "/home/coder/script.sh", + content: combinedScript, + }); + + return { id, coderEnvVars }; +}; + +setDefaultTimeout(60 * 1000); + +describe("agent-firewall", async () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + testRequiredVariables(import.meta.dir, { + agent_id: "test-agent-id", + }); + + test("terraform-state-basic", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + }); + + const resources = state.resources; + + // No coder_env resources should exist + const envResources = resources.filter((r) => r.type === "coder_env"); + expect(envResources).toHaveLength(0); + + // Verify no env vars are exported + const coderEnvVars = extractCoderEnvVars(state); + expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBeUndefined(); + expect(coderEnvVars["BOUNDARY_CONFIG"]).toBeUndefined(); + + // Verify agent_firewall_config_path output + expect(state.outputs["agent_firewall_config_path"]?.value).toBe( + "$HOME/.coder-modules/coder/agent-firewall/config/config.yaml", + ); + + // Verify agent_firewall_wrapper_path output + expect(state.outputs["agent_firewall_wrapper_path"]?.value).toBe( + "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh", + ); + + // Verify scripts output contains install script + const scripts = state.outputs["scripts"]?.value as string[]; + expect(scripts).toContain("coder-agent-firewall-install_script"); + }); + + test("terraform-state-custom-module-directory", async () => { + const customDir = "$HOME/.coder-modules/custom/agent-firewall"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + module_directory: customDir, + }); + + // Verify output uses custom dir + const outputs = state.outputs; + expect(outputs["agent_firewall_wrapper_path"]?.value).toBe( + `${customDir}/scripts/agent-firewall-wrapper.sh`, + ); + // Config path follows module directory + expect(outputs["agent_firewall_config_path"]?.value).toBe( + `${customDir}/config/config.yaml`, + ); + }); + + test("terraform-state-inline-config", async () => { + const inlineConfig = + "allowlist:\n - domain=example.com\nlog_level: debug\n"; + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + agent_firewall_config: inlineConfig, + }); + + // Inline config still writes to the managed path. + expect(state.outputs["agent_firewall_config_path"]?.value).toBe( + "$HOME/.coder-modules/coder/agent-firewall/config/config.yaml", + ); + }); + + test("terraform-state-config-path", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "test-agent-id", + agent_firewall_config_path: "/workspace/my-config.yaml", + }); + + // agent_firewall_config_path output should point to the user-provided path. + expect(state.outputs["agent_firewall_config_path"]?.value).toBe( + "/workspace/my-config.yaml", + ); + }); + + test("happy-path-coder-subcommand", async () => { + const { id } = await setup(); + await execModuleScript(id); + + // Verify the wrapper script was created + const wrapperContent = await readFileContainer(id, WRAPPER_PATH); + expect(wrapperContent).toContain("#!/usr/bin/env bash"); + expect(wrapperContent).toContain("coder-no-caps"); + expect(wrapperContent).toContain("boundary"); + + // Verify the wrapper script is executable + const statResult = await execContainer(id, [ + "stat", + "-c", + "%a", + WRAPPER_PATH, + ]); + expect(statResult.stdout.trim()).toMatch(/7[0-9][0-9]/); + + // Verify coder-no-caps binary was created + const coderNoCapsResult = await execContainer(id, [ + "test", + "-f", + `${MODULE_DIR}/scripts/coder-no-caps`, + ]); + expect(coderNoCapsResult.exitCode).toBe(0); + + // Verify default boundary config was written inside module directory + const configContent = await readFileContainer(id, CONFIG_PATH); + expect(configContent).toContain("allowlist:"); + expect(configContent).toContain("domain=api.anthropic.com"); + expect(configContent).toContain("domain=api.openai.com"); + expect(configContent).toContain("proxy_port: 8087"); + + // Verify Coder domain was auto-filled from data.coder_workspace.me + // (the placeholder should be replaced with the actual deployment domain). + expect(configContent).not.toContain("domain=your-deployment.coder.com"); + + // Verify $HOME was expanded in log_dir (should be absolute, not literal $HOME). + expect(configContent).toContain("log_dir: /home/coder/"); + expect(configContent).not.toContain("$HOME"); + + // Check install log + const installLog = await readFileContainer( + id, + `${MODULE_DIR}/logs/install.log`, + ); + expect(installLog).toContain("Using coder boundary subcommand"); + expect(installLog).toContain("Boundary config written to"); + expect(installLog).toContain("boundary wrapper configured"); + }); + + test("inline-config-written", async () => { + const customConfig = + "allowlist:\n - domain=custom.example.com\nlog_level: info\n"; + const { id } = await setup({ + moduleVariables: { + agent_firewall_config: customConfig, + }, + }); + await execModuleScript(id); + + // Verify the inline config was written + const configContent = await readFileContainer(id, CONFIG_PATH); + expect(configContent).toContain("domain=custom.example.com"); + expect(configContent).toContain("log_level: info"); + }); + + test("config-path-skips-write", async () => { + const { id } = await setup({ + moduleVariables: { + agent_firewall_config_path: "/workspace/external-config.yaml", + }, + }); + await execModuleScript(id); + + // Verify NO config was written to the default path + const checkResult = await execContainer(id, ["test", "-f", CONFIG_PATH]); + expect(checkResult.exitCode).not.toBe(0); + + // Check install log confirms skip + const installLog = await readFileContainer( + id, + `${MODULE_DIR}/logs/install.log`, + ); + expect(installLog).toContain( + "Using external boundary config, skipping config write", + ); + }); + + // Note: Tests for use_agent_firewall_directly and + // compile_agent_firewall_from_source are skipped because they require + // network access (downloading boundary) or compilation which are too + // slow for unit tests. These modes are tested manually. + + test("custom-hooks", async () => { + const preInstallMarker = "pre-install-executed"; + const postInstallMarker = "post-install-executed"; + + const { id } = await setup({ + moduleVariables: { + pre_install_script: `#!/bin/bash\necho '${preInstallMarker}'`, + post_install_script: `#!/bin/bash\necho '${postInstallMarker}'`, + }, + }); + await execModuleScript(id); + + // Verify pre-install script ran + const preInstallLog = await readFileContainer( + id, + `${MODULE_DIR}/logs/pre_install.log`, + ); + expect(preInstallLog).toContain(preInstallMarker); + + // Verify post-install script ran + const postInstallLog = await readFileContainer( + id, + `${MODULE_DIR}/logs/post_install.log`, + ); + expect(postInstallLog).toContain(postInstallMarker); + + // Verify main install still ran + const installLog = await readFileContainer( + id, + `${MODULE_DIR}/logs/install.log`, + ); + expect(installLog).toContain("boundary wrapper configured"); + }); + + test("no-env-vars", async () => { + const { coderEnvVars } = await setup(); + + // No env vars should be exported by this module. + expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBeUndefined(); + expect(coderEnvVars["BOUNDARY_CONFIG"]).toBeUndefined(); + }); + + test("wrapper-script-execution", async () => { + const { id } = await setup(); + await execModuleScript(id); + + // Try executing the wrapper script with a command + const wrapperResult = await execContainer(id, [ + "bash", + "-c", + `${WRAPPER_PATH} echo boundary-test`, + ]); + + // The wrapper passes the command directly to the boundary command + expect(wrapperResult.stdout).toContain("boundary-test"); + }); + + test("installation-idempotency", async () => { + const { id } = await setup(); + + // Run the installation twice + await execModuleScript(id); + const firstInstallLog = await readFileContainer( + id, + `${MODULE_DIR}/logs/install.log`, + ); + + // Run again + const secondRun = await execModuleScript(id); + expect(secondRun.exitCode).toBe(0); + + // Both runs should succeed + expect(firstInstallLog).toContain("boundary wrapper configured"); + }); +}); diff --git a/registry/coder/modules/agent-firewall/main.tf b/registry/coder/modules/agent-firewall/main.tf new file mode 100644 index 00000000..8e795007 --- /dev/null +++ b/registry/coder/modules/agent-firewall/main.tf @@ -0,0 +1,128 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.5" + } + } +} + +data "coder_workspace" "me" {} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "agent_firewall_version" { + type = string + description = "Agent firewall version. When use_agent_firewall_directly is true, a release version should be provided or 'latest' for the latest release. When compile_agent_firewall_from_source is true, a valid git reference should be provided (tag, commit, branch)." + default = "latest" +} + +variable "compile_agent_firewall_from_source" { + type = bool + description = "Whether to compile agent-firewall from source instead of using the official install script." + default = false +} + +variable "use_agent_firewall_directly" { + type = bool + description = "Whether to use agent-firewall binary directly instead of `coder boundary` subcommand. When false (default), uses `coder boundary` subcommand. When true, installs and uses agent-firewall binary from release." + default = false +} + +variable "agent_firewall_config" { + type = string + description = "Inline agent-firewall configuration content (YAML). Overrides the module's default config. Mutually exclusive with agent_firewall_config_path." + default = null + + validation { + condition = !(var.agent_firewall_config != null && var.agent_firewall_config_path != null) + error_message = "Only one of agent_firewall_config or agent_firewall_config_path may be set." + } +} + +variable "agent_firewall_config_path" { + type = string + description = "Path to an existing agent-firewall config file in the workspace. When set, no config is written and the agent_firewall_config_path output points to this path. Mutually exclusive with agent_firewall_config." + default = null +} + +variable "pre_install_script" { + type = string + description = "Custom script to run before installing agent-firewall." + default = null +} + +variable "post_install_script" { + type = string + description = "Custom script to run after installing agent-firewall." + default = null +} + +variable "module_directory" { + type = string + description = "Directory where the agent-firewall module scripts will be located. Default is $HOME/.coder-modules/coder/agent-firewall." + default = "$HOME/.coder-modules/coder/agent-firewall" +} + +locals { + boundary_wrapper_path = "${var.module_directory}/scripts/agent-firewall-wrapper.sh" + + # Extract domain from the Coder access URL for the default config + # allowlist (e.g., "https://dev.coder.com/" -> "dev.coder.com"). + coder_domain = try(regex("^https?://([^/:]+)", data.coder_workspace.me.access_url)[0], "") + + # Config handling: resolve which config content to write and where + # agent_firewall_config_path output points to. + default_boundary_config = templatefile("${path.module}/config.yaml.tftpl", { + CODER_DOMAIN = local.coder_domain + BOUNDARY_LOG_DIR = "${var.module_directory}/logs/agent_firewall_logs" + }) + boundary_config_content = var.agent_firewall_config != null ? var.agent_firewall_config : local.default_boundary_config + boundary_config_dir = "${var.module_directory}/config" + boundary_config_file_path = "${local.boundary_config_dir}/config.yaml" + effective_boundary_config_path = var.agent_firewall_config_path != null ? var.agent_firewall_config_path : local.boundary_config_file_path + write_boundary_config = var.agent_firewall_config_path == null + + install_script = templatefile("${path.module}/scripts/install.sh.tftpl", { + BOUNDARY_VERSION = var.agent_firewall_version + COMPILE_BOUNDARY_FROM_SOURCE = tostring(var.compile_agent_firewall_from_source) + USE_BOUNDARY_DIRECTLY = tostring(var.use_agent_firewall_directly) + MODULE_DIR = var.module_directory + BOUNDARY_WRAPPER_PATH = local.boundary_wrapper_path + WRITE_BOUNDARY_CONFIG = tostring(local.write_boundary_config) + BOUNDARY_CONFIG_CONTENT_B64 = local.write_boundary_config ? base64encode(local.boundary_config_content) : "" + BOUNDARY_CONFIG_DIR = local.boundary_config_dir + BOUNDARY_CONFIG_FILE = local.boundary_config_file_path + }) +} + +module "coder_utils" { + source = "registry.coder.com/coder/coder-utils/coder" + version = "0.0.1" + agent_id = var.agent_id + display_name_prefix = "Agent Firewall" + module_directory = var.module_directory + pre_install_script = var.pre_install_script + post_install_script = var.post_install_script + install_script = local.install_script +} + +output "agent_firewall_wrapper_path" { + description = "Path to the agent-firewall wrapper script." + value = local.boundary_wrapper_path +} + +output "agent_firewall_config_path" { + description = "Effective path to the agent-firewall config file." + value = local.effective_boundary_config_path +} + +output "scripts" { + description = "List of script names for coder exp sync coordination." + value = module.coder_utils.scripts +} diff --git a/registry/coder/modules/agent-firewall/scripts/install.sh.tftpl b/registry/coder/modules/agent-firewall/scripts/install.sh.tftpl new file mode 100644 index 00000000..6179e396 --- /dev/null +++ b/registry/coder/modules/agent-firewall/scripts/install.sh.tftpl @@ -0,0 +1,131 @@ +#!/bin/bash +# Sets up boundary for network isolation in Coder workspaces. + +set -euo pipefail + +BOUNDARY_VERSION='${BOUNDARY_VERSION}' +COMPILE_BOUNDARY_FROM_SOURCE='${COMPILE_BOUNDARY_FROM_SOURCE}' +USE_BOUNDARY_DIRECTLY='${USE_BOUNDARY_DIRECTLY}' +MODULE_DIR="${MODULE_DIR}" +BOUNDARY_WRAPPER_PATH="${BOUNDARY_WRAPPER_PATH}" +WRITE_BOUNDARY_CONFIG='${WRITE_BOUNDARY_CONFIG}' +BOUNDARY_CONFIG_CONTENT=$(echo -n '${BOUNDARY_CONFIG_CONTENT_B64}' | base64 -d | sed "s|\$HOME|$HOME|g") +BOUNDARY_CONFIG_DIR="${BOUNDARY_CONFIG_DIR}" +BOUNDARY_CONFIG_FILE="${BOUNDARY_CONFIG_FILE}" + +printf "BOUNDARY_VERSION: %s\n" "$${BOUNDARY_VERSION}" +printf "COMPILE_BOUNDARY_FROM_SOURCE: %s\n" "$${COMPILE_BOUNDARY_FROM_SOURCE}" +printf "USE_BOUNDARY_DIRECTLY: %s\n" "$${USE_BOUNDARY_DIRECTLY}" +printf "MODULE_DIR: %s\n" "$${MODULE_DIR}" +printf "BOUNDARY_WRAPPER_PATH: %s\n" "$${BOUNDARY_WRAPPER_PATH}" +printf "WRITE_BOUNDARY_CONFIG: %s\n" "$${WRITE_BOUNDARY_CONFIG}" +printf "BOUNDARY_CONFIG_DIR: %s\n" "$${BOUNDARY_CONFIG_DIR}" +printf "BOUNDARY_CONFIG_FILE: %s\n" "$${BOUNDARY_CONFIG_FILE}" + +validate_boundary_subcommand() { + if ! command -v coder > /dev/null 2>&1; then + echo "Error: 'coder' command not found. boundary cannot be enabled." >&2 + exit 1 + fi + + local output + echo "Checking for license" + if ! output=$(coder boundary 2>&1); then + if echo "$${output}" | grep -qi "license is not entitled"; then + echo "Error: your Coder deployment is not licensed for the boundary feature." >&2 + echo "$${output}" >&2 + echo "" >&2 + exit 1 + fi + fi +} + +# Install boundary binary if needed. +# Uses one of three strategies: +# 1. Compile from source (compile_boundary_from_source=true) +# 2. Install from release (use_boundary_directly=true) +# 3. Use coder boundary subcommand (default, no installation needed) +install_boundary() { + if [[ "$${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]]; then + echo "Compiling boundary from source (version: $${BOUNDARY_VERSION})" + + # Remove existing boundary directory to allow re-running safely + if [[ -d boundary ]]; then + rm -rf boundary + fi + + echo "Cloning boundary repository" + git clone https://github.com/coder/boundary.git + cd boundary || exit 1 + git checkout "$${BOUNDARY_VERSION}" + + make build + + sudo cp boundary /usr/local/bin/ + sudo chmod +x /usr/local/bin/boundary + cd - || exit 1 + elif [[ "$${USE_BOUNDARY_DIRECTLY}" = "true" ]]; then + echo "Installing boundary using official install script (version: $${BOUNDARY_VERSION})" + curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "$${BOUNDARY_VERSION}" + else + validate_boundary_subcommand + echo "Using coder boundary subcommand (provided by Coder)" + fi +} + +# Write boundary config file if the module is responsible for it. +write_boundary_config() { + if [[ "$${WRITE_BOUNDARY_CONFIG}" != "true" ]]; then + echo "Using external boundary config, skipping config write." + return 0 + fi + + mkdir -p "$${BOUNDARY_CONFIG_DIR}" + echo "$${BOUNDARY_CONFIG_CONTENT}" > "$${BOUNDARY_CONFIG_FILE}" + echo "Boundary config written to $${BOUNDARY_CONFIG_FILE}" +} + +# Set up boundary: install, write config, create wrapper script. +setup_boundary() { + echo "Setting up coder boundary..." + + # Install boundary binary if needed + install_boundary + + # Write boundary config + write_boundary_config + + # Ensure the wrapper script directory exists. + mkdir -p "$(dirname "$${BOUNDARY_WRAPPER_PATH}")" + + if [[ "$${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]] || [[ "$${USE_BOUNDARY_DIRECTLY}" = "true" ]]; then + # Use boundary binary directly (from compilation or release installation) + cat > "$${BOUNDARY_WRAPPER_PATH}" << 'WRAPPER_EOF' +#!/usr/bin/env bash +set -euo pipefail +exec boundary "$@" +WRAPPER_EOF + else + # Use coder boundary subcommand (default) + # Copy coder binary to strip CAP_NET_ADMIN capabilities. + # This is necessary because boundary doesn't work with privileged binaries + # (you can't launch privileged binaries inside network namespaces unless + # you have sys_admin). + CODER_NO_CAPS="$${MODULE_DIR}/scripts/coder-no-caps" + if ! cp "$(command -v coder)" "$${CODER_NO_CAPS}"; then + echo "Error: Failed to copy coder binary to $${CODER_NO_CAPS}. boundary cannot be enabled." >&2 + exit 1 + fi + cat > "$${BOUNDARY_WRAPPER_PATH}" << 'WRAPPER_EOF' +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$${BASH_SOURCE[0]}")" && pwd)" +exec "$${SCRIPT_DIR}/coder-no-caps" boundary "$@" +WRAPPER_EOF + fi + + chmod +x "$${BOUNDARY_WRAPPER_PATH}" + echo "boundary wrapper configured: $${BOUNDARY_WRAPPER_PATH}" +} + +setup_boundary diff --git a/registry/coder/modules/agent-firewall/testdata/coder-mock.sh b/registry/coder/modules/agent-firewall/testdata/coder-mock.sh new file mode 100644 index 00000000..89242004 --- /dev/null +++ b/registry/coder/modules/agent-firewall/testdata/coder-mock.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +# Mock coder command for testing boundary module +# Handles: coder boundary [--help | ] +# Handles: coder exp sync [want|start|complete] (no-op for testing) + +# Handle exp sync commands (no-op for testing) +if [[ "$1" == "exp" ]] && [[ "$2" == "sync" ]]; then + exit 0 +fi + +if [[ "$1" == "boundary" ]]; then + shift + + # Handle --help flag + if [[ "$1" == "--help" ]]; then + cat << 'EOF' +boundary - Run commands in network isolation + +Usage: + coder boundary [flags] -- [args...] + +Examples: + coder boundary -- curl https://example.com + coder boundary -- npm install + +Flags: + -h, --help help for boundary +EOF + exit 0 + fi + + # Execute the remaining arguments as a command + exec "$@" +fi + +echo "Mock coder: Unknown command: $*" +exit 1 From 4ca251f448c9f82ed53ab2825c7546764249457e Mon Sep 17 00:00:00 2001 From: Morgan Lunt Date: Fri, 15 May 2026 06:27:42 -0700 Subject: [PATCH 7/7] feat(claude-code): add managed_settings input for policy delivery via /etc/claude-code (#863) ## Problem The module configures Claude Code's permission posture by reaching around the permission system rather than through it: - `scripts/install.sh` writes `bypassPermissionsModeAccepted`, `autoModeAccepted`, and `primaryApiKey` directly into the user-writable `~/.claude.json`. Any process in the workspace can read the API key or flip the acceptance flags back. - `scripts/start.sh` adds `--dangerously-skip-permissions` to every task launch, even when the template author set an explicit `permission_mode`. The README has to carry a security warning telling people the module bypasses permission checks. - `permission_mode`, `allowed_tools`, and `disallowed_tools` each plumb through a different ad-hoc path (CLI flag, `coder` subcommand) instead of a single policy surface. ## Change Add a `managed_settings` input that renders to `/etc/claude-code/managed-settings.d/10-coder.json`. Claude Code reads that drop-in directory at startup with the highest configuration precedence (above `~/.claude/settings.json` and project settings), so template authors get an admin-controlled policy file that users inside the workspace cannot override. The mechanism is a local file read with no API call, so it works identically for the Anthropic API, AWS Bedrock, Google Vertex AI, and AI Bridge / AI Gateway. ```hcl managed_settings = { permissions = { defaultMode = "acceptEdits" disableBypassPermissionsMode = "disable" deny = ["Bash(curl:*)", "WebFetch"] } } ``` Supporting changes: - `install.sh` writes the policy file (root-owned, 0644) and stops writing `bypassPermissionsModeAccepted`, `autoModeAccepted`, and `primaryApiKey` into `~/.claude.json`. The API key is already exported via `coder_env` as `CLAUDE_API_KEY`; duplicating it on disk is unnecessary. `hasCompletedOnboarding` stays because there is no env-var alternative for it. - `start.sh` only adds `--dangerously-skip-permissions` for tasks when no explicit `permission_mode` is set (same fix as #846; included here so this PR is self-contained, happy to drop if #846 lands first). - `permission_mode`, `allowed_tools`, and `disallowed_tools` are marked deprecated and shimmed into `managed_settings.permissions` for one release when `managed_settings` is not provided. - README security warning rewritten to point at the policy mechanism instead of telling people the module is unsafe by design. ## Relationship to #861 #861 strips this module to install-and-configure and removes `permission_mode` / `allowed_tools` / `disallowed_tools` outright. `managed_settings` is the natural replacement for those: it is install-time (survives the `start.sh` removal), it covers everything the dropped variables did plus `hooks`, `env`, `model`, `apiKeyHelper`, and the rest of the settings schema, and it does not require the module to know anything about how Claude is launched. If #861 lands first I will rebase this on top and drop the deprecation shim and the `start.sh` hunk. ## Validation - `terraform fmt` / `terraform validate` clean - New tests: `claude-managed-settings-written`, `claude-managed-settings-legacy-shim`, `claude-no-policy-keys-in-claudejson`, plus an assertion in `claude-auto-permission-mode` that `--dangerously-skip-permissions` is absent when a mode is set - Manually verified `/etc/claude-code/managed-settings.d/*.json` precedence in the Claude Code CLI source Closes #818. Relates to #284, #846, #861. Disclosure: I work at Anthropic on the Claude Code team. Happy to adjust scope or split this further if that is easier to review. --------- Co-authored-by: DevCats Co-authored-by: DevCats --- registry/coder/modules/claude-code/README.md | 43 +++++++++++++--- .../coder/modules/claude-code/main.test.ts | 50 ++++++++++++++++++- registry/coder/modules/claude-code/main.tf | 7 +++ .../coder/modules/claude-code/main.tftest.hcl | 44 ++++++++++++++++ .../claude-code/scripts/install.sh.tftpl | 33 ++++++++++-- 5 files changed, 163 insertions(+), 14 deletions(-) diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index b10e72bd..86b05ce0 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -13,7 +13,7 @@ Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/agent ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.1.0" + version = "5.2.0" agent_id = coder_agent.main.id anthropic_api_key = "xxxx-xxxxx-xxxx" } @@ -47,7 +47,7 @@ locals { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.1.0" + version = "5.2.0" agent_id = coder_agent.main.id workdir = local.claude_workdir anthropic_api_key = "xxxx-xxxxx-xxxx" @@ -78,7 +78,7 @@ resource "coder_app" "claude" { ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.1.0" + version = "5.2.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_ai_gateway = true @@ -95,6 +95,33 @@ Claude Code then routes API requests through Coder's AI Gateway instead of direc > [!CAUTION] > `enable_ai_gateway = true` is mutually exclusive with `anthropic_api_key` and `claude_code_oauth_token`. Setting any of them together fails at plan time. +### Enterprise policy via managed settings + +The `managed_settings` input writes a policy file to `/etc/claude-code/managed-settings.d/10-coder.json` inside the workspace. Claude Code reads this directory at startup with the highest configuration precedence, so users cannot override these values in their own `~/.claude/settings.json`. This is a local file mechanism and works with any inference backend (Anthropic API, AWS Bedrock, Google Vertex AI, or AI Gateway). + +```tf +module "claude-code" { + source = "registry.coder.com/coder/claude-code/coder" + version = "5.2.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/project" + anthropic_api_key = "xxxx-xxxxx-xxxx" + + managed_settings = { + permissions = { + defaultMode = "acceptEdits" + disableBypassPermissionsMode = "disable" + deny = ["Bash(curl:*)", "Bash(wget:*)", "WebFetch"] + } + env = { + DISABLE_TELEMETRY = "0" + } + } +} +``` + +See the [Claude Code settings reference](https://docs.anthropic.com/en/docs/claude-code/settings) for the full schema. Common keys: `permissions` (`defaultMode`, `allow`, `deny`, `disableBypassPermissionsMode`, `additionalDirectories`), `env`, `model`, `apiKeyHelper`, `hooks`, `cleanupPeriodDays`. + ### Advanced Configuration This example shows version pinning, a pre-installed binary path, a custom model, and MCP servers. @@ -102,7 +129,7 @@ This example shows version pinning, a pre-installed binary path, a custom model, ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.1.0" + version = "5.2.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -166,7 +193,7 @@ Downstream `coder_script` resources can wait for this module's install pipeline ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.1.0" + version = "5.2.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" anthropic_api_key = "xxxx-xxxxx-xxxx" @@ -252,7 +279,7 @@ resource "coder_env" "bedrock_api_key" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.1.0" + version = "5.2.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" @@ -309,7 +336,7 @@ resource "coder_env" "google_application_credentials" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.1.0" + version = "5.2.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "claude-sonnet-4@20250514" @@ -350,7 +377,7 @@ The module automatically tags every span and metric with `coder.workspace_id`, ` ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "5.1.0" + version = "5.2.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" anthropic_api_key = "xxxx-xxxxx-xxxx" diff --git a/registry/coder/modules/claude-code/main.test.ts b/registry/coder/modules/claude-code/main.test.ts index bee682d6..56745f67 100644 --- a/registry/coder/modules/claude-code/main.test.ts +++ b/registry/coder/modules/claude-code/main.test.ts @@ -382,10 +382,13 @@ describe("claude-code", async () => { const parsed = JSON.parse(claudeConfig); expect(parsed.autoUpdaterStatus).toBe("disabled"); expect(parsed.hasCompletedOnboarding).toBe(true); - expect(parsed.bypassPermissionsModeAccepted).toBe(true); expect(parsed.hasAcknowledgedCostThreshold).toBe(true); expect(parsed.projects[workdir].hasCompletedProjectOnboarding).toBe(true); expect(parsed.projects[workdir].hasTrustDialogAccepted).toBe(true); + // Permission posture is delivered via /etc/claude-code/managed-settings.d/, + // not user-writable ~/.claude.json acceptance flags. + expect(parsed.bypassPermissionsModeAccepted).toBeUndefined(); + expect(parsed.autoModeAccepted).toBeUndefined(); }); test("standalone-mode-with-oauth-token", async () => { @@ -413,7 +416,7 @@ describe("claude-code", async () => { ); const parsed = JSON.parse(claudeConfig); expect(parsed.hasCompletedOnboarding).toBe(true); - expect(parsed.bypassPermissionsModeAccepted).toBe(true); + expect(parsed.bypassPermissionsModeAccepted).toBeUndefined(); }); test("standalone-mode-no-auth", async () => { @@ -436,6 +439,49 @@ describe("claude-code", async () => { expect(resp.stdout.trim()).toBe("ABSENT"); }); + test("claude-managed-settings-written", async () => { + const { id, scripts } = await setup({ + moduleVariables: { + managed_settings: JSON.stringify({ + permissions: { + defaultMode: "acceptEdits", + disableBypassPermissionsMode: "disable", + deny: ["Bash(rm -rf*)"], + }, + }), + }, + }); + await runScripts(id, scripts); + + const policy = await execContainer(id, [ + "bash", + "-c", + "cat /etc/claude-code/managed-settings.d/10-coder.json", + ]); + expect(policy.exitCode).toBe(0); + expect(policy.stdout).toContain('"defaultMode":"acceptEdits"'); + expect(policy.stdout).toContain('"disableBypassPermissionsMode":"disable"'); + expect(policy.stdout).toContain('"deny":["Bash(rm -rf*)"]'); + + const installLog = await readFileContainer( + id, + "/home/coder/.coder-modules/coder/claude-code/logs/install.log", + ); + expect(installLog).toContain("Wrote Claude Code managed settings"); + }); + + test("claude-managed-settings-not-set", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + + const resp = await execContainer(id, [ + "bash", + "-c", + "test -e /etc/claude-code/managed-settings.d/10-coder.json && echo EXISTS || echo ABSENT", + ]); + expect(resp.stdout.trim()).toBe("ABSENT"); + }); + test("telemetry-otel", async () => { const { coderEnvVars } = await setup({ moduleVariables: { diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index acfe8538..9013a4be 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -102,6 +102,12 @@ variable "claude_binary_path" { } } +variable "managed_settings" { + type = any + description = "Policy settings written to /etc/claude-code/managed-settings.d/10-coder.json. Highest-precedence client config; works with any inference backend (Anthropic API, Bedrock, Vertex, AI Gateway). See https://docs.anthropic.com/en/docs/claude-code/settings for the schema." + default = null +} + variable "enable_ai_gateway" { type = bool description = "Use AI Gateway for Claude Code. https://coder.com/docs/ai-coder/ai-gateway" @@ -237,6 +243,7 @@ locals { ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : "" ARG_MCP_CONFIG_REMOTE_PATH = base64encode(jsonencode(var.mcp_config_remote_path)) ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway) + ARG_MANAGED_SETTINGS_JSON = var.managed_settings != null ? base64encode(jsonencode(var.managed_settings)) : "" }) module_dir_name = ".coder-modules/coder/claude-code" } diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index 08e0c005..bfa7a357 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -283,3 +283,47 @@ run "test_workdir_optional" { error_message = "workdir should default to null when omitted" } } + +run "test_managed_settings" { + command = plan + + variables { + agent_id = "test-agent-managed-settings" + workdir = "/home/coder/project" + managed_settings = { + permissions = { + defaultMode = "acceptEdits" + disableBypassPermissionsMode = "disable" + deny = ["Bash(rm -rf*)"] + } + } + } + + assert { + condition = var.managed_settings.permissions.defaultMode == "acceptEdits" + error_message = "managed_settings should accept the permissions object" + } + + assert { + condition = strcontains(local.install_script, "/etc/claude-code/managed-settings.d") + error_message = "install script should reference the managed-settings.d drop-in directory" + } + + assert { + condition = strcontains(local.install_script, base64encode(jsonencode(var.managed_settings))) + error_message = "install script should embed the base64-encoded managed_settings JSON" + } +} + +run "test_managed_settings_default_null" { + command = plan + + variables { + agent_id = "test-agent-managed-settings-default" + } + + assert { + condition = var.managed_settings == null + error_message = "managed_settings should default to null when omitted" + } +} diff --git a/registry/coder/modules/claude-code/scripts/install.sh.tftpl b/registry/coder/modules/claude-code/scripts/install.sh.tftpl index bd142c5d..1e5fd631 100644 --- a/registry/coder/modules/claude-code/scripts/install.sh.tftpl +++ b/registry/coder/modules/claude-code/scripts/install.sh.tftpl @@ -17,6 +17,7 @@ ARG_CLAUDE_BINARY_PATH="$${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}" ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d) ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n '${ARG_MCP_CONFIG_REMOTE_PATH}' | base64 -d) ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}' +ARG_MANAGED_SETTINGS_JSON=$(echo -n '${ARG_MANAGED_SETTINGS_JSON}' | base64 -d) export PATH="$${ARG_CLAUDE_BINARY_PATH}:$PATH" @@ -29,6 +30,7 @@ printf "ARG_CLAUDE_BINARY_PATH: %s\n" "$${ARG_CLAUDE_BINARY_PATH}" printf "ARG_MCP: %s\n" "$${ARG_MCP}" printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$${ARG_MCP_CONFIG_REMOTE_PATH}" printf "ARG_ENABLE_AI_GATEWAY: %s\n" "$${ARG_ENABLE_AI_GATEWAY}" +printf "ARG_MANAGED_SETTINGS_JSON: %s\n" "$${ARG_MANAGED_SETTINGS_JSON}" echo "--------------------------------" @@ -144,6 +146,32 @@ function setup_claude_configurations() { } +function write_managed_settings() { + if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then + return + fi + + local dropin_dir="/etc/claude-code/managed-settings.d" + local target="$${dropin_dir}/10-coder.json" + + 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 + + if command_exists sudo; then + sudo mkdir -p "$${dropin_dir}" + echo "$${ARG_MANAGED_SETTINGS_JSON}" | sudo tee "$${target}" > /dev/null + sudo chmod 0644 "$${target}" + else + mkdir -p "$${dropin_dir}" + echo "$${ARG_MANAGED_SETTINGS_JSON}" > "$${target}" + chmod 0644 "$${target}" + fi + + echo "Wrote Claude Code managed settings to $${target}" +} + function configure_standalone_mode() { echo "Configuring Claude Code for standalone mode..." @@ -158,8 +186,6 @@ function configure_standalone_mode() { echo "Updating existing Claude configuration at $${claude_config}" jq '.autoUpdaterStatus = "disabled" | - .autoModeAccepted = true | - .bypassPermissionsModeAccepted = true | .hasAcknowledgedCostThreshold = true | .hasCompletedOnboarding = true' \ "$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}" @@ -168,8 +194,6 @@ function configure_standalone_mode() { cat > "$${claude_config}" << EOF { "autoUpdaterStatus": "disabled", - "autoModeAccepted": true, - "bypassPermissionsModeAccepted": true, "hasAcknowledgedCostThreshold": true, "hasCompletedOnboarding": true } @@ -189,4 +213,5 @@ EOF install_claude_code_cli setup_claude_configurations +write_managed_settings configure_standalone_mode