From c765d9ba787448a69d736deeb9736f90a7b39b5c Mon Sep 17 00:00:00 2001 From: Shane White Date: Mon, 2 Mar 2026 15:20:11 +0000 Subject: [PATCH 01/15] Add boundary support to agent api and Codex CLI Module --- registry/coder-labs/modules/codex/README.md | 32 +++++++++++- registry/coder-labs/modules/codex/main.tf | 34 ++++++++++++- registry/coder/modules/agentapi/README.md | 2 +- registry/coder/modules/agentapi/main.tf | 32 ++++++++++++ .../coder/modules/agentapi/scripts/main.sh | 49 ++++++++++++++++++- 5 files changed, 145 insertions(+), 4 deletions(-) diff --git a/registry/coder-labs/modules/codex/README.md b/registry/coder-labs/modules/codex/README.md index b4a895dea..4727ed2a0 100644 --- a/registry/coder-labs/modules/codex/README.md +++ b/registry/coder-labs/modules/codex/README.md @@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.1" + version = "4.2.0" agent_id = coder_agent.example.id openai_api_key = var.openai_api_key workdir = "/home/coder/project" @@ -140,6 +140,36 @@ 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. +### Network Filtering with Coder Boundary + +This example shows how to enable network filtering using Coder Boundary to restrict outbound network access. + +```tf +module "codex" { + source = "registry.coder.com/coder-labs/codex/coder" + version = "4.1.1" + agent_id = coder_agent.example.id + openai_api_key = "..." + workdir = "/home/coder/project" + + # Enable boundary with landjail (requires Linux 6.7+) + enable_boundary = true + boundary_jail_type = "landjail" # or "nsjail" (default) + boundary_proxy_port = 8087 # optional, default is 8087 + + # Optional: provide custom boundary config with allowlist + # boundary_config_path = "/path/to/config.yaml" +} +``` + +When `enable_boundary = true`: +- All network traffic from Codex is routed through a filtering proxy +- Only allowlisted domains are accessible (configure via `~/.config/coder_boundary/config.yaml`) +- `landjail` uses Landlock V4 (no special capabilities needed, requires kernel 6.7+) +- `nsjail` uses Linux namespaces (may require Docker seccomp modifications) + +For more information, see [Coder Boundary Documentation](https://coder.com/docs/admin/security/boundary). + ## How it Works - **Install**: The module installs Codex CLI and sets up the environment diff --git a/registry/coder-labs/modules/codex/main.tf b/registry/coder-labs/modules/codex/main.tf index cc07ce2f9..0f0f92803 100644 --- a/registry/coder-labs/modules/codex/main.tf +++ b/registry/coder-labs/modules/codex/main.tf @@ -170,6 +170,34 @@ variable "codex_system_prompt" { 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_jail_type" { + type = string + description = "Jail type: nsjail (default) or landjail" + default = "nsjail" + validation { + condition = contains(["nsjail", "landjail"], var.boundary_jail_type) + error_message = "Must be nsjail or landjail." + } +} + +variable "boundary_proxy_port" { + type = number + description = "HTTP proxy port for boundary" + default = 8087 +} + +variable "boundary_config_path" { + type = string + description = "Path to boundary config.yaml file. If not provided, a minimal config with jail_type and proxy_port will be generated." + default = "" +} + resource "coder_env" "openai_api_key" { agent_id = var.agent_id name = "OPENAI_API_KEY" @@ -205,7 +233,7 @@ locals { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "2.0.0" + version = "2.2.0" agent_id = var.agent_id folder = local.workdir @@ -223,6 +251,10 @@ module "agentapi" { agentapi_version = var.agentapi_version pre_install_script = var.pre_install_script post_install_script = var.post_install_script + enable_boundary = var.enable_boundary + boundary_jail_type = var.boundary_jail_type + boundary_proxy_port = var.boundary_proxy_port + boundary_config_path = var.boundary_config_path start_script = <<-EOT #!/bin/bash set -o errexit diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index e7a9869fb..722b39da8 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI ```tf module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "2.1.1" + version = "2.2.0" agent_id = var.agent_id web_app_slug = local.app_slug diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index 6914be779..78629c6ff 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -164,6 +164,34 @@ variable "module_dir_name" { description = "Name of the subdirectory in the home directory for module files." } +variable "enable_boundary" { + type = bool + description = "Enable coder boundary for network filtering" + default = false +} + +variable "boundary_jail_type" { + type = string + description = "Jail type: nsjail (default) or landjail" + default = "nsjail" + validation { + condition = contains(["nsjail", "landjail"], var.boundary_jail_type) + error_message = "Must be nsjail or landjail." + } +} + +variable "boundary_proxy_port" { + type = number + description = "HTTP proxy port for boundary" + default = 8087 +} + +variable "boundary_config_path" { + type = string + description = "Path to boundary config.yaml file. If not provided, a minimal config with jail_type and proxy_port will be generated." + default = "" +} + locals { # we always trim the slash for consistency @@ -209,6 +237,10 @@ resource "coder_script" "agentapi" { ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \ ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \ ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ + ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \ + ARG_BOUNDARY_JAIL_TYPE='${var.boundary_jail_type}' \ + ARG_BOUNDARY_PROXY_PORT='${var.boundary_proxy_port}' \ + ARG_BOUNDARY_CONFIG_PATH='${var.boundary_config_path}' \ /tmp/main.sh EOT run_on_start = true diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 63e013eb9..85ee00b14 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -16,6 +16,10 @@ AGENTAPI_PORT="$ARG_AGENTAPI_PORT" AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}" TASK_ID="${ARG_TASK_ID:-}" TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" +ENABLE_BOUNDARY="${ARG_ENABLE_BOUNDARY:-false}" +BOUNDARY_JAIL_TYPE="${ARG_BOUNDARY_JAIL_TYPE:-nsjail}" +BOUNDARY_PROXY_PORT="${ARG_BOUNDARY_PROXY_PORT:-8087}" +BOUNDARY_CONFIG_PATH="${ARG_BOUNDARY_CONFIG_PATH:-}" set +o nounset command_exists() { @@ -103,8 +107,51 @@ export LC_ALL=en_US.UTF-8 cd "${WORKDIR}" +# Set up boundary if enabled +BOUNDARY_WRAPPER="" +if [ "${ENABLE_BOUNDARY}" = "true" ]; then + echo "Setting up coder boundary..." + + # Create boundary config directory + mkdir -p ~/.config/coder_boundary + + # Write boundary config if custom path not provided + if [ -z "${BOUNDARY_CONFIG_PATH}" ]; then + echo "Generating boundary config with jail_type=${BOUNDARY_JAIL_TYPE} and proxy_port=${BOUNDARY_PROXY_PORT}" + cat > ~/.config/coder_boundary/config.yaml < "$module_path/agentapi-start.log" & + +# Start agentapi with or without boundary wrapper +if [ -n "${BOUNDARY_WRAPPER}" ]; then + echo "Starting agentapi with boundary wrapper..." + nohup ${BOUNDARY_WRAPPER} "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" & +else + nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" & +fi "$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}" From 636ed84086892a2e462b83865d9522b56f0a8231 Mon Sep 17 00:00:00 2001 From: Shane White Date: Mon, 2 Mar 2026 15:32:47 +0000 Subject: [PATCH 02/15] fix: updated agent api logic and amended codex start script to supprot boundary --- registry/coder-labs/modules/codex/README.md | 4 ++-- registry/coder-labs/modules/codex/scripts/start.sh | 8 +++++++- registry/coder/modules/agentapi/scripts/main.sh | 13 +++---------- 3 files changed, 12 insertions(+), 13 deletions(-) diff --git a/registry/coder-labs/modules/codex/README.md b/registry/coder-labs/modules/codex/README.md index 4727ed2a0..2763e078e 100644 --- a/registry/coder-labs/modules/codex/README.md +++ b/registry/coder-labs/modules/codex/README.md @@ -147,7 +147,7 @@ This example shows how to enable network filtering using Coder Boundary to restr ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.1" + version = "4.2.0" agent_id = coder_agent.example.id openai_api_key = "..." workdir = "/home/coder/project" @@ -168,7 +168,7 @@ When `enable_boundary = true`: - `landjail` uses Landlock V4 (no special capabilities needed, requires kernel 6.7+) - `nsjail` uses Linux namespaces (may require Docker seccomp modifications) -For more information, see [Coder Boundary Documentation](https://coder.com/docs/admin/security/boundary). +For more information, see [Agent Boundaries Documentation](https://coder.com/docs/ai-coder/agent-boundaries). ## How it Works diff --git a/registry/coder-labs/modules/codex/scripts/start.sh b/registry/coder-labs/modules/codex/scripts/start.sh index 3e55dc70f..9ff4ef282 100644 --- a/registry/coder-labs/modules/codex/scripts/start.sh +++ b/registry/coder-labs/modules/codex/scripts/start.sh @@ -213,7 +213,13 @@ capture_session_id() { start_codex() { printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}" - agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" & + if [ -n "${BOUNDARY_WRAPPER:-}" ]; then + printf "Starting with coder boundary enabled\n" + agentapi server --term-width 67 --term-height 1190 -- \ + ${BOUNDARY_WRAPPER} codex "${CODEX_ARGS[@]}" & + else + agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" & + fi capture_session_id } diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 85ee00b14..b660e2041 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -108,7 +108,7 @@ export LC_ALL=en_US.UTF-8 cd "${WORKDIR}" # Set up boundary if enabled -BOUNDARY_WRAPPER="" +export BOUNDARY_WRAPPER="" if [ "${ENABLE_BOUNDARY}" = "true" ]; then echo "Setting up coder boundary..." @@ -135,11 +135,10 @@ EOF if command_exists coder; then CODER_NO_CAPS="$(dirname "$(which coder)")/coder-no-caps" cp "$(which coder)" "$CODER_NO_CAPS" - BOUNDARY_WRAPPER="$CODER_NO_CAPS boundary --" + export BOUNDARY_WRAPPER="$CODER_NO_CAPS boundary --" echo "Boundary wrapper configured: ${BOUNDARY_WRAPPER}" else echo "Warning: coder command not found, boundary will not be enabled" - BOUNDARY_WRAPPER="" fi fi @@ -147,11 +146,5 @@ export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}" # Disable host header check since AgentAPI is proxied by Coder (which does its own validation) export AGENTAPI_ALLOWED_HOSTS="*" -# Start agentapi with or without boundary wrapper -if [ -n "${BOUNDARY_WRAPPER}" ]; then - echo "Starting agentapi with boundary wrapper..." - nohup ${BOUNDARY_WRAPPER} "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" & -else - nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" & -fi +nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" & "$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}" From 7b915e7e3513fc680ca71b6c3e562a82357bc482 Mon Sep 17 00:00:00 2001 From: Shane White Date: Tue, 3 Mar 2026 11:38:53 +0000 Subject: [PATCH 03/15] fix: resolve merge conflicts --- registry/coder-labs/modules/codex/scripts/start.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/registry/coder-labs/modules/codex/scripts/start.sh b/registry/coder-labs/modules/codex/scripts/start.sh index 9ff4ef282..b5156f8c9 100644 --- a/registry/coder-labs/modules/codex/scripts/start.sh +++ b/registry/coder-labs/modules/codex/scripts/start.sh @@ -215,10 +215,10 @@ start_codex() { printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}" if [ -n "${BOUNDARY_WRAPPER:-}" ]; then printf "Starting with coder boundary enabled\n" - agentapi server --term-width 67 --term-height 1190 -- \ + agentapi server --type codex --term-width 67 --term-height 1190 -- \ ${BOUNDARY_WRAPPER} codex "${CODEX_ARGS[@]}" & else - agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" & + agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" & fi capture_session_id } From 350b0e722928a3b501d84dc24b68e7254aa0a3f0 Mon Sep 17 00:00:00 2001 From: Shane White Date: Tue, 3 Mar 2026 12:02:59 +0000 Subject: [PATCH 04/15] chore: Remove codex changes to separate into another PR --- registry/coder-labs/modules/codex/README.md | 30 ---------------- registry/coder-labs/modules/codex/main.tf | 34 +------------------ .../coder-labs/modules/codex/scripts/start.sh | 8 +---- 3 files changed, 2 insertions(+), 70 deletions(-) diff --git a/registry/coder-labs/modules/codex/README.md b/registry/coder-labs/modules/codex/README.md index 88758c2f9..16e6c105b 100644 --- a/registry/coder-labs/modules/codex/README.md +++ b/registry/coder-labs/modules/codex/README.md @@ -140,36 +140,6 @@ 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. -### Network Filtering with Coder Boundary - -This example shows how to enable network filtering using Coder Boundary to restrict outbound network access. - -```tf -module "codex" { - source = "registry.coder.com/coder-labs/codex/coder" - version = "4.2.0" - agent_id = coder_agent.example.id - openai_api_key = "..." - workdir = "/home/coder/project" - - # Enable boundary with landjail (requires Linux 6.7+) - enable_boundary = true - boundary_jail_type = "landjail" # or "nsjail" (default) - boundary_proxy_port = 8087 # optional, default is 8087 - - # Optional: provide custom boundary config with allowlist - # boundary_config_path = "/path/to/config.yaml" -} -``` - -When `enable_boundary = true`: -- All network traffic from Codex is routed through a filtering proxy -- Only allowlisted domains are accessible (configure via `~/.config/coder_boundary/config.yaml`) -- `landjail` uses Landlock V4 (no special capabilities needed, requires kernel 6.7+) -- `nsjail` uses Linux namespaces (may require Docker seccomp modifications) - -For more information, see [Agent Boundaries Documentation](https://coder.com/docs/ai-coder/agent-boundaries). - ## How it Works - **Install**: The module installs Codex CLI and sets up the environment diff --git a/registry/coder-labs/modules/codex/main.tf b/registry/coder-labs/modules/codex/main.tf index 4e44f3cd4..41bf86ee1 100644 --- a/registry/coder-labs/modules/codex/main.tf +++ b/registry/coder-labs/modules/codex/main.tf @@ -170,34 +170,6 @@ variable "codex_system_prompt" { 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_jail_type" { - type = string - description = "Jail type: nsjail (default) or landjail" - default = "nsjail" - validation { - condition = contains(["nsjail", "landjail"], var.boundary_jail_type) - error_message = "Must be nsjail or landjail." - } -} - -variable "boundary_proxy_port" { - type = number - description = "HTTP proxy port for boundary" - default = 8087 -} - -variable "boundary_config_path" { - type = string - description = "Path to boundary config.yaml file. If not provided, a minimal config with jail_type and proxy_port will be generated." - default = "" -} - resource "coder_env" "openai_api_key" { agent_id = var.agent_id name = "OPENAI_API_KEY" @@ -234,7 +206,7 @@ locals { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "2.2.0" + version = "2.0.0" agent_id = var.agent_id folder = local.workdir @@ -252,10 +224,6 @@ module "agentapi" { agentapi_version = var.agentapi_version pre_install_script = var.pre_install_script post_install_script = var.post_install_script - enable_boundary = var.enable_boundary - boundary_jail_type = var.boundary_jail_type - boundary_proxy_port = var.boundary_proxy_port - boundary_config_path = var.boundary_config_path start_script = <<-EOT #!/bin/bash set -o errexit diff --git a/registry/coder-labs/modules/codex/scripts/start.sh b/registry/coder-labs/modules/codex/scripts/start.sh index 1264398d5..e0e7d9725 100644 --- a/registry/coder-labs/modules/codex/scripts/start.sh +++ b/registry/coder-labs/modules/codex/scripts/start.sh @@ -210,13 +210,7 @@ capture_session_id() { start_codex() { printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}" - if [ -n "${BOUNDARY_WRAPPER:-}" ]; then - printf "Starting with coder boundary enabled\n" - agentapi server --type codex --term-width 67 --term-height 1190 -- \ - ${BOUNDARY_WRAPPER} codex "${CODEX_ARGS[@]}" & - else - agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" & - fi + agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" & capture_session_id } From 658a90a48a83367151b32cc2114a726960513d83 Mon Sep 17 00:00:00 2001 From: Shane White Date: Tue, 3 Mar 2026 12:05:14 +0000 Subject: [PATCH 05/15] fix: bun prettier issue --- registry/coder/modules/agentapi/scripts/main.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index afb19cde7..92845cc14 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -124,7 +124,7 @@ if [ "${ENABLE_BOUNDARY}" = "true" ]; then # Write boundary config if custom path not provided if [ -z "${BOUNDARY_CONFIG_PATH}" ]; then echo "Generating boundary config with jail_type=${BOUNDARY_JAIL_TYPE} and proxy_port=${BOUNDARY_PROXY_PORT}" - cat > ~/.config/coder_boundary/config.yaml < ~/.config/coder_boundary/config.yaml << EOF jail_type: ${BOUNDARY_JAIL_TYPE} proxy_port: ${BOUNDARY_PROXY_PORT} log_level: warn From 93232974f12f9d1a18d4b066eb56030003208dbd Mon Sep 17 00:00:00 2001 From: Shane White <85908724+shanewhite97@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:06:44 +0000 Subject: [PATCH 06/15] Update registry/coder/modules/agentapi/scripts/main.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- registry/coder/modules/agentapi/scripts/main.sh | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 92845cc14..1f45cb260 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -144,7 +144,8 @@ EOF export BOUNDARY_WRAPPER="$CODER_NO_CAPS boundary --" echo "Boundary wrapper configured: ${BOUNDARY_WRAPPER}" else - echo "Warning: coder command not found, boundary will not be enabled" + echo "Error: ENABLE_BOUNDARY=true, but 'coder' command not found. Boundary cannot be enabled." >&2 + exit 1 fi fi From 8daa78ad540e4e2b83fbb5b2f8f8680a745dbf85 Mon Sep 17 00:00:00 2001 From: Shane White <85908724+shanewhite97@users.noreply.github.com> Date: Tue, 3 Mar 2026 12:07:23 +0000 Subject: [PATCH 07/15] Update registry/coder/modules/agentapi/scripts/main.sh Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- registry/coder/modules/agentapi/scripts/main.sh | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 1f45cb260..cc4064ab0 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -139,10 +139,14 @@ EOF # Copy coder binary to strip CAP_NET_ADMIN capabilities # This is necessary because boundary doesn't work with privileged binaries if command_exists coder; then - CODER_NO_CAPS="$(dirname "$(which coder)")/coder-no-caps" - cp "$(which coder)" "$CODER_NO_CAPS" - export BOUNDARY_WRAPPER="$CODER_NO_CAPS boundary --" - echo "Boundary wrapper configured: ${BOUNDARY_WRAPPER}" + # Use a user-writable location under the module directory for the copied binary + CODER_NO_CAPS="$module_path/coder-no-caps" + if cp "$(which coder)" "$CODER_NO_CAPS"; then + export BOUNDARY_WRAPPER="$CODER_NO_CAPS boundary --" + echo "Boundary wrapper configured: ${BOUNDARY_WRAPPER}" + else + echo "Warning: Failed to copy coder binary to ${CODER_NO_CAPS}, boundary will not be enabled" + fi else echo "Error: ENABLE_BOUNDARY=true, but 'coder' command not found. Boundary cannot be enabled." >&2 exit 1 From 7d7c750ae6660bfd55943574ea20e407e91e98bd Mon Sep 17 00:00:00 2001 From: Shane White Date: Tue, 3 Mar 2026 12:47:47 +0000 Subject: [PATCH 08/15] feat: add validation tests for boundary --- registry/coder/modules/agentapi/main.test.ts | 139 ++++++++++++++++++ .../agentapi/testdata/agentapi-mock.js | 9 ++ 2 files changed, 148 insertions(+) diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index cedf840c2..0c06c6bba 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -245,6 +245,17 @@ describe("agentapi", async () => { } }); + test("validate-boundary-jail-type", async () => { + expect( + setup({ + moduleVariables: { + enable_boundary: "true", + boundary_jail_type: "invalid", + }, + }), + ).rejects.toThrow("Must be nsjail or landjail."); + }); + test("agentapi-allowed-hosts", async () => { // verify that the agentapi binary has access to the AGENTAPI_ALLOWED_HOSTS environment variable // set in main.sh @@ -612,5 +623,133 @@ describe("agentapi", async () => { // Should still send SIGTERM (graceful shutdown always happens). expect(result.stdout).toContain("Sending SIGTERM to AgentAPI"); }); + + test("boundary-disabled-by-default", async () => { + const { id } = await setup(); + await execModuleScript(id); + await expectAgentAPIStarted(id); + // Config file should NOT exist when boundary is disabled + const configCheck = await execContainer(id, [ + "bash", + "-c", + "test -f /home/coder/.config/coder_boundary/config.yaml && echo exists || echo missing", + ]); + expect(configCheck.stdout.trim()).toBe("missing"); + // BOUNDARY_WRAPPER should NOT be in the mock log + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + expect(mockLog).not.toContain("BOUNDARY_WRAPPER:"); + }); + test("boundary-enabled-generates-config", async () => { + const { id } = await setup({ + moduleVariables: { + enable_boundary: "true", + boundary_jail_type: "landjail", + boundary_proxy_port: "8087", + }, + }); + // Add mock coder binary for boundary setup + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: `#!/bin/bash +if [ "$1" = "boundary" ]; then + shift; shift; exec "$@" +fi +echo "mock coder"`, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + // Config file should exist with correct values + const config = await readFileContainer( + id, + "/home/coder/.config/coder_boundary/config.yaml", + ); + expect(config).toContain("jail_type: landjail"); + expect(config).toContain("proxy_port: 8087"); + expect(config).toContain("log_level: warn"); + // Config should have restrictive permissions (600) + const perms = await execContainer(id, [ + "bash", + "-c", + "stat -c '%a' /home/coder/.config/coder_boundary/config.yaml", + ]); + expect(perms.stdout.trim()).toBe("600"); + // BOUNDARY_WRAPPER should be exported and contain 'boundary' + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + expect(mockLog).toContain("BOUNDARY_WRAPPER:"); + expect(mockLog).toContain("boundary --"); + }); + test("boundary-custom-config-path", async () => { + const { id } = await setup({ + moduleVariables: { + enable_boundary: "true", + boundary_config_path: "/tmp/custom-boundary.yaml", + }, + }); + // Write a custom config file before running the module script + await execContainer(id, [ + "bash", + "-c", + `cat > /tmp/custom-boundary.yaml <<'EOF' +jail_type: nsjail +proxy_port: 9999 +log_level: debug +allowlist: + - "domain=example.com" +EOF`, + ]); + + // Add mock coder binary for boundary setup + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: `#!/bin/bash +if [ "$1" = "boundary" ]; then + shift; shift; exec "$@" +fi +echo "mock coder"`, + }); + + await execModuleScript(id); + await expectAgentAPIStarted(id); + // Should have copied the custom config, not generated one + const config = await readFileContainer( + id, + "/home/coder/.config/coder_boundary/config.yaml", + ); + expect(config).toContain("proxy_port: 9999"); + expect(config).toContain("domain=example.com"); + }); + test("boundary-enabled-no-coder-binary", async () => { + const { id } = await setup({ + moduleVariables: { + enable_boundary: "true", + }, + }); + + await execContainer( + id, + [ + "bash", + "-c", + "rm -f /usr/bin/coder /usr/local/bin/coder 2>/dev/null; hash -r", + ], + ["--user", "root"], + ); + + const resp = await execModuleScript(id); + + // Script should fail because coder binary is required + expect(resp.exitCode).not.toBe(0); + + const scriptLog = await readFileContainer(id, "/home/coder/script.log"); + expect(scriptLog).toContain("Boundary cannot be enabled"); + }); }); }); diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock.js b/registry/coder/modules/agentapi/testdata/agentapi-mock.js index 84a88c047..2dc310258 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-mock.js +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock.js @@ -30,6 +30,15 @@ for (const v of [ `\n${v}: ${process.env[v]}`, ); } + // Log boundary env vars. + for (const v of ["BOUNDARY_WRAPPER"]) { + if (process.env[v]) { + fs.appendFileSync( + "/home/coder/agentapi-mock.log", + `\n${v}: ${process.env[v]}`, + ); + } + } } // Write PID file for shutdown script. From 18b8d416830aad504f974a5e2bd2226c137fc37c Mon Sep 17 00:00:00 2001 From: Shane White Date: Thu, 5 Mar 2026 08:55:29 +0000 Subject: [PATCH 09/15] fix: swap to using wrapper script, remove redundant variables and copy the boundary config, renamed BOUNDARY_PREFIX to AGENTAPI_BOUNDARY_PREFIX --- registry/coder/modules/agentapi/main.test.ts | 19 ++------ registry/coder/modules/agentapi/main.tf | 27 +++-------- .../coder/modules/agentapi/scripts/main.sh | 45 ++++++++----------- .../agentapi/testdata/agentapi-mock.js | 16 +++---- .../agentapi/testdata/agentapi-start.sh | 16 +++++-- 5 files changed, 50 insertions(+), 73 deletions(-) diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index 0c06c6bba..4ff5e3f06 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -245,17 +245,6 @@ describe("agentapi", async () => { } }); - test("validate-boundary-jail-type", async () => { - expect( - setup({ - moduleVariables: { - enable_boundary: "true", - boundary_jail_type: "invalid", - }, - }), - ).rejects.toThrow("Must be nsjail or landjail."); - }); - test("agentapi-allowed-hosts", async () => { // verify that the agentapi binary has access to the AGENTAPI_ALLOWED_HOSTS environment variable // set in main.sh @@ -635,12 +624,12 @@ describe("agentapi", async () => { "test -f /home/coder/.config/coder_boundary/config.yaml && echo exists || echo missing", ]); expect(configCheck.stdout.trim()).toBe("missing"); - // BOUNDARY_WRAPPER should NOT be in the mock log + // AGENTAPI_BOUNDARY_PREFIX should NOT be in the mock log const mockLog = await readFileContainer( id, "/home/coder/agentapi-mock.log", ); - expect(mockLog).not.toContain("BOUNDARY_WRAPPER:"); + expect(mockLog).not.toContain("AGENTAPI_BOUNDARY_PREFIX:"); }); test("boundary-enabled-generates-config", async () => { const { id } = await setup({ @@ -677,12 +666,12 @@ echo "mock coder"`, "stat -c '%a' /home/coder/.config/coder_boundary/config.yaml", ]); expect(perms.stdout.trim()).toBe("600"); - // BOUNDARY_WRAPPER should be exported and contain 'boundary' + // AGENTAPI_BOUNDARY_PREFIX should be exported and contain 'boundary' const mockLog = await readFileContainer( id, "/home/coder/agentapi-mock.log", ); - expect(mockLog).toContain("BOUNDARY_WRAPPER:"); + expect(mockLog).toContain("AGENTAPI_BOUNDARY_PREFIX:"); expect(mockLog).toContain("boundary --"); }); test("boundary-custom-config-path", async () => { diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index d1ee86ebc..8068b186b 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -166,30 +166,17 @@ variable "module_dir_name" { variable "enable_boundary" { type = bool - description = "Enable coder boundary for network filtering" + description = "Enable coder boundary for network filtering. Requires boundary_config_path to be set." default = false } - -variable "boundary_jail_type" { - type = string - description = "Jail type: nsjail (default) or landjail" - default = "nsjail" - validation { - condition = contains(["nsjail", "landjail"], var.boundary_jail_type) - error_message = "Must be nsjail or landjail." - } -} - -variable "boundary_proxy_port" { - type = number - description = "HTTP proxy port for boundary" - default = 8087 -} - variable "boundary_config_path" { type = string - description = "Path to boundary config.yaml file. If not provided, a minimal config with jail_type and proxy_port will be generated." + description = "Path to boundary config.yaml file. Required when enable_boundary is true. Must contain allowlist, jail_type, proxy_port, and log_level." default = "" + validation { + condition = !var.enable_boundary || var.boundary_config_path != "" + error_message = "boundary_config_path is required when enable_boundary is true." + } } variable "enable_state_persistence" { @@ -257,8 +244,6 @@ resource "coder_script" "agentapi" { ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \ ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \ - ARG_BOUNDARY_JAIL_TYPE='${var.boundary_jail_type}' \ - ARG_BOUNDARY_PROXY_PORT='${var.boundary_proxy_port}' \ ARG_BOUNDARY_CONFIG_PATH='${var.boundary_config_path}' \ ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \ ARG_STATE_FILE_PATH='${var.state_file_path}' \ diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index cc4064ab0..460867dde 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -17,8 +17,6 @@ AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}" TASK_ID="${ARG_TASK_ID:-}" TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" ENABLE_BOUNDARY="${ARG_ENABLE_BOUNDARY:-false}" -BOUNDARY_JAIL_TYPE="${ARG_BOUNDARY_JAIL_TYPE:-nsjail}" -BOUNDARY_PROXY_PORT="${ARG_BOUNDARY_PROXY_PORT:-8087}" BOUNDARY_CONFIG_PATH="${ARG_BOUNDARY_CONFIG_PATH:-}" ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}" STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}" @@ -114,38 +112,33 @@ export LC_ALL=en_US.UTF-8 cd "${WORKDIR}" # Set up boundary if enabled -export BOUNDARY_WRAPPER="" +export AGENTAPI_BOUNDARY_PREFIX="" if [ "${ENABLE_BOUNDARY}" = "true" ]; then echo "Setting up coder boundary..." - - # Create boundary config directory + # Copy the user-provided boundary config mkdir -p ~/.config/coder_boundary - - # Write boundary config if custom path not provided - if [ -z "${BOUNDARY_CONFIG_PATH}" ]; then - echo "Generating boundary config with jail_type=${BOUNDARY_JAIL_TYPE} and proxy_port=${BOUNDARY_PROXY_PORT}" - cat > ~/.config/coder_boundary/config.yaml << EOF -jail_type: ${BOUNDARY_JAIL_TYPE} -proxy_port: ${BOUNDARY_PROXY_PORT} -log_level: warn -EOF - else - echo "Using custom boundary config from ${BOUNDARY_CONFIG_PATH}" - cp "${BOUNDARY_CONFIG_PATH}" ~/.config/coder_boundary/config.yaml - fi - + echo "Using boundary config from ${BOUNDARY_CONFIG_PATH}" + cp "${BOUNDARY_CONFIG_PATH}" ~/.config/coder_boundary/config.yaml chmod 600 ~/.config/coder_boundary/config.yaml - - # Copy coder binary to strip CAP_NET_ADMIN capabilities - # This is necessary because boundary doesn't work with privileged binaries + # Copy coder binary to strip CAP_NET_ADMIN capabilities. + # This is necessary because boundary doesn't work with privileged binaries. if command_exists coder; then - # Use a user-writable location under the module directory for the copied binary CODER_NO_CAPS="$module_path/coder-no-caps" if cp "$(which coder)" "$CODER_NO_CAPS"; then - export BOUNDARY_WRAPPER="$CODER_NO_CAPS boundary --" - echo "Boundary wrapper configured: ${BOUNDARY_WRAPPER}" + # Write a wrapper script to avoid word-splitting issues with exported strings. + BOUNDARY_WRAPPER_SCRIPT="$module_path/boundary-wrapper.sh" + cat > "${BOUNDARY_WRAPPER_SCRIPT}" <<'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 + chmod +x "${BOUNDARY_WRAPPER_SCRIPT}" + export AGENTAPI_BOUNDARY_PREFIX="${BOUNDARY_WRAPPER_SCRIPT}" + echo "Boundary wrapper configured: ${AGENTAPI_BOUNDARY_PREFIX}" else - echo "Warning: Failed to copy coder binary to ${CODER_NO_CAPS}, boundary will not be enabled" + echo "Error: Failed to copy coder binary to ${CODER_NO_CAPS}. Boundary cannot be enabled." >&2 + exit 1 fi else echo "Error: ENABLE_BOUNDARY=true, but 'coder' command not found. Boundary cannot be enabled." >&2 diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock.js b/registry/coder/modules/agentapi/testdata/agentapi-mock.js index 2dc310258..e2e2d560d 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-mock.js +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock.js @@ -30,14 +30,14 @@ for (const v of [ `\n${v}: ${process.env[v]}`, ); } - // Log boundary env vars. - for (const v of ["BOUNDARY_WRAPPER"]) { - if (process.env[v]) { - fs.appendFileSync( - "/home/coder/agentapi-mock.log", - `\n${v}: ${process.env[v]}`, - ); - } +} +// Log boundary env vars. +for (const v of ["AGENTAPI_BOUNDARY_PREFIX"]) { + if (process.env[v]) { + fs.appendFileSync( + "/home/coder/agentapi-mock.log", + `\n${v}: ${process.env[v]}`, + ); } } diff --git a/registry/coder/modules/agentapi/testdata/agentapi-start.sh b/registry/coder/modules/agentapi/testdata/agentapi-start.sh index 259eb0c9f..417b64d09 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-start.sh +++ b/registry/coder/modules/agentapi/testdata/agentapi-start.sh @@ -17,6 +17,16 @@ if [ -n "$AGENTAPI_CHAT_BASE_PATH" ]; then export AGENTAPI_CHAT_BASE_PATH fi -agentapi server --port "$port" --term-width 67 --term-height 1190 -- \ - bash -c aiagent \ - > "$log_file_path" 2>&1 +# Use boundary wrapper if configured by agentapi module. +# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh +# and points to a wrapper script that runs the command through coder boundary. +if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then + echo "Starting with boundary: ${AGENTAPI_BOUNDARY_PREFIX}" >> /home/coder/test-agentapi-start.log + agentapi server --port "$port" --term-width 67 --term-height 1190 -- \ + "${AGENTAPI_BOUNDARY_PREFIX}" bash -c aiagent \ + > "$log_file_path" 2>&1 +else + agentapi server --port "$port" --term-width 67 --term-height 1190 -- \ + bash -c aiagent \ + > "$log_file_path" 2>&1 +fi From 7002c974357128e7a618c6c79dad676e01c27229 Mon Sep 17 00:00:00 2001 From: Shane White Date: Thu, 5 Mar 2026 08:56:14 +0000 Subject: [PATCH 10/15] docs: Added section for implementation of boundary in the agentapi README.md --- registry/coder/modules/agentapi/README.md | 29 +++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index c5e9ae423..36fab85c8 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -89,6 +89,35 @@ module "agentapi" { } ``` +## Boundary (Network Filtering) +The agentapi module supports optional [Agent Boundaries](https://coder.com/docs/ai-coder/agent-boundaries) +for network filtering. When enabled, the module sets up a `AGENTAPI_BOUNDARY_PREFIX` environment +variable that points to a wrapper script. Agent modules should use this prefix in their +start scripts to run the agent process through boundary. +Boundary requires a `config.yaml` file with your allowlist, jail type, proxy port, and log +level. See the [Agent Boundaries documentation](https://coder.com/docs/ai-coder/agent-boundaries) +for configuration details. +To enable: +```tf +module "agentapi" { + # ... other config + enable_boundary = true + boundary_config_path = "${path.module}/boundary-config.yaml" +} +``` +### Contract for agent modules +When `enable_boundary = true`, the agentapi module exports `AGENTAPI_BOUNDARY_PREFIX` +as an environment variable pointing to a wrapper script. Agent module start scripts +should check for this variable and use it to prefix the agent command: +```bash +if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then + agentapi server -- "${AGENTAPI_BOUNDARY_PREFIX}" my-agent "${ARGS[@]}" & +else + agentapi server -- my-agent "${ARGS[@]}" & +fi +``` +This ensures only the agent process is sandboxed while agentapi itself runs unrestricted. + ## For module developers For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf). From 7fcc6a1cd99ec99f77c0f0a91db5da1fc87640d6 Mon Sep 17 00:00:00 2001 From: Shane White Date: Thu, 5 Mar 2026 09:06:52 +0000 Subject: [PATCH 11/15] fix: update tests based on new config file logic. Also ran run formatter --- registry/coder/modules/agentapi/README.md | 6 ++ registry/coder/modules/agentapi/main.test.ts | 94 +++++++++---------- .../coder/modules/agentapi/scripts/main.sh | 2 +- 3 files changed, 53 insertions(+), 49 deletions(-) diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index 36fab85c8..d99beb64b 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -90,6 +90,7 @@ module "agentapi" { ``` ## Boundary (Network Filtering) + The agentapi module supports optional [Agent Boundaries](https://coder.com/docs/ai-coder/agent-boundaries) for network filtering. When enabled, the module sets up a `AGENTAPI_BOUNDARY_PREFIX` environment variable that points to a wrapper script. Agent modules should use this prefix in their @@ -98,6 +99,7 @@ Boundary requires a `config.yaml` file with your allowlist, jail type, proxy por level. See the [Agent Boundaries documentation](https://coder.com/docs/ai-coder/agent-boundaries) for configuration details. To enable: + ```tf module "agentapi" { # ... other config @@ -105,10 +107,13 @@ module "agentapi" { boundary_config_path = "${path.module}/boundary-config.yaml" } ``` + ### Contract for agent modules + When `enable_boundary = true`, the agentapi module exports `AGENTAPI_BOUNDARY_PREFIX` as an environment variable pointing to a wrapper script. Agent module start scripts should check for this variable and use it to prefix the agent command: + ```bash if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then agentapi server -- "${AGENTAPI_BOUNDARY_PREFIX}" my-agent "${ARGS[@]}" & @@ -116,6 +121,7 @@ else agentapi server -- my-agent "${ARGS[@]}" & fi ``` + This ensures only the agent process is sandboxed while agentapi itself runs unrestricted. ## For module developers diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index 4ff5e3f06..e02c64cf3 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -631,14 +631,26 @@ describe("agentapi", async () => { ); expect(mockLog).not.toContain("AGENTAPI_BOUNDARY_PREFIX:"); }); - test("boundary-enabled-generates-config", async () => { + + test("boundary-enabled", async () => { const { id } = await setup({ moduleVariables: { enable_boundary: "true", - boundary_jail_type: "landjail", - boundary_proxy_port: "8087", + boundary_config_path: "/tmp/test-boundary.yaml", }, }); + // Write boundary config before running the module + await execContainer(id, [ + "bash", + "-c", + `cat > /tmp/test-boundary.yaml <<'EOF' +jail_type: landjail +proxy_port: 8087 +log_level: warn +allowlist: + - "domain=api.example.com" +EOF`, + ]); // Add mock coder binary for boundary setup await writeExecutable({ containerId: id, @@ -651,14 +663,14 @@ echo "mock coder"`, }); await execModuleScript(id); await expectAgentAPIStarted(id); - // Config file should exist with correct values + // Config should be copied to the standard location const config = await readFileContainer( id, "/home/coder/.config/coder_boundary/config.yaml", ); expect(config).toContain("jail_type: landjail"); expect(config).toContain("proxy_port: 8087"); - expect(config).toContain("log_level: warn"); + expect(config).toContain("domain=api.example.com"); // Config should have restrictive permissions (600) const perms = await execContainer(id, [ "bash", @@ -666,62 +678,38 @@ echo "mock coder"`, "stat -c '%a' /home/coder/.config/coder_boundary/config.yaml", ]); expect(perms.stdout.trim()).toBe("600"); - // AGENTAPI_BOUNDARY_PREFIX should be exported and contain 'boundary' + // AGENTAPI_BOUNDARY_PREFIX should be exported const mockLog = await readFileContainer( id, "/home/coder/agentapi-mock.log", ); expect(mockLog).toContain("AGENTAPI_BOUNDARY_PREFIX:"); - expect(mockLog).toContain("boundary --"); + // E2E: start script should have used the wrapper + const startLog = await readFileContainer( + id, + "/home/coder/test-agentapi-start.log", + ); + expect(startLog).toContain("Starting with boundary:"); }); - test("boundary-custom-config-path", async () => { + + test("boundary-enabled-no-coder-binary", async () => { const { id } = await setup({ moduleVariables: { enable_boundary: "true", - boundary_config_path: "/tmp/custom-boundary.yaml", + boundary_config_path: "/tmp/test-boundary.yaml", }, }); - // Write a custom config file before running the module script + // Write boundary config (still needed before the coder check) await execContainer(id, [ "bash", "-c", - `cat > /tmp/custom-boundary.yaml <<'EOF' -jail_type: nsjail -proxy_port: 9999 -log_level: debug -allowlist: - - "domain=example.com" + `cat > /tmp/test-boundary.yaml <<'EOF' +jail_type: landjail +proxy_port: 8087 +log_level: warn EOF`, ]); - - // Add mock coder binary for boundary setup - await writeExecutable({ - containerId: id, - filePath: "/usr/bin/coder", - content: `#!/bin/bash -if [ "$1" = "boundary" ]; then - shift; shift; exec "$@" -fi -echo "mock coder"`, - }); - - await execModuleScript(id); - await expectAgentAPIStarted(id); - // Should have copied the custom config, not generated one - const config = await readFileContainer( - id, - "/home/coder/.config/coder_boundary/config.yaml", - ); - expect(config).toContain("proxy_port: 9999"); - expect(config).toContain("domain=example.com"); - }); - test("boundary-enabled-no-coder-binary", async () => { - const { id } = await setup({ - moduleVariables: { - enable_boundary: "true", - }, - }); - + // Remove coder binary to simulate it not being available await execContainer( id, [ @@ -731,14 +719,24 @@ echo "mock coder"`, ], ["--user", "root"], ); - const resp = await execModuleScript(id); - // Script should fail because coder binary is required expect(resp.exitCode).not.toBe(0); - const scriptLog = await readFileContainer(id, "/home/coder/script.log"); expect(scriptLog).toContain("Boundary cannot be enabled"); }); + + test("validate-boundary-requires-config", async () => { + expect( + setup({ + moduleVariables: { + enable_boundary: "true", + // boundary_config_path intentionally omitted + }, + }), + ).rejects.toThrow( + "boundary_config_path is required when enable_boundary is true.", + ); + }); }); }); diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 460867dde..53d3e46d5 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -127,7 +127,7 @@ if [ "${ENABLE_BOUNDARY}" = "true" ]; then if cp "$(which coder)" "$CODER_NO_CAPS"; then # Write a wrapper script to avoid word-splitting issues with exported strings. BOUNDARY_WRAPPER_SCRIPT="$module_path/boundary-wrapper.sh" - cat > "${BOUNDARY_WRAPPER_SCRIPT}" <<'WRAPPER_EOF' + cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF' #!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" From 4b3c5aa8dbdac9c347ed16906d5b5e1fd071dddb Mon Sep 17 00:00:00 2001 From: Shane White Date: Fri, 6 Mar 2026 09:10:02 +0000 Subject: [PATCH 12/15] docs: add new line for README.md formatting --- registry/coder/modules/agentapi/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index d99beb64b..2b9501c00 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -95,6 +95,7 @@ The agentapi module supports optional [Agent Boundaries](https://coder.com/docs/ for network filtering. When enabled, the module sets up a `AGENTAPI_BOUNDARY_PREFIX` environment variable that points to a wrapper script. Agent modules should use this prefix in their start scripts to run the agent process through boundary. + Boundary requires a `config.yaml` file with your allowlist, jail type, proxy port, and log level. See the [Agent Boundaries documentation](https://coder.com/docs/ai-coder/agent-boundaries) for configuration details. From 01155ccbd3f5c7ca41dc9226dc38ec21fee71fc9 Mon Sep 17 00:00:00 2001 From: Shane White Date: Fri, 6 Mar 2026 09:22:58 +0000 Subject: [PATCH 13/15] fix: addressed latest comments on the PR. boundary_config_path removed in favoured of standard method. README and tests updated. --- registry/coder/modules/agentapi/README.md | 4 +- registry/coder/modules/agentapi/main.test.ts | 40 ++++++------------- registry/coder/modules/agentapi/main.tf | 13 +++--- .../coder/modules/agentapi/scripts/main.sh | 6 +-- 4 files changed, 25 insertions(+), 38 deletions(-) diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index 2b9501c00..a225ca490 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -104,8 +104,8 @@ To enable: ```tf module "agentapi" { # ... other config - enable_boundary = true - boundary_config_path = "${path.module}/boundary-config.yaml" + enable_boundary = true + boundary_config = file("${path.module}/boundary-config.yaml") } ``` diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index e02c64cf3..1039129db 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -612,7 +612,9 @@ describe("agentapi", async () => { // Should still send SIGTERM (graceful shutdown always happens). expect(result.stdout).toContain("Sending SIGTERM to AgentAPI"); }); + }); + describe("boundary", async () => { test("boundary-disabled-by-default", async () => { const { id } = await setup(); await execModuleScript(id); @@ -636,21 +638,13 @@ describe("agentapi", async () => { const { id } = await setup({ moduleVariables: { enable_boundary: "true", - boundary_config_path: "/tmp/test-boundary.yaml", - }, - }); - // Write boundary config before running the module - await execContainer(id, [ - "bash", - "-c", - `cat > /tmp/test-boundary.yaml <<'EOF' -jail_type: landjail + boundary_config: `jail_type: landjail proxy_port: 8087 log_level: warn allowlist: - - "domain=api.example.com" -EOF`, - ]); + - "domain=api.example.com"`, + }, + }); // Add mock coder binary for boundary setup await writeExecutable({ containerId: id, @@ -663,7 +657,7 @@ echo "mock coder"`, }); await execModuleScript(id); await expectAgentAPIStarted(id); - // Config should be copied to the standard location + // Config should be written to the standard location const config = await readFileContainer( id, "/home/coder/.config/coder_boundary/config.yaml", @@ -696,19 +690,11 @@ echo "mock coder"`, const { id } = await setup({ moduleVariables: { enable_boundary: "true", - boundary_config_path: "/tmp/test-boundary.yaml", + boundary_config: `jail_type: landjail +proxy_port: 8087 +log_level: warn`, }, }); - // Write boundary config (still needed before the coder check) - await execContainer(id, [ - "bash", - "-c", - `cat > /tmp/test-boundary.yaml <<'EOF' -jail_type: landjail -proxy_port: 8087 -log_level: warn -EOF`, - ]); // Remove coder binary to simulate it not being available await execContainer( id, @@ -725,17 +711,17 @@ EOF`, const scriptLog = await readFileContainer(id, "/home/coder/script.log"); expect(scriptLog).toContain("Boundary cannot be enabled"); }); - + test("validate-boundary-requires-config", async () => { expect( setup({ moduleVariables: { enable_boundary: "true", - // boundary_config_path intentionally omitted + // boundary_config intentionally omitted }, }), ).rejects.toThrow( - "boundary_config_path is required when enable_boundary is true.", + "boundary_config is required when enable_boundary is true.", ); }); }); diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index 8068b186b..4dca7d2ef 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -166,16 +166,17 @@ variable "module_dir_name" { variable "enable_boundary" { type = bool - description = "Enable coder boundary for network filtering. Requires boundary_config_path to be set." + description = "Enable coder boundary for network filtering. Requires boundary_config to be set." default = false } -variable "boundary_config_path" { + +variable "boundary_config" { type = string - description = "Path to boundary config.yaml file. Required when enable_boundary is true. Must contain allowlist, jail_type, proxy_port, and log_level." + description = "Content of boundary config.yaml file. Required when enable_boundary is true. Must contain allowlist, jail_type, proxy_port, and log_level." default = "" validation { - condition = !var.enable_boundary || var.boundary_config_path != "" - error_message = "boundary_config_path is required when enable_boundary is true." + condition = !var.enable_boundary || var.boundary_config != "" + error_message = "boundary_config is required when enable_boundary is true." } } @@ -244,7 +245,7 @@ resource "coder_script" "agentapi" { ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \ ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \ - ARG_BOUNDARY_CONFIG_PATH='${var.boundary_config_path}' \ + ARG_BOUNDARY_CONFIG="$(echo -n '${base64encode(var.boundary_config)}' | base64 -d)" \ ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \ ARG_STATE_FILE_PATH='${var.state_file_path}' \ ARG_PID_FILE_PATH='${var.pid_file_path}' \ diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 53d3e46d5..a57efe5e2 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -17,7 +17,7 @@ AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}" TASK_ID="${ARG_TASK_ID:-}" TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" ENABLE_BOUNDARY="${ARG_ENABLE_BOUNDARY:-false}" -BOUNDARY_CONFIG_PATH="${ARG_BOUNDARY_CONFIG_PATH:-}" +BOUNDARY_CONFIG="${ARG_BOUNDARY_CONFIG:-}" ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}" STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}" PID_FILE_PATH="${ARG_PID_FILE_PATH:-}" @@ -117,8 +117,8 @@ if [ "${ENABLE_BOUNDARY}" = "true" ]; then echo "Setting up coder boundary..." # Copy the user-provided boundary config mkdir -p ~/.config/coder_boundary - echo "Using boundary config from ${BOUNDARY_CONFIG_PATH}" - cp "${BOUNDARY_CONFIG_PATH}" ~/.config/coder_boundary/config.yaml + echo "Writing boundary config to ~/.config/coder_boundary/config.yaml" + echo -n "${BOUNDARY_CONFIG}" > ~/.config/coder_boundary/config.yaml chmod 600 ~/.config/coder_boundary/config.yaml # Copy coder binary to strip CAP_NET_ADMIN capabilities. # This is necessary because boundary doesn't work with privileged binaries. From de2e41c1f75d1c69b6dff6191272d0a1172a147c Mon Sep 17 00:00:00 2001 From: Shane White Date: Fri, 6 Mar 2026 17:45:05 +0000 Subject: [PATCH 14/15] fix: formatting issues --- registry/coder/modules/agentapi/main.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index 1039129db..4e82e6eab 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -711,7 +711,7 @@ log_level: warn`, const scriptLog = await readFileContainer(id, "/home/coder/script.log"); expect(scriptLog).toContain("Boundary cannot be enabled"); }); - + test("validate-boundary-requires-config", async () => { expect( setup({ From 04e50d38d665a6ea51790c192434dadf0d4fc540 Mon Sep 17 00:00:00 2001 From: Shane White Date: Fri, 6 Mar 2026 17:49:09 +0000 Subject: [PATCH 15/15] feat: provide boundary support for agent modules Enable any agent module to run its AI agent inside Coder's Agent Boundaries. The agentapi module handles config setup and wrapper script creation, then exports AGENTAPI_BOUNDARY_PREFIX for consuming modules to use in their start scripts. Users must provide a boundary config.yaml with their allowlist and settings when enabling boundary. --- .github/workflows/ci.yaml | 2 +- registry/coder-labs/modules/codex/README.md | 27 +- .../coder-labs/modules/codex/main.test.ts | 11 +- registry/coder-labs/modules/codex/main.tf | 66 ++-- .../coder-labs/modules/codex/main.tftest.hcl | 187 ++++++++++ .../modules/codex/scripts/install.sh | 12 + .../coder-labs/modules/codex/scripts/start.sh | 9 +- registry/coder-labs/modules/copilot/README.md | 45 ++- .../modules/copilot/copilot.tftest.hcl | 113 +++++++ registry/coder-labs/modules/copilot/main.tf | 34 +- .../modules/copilot/scripts/start.sh | 46 +++ registry/coder/modules/agentapi/README.md | 65 +++- .../modules/agentapi/agentapi.tftest.hcl | 108 ++++++ registry/coder/modules/agentapi/main.test.ts | 319 +++++++++++++++++- registry/coder/modules/agentapi/main.tf | 44 +++ .../agentapi/scripts/agentapi-shutdown.sh | 79 ++++- .../coder/modules/agentapi/scripts/lib.sh | 45 +++ .../coder/modules/agentapi/scripts/main.sh | 57 ++++ .../testdata/agentapi-mock-shutdown.js | 18 + .../agentapi/testdata/agentapi-mock.js | 38 +++ .../agentapi/testdata/agentapi-start.sh | 16 +- .../coder/modules/aibridge-proxy/README.md | 89 +++++ .../coder/modules/aibridge-proxy/main.test.ts | 254 ++++++++++++++ registry/coder/modules/aibridge-proxy/main.tf | 81 +++++ .../modules/aibridge-proxy/main.tftest.hcl | 210 ++++++++++++ .../modules/aibridge-proxy/scripts/setup.sh | 79 +++++ registry/coder/modules/claude-code/README.md | 31 +- registry/coder/modules/claude-code/main.tf | 47 +-- .../coder/modules/claude-code/main.tftest.hcl | 30 ++ .../coder/modules/devcontainers-cli/README.md | 7 +- .../coder/modules/devcontainers-cli/main.tf | 17 +- registry/coder/modules/vscode-web/README.md | 19 +- .../coder/modules/vscode-web/main.test.ts | 302 +++++++++++++++-- registry/coder/modules/vscode-web/main.tf | 9 +- registry/coder/modules/vscode-web/run.sh | 56 ++- 35 files changed, 2415 insertions(+), 157 deletions(-) create mode 100644 registry/coder-labs/modules/codex/main.tftest.hcl create mode 100644 registry/coder/modules/agentapi/agentapi.tftest.hcl create mode 100644 registry/coder/modules/agentapi/scripts/lib.sh create mode 100644 registry/coder/modules/aibridge-proxy/README.md create mode 100644 registry/coder/modules/aibridge-proxy/main.test.ts create mode 100644 registry/coder/modules/aibridge-proxy/main.tf create mode 100644 registry/coder/modules/aibridge-proxy/main.tftest.hcl create mode 100644 registry/coder/modules/aibridge-proxy/scripts/setup.sh diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c0204f9f5..8b9941828 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,7 +1,7 @@ name: CI on: pull_request: - branches: [main] + # Cancel in-progress runs for pull requests when developers push new changes concurrency: group: ${{ github.workflow }}-${{ github.ref }} diff --git a/registry/coder-labs/modules/codex/README.md b/registry/coder-labs/modules/codex/README.md index b4a895dea..df486812c 100644 --- a/registry/coder-labs/modules/codex/README.md +++ b/registry/coder-labs/modules/codex/README.md @@ -13,7 +13,7 @@ Run Codex CLI in your workspace to access OpenAI's models through the Codex inte ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.1" + version = "4.2.0" agent_id = coder_agent.example.id openai_api_key = var.openai_api_key workdir = "/home/coder/project" @@ -32,7 +32,7 @@ module "codex" { module "codex" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.1" + version = "4.2.0" agent_id = coder_agent.example.id openai_api_key = "..." workdir = "/home/coder/project" @@ -51,7 +51,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.1" + version = "4.2.0" agent_id = coder_agent.example.id workdir = "/home/coder/project" enable_aibridge = true @@ -63,6 +63,8 @@ When `enable_aibridge = true`, the module: - Configures Codex to use the AI Bridge profile 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 +profile = "aibridge" # sets the default profile to aibridge + [model_providers.aibridge] name = "AI Bridge" base_url = "https://example.coder.com/api/v2/aibridge/openai/v1" @@ -75,8 +77,6 @@ model = "" # as configured in the module input model_reasoning_effort = "" # as configured in the module input ``` -Codex then runs with `--profile aibridge` - 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`. @@ -94,7 +94,7 @@ data "coder_task" "me" {} module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.1" + version = "4.2.0" agent_id = coder_agent.example.id openai_api_key = "..." ai_prompt = data.coder_task.me.prompt @@ -112,7 +112,7 @@ This example shows additional configuration options for custom models, MCP serve ```tf module "codex" { source = "registry.coder.com/coder-labs/codex/coder" - version = "4.1.1" + version = "4.2.0" agent_id = coder_agent.example.id openai_api_key = "..." workdir = "/home/coder/project" @@ -148,6 +148,19 @@ module "codex" { - **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: + +```tf +module "codex" { + # ... other config + enable_state_persistence = false +} +``` + ## Configuration ### Default Configuration diff --git a/registry/coder-labs/modules/codex/main.test.ts b/registry/coder-labs/modules/codex/main.test.ts index a4edd8185..f8d9f0a59 100644 --- a/registry/coder-labs/modules/codex/main.test.ts +++ b/registry/coder-labs/modules/codex/main.test.ts @@ -464,22 +464,13 @@ describe("codex", async () => { }); await execModuleScript(id); - - const startLog = await readFileContainer( - id, - "/home/coder/.codex-module/agentapi-start.log", - ); - const configToml = await readFileContainer( id, "/home/coder/.codex/config.toml", ); - expect(startLog).toContain("AI Bridge is enabled, using profile aibridge"); - expect(startLog).toContain( - "Starting Codex with arguments: --profile aibridge", - ); expect(configToml).toContain( "[profiles.aibridge]\n" + 'model_provider = "aibridge"', ); + expect(configToml).toContain('profile = "aibridge"'); }); }); diff --git a/registry/coder-labs/modules/codex/main.tf b/registry/coder-labs/modules/codex/main.tf index cc07ce2f9..dd70fdc48 100644 --- a/registry/coder-labs/modules/codex/main.tf +++ b/registry/coder-labs/modules/codex/main.tf @@ -131,13 +131,13 @@ variable "install_agentapi" { variable "agentapi_version" { type = string description = "The version of AgentAPI to install." - default = "v0.11.8" + default = "v0.12.1" } variable "codex_model" { type = string - description = "The model for Codex to use. Defaults to gpt-5.2-codex." - default = "gpt-5.2-codex" + description = "The model for Codex to use. Defaults to gpt-5.3-codex." + default = "gpt-5.3-codex" } variable "pre_install_script" { @@ -164,6 +164,12 @@ variable "continue" { default = true } +variable "enable_state_persistence" { + type = bool + description = "Enable AgentAPI conversation state persistence across restarts." + default = true +} + variable "codex_system_prompt" { type = string description = "System instructions written to AGENTS.md in the ~/.codex directory" @@ -184,12 +190,13 @@ resource "coder_env" "coder_aibridge_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" - aibridge_config = <<-EOF + 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.3-codex" + aibridge_config = <<-EOF [model_providers.aibridge] name = "AI Bridge" base_url = "${data.coder_workspace.me.access_url}/api/v2/aibridge/openai/v1" @@ -205,25 +212,26 @@ locals { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "2.0.0" - - agent_id = var.agent_id - folder = local.workdir - web_app_slug = local.app_slug - web_app_order = var.order - web_app_group = var.group - web_app_icon = var.icon - web_app_display_name = var.web_app_display_name - cli_app = var.cli_app - cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null - cli_app_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 - pre_install_script = var.pre_install_script - post_install_script = var.post_install_script - start_script = <<-EOT + version = "2.2.0" + + agent_id = var.agent_id + folder = local.workdir + web_app_slug = local.app_slug + web_app_order = var.order + web_app_group = var.group + web_app_icon = var.icon + web_app_display_name = var.web_app_display_name + cli_app = var.cli_app + cli_app_slug = var.cli_app ? "${local.app_slug}-cli" : null + cli_app_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 + start_script = <<-EOT #!/bin/bash set -o errexit set -o pipefail @@ -249,6 +257,8 @@ module "agentapi" { 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)}' \ diff --git a/registry/coder-labs/modules/codex/main.tftest.hcl b/registry/coder-labs/modules/codex/main.tftest.hcl new file mode 100644 index 000000000..1237df5de --- /dev/null +++ b/registry/coder-labs/modules/codex/main.tftest.hcl @@ -0,0 +1,187 @@ +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" + } + + assert { + condition = var.enable_aibridge == false + error_message = "enable_aibridge should default to false" + } +} diff --git a/registry/coder-labs/modules/codex/scripts/install.sh b/registry/coder-labs/modules/codex/scripts/install.sh index 97d539a8c..4742c4137 100644 --- a/registry/coder-labs/modules/codex/scripts/install.sh +++ b/registry/coder-labs/modules/codex/scripts/install.sh @@ -20,6 +20,8 @@ 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")" @@ -90,15 +92,25 @@ function install_codex() { write_minimal_default_config() { local config_path="$1" + + ARG_DEFAULT_PROFILE="" + + if [[ "${ARG_ENABLE_AIBRIDGE}" = "true" ]]; then + ARG_DEFAULT_PROFILE='profile = "aibridge"' + fi + cat << EOF > "$config_path" # Minimal Default Codex Configuration sandbox_mode = "workspace-write" approval_policy = "never" preferred_auth_method = "apikey" +${ARG_DEFAULT_PROFILE} [sandbox_workspace_write] network_access = true +[notice.model_migrations] +"${ARG_CODEX_MODEL}" = "${ARG_LATEST_CODEX_MODEL}" EOF } diff --git a/registry/coder-labs/modules/codex/scripts/start.sh b/registry/coder-labs/modules/codex/scripts/start.sh index 3e55dc70f..e0e7d9725 100644 --- a/registry/coder-labs/modules/codex/scripts/start.sh +++ b/registry/coder-labs/modules/codex/scripts/start.sh @@ -155,11 +155,8 @@ setup_workdir() { build_codex_args() { CODEX_ARGS=() - if [ "$ARG_ENABLE_AIBRIDGE" = "true" ]; then - printf "AI Bridge is enabled, using profile aibridge\n" - CODEX_ARGS+=("--profile" "aibridge") - elif [ -n "$ARG_CODEX_MODEL" ]; then - CODEX_ARGS+=("--model" "$ARG_CODEX_MODEL") + if [[ -n "${ARG_CODEX_MODEL}" ]] && [[ "${ARG_ENABLE_AIBRIDGE}" != "true" ]]; then + CODEX_ARGS+=("--model" "${ARG_CODEX_MODEL}") fi if [ "$ARG_CONTINUE" = "true" ]; then @@ -213,7 +210,7 @@ capture_session_id() { start_codex() { printf "Starting Codex with arguments: %s\n" "${CODEX_ARGS[*]}" - agentapi server --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" & + agentapi server --type codex --term-width 67 --term-height 1190 -- codex "${CODEX_ARGS[@]}" & capture_session_id } diff --git a/registry/coder-labs/modules/copilot/README.md b/registry/coder-labs/modules/copilot/README.md index 76b8f025c..7c0e56935 100644 --- a/registry/coder-labs/modules/copilot/README.md +++ b/registry/coder-labs/modules/copilot/README.md @@ -3,7 +3,7 @@ display_name: Copilot CLI description: GitHub Copilot CLI agent for AI-powered terminal assistance icon: ../../../../.icons/github.svg verified: false -tags: [agent, copilot, ai, github, tasks] +tags: [agent, copilot, ai, github, tasks, aibridge] --- # Copilot @@ -13,7 +13,7 @@ Run [GitHub Copilot CLI](https://docs.github.com/copilot/concepts/agents/about-c ```tf module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.3.0" + version = "0.4.0" agent_id = coder_agent.example.id workdir = "/home/coder/projects" } @@ -51,7 +51,7 @@ data "coder_parameter" "ai_prompt" { module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.3.0" + version = "0.4.0" agent_id = coder_agent.example.id workdir = "/home/coder/projects" @@ -71,7 +71,7 @@ Customize tool permissions, MCP servers, and Copilot settings: ```tf module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.3.0" + version = "0.4.0" agent_id = coder_agent.example.id workdir = "/home/coder/projects" @@ -142,7 +142,7 @@ variable "github_token" { module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.3.0" + version = "0.4.0" agent_id = coder_agent.example.id workdir = "/home/coder/projects" github_token = var.github_token @@ -156,7 +156,7 @@ Run Copilot as a command-line tool without task reporting or web interface. This ```tf module "copilot" { source = "registry.coder.com/coder-labs/copilot/coder" - version = "0.3.0" + version = "0.4.0" agent_id = coder_agent.example.id workdir = "/home/coder" report_tasks = false @@ -164,6 +164,39 @@ module "copilot" { } ``` +### Usage with AI Bridge Proxy + +[AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy) routes Copilot traffic through [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge) for centralized LLM management and governance. +The proxy environment variables are scoped to the Copilot process only and do not affect other workspace traffic. + +```tf +module "aibridge-proxy" { + source = "registry.coder.com/coder/aibridge-proxy/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + proxy_url = "https://aiproxy.example.com" +} + +module "copilot" { + source = "registry.coder.com/coder-labs/copilot/coder" + version = "0.4.0" + agent_id = coder_agent.main.id + workdir = "/home/coder/projects" + enable_aibridge_proxy = true + aibridge_proxy_auth_url = module.aibridge-proxy.proxy_auth_url + aibridge_proxy_cert_path = module.aibridge-proxy.cert_path +} +``` + +> [!NOTE] +> AI Bridge Proxy is a Premium Coder feature that requires [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance). +> See the [AI Bridge Proxy setup guide](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup) for details on configuring the proxy on your Coder deployment. +> GitHub authentication is still required for Copilot as the proxy authenticates with AI Bridge using the Coder session token, but does not replace GitHub authentication. + +> [!IMPORTANT] +> When using AI Bridge Proxy, enable [startup coordination](https://coder.com/docs/admin/templates/startup-coordination) by setting `CODER_AGENT_SOCKET_SERVER_ENABLED=true` in the workspace container environment. +> This ensures the Copilot module waits for the `aibridge-proxy` module to complete before starting. Without it, the Copilot start script may fail if the AI Bridge Proxy setup has not completed in time. + ## Authentication The module supports multiple authentication methods (in priority order): diff --git a/registry/coder-labs/modules/copilot/copilot.tftest.hcl b/registry/coder-labs/modules/copilot/copilot.tftest.hcl index 185c019ba..0ff2379a0 100644 --- a/registry/coder-labs/modules/copilot/copilot.tftest.hcl +++ b/registry/coder-labs/modules/copilot/copilot.tftest.hcl @@ -234,3 +234,116 @@ run "app_slug_is_consistent" { error_message = "module_dir_name should be '.copilot-module'" } } + +run "aibridge_proxy_defaults" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + assert { + condition = var.enable_aibridge_proxy == false + error_message = "enable_aibridge_proxy should default to false" + } + + assert { + condition = var.aibridge_proxy_auth_url == null + error_message = "aibridge_proxy_auth_url should default to null" + } + + assert { + condition = var.aibridge_proxy_cert_path == null + error_message = "aibridge_proxy_cert_path should default to null" + } +} + +run "aibridge_proxy_enabled" { + command = plan + + variables { + agent_id = "test-agent-aibridge-proxy" + workdir = "/home/coder" + enable_aibridge_proxy = true + aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com" + aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem" + } + + assert { + condition = var.enable_aibridge_proxy == true + error_message = "AI Bridge Proxy should be enabled" + } + + assert { + condition = var.aibridge_proxy_auth_url == "https://coder:mock-token@aiproxy.example.com" + error_message = "AI Bridge Proxy auth URL should match the input variable" + } + + assert { + condition = var.aibridge_proxy_cert_path == "/tmp/aibridge-proxy/ca-cert.pem" + error_message = "AI Bridge Proxy cert path should match the input variable" + } +} + +run "aibridge_proxy_validation_missing_proxy_auth_url" { + command = plan + + variables { + agent_id = "test-agent-validation" + workdir = "/home/coder" + enable_aibridge_proxy = true + aibridge_proxy_auth_url = "" + aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem" + } + + expect_failures = [ + var.enable_aibridge_proxy, + ] +} + +run "aibridge_proxy_validation_missing_cert_path" { + command = plan + + variables { + agent_id = "test-agent-validation" + workdir = "/home/coder" + enable_aibridge_proxy = true + aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com" + aibridge_proxy_cert_path = "" + } + + expect_failures = [ + var.enable_aibridge_proxy, + ] +} + +run "aibridge_proxy_with_copilot_config" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + copilot_model = "gpt-5" + github_token = "ghp_test123" + allow_all_tools = true + enable_aibridge_proxy = true + aibridge_proxy_auth_url = "https://coder:mock-token@aiproxy.example.com" + aibridge_proxy_cert_path = "/tmp/aibridge-proxy/ca-cert.pem" + } + + assert { + condition = var.enable_aibridge_proxy == true + error_message = "AI Bridge Proxy should be enabled" + } + + assert { + condition = length(resource.coder_env.github_token) == 1 + error_message = "github_token environment variable should be set alongside proxy" + } + + assert { + condition = length(resource.coder_env.copilot_model) == 1 + error_message = "copilot_model environment variable should be set alongside proxy" + } +} diff --git a/registry/coder-labs/modules/copilot/main.tf b/registry/coder-labs/modules/copilot/main.tf index 218184d75..2837961f5 100644 --- a/registry/coder-labs/modules/copilot/main.tf +++ b/registry/coder-labs/modules/copilot/main.tf @@ -1,5 +1,5 @@ terraform { - required_version = ">= 1.0" + required_version = ">= 1.9" required_providers { coder = { source = "coder/coder" @@ -173,6 +173,35 @@ variable "post_install_script" { default = null } +variable "enable_aibridge_proxy" { + type = bool + description = "Route Copilot traffic through AI Bridge Proxy. See https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy" + default = false + + validation { + condition = !var.enable_aibridge_proxy || (var.aibridge_proxy_auth_url != null && length(var.aibridge_proxy_auth_url) > 0) + error_message = "aibridge_proxy_auth_url is required when enable_aibridge_proxy is true." + } + + validation { + condition = !var.enable_aibridge_proxy || (var.aibridge_proxy_cert_path != null && length(var.aibridge_proxy_cert_path) > 0) + error_message = "aibridge_proxy_cert_path is required when enable_aibridge_proxy is true." + } +} + +variable "aibridge_proxy_auth_url" { + type = string + description = "AI Bridge Proxy URL with authentication. Use the proxy_auth_url output from the aibridge-proxy module." + default = null + sensitive = true +} + +variable "aibridge_proxy_cert_path" { + type = string + description = "Path to the AI Bridge Proxy CA certificate. Use the cert_path output from the aibridge-proxy module." + default = null +} + data "coder_workspace" "me" {} data "coder_workspace_owner" "me" {} @@ -279,6 +308,9 @@ module "agentapi" { ARG_TRUSTED_DIRECTORIES='${join(",", var.trusted_directories)}' \ ARG_EXTERNAL_AUTH_ID='${var.external_auth_id}' \ ARG_RESUME_SESSION='${var.resume_session}' \ + ARG_ENABLE_AIBRIDGE_PROXY='${var.enable_aibridge_proxy}' \ + ARG_AIBRIDGE_PROXY_AUTH_URL='${var.aibridge_proxy_auth_url != null ? var.aibridge_proxy_auth_url : ""}' \ + ARG_AIBRIDGE_PROXY_CERT_PATH='${var.aibridge_proxy_cert_path != null ? var.aibridge_proxy_cert_path : ""}' \ /tmp/start.sh EOT diff --git a/registry/coder-labs/modules/copilot/scripts/start.sh b/registry/coder-labs/modules/copilot/scripts/start.sh index 98341e9bc..0aecb1feb 100644 --- a/registry/coder-labs/modules/copilot/scripts/start.sh +++ b/registry/coder-labs/modules/copilot/scripts/start.sh @@ -22,6 +22,9 @@ ARG_DENY_TOOLS=${ARG_DENY_TOOLS:-} ARG_TRUSTED_DIRECTORIES=${ARG_TRUSTED_DIRECTORIES:-} ARG_EXTERNAL_AUTH_ID=${ARG_EXTERNAL_AUTH_ID:-github} ARG_RESUME_SESSION=${ARG_RESUME_SESSION:-true} +ARG_ENABLE_AIBRIDGE_PROXY=${ARG_ENABLE_AIBRIDGE_PROXY:-false} +ARG_AIBRIDGE_PROXY_AUTH_URL=${ARG_AIBRIDGE_PROXY_AUTH_URL:-} +ARG_AIBRIDGE_PROXY_CERT_PATH=${ARG_AIBRIDGE_PROXY_CERT_PATH:-} validate_copilot_installation() { if ! command_exists copilot; then @@ -118,6 +121,48 @@ setup_github_authentication() { return 0 } +setup_aibridge_proxy() { + if [ "$ARG_ENABLE_AIBRIDGE_PROXY" != "true" ]; then + return 0 + fi + + echo "Setting up AI Bridge Proxy..." + + # Wait for the aibridge-proxy module to finish. + # Uses startup coordination to block until aibridge-proxy-setup signals completion. + if command -v coder > /dev/null 2>&1; then + coder exp sync want "copilot-aibridge" "aibridge-proxy-setup" > /dev/null 2>&1 || true + coder exp sync start "copilot-aibridge" > /dev/null 2>&1 || true + trap 'coder exp sync complete "copilot-aibridge" > /dev/null 2>&1 || true' EXIT + fi + + if [ -z "$ARG_AIBRIDGE_PROXY_AUTH_URL" ]; then + echo "ERROR: AI Bridge Proxy is enabled but no proxy auth URL provided." + exit 1 + fi + + if [ -z "$ARG_AIBRIDGE_PROXY_CERT_PATH" ]; then + echo "ERROR: AI Bridge Proxy is enabled but no certificate path provided." + exit 1 + fi + + if [ ! -f "$ARG_AIBRIDGE_PROXY_CERT_PATH" ]; then + echo "ERROR: AI Bridge Proxy certificate not found at $ARG_AIBRIDGE_PROXY_CERT_PATH." + echo " Ensure the aibridge-proxy module has successfully completed setup." + exit 1 + fi + + # Set proxy environment variables scoped to this process tree only. + # These are inherited by the agentapi/copilot process below, + # but do not affect other workspace processes, avoiding routing + # unnecessary traffic through the proxy. + export HTTPS_PROXY="$ARG_AIBRIDGE_PROXY_AUTH_URL" + export NODE_EXTRA_CA_CERTS="$ARG_AIBRIDGE_PROXY_CERT_PATH" + + echo "✓ AI Bridge Proxy configured" + echo " CA certificate: $ARG_AIBRIDGE_PROXY_CERT_PATH" +} + start_agentapi() { echo "Starting in directory: $ARG_WORKDIR" cd "$ARG_WORKDIR" @@ -157,5 +202,6 @@ start_agentapi() { } setup_github_authentication +setup_aibridge_proxy validate_copilot_installation start_agentapi diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index e7a9869fb..a225ca490 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -16,7 +16,7 @@ The AgentAPI module is a building block for modules that need to run an AgentAPI ```tf module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "2.1.1" + version = "2.2.0" agent_id = var.agent_id web_app_slug = local.app_slug @@ -62,6 +62,69 @@ module "agentapi" { } ``` +## State Persistence + +AgentAPI can save and restore conversation state across workspace restarts. +This is disabled by default and requires agentapi binary >= v0.12.0. + +State and PID files are stored in `$HOME//` alongside other +module files (e.g. `$HOME/.claude-module/agentapi-state.json`). + +To enable: + +```tf +module "agentapi" { + # ... other config + enable_state_persistence = true +} +``` + +To override file paths: + +```tf +module "agentapi" { + # ... other config + state_file_path = "/custom/path/state.json" + pid_file_path = "/custom/path/agentapi.pid" +} +``` + +## Boundary (Network Filtering) + +The agentapi module supports optional [Agent Boundaries](https://coder.com/docs/ai-coder/agent-boundaries) +for network filtering. When enabled, the module sets up a `AGENTAPI_BOUNDARY_PREFIX` environment +variable that points to a wrapper script. Agent modules should use this prefix in their +start scripts to run the agent process through boundary. + +Boundary requires a `config.yaml` file with your allowlist, jail type, proxy port, and log +level. See the [Agent Boundaries documentation](https://coder.com/docs/ai-coder/agent-boundaries) +for configuration details. +To enable: + +```tf +module "agentapi" { + # ... other config + enable_boundary = true + boundary_config = file("${path.module}/boundary-config.yaml") +} +``` + +### Contract for agent modules + +When `enable_boundary = true`, the agentapi module exports `AGENTAPI_BOUNDARY_PREFIX` +as an environment variable pointing to a wrapper script. Agent module start scripts +should check for this variable and use it to prefix the agent command: + +```bash +if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then + agentapi server -- "${AGENTAPI_BOUNDARY_PREFIX}" my-agent "${ARGS[@]}" & +else + agentapi server -- my-agent "${ARGS[@]}" & +fi +``` + +This ensures only the agent process is sandboxed while agentapi itself runs unrestricted. + ## For module developers For a complete example of how to use this module, see the [Goose module](https://github.com/coder/registry/blob/main/registry/coder/modules/goose/main.tf). diff --git a/registry/coder/modules/agentapi/agentapi.tftest.hcl b/registry/coder/modules/agentapi/agentapi.tftest.hcl new file mode 100644 index 000000000..87404c625 --- /dev/null +++ b/registry/coder/modules/agentapi/agentapi.tftest.hcl @@ -0,0 +1,108 @@ +mock_provider "coder" {} + +variables { + agent_id = "test-agent" + web_app_icon = "/icon/test.svg" + web_app_display_name = "Test" + web_app_slug = "test" + cli_app_display_name = "Test CLI" + cli_app_slug = "test-cli" + start_script = "echo test" + module_dir_name = ".test-module" +} + +run "default_values" { + command = plan + + assert { + condition = var.enable_state_persistence == false + error_message = "enable_state_persistence should default to false" + } + + assert { + condition = var.state_file_path == "" + error_message = "state_file_path should default to empty string" + } + + assert { + condition = var.pid_file_path == "" + error_message = "pid_file_path should default to empty string" + } + + # Verify start script contains state persistence ARG_ vars. + assert { + condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi.script)) + error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE" + } + + assert { + condition = can(regex("ARG_STATE_FILE_PATH", coder_script.agentapi.script)) + error_message = "start script should contain ARG_STATE_FILE_PATH" + } + + assert { + condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi.script)) + error_message = "start script should contain ARG_PID_FILE_PATH" + } + + # Verify shutdown script contains PID-related ARG_ vars. + assert { + condition = can(regex("ARG_PID_FILE_PATH", coder_script.agentapi_shutdown.script)) + error_message = "shutdown script should contain ARG_PID_FILE_PATH" + } + + assert { + condition = can(regex("ARG_MODULE_DIR_NAME", coder_script.agentapi_shutdown.script)) + error_message = "shutdown script should contain ARG_MODULE_DIR_NAME" + } + + assert { + condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE", coder_script.agentapi_shutdown.script)) + error_message = "shutdown script should contain ARG_ENABLE_STATE_PERSISTENCE" + } +} + +run "state_persistence_disabled" { + command = plan + + variables { + enable_state_persistence = false + } + + assert { + condition = var.enable_state_persistence == false + error_message = "enable_state_persistence should be false" + } + + # Even when disabled, the ARG_ vars should still be in the script + # (the shell script handles the conditional logic). + assert { + condition = can(regex("ARG_ENABLE_STATE_PERSISTENCE='false'", coder_script.agentapi.script)) + error_message = "start script should contain ARG_ENABLE_STATE_PERSISTENCE='false'" + } +} + +run "custom_paths" { + command = plan + + variables { + state_file_path = "/custom/state.json" + pid_file_path = "/custom/agentapi.pid" + } + + assert { + condition = can(regex("/custom/state.json", coder_script.agentapi.script)) + error_message = "start script should contain custom state_file_path" + } + + assert { + condition = can(regex("/custom/agentapi.pid", coder_script.agentapi.script)) + error_message = "start script should contain custom pid_file_path" + } + + # Verify custom paths also appear in shutdown script. + assert { + condition = can(regex("/custom/agentapi.pid", coder_script.agentapi_shutdown.script)) + error_message = "shutdown script should contain custom pid_file_path" + } +} diff --git a/registry/coder/modules/agentapi/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index 20b47b1a0..4e82e6eab 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -258,11 +258,76 @@ describe("agentapi", async () => { expect(agentApiStartLog).toContain("AGENTAPI_ALLOWED_HOSTS: *"); }); + test("state-persistence-disabled", async () => { + const { id } = await setup({ + moduleVariables: { + enable_state_persistence: "false", + }, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + // PID file should always be exported + expect(mockLog).toContain("AGENTAPI_PID_FILE:"); + // State vars should NOT be present when disabled + expect(mockLog).not.toContain("AGENTAPI_STATE_FILE:"); + expect(mockLog).not.toContain("AGENTAPI_SAVE_STATE:"); + expect(mockLog).not.toContain("AGENTAPI_LOAD_STATE:"); + }); + + test("state-persistence-custom-paths", async () => { + const { id } = await setup({ + moduleVariables: { + enable_state_persistence: "true", + state_file_path: "/home/coder/custom/state.json", + pid_file_path: "/home/coder/custom/agentapi.pid", + }, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + expect(mockLog).toContain( + "AGENTAPI_STATE_FILE: /home/coder/custom/state.json", + ); + expect(mockLog).toContain( + "AGENTAPI_PID_FILE: /home/coder/custom/agentapi.pid", + ); + }); + + test("state-persistence-default-paths", async () => { + const { id } = await setup({ + moduleVariables: { + enable_state_persistence: "true", + }, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + expect(mockLog).toContain( + `AGENTAPI_STATE_FILE: /home/coder/${moduleDirName}/agentapi-state.json`, + ); + expect(mockLog).toContain( + `AGENTAPI_PID_FILE: /home/coder/${moduleDirName}/agentapi.pid`, + ); + expect(mockLog).toContain("AGENTAPI_SAVE_STATE: true"); + expect(mockLog).toContain("AGENTAPI_LOAD_STATE: true"); + }); + describe("shutdown script", async () => { const setupMocks = async ( containerId: string, agentapiPreset: string, httpCode: number = 204, + pidFilePath: string = "", ) => { const agentapiMock = await loadTestFile( import.meta.dir, @@ -285,10 +350,11 @@ describe("agentapi", async () => { content: coderMock, }); + const pidFileEnv = pidFilePath ? `AGENTAPI_PID_FILE=${pidFilePath}` : ""; await execContainer(containerId, [ "bash", "-c", - `PRESET=${agentapiPreset} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`, + `PRESET=${agentapiPreset} ${pidFileEnv} nohup node /usr/local/bin/mock-agentapi 3284 > /tmp/mock-agentapi.log 2>&1 &`, ]); await execContainer(containerId, [ @@ -303,12 +369,25 @@ describe("agentapi", async () => { const runShutdownScript = async ( containerId: string, taskId: string = "test-task", + pidFilePath: string = "", + enableStatePersistence: string = "false", ) => { const shutdownScript = await loadTestFile( import.meta.dir, "../scripts/agentapi-shutdown.sh", ); + const libScript = await loadTestFile( + import.meta.dir, + "../scripts/lib.sh", + ); + + await writeExecutable({ + containerId, + filePath: "/tmp/agentapi-lib.sh", + content: libScript, + }); + await writeExecutable({ containerId, filePath: "/tmp/shutdown.sh", @@ -318,7 +397,7 @@ describe("agentapi", async () => { return await execContainer(containerId, [ "bash", "-c", - `ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`, + `ARG_TASK_ID=${taskId} ARG_AGENTAPI_PORT=3284 ARG_PID_FILE_PATH=${pidFilePath} ARG_ENABLE_STATE_PERSISTENCE=${enableStatePersistence} CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`, ]); }; @@ -334,6 +413,7 @@ describe("agentapi", async () => { expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Retrieved 5 messages for log snapshot"); expect(result.stdout).toContain("Log snapshot posted successfully"); + expect(result.stdout).not.toContain("Log snapshot capture failed"); const posted = await readFileContainer(id, "/tmp/snapshot-posted.json"); const snapshot = JSON.parse(posted); @@ -409,5 +489,240 @@ describe("agentapi", async () => { "Log snapshot endpoint not supported by this Coder version", ); }); + + test("sends SIGUSR1 before shutdown", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + const pidFile = "/tmp/agentapi-test.pid"; + await setupMocks(id, "normal", 204, pidFile); + const result = await runShutdownScript(id, "test-task", pidFile, "true"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI"); + + const sigusr1Log = await readFileContainer(id, "/tmp/sigusr1-received"); + expect(sigusr1Log).toContain("SIGUSR1 received"); + }); + + test("handles missing PID file gracefully", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + await setupMocks(id, "normal"); + // Pass a non-existent PID file path with persistence enabled to + // exercise the SIGUSR1 path with a missing PID. + const result = await runShutdownScript( + id, + "test-task", + "/tmp/nonexistent.pid", + "true", + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Shutdown complete"); + }); + + test("sends SIGTERM even when snapshot fails", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + const pidFile = "/tmp/agentapi-test.pid"; + // HTTP 500 will cause snapshot to fail + await setupMocks(id, "normal", 500, pidFile); + const result = await runShutdownScript(id, "test-task", pidFile, "true"); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain( + "Log snapshot capture failed, continuing shutdown", + ); + expect(result.stdout).toContain("Sending SIGTERM to AgentAPI"); + }); + + test("resolves default PID path from MODULE_DIR_NAME", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + // Start mock with PID file at the module_dir_name default location. + const defaultPidPath = `/home/coder/${moduleDirName}/agentapi.pid`; + await setupMocks(id, "normal", 204, defaultPidPath); + // Don't pass pidFilePath - let shutdown script compute it from MODULE_DIR_NAME. + const shutdownScript = await loadTestFile( + import.meta.dir, + "../scripts/agentapi-shutdown.sh", + ); + const libScript = await loadTestFile( + import.meta.dir, + "../scripts/lib.sh", + ); + await writeExecutable({ + containerId: id, + filePath: "/tmp/agentapi-lib.sh", + content: libScript, + }); + await writeExecutable({ + containerId: id, + filePath: "/tmp/shutdown.sh", + content: shutdownScript, + }); + const result = await execContainer(id, [ + "bash", + "-c", + `ARG_TASK_ID=test-task ARG_AGENTAPI_PORT=3284 ARG_MODULE_DIR_NAME=${moduleDirName} ARG_ENABLE_STATE_PERSISTENCE=true CODER_AGENT_URL=http://localhost:18080 CODER_AGENT_TOKEN=test-token /tmp/shutdown.sh`, + ]); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Sending SIGUSR1 to AgentAPI"); + expect(result.stdout).toContain("Sending SIGTERM to AgentAPI"); + }); + + test("skips SIGUSR1 when no PID file available", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + await setupMocks(id, "normal", 204); + // No pidFilePath and no MODULE_DIR_NAME, so no PID file can be resolved. + const result = await runShutdownScript(id, "test-task", "", "false"); + + expect(result.exitCode).toBe(0); + // Should not send SIGUSR1 or SIGTERM (no PID to signal). + expect(result.stdout).not.toContain("Sending SIGUSR1"); + expect(result.stdout).not.toContain("Sending SIGTERM"); + expect(result.stdout).toContain("Shutdown complete"); + }); + + test("skips SIGUSR1 when state persistence disabled", async () => { + const { id } = await setup({ + moduleVariables: {}, + skipAgentAPIMock: true, + }); + const pidFile = "/tmp/agentapi-test.pid"; + await setupMocks(id, "normal", 204, pidFile); + // PID file exists but state persistence is disabled. + const result = await runShutdownScript(id, "test-task", pidFile, "false"); + + expect(result.exitCode).toBe(0); + // Should NOT send SIGUSR1 (persistence disabled). + expect(result.stdout).not.toContain("Sending SIGUSR1"); + // Should still send SIGTERM (graceful shutdown always happens). + expect(result.stdout).toContain("Sending SIGTERM to AgentAPI"); + }); + }); + + describe("boundary", async () => { + test("boundary-disabled-by-default", async () => { + const { id } = await setup(); + await execModuleScript(id); + await expectAgentAPIStarted(id); + // Config file should NOT exist when boundary is disabled + const configCheck = await execContainer(id, [ + "bash", + "-c", + "test -f /home/coder/.config/coder_boundary/config.yaml && echo exists || echo missing", + ]); + expect(configCheck.stdout.trim()).toBe("missing"); + // AGENTAPI_BOUNDARY_PREFIX should NOT be in the mock log + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + expect(mockLog).not.toContain("AGENTAPI_BOUNDARY_PREFIX:"); + }); + + test("boundary-enabled", async () => { + const { id } = await setup({ + moduleVariables: { + enable_boundary: "true", + boundary_config: `jail_type: landjail +proxy_port: 8087 +log_level: warn +allowlist: + - "domain=api.example.com"`, + }, + }); + // Add mock coder binary for boundary setup + await writeExecutable({ + containerId: id, + filePath: "/usr/bin/coder", + content: `#!/bin/bash +if [ "$1" = "boundary" ]; then + shift; shift; exec "$@" +fi +echo "mock coder"`, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + // Config should be written to the standard location + const config = await readFileContainer( + id, + "/home/coder/.config/coder_boundary/config.yaml", + ); + expect(config).toContain("jail_type: landjail"); + expect(config).toContain("proxy_port: 8087"); + expect(config).toContain("domain=api.example.com"); + // Config should have restrictive permissions (600) + const perms = await execContainer(id, [ + "bash", + "-c", + "stat -c '%a' /home/coder/.config/coder_boundary/config.yaml", + ]); + expect(perms.stdout.trim()).toBe("600"); + // AGENTAPI_BOUNDARY_PREFIX should be exported + const mockLog = await readFileContainer( + id, + "/home/coder/agentapi-mock.log", + ); + expect(mockLog).toContain("AGENTAPI_BOUNDARY_PREFIX:"); + // E2E: start script should have used the wrapper + const startLog = await readFileContainer( + id, + "/home/coder/test-agentapi-start.log", + ); + expect(startLog).toContain("Starting with boundary:"); + }); + + test("boundary-enabled-no-coder-binary", async () => { + const { id } = await setup({ + moduleVariables: { + enable_boundary: "true", + boundary_config: `jail_type: landjail +proxy_port: 8087 +log_level: warn`, + }, + }); + // Remove coder binary to simulate it not being available + await execContainer( + id, + [ + "bash", + "-c", + "rm -f /usr/bin/coder /usr/local/bin/coder 2>/dev/null; hash -r", + ], + ["--user", "root"], + ); + const resp = await execModuleScript(id); + // Script should fail because coder binary is required + expect(resp.exitCode).not.toBe(0); + const scriptLog = await readFileContainer(id, "/home/coder/script.log"); + expect(scriptLog).toContain("Boundary cannot be enabled"); + }); + + test("validate-boundary-requires-config", async () => { + expect( + setup({ + moduleVariables: { + enable_boundary: "true", + // boundary_config intentionally omitted + }, + }), + ).rejects.toThrow( + "boundary_config is required when enable_boundary is true.", + ); + }); }); }); diff --git a/registry/coder/modules/agentapi/main.tf b/registry/coder/modules/agentapi/main.tf index 6914be779..4dca7d2ef 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -164,6 +164,39 @@ variable "module_dir_name" { description = "Name of the subdirectory in the home directory for module files." } +variable "enable_boundary" { + type = bool + description = "Enable coder boundary for network filtering. Requires boundary_config to be set." + default = false +} + +variable "boundary_config" { + type = string + description = "Content of boundary config.yaml file. Required when enable_boundary is true. Must contain allowlist, jail_type, proxy_port, and log_level." + default = "" + validation { + condition = !var.enable_boundary || var.boundary_config != "" + error_message = "boundary_config is required when enable_boundary is true." + } +} + +variable "enable_state_persistence" { + type = bool + description = "Enable AgentAPI conversation state persistence across restarts." + default = false +} + +variable "state_file_path" { + type = string + description = "Path to the AgentAPI state file. Defaults to $HOME//agentapi-state.json." + default = "" +} + +variable "pid_file_path" { + type = string + description = "Path to the AgentAPI PID file. Defaults to $HOME//agentapi.pid." + default = "" +} locals { # we always trim the slash for consistency @@ -182,6 +215,7 @@ locals { agentapi_chat_base_path = var.agentapi_subdomain ? "" : "/@${data.coder_workspace_owner.me.name}/${data.coder_workspace.me.name}.${var.agent_id}/apps/${var.web_app_slug}/chat" main_script = file("${path.module}/scripts/main.sh") shutdown_script = file("${path.module}/scripts/agentapi-shutdown.sh") + lib_script = file("${path.module}/scripts/lib.sh") } resource "coder_script" "agentapi" { @@ -195,6 +229,7 @@ resource "coder_script" "agentapi" { echo -n '${base64encode(local.main_script)}' | base64 -d > /tmp/main.sh chmod +x /tmp/main.sh + echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh ARG_MODULE_DIR_NAME='${var.module_dir_name}' \ ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \ @@ -209,6 +244,11 @@ resource "coder_script" "agentapi" { ARG_AGENTAPI_CHAT_BASE_PATH='${local.agentapi_chat_base_path}' \ ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \ ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ + ARG_ENABLE_BOUNDARY='${var.enable_boundary}' \ + ARG_BOUNDARY_CONFIG="$(echo -n '${base64encode(var.boundary_config)}' | base64 -d)" \ + ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \ + ARG_STATE_FILE_PATH='${var.state_file_path}' \ + ARG_PID_FILE_PATH='${var.pid_file_path}' \ /tmp/main.sh EOT run_on_start = true @@ -225,10 +265,14 @@ resource "coder_script" "agentapi_shutdown" { echo -n '${base64encode(local.shutdown_script)}' | base64 -d > /tmp/agentapi-shutdown.sh chmod +x /tmp/agentapi-shutdown.sh + echo -n '${base64encode(local.lib_script)}' | base64 -d > /tmp/agentapi-lib.sh ARG_TASK_ID='${try(data.coder_task.me.id, "")}' \ ARG_TASK_LOG_SNAPSHOT='${var.task_log_snapshot}' \ ARG_AGENTAPI_PORT='${var.agentapi_port}' \ + ARG_ENABLE_STATE_PERSISTENCE='${var.enable_state_persistence}' \ + ARG_MODULE_DIR_NAME='${var.module_dir_name}' \ + ARG_PID_FILE_PATH='${var.pid_file_path}' \ /tmp/agentapi-shutdown.sh EOT } diff --git a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh index bbee76282..8de176e44 100644 --- a/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh +++ b/registry/coder/modules/agentapi/scripts/agentapi-shutdown.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # AgentAPI shutdown script. # -# Captures the last 10 messages from AgentAPI and posts them to Coder instance -# as a snapshot. This script is called during workspace shutdown to access -# conversation history for paused tasks. +# Performs a graceful shutdown of AgentAPI: sends SIGUSR1 to trigger state save, +# captures the last 10 messages as a log snapshot posted to the Coder instance, +# then sends SIGTERM for graceful termination. set -euo pipefail @@ -11,6 +11,13 @@ set -euo pipefail readonly TASK_ID="${ARG_TASK_ID:-}" readonly TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" readonly AGENTAPI_PORT="${ARG_AGENTAPI_PORT:-3284}" +readonly ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}" +readonly MODULE_DIR_NAME="${ARG_MODULE_DIR_NAME:-}" +readonly PID_FILE_PATH="${ARG_PID_FILE_PATH:-${MODULE_DIR_NAME:+$HOME/$MODULE_DIR_NAME/agentapi.pid}}" + +# Source shared utilities (written by the coder_script wrapper). +# shellcheck source=lib.sh +source /tmp/agentapi-lib.sh # Runtime environment variables. readonly CODER_AGENT_URL="${CODER_AGENT_URL:-}" @@ -20,7 +27,7 @@ readonly CODER_AGENT_TOKEN="${CODER_AGENT_TOKEN:-}" readonly MAX_PAYLOAD_SIZE=65536 # 64KB readonly MAX_MESSAGE_CONTENT=57344 # 56KB readonly MAX_MESSAGES=10 -readonly FETCH_TIMEOUT=5 +readonly FETCH_TIMEOUT=10 readonly POST_TIMEOUT=10 log() { @@ -138,44 +145,45 @@ post_task_log_snapshot() { capture_task_log_snapshot() { if [[ -z $TASK_ID ]]; then log "No task ID, skipping log snapshot" - exit 0 + return 0 fi if [[ -z $CODER_AGENT_URL ]]; then error "CODER_AGENT_URL not set, cannot capture log snapshot" - exit 1 + return 1 fi if [[ -z $CODER_AGENT_TOKEN ]]; then error "CODER_AGENT_TOKEN not set, cannot capture log snapshot" - exit 1 + return 1 fi if ! command -v jq > /dev/null 2>&1; then error "jq not found, cannot capture log snapshot" - exit 1 + return 1 fi if ! command -v curl > /dev/null 2>&1; then error "curl not found, cannot capture log snapshot" - exit 1 + return 1 fi + # Not local, must be visible to the EXIT trap after the function returns. tmpdir=$(mktemp -d) - trap 'rm -rf "$tmpdir"' EXIT + trap 'trap - EXIT; rm -rf "$tmpdir"' EXIT local payload_file="${tmpdir}/payload.json" if ! fetch_and_build_messages_payload "$payload_file"; then error "Cannot capture log snapshot without messages" - exit 1 + return 1 fi local message_count message_count=$(jq '.messages | length' < "$payload_file") if ((message_count == 0)); then log "No messages for log snapshot" - exit 0 + return 0 fi log "Retrieved $message_count messages for log snapshot" @@ -183,7 +191,7 @@ capture_task_log_snapshot() { # Ensure payload fits within size limit. if ! truncate_messages_payload_to_size "$payload_file" "$MAX_PAYLOAD_SIZE"; then error "Failed to truncate payload to size limit" - exit 1 + return 1 fi local final_size final_count @@ -193,19 +201,60 @@ capture_task_log_snapshot() { if ! post_task_log_snapshot "$payload_file" "$tmpdir"; then error "Log snapshot capture failed" - exit 1 + return 1 fi } main() { log "Shutting down AgentAPI" + local agentapi_pid= + if [[ -n $PID_FILE_PATH ]]; then + agentapi_pid=$(cat "$PID_FILE_PATH" 2> /dev/null || echo "") + fi + + # State persistence is only enabled when the binary supports it (>= v0.12.0). + # The default SIGUSR1 disposition on Linux is terminate, so sending it to an + # older binary would kill the process. + local state_persistence=0 + if [[ $ENABLE_STATE_PERSISTENCE == true ]] && version_at_least 0.12.0 "$(agentapi_version)"; then + state_persistence=1 + fi + + # Trigger state save via SIGUSR1 (saves without exiting). + if ((state_persistence)) && [[ -n $agentapi_pid ]] && kill -0 "$agentapi_pid" 2> /dev/null; then + log "Sending SIGUSR1 to AgentAPI (pid $agentapi_pid) to save state" + kill -USR1 "$agentapi_pid" || true + # Allow time for state save to complete before proceeding. + sleep 1 + fi + + # Capture log snapshot for task history. if [[ $TASK_LOG_SNAPSHOT == true ]]; then - capture_task_log_snapshot + # Subshell scopes the EXIT trap (tmpdir cleanup) inside + # capture_task_log_snapshot and preserves set -e, which + # || would otherwise disable for the function body. + (capture_task_log_snapshot) || log "Log snapshot capture failed, continuing shutdown" else log "Log snapshot disabled, skipping" fi + # Graceful termination. + if [[ -n $agentapi_pid ]] && kill -0 "$agentapi_pid" 2> /dev/null; then + log "Sending SIGTERM to AgentAPI (pid $agentapi_pid)" + kill -TERM "$agentapi_pid" 2> /dev/null || true + + # Wait for process to exit to guarantee a clean shutdown. + local elapsed=0 + while kill -0 "$agentapi_pid" 2> /dev/null; do + sleep 1 + ((elapsed++)) || true + if ((elapsed % 5 == 0)); then + log "Warning: AgentAPI (pid $agentapi_pid) still running after ${elapsed}s" + fi + done + fi + log "Shutdown complete" } diff --git a/registry/coder/modules/agentapi/scripts/lib.sh b/registry/coder/modules/agentapi/scripts/lib.sh new file mode 100644 index 000000000..20bdef479 --- /dev/null +++ b/registry/coder/modules/agentapi/scripts/lib.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# Shared utility functions for agentapi module scripts. + +# version_at_least checks if an actual version meets a minimum requirement. +# Non-semver strings (e.g. "latest", custom builds) always pass. +# Usage: version_at_least +# version_at_least v0.12.0 v0.10.0 # returns 1 (false) +# version_at_least v0.12.0 v0.12.0 # returns 0 (true) +# version_at_least v0.12.0 latest # returns 0 (true) +version_at_least() { + local min="${1#v}" + local actual="${2#v}" + + # Non-semver versions pass through (e.g. "latest", custom builds). + if ! [[ $actual =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]]; then + return 0 + fi + + local act_major="${BASH_REMATCH[1]}" + local act_minor="${BASH_REMATCH[2]}" + local act_patch="${BASH_REMATCH[3]}" + + [[ $min =~ ^([0-9]+)\.([0-9]+)\.([0-9]+)$ ]] || return 0 + + local min_major="${BASH_REMATCH[1]}" + local min_minor="${BASH_REMATCH[2]}" + local min_patch="${BASH_REMATCH[3]}" + + # Arithmetic expressions set exit status: 0 (true) if non-zero, 1 (false) if zero. + if ((act_major != min_major)); then + ((act_major > min_major)) + return + fi + if ((act_minor != min_minor)); then + ((act_minor > min_minor)) + return + fi + ((act_patch >= min_patch)) +} + +# agentapi_version returns the installed agentapi binary version (e.g. "0.11.8"). +# Returns empty string if the binary is missing or doesn't support --version. +agentapi_version() { + agentapi --version 2> /dev/null | awk '{print $NF}' +} diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 63e013eb9..a57efe5e2 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -16,8 +16,16 @@ AGENTAPI_PORT="$ARG_AGENTAPI_PORT" AGENTAPI_CHAT_BASE_PATH="${ARG_AGENTAPI_CHAT_BASE_PATH:-}" TASK_ID="${ARG_TASK_ID:-}" TASK_LOG_SNAPSHOT="${ARG_TASK_LOG_SNAPSHOT:-true}" +ENABLE_BOUNDARY="${ARG_ENABLE_BOUNDARY:-false}" +BOUNDARY_CONFIG="${ARG_BOUNDARY_CONFIG:-}" +ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}" +STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}" +PID_FILE_PATH="${ARG_PID_FILE_PATH:-}" set +o nounset +# shellcheck source=lib.sh +source /tmp/agentapi-lib.sh + command_exists() { command -v "$1" > /dev/null 2>&1 } @@ -103,8 +111,57 @@ export LC_ALL=en_US.UTF-8 cd "${WORKDIR}" +# Set up boundary if enabled +export AGENTAPI_BOUNDARY_PREFIX="" +if [ "${ENABLE_BOUNDARY}" = "true" ]; then + echo "Setting up coder boundary..." + # Copy the user-provided boundary config + mkdir -p ~/.config/coder_boundary + echo "Writing boundary config to ~/.config/coder_boundary/config.yaml" + echo -n "${BOUNDARY_CONFIG}" > ~/.config/coder_boundary/config.yaml + chmod 600 ~/.config/coder_boundary/config.yaml + # Copy coder binary to strip CAP_NET_ADMIN capabilities. + # This is necessary because boundary doesn't work with privileged binaries. + if command_exists coder; then + CODER_NO_CAPS="$module_path/coder-no-caps" + if cp "$(which coder)" "$CODER_NO_CAPS"; then + # Write a wrapper script to avoid word-splitting issues with exported strings. + BOUNDARY_WRAPPER_SCRIPT="$module_path/boundary-wrapper.sh" + cat > "${BOUNDARY_WRAPPER_SCRIPT}" << '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 + chmod +x "${BOUNDARY_WRAPPER_SCRIPT}" + export AGENTAPI_BOUNDARY_PREFIX="${BOUNDARY_WRAPPER_SCRIPT}" + echo "Boundary wrapper configured: ${AGENTAPI_BOUNDARY_PREFIX}" + else + echo "Error: Failed to copy coder binary to ${CODER_NO_CAPS}. Boundary cannot be enabled." >&2 + exit 1 + fi + else + echo "Error: ENABLE_BOUNDARY=true, but 'coder' command not found. Boundary cannot be enabled." >&2 + exit 1 + fi +fi + export AGENTAPI_CHAT_BASE_PATH="${AGENTAPI_CHAT_BASE_PATH:-}" # Disable host header check since AgentAPI is proxied by Coder (which does its own validation) export AGENTAPI_ALLOWED_HOSTS="*" + +export AGENTAPI_PID_FILE="${PID_FILE_PATH:-$module_path/agentapi.pid}" +# Only set state env vars when persistence is enabled and the binary supports +# it. State persistence requires agentapi >= v0.12.0. +if [ "${ENABLE_STATE_PERSISTENCE}" = "true" ]; then + actual_version=$(agentapi_version) + if version_at_least 0.12.0 "$actual_version"; then + export AGENTAPI_STATE_FILE="${STATE_FILE_PATH:-$module_path/agentapi-state.json}" + export AGENTAPI_SAVE_STATE="true" + export AGENTAPI_LOAD_STATE="true" + else + echo "Warning: State persistence requires agentapi >= v0.12.0 (current: ${actual_version:-unknown}), skipping." + fi +fi nohup "$module_path/scripts/agentapi-start.sh" true "${AGENTAPI_PORT}" &> "$module_path/agentapi-start.log" & "$module_path/scripts/agentapi-wait-for-start.sh" "${AGENTAPI_PORT}" diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js b/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js index c6b0fb7fe..c53a0757a 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock-shutdown.js @@ -3,8 +3,26 @@ // Usage: MESSAGES='[...]' node agentapi-mock-shutdown.js [port] const http = require("http"); +const fs = require("fs"); const port = process.argv[2] || 3284; +// Write PID file for shutdown script. +if (process.env.AGENTAPI_PID_FILE) { + const path = require("path"); + fs.mkdirSync(path.dirname(process.env.AGENTAPI_PID_FILE), { + recursive: true, + }); + fs.writeFileSync(process.env.AGENTAPI_PID_FILE, String(process.pid)); +} + +// Handle SIGUSR1 (state save signal from shutdown script). +process.on("SIGUSR1", () => { + fs.writeFileSync( + "/tmp/sigusr1-received", + `SIGUSR1 received at ${Date.now()}\n`, + ); +}); + // Parse messages from environment or use default let messages = []; if (process.env.MESSAGES) { diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock.js b/registry/coder/modules/agentapi/testdata/agentapi-mock.js index 72db716a3..e2e2d560d 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-mock.js +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock.js @@ -6,12 +6,50 @@ const args = process.argv.slice(2); const portIdx = args.findIndex((arg) => arg === "--port") + 1; const port = portIdx ? args[portIdx] : 3284; +if (args.includes("--version")) { + console.log("agentapi version 99.99.99"); + process.exit(0); +} + console.log(`starting server on port ${port}`); fs.writeFileSync( "/home/coder/agentapi-mock.log", `AGENTAPI_ALLOWED_HOSTS: ${process.env.AGENTAPI_ALLOWED_HOSTS}`, ); +// Log state persistence env vars. +for (const v of [ + "AGENTAPI_STATE_FILE", + "AGENTAPI_PID_FILE", + "AGENTAPI_SAVE_STATE", + "AGENTAPI_LOAD_STATE", +]) { + if (process.env[v]) { + fs.appendFileSync( + "/home/coder/agentapi-mock.log", + `\n${v}: ${process.env[v]}`, + ); + } +} +// Log boundary env vars. +for (const v of ["AGENTAPI_BOUNDARY_PREFIX"]) { + if (process.env[v]) { + fs.appendFileSync( + "/home/coder/agentapi-mock.log", + `\n${v}: ${process.env[v]}`, + ); + } +} + +// Write PID file for shutdown script. +if (process.env.AGENTAPI_PID_FILE) { + const path = require("path"); + fs.mkdirSync(path.dirname(process.env.AGENTAPI_PID_FILE), { + recursive: true, + }); + fs.writeFileSync(process.env.AGENTAPI_PID_FILE, String(process.pid)); +} + http .createServer(function (_request, response) { response.writeHead(200); diff --git a/registry/coder/modules/agentapi/testdata/agentapi-start.sh b/registry/coder/modules/agentapi/testdata/agentapi-start.sh index 259eb0c9f..417b64d09 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-start.sh +++ b/registry/coder/modules/agentapi/testdata/agentapi-start.sh @@ -17,6 +17,16 @@ if [ -n "$AGENTAPI_CHAT_BASE_PATH" ]; then export AGENTAPI_CHAT_BASE_PATH fi -agentapi server --port "$port" --term-width 67 --term-height 1190 -- \ - bash -c aiagent \ - > "$log_file_path" 2>&1 +# Use boundary wrapper if configured by agentapi module. +# AGENTAPI_BOUNDARY_PREFIX is set by the agentapi module's main.sh +# and points to a wrapper script that runs the command through coder boundary. +if [ -n "${AGENTAPI_BOUNDARY_PREFIX:-}" ]; then + echo "Starting with boundary: ${AGENTAPI_BOUNDARY_PREFIX}" >> /home/coder/test-agentapi-start.log + agentapi server --port "$port" --term-width 67 --term-height 1190 -- \ + "${AGENTAPI_BOUNDARY_PREFIX}" bash -c aiagent \ + > "$log_file_path" 2>&1 +else + agentapi server --port "$port" --term-width 67 --term-height 1190 -- \ + bash -c aiagent \ + > "$log_file_path" 2>&1 +fi diff --git a/registry/coder/modules/aibridge-proxy/README.md b/registry/coder/modules/aibridge-proxy/README.md new file mode 100644 index 000000000..412433658 --- /dev/null +++ b/registry/coder/modules/aibridge-proxy/README.md @@ -0,0 +1,89 @@ +--- +display_name: AI Bridge Proxy +description: Configure a workspace to route AI tool traffic through AI Bridge via AI Bridge Proxy. +icon: ../../../../.icons/coder.svg +verified: true +tags: [helper, aibridge] +--- + +# AI Bridge Proxy + +This module configures a Coder workspace to use [AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy). +It downloads the proxy's CA certificate from the Coder deployment and provides Terraform outputs (`proxy_auth_url` and `cert_path`) that tool-specific modules can use to route their traffic through the proxy. + +```tf +module "aibridge-proxy" { + source = "registry.coder.com/coder/aibridge-proxy/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + proxy_url = "https://aiproxy.example.com" +} +``` + +> [!NOTE] +> AI Bridge Proxy is a Premium Coder feature that requires [AI Governance Add-On](https://coder.com/docs/ai-coder/ai-governance). +> See the [AI Bridge Proxy setup guide](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup) for details on configuring the proxy on your Coder deployment. + +## How it works + +[AI Bridge Proxy](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy) is an HTTP proxy that intercepts traffic to AI providers and forwards it through [AI Bridge](https://coder.com/docs/ai-coder/ai-bridge), enabling centralized LLM management, governance, and cost tracking. +Any process with the proxy environment variables set will route **all** its traffic through the proxy. + +This module **does not** set proxy environment variables globally on the workspace. +Instead, it provides Terraform outputs (`proxy_auth_url` and `cert_path`) that tool-specific modules consume to configure proxy routing. +See the [Copilot module](https://registry.coder.com/modules/coder-labs/copilot) for a working integration example. + +It is recommended that tool modules scope the proxy environment variables to their own process rather than setting them globally on the workspace, to avoid routing unnecessary traffic through the proxy. + +> [!WARNING] +> If the setup script fails (e.g. the proxy is unreachable), the workspace will still start but the agent will report a startup script error. +> Tools that depend on the proxy will not work until the issue is resolved. Check the workspace build logs for details. + +## Startup Coordination + +When used with tool-specific modules (e.g. [Copilot](https://registry.coder.com/modules/coder-labs/copilot)), +the setup script signals completion via [`coder exp sync`](https://coder.com/docs/admin/templates/startup-coordination) so dependent modules can wait for the `aibridge-proxy` module to complete before starting. + +Dependent modules are unblocked once the setup script finishes, regardless of success or failure. +If the setup fails, dependent modules are expected to detect the failure and handle the error accordingly. + +To enable startup coordination, set `CODER_AGENT_SOCKET_SERVER_ENABLED=true` in the workspace container environment: + +```hcl +env = [ + "CODER_AGENT_TOKEN=${coder_agent.main.token}", + "CODER_AGENT_SOCKET_SERVER_ENABLED=true", +] +``` + +> [!NOTE] +> [Startup coordination](https://coder.com/docs/admin/templates/startup-coordination) requires Coder >= v2.30. +> Without it, the sync calls are skipped gracefully but dependent modules may fail to start if the `aibridge-proxy` setup has not completed in time. + +## Examples + +### Custom certificate path + +```tf +module "aibridge-proxy" { + source = "registry.coder.com/coder/aibridge-proxy/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + proxy_url = "https://aiproxy.example.com" + cert_path = "/home/coder/.certs/aibridge-proxy-ca.pem" +} +``` + +### Proxy with custom port + +For deployments where the proxy is accessed directly on a configured port. +See [security considerations](https://coder.com/docs/ai-coder/ai-bridge/ai-bridge-proxy/setup#security-considerations) for network access guidelines. + +```tf +module "aibridge-proxy" { + source = "registry.coder.com/coder/aibridge-proxy/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + proxy_url = "http://internal-proxy:8888" +} +``` diff --git a/registry/coder/modules/aibridge-proxy/main.test.ts b/registry/coder/modules/aibridge-proxy/main.test.ts new file mode 100644 index 000000000..29274d3d6 --- /dev/null +++ b/registry/coder/modules/aibridge-proxy/main.test.ts @@ -0,0 +1,254 @@ +import { serve } from "bun"; +import { + afterEach, + beforeAll, + describe, + expect, + it, + setDefaultTimeout, +} from "bun:test"; +import { + execContainer, + findResourceInstance, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, +} from "~test"; + +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); + } + } +}); + +const FAKE_CERT = + "-----BEGIN CERTIFICATE-----\nMIIBfakecert\n-----END CERTIFICATE-----\n"; + +// Runs terraform apply to render the setup script, then starts a Docker +// container where we can execute it against a mock server. +const setupContainer = async (vars: Record = {}) => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + proxy_url: "https://aiproxy.example.com", + ...vars, + }); + const instance = findResourceInstance(state, "coder_script"); + const id = await runContainer("lorello/alpine-bash"); + + registerCleanup(async () => { + await removeContainer(id); + }); + + return { id, instance }; +}; + +// Starts a mock HTTP server that simulates the Coder API certificate endpoint. +// Returns the server and its base URL. +const setupServer = (handler: (req: Request) => Response) => { + const server = serve({ + fetch: handler, + port: 0, + }); + registerCleanup(async () => { + server.stop(); + }); + return { + server, + // Base URL without trailing slash + url: server.url.toString().slice(0, -1), + }; +}; + +setDefaultTimeout(30 * 1000); + +describe("aibridge-proxy", () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + // Verify that agent_id and proxy_url are required. + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + proxy_url: "https://aiproxy.example.com", + }); + + it("downloads the CA certificate successfully", async () => { + let receivedToken = ""; + const { url } = setupServer((req) => { + const reqUrl = new URL(req.url); + if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") { + receivedToken = req.headers.get("Coder-Session-Token") || ""; + return new Response(FAKE_CERT, { + status: 200, + headers: { "Content-Type": "application/x-pem-file" }, + }); + } + return new Response("not found", { status: 404 }); + }); + + const { id, instance } = await setupContainer(); + + // Override ACCESS_URL and SESSION_TOKEN at runtime to point at the mock server. + const exec = await execContainer(id, [ + "env", + `ACCESS_URL=${url}`, + "SESSION_TOKEN=test-session-token-123", + "bash", + "-c", + instance.script, + ]); + expect(exec.exitCode).toBe(0); + expect(exec.stdout).toContain( + "AI Bridge Proxy CA certificate saved to /tmp/aibridge-proxy/ca-cert.pem", + ); + + // Verify the cert was written to the default path. + const certContent = await execContainer(id, [ + "cat", + "/tmp/aibridge-proxy/ca-cert.pem", + ]); + expect(certContent.stdout).toContain("BEGIN CERTIFICATE"); + + // Verify the session token was sent in the request header. + expect(receivedToken).toBe("test-session-token-123"); + }); + + it("fails when the server is unreachable", async () => { + const { id, instance } = await setupContainer(); + + // Port 9999 has nothing listening, so curl will fail to connect. + const exec = await execContainer(id, [ + "env", + "ACCESS_URL=http://localhost:9999", + "SESSION_TOKEN=mock-token", + "bash", + "-c", + instance.script, + ]); + expect(exec.exitCode).not.toBe(0); + expect(exec.stdout).toContain( + "AI Bridge Proxy setup failed: could not connect to", + ); + }); + + it("fails when the server returns a non-200 status", async () => { + const { url } = setupServer(() => { + return new Response("not found", { status: 404 }); + }); + + const { id, instance } = await setupContainer(); + + const exec = await execContainer(id, [ + "env", + `ACCESS_URL=${url}`, + "SESSION_TOKEN=mock-token", + "bash", + "-c", + instance.script, + ]); + expect(exec.exitCode).not.toBe(0); + expect(exec.stdout).toContain( + "AI Bridge Proxy setup failed: unexpected response", + ); + }); + + it("fails when the server returns an empty response", async () => { + const { url } = setupServer((req) => { + const reqUrl = new URL(req.url); + if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") { + return new Response("", { status: 200 }); + } + return new Response("not found", { status: 404 }); + }); + + const { id, instance } = await setupContainer(); + + const exec = await execContainer(id, [ + "env", + `ACCESS_URL=${url}`, + "SESSION_TOKEN=mock-token", + "bash", + "-c", + instance.script, + ]); + expect(exec.exitCode).not.toBe(0); + expect(exec.stdout).toContain( + "AI Bridge Proxy setup failed: downloaded certificate is empty.", + ); + }); + + it("saves the certificate to a custom path", async () => { + const { url } = setupServer((req) => { + const reqUrl = new URL(req.url); + if (reqUrl.pathname === "/api/v2/aibridge/proxy/ca-cert.pem") { + return new Response(FAKE_CERT, { + status: 200, + headers: { "Content-Type": "application/x-pem-file" }, + }); + } + return new Response("not found", { status: 404 }); + }); + + // Pass a custom cert_path to terraform apply so the script uses it. + const { id, instance } = await setupContainer({ + cert_path: "/tmp/custom/certs/proxy-ca.pem", + }); + + const exec = await execContainer(id, [ + "env", + `ACCESS_URL=${url}`, + "SESSION_TOKEN=mock-token", + "bash", + "-c", + instance.script, + ]); + expect(exec.exitCode).toBe(0); + expect(exec.stdout).toContain( + "AI Bridge Proxy CA certificate saved to /tmp/custom/certs/proxy-ca.pem", + ); + + const certContent = await execContainer(id, [ + "cat", + "/tmp/custom/certs/proxy-ca.pem", + ]); + expect(certContent.stdout).toContain("BEGIN CERTIFICATE"); + }); + + it("does not create global proxy env vars via coder_env", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + proxy_url: "https://aiproxy.example.com", + }); + + // Proxy env vars should NOT be set globally via coder_env. + // They are intended to be scoped to specific tool processes. + const proxyEnvVarNames = [ + "HTTP_PROXY", + "HTTPS_PROXY", + "NODE_EXTRA_CA_CERTS", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + ]; + const proxyEnvVars = state.resources.filter( + (r) => + r.type === "coder_env" && + r.instances.some((i) => + proxyEnvVarNames.includes(i.attributes.name as string), + ), + ); + expect(proxyEnvVars.length).toBe(0); + }); +}); diff --git a/registry/coder/modules/aibridge-proxy/main.tf b/registry/coder/modules/aibridge-proxy/main.tf new file mode 100644 index 000000000..62200a31e --- /dev/null +++ b/registry/coder/modules/aibridge-proxy/main.tf @@ -0,0 +1,81 @@ +terraform { + required_version = ">= 1.9" + + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.12" + } + } +} + +variable "agent_id" { + type = string + description = "The ID of a Coder agent." +} + +variable "proxy_url" { + type = string + description = "The full URL of the AI Bridge Proxy. Include the port if not using standard ports (e.g. https://aiproxy.example.com or http://internal-proxy:8888)." + + validation { + condition = can(regex("^https?://", var.proxy_url)) + error_message = "proxy_url must start with http:// or https://." + } +} + +variable "cert_path" { + type = string + description = "Absolute path where the AI Bridge Proxy CA certificate will be saved." + default = "/tmp/aibridge-proxy/ca-cert.pem" + + validation { + condition = startswith(var.cert_path, "/") + error_message = "cert_path must be an absolute path." + } +} + +data "coder_workspace" "me" {} + +data "coder_workspace_owner" "me" {} + +locals { + # Build the proxy URL with Coder authentication embedded. + # AI Bridge Proxy expects the Coder session token as the password + # in basic auth: http://coder:@host:port + proxy_auth_url = replace( + var.proxy_url, + "://", + "://coder:${data.coder_workspace_owner.me.session_token}@" + ) +} + +# These outputs are intended to be consumed by tool-specific modules, +# to set proxy environment variables scoped to their process, rather than globally. +output "proxy_auth_url" { + description = "The AI Bridge Proxy URL with Coder authentication embedded (http://coder:@host:port)." + value = local.proxy_auth_url + sensitive = true +} + +output "cert_path" { + description = "Path to the downloaded AI Bridge Proxy CA certificate." + value = var.cert_path +} + +# Downloads the CA certificate from the Coder deployment. +# This runs on workspace start but does not block login, if the script +# fails, the workspace remains usable and the error is visible in the build logs. +# Tools that depend on the proxy will fail until the certificate is available. +resource "coder_script" "aibridge_proxy_setup" { + agent_id = var.agent_id + display_name = "AI Bridge Proxy Setup" + icon = "/icon/coder.svg" + run_on_start = true + start_blocks_login = false + script = templatefile("${path.module}/scripts/setup.sh", { + CERT_PATH = var.cert_path, + ACCESS_URL = data.coder_workspace.me.access_url, + SESSION_TOKEN = data.coder_workspace_owner.me.session_token, + }) +} diff --git a/registry/coder/modules/aibridge-proxy/main.tftest.hcl b/registry/coder/modules/aibridge-proxy/main.tftest.hcl new file mode 100644 index 000000000..08e329a53 --- /dev/null +++ b/registry/coder/modules/aibridge-proxy/main.tftest.hcl @@ -0,0 +1,210 @@ +run "test_aibridge_proxy_basic" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + } + + assert { + condition = var.agent_id == "test-agent-id" + error_message = "Agent ID should match the input variable" + } + + assert { + condition = var.proxy_url == "https://aiproxy.example.com" + error_message = "Proxy URL should match the input variable" + } + + assert { + condition = var.cert_path == "/tmp/aibridge-proxy/ca-cert.pem" + error_message = "cert_path should default to /tmp/aibridge-proxy/ca-cert.pem" + } +} + +run "test_aibridge_proxy_empty_url_validation" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "" + } + + expect_failures = [ + var.proxy_url, + ] +} + +run "test_aibridge_proxy_invalid_url_validation" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "aiproxy.example.com" + } + + expect_failures = [ + var.proxy_url, + ] +} + +run "test_aibridge_proxy_url_formats" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + } + + assert { + condition = can(regex("^https?://", var.proxy_url)) + error_message = "Proxy URL should be a valid URL with scheme" + } +} + +run "test_aibridge_proxy_https_with_port" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com:8443" + } + + assert { + condition = can(regex("^https?://", var.proxy_url)) + error_message = "Proxy URL should support HTTPS with custom port" + } +} + +run "test_aibridge_proxy_http_with_port" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "http://internal-proxy:8888" + } + + assert { + condition = can(regex("^https?://", var.proxy_url)) + error_message = "Proxy URL should support HTTP with custom port" + } +} + +run "test_aibridge_proxy_empty_cert_path_validation" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + cert_path = "" + } + + expect_failures = [ + var.cert_path, + ] +} + +run "test_aibridge_proxy_relative_cert_path_validation" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + cert_path = "relative/path/ca-cert.pem" + } + + expect_failures = [ + var.cert_path, + ] +} + +run "test_aibridge_proxy_custom_cert_path" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + cert_path = "/home/coder/.certs/ca-cert.pem" + } + + assert { + condition = var.cert_path == "/home/coder/.certs/ca-cert.pem" + error_message = "cert_path should match the input variable" + } +} + +run "test_aibridge_proxy_script" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + } + + assert { + condition = coder_script.aibridge_proxy_setup.run_on_start == true + error_message = "Script should run on start" + } + + assert { + condition = coder_script.aibridge_proxy_setup.start_blocks_login == false + error_message = "Script should not block login" + } + + assert { + condition = coder_script.aibridge_proxy_setup.display_name == "AI Bridge Proxy Setup" + error_message = "Script display name should be 'AI Bridge Proxy Setup'" + } +} + +run "test_aibridge_proxy_auth_url_https" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "https://aiproxy.example.com" + } + + override_data { + target = data.coder_workspace_owner.me + values = { + session_token = "mock-session-token" + } + } + + assert { + condition = output.proxy_auth_url == "https://coder:mock-session-token@aiproxy.example.com" + error_message = "proxy_auth_url should contain the mocked session token" + } + + assert { + condition = output.cert_path == "/tmp/aibridge-proxy/ca-cert.pem" + error_message = "cert_path output should match the default" + } +} + +run "test_aibridge_proxy_auth_url_http_with_port" { + command = plan + + variables { + agent_id = "test-agent-id" + proxy_url = "http://internal-proxy:8888" + } + + override_data { + target = data.coder_workspace_owner.me + values = { + session_token = "mock-session-token" + } + } + + assert { + condition = output.proxy_auth_url == "http://coder:mock-session-token@internal-proxy:8888" + error_message = "proxy_auth_url should preserve the port" + } + + assert { + condition = output.cert_path == "/tmp/aibridge-proxy/ca-cert.pem" + error_message = "cert_path output should match the default" + } +} diff --git a/registry/coder/modules/aibridge-proxy/scripts/setup.sh b/registry/coder/modules/aibridge-proxy/scripts/setup.sh new file mode 100644 index 000000000..c63b60e34 --- /dev/null +++ b/registry/coder/modules/aibridge-proxy/scripts/setup.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash + +if [ -z "$CERT_PATH" ]; then + CERT_PATH="${CERT_PATH}" +fi + +if [ -z "$ACCESS_URL" ]; then + ACCESS_URL="${ACCESS_URL}" +fi + +if [ -z "$SESSION_TOKEN" ]; then + SESSION_TOKEN="${SESSION_TOKEN}" +fi + +set -euo pipefail + +# Signal startup coordination. +# The trap ensures 'complete' is always called (even on failure) so dependent +# scripts unblock promptly and can check for the certificate themselves. +if command -v coder > /dev/null 2>&1; then + coder exp sync start "aibridge-proxy-setup" > /dev/null 2>&1 || true + trap 'coder exp sync complete "aibridge-proxy-setup" > /dev/null 2>&1 || true' EXIT +fi + +if [ -z "$ACCESS_URL" ]; then + echo "Error: Coder access URL is not set." + exit 1 +fi + +if [ -z "$SESSION_TOKEN" ]; then + echo "Error: Coder session token is not set." + exit 1 +fi + +if ! command -v curl > /dev/null; then + echo "Error: curl is not installed." + exit 1 +fi + +echo "--------------------------------" +echo "AI Bridge Proxy Setup" +printf "Certificate path: %s\n" "$CERT_PATH" +printf "Access URL: %s\n" "$ACCESS_URL" +echo "--------------------------------" + +CERT_DIR=$(dirname "$CERT_PATH") +mkdir -p "$CERT_DIR" + +CERT_URL="$ACCESS_URL/api/v2/aibridge/proxy/ca-cert.pem" +echo "Downloading AI Bridge Proxy CA certificate from $CERT_URL..." + +# Download the certificate with a 5s connection timeout and 10s total timeout +# to avoid the script hanging indefinitely. +if ! HTTP_STATUS=$(curl -s -o "$CERT_PATH" -w "%%{http_code}" \ + --connect-timeout 5 \ + --max-time 10 \ + -H "Coder-Session-Token: $SESSION_TOKEN" \ + "$CERT_URL"); then + echo "❌ AI Bridge Proxy setup failed: could not connect to $CERT_URL." + echo "Ensure AI Bridge Proxy is enabled and reachable from the workspace." + rm -f "$CERT_PATH" + exit 1 +fi + +if [ "$HTTP_STATUS" -ne 200 ]; then + echo "❌ AI Bridge Proxy setup failed: unexpected response (HTTP $HTTP_STATUS)." + echo "Ensure AI Bridge Proxy is enabled and reachable from the workspace." + rm -f "$CERT_PATH" + exit 1 +fi + +if [ ! -s "$CERT_PATH" ]; then + echo "❌ AI Bridge Proxy setup failed: downloaded certificate is empty." + rm -f "$CERT_PATH" + exit 1 +fi + +echo "AI Bridge Proxy CA certificate saved to $CERT_PATH" +echo "✅ AI Bridge Proxy setup complete." diff --git a/registry/coder/modules/claude-code/README.md b/registry/coder/modules/claude-code/README.md index 340eb175d..3d875046f 100644 --- a/registry/coder/modules/claude-code/README.md +++ b/registry/coder/modules/claude-code/README.md @@ -13,7 +13,7 @@ Run the [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_api_key = "xxxx-xxxxx-xxxx" @@ -36,6 +36,19 @@ module "claude-code" { By default, Claude Code automatically resumes existing conversations when your workspace restarts. Sessions are tracked per workspace directory, so conversations continue where you left off. If no session exists (first start), your `ai_prompt` will run normally. To disable this behavior and always start fresh, set `continue = false` +## State Persistence + +AgentAPI can save and restore its conversation state to disk across workspace restarts. This complements `continue` (which resumes the Claude 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: + +```tf +module "claude-code" { + # ... other config + enable_state_persistence = false +} +``` + ## Examples ### Usage with Agent Boundaries @@ -47,7 +60,7 @@ By default, when `enable_boundary = true`, the module uses `coder boundary` subc ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_boundary = true @@ -68,7 +81,7 @@ For tasks integration with AI Bridge, add `enable_aibridge = true` to the [Usage ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" enable_aibridge = true @@ -97,7 +110,7 @@ data "coder_task" "me" {} module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" ai_prompt = data.coder_task.me.prompt @@ -120,7 +133,7 @@ This example shows additional configuration options for version pinning, custom ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" @@ -176,7 +189,7 @@ Run and configure Claude Code as a standalone CLI in your workspace. ```tf module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" install_claude_code = true @@ -198,7 +211,7 @@ variable "claude_code_oauth_token" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" claude_code_oauth_token = var.claude_code_oauth_token @@ -271,7 +284,7 @@ resource "coder_env" "bedrock_api_key" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" @@ -328,7 +341,7 @@ resource "coder_env" "google_application_credentials" { module "claude-code" { source = "registry.coder.com/coder/claude-code/coder" - version = "4.7.5" + version = "4.8.0" agent_id = coder_agent.main.id workdir = "/home/coder/project" model = "claude-sonnet-4@20250514" diff --git a/registry/coder/modules/claude-code/main.tf b/registry/coder/modules/claude-code/main.tf index 07e3eb5a4..337ebd201 100644 --- a/registry/coder/modules/claude-code/main.tf +++ b/registry/coder/modules/claude-code/main.tf @@ -67,7 +67,7 @@ variable "cli_app_display_name" { variable "pre_install_script" { type = string - description = "Custom script to run before installing Claude Code." + description = "Custom script to run before installing Claude Code. Can be used for dependency ordering between modules (e.g., waiting for git-clone to complete before Claude Code initialization)." default = null } @@ -261,6 +261,12 @@ variable "enable_aibridge" { } } +variable "enable_state_persistence" { + type = bool + description = "Enable AgentAPI conversation state persistence across restarts." + default = true +} + resource "coder_env" "claude_code_md_path" { count = var.claude_md_path == "" ? 0 : 1 agent_id = var.agent_id @@ -356,25 +362,26 @@ locals { module "agentapi" { source = "registry.coder.com/coder/agentapi/coder" - version = "2.0.0" - - agent_id = var.agent_id - 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 - folder = local.workdir - 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 - agentapi_subdomain = var.subdomain - module_dir_name = local.module_dir_name - install_agentapi = var.install_agentapi - agentapi_version = var.agentapi_version - pre_install_script = var.pre_install_script - post_install_script = var.post_install_script - start_script = <<-EOT + version = "2.2.0" + + agent_id = var.agent_id + 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 + folder = local.workdir + 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 + agentapi_subdomain = var.subdomain + module_dir_name = local.module_dir_name + install_agentapi = var.install_agentapi + 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 + start_script = <<-EOT #!/bin/bash set -o errexit set -o pipefail diff --git a/registry/coder/modules/claude-code/main.tftest.hcl b/registry/coder/modules/claude-code/main.tftest.hcl index e273d3211..3d11989bc 100644 --- a/registry/coder/modules/claude-code/main.tftest.hcl +++ b/registry/coder/modules/claude-code/main.tftest.hcl @@ -387,6 +387,36 @@ run "test_aibridge_disabled_with_api_key" { } } +run "test_enable_state_persistence_default" { + command = plan + + variables { + agent_id = "test-agent" + workdir = "/home/coder" + } + + 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" + enable_state_persistence = false + } + + assert { + condition = var.enable_state_persistence == false + error_message = "enable_state_persistence should be false when explicitly disabled" + } +} + + run "test_no_api_key_no_env" { command = plan diff --git a/registry/coder/modules/devcontainers-cli/README.md b/registry/coder/modules/devcontainers-cli/README.md index bb5ec6dec..771be25da 100644 --- a/registry/coder/modules/devcontainers-cli/README.md +++ b/registry/coder/modules/devcontainers-cli/README.md @@ -14,8 +14,9 @@ The devcontainers-cli module provides an easy way to install [`@devcontainers/cl ```tf module "devcontainers-cli" { - source = "registry.coder.com/coder/devcontainers-cli/coder" - version = "1.0.34" - agent_id = coder_agent.example.id + source = "registry.coder.com/coder/devcontainers-cli/coder" + version = "1.1.0" + agent_id = coder_agent.example.id + start_blocks_login = false } ``` diff --git a/registry/coder/modules/devcontainers-cli/main.tf b/registry/coder/modules/devcontainers-cli/main.tf index a2aee348b..16fa35fe9 100644 --- a/registry/coder/modules/devcontainers-cli/main.tf +++ b/registry/coder/modules/devcontainers-cli/main.tf @@ -14,10 +14,17 @@ variable "agent_id" { description = "The ID of a Coder agent." } +variable "start_blocks_login" { + type = bool + default = false + description = "Boolean, This option determines whether users can log in immediately or must wait for the workspace to finish running this script upon startup." +} + resource "coder_script" "devcontainers-cli" { - agent_id = var.agent_id - display_name = "devcontainers-cli" - icon = "/icon/devcontainers.svg" - script = templatefile("${path.module}/run.sh", {}) - run_on_start = true + agent_id = var.agent_id + display_name = "devcontainers-cli" + icon = "/icon/devcontainers.svg" + script = templatefile("${path.module}/run.sh", {}) + run_on_start = true + start_blocks_login = var.start_blocks_login } diff --git a/registry/coder/modules/vscode-web/README.md b/registry/coder/modules/vscode-web/README.md index 43b1eb9d0..35cad1adb 100644 --- a/registry/coder/modules/vscode-web/README.md +++ b/registry/coder/modules/vscode-web/README.md @@ -14,7 +14,7 @@ Automatically install [Visual Studio Code Server](https://code.visualstudio.com/ module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "1.5.0" agent_id = coder_agent.example.id accept_license = true } @@ -30,7 +30,7 @@ module "vscode-web" { module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "1.5.0" agent_id = coder_agent.example.id install_prefix = "/home/coder/.vscode-web" folder = "/home/coder" @@ -44,22 +44,22 @@ module "vscode-web" { module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "1.5.0" agent_id = coder_agent.example.id extensions = ["github.copilot", "ms-python.python", "ms-toolsai.jupyter"] accept_license = true } ``` -### Pre-configure Settings +### Pre-configure Machine Settings -Configure VS Code's [settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file) file: +Configure VS Code's [Machine settings.json](https://code.visualstudio.com/docs/getstarted/settings#_settings-json-file). These settings are merged with any existing machine settings on startup: ```tf module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "1.5.0" agent_id = coder_agent.example.id extensions = ["dracula-theme.theme-dracula"] settings = { @@ -69,6 +69,9 @@ module "vscode-web" { } ``` +> [!WARNING] +> Merging settings requires `jq` or `python3`. If neither is available, existing machine settings will be preserved. User settings configured through the VS Code UI are stored in browser local storage and will not persist across different browsers or devices. + ### Pin a specific VS Code Web version By default, this module installs the latest. To pin a specific version, retrieve the commit ID from the [VS Code Update API](https://update.code.visualstudio.com/api/commits/stable/server-linux-x64-web) and verify its corresponding release on the [VS Code GitHub Releases](https://github.com/microsoft/vscode/releases). @@ -77,7 +80,7 @@ By default, this module installs the latest. To pin a specific version, retrieve module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "1.5.0" agent_id = coder_agent.example.id commit_id = "e54c774e0add60467559eb0d1e229c6452cf8447" accept_license = true @@ -93,7 +96,7 @@ Note: Either `workspace` or `folder` can be used, but not both simultaneously. T module "vscode-web" { count = data.coder_workspace.me.start_count source = "registry.coder.com/coder/vscode-web/coder" - version = "1.4.3" + version = "1.5.0" agent_id = coder_agent.example.id workspace = "/home/coder/coder.code-workspace" } diff --git a/registry/coder/modules/vscode-web/main.test.ts b/registry/coder/modules/vscode-web/main.test.ts index 860fc176c..96c787c86 100644 --- a/registry/coder/modules/vscode-web/main.test.ts +++ b/registry/coder/modules/vscode-web/main.test.ts @@ -1,42 +1,298 @@ -import { describe, expect, it } from "bun:test"; -import { runTerraformApply, runTerraformInit } from "~test"; +import { + describe, + expect, + it, + beforeAll, + afterEach, + setDefaultTimeout, +} from "bun:test"; +import { + runTerraformApply, + runTerraformInit, + runContainer, + execContainer, + removeContainer, + findResourceInstance, +} from "~test"; + +// Set timeout to 2 minutes for tests that install packages +setDefaultTimeout(2 * 60 * 1000); + +let cleanupContainers: string[] = []; + +afterEach(async () => { + for (const id of cleanupContainers) { + try { + await removeContainer(id); + } catch { + // Ignore cleanup errors + } + } + cleanupContainers = []; +}); describe("vscode-web", async () => { - await runTerraformInit(import.meta.dir); + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); - it("accept_license should be set to true", () => { - const t = async () => { + it("accept_license should be set to true", async () => { + try { await runTerraformApply(import.meta.dir, { agent_id: "foo", - accept_license: "false", + accept_license: false, }); - }; - expect(t).toThrow("Invalid value for variable"); + throw new Error("Expected terraform apply to fail"); + } catch (ex) { + expect((ex as Error).message).toContain("Invalid value for variable"); + } }); - it("use_cached and offline can not be used together", () => { - const t = async () => { + it("use_cached and offline can not be used together", async () => { + try { await runTerraformApply(import.meta.dir, { agent_id: "foo", - accept_license: "true", - use_cached: "true", - offline: "true", + accept_license: true, + use_cached: true, + offline: true, }); - }; - expect(t).toThrow("Offline and Use Cached can not be used together"); + throw new Error("Expected terraform apply to fail"); + } catch (ex) { + expect((ex as Error).message).toContain( + "Offline and Use Cached can not be used together", + ); + } }); - it("offline and extensions can not be used together", () => { - const t = async () => { + it("offline and extensions can not be used together", async () => { + try { await runTerraformApply(import.meta.dir, { agent_id: "foo", - accept_license: "true", - offline: "true", - extensions: '["1", "2"]', + accept_license: true, + offline: true, + extensions: '["ms-python.python"]', }); - }; - expect(t).toThrow("Offline mode does not allow extensions to be installed"); + throw new Error("Expected terraform apply to fail"); + } catch (ex) { + expect((ex as Error).message).toContain( + "Offline mode does not allow extensions to be installed", + ); + } + }); + + it("creates settings file with correct content", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + use_cached: true, + settings: '{"editor.fontSize": 14}', + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Create a mock code-server CLI that the script expects + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF' +#!/bin/bash +echo "Mock code-server running" +exit 0 +MOCKEOF +chmod +x /tmp/vscode-web/bin/code-server`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + const scriptResult = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + expect(scriptResult.exitCode).toBe(0); + + // Check that settings file was created + const settingsResult = await execContainer(containerId, [ + "cat", + "/root/.vscode-server/data/Machine/settings.json", + ]); + + expect(settingsResult.exitCode).toBe(0); + expect(settingsResult.stdout).toContain("editor.fontSize"); + expect(settingsResult.stdout).toContain("14"); + }); + + it("merges settings with existing settings file", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + use_cached: true, + settings: '{"new.setting": "new_value"}', + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Install jq and create mock code-server CLI + await execContainer(containerId, ["apt-get", "update", "-qq"]); + await execContainer(containerId, ["apt-get", "install", "-y", "-qq", "jq"]); + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF' +#!/bin/bash +echo "Mock code-server running" +exit 0 +MOCKEOF +chmod +x /tmp/vscode-web/bin/code-server`, + ]); + + // Pre-create an existing settings file + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + const scriptResult = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + expect(scriptResult.exitCode).toBe(0); + + // Check that settings were merged (both existing and new should be present) + const settingsResult = await execContainer(containerId, [ + "cat", + "/root/.vscode-server/data/Machine/settings.json", + ]); + + expect(settingsResult.exitCode).toBe(0); + // Should contain both existing and new settings + expect(settingsResult.stdout).toContain("existing.setting"); + expect(settingsResult.stdout).toContain("existing_value"); + expect(settingsResult.stdout).toContain("new.setting"); + expect(settingsResult.stdout).toContain("new_value"); + }); + + it("merges settings using python3 fallback when jq unavailable", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + use_cached: true, + settings: '{"new.setting": "new_value"}', + }); + + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Install python3 (ubuntu:22.04 doesn't have it by default) + await execContainer(containerId, ["apt-get", "update", "-qq"]); + await execContainer(containerId, [ + "apt-get", + "install", + "-y", + "-qq", + "python3", + ]); + + // Create mock code-server CLI (no jq installed) + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF' +#!/bin/bash +echo "Mock code-server running" +exit 0 +MOCKEOF +chmod +x /tmp/vscode-web/bin/code-server`, + ]); + + // Pre-create an existing settings file + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + const scriptResult = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + expect(scriptResult.exitCode).toBe(0); + + // Check that settings were merged using python3 fallback + const settingsResult = await execContainer(containerId, [ + "cat", + "/root/.vscode-server/data/Machine/settings.json", + ]); + + expect(settingsResult.exitCode).toBe(0); + // Should contain both existing and new settings + expect(settingsResult.stdout).toContain("existing.setting"); + expect(settingsResult.stdout).toContain("existing_value"); + expect(settingsResult.stdout).toContain("new.setting"); + expect(settingsResult.stdout).toContain("new_value"); }); - // More tests depend on shebang refactors + it("preserves existing settings when neither jq nor python3 available", async () => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + accept_license: true, + use_cached: true, + settings: '{"new.setting": "new_value"}', + }); + + // Use ubuntu without installing jq or python3 (neither available by default) + const containerId = await runContainer("ubuntu:22.04"); + cleanupContainers.push(containerId); + + // Create mock code-server CLI + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /tmp/vscode-web/bin && cat > /tmp/vscode-web/bin/code-server << 'MOCKEOF' +#!/bin/bash +echo "Mock code-server running" +exit 0 +MOCKEOF +chmod +x /tmp/vscode-web/bin/code-server`, + ]); + + // Pre-create an existing settings file + await execContainer(containerId, [ + "bash", + "-c", + `mkdir -p /root/.vscode-server/data/Machine && echo '{"existing.setting": "existing_value"}' > /root/.vscode-server/data/Machine/settings.json`, + ]); + + const script = findResourceInstance(state, "coder_script"); + + // Run script - should warn but not fail + const scriptResult = await execContainer(containerId, [ + "bash", + "-c", + script.script, + ]); + expect(scriptResult.exitCode).toBe(0); + expect(scriptResult.stdout).toContain("Could not merge settings"); + + // Existing settings should be preserved (not overwritten) + const settingsResult = await execContainer(containerId, [ + "cat", + "/root/.vscode-server/data/Machine/settings.json", + ]); + + expect(settingsResult.exitCode).toBe(0); + expect(settingsResult.stdout).toContain("existing.setting"); + expect(settingsResult.stdout).toContain("existing_value"); + expect(settingsResult.stdout).not.toContain("new.setting"); + expect(settingsResult.stdout).not.toContain("new_value"); + }); }); diff --git a/registry/coder/modules/vscode-web/main.tf b/registry/coder/modules/vscode-web/main.tf index 7a2029c87..ff86e455f 100644 --- a/registry/coder/modules/vscode-web/main.tf +++ b/registry/coder/modules/vscode-web/main.tf @@ -105,7 +105,7 @@ variable "group" { variable "settings" { type = any - description = "A map of settings to apply to VS Code web." + description = "A map of settings to apply to VS Code Web's Machine settings. These settings are merged with any existing machine settings on startup." default = {} } @@ -167,6 +167,10 @@ variable "workspace" { data "coder_workspace_owner" "me" {} data "coder_workspace" "me" {} +locals { + settings_b64 = var.settings != {} ? base64encode(jsonencode(var.settings)) : "" +} + resource "coder_script" "vscode-web" { agent_id = var.agent_id display_name = "VS Code Web" @@ -177,8 +181,7 @@ resource "coder_script" "vscode-web" { INSTALL_PREFIX : var.install_prefix, EXTENSIONS : join(",", var.extensions), TELEMETRY_LEVEL : var.telemetry_level, - // This is necessary otherwise the quotes are stripped! - SETTINGS : replace(jsonencode(var.settings), "\"", "\\\""), + SETTINGS_B64 : local.settings_b64, OFFLINE : var.offline, USE_CACHED : var.use_cached, DISABLE_TRUST : var.disable_trust, diff --git a/registry/coder/modules/vscode-web/run.sh b/registry/coder/modules/vscode-web/run.sh index 57bb760f9..dea8e5853 100644 --- a/registry/coder/modules/vscode-web/run.sh +++ b/registry/coder/modules/vscode-web/run.sh @@ -4,13 +4,54 @@ BOLD='\033[0;1m' EXTENSIONS=("${EXTENSIONS}") VSCODE_WEB="${INSTALL_PREFIX}/bin/code-server" +# Merge settings from module with existing settings file +# Uses jq if available, falls back to Python3 for deep merge +merge_settings() { + local new_settings="$1" + local settings_file="$2" + + if [ -z "$new_settings" ] || [ "$new_settings" = "{}" ]; then + return 0 + fi + + if [ ! -f "$settings_file" ]; then + mkdir -p "$(dirname "$settings_file")" + printf '%s\n' "$new_settings" > "$settings_file" + printf "⚙️ Creating settings file...\n" + return 0 + fi + + local tmpfile + tmpfile="$(mktemp)" + + if command -v jq > /dev/null 2>&1; then + if jq -s '.[0] * .[1]' "$settings_file" <(printf '%s\n' "$new_settings") > "$tmpfile" 2> /dev/null; then + mv "$tmpfile" "$settings_file" + printf "⚙️ Merging settings...\n" + return 0 + fi + fi + + if command -v python3 > /dev/null 2>&1; then + if python3 -c "import json,sys;m=lambda a,b:{**a,**{k:m(a[k],v)if k in a and type(a[k])==type(v)==dict else v for k,v in b.items()}};print(json.dumps(m(json.load(open(sys.argv[1])),json.loads(sys.argv[2])),indent=2))" "$settings_file" "$new_settings" > "$tmpfile" 2> /dev/null; then + mv "$tmpfile" "$settings_file" + printf "⚙️ Merging settings...\n" + return 0 + fi + fi + + rm -f "$tmpfile" + printf "Warning: Could not merge settings (jq or python3 required). Keeping existing settings.\n" + return 0 +} + # Set extension directory EXTENSION_ARG="" if [ -n "${EXTENSIONS_DIR}" ]; then EXTENSION_ARG="--extensions-dir=${EXTENSIONS_DIR}" fi -# Set extension directory +# Set server base path SERVER_BASE_PATH_ARG="" if [ -n "${SERVER_BASE_PATH}" ]; then SERVER_BASE_PATH_ARG="--server-base-path=${SERVER_BASE_PATH}" @@ -28,11 +69,14 @@ run_vscode_web() { "$VSCODE_WEB" serve-local "$EXTENSION_ARG" "$SERVER_BASE_PATH_ARG" "$DISABLE_TRUST_ARG" --port "${PORT}" --host 127.0.0.1 --accept-server-license-terms --without-connection-token --telemetry-level "${TELEMETRY_LEVEL}" > "${LOG_PATH}" 2>&1 & } -# Check if the settings file exists... -if [ ! -f ~/.vscode-server/data/Machine/settings.json ]; then - echo "⚙️ Creating settings file..." - mkdir -p ~/.vscode-server/data/Machine - echo "${SETTINGS}" > ~/.vscode-server/data/Machine/settings.json +# Apply machine settings (merge with existing if present) +SETTINGS_B64='${SETTINGS_B64}' +if [ -n "$SETTINGS_B64" ]; then + if SETTINGS_JSON="$(echo -n "$SETTINGS_B64" | base64 -d 2> /dev/null)" && [ -n "$SETTINGS_JSON" ]; then + merge_settings "$SETTINGS_JSON" ~/.vscode-server/data/Machine/settings.json + else + printf "Warning: Failed to decode settings. Skipping settings configuration.\n" + fi fi # Check if vscode-server is already installed for offline or cached mode