Skip to content
Open
48 changes: 38 additions & 10 deletions registry/coder/modules/claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.9.1"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_api_key = "xxxx-xxxxx-xxxx"
Expand Down Expand Up @@ -55,15 +55,43 @@ module "claude-code" {

This example shows how to configure the Claude Code module to run the agent behind a process-level boundary that restricts its network access.

By default, when `enable_boundary = true`, the module uses `coder boundary` subcommand (provided by Coder) without requiring any installation.
When `enable_boundary = true`, you must provide network filtering rules via one of two options:

- `boundary_config` — inline YAML string (config lives in the template)
- `boundary_config_path` — path to a config file already on disk

The module writes the config to `~/.config/coder_boundary/config.yaml` automatically.

#### Inline boundary config

```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.1"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_boundary = true

boundary_config = <<-EOT
allow:
- "*.anthropic.com"
- "*.github.com"
EOT
}
```

#### Boundary config from file path

Use this when the config file is provisioned separately or managed outside the template:

```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_boundary = true
boundary_config_path = "/home/coder/.config/coder_boundary/config.yaml"
}
```

Expand All @@ -81,7 +109,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.9.1"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_aibridge = true
Expand Down Expand Up @@ -110,7 +138,7 @@ data "coder_task" "me" {}

module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.1"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
ai_prompt = data.coder_task.me.prompt
Expand All @@ -133,7 +161,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.9.1"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"

Expand Down Expand Up @@ -189,7 +217,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.9.1"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
install_claude_code = true
Expand All @@ -211,7 +239,7 @@ variable "claude_code_oauth_token" {

module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.1"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
claude_code_oauth_token = var.claude_code_oauth_token
Expand Down Expand Up @@ -284,7 +312,7 @@ resource "coder_env" "bedrock_api_key" {

module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.1"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
Expand Down Expand Up @@ -341,7 +369,7 @@ resource "coder_env" "google_application_credentials" {

module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "4.9.1"
version = "4.9.2"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
Expand Down
39 changes: 37 additions & 2 deletions registry/coder/modules/claude-code/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,38 @@ variable "enable_boundary" {
type = bool
description = "Whether to enable coder boundary for network filtering"
default = false

validation {
condition = !var.enable_boundary || (var.boundary_config == null || trimspace(var.boundary_config) == "") || (var.boundary_config_path == null || trimspace(var.boundary_config_path) == "")
error_message = "Only one of boundary_config or boundary_config_path can be provided, not both."
}

validation {
condition = ((var.boundary_config == null || trimspace(var.boundary_config) == "") && (var.boundary_config_path == null || trimspace(var.boundary_config_path) == "")) || var.enable_boundary
error_message = "boundary_config and boundary_config_path can only be set when enable_boundary is true."
}
}

variable "boundary_config" {
type = string
description = "Inline YAML config for coder boundary network filtering rules. Written to ~/.config/coder_boundary/config.yaml before boundary starts. Mutually exclusive with boundary_config_path."
default = null

validation {
condition = var.boundary_config == null || trimspace(var.boundary_config) != ""
error_message = "boundary_config must not be empty or whitespace-only when provided."
}
}

variable "boundary_config_path" {
type = string
description = "Path to an existing boundary config file on disk. Symlinked to ~/.config/coder_boundary/config.yaml before boundary starts. Mutually exclusive with boundary_config."
default = null

validation {
condition = var.boundary_config_path == null || trimspace(var.boundary_config_path) != ""
error_message = "boundary_config_path must not be empty or whitespace-only when provided."
}
}

variable "boundary_version" {
Expand Down Expand Up @@ -331,8 +363,9 @@ locals {
start_script = file("${path.module}/scripts/start.sh")
module_dir_name = ".claude-module"
# Extract hostname from access_url for boundary --allow flag
coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "")
claude_api_key = var.enable_aibridge ? data.coder_workspace_owner.me.session_token : var.claude_api_key
coder_host = replace(replace(data.coder_workspace.me.access_url, "https://", ""), "http://", "")
boundary_config_b64 = var.boundary_config != null ? base64encode(var.boundary_config) : ""
claude_api_key = var.enable_aibridge ? data.coder_workspace_owner.me.session_token : var.claude_api_key

# Required prompts for the module to properly report task status to Coder
report_tasks_system_prompt = <<-EOT
Expand Down Expand Up @@ -407,6 +440,8 @@ module "agentapi" {
ARG_COMPILE_FROM_SOURCE='${var.compile_boundary_from_source}' \
ARG_USE_BOUNDARY_DIRECTLY='${var.use_boundary_directly}' \
ARG_CODER_HOST='${local.coder_host}' \
ARG_BOUNDARY_CONFIG='${local.boundary_config_b64}' \
ARG_BOUNDARY_CONFIG_PATH='${var.boundary_config_path != null ? var.boundary_config_path : ""}' \
ARG_CLAUDE_BINARY_PATH='${var.claude_binary_path}' \
/tmp/start.sh
EOT
Expand Down
175 changes: 172 additions & 3 deletions registry/coder/modules/claude-code/main.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -188,13 +188,61 @@ run "test_claude_code_permission_mode_validation" {
}
}

run "test_claude_code_with_boundary" {
run "test_claude_code_with_boundary_inline_config" {
command = plan

variables {
agent_id = "test-agent-boundary"
workdir = "/home/coder/boundary-test"
enable_boundary = true
boundary_config = <<-EOT
allow:
- "*.anthropic.com"
- "*.github.com"
EOT
}

override_data {
target = data.coder_workspace.me
values = {
access_url = "https://coder.example.com"
}
}

assert {
condition = var.enable_boundary == true
error_message = "Boundary should be enabled"
}

assert {
condition = var.boundary_config != null
error_message = "Boundary config should be set"
}

assert {
condition = local.coder_host == "coder.example.com"
error_message = "Coder host should be 'coder.example.com' after stripping https:// from access URL"
}

assert {
condition = local.boundary_config_b64 != ""
error_message = "Boundary config should be base64-encoded for the start script"
}

assert {
condition = base64decode(local.boundary_config_b64) == var.boundary_config
error_message = "Base64-encoded boundary config should decode back to the original config"
}
}

run "test_claude_code_with_boundary_config_path" {
command = plan

variables {
agent_id = "test-agent-boundary-path"
workdir = "/home/coder/boundary-test"
enable_boundary = true
boundary_config_path = "/home/coder/.config/coder_boundary/config.yaml"
}

assert {
Expand All @@ -203,9 +251,130 @@ run "test_claude_code_with_boundary" {
}

assert {
condition = local.coder_host != ""
error_message = "Coder host should be extracted from access URL"
condition = var.boundary_config_path == "/home/coder/.config/coder_boundary/config.yaml"
error_message = "Boundary config path should be set correctly"
}
}

run "test_claude_code_with_boundary_no_config" {
command = plan

variables {
agent_id = "test-agent-boundary"
workdir = "/home/coder/boundary-test"
enable_boundary = true
}

assert {
condition = var.enable_boundary == true
error_message = "Boundary should be enabled"
}
}

run "test_boundary_both_configs_fails" {
command = plan

variables {
agent_id = "test-agent-boundary-both"
workdir = "/home/coder/boundary-test"
enable_boundary = true
boundary_config = "allow:\n - '*.example.com'"
boundary_config_path = "/home/coder/.config/coder_boundary/config.yaml"
}

expect_failures = [
var.enable_boundary,
]
}

run "test_boundary_config_without_boundary_fails" {
command = plan

variables {
agent_id = "test-agent-no-boundary"
workdir = "/home/coder/boundary-test"
enable_boundary = false
boundary_config = "allow:\n - '*.example.com'"
}

expect_failures = [
var.enable_boundary,
]
}

run "test_boundary_config_path_without_boundary_fails" {
command = plan

variables {
agent_id = "test-agent-no-boundary-path"
workdir = "/home/coder/boundary-test"
enable_boundary = false
boundary_config_path = "/home/coder/.config/coder_boundary/config.yaml"
}

expect_failures = [
var.enable_boundary,
]
}

run "test_boundary_empty_config_fails" {
command = plan

variables {
agent_id = "test-agent-empty-config"
workdir = "/home/coder/boundary-test"
enable_boundary = true
boundary_config = ""
}

expect_failures = [
var.boundary_config,
]
}

run "test_boundary_empty_config_path_fails" {
command = plan

variables {
agent_id = "test-agent-empty-config-path"
workdir = "/home/coder/boundary-test"
enable_boundary = true
boundary_config_path = ""
}

expect_failures = [
var.boundary_config_path,
]
}

run "test_boundary_whitespace_config_fails" {
command = plan

variables {
agent_id = "test-agent-whitespace-config"
workdir = "/home/coder/boundary-test"
enable_boundary = true
boundary_config = " "
}

expect_failures = [
var.boundary_config,
]
}

run "test_boundary_whitespace_config_path_fails" {
command = plan

variables {
agent_id = "test-agent-whitespace-config-path"
workdir = "/home/coder/boundary-test"
enable_boundary = true
boundary_config_path = " "
}

expect_failures = [
var.boundary_config_path,
]
}

run "test_claude_code_system_prompt" {
Expand Down
Loading
Loading