diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index c5e9ae423..22ce50fe8 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.2.0" + version = "2.3.0" agent_id = var.agent_id web_app_slug = local.app_slug @@ -67,8 +67,7 @@ module "agentapi" { 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`). +State and PID files are stored in `$HOME//` alongside other module files (e.g. `$HOME/.claude-module/agentapi-state.json`). To enable: @@ -89,6 +88,47 @@ 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 = "/home/coder/.config/coder_boundary/config.yaml" + + # Optional: install boundary binary instead of using coder subcommand + # use_boundary_directly        = true + # boundary_version              = "0.6.0" + # compile_boundary_from_source  = false +} +``` + +### 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/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index cedf840c2..39d10ca7a 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -613,4 +613,109 @@ describe("agentapi", async () => { 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_path: "/tmp/test-boundary.yaml", + }, + }); + // Write boundary config to the path 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, + filePath: "/usr/bin/coder", + content: `#!/bin/bash +if [ "$1" = "boundary" ]; then + shift; shift; exec "$@" +fi +echo "mock coder"`, + }); + await execModuleScript(id); + await expectAgentAPIStarted(id); + // Verify the config file exists at the specified path + const config = await readFileContainer(id, "/tmp/test-boundary.yaml"); + expect(config).toContain("jail_type: landjail"); + expect(config).toContain("proxy_port: 8087"); + expect(config).toContain("domain=api.example.com"); + // 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_path: "/tmp/test-boundary.yaml", + }, + }); + // Write boundary config + 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, + [ + "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/main.tf b/registry/coder/modules/agentapi/main.tf index 8818736d7..6f177036b 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -164,6 +164,36 @@ 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_path" { + type = string + description = "Path to boundary config.yaml inside the workspace. If provided, exposed as BOUNDARY_CONFIG env var." + default = "" +} + +variable "boundary_version" { + type = string + description = "Boundary version. When use_boundary_directly is true, a release version should be provided or 'latest' for the latest release. When compile_boundary_from_source is true, a valid git reference should be provided (tag, commit, branch)." + default = "latest" +} + +variable "compile_boundary_from_source" { + type = bool + description = "Whether to compile boundary from source instead of using the official install script." + default = false +} + +variable "use_boundary_directly" { + type = bool + description = "Whether to use boundary binary directly instead of coder boundary subcommand. When false (default), uses coder boundary subcommand. When true, installs and uses boundary binary from release." + default = false +} + variable "enable_state_persistence" { type = bool description = "Enable AgentAPI conversation state persistence across restarts." @@ -182,6 +212,13 @@ variable "pid_file_path" { default = "" } +resource "coder_env" "boundary_config" { + count = var.enable_boundary && var.boundary_config_path != "" ? 1 : 0 + agent_id = var.agent_id + name = "BOUNDARY_CONFIG" + value = var.boundary_config_path +} + locals { # we always trim the slash for consistency workdir = trimsuffix(var.folder, "/") @@ -200,6 +237,7 @@ locals { 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") + boundary_script = file("${path.module}/scripts/boundary.sh") } resource "coder_script" "agentapi" { @@ -214,6 +252,9 @@ 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 + + echo -n '${base64encode(local.boundary_script)}' | base64 -d > /tmp/agentapi-boundary.sh + chmod +x /tmp/agentapi-boundary.sh ARG_MODULE_DIR_NAME='${var.module_dir_name}' \ ARG_WORKDIR="$(echo -n '${base64encode(local.workdir)}' | base64 -d)" \ @@ -228,6 +269,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_VERSION='${var.boundary_version}' \ + ARG_COMPILE_BOUNDARY_FROM_SOURCE='${var.compile_boundary_from_source}' \ + ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \ 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/boundary.sh b/registry/coder/modules/agentapi/scripts/boundary.sh new file mode 100644 index 000000000..d57f22614 --- /dev/null +++ b/registry/coder/modules/agentapi/scripts/boundary.sh @@ -0,0 +1,95 @@ +#!/bin/bash +# boundary.sh - Boundary installation and setup for agentapi module. +# Sourced by main.sh when ENABLE_BOUNDARY=true. +# Exports AGENTAPI_BOUNDARY_PREFIX for use by module start scripts. + +validate_boundary_subcommand() { + if command_exists coder; then + if coder boundary --help > /dev/null 2>&1; then + return 0 + else + echo "Error: 'coder' command found but does not support 'boundary' subcommand. Please enable install_boundary." + exit 1 + fi + else + echo "Error: ENABLE_BOUNDARY=true, but 'coder' command not found. Boundary cannot be enabled." >&2 + exit 1 + fi +} + +# Install boundary binary if needed. +# Uses one of three strategies: +# 1. Compile from source (compile_boundary_from_source=true) +# 2. Install from release (use_boundary_directly=true) +# 3. Use coder boundary subcommand (default, no installation needed) +install_boundary() { + if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]; then + echo "Compiling boundary from source (version: ${BOUNDARY_VERSION})" + + # Remove existing boundary directory to allow re-running safely + if [ -d boundary ]; then + rm -rf boundary + fi + + echo "Cloning boundary repository" + git clone https://github.com/coder/boundary.git + cd boundary || exit 1 + git checkout "${BOUNDARY_VERSION}" + + make build + + sudo cp boundary /usr/local/bin/ + sudo chmod +x /usr/local/bin/boundary + cd - || exit 1 + elif [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then + echo "Installing boundary using official install script (version: ${BOUNDARY_VERSION})" + curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "${BOUNDARY_VERSION}" + else + validate_boundary_subcommand + echo "Using coder boundary subcommand (provided by Coder)" + fi +} + +# Set up boundary: install, write config, create wrapper script. +# Exports AGENTAPI_BOUNDARY_PREFIX pointing to the wrapper script. +setup_boundary() { + local module_path="$1" + + echo "Setting up coder boundary..." + + # Install boundary binary if needed + install_boundary + + # Determine which boundary command to use and create wrapper script + BOUNDARY_WRAPPER_SCRIPT="$module_path/boundary-wrapper.sh" + + if [ "${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ] || [ "${USE_BOUNDARY_DIRECTLY}" = "true" ]; then + # Use boundary binary directly (from compilation or release installation) + cat > "${BOUNDARY_WRAPPER_SCRIPT}" << 'WRAPPER_EOF' +#!/usr/bin/env bash +set -euo pipefail +exec boundary -- "$@" +WRAPPER_EOF + else + # Use coder boundary subcommand (default) + # Copy coder binary to strip CAP_NET_ADMIN capabilities. + # This is necessary because boundary doesn't work with privileged binaries + # (you can't launch privileged binaries inside network namespaces unless + # you have sys_admin). + CODER_NO_CAPS="$module_path/coder-no-caps" + if ! cp "$(which coder)" "$CODER_NO_CAPS"; then + echo "Error: Failed to copy coder binary to ${CODER_NO_CAPS}. Boundary cannot be enabled." >&2 + exit 1 + fi + cat > "${BOUNDARY_WRAPPER_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 + fi + + chmod +x "${BOUNDARY_WRAPPER_SCRIPT}" + export AGENTAPI_BOUNDARY_PREFIX="${BOUNDARY_WRAPPER_SCRIPT}" + echo "Boundary wrapper configured: ${AGENTAPI_BOUNDARY_PREFIX}" +} diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 928132c8c..b0afa24af 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_VERSION="${ARG_BOUNDARY_VERSION:-latest}" +COMPILE_BOUNDARY_FROM_SOURCE="${ARG_COMPILE_BOUNDARY_FROM_SOURCE:-false}" +USE_BOUNDARY_DIRECTLY="${ARG_USE_BOUNDARY_DIRECTLY:-false}" ENABLE_STATE_PERSISTENCE="${ARG_ENABLE_STATE_PERSISTENCE:-false}" STATE_FILE_PATH="${ARG_STATE_FILE_PATH:-}" PID_FILE_PATH="${ARG_PID_FILE_PATH:-}" @@ -109,9 +113,18 @@ export LC_ALL=en_US.UTF-8 cd "${WORKDIR}" +# Set up boundary if enabled +export AGENTAPI_BOUNDARY_PREFIX="" +if [ "${ENABLE_BOUNDARY}" = "true" ]; then + # shellcheck source=boundary.sh + source /tmp/agentapi-boundary.sh + setup_boundary "$module_path" +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. diff --git a/registry/coder/modules/agentapi/testdata/agentapi-mock.js b/registry/coder/modules/agentapi/testdata/agentapi-mock.js index 84a88c047..e2e2d560d 100644 --- a/registry/coder/modules/agentapi/testdata/agentapi-mock.js +++ b/registry/coder/modules/agentapi/testdata/agentapi-mock.js @@ -31,6 +31,15 @@ for (const v of [ ); } } +// 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) { 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