Compare commits

..

1 Commits

29 changed files with 67 additions and 1534 deletions

View File

@ -37,7 +37,7 @@ jobs:
all: all:
- '**' - '**'
- name: Set up Terraform - name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@2b778f292c2ddf8ac261683d0d5d8a18da1512f6 # v2.33.3 uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
- name: Set up Bun - name: Set up Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with: with:
@ -87,13 +87,13 @@ jobs:
bun-version: latest bun-version: latest
# Need Terraform for its formatter # Need Terraform for its formatter
- name: Install Terraform - name: Install Terraform
uses: coder/coder/.github/actions/setup-tf@2b778f292c2ddf8ac261683d0d5d8a18da1512f6 # v2.33.3 uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install
- name: Validate formatting - name: Validate formatting
run: bun fmt:ci run: bun fmt:ci
- name: Check for typos - name: Check for typos
uses: crate-ci/typos@aca895bf05aec0cb7dffa6f94495e923224d9f17 # v1.46.2 uses: crate-ci/typos@cf5f1c29a8ac336af8568821ec41919923b05a83 # v1.45.1
with: with:
config: .github/typos.toml config: .github/typos.toml
validate-readme-files: validate-readme-files:

View File

@ -31,7 +31,7 @@ jobs:
bun-version: latest bun-version: latest
- name: Set up Terraform - name: Set up Terraform
uses: coder/coder/.github/actions/setup-tf@2b778f292c2ddf8ac261683d0d5d8a18da1512f6 # v2.33.3 uses: coder/coder/.github/actions/setup-tf@34584e909bbe6f501fb2cbdc994325b4d3f9e2ef # v2.32.0
- name: Install dependencies - name: Install dependencies
run: bun install run: bun install

View File

@ -27,7 +27,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Run zizmor (blocking, HIGH only) - name: Run zizmor (blocking, HIGH only)
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
with: with:
advanced-security: false advanced-security: false
annotations: true annotations: true
@ -49,7 +49,7 @@ jobs:
persist-credentials: false persist-credentials: false
- name: Run zizmor (SARIF) - name: Run zizmor (SARIF)
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
with: with:
inputs: | inputs: |
.github/workflows .github/workflows

View File

@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="7" height="7" x="14" y="3" rx="1"/>
<path d="M10 21V8a1 1 0 0 0-1-1H4a1 1 0 0 0-1 1v12a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-5a1 1 0 0 0-1-1H3"/>
</svg>

Before

Width:  |  Height:  |  Size: 339 B

View File

@ -1,5 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect width="18" height="7" x="3" y="3" rx="1"/>
<rect width="9" height="7" x="3" y="14" rx="1"/>
<rect width="5" height="7" x="16" y="14" rx="1"/>
</svg>

Before

Width:  |  Height:  |  Size: 336 B

View File

@ -50,16 +50,16 @@ variable "sessions" {
default = ["default"] default = ["default"]
} }
resource "coder_script" "tmux" { module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1"
agent_id = var.agent_id agent_id = var.agent_id
display_name = "tmux"
icon = "/icon/terminal.svg" icon = "/icon/terminal.svg"
script = templatefile("${path.module}/scripts/run.sh", { install_script = templatefile("${path.module}/scripts/run.sh", {
TMUX_CONFIG = base64encode(var.tmux_config) TMUX_CONFIG = base64encode(var.tmux_config)
SAVE_INTERVAL = var.save_interval SAVE_INTERVAL = var.save_interval
}) })
run_on_start = true module_directory = "$HOME/.coder-modules/anomaly/tmux"
run_on_stop = false
} }
resource "coder_app" "tmux_sessions" { resource "coder_app" "tmux_sessions" {
@ -76,3 +76,8 @@ resource "coder_app" "tmux_sessions" {
SESSION_NAME = each.value SESSION_NAME = each.value
}) })
} }
output "scripts" {
description = "Ordered list of coder exp sync names for the coder_script resources this module actually creates, in run order (pre_install, install, post_install). Scripts that were not configured are absent from the list."
value = module.coder_utils.scripts
}

View File

@ -101,7 +101,7 @@ module "codex" {
preferred_auth_method = "apikey" preferred_auth_method = "apikey"
EOT EOT
mcp = <<-EOT additional_mcp_servers = <<-EOT
[mcp_servers.GitHub] [mcp_servers.GitHub]
command = "npx" command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"] args = ["-y", "@modelcontextprotocol/server-github"]

View File

@ -246,7 +246,7 @@ describe("codex", async () => {
].join("\n"); ].join("\n");
const { id, scripts } = await setup({ const { id, scripts } = await setup({
moduleVariables: { moduleVariables: {
mcp: additional, additional_mcp_servers: additional,
}, },
}); });
await runScripts(id, scripts); await runScripts(id, scripts);

View File

@ -50,8 +50,8 @@ variable "install_codex" {
variable "codex_version" { variable "codex_version" {
type = string type = string
description = "The version of Codex to install." description = "The version of Codex to install. Empty string installs the latest available version."
default = "latest" default = ""
} }
variable "openai_api_key" { variable "openai_api_key" {
@ -75,16 +75,16 @@ variable "base_config_toml" {
trust_level = "trusted" trust_level = "trusted"
When non-empty, the value is written verbatim as the base of config.toml; When non-empty, the value is written verbatim as the base of config.toml;
mcp and AI Gateway sections are still appended after it. additional_mcp_servers and AI Gateway sections are still appended after it.
Note: model_reasoning_effort and workdir trust are only applied in the Note: model_reasoning_effort and workdir trust are only applied in the
default config. Include them in your custom config if needed. default config. Include them in your custom config if needed.
EOT EOT
default = "" default = ""
} }
variable "mcp" { variable "additional_mcp_servers" {
type = string type = string
description = "MCP server configurations in TOML format. When set, servers are appended to the Codex config.toml." description = "Additional MCP servers configuration in TOML format."
default = "" default = ""
} }
@ -140,7 +140,7 @@ locals {
ARG_CODEX_VERSION = var.codex_version != "" ? base64encode(var.codex_version) : "" ARG_CODEX_VERSION = var.codex_version != "" ? base64encode(var.codex_version) : ""
ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : "" ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : ""
ARG_BASE_CONFIG_TOML = var.base_config_toml != "" ? base64encode(var.base_config_toml) : "" ARG_BASE_CONFIG_TOML = var.base_config_toml != "" ? base64encode(var.base_config_toml) : ""
ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : "" ARG_ADDITIONAL_MCP_SERVERS = var.additional_mcp_servers != "" ? base64encode(var.additional_mcp_servers) : ""
ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway) ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway)
ARG_AIBRIDGE_CONFIG = var.enable_ai_gateway ? base64encode(local.aibridge_config) : "" ARG_AIBRIDGE_CONFIG = var.enable_ai_gateway ? base64encode(local.aibridge_config) : ""
ARG_MODEL_REASONING_EFFORT = var.model_reasoning_effort ARG_MODEL_REASONING_EFFORT = var.model_reasoning_effort

View File

@ -12,7 +12,7 @@ ARG_INSTALL='${ARG_INSTALL}'
ARG_CODEX_VERSION=$(echo -n '${ARG_CODEX_VERSION}' | base64 -d) ARG_CODEX_VERSION=$(echo -n '${ARG_CODEX_VERSION}' | base64 -d)
ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d) ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | base64 -d)
ARG_BASE_CONFIG_TOML=$(echo -n '${ARG_BASE_CONFIG_TOML}' | base64 -d) ARG_BASE_CONFIG_TOML=$(echo -n '${ARG_BASE_CONFIG_TOML}' | base64 -d)
ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d) ARG_ADDITIONAL_MCP_SERVERS=$(echo -n '${ARG_ADDITIONAL_MCP_SERVERS}' | base64 -d)
ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}' ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}'
ARG_AIBRIDGE_CONFIG=$(echo -n '${ARG_AIBRIDGE_CONFIG}' | base64 -d) ARG_AIBRIDGE_CONFIG=$(echo -n '${ARG_AIBRIDGE_CONFIG}' | base64 -d)
ARG_MODEL_REASONING_EFFORT='${ARG_MODEL_REASONING_EFFORT}' ARG_MODEL_REASONING_EFFORT='${ARG_MODEL_REASONING_EFFORT}'
@ -150,9 +150,9 @@ function populate_config_toml() {
write_minimal_default_config "$${config_path}" write_minimal_default_config "$${config_path}"
fi fi
if [ -n "$${ARG_MCP}" ]; then if [ -n "$${ARG_ADDITIONAL_MCP_SERVERS}" ]; then
printf "Adding MCP servers\n" printf "Adding additional MCP servers\n"
echo "$${ARG_MCP}" >> "$${config_path}" echo "$${ARG_ADDITIONAL_MCP_SERVERS}" >> "$${config_path}"
fi fi
if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] && [ -n "$${ARG_AIBRIDGE_CONFIG}" ]; then if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] && [ -n "$${ARG_AIBRIDGE_CONFIG}" ]; then

View File

@ -13,7 +13,7 @@ Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace
```tf ```tf
module "gemini" { module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder" source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.1" version = "3.0.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
folder = "/home/coder/project" folder = "/home/coder/project"
} }
@ -46,7 +46,7 @@ variable "gemini_api_key" {
module "gemini" { module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder" source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.1" version = "3.0.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
gemini_api_key = var.gemini_api_key gemini_api_key = var.gemini_api_key
folder = "/home/coder/project" folder = "/home/coder/project"
@ -94,7 +94,7 @@ data "coder_parameter" "ai_prompt" {
module "gemini" { module "gemini" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/gemini/coder" source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.1" version = "3.0.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
gemini_api_key = var.gemini_api_key gemini_api_key = var.gemini_api_key
gemini_model = "gemini-2.5-flash" gemini_model = "gemini-2.5-flash"
@ -105,22 +105,6 @@ module "gemini" {
You are a helpful coding assistant. Always explain your code changes clearly. You are a helpful coding assistant. Always explain your code changes clearly.
YOU MUST REPORT ALL TASKS TO CODER. YOU MUST REPORT ALL TASKS TO CODER.
EOT EOT
pre_install_script = <<-EOT
#!/bin/bash
set -e
echo "Installing Node.js via NodeSource..."
sudo apt-get update -qq && sudo apt-get install -y curl ca-certificates
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo bash -
sudo apt-get install -y nodejs
echo "Node version: $(node -v)"
echo "npm version: $(npm -v)"
echo "Node install complete."
EOT
} }
``` ```
@ -134,7 +118,7 @@ For enterprise users who prefer Google's Vertex AI platform:
```tf ```tf
module "gemini" { module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder" source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.1" version = "3.0.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
gemini_api_key = var.gemini_api_key gemini_api_key = var.gemini_api_key
folder = "/home/coder/project" folder = "/home/coder/project"

View File

@ -148,16 +148,22 @@ locals {
base_extensions = <<-EOT base_extensions = <<-EOT
{ {
"coder": { "coder": {
"command": "coder",
"args": [ "args": [
"exp", "exp",
"mcp", "mcp",
"server" "server"
], ],
"command": "coder",
"description": "Report ALL tasks and statuses (in progress, done, failed) you are working on.",
"enabled": true,
"env": { "env": {
"CODER_MCP_APP_STATUS_SLUG": "${local.app_slug}", "CODER_MCP_APP_STATUS_SLUG": "${local.app_slug}",
"CODER_MCP_AI_AGENTAPI_URL": "http://localhost:3284" "CODER_MCP_AI_AGENTAPI_URL": "http://localhost:3284"
} },
"name": "Coder",
"timeout": 3000,
"type": "stdio",
"trust": true
} }
} }
EOT EOT

View File

@ -17,7 +17,6 @@ echo "--------------------------------"
printf "gemini_config: %s\n" "$ARG_GEMINI_CONFIG" printf "gemini_config: %s\n" "$ARG_GEMINI_CONFIG"
printf "install: %s\n" "$ARG_INSTALL" printf "install: %s\n" "$ARG_INSTALL"
printf "gemini_version: %s\n" "$ARG_GEMINI_VERSION" printf "gemini_version: %s\n" "$ARG_GEMINI_VERSION"
printf "BASE_EXTENSIONS: %s\n" "$BASE_EXTENSIONS"
echo "--------------------------------" echo "--------------------------------"
set +o nounset set +o nounset
@ -141,25 +140,6 @@ function add_system_prompt_if_exists() {
fi fi
} }
function patch_coder_mcp_command() {
CODER_BIN=$(which coder)
SETTINGS_PATH="$HOME/.gemini/settings.json"
if [ -z "$CODER_BIN" ]; then
printf "Warning: could not find coder binary, MCP command path not patched.\n"
return
fi
printf "Patching coder MCP command path to: %s\n" "$CODER_BIN"
TMP_SETTINGS=$(mktemp)
jq --arg bin "$CODER_BIN" \
'.mcpServers.coder.command = $bin' \
"$SETTINGS_PATH" > "$TMP_SETTINGS" && mv "$TMP_SETTINGS" "$SETTINGS_PATH"
printf "Patch complete.\n"
}
function configure_mcp() { function configure_mcp() {
export CODER_MCP_APP_STATUS_SLUG="gemini" export CODER_MCP_APP_STATUS_SLUG="gemini"
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284" export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
@ -169,5 +149,4 @@ function configure_mcp() {
install_gemini install_gemini
populate_settings_json populate_settings_json
add_system_prompt_if_exists add_system_prompt_if_exists
patch_coder_mcp_command
configure_mcp configure_mcp

View File

@ -1,146 +0,0 @@
---
display_name: Agent Firewall
description: Configures agent-firewall for network isolation in Coder workspaces
icon: ../../../../.icons/coder.svg
verified: true
tags: [agent-firewall, ai, agents, firewall, boundary]
---
# Agent Firewall
Installs [agent-firewall](https://coder.com/docs/ai-coder/agent-firewall) for network isolation in Coder workspaces.
This module:
- Installs agent-firewall (via coder subcommand, direct installation, or compilation from source)
- Creates a wrapper script at `$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh`
- Writes a [default agent-firewall config](https://github.com/coder/registry/blob/main/registry/coder/modules/agent-firewall/config.yaml.tftpl) to `$HOME/.coder-modules/coder/agent-firewall/config/config.yaml` (customizable)
- Provides the wrapper path, config path, and script names via outputs
- Uses coder-utils and output `scripts` for synchronization. https://registry.coder.com/modules/coder/coder-utils?tab=outputs
```tf
module "agent-firewall" {
source = "registry.coder.com/coder/agent-firewall/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
}
```
## Examples
Use the `agent_firewall_wrapper_path` output to access the wrapper path and `agent_firewall_config_path` to access config path in Terraform and pass it to scripts that should run commands in network isolation.
### With Claude Code
Use agent-firewall alongside the `claude-code` module to run Claude in a
network-isolated environment.
#### As an automated task
```tf
module "agent-firewall" {
source = "registry.coder.com/coder/agent-firewall/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
}
resource "coder_script" "claude_with_agent_firewall" {
agent_id = coder_agent.main.id
display_name = "Claude (Agent Firewall)"
run_on_start = true
script = <<-EOT
#!/bin/bash
set -e
coder exp sync want claude-agent-firewall \
${join(" ", module.agent-firewall.scripts)} \
${join(" ", module.claude-code.scripts)}
coder exp sync start claude-agent-firewall
"${module.agent-firewall.agent_firewall_wrapper_path}" --config="${module.agent-firewall.agent_firewall_config_path}" -- claude -p "Fix issue #840 from coder/coder"
EOT
}
```
#### As a Coder app
```tf
module "agent-firewall" {
source = "registry.coder.com/coder/agent-firewall/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
}
resource "coder_app" "claude_with_agent_firewall" {
agent_id = coder_agent.main.id
display_name = "Claude Code"
slug = "claude-code"
command = <<-EOT
#!/bin/bash
set -e
exec tmux new-session -A -s claude-code \
'"${module.agent-firewall.agent_firewall_wrapper_path}" --config="${module.agent-firewall.agent_firewall_config_path}" -- claude'
EOT
}
```
## Configuration
The module ships with a comprehensive default config based on the
[Coder dogfood allowlist](https://github.com/coder/coder/blob/main/dogfood/coder/boundary-config.yaml). It covers Anthropic services,
OpenAI services, version control, package managers, container registries,
cloud platforms, and common development tools.
The Coder deployment domain is automatically added to the allowlist using
`data.coder_workspace.me.access_url`.
By default the config is written to
`$HOME/.coder-modules/coder/agent-firewall/config/config.yaml`. You can
access the resolved path via the `agent_firewall_config_path` output. Override
it in two ways:
### Inline config
Pass the full YAML content directly:
```tf
module "agent-firewall" {
source = "registry.coder.com/coder/agent-firewall/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
agent_firewall_config = <<-YAML
allowlist:
- domain=your-deployment.coder.com
- domain=api.anthropic.com
- domain=api.openai.com
log_dir: /tmp/agent_firewall_logs
proxy_port: 8087
log_level: warn
YAML
}
```
### External config file
Point to an existing config file in the workspace. The module will not
write any config and the `agent_firewall_config_path` output will point to
your path. The file must exist on disk before agent-firewall starts.
```tf
module "agent-firewall" {
source = "registry.coder.com/coder/agent-firewall/coder"
version = "0.0.1"
agent_id = coder_agent.main.id
agent_firewall_config_path = "/workspace/my-agent-firewall-config.yaml"
}
```
> **Note:** `agent_firewall_config` and `agent_firewall_config_path` are mutually
> exclusive, setting both produces a validation error.
See the [Agent Firewall docs](https://coder.com/docs/ai-coder/agent-firewall)
for the full config reference.
## References
- [Agent Firewall Documentation](https://coder.com/docs/ai-coder/agent-firewall)

View File

@ -1,157 +0,0 @@
# Test for agent-firewall module
run "plan_with_required_vars" {
command = plan
variables {
agent_id = "test-agent-id"
}
# Verify the agent_firewall_wrapper_path output
assert {
condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh"
error_message = "agent_firewall_wrapper_path output should be correct"
}
# Verify agent_firewall_config_path output defaults to the managed path
assert {
condition = output.agent_firewall_config_path == "$HOME/.coder-modules/coder/agent-firewall/config/config.yaml"
error_message = "agent_firewall_config_path output should default to managed config path"
}
# Verify the scripts output contains the install script name
assert {
condition = contains(output.scripts, "coder-agent-firewall-install_script")
error_message = "scripts should contain the install script name"
}
}
run "plan_with_compile_from_source" {
command = plan
variables {
agent_id = "test-agent-id"
compile_agent_firewall_from_source = true
agent_firewall_version = "main"
}
assert {
condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh"
error_message = "agent_firewall_wrapper_path output should be correct"
}
assert {
condition = contains(output.scripts, "coder-agent-firewall-install_script")
error_message = "scripts should contain the install script name"
}
}
run "plan_with_use_directly" {
command = plan
variables {
agent_id = "test-agent-id"
use_agent_firewall_directly = true
agent_firewall_version = "latest"
}
assert {
condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh"
error_message = "agent_firewall_wrapper_path output should be correct"
}
assert {
condition = contains(output.scripts, "coder-agent-firewall-install_script")
error_message = "scripts should contain the install script name"
}
}
run "plan_with_custom_hooks" {
command = plan
variables {
agent_id = "test-agent-id"
pre_install_script = "echo 'Before install'"
post_install_script = "echo 'After install'"
}
assert {
condition = contains(output.scripts, "coder-agent-firewall-install_script")
error_message = "scripts should contain the install script name"
}
# Verify pre and post install script names are set
assert {
condition = contains(output.scripts, "coder-agent-firewall-pre_install_script")
error_message = "scripts should contain the pre_install script name"
}
assert {
condition = contains(output.scripts, "coder-agent-firewall-post_install_script")
error_message = "scripts should contain the post_install script name"
}
}
run "plan_with_custom_module_directory" {
command = plan
variables {
agent_id = "test-agent-id"
module_directory = "$HOME/.coder-modules/custom/agent-firewall"
}
assert {
condition = output.agent_firewall_wrapper_path == "$HOME/.coder-modules/custom/agent-firewall/scripts/agent-firewall-wrapper.sh"
error_message = "agent_firewall_wrapper_path output should use custom module directory"
}
# Config path should also follow the module directory
assert {
condition = output.agent_firewall_config_path == "$HOME/.coder-modules/custom/agent-firewall/config/config.yaml"
error_message = "agent_firewall_config_path output should use custom module directory"
}
}
run "plan_with_inline_config" {
command = plan
variables {
agent_id = "test-agent-id"
agent_firewall_config = "allowlist:\n - domain=example.com\nlog_level: debug\n"
}
# Inline config should still point to the managed path.
assert {
condition = output.agent_firewall_config_path == "$HOME/.coder-modules/coder/agent-firewall/config/config.yaml"
error_message = "agent_firewall_config_path output should point to managed config path"
}
}
run "plan_with_config_path" {
command = plan
variables {
agent_id = "test-agent-id"
agent_firewall_config_path = "/workspace/my-boundary-config.yaml"
}
# agent_firewall_config_path output should point to the user-provided path.
assert {
condition = output.agent_firewall_config_path == "/workspace/my-boundary-config.yaml"
error_message = "agent_firewall_config_path output should point to user-provided path"
}
}
run "plan_with_both_configs_should_fail" {
command = plan
variables {
agent_id = "test-agent-id"
agent_firewall_config = "allowlist: []"
agent_firewall_config_path = "/workspace/config.yaml"
}
expect_failures = [
var.agent_firewall_config,
]
}

View File

@ -1,218 +0,0 @@
allowlist:
- domain=${CODER_DOMAIN}
# Anthropic Services
- domain=api.anthropic.com
- domain=statsig.anthropic.com
- domain=claude.ai
# OpenAI Services
- domain=api.openai.com
- domain=platform.openai.com
- domain=openai.com
- domain=chatgpt.com
- domain=*.oaiusercontent.com
- domain=*.oaistatic.com
# Version Control
- domain=github.com
- domain=www.github.com
- domain=api.github.com
- domain=raw.githubusercontent.com
- domain=objects.githubusercontent.com
- domain=codeload.github.com
- domain=avatars.githubusercontent.com
- domain=camo.githubusercontent.com
- domain=gist.github.com
- domain=gitlab.com
- domain=www.gitlab.com
- domain=registry.gitlab.com
- domain=bitbucket.org
- domain=www.bitbucket.org
- domain=api.bitbucket.org
# Container Registries
- domain=registry-1.docker.io
- domain=auth.docker.io
- domain=index.docker.io
- domain=hub.docker.com
- domain=www.docker.com
- domain=production.cloudflare.docker.com
- domain=download.docker.com
- domain=*.gcr.io
- domain=ghcr.io
- domain=mcr.microsoft.com
- domain=*.data.mcr.microsoft.com
# Cloud Platforms
- domain=cloud.google.com
- domain=accounts.google.com
- domain=gcloud.google.com
- domain=*.googleapis.com
- domain=storage.googleapis.com
- domain=compute.googleapis.com
- domain=container.googleapis.com
- domain=azure.com
- domain=portal.azure.com
- domain=microsoft.com
- domain=www.microsoft.com
- domain=*.microsoftonline.com
- domain=packages.microsoft.com
- domain=dotnet.microsoft.com
- domain=dot.net
- domain=visualstudio.com
- domain=dev.azure.com
- domain=oracle.com
- domain=www.oracle.com
- domain=java.com
- domain=www.java.com
- domain=java.net
- domain=www.java.net
- domain=download.oracle.com
- domain=yum.oracle.com
# Package Managers - JavaScript/Node
- domain=registry.npmjs.org
- domain=www.npmjs.com
- domain=www.npmjs.org
- domain=npmjs.com
- domain=npmjs.org
- domain=yarnpkg.com
- domain=registry.yarnpkg.com
# Package Managers - Python
- domain=pypi.org
- domain=www.pypi.org
- domain=files.pythonhosted.org
- domain=pythonhosted.org
- domain=test.pypi.org
- domain=pypi.python.org
- domain=pypa.io
- domain=www.pypa.io
# Package Managers - Ruby
- domain=rubygems.org
- domain=www.rubygems.org
- domain=api.rubygems.org
- domain=index.rubygems.org
- domain=ruby-lang.org
- domain=www.ruby-lang.org
- domain=rubyforge.org
- domain=www.rubyforge.org
- domain=rubyonrails.org
- domain=www.rubyonrails.org
- domain=rvm.io
- domain=get.rvm.io
# Package Managers - Rust
- domain=crates.io
- domain=www.crates.io
- domain=static.crates.io
- domain=rustup.rs
- domain=static.rust-lang.org
- domain=www.rust-lang.org
# Package Managers - Go
- domain=proxy.golang.org
- domain=sum.golang.org
- domain=index.golang.org
- domain=golang.org
- domain=www.golang.org
- domain=go.dev
- domain=dl.google.com
- domain=goproxy.io
- domain=pkg.go.dev
# Package Managers - JVM
- domain=maven.org
- domain=repo.maven.org
- domain=central.maven.org
- domain=repo1.maven.org
- domain=jcenter.bintray.com
- domain=gradle.org
- domain=www.gradle.org
- domain=services.gradle.org
- domain=spring.io
- domain=repo.spring.io
# Package Managers - Other Languages
- domain=packagist.org
- domain=www.packagist.org
- domain=repo.packagist.org
- domain=nuget.org
- domain=www.nuget.org
- domain=api.nuget.org
- domain=pub.dev
- domain=api.pub.dev
- domain=hex.pm
- domain=www.hex.pm
- domain=cpan.org
- domain=www.cpan.org
- domain=metacpan.org
- domain=www.metacpan.org
- domain=api.metacpan.org
- domain=cocoapods.org
- domain=www.cocoapods.org
- domain=cdn.cocoapods.org
- domain=haskell.org
- domain=www.haskell.org
- domain=hackage.haskell.org
- domain=swift.org
- domain=www.swift.org
# Linux Distributions
- domain=archive.ubuntu.com
- domain=security.ubuntu.com
- domain=ubuntu.com
- domain=www.ubuntu.com
- domain=*.ubuntu.com
- domain=ppa.launchpad.net
- domain=launchpad.net
- domain=www.launchpad.net
# Development Tools & Platforms
- domain=dl.k8s.io
- domain=pkgs.k8s.io
- domain=k8s.io
- domain=www.k8s.io
- domain=releases.hashicorp.com
- domain=apt.releases.hashicorp.com
- domain=rpm.releases.hashicorp.com
- domain=archive.releases.hashicorp.com
- domain=hashicorp.com
- domain=www.hashicorp.com
- domain=repo.anaconda.com
- domain=conda.anaconda.org
- domain=anaconda.org
- domain=www.anaconda.com
- domain=anaconda.com
- domain=continuum.io
- domain=apache.org
- domain=www.apache.org
- domain=archive.apache.org
- domain=downloads.apache.org
- domain=eclipse.org
- domain=www.eclipse.org
- domain=download.eclipse.org
- domain=nodejs.org
- domain=www.nodejs.org
# Cloud Services & Monitoring
- domain=statsig.com
- domain=www.statsig.com
- domain=api.statsig.com
- domain=*.sentry.io
# Content Delivery & Mirrors
- domain=*.sourceforge.net
- domain=packagecloud.io
- domain=*.packagecloud.io
# Schema & Configuration
- domain=json-schema.org
- domain=www.json-schema.org
- domain=json.schemastore.org
- domain=www.schemastore.org
log_dir: ${BOUNDARY_LOG_DIR}
log_level: warn
proxy_port: 8087

View File

@ -1,376 +0,0 @@
import {
test,
afterEach,
describe,
setDefaultTimeout,
beforeAll,
expect,
} from "bun:test";
import {
execContainer,
readFileContainer,
runTerraformInit,
runTerraformApply,
testRequiredVariables,
runContainer,
removeContainer,
} from "~test";
import {
loadTestFile,
writeExecutable,
execModuleScript,
extractCoderEnvVars,
} from "../agentapi/test-util";
let cleanupFunctions: (() => Promise<void>)[] = [];
const registerCleanup = (cleanup: () => Promise<void>) => {
cleanupFunctions.push(cleanup);
};
afterEach(async () => {
const cleanupFnsCopy = cleanupFunctions.slice().reverse();
cleanupFunctions = [];
for (const cleanup of cleanupFnsCopy) {
try {
await cleanup();
} catch (error) {
console.error("Error during cleanup:", error);
}
}
});
interface SetupProps {
moduleVariables?: Record<string, string>;
skipCoderMock?: boolean;
}
const MODULE_DIR = "/home/coder/.coder-modules/coder/agent-firewall";
const CONFIG_PATH = `${MODULE_DIR}/config/config.yaml`;
const WRAPPER_PATH = `${MODULE_DIR}/scripts/agent-firewall-wrapper.sh`;
const setup = async (
props?: SetupProps,
): Promise<{ id: string; coderEnvVars: Record<string, string> }> => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
...props?.moduleVariables,
});
const coderEnvVars = extractCoderEnvVars(state);
const id = await runContainer("codercom/enterprise-node:latest");
registerCleanup(async () => {
await removeContainer(id);
});
await execContainer(id, ["bash", "-c", "mkdir -p /home/coder/project"]);
// Create a mock coder binary with boundary subcommand and exp sync support
if (!props?.skipCoderMock) {
await writeExecutable({
containerId: id,
filePath: "/usr/bin/coder",
content: await loadTestFile(import.meta.dir, "coder-mock.sh"),
});
}
// Extract ALL coder_scripts from the state (coder-utils creates multiple)
const allScripts = state.resources
.filter((r) => r.type === "coder_script")
.map((r) => ({
name: r.name,
script: r.instances[0].attributes.script as string,
}));
// Run scripts in lifecycle order
const executionOrder = [
"pre_install_script",
"install_script",
"post_install_script",
];
const orderedScripts = executionOrder
.map((name) => allScripts.find((s) => s.name === name))
.filter((s): s is NonNullable<typeof s> => s != null);
// Write each script individually and create a combined runner
const scriptPaths: string[] = [];
for (const s of orderedScripts) {
const scriptPath = `/home/coder/${s.name}.sh`;
await writeExecutable({
containerId: id,
filePath: scriptPath,
content: s.script,
});
scriptPaths.push(scriptPath);
}
const combinedScript = [
"#!/bin/bash",
"set -o errexit",
"set -o pipefail",
...scriptPaths.map((p) => `bash "${p}"`),
].join("\n");
await writeExecutable({
containerId: id,
filePath: "/home/coder/script.sh",
content: combinedScript,
});
return { id, coderEnvVars };
};
setDefaultTimeout(60 * 1000);
describe("agent-firewall", async () => {
beforeAll(async () => {
await runTerraformInit(import.meta.dir);
});
testRequiredVariables(import.meta.dir, {
agent_id: "test-agent-id",
});
test("terraform-state-basic", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent-id",
});
const resources = state.resources;
// No coder_env resources should exist
const envResources = resources.filter((r) => r.type === "coder_env");
expect(envResources).toHaveLength(0);
// Verify no env vars are exported
const coderEnvVars = extractCoderEnvVars(state);
expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBeUndefined();
expect(coderEnvVars["BOUNDARY_CONFIG"]).toBeUndefined();
// Verify agent_firewall_config_path output
expect(state.outputs["agent_firewall_config_path"]?.value).toBe(
"$HOME/.coder-modules/coder/agent-firewall/config/config.yaml",
);
// Verify agent_firewall_wrapper_path output
expect(state.outputs["agent_firewall_wrapper_path"]?.value).toBe(
"$HOME/.coder-modules/coder/agent-firewall/scripts/agent-firewall-wrapper.sh",
);
// Verify scripts output contains install script
const scripts = state.outputs["scripts"]?.value as string[];
expect(scripts).toContain("coder-agent-firewall-install_script");
});
test("terraform-state-custom-module-directory", async () => {
const customDir = "$HOME/.coder-modules/custom/agent-firewall";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent-id",
module_directory: customDir,
});
// Verify output uses custom dir
const outputs = state.outputs;
expect(outputs["agent_firewall_wrapper_path"]?.value).toBe(
`${customDir}/scripts/agent-firewall-wrapper.sh`,
);
// Config path follows module directory
expect(outputs["agent_firewall_config_path"]?.value).toBe(
`${customDir}/config/config.yaml`,
);
});
test("terraform-state-inline-config", async () => {
const inlineConfig =
"allowlist:\n - domain=example.com\nlog_level: debug\n";
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent-id",
agent_firewall_config: inlineConfig,
});
// Inline config still writes to the managed path.
expect(state.outputs["agent_firewall_config_path"]?.value).toBe(
"$HOME/.coder-modules/coder/agent-firewall/config/config.yaml",
);
});
test("terraform-state-config-path", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "test-agent-id",
agent_firewall_config_path: "/workspace/my-config.yaml",
});
// agent_firewall_config_path output should point to the user-provided path.
expect(state.outputs["agent_firewall_config_path"]?.value).toBe(
"/workspace/my-config.yaml",
);
});
test("happy-path-coder-subcommand", async () => {
const { id } = await setup();
await execModuleScript(id);
// Verify the wrapper script was created
const wrapperContent = await readFileContainer(id, WRAPPER_PATH);
expect(wrapperContent).toContain("#!/usr/bin/env bash");
expect(wrapperContent).toContain("coder-no-caps");
expect(wrapperContent).toContain("boundary");
// Verify the wrapper script is executable
const statResult = await execContainer(id, [
"stat",
"-c",
"%a",
WRAPPER_PATH,
]);
expect(statResult.stdout.trim()).toMatch(/7[0-9][0-9]/);
// Verify coder-no-caps binary was created
const coderNoCapsResult = await execContainer(id, [
"test",
"-f",
`${MODULE_DIR}/scripts/coder-no-caps`,
]);
expect(coderNoCapsResult.exitCode).toBe(0);
// Verify default boundary config was written inside module directory
const configContent = await readFileContainer(id, CONFIG_PATH);
expect(configContent).toContain("allowlist:");
expect(configContent).toContain("domain=api.anthropic.com");
expect(configContent).toContain("domain=api.openai.com");
expect(configContent).toContain("proxy_port: 8087");
// Verify Coder domain was auto-filled from data.coder_workspace.me
// (the placeholder should be replaced with the actual deployment domain).
expect(configContent).not.toContain("domain=your-deployment.coder.com");
// Verify $HOME was expanded in log_dir (should be absolute, not literal $HOME).
expect(configContent).toContain("log_dir: /home/coder/");
expect(configContent).not.toContain("$HOME");
// Check install log
const installLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/install.log`,
);
expect(installLog).toContain("Using coder boundary subcommand");
expect(installLog).toContain("Boundary config written to");
expect(installLog).toContain("boundary wrapper configured");
});
test("inline-config-written", async () => {
const customConfig =
"allowlist:\n - domain=custom.example.com\nlog_level: info\n";
const { id } = await setup({
moduleVariables: {
agent_firewall_config: customConfig,
},
});
await execModuleScript(id);
// Verify the inline config was written
const configContent = await readFileContainer(id, CONFIG_PATH);
expect(configContent).toContain("domain=custom.example.com");
expect(configContent).toContain("log_level: info");
});
test("config-path-skips-write", async () => {
const { id } = await setup({
moduleVariables: {
agent_firewall_config_path: "/workspace/external-config.yaml",
},
});
await execModuleScript(id);
// Verify NO config was written to the default path
const checkResult = await execContainer(id, ["test", "-f", CONFIG_PATH]);
expect(checkResult.exitCode).not.toBe(0);
// Check install log confirms skip
const installLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/install.log`,
);
expect(installLog).toContain(
"Using external boundary config, skipping config write",
);
});
// Note: Tests for use_agent_firewall_directly and
// compile_agent_firewall_from_source are skipped because they require
// network access (downloading boundary) or compilation which are too
// slow for unit tests. These modes are tested manually.
test("custom-hooks", async () => {
const preInstallMarker = "pre-install-executed";
const postInstallMarker = "post-install-executed";
const { id } = await setup({
moduleVariables: {
pre_install_script: `#!/bin/bash\necho '${preInstallMarker}'`,
post_install_script: `#!/bin/bash\necho '${postInstallMarker}'`,
},
});
await execModuleScript(id);
// Verify pre-install script ran
const preInstallLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/pre_install.log`,
);
expect(preInstallLog).toContain(preInstallMarker);
// Verify post-install script ran
const postInstallLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/post_install.log`,
);
expect(postInstallLog).toContain(postInstallMarker);
// Verify main install still ran
const installLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/install.log`,
);
expect(installLog).toContain("boundary wrapper configured");
});
test("no-env-vars", async () => {
const { coderEnvVars } = await setup();
// No env vars should be exported by this module.
expect(coderEnvVars["BOUNDARY_WRAPPER_PATH"]).toBeUndefined();
expect(coderEnvVars["BOUNDARY_CONFIG"]).toBeUndefined();
});
test("wrapper-script-execution", async () => {
const { id } = await setup();
await execModuleScript(id);
// Try executing the wrapper script with a command
const wrapperResult = await execContainer(id, [
"bash",
"-c",
`${WRAPPER_PATH} echo boundary-test`,
]);
// The wrapper passes the command directly to the boundary command
expect(wrapperResult.stdout).toContain("boundary-test");
});
test("installation-idempotency", async () => {
const { id } = await setup();
// Run the installation twice
await execModuleScript(id);
const firstInstallLog = await readFileContainer(
id,
`${MODULE_DIR}/logs/install.log`,
);
// Run again
const secondRun = await execModuleScript(id);
expect(secondRun.exitCode).toBe(0);
// Both runs should succeed
expect(firstInstallLog).toContain("boundary wrapper configured");
});
});

View File

@ -1,128 +0,0 @@
terraform {
required_version = ">= 1.9"
required_providers {
coder = {
source = "coder/coder"
version = ">= 2.5"
}
}
}
data "coder_workspace" "me" {}
variable "agent_id" {
type = string
description = "The ID of a Coder agent."
}
variable "agent_firewall_version" {
type = string
description = "Agent firewall version. When use_agent_firewall_directly is true, a release version should be provided or 'latest' for the latest release. When compile_agent_firewall_from_source is true, a valid git reference should be provided (tag, commit, branch)."
default = "latest"
}
variable "compile_agent_firewall_from_source" {
type = bool
description = "Whether to compile agent-firewall from source instead of using the official install script."
default = false
}
variable "use_agent_firewall_directly" {
type = bool
description = "Whether to use agent-firewall binary directly instead of `coder boundary` subcommand. When false (default), uses `coder boundary` subcommand. When true, installs and uses agent-firewall binary from release."
default = false
}
variable "agent_firewall_config" {
type = string
description = "Inline agent-firewall configuration content (YAML). Overrides the module's default config. Mutually exclusive with agent_firewall_config_path."
default = null
validation {
condition = !(var.agent_firewall_config != null && var.agent_firewall_config_path != null)
error_message = "Only one of agent_firewall_config or agent_firewall_config_path may be set."
}
}
variable "agent_firewall_config_path" {
type = string
description = "Path to an existing agent-firewall config file in the workspace. When set, no config is written and the agent_firewall_config_path output points to this path. Mutually exclusive with agent_firewall_config."
default = null
}
variable "pre_install_script" {
type = string
description = "Custom script to run before installing agent-firewall."
default = null
}
variable "post_install_script" {
type = string
description = "Custom script to run after installing agent-firewall."
default = null
}
variable "module_directory" {
type = string
description = "Directory where the agent-firewall module scripts will be located. Default is $HOME/.coder-modules/coder/agent-firewall."
default = "$HOME/.coder-modules/coder/agent-firewall"
}
locals {
boundary_wrapper_path = "${var.module_directory}/scripts/agent-firewall-wrapper.sh"
# Extract domain from the Coder access URL for the default config
# allowlist (e.g., "https://dev.coder.com/" -> "dev.coder.com").
coder_domain = try(regex("^https?://([^/:]+)", data.coder_workspace.me.access_url)[0], "")
# Config handling: resolve which config content to write and where
# agent_firewall_config_path output points to.
default_boundary_config = templatefile("${path.module}/config.yaml.tftpl", {
CODER_DOMAIN = local.coder_domain
BOUNDARY_LOG_DIR = "${var.module_directory}/logs/agent_firewall_logs"
})
boundary_config_content = var.agent_firewall_config != null ? var.agent_firewall_config : local.default_boundary_config
boundary_config_dir = "${var.module_directory}/config"
boundary_config_file_path = "${local.boundary_config_dir}/config.yaml"
effective_boundary_config_path = var.agent_firewall_config_path != null ? var.agent_firewall_config_path : local.boundary_config_file_path
write_boundary_config = var.agent_firewall_config_path == null
install_script = templatefile("${path.module}/scripts/install.sh.tftpl", {
BOUNDARY_VERSION = var.agent_firewall_version
COMPILE_BOUNDARY_FROM_SOURCE = tostring(var.compile_agent_firewall_from_source)
USE_BOUNDARY_DIRECTLY = tostring(var.use_agent_firewall_directly)
MODULE_DIR = var.module_directory
BOUNDARY_WRAPPER_PATH = local.boundary_wrapper_path
WRITE_BOUNDARY_CONFIG = tostring(local.write_boundary_config)
BOUNDARY_CONFIG_CONTENT_B64 = local.write_boundary_config ? base64encode(local.boundary_config_content) : ""
BOUNDARY_CONFIG_DIR = local.boundary_config_dir
BOUNDARY_CONFIG_FILE = local.boundary_config_file_path
})
}
module "coder_utils" {
source = "registry.coder.com/coder/coder-utils/coder"
version = "0.0.1"
agent_id = var.agent_id
display_name_prefix = "Agent Firewall"
module_directory = var.module_directory
pre_install_script = var.pre_install_script
post_install_script = var.post_install_script
install_script = local.install_script
}
output "agent_firewall_wrapper_path" {
description = "Path to the agent-firewall wrapper script."
value = local.boundary_wrapper_path
}
output "agent_firewall_config_path" {
description = "Effective path to the agent-firewall config file."
value = local.effective_boundary_config_path
}
output "scripts" {
description = "List of script names for coder exp sync coordination."
value = module.coder_utils.scripts
}

View File

@ -1,131 +0,0 @@
#!/bin/bash
# Sets up boundary for network isolation in Coder workspaces.
set -euo pipefail
BOUNDARY_VERSION='${BOUNDARY_VERSION}'
COMPILE_BOUNDARY_FROM_SOURCE='${COMPILE_BOUNDARY_FROM_SOURCE}'
USE_BOUNDARY_DIRECTLY='${USE_BOUNDARY_DIRECTLY}'
MODULE_DIR="${MODULE_DIR}"
BOUNDARY_WRAPPER_PATH="${BOUNDARY_WRAPPER_PATH}"
WRITE_BOUNDARY_CONFIG='${WRITE_BOUNDARY_CONFIG}'
BOUNDARY_CONFIG_CONTENT=$(echo -n '${BOUNDARY_CONFIG_CONTENT_B64}' | base64 -d | sed "s|\$HOME|$HOME|g")
BOUNDARY_CONFIG_DIR="${BOUNDARY_CONFIG_DIR}"
BOUNDARY_CONFIG_FILE="${BOUNDARY_CONFIG_FILE}"
printf "BOUNDARY_VERSION: %s\n" "$${BOUNDARY_VERSION}"
printf "COMPILE_BOUNDARY_FROM_SOURCE: %s\n" "$${COMPILE_BOUNDARY_FROM_SOURCE}"
printf "USE_BOUNDARY_DIRECTLY: %s\n" "$${USE_BOUNDARY_DIRECTLY}"
printf "MODULE_DIR: %s\n" "$${MODULE_DIR}"
printf "BOUNDARY_WRAPPER_PATH: %s\n" "$${BOUNDARY_WRAPPER_PATH}"
printf "WRITE_BOUNDARY_CONFIG: %s\n" "$${WRITE_BOUNDARY_CONFIG}"
printf "BOUNDARY_CONFIG_DIR: %s\n" "$${BOUNDARY_CONFIG_DIR}"
printf "BOUNDARY_CONFIG_FILE: %s\n" "$${BOUNDARY_CONFIG_FILE}"
validate_boundary_subcommand() {
if ! command -v coder > /dev/null 2>&1; then
echo "Error: 'coder' command not found. boundary cannot be enabled." >&2
exit 1
fi
local output
echo "Checking for license"
if ! output=$(coder boundary 2>&1); then
if echo "$${output}" | grep -qi "license is not entitled"; then
echo "Error: your Coder deployment is not licensed for the boundary feature." >&2
echo "$${output}" >&2
echo "" >&2
exit 1
fi
fi
}
# Install boundary binary if needed.
# Uses one of three strategies:
# 1. Compile from source (compile_boundary_from_source=true)
# 2. Install from release (use_boundary_directly=true)
# 3. Use coder boundary subcommand (default, no installation needed)
install_boundary() {
if [[ "$${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]]; then
echo "Compiling boundary from source (version: $${BOUNDARY_VERSION})"
# Remove existing boundary directory to allow re-running safely
if [[ -d boundary ]]; then
rm -rf boundary
fi
echo "Cloning boundary repository"
git clone https://github.com/coder/boundary.git
cd boundary || exit 1
git checkout "$${BOUNDARY_VERSION}"
make build
sudo cp boundary /usr/local/bin/
sudo chmod +x /usr/local/bin/boundary
cd - || exit 1
elif [[ "$${USE_BOUNDARY_DIRECTLY}" = "true" ]]; then
echo "Installing boundary using official install script (version: $${BOUNDARY_VERSION})"
curl -fsSL https://raw.githubusercontent.com/coder/boundary/main/install.sh | bash -s -- --version "$${BOUNDARY_VERSION}"
else
validate_boundary_subcommand
echo "Using coder boundary subcommand (provided by Coder)"
fi
}
# Write boundary config file if the module is responsible for it.
write_boundary_config() {
if [[ "$${WRITE_BOUNDARY_CONFIG}" != "true" ]]; then
echo "Using external boundary config, skipping config write."
return 0
fi
mkdir -p "$${BOUNDARY_CONFIG_DIR}"
echo "$${BOUNDARY_CONFIG_CONTENT}" > "$${BOUNDARY_CONFIG_FILE}"
echo "Boundary config written to $${BOUNDARY_CONFIG_FILE}"
}
# Set up boundary: install, write config, create wrapper script.
setup_boundary() {
echo "Setting up coder boundary..."
# Install boundary binary if needed
install_boundary
# Write boundary config
write_boundary_config
# Ensure the wrapper script directory exists.
mkdir -p "$(dirname "$${BOUNDARY_WRAPPER_PATH}")"
if [[ "$${COMPILE_BOUNDARY_FROM_SOURCE}" = "true" ]] || [[ "$${USE_BOUNDARY_DIRECTLY}" = "true" ]]; then
# Use boundary binary directly (from compilation or release installation)
cat > "$${BOUNDARY_WRAPPER_PATH}" << 'WRAPPER_EOF'
#!/usr/bin/env bash
set -euo pipefail
exec boundary "$@"
WRAPPER_EOF
else
# Use coder boundary subcommand (default)
# Copy coder binary to strip CAP_NET_ADMIN capabilities.
# This is necessary because boundary doesn't work with privileged binaries
# (you can't launch privileged binaries inside network namespaces unless
# you have sys_admin).
CODER_NO_CAPS="$${MODULE_DIR}/scripts/coder-no-caps"
if ! cp "$(command -v coder)" "$${CODER_NO_CAPS}"; then
echo "Error: Failed to copy coder binary to $${CODER_NO_CAPS}. boundary cannot be enabled." >&2
exit 1
fi
cat > "$${BOUNDARY_WRAPPER_PATH}" << 'WRAPPER_EOF'
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$${BASH_SOURCE[0]}")" && pwd)"
exec "$${SCRIPT_DIR}/coder-no-caps" boundary "$@"
WRAPPER_EOF
fi
chmod +x "$${BOUNDARY_WRAPPER_PATH}"
echo "boundary wrapper configured: $${BOUNDARY_WRAPPER_PATH}"
}
setup_boundary

View File

@ -1,38 +0,0 @@
#!/bin/bash
# Mock coder command for testing boundary module
# Handles: coder boundary [--help | <command>]
# Handles: coder exp sync [want|start|complete] (no-op for testing)
# Handle exp sync commands (no-op for testing)
if [[ "$1" == "exp" ]] && [[ "$2" == "sync" ]]; then
exit 0
fi
if [[ "$1" == "boundary" ]]; then
shift
# Handle --help flag
if [[ "$1" == "--help" ]]; then
cat << 'EOF'
boundary - Run commands in network isolation
Usage:
coder boundary [flags] -- <command> [args...]
Examples:
coder boundary -- curl https://example.com
coder boundary -- npm install
Flags:
-h, --help help for boundary
EOF
exit 0
fi
# Execute the remaining arguments as a command
exec "$@"
fi
echo "Mock coder: Unknown command: $*"
exit 1

View File

@ -13,7 +13,7 @@ Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/agent
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0" version = "5.1.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
anthropic_api_key = "xxxx-xxxxx-xxxx" anthropic_api_key = "xxxx-xxxxx-xxxx"
} }
@ -47,7 +47,7 @@ locals {
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0" version = "5.1.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = local.claude_workdir workdir = local.claude_workdir
anthropic_api_key = "xxxx-xxxxx-xxxx" anthropic_api_key = "xxxx-xxxxx-xxxx"
@ -78,7 +78,7 @@ resource "coder_app" "claude" {
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0" version = "5.1.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
enable_ai_gateway = true enable_ai_gateway = true
@ -95,33 +95,6 @@ Claude Code then routes API requests through Coder's AI Gateway instead of direc
> [!CAUTION] > [!CAUTION]
> `enable_ai_gateway = true` is mutually exclusive with `anthropic_api_key` and `claude_code_oauth_token`. Setting any of them together fails at plan time. > `enable_ai_gateway = true` is mutually exclusive with `anthropic_api_key` and `claude_code_oauth_token`. Setting any of them together fails at plan time.
### Enterprise policy via managed settings
The `managed_settings` input writes a policy file to `/etc/claude-code/managed-settings.d/10-coder.json` inside the workspace. Claude Code reads this directory at startup with the highest configuration precedence, so users cannot override these values in their own `~/.claude/settings.json`. This is a local file mechanism and works with any inference backend (Anthropic API, AWS Bedrock, Google Vertex AI, or AI Gateway).
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
managed_settings = {
permissions = {
defaultMode = "acceptEdits"
disableBypassPermissionsMode = "disable"
deny = ["Bash(curl:*)", "Bash(wget:*)", "WebFetch"]
}
env = {
DISABLE_TELEMETRY = "0"
}
}
}
```
See the [Claude Code settings reference](https://docs.anthropic.com/en/docs/claude-code/settings) for the full schema. Common keys: `permissions` (`defaultMode`, `allow`, `deny`, `disableBypassPermissionsMode`, `additionalDirectories`), `env`, `model`, `apiKeyHelper`, `hooks`, `cleanupPeriodDays`.
### Advanced Configuration ### Advanced Configuration
This example shows version pinning, a pre-installed binary path, a custom model, and MCP servers. This example shows version pinning, a pre-installed binary path, a custom model, and MCP servers.
@ -129,7 +102,7 @@ This example shows version pinning, a pre-installed binary path, a custom model,
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0" version = "5.1.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
@ -193,7 +166,7 @@ Downstream `coder_script` resources can wait for this module's install pipeline
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0" version = "5.1.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx" anthropic_api_key = "xxxx-xxxxx-xxxx"
@ -279,7 +252,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0" version = "5.1.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0" model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@ -336,7 +309,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0" version = "5.1.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514" model = "claude-sonnet-4@20250514"
@ -377,7 +350,7 @@ The module automatically tags every span and metric with `coder.workspace_id`, `
```tf ```tf
module "claude-code" { module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder" source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0" version = "5.1.0"
agent_id = coder_agent.main.id agent_id = coder_agent.main.id
workdir = "/home/coder/project" workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx" anthropic_api_key = "xxxx-xxxxx-xxxx"

View File

@ -382,13 +382,10 @@ describe("claude-code", async () => {
const parsed = JSON.parse(claudeConfig); const parsed = JSON.parse(claudeConfig);
expect(parsed.autoUpdaterStatus).toBe("disabled"); expect(parsed.autoUpdaterStatus).toBe("disabled");
expect(parsed.hasCompletedOnboarding).toBe(true); expect(parsed.hasCompletedOnboarding).toBe(true);
expect(parsed.bypassPermissionsModeAccepted).toBe(true);
expect(parsed.hasAcknowledgedCostThreshold).toBe(true); expect(parsed.hasAcknowledgedCostThreshold).toBe(true);
expect(parsed.projects[workdir].hasCompletedProjectOnboarding).toBe(true); expect(parsed.projects[workdir].hasCompletedProjectOnboarding).toBe(true);
expect(parsed.projects[workdir].hasTrustDialogAccepted).toBe(true); expect(parsed.projects[workdir].hasTrustDialogAccepted).toBe(true);
// Permission posture is delivered via /etc/claude-code/managed-settings.d/,
// not user-writable ~/.claude.json acceptance flags.
expect(parsed.bypassPermissionsModeAccepted).toBeUndefined();
expect(parsed.autoModeAccepted).toBeUndefined();
}); });
test("standalone-mode-with-oauth-token", async () => { test("standalone-mode-with-oauth-token", async () => {
@ -416,7 +413,7 @@ describe("claude-code", async () => {
); );
const parsed = JSON.parse(claudeConfig); const parsed = JSON.parse(claudeConfig);
expect(parsed.hasCompletedOnboarding).toBe(true); expect(parsed.hasCompletedOnboarding).toBe(true);
expect(parsed.bypassPermissionsModeAccepted).toBeUndefined(); expect(parsed.bypassPermissionsModeAccepted).toBe(true);
}); });
test("standalone-mode-no-auth", async () => { test("standalone-mode-no-auth", async () => {
@ -439,49 +436,6 @@ describe("claude-code", async () => {
expect(resp.stdout.trim()).toBe("ABSENT"); expect(resp.stdout.trim()).toBe("ABSENT");
}); });
test("claude-managed-settings-written", async () => {
const { id, scripts } = await setup({
moduleVariables: {
managed_settings: JSON.stringify({
permissions: {
defaultMode: "acceptEdits",
disableBypassPermissionsMode: "disable",
deny: ["Bash(rm -rf*)"],
},
}),
},
});
await runScripts(id, scripts);
const policy = await execContainer(id, [
"bash",
"-c",
"cat /etc/claude-code/managed-settings.d/10-coder.json",
]);
expect(policy.exitCode).toBe(0);
expect(policy.stdout).toContain('"defaultMode":"acceptEdits"');
expect(policy.stdout).toContain('"disableBypassPermissionsMode":"disable"');
expect(policy.stdout).toContain('"deny":["Bash(rm -rf*)"]');
const installLog = await readFileContainer(
id,
"/home/coder/.coder-modules/coder/claude-code/logs/install.log",
);
expect(installLog).toContain("Wrote Claude Code managed settings");
});
test("claude-managed-settings-not-set", async () => {
const { id, scripts } = await setup();
await runScripts(id, scripts);
const resp = await execContainer(id, [
"bash",
"-c",
"test -e /etc/claude-code/managed-settings.d/10-coder.json && echo EXISTS || echo ABSENT",
]);
expect(resp.stdout.trim()).toBe("ABSENT");
});
test("telemetry-otel", async () => { test("telemetry-otel", async () => {
const { coderEnvVars } = await setup({ const { coderEnvVars } = await setup({
moduleVariables: { moduleVariables: {

View File

@ -102,12 +102,6 @@ variable "claude_binary_path" {
} }
} }
variable "managed_settings" {
type = any
description = "Policy settings written to /etc/claude-code/managed-settings.d/10-coder.json. Highest-precedence client config; works with any inference backend (Anthropic API, Bedrock, Vertex, AI Gateway). See https://docs.anthropic.com/en/docs/claude-code/settings for the schema."
default = null
}
variable "enable_ai_gateway" { variable "enable_ai_gateway" {
type = bool type = bool
description = "Use AI Gateway for Claude Code. https://coder.com/docs/ai-coder/ai-gateway" description = "Use AI Gateway for Claude Code. https://coder.com/docs/ai-coder/ai-gateway"
@ -243,7 +237,6 @@ locals {
ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : "" ARG_MCP = var.mcp != "" ? base64encode(var.mcp) : ""
ARG_MCP_CONFIG_REMOTE_PATH = base64encode(jsonencode(var.mcp_config_remote_path)) ARG_MCP_CONFIG_REMOTE_PATH = base64encode(jsonencode(var.mcp_config_remote_path))
ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway) ARG_ENABLE_AI_GATEWAY = tostring(var.enable_ai_gateway)
ARG_MANAGED_SETTINGS_JSON = var.managed_settings != null ? base64encode(jsonencode(var.managed_settings)) : ""
}) })
module_dir_name = ".coder-modules/coder/claude-code" module_dir_name = ".coder-modules/coder/claude-code"
} }

View File

@ -283,47 +283,3 @@ run "test_workdir_optional" {
error_message = "workdir should default to null when omitted" error_message = "workdir should default to null when omitted"
} }
} }
run "test_managed_settings" {
command = plan
variables {
agent_id = "test-agent-managed-settings"
workdir = "/home/coder/project"
managed_settings = {
permissions = {
defaultMode = "acceptEdits"
disableBypassPermissionsMode = "disable"
deny = ["Bash(rm -rf*)"]
}
}
}
assert {
condition = var.managed_settings.permissions.defaultMode == "acceptEdits"
error_message = "managed_settings should accept the permissions object"
}
assert {
condition = strcontains(local.install_script, "/etc/claude-code/managed-settings.d")
error_message = "install script should reference the managed-settings.d drop-in directory"
}
assert {
condition = strcontains(local.install_script, base64encode(jsonencode(var.managed_settings)))
error_message = "install script should embed the base64-encoded managed_settings JSON"
}
}
run "test_managed_settings_default_null" {
command = plan
variables {
agent_id = "test-agent-managed-settings-default"
}
assert {
condition = var.managed_settings == null
error_message = "managed_settings should default to null when omitted"
}
}

View File

@ -17,7 +17,6 @@ ARG_CLAUDE_BINARY_PATH="$${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d) ARG_MCP=$(echo -n '${ARG_MCP}' | base64 -d)
ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n '${ARG_MCP_CONFIG_REMOTE_PATH}' | base64 -d) ARG_MCP_CONFIG_REMOTE_PATH=$(echo -n '${ARG_MCP_CONFIG_REMOTE_PATH}' | base64 -d)
ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}' ARG_ENABLE_AI_GATEWAY='${ARG_ENABLE_AI_GATEWAY}'
ARG_MANAGED_SETTINGS_JSON=$(echo -n '${ARG_MANAGED_SETTINGS_JSON}' | base64 -d)
export PATH="$${ARG_CLAUDE_BINARY_PATH}:$PATH" export PATH="$${ARG_CLAUDE_BINARY_PATH}:$PATH"
@ -30,7 +29,6 @@ printf "ARG_CLAUDE_BINARY_PATH: %s\n" "$${ARG_CLAUDE_BINARY_PATH}"
printf "ARG_MCP: %s\n" "$${ARG_MCP}" printf "ARG_MCP: %s\n" "$${ARG_MCP}"
printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$${ARG_MCP_CONFIG_REMOTE_PATH}" printf "ARG_MCP_CONFIG_REMOTE_PATH: %s\n" "$${ARG_MCP_CONFIG_REMOTE_PATH}"
printf "ARG_ENABLE_AI_GATEWAY: %s\n" "$${ARG_ENABLE_AI_GATEWAY}" printf "ARG_ENABLE_AI_GATEWAY: %s\n" "$${ARG_ENABLE_AI_GATEWAY}"
printf "ARG_MANAGED_SETTINGS_JSON: %s\n" "$${ARG_MANAGED_SETTINGS_JSON}"
echo "--------------------------------" echo "--------------------------------"
@ -146,32 +144,6 @@ function setup_claude_configurations() {
} }
function write_managed_settings() {
if [ -z "$${ARG_MANAGED_SETTINGS_JSON}" ]; then
return
fi
local dropin_dir="/etc/claude-code/managed-settings.d"
local target="$${dropin_dir}/10-coder.json"
if ! echo "$${ARG_MANAGED_SETTINGS_JSON}" | jq empty 2> /dev/null; then
echo "Warning: managed_settings is not valid JSON, skipping policy write"
return
fi
if command_exists sudo; then
sudo mkdir -p "$${dropin_dir}"
echo "$${ARG_MANAGED_SETTINGS_JSON}" | sudo tee "$${target}" > /dev/null
sudo chmod 0644 "$${target}"
else
mkdir -p "$${dropin_dir}"
echo "$${ARG_MANAGED_SETTINGS_JSON}" > "$${target}"
chmod 0644 "$${target}"
fi
echo "Wrote Claude Code managed settings to $${target}"
}
function configure_standalone_mode() { function configure_standalone_mode() {
echo "Configuring Claude Code for standalone mode..." echo "Configuring Claude Code for standalone mode..."
@ -186,6 +158,8 @@ function configure_standalone_mode() {
echo "Updating existing Claude configuration at $${claude_config}" echo "Updating existing Claude configuration at $${claude_config}"
jq '.autoUpdaterStatus = "disabled" | jq '.autoUpdaterStatus = "disabled" |
.autoModeAccepted = true |
.bypassPermissionsModeAccepted = true |
.hasAcknowledgedCostThreshold = true | .hasAcknowledgedCostThreshold = true |
.hasCompletedOnboarding = true' \ .hasCompletedOnboarding = true' \
"$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}" "$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}"
@ -194,6 +168,8 @@ function configure_standalone_mode() {
cat > "$${claude_config}" << EOF cat > "$${claude_config}" << EOF
{ {
"autoUpdaterStatus": "disabled", "autoUpdaterStatus": "disabled",
"autoModeAccepted": true,
"bypassPermissionsModeAccepted": true,
"hasAcknowledgedCostThreshold": true, "hasAcknowledgedCostThreshold": true,
"hasCompletedOnboarding": true "hasCompletedOnboarding": true
} }
@ -213,5 +189,4 @@ EOF
install_claude_code_cli install_claude_code_cli
setup_claude_configurations setup_claude_configurations
write_managed_settings
configure_standalone_mode configure_standalone_mode

View File

@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
} }
@ -28,7 +28,7 @@ module "git-clone" {
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
base_dir = "~/projects/coder" base_dir = "~/projects/coder"
@ -43,7 +43,7 @@ To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-prov
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
} }
@ -70,7 +70,7 @@ data "coder_parameter" "git_repo" {
module "git_clone" { module "git_clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = data.coder_parameter.git_repo.value url = data.coder_parameter.git_repo.value
} }
@ -105,7 +105,7 @@ Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `g
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.example.com/coder/coder/tree/feat/example" url = "https://github.example.com/coder/coder/tree/feat/example"
git_providers = { git_providers = {
@ -125,7 +125,7 @@ To GitLab clone with a specific branch like `feat/example`
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://gitlab.com/coder/coder/-/tree/feat/example" url = "https://gitlab.com/coder/coder/-/tree/feat/example"
} }
@ -137,7 +137,7 @@ Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com`
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://gitlab.example.com/coder/coder/-/tree/feat/example" url = "https://gitlab.example.com/coder/coder/-/tree/feat/example"
git_providers = { git_providers = {
@ -159,7 +159,7 @@ For example, to clone the `feat/example` branch:
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
branch_name = "feat/example" branch_name = "feat/example"
@ -177,7 +177,7 @@ For example, this will clone into the `~/projects/coder/coder-dev` folder:
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
folder_name = "coder-dev" folder_name = "coder-dev"
@ -196,36 +196,13 @@ If not defined, the default, `0`, performs a full clone.
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
depth = 1 depth = 1
} }
``` ```
## Pre-clone script
Run a custom script before cloning the repository by setting the `pre_clone_script` variable.
This is useful for preparing the environment or validating prerequisites before cloning.
```tf
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
pre_clone_script = <<-EOT
#!/bin/bash
echo "Preparing to clone repository..."
# Check prerequisites
command -v npm >/dev/null 2>&1 || { echo "npm is required but not installed."; exit 1; }
# Set up environment
export NODE_ENV=development
EOT
}
```
## Post-clone script ## Post-clone script
Run a custom script after cloning the repository by setting the `post_clone_script` variable. Run a custom script after cloning the repository by setting the `post_clone_script` variable.
@ -235,7 +212,7 @@ This is useful for running initialization tasks like installing dependencies or
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.2.3"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
post_clone_script = <<-EOT post_clone_script = <<-EOT

View File

@ -250,59 +250,15 @@ describe("git-clone", async () => {
const state = await runTerraformApply(import.meta.dir, { const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo", agent_id: "foo",
url: "fake-url", url: "fake-url",
base_dir: "/tmp",
post_clone_script: "echo 'Post-clone script executed'", post_clone_script: "echo 'Post-clone script executed'",
}); });
const output = await executeScriptInContainer( const output = await executeScriptInContainer(
state, state,
"alpine/git", "alpine/git",
"sh", "sh",
"mkdir -p /tmp/fake-url && echo 'existing' > /tmp/fake-url/file.txt", "mkdir -p ~/fake-url && echo 'existing' > ~/fake-url/file.txt",
); );
expect(output.stdout).toContain("Running post-clone script..."); expect(output.stdout).toContain("Running post-clone script...");
expect(output.stdout).toContain("Post-clone script executed"); expect(output.stdout).toContain("Post-clone script executed");
}); });
it("runs pre-clone script", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "fake-url",
pre_clone_script: "echo 'Pre-clone script executed'",
});
const output = await executeScriptInContainer(state, "alpine/git");
expect(output.stdout).toContain("Running pre-clone script...");
expect(output.stdout).toContain("Pre-clone script executed");
expect(output.stdout).toContain("Cloning fake-url to ~/fake-url...");
});
it("fails when pre-clone script fails", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "fake-url",
pre_clone_script: "echo 'Pre-clone script failed'; exit 42",
});
const output = await executeScriptInContainer(state, "alpine/git");
expect(output.exitCode).toBe(42);
expect(output.stdout).toContain("Running pre-clone script...");
expect(output.stdout).toContain("Pre-clone script failed");
expect(output.stdout).not.toContain("Cloning fake-url to ~/fake-url...");
});
it("fails when post-clone script fails", async () => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
url: "fake-url",
base_dir: "/tmp",
post_clone_script: "echo 'Post-clone script failed'; exit 43",
});
const output = await executeScriptInContainer(
state,
"alpine/git",
"sh",
"mkdir -p /tmp/fake-url && echo 'existing' > /tmp/fake-url/file.txt",
);
expect(output.exitCode).toBe(43);
expect(output.stdout).toContain("Running post-clone script...");
expect(output.stdout).toContain("Post-clone script failed");
});
}); });

View File

@ -68,12 +68,6 @@ variable "post_clone_script" {
default = null default = null
} }
variable "pre_clone_script" {
description = "Custom script to run before cloning the repository. Runs before git clone, even if the repository already exists."
type = string
default = null
}
locals { locals {
# Remove query parameters and fragments from the URL # Remove query parameters and fragments from the URL
url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "") url = replace(replace(var.url, "/\\?.*/", ""), "/#.*/", "")
@ -95,8 +89,6 @@ locals {
web_url = startswith(local.clone_url, "git@") ? replace(replace(local.clone_url, ":", "/"), "git@", "https://") : local.clone_url web_url = startswith(local.clone_url, "git@") ? replace(replace(local.clone_url, ":", "/"), "git@", "https://") : local.clone_url
# Encode the post_clone_script for passing to the shell script # Encode the post_clone_script for passing to the shell script
encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : "" encoded_post_clone_script = var.post_clone_script != null ? base64encode(var.post_clone_script) : ""
# Encode the pre_clone_script for passing to the shell script
encoded_pre_clone_script = var.pre_clone_script != null ? base64encode(var.pre_clone_script) : ""
} }
output "repo_dir" { output "repo_dir" {
@ -137,7 +129,6 @@ resource "coder_script" "git_clone" {
BRANCH_NAME : local.branch_name, BRANCH_NAME : local.branch_name,
DEPTH = var.depth, DEPTH = var.depth,
POST_CLONE_SCRIPT : local.encoded_post_clone_script, POST_CLONE_SCRIPT : local.encoded_post_clone_script,
PRE_CLONE_SCRIPT : local.encoded_pre_clone_script,
}) })
display_name = "Git Clone" display_name = "Git Clone"
icon = "/icon/git.svg" icon = "/icon/git.svg"

View File

@ -1,7 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail
REPO_URL="${REPO_URL}" REPO_URL="${REPO_URL}"
CLONE_PATH="${CLONE_PATH}" CLONE_PATH="${CLONE_PATH}"
BRANCH_NAME="${BRANCH_NAME}" BRANCH_NAME="${BRANCH_NAME}"
@ -9,7 +7,6 @@ BRANCH_NAME="${BRANCH_NAME}"
CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}" CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}"
DEPTH="${DEPTH}" DEPTH="${DEPTH}"
POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}" POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}"
PRE_CLONE_SCRIPT="${PRE_CLONE_SCRIPT}"
# Check if the variable is empty... # Check if the variable is empty...
if [ -z "$REPO_URL" ]; then if [ -z "$REPO_URL" ]; then
@ -36,16 +33,6 @@ if [ ! -d "$CLONE_PATH" ]; then
mkdir -p "$CLONE_PATH" mkdir -p "$CLONE_PATH"
fi fi
# Run pre-clone script if provided
if [ -n "$PRE_CLONE_SCRIPT" ]; then
echo "Running pre-clone script..."
PRE_CLONE_TMP=$(mktemp)
echo "$PRE_CLONE_SCRIPT" | base64 -d > "$PRE_CLONE_TMP"
chmod +x "$PRE_CLONE_TMP"
$PRE_CLONE_TMP
rm "$PRE_CLONE_TMP"
fi
# Check if the directory is empty # Check if the directory is empty
# and if it is, clone the repo, otherwise skip cloning # and if it is, clone the repo, otherwise skip cloning
if [ -z "$(ls -A "$CLONE_PATH")" ]; then if [ -z "$(ls -A "$CLONE_PATH")" ]; then