diff --git a/registry/coder/modules/agentapi/README.md b/registry/coder/modules/agentapi/README.md index c5e9ae423..a225ca490 100644 --- a/registry/coder/modules/agentapi/README.md +++ b/registry/coder/modules/agentapi/README.md @@ -89,6 +89,42 @@ 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 = 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/main.test.ts b/registry/coder/modules/agentapi/main.test.ts index cedf840c2..4e82e6eab 100644 --- a/registry/coder/modules/agentapi/main.test.ts +++ b/registry/coder/modules/agentapi/main.test.ts @@ -613,4 +613,116 @@ 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: `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 8818736d7..4dca7d2ef 100644 --- a/registry/coder/modules/agentapi/main.tf +++ b/registry/coder/modules/agentapi/main.tf @@ -164,6 +164,22 @@ 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." @@ -228,6 +244,8 @@ 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}' \ diff --git a/registry/coder/modules/agentapi/scripts/main.sh b/registry/coder/modules/agentapi/scripts/main.sh index 928132c8c..a57efe5e2 100644 --- a/registry/coder/modules/agentapi/scripts/main.sh +++ b/registry/coder/modules/agentapi/scripts/main.sh @@ -16,6 +16,8 @@ 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:-}" @@ -109,9 +111,45 @@ 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. 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