Compare commits

..

1 Commits

24 changed files with 60 additions and 1484 deletions
+11 -6
View File
@@ -50,16 +50,16 @@ variable "sessions" {
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
display_name = "tmux"
icon = "/icon/terminal.svg"
script = templatefile("${path.module}/scripts/run.sh", {
TMUX_CONFIG = base64encode(var.tmux_config)
install_script = templatefile("${path.module}/scripts/run.sh", {
TMUX_CONFIG = base64encode(var.tmux_config)
SAVE_INTERVAL = var.save_interval
})
run_on_start = true
run_on_stop = false
module_directory = "$HOME/.coder-modules/anomaly/tmux"
}
resource "coder_app" "tmux_sessions" {
@@ -76,3 +76,8 @@ resource "coder_app" "tmux_sessions" {
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
}
+1 -1
View File
@@ -101,7 +101,7 @@ module "codex" {
preferred_auth_method = "apikey"
EOT
mcp = <<-EOT
additional_mcp_servers = <<-EOT
[mcp_servers.GitHub]
command = "npx"
args = ["-y", "@modelcontextprotocol/server-github"]
@@ -246,7 +246,7 @@ describe("codex", async () => {
].join("\n");
const { id, scripts } = await setup({
moduleVariables: {
mcp: additional,
additional_mcp_servers: additional,
},
});
await runScripts(id, scripts);
+6 -6
View File
@@ -50,8 +50,8 @@ variable "install_codex" {
variable "codex_version" {
type = string
description = "The version of Codex to install."
default = "latest"
description = "The version of Codex to install. Empty string installs the latest available version."
default = ""
}
variable "openai_api_key" {
@@ -75,16 +75,16 @@ variable "base_config_toml" {
trust_level = "trusted"
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
default config. Include them in your custom config if needed.
EOT
default = ""
}
variable "mcp" {
variable "additional_mcp_servers" {
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 = ""
}
@@ -140,7 +140,7 @@ locals {
ARG_CODEX_VERSION = var.codex_version != "" ? base64encode(var.codex_version) : ""
ARG_WORKDIR = local.workdir != "" ? base64encode(local.workdir) : ""
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_AIBRIDGE_CONFIG = var.enable_ai_gateway ? base64encode(local.aibridge_config) : ""
ARG_MODEL_REASONING_EFFORT = var.model_reasoning_effort
@@ -12,7 +12,7 @@ ARG_INSTALL='${ARG_INSTALL}'
ARG_CODEX_VERSION=$(echo -n '${ARG_CODEX_VERSION}' | base64 -d)
ARG_WORKDIR=$(echo -n '${ARG_WORKDIR}' | 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_AIBRIDGE_CONFIG=$(echo -n '${ARG_AIBRIDGE_CONFIG}' | base64 -d)
ARG_MODEL_REASONING_EFFORT='${ARG_MODEL_REASONING_EFFORT}'
@@ -150,9 +150,9 @@ function populate_config_toml() {
write_minimal_default_config "$${config_path}"
fi
if [ -n "$${ARG_MCP}" ]; then
printf "Adding MCP servers\n"
echo "$${ARG_MCP}" >> "$${config_path}"
if [ -n "$${ARG_ADDITIONAL_MCP_SERVERS}" ]; then
printf "Adding additional MCP servers\n"
echo "$${ARG_ADDITIONAL_MCP_SERVERS}" >> "$${config_path}"
fi
if [ "$${ARG_ENABLE_AI_GATEWAY}" = "true" ] && [ -n "$${ARG_AIBRIDGE_CONFIG}" ]; then
+4 -20
View File
@@ -13,7 +13,7 @@ Run [Gemini CLI](https://github.com/google-gemini/gemini-cli) in your workspace
```tf
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.1"
version = "3.0.0"
agent_id = coder_agent.main.id
folder = "/home/coder/project"
}
@@ -46,7 +46,7 @@ variable "gemini_api_key" {
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.1"
version = "3.0.0"
agent_id = coder_agent.main.id
gemini_api_key = var.gemini_api_key
folder = "/home/coder/project"
@@ -94,7 +94,7 @@ data "coder_parameter" "ai_prompt" {
module "gemini" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.1"
version = "3.0.0"
agent_id = coder_agent.main.id
gemini_api_key = var.gemini_api_key
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 MUST REPORT ALL TASKS TO CODER.
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
module "gemini" {
source = "registry.coder.com/coder-labs/gemini/coder"
version = "3.0.1"
version = "3.0.0"
agent_id = coder_agent.main.id
gemini_api_key = var.gemini_api_key
folder = "/home/coder/project"
+8 -2
View File
@@ -148,16 +148,22 @@ locals {
base_extensions = <<-EOT
{
"coder": {
"command": "coder",
"args": [
"exp",
"mcp",
"server"
],
"command": "coder",
"description": "Report ALL tasks and statuses (in progress, done, failed) you are working on.",
"enabled": true,
"env": {
"CODER_MCP_APP_STATUS_SLUG": "${local.app_slug}",
"CODER_MCP_AI_AGENTAPI_URL": "http://localhost:3284"
}
},
"name": "Coder",
"timeout": 3000,
"type": "stdio",
"trust": true
}
}
EOT
@@ -17,7 +17,6 @@ echo "--------------------------------"
printf "gemini_config: %s\n" "$ARG_GEMINI_CONFIG"
printf "install: %s\n" "$ARG_INSTALL"
printf "gemini_version: %s\n" "$ARG_GEMINI_VERSION"
printf "BASE_EXTENSIONS: %s\n" "$BASE_EXTENSIONS"
echo "--------------------------------"
set +o nounset
@@ -141,25 +140,6 @@ function add_system_prompt_if_exists() {
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() {
export CODER_MCP_APP_STATUS_SLUG="gemini"
export CODER_MCP_AI_AGENTAPI_URL="http://localhost:3284"
@@ -169,5 +149,4 @@ function configure_mcp() {
install_gemini
populate_settings_json
add_system_prompt_if_exists
patch_coder_mcp_command
configure_mcp
@@ -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)
@@ -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,
]
}
@@ -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
@@ -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");
});
});
@@ -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
}
@@ -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
@@ -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
+8 -35
View File
@@ -13,7 +13,7 @@ Install and configure the [Claude Code](https://docs.anthropic.com/en/docs/agent
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0"
version = "5.1.0"
agent_id = coder_agent.main.id
anthropic_api_key = "xxxx-xxxxx-xxxx"
}
@@ -47,7 +47,7 @@ locals {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = local.claude_workdir
anthropic_api_key = "xxxx-xxxxx-xxxx"
@@ -78,7 +78,7 @@ resource "coder_app" "claude" {
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
enable_ai_gateway = true
@@ -95,33 +95,6 @@ Claude Code then routes API requests through Coder's AI Gateway instead of direc
> [!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.
### 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
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
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
@@ -193,7 +166,7 @@ Downstream `coder_script` resources can wait for this module's install pipeline
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
@@ -279,7 +252,7 @@ resource "coder_env" "bedrock_api_key" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "global.anthropic.claude-sonnet-4-5-20250929-v1:0"
@@ -336,7 +309,7 @@ resource "coder_env" "google_application_credentials" {
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
model = "claude-sonnet-4@20250514"
@@ -377,7 +350,7 @@ The module automatically tags every span and metric with `coder.workspace_id`, `
```tf
module "claude-code" {
source = "registry.coder.com/coder/claude-code/coder"
version = "5.2.0"
version = "5.1.0"
agent_id = coder_agent.main.id
workdir = "/home/coder/project"
anthropic_api_key = "xxxx-xxxxx-xxxx"
@@ -382,13 +382,10 @@ describe("claude-code", async () => {
const parsed = JSON.parse(claudeConfig);
expect(parsed.autoUpdaterStatus).toBe("disabled");
expect(parsed.hasCompletedOnboarding).toBe(true);
expect(parsed.bypassPermissionsModeAccepted).toBe(true);
expect(parsed.hasAcknowledgedCostThreshold).toBe(true);
expect(parsed.projects[workdir].hasCompletedProjectOnboarding).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 () => {
@@ -416,7 +413,7 @@ describe("claude-code", async () => {
);
const parsed = JSON.parse(claudeConfig);
expect(parsed.hasCompletedOnboarding).toBe(true);
expect(parsed.bypassPermissionsModeAccepted).toBeUndefined();
expect(parsed.bypassPermissionsModeAccepted).toBe(true);
});
test("standalone-mode-no-auth", async () => {
@@ -439,49 +436,6 @@ describe("claude-code", async () => {
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 () => {
const { coderEnvVars } = await setup({
moduleVariables: {
@@ -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" {
type = bool
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_CONFIG_REMOTE_PATH = base64encode(jsonencode(var.mcp_config_remote_path))
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"
}
@@ -283,47 +283,3 @@ run "test_workdir_optional" {
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"
}
}
@@ -17,7 +17,6 @@ ARG_CLAUDE_BINARY_PATH="$${ARG_CLAUDE_BINARY_PATH//\$HOME/$HOME}"
ARG_MCP=$(echo -n '${ARG_MCP}' | 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_MANAGED_SETTINGS_JSON=$(echo -n '${ARG_MANAGED_SETTINGS_JSON}' | base64 -d)
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_CONFIG_REMOTE_PATH: %s\n" "$${ARG_MCP_CONFIG_REMOTE_PATH}"
printf "ARG_ENABLE_AI_GATEWAY: %s\n" "$${ARG_ENABLE_AI_GATEWAY}"
printf "ARG_MANAGED_SETTINGS_JSON: %s\n" "$${ARG_MANAGED_SETTINGS_JSON}"
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() {
echo "Configuring Claude Code for standalone mode..."
@@ -186,6 +158,8 @@ function configure_standalone_mode() {
echo "Updating existing Claude configuration at $${claude_config}"
jq '.autoUpdaterStatus = "disabled" |
.autoModeAccepted = true |
.bypassPermissionsModeAccepted = true |
.hasAcknowledgedCostThreshold = true |
.hasCompletedOnboarding = true' \
"$${claude_config}" > "$${claude_config}.tmp" && mv "$${claude_config}.tmp" "$${claude_config}"
@@ -194,6 +168,8 @@ function configure_standalone_mode() {
cat > "$${claude_config}" << EOF
{
"autoUpdaterStatus": "disabled",
"autoModeAccepted": true,
"bypassPermissionsModeAccepted": true,
"hasAcknowledgedCostThreshold": true,
"hasCompletedOnboarding": true
}
@@ -213,5 +189,4 @@ EOF
install_claude_code_cli
setup_claude_configurations
write_managed_settings
configure_standalone_mode
+11 -34
View File
@@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
}
@@ -28,7 +28,7 @@ module "git-clone" {
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://github.com/coder/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" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
}
@@ -70,7 +70,7 @@ data "coder_parameter" "git_repo" {
module "git_clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
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" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://github.example.com/coder/coder/tree/feat/example"
git_providers = {
@@ -125,7 +125,7 @@ To GitLab clone with a specific branch like `feat/example`
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
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" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://gitlab.example.com/coder/coder/-/tree/feat/example"
git_providers = {
@@ -159,7 +159,7 @@ For example, to clone the `feat/example` branch:
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
branch_name = "feat/example"
@@ -177,7 +177,7 @@ For example, this will clone into the `~/projects/coder/coder-dev` folder:
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
folder_name = "coder-dev"
@@ -196,36 +196,13 @@ If not defined, the default, `0`, performs a full clone.
module "git-clone" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
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.0"
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
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" {
count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.0"
version = "1.2.3"
agent_id = coder_agent.example.id
url = "https://github.com/coder/coder"
post_clone_script = <<-EOT
@@ -261,16 +261,4 @@ describe("git-clone", async () => {
expect(output.stdout).toContain("Running post-clone script...");
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...");
});
});
-9
View File
@@ -68,12 +68,6 @@ variable "post_clone_script" {
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 {
# Remove query parameters and fragments from the 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
# 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) : ""
# 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" {
@@ -137,7 +129,6 @@ resource "coder_script" "git_clone" {
BRANCH_NAME : local.branch_name,
DEPTH = var.depth,
POST_CLONE_SCRIPT : local.encoded_post_clone_script,
PRE_CLONE_SCRIPT : local.encoded_pre_clone_script,
})
display_name = "Git Clone"
icon = "/icon/git.svg"
-11
View File
@@ -7,7 +7,6 @@ BRANCH_NAME="${BRANCH_NAME}"
CLONE_PATH="$${CLONE_PATH/#\~/$${HOME}}"
DEPTH="${DEPTH}"
POST_CLONE_SCRIPT="${POST_CLONE_SCRIPT}"
PRE_CLONE_SCRIPT="${PRE_CLONE_SCRIPT}"
# Check if the variable is empty...
if [ -z "$REPO_URL" ]; then
@@ -34,16 +33,6 @@ if [ ! -d "$CLONE_PATH" ]; then
mkdir -p "$CLONE_PATH"
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
# and if it is, clone the repo, otherwise skip cloning
if [ -z "$(ls -A "$CLONE_PATH")" ]; then