diff --git a/registry/coder-labs/modules/codex/README.md b/registry/coder-labs/modules/codex/README.md index 16786d02..08701fb1 100644 --- a/registry/coder-labs/modules/codex/README.md +++ b/registry/coder-labs/modules/codex/README.md @@ -1,149 +1,107 @@ --- 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 = <<-EOT [mcp_servers.GitHub] command = "npx" args = ["-y", "@modelcontextprotocol/server-github"] @@ -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..f6180772 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); + expect(coderEnvVars["OPENAI_API_KEY"]).toBe(apiKey); + }); - const resp = await readFileContainer( - id, - "/home/coder/.codex-module/agentapi-start.log", - ); - expect(resp).toContain("OpenAI API Key: Provided"); + 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: { + base_config_toml: baseConfig, + }, + }); + 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 = [ + "[mcp_servers.GitHub]", + 'command = "npx"', + 'args = ["-y", "@modelcontextprotocol/server-github"]', + 'type = "stdio"', + 'description = "GitHub integration"', + ].join("\n"); + const { id, scripts } = await setup({ + moduleVariables: { + mcp: additional, + }, + }); + await runScripts(id, scripts); + const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); + expect(resp).toContain("[mcp_servers.GitHub]"); + expect(resp).toContain("GitHub integration"); + }); + + test("minimal-default-config", async () => { + const { id, scripts } = await setup(); + await runScripts(id, scripts); + const resp = await readFileContainer(id, "/home/coder/.codex/config.toml"); + 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("pre-post-install-scripts", async () => { - const { id } = await setup({ + const { id, scripts } = await setup({ moduleVariables: { - pre_install_script: "#!/bin/bash\necho 'pre-install-script'", - post_install_script: "#!/bin/bash\necho 'post-install-script'", + pre_install_script: "#!/bin/bash\necho 'codex-pre-install-script'", + post_install_script: "#!/bin/bash\necho 'codex-post-install-script'", }, }); - await execModuleScript(id); + await runScripts(id, scripts); + const preInstallLog = await readFileContainer( id, - "/home/coder/.codex-module/pre_install.log", + "/home/coder/.coder-modules/coder-labs/codex/logs/pre_install.log", ); - expect(preInstallLog).toContain("pre-install-script"); + expect(preInstallLog).toContain("codex-pre-install-script"); + const postInstallLog = await readFileContainer( id, - "/home/coder/.codex-module/post_install.log", + "/home/coder/.coder-modules/coder-labs/codex/logs/post_install.log", ); - expect(postInstallLog).toContain("post-install-script"); + expect(postInstallLog).toContain("codex-post-install-script"); }); test("workdir-variable", async () => { - const workdir = "/tmp/codex-test-workdir"; - const { id } = await setup({ - skipCodexMock: false, + const workdir = "/home/coder/codex-test-folder"; + const { id, scripts } = await setup({ moduleVariables: { workdir, }, }); - await execModuleScript(id); - const resp = await readFileContainer( + await runScripts(id, scripts); + const installLog = await readFileContainer( id, - "/home/coder/.codex-module/install.log", + "/home/coder/.coder-modules/coder-labs/codex/logs/install.log", ); - expect(resp).toContain(workdir); + expect(installLog).toContain(workdir); }); - 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({ + test("codex-with-ai-gateway", async () => { + const { id, coderEnvVars, scripts } = await setup({ moduleVariables: { - additional_mcp_servers: additional, - }, - }); - await execModuleScript(id); - 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 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); - }); - - test("codex-system-prompt", async () => { - const prompt = "This is a system prompt for Codex."; - const { id } = await setup({ - moduleVariables: { - codex_system_prompt: prompt, - }, - }); - await execModuleScript(id); - const resp = await readFileContainer(id, "/home/coder/.codex/AGENTS.md"); - expect(resp).toContain(prompt); - }); - - 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( - id, - "/home/coder/.codex-module/agentapi-start.log", - ); - expect(startLog).toContain("Capturing new session ID"); - expect(startLog).toContain("Session tracked"); - expect(startLog).toContain(expectedSessionId); - }); - - test("codex-continue-resume-existing-session", async () => { - const { id } = await setup({ - moduleVariables: { - continue: "true", - ai_prompt: "test prompt", - }, - }); - - 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}`, - ); - expect(startLog.stdout).not.toContain("test prompt"); - }); - - test("codex-with-aibridge", async () => { - const { id } = 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..c23129bc 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." - default = "" -} - -variable "boundary_version" { - type = string - description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release." + description = "The version of Codex to install." default = "latest" } -variable "compile_boundary_from_source" { - type = bool - description = "Whether to compile boundary from source instead of using the official install script." - default = false +variable "openai_api_key" { + type = string + description = "OpenAI API key for Codex CLI." + sensitive = true + default = "" } -variable "use_boundary_directly" { +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; + 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 "mcp" { + type = string + description = "MCP server configurations in TOML format. When set, servers are appended to the Codex config.toml." + 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_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 + 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..584c978b --- /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_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}' +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_MCP}" ]; then + printf "Adding MCP servers\n" + echo "$${ARG_MCP}" >> "$${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 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 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 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 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" { 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