mirror of
https://github.com/coder/registry.git
synced 2026-06-07 15:08:15 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| db433e4d34 | |||
| 99f3524160 | |||
| 99510a1f75 |
@@ -0,0 +1,73 @@
|
||||
---
|
||||
display_name: Claude Code self-hosted runner
|
||||
description: Run Anthropic's Claude Code self-hosted runner as a long-lived process inside a Coder workspace, with per-workspace scoped self-eviction so the prebuild reconciler keeps the pool warm.
|
||||
icon: ../../../../.icons/claude.svg
|
||||
verified: false
|
||||
tags: [ai, claude, claude-code, anthropic, runner]
|
||||
---
|
||||
|
||||
# Claude Code self-hosted runner
|
||||
|
||||
Drops Anthropic's [Claude Code self-hosted runner](https://docs.anthropic.com/en/docs/claude-code/self-hosted-runners) into any Coder template that has a `coder_agent` and a workspace image with the runner binary installed (`/usr/local/bin/claude self-hosted-runner` by default).
|
||||
|
||||
The module owns the runner script (writes a per-session wrapper that forces `--permission-mode bypassPermissions`, then spawns a detached supervisor that runs the runner in the foreground and POSTs a delete build to self-evict on drain), the agent environment variables it needs, an optional bot-git askpass setup, and a host Docker socket gid fixup. Agent metadata items (lock status, active sessions, runner ID, last poll) are emitted via the `agent_metadata` output for the parent to splat into a `dynamic "metadata"` block.
|
||||
|
||||
The parent template still owns the `coder_agent` itself, the per-workspace scope-restricted self-evict token (minted via the `Mastercard/restapi` provider against an admin bootstrap token), the prebuild preset, and the infra block (`docker_container`, `kubernetes_pod`, etc.).
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This module is part of the [Claude Code self-hosted runners on Coder](https://coder.com/docs/ai-coder/claude-code-self-hosted-runners) recipe, which currently targets Anthropic's EAP build of the runner. Both the runner binary and the wire contract are still evolving; expect API drift until Anthropic ships GA.
|
||||
|
||||
## Usage
|
||||
|
||||
```tf
|
||||
module "claude_self_hosted_runner" {
|
||||
source = "registry.coder.com/coder-labs/claude-self-hosted-runner/coder"
|
||||
version = "1.0.0"
|
||||
|
||||
agent_id = coder_agent.main.id
|
||||
workspace_id = data.coder_workspace.me.id
|
||||
pool_secret = var.pool_secret
|
||||
self_evict_token = jsondecode(restapi_object.self_evict_token.api_response).key
|
||||
git_bot_token = var.git_bot_token
|
||||
capacity = tonumber(data.coder_parameter.capacity.value)
|
||||
}
|
||||
|
||||
resource "coder_agent" "main" {
|
||||
# ... arch, os, dir, startup_script_behavior, etc.
|
||||
|
||||
# Static metadata blocks coexist with the dynamic block below;
|
||||
# Terraform concatenates them on the same coder_agent.
|
||||
metadata {
|
||||
display_name = "CPU"
|
||||
key = "cpu"
|
||||
script = "top -bn1 | awk '/Cpu/ {print $2 \"%\"}'"
|
||||
interval = 10
|
||||
timeout = 5
|
||||
}
|
||||
|
||||
dynamic "metadata" {
|
||||
for_each = module.claude_self_hosted_runner.agent_metadata
|
||||
content {
|
||||
display_name = metadata.value.display_name
|
||||
key = metadata.value.key
|
||||
interval = metadata.value.interval
|
||||
timeout = metadata.value.timeout
|
||||
script = metadata.value.script
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## What the module does
|
||||
|
||||
- Writes `$HOME/.claude/wrapper.sh` at agent start. The wrapper appends `--permission-mode bypassPermissions` after `"$@"` so unattended sessions never stall on a tool-approval prompt; Claude Code's flag parser is last-occurrence-wins, so this overrides the server-supplied permission mode.
|
||||
- Sets up the runner's required environment (`CLAUDE_POOL_SECRET`, `CLAUDE_CAPACITY`, `GIT_BOT_TOKEN`, `CODER_SELF_TOKEN`, `CODER_WORKSPACE_ID`) via `coder_env` resources on the agent.
|
||||
- Spawns a `setsid nohup` supervisor that runs the runner in the foreground. When the runner exits on drain, the supervisor POSTs `/api/v2/workspaces/{id}/builds` with `{"transition":"delete"}` to self-evict, so Coder's prebuild reconciler can queue a replacement.
|
||||
- Wires up `GIT_ASKPASS` if `git_bot_token` is supplied so the runner's child claude can `git push` without baking credentials into the image.
|
||||
- If the parent template mounts the host Docker socket at `/var/run/docker.sock` and the gid does not match the in-container `docker` group, chgrps the socket so the workspace user can use it without sudo.
|
||||
|
||||
## Self-eviction security model
|
||||
|
||||
The `self_evict_token` input is minted by the parent template via the `Mastercard/restapi` provider at template build time, against an admin bootstrap token that lives in Terraform state and is never injected into the workspace. The minted token is scoped to `workspace:delete + workspace:read + template:read + user:read` and allow-listed to this single workspace's UUID. A leaked copy can do exactly one thing: delete this one workspace. No read of peer prebuilds, no SSH, no external auth, no git creds.
|
||||
|
||||
The supervisor uses raw `curl` against `/api/v2/workspaces/{id}/builds`, not the `coder delete` CLI. The CLI fetches workspace resources first, which fails against the scoped token whose allow-list intersection excludes peer workspaces.
|
||||
@@ -0,0 +1,185 @@
|
||||
terraform {
|
||||
required_version = ">= 1.5"
|
||||
|
||||
required_providers {
|
||||
coder = {
|
||||
source = "coder/coder"
|
||||
version = ">= 2.13"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
variable "agent_id" {
|
||||
type = string
|
||||
description = "The ID of a Coder agent."
|
||||
}
|
||||
|
||||
variable "workspace_id" {
|
||||
type = string
|
||||
description = "data.coder_workspace.me.id from the parent template. Used by the supervisor to self-evict via the workspace builds endpoint."
|
||||
}
|
||||
|
||||
variable "pool_secret" {
|
||||
type = string
|
||||
description = "Claude Code self-hosted runner pool secret (from claude.ai)."
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "self_evict_token" {
|
||||
type = string
|
||||
description = "Per-workspace, scope-restricted Coder API token. Scope = workspace:delete + workspace:read + template:read + user:read, allow_list = this workspace's UUID. A leaked copy can only delete this one workspace. The parent template mints it via the Mastercard/restapi provider at build time."
|
||||
sensitive = true
|
||||
}
|
||||
|
||||
variable "git_bot_token" {
|
||||
type = string
|
||||
description = "Optional git PAT for the bot identity. Wired through GIT_ASKPASS so the runner's child claude can push without baking credentials into the image."
|
||||
sensitive = true
|
||||
default = ""
|
||||
}
|
||||
|
||||
variable "capacity" {
|
||||
type = number
|
||||
description = "Maximum sessions the runner serves at once. The runner locks to one Anthropic user; this caps parallelism within that user's queue."
|
||||
default = 4
|
||||
validation {
|
||||
condition = var.capacity >= 1 && var.capacity <= 16
|
||||
error_message = "capacity must be between 1 and 16."
|
||||
}
|
||||
}
|
||||
|
||||
variable "runner_binary_path" {
|
||||
type = string
|
||||
description = "Path to the `claude self-hosted-runner` binary inside the workspace."
|
||||
default = "/usr/local/bin/claude"
|
||||
}
|
||||
|
||||
variable "claude_binary_path" {
|
||||
type = string
|
||||
description = "Path to the Claude Code binary the wrapper execs for each session."
|
||||
default = "/opt/claude/claude"
|
||||
}
|
||||
|
||||
variable "order" {
|
||||
type = number
|
||||
description = "Order of the runner script in the agent UI."
|
||||
default = null
|
||||
}
|
||||
|
||||
resource "coder_env" "pool_secret" {
|
||||
agent_id = var.agent_id
|
||||
name = "CLAUDE_POOL_SECRET"
|
||||
value = var.pool_secret
|
||||
}
|
||||
|
||||
resource "coder_env" "capacity" {
|
||||
agent_id = var.agent_id
|
||||
name = "CLAUDE_CAPACITY"
|
||||
value = tostring(var.capacity)
|
||||
}
|
||||
|
||||
resource "coder_env" "git_bot_token" {
|
||||
agent_id = var.agent_id
|
||||
name = "GIT_BOT_TOKEN"
|
||||
value = var.git_bot_token
|
||||
}
|
||||
|
||||
resource "coder_env" "self_token" {
|
||||
agent_id = var.agent_id
|
||||
name = "CODER_SELF_TOKEN"
|
||||
value = var.self_evict_token
|
||||
}
|
||||
|
||||
resource "coder_env" "workspace_id" {
|
||||
agent_id = var.agent_id
|
||||
name = "CODER_WORKSPACE_ID"
|
||||
value = var.workspace_id
|
||||
}
|
||||
|
||||
resource "coder_script" "claude_runner" {
|
||||
agent_id = var.agent_id
|
||||
display_name = "Claude self-hosted runner"
|
||||
icon = "/icon/code.svg"
|
||||
run_on_start = true
|
||||
start_blocks_login = false
|
||||
script = templatefile("${path.module}/scripts/run.sh", {
|
||||
CLAUDE_BINARY_PATH = var.claude_binary_path
|
||||
RUNNER_BINARY_PATH = var.runner_binary_path
|
||||
})
|
||||
}
|
||||
|
||||
# Agent metadata items. The parent splats this list into a
|
||||
# `dynamic "metadata"` block on its own `coder_agent` because nested
|
||||
# blocks cannot be injected from a module. Scraped from the runner's
|
||||
# local /healthz and /metrics endpoints; this is the only window a
|
||||
# Coder admin has into who the Anthropic pool has bound this workspace
|
||||
# to (the runner does not expose the locked user's email over its
|
||||
# local endpoints; that lives in claude.ai > Self-hosted runner pools).
|
||||
output "agent_metadata" {
|
||||
description = "List of agent metadata items the parent template should splat into a `dynamic \"metadata\"` block on its coder_agent."
|
||||
value = [
|
||||
{
|
||||
display_name = "Lock status"
|
||||
key = "0_lock_status"
|
||||
interval = 10
|
||||
timeout = 5
|
||||
# The runner does not expose its locked state via /metrics or
|
||||
# /healthz in the current BYOC build, so we infer it from
|
||||
# active_sessions and latch a sticky flag on disk: once a
|
||||
# session has landed, the runner is locked to that Anthropic
|
||||
# user for its entire lifetime per Anthropic's spec, even when
|
||||
# the active count drops back to zero between sessions.
|
||||
script = <<-EOT
|
||||
flag="$HOME/.claude/locked"
|
||||
active=$(curl -fsS http://127.0.0.1:8080/healthz 2>/dev/null \
|
||||
| jq -r '.active_sessions // 0')
|
||||
if [ "$${active:-0}" -gt 0 ] && [ ! -f "$flag" ]; then
|
||||
touch "$flag" 2>/dev/null || true
|
||||
fi
|
||||
if [ -f "$flag" ]; then
|
||||
printf 'locked'
|
||||
else
|
||||
printf 'unlocked'
|
||||
fi
|
||||
EOT
|
||||
},
|
||||
{
|
||||
display_name = "Active sessions"
|
||||
key = "1_active_sessions"
|
||||
interval = 5
|
||||
timeout = 5
|
||||
script = <<-EOT
|
||||
active=$(curl -fsS http://127.0.0.1:8080/healthz 2>/dev/null \
|
||||
| jq -r '.active_sessions // empty')
|
||||
if [ -z "$active" ]; then echo '?'; exit 0; fi
|
||||
printf '%s / %s' "$active" "$${CLAUDE_CAPACITY:-1}"
|
||||
EOT
|
||||
},
|
||||
{
|
||||
display_name = "Runner ID"
|
||||
key = "2_runner_id"
|
||||
interval = 30
|
||||
timeout = 5
|
||||
script = <<-EOT
|
||||
curl -fsS http://127.0.0.1:8080/healthz 2>/dev/null \
|
||||
| jq -r '.runner_id // "(starting)"'
|
||||
EOT
|
||||
},
|
||||
{
|
||||
display_name = "Last Anthropic poll"
|
||||
key = "3_last_poll"
|
||||
interval = 15
|
||||
timeout = 5
|
||||
script = <<-EOT
|
||||
age=$(curl -fsS http://127.0.0.1:8080/healthz 2>/dev/null \
|
||||
| jq -r '.last_poll_age_ms // empty')
|
||||
if [ -z "$age" ]; then echo '?'; exit 0; fi
|
||||
if [ "$age" -lt 30000 ]; then
|
||||
printf 'ok (%sms ago)' "$age"
|
||||
else
|
||||
printf 'stale (%ss ago)' $((age/1000))
|
||||
fi
|
||||
EOT
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
run "plan_with_required_vars" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workspace_id = "test-workspace"
|
||||
pool_secret = "test-pool-secret"
|
||||
self_evict_token = "test-self-token"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(resource.coder_env.pool_secret.value) > 0
|
||||
error_message = "pool_secret env should be set"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_env.capacity.value == "4"
|
||||
error_message = "default capacity should be 4"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_script.claude_runner.display_name == "Claude self-hosted runner"
|
||||
error_message = "expected the runner coder_script display_name"
|
||||
}
|
||||
}
|
||||
|
||||
run "custom_capacity_and_binary_paths" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workspace_id = "test-workspace"
|
||||
pool_secret = "test-pool-secret"
|
||||
self_evict_token = "test-self-token"
|
||||
capacity = 8
|
||||
claude_binary_path = "/custom/claude"
|
||||
runner_binary_path = "/custom/runner"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_env.capacity.value == "8"
|
||||
error_message = "capacity input should flow into CLAUDE_CAPACITY env"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.claude_runner.script, "/custom/claude")
|
||||
error_message = "claude_binary_path should appear in the rendered script"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = strcontains(resource.coder_script.claude_runner.script, "/custom/runner")
|
||||
error_message = "runner_binary_path should appear in the rendered script"
|
||||
}
|
||||
}
|
||||
|
||||
run "git_bot_token_optional" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workspace_id = "test-workspace"
|
||||
pool_secret = "test-pool-secret"
|
||||
self_evict_token = "test-self-token"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = resource.coder_env.git_bot_token.value == ""
|
||||
error_message = "git_bot_token should default to empty string"
|
||||
}
|
||||
}
|
||||
|
||||
run "capacity_validation_rejects_zero" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workspace_id = "test-workspace"
|
||||
pool_secret = "test-pool-secret"
|
||||
self_evict_token = "test-self-token"
|
||||
capacity = 0
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.capacity,
|
||||
]
|
||||
}
|
||||
|
||||
run "capacity_validation_rejects_high" {
|
||||
command = plan
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workspace_id = "test-workspace"
|
||||
pool_secret = "test-pool-secret"
|
||||
self_evict_token = "test-self-token"
|
||||
capacity = 17
|
||||
}
|
||||
|
||||
expect_failures = [
|
||||
var.capacity,
|
||||
]
|
||||
}
|
||||
|
||||
run "agent_metadata_output_has_four_items" {
|
||||
command = apply
|
||||
|
||||
variables {
|
||||
agent_id = "test-agent"
|
||||
workspace_id = "test-workspace"
|
||||
pool_secret = "test-pool-secret"
|
||||
self_evict_token = "test-self-token"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = length(output.agent_metadata) == 4
|
||||
error_message = "agent_metadata should expose four scraping items"
|
||||
}
|
||||
|
||||
assert {
|
||||
condition = output.agent_metadata[0].key == "0_lock_status"
|
||||
error_message = "first metadata item should be lock_status"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env bash
|
||||
# Wires up everything the Claude Code self-hosted runner needs at agent
|
||||
# start, then spawns a detached supervisor that keeps the runner alive
|
||||
# and self-evicts on drain.
|
||||
#
|
||||
# Runtime env (set by coder_env in main.tf):
|
||||
# CLAUDE_POOL_SECRET Anthropic pool secret (mandatory).
|
||||
# CLAUDE_CAPACITY Max parallel sessions per runner (default 1).
|
||||
# GIT_BOT_TOKEN Optional bot PAT for GIT_ASKPASS.
|
||||
# CODER_SELF_TOKEN Per-workspace scope-restricted Coder API token.
|
||||
# CODER_WORKSPACE_ID This workspace's UUID, used by self-eviction.
|
||||
# CODER_AGENT_URL Set by the Coder agent itself.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
CLAUDE_BINARY_PATH='${CLAUDE_BINARY_PATH}'
|
||||
RUNNER_BINARY_PATH='${RUNNER_BINARY_PATH}'
|
||||
|
||||
if [ -z "$${CLAUDE_POOL_SECRET:-}" ]; then
|
||||
echo "CLAUDE_POOL_SECRET is empty. Set the pool_secret input on the module."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
install -d -m 0700 "$HOME/.claude"
|
||||
|
||||
# --- Bot git askpass ----------------------------------------------------
|
||||
if [ -n "$${GIT_BOT_TOKEN:-}" ]; then
|
||||
install -d -m 0700 "$HOME/.git-creds"
|
||||
cat > "$HOME/.git-creds/askpass.sh" << 'ASK'
|
||||
#!/bin/sh
|
||||
printf '%s' "$GIT_BOT_TOKEN"
|
||||
ASK
|
||||
chmod 0500 "$HOME/.git-creds/askpass.sh"
|
||||
git config --global core.askPass "$HOME/.git-creds/askpass.sh"
|
||||
git config --global credential.helper ''
|
||||
fi
|
||||
|
||||
# --- Host Docker socket gid fixup --------------------------------------
|
||||
if [ -S /var/run/docker.sock ]; then
|
||||
sock_gid=$(stat -c %g /var/run/docker.sock)
|
||||
docker_gid=$(getent group docker | cut -d: -f3 || true)
|
||||
if [ -n "$${docker_gid:-}" ] && [ "$${sock_gid}" != "$${docker_gid}" ]; then
|
||||
sudo chgrp "$${docker_gid}" /var/run/docker.sock 2> /dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- Pool secret on disk -----------------------------------------------
|
||||
POOL_SECRET_FILE="$HOME/.claude/pool-secret"
|
||||
rm -f "$POOL_SECRET_FILE"
|
||||
umask 077
|
||||
printf '%s' "$${CLAUDE_POOL_SECRET}" > "$POOL_SECRET_FILE"
|
||||
chmod 0400 "$POOL_SECRET_FILE"
|
||||
|
||||
# --- Wrapper script -----------------------------------------------------
|
||||
# Runner execs this once per session, appending its server-computed
|
||||
# flags. Claude Code's flag parser is last-occurrence-wins, so flags
|
||||
# after "$@" win. Force --permission-mode bypassPermissions so
|
||||
# unattended sessions never stall on a tool-approval prompt.
|
||||
WRAPPER="$HOME/.claude/wrapper.sh"
|
||||
{
|
||||
echo '#!/bin/bash'
|
||||
echo "exec $${CLAUDE_BINARY_PATH} \"\$@\" --permission-mode bypassPermissions"
|
||||
} > "$WRAPPER"
|
||||
chmod 0755 "$WRAPPER"
|
||||
|
||||
# --- Supervisor --------------------------------------------------------
|
||||
# Runs the runner in the foreground; on runner exit POSTs a delete
|
||||
# build to self-evict. Raw curl, not `coder delete`: the CLI fetches
|
||||
# workspace resources first, which fails with the per-workspace
|
||||
# scoped token whose allow-list excludes peer prebuilds.
|
||||
#
|
||||
# Single-quoted heredoc, so nothing is expanded by the outer shell.
|
||||
# The supervisor reads its env vars (CODER_SELF_TOKEN, CODER_AGENT_URL,
|
||||
# etc.) at runtime, when it's invoked under setsid.
|
||||
SUPERVISOR="$HOME/.claude/supervisor.sh"
|
||||
cat > "$SUPERVISOR" << SUP
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
exec >>"\$HOME/.claude/supervisor.log" 2>&1
|
||||
echo "[supervisor] start \$(date -Is)"
|
||||
|
||||
$${RUNNER_BINARY_PATH} self-hosted-runner \\
|
||||
--pool-secret-file "\$HOME/.claude/pool-secret" \\
|
||||
--capacity "\$${CLAUDE_CAPACITY:-1}" \\
|
||||
--log-file "\$HOME/.claude/runner.log" \\
|
||||
--exec-path "\$HOME/.claude/wrapper.sh"
|
||||
echo "[supervisor] runner exited rc=\$? \$(date -Is)"
|
||||
|
||||
if [ -z "\$${CODER_SELF_TOKEN:-}" ]; then
|
||||
echo "[supervisor] CODER_SELF_TOKEN is empty; skipping self-eviction."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
http_code=\$(curl -s -o /tmp/evict.out -w "%%{http_code}" \\
|
||||
-X POST \\
|
||||
-H "Coder-Session-Token: \$CODER_SELF_TOKEN" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{"transition":"delete"}' \\
|
||||
"\$CODER_AGENT_URL/api/v2/workspaces/\$CODER_WORKSPACE_ID/builds")
|
||||
if [ "\$http_code" = "201" ]; then
|
||||
echo "[supervisor] self-eviction queued (HTTP 201)."
|
||||
else
|
||||
echo "[supervisor] self-eviction failed (HTTP \$http_code): \$(head -c 300 /tmp/evict.out)"
|
||||
fi
|
||||
SUP
|
||||
chmod 0700 "$SUPERVISOR"
|
||||
|
||||
# Detach with setsid + nohup. The supervisor reopens stdout/stderr to
|
||||
# its own logfile; redirect all standard fds here to /dev/null so this
|
||||
# script's exit doesn't drag the supervisor with it.
|
||||
setsid nohup "$SUPERVISOR" < /dev/null > /dev/null 2>&1 &
|
||||
disown
|
||||
|
||||
echo "Runner spawned as detached supervisor (pid=$!). See ~/.claude/supervisor.log."
|
||||
@@ -0,0 +1,146 @@
|
||||
---
|
||||
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)
|
||||
@@ -0,0 +1,157 @@
|
||||
# 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,
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
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
|
||||
@@ -0,0 +1,376 @@
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,128 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/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
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user