Compare commits

..

2 Commits

Author SHA1 Message Date
Ben Potter
db433e4d34 fix(coder-labs/claude-self-hosted-runner): infer lock status from active_sessions
The previous metadata script scraped
claude_code_self_hosted_runner_locked_account from /metrics. In
Anthropic BYOC build 2.1.97-byoc.9 the runner declares the HELP/TYPE
lines for that gauge but never emits a sample line, even when the
runner is actively serving sessions. The metadata item always
showed 'unlocked' regardless of state, which is wrong: a runner
with active_sessions > 0 has been locked to an Anthropic user for
its lifetime per Anthropic's spec.

Switch to inferring the lock status from /healthz's
active_sessions, and latch a sticky flag at $HOME/.claude/locked
on first observation of active_sessions > 0. Once locked, always
locked, even when the active count drops back to zero between
sessions of the same locked user.

Module tests still pass (6/6). Live verification will follow on the
bpmct/coder-dev-tunnel deployment.
2026-05-18 14:20:43 +00:00
Ben Potter
99f3524160 feat(coder-labs/claude-self-hosted-runner): scaffold module
Drops Anthropic's Claude Code self-hosted runner into any Coder
template that has a coder_agent and a workspace image with the
runner binary installed. 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 env it needs, optional bot-git askpass, and 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.

terraform test passes (6/6); rendered run.sh shellchecks clean. This
module is referenced from the Coder docs guide at
docs/ai-coder/claude-code-self-hosted-runners but is not yet
published to the registry; the docs reference the GitHub source
directly until the EAP recipe stabilizes.
2026-05-14 01:03:57 +00:00
17 changed files with 528 additions and 225 deletions

View File

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

View File

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

View File

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

View File

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

Before

Width:  |  Height:  |  Size: 339 B

View File

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

Before

Width:  |  Height:  |  Size: 336 B

View File

@ -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.

View File

@ -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
},
]
}

View File

@ -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"
}
}

View File

@ -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."

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,7 +14,7 @@ This module allows you to automatically clone a repository by URL and skip if it
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.3.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
} }
@ -28,7 +28,7 @@ module "git-clone" {
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.3.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
base_dir = "~/projects/coder" base_dir = "~/projects/coder"
@ -43,7 +43,7 @@ To use with [Git Authentication](https://coder.com/docs/v2/latest/admin/git-prov
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.3.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
} }
@ -70,7 +70,7 @@ data "coder_parameter" "git_repo" {
module "git_clone" { module "git_clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.3.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = data.coder_parameter.git_repo.value url = data.coder_parameter.git_repo.value
} }
@ -105,7 +105,7 @@ Configuring `git-clone` for a self-hosted GitHub Enterprise Server running at `g
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.3.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.example.com/coder/coder/tree/feat/example" url = "https://github.example.com/coder/coder/tree/feat/example"
git_providers = { git_providers = {
@ -125,7 +125,7 @@ To GitLab clone with a specific branch like `feat/example`
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.3.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://gitlab.com/coder/coder/-/tree/feat/example" url = "https://gitlab.com/coder/coder/-/tree/feat/example"
} }
@ -137,7 +137,7 @@ Configuring `git-clone` for a self-hosted GitLab running at `gitlab.example.com`
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.3.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://gitlab.example.com/coder/coder/-/tree/feat/example" url = "https://gitlab.example.com/coder/coder/-/tree/feat/example"
git_providers = { git_providers = {
@ -159,7 +159,7 @@ For example, to clone the `feat/example` branch:
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.3.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
branch_name = "feat/example" branch_name = "feat/example"
@ -177,7 +177,7 @@ For example, this will clone into the `~/projects/coder/coder-dev` folder:
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.3.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
folder_name = "coder-dev" folder_name = "coder-dev"
@ -196,7 +196,7 @@ If not defined, the default, `0`, performs a full clone.
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.3.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
depth = 1 depth = 1
@ -212,7 +212,7 @@ This is useful for preparing the environment or validating prerequisites before
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.3.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
pre_clone_script = <<-EOT pre_clone_script = <<-EOT
@ -235,7 +235,7 @@ This is useful for running initialization tasks like installing dependencies or
module "git-clone" { module "git-clone" {
count = data.coder_workspace.me.start_count count = data.coder_workspace.me.start_count
source = "registry.coder.com/coder/git-clone/coder" source = "registry.coder.com/coder/git-clone/coder"
version = "1.3.1" version = "1.3.0"
agent_id = coder_agent.example.id agent_id = coder_agent.example.id
url = "https://github.com/coder/coder" url = "https://github.com/coder/coder"
post_clone_script = <<-EOT post_clone_script = <<-EOT

View File

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

View File

@ -1,7 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail
REPO_URL="${REPO_URL}" REPO_URL="${REPO_URL}"
CLONE_PATH="${CLONE_PATH}" CLONE_PATH="${CLONE_PATH}"
BRANCH_NAME="${BRANCH_NAME}" BRANCH_NAME="${BRANCH_NAME}"