diff --git a/registry/coder/modules/agent-relay-claude-code/README.md b/registry/coder/modules/agent-relay-claude-code/README.md index 87180cf74..f9334a1c6 100644 --- a/registry/coder/modules/agent-relay-claude-code/README.md +++ b/registry/coder/modules/agent-relay-claude-code/README.md @@ -16,7 +16,7 @@ on each build and runs the Claude Code self-hosted runner. ```tf module "claude_code_runner" { source = "registry.coder.com/coder/agent-relay-claude-code/coder" - version = "0.4.0" + version = "0.5.0" agent_id = coder_agent.main.id # Downloads the Claude Code CLI at start when it is not in the image. Bake @@ -33,12 +33,21 @@ resource "coder_agent" "main" { interval = 10 timeout = 5 } + + metadata { + key = "agent_relay_session_attached" + display_name = "Session attached" + script = module.claude_code_runner.session_attached_metadata_script + interval = 30 + timeout = 5 + } } ``` The `agent_relay_status` metadata block is required. It has to live on the `coder_agent`, which the module cannot declare; the relay reads it to decide -when to reap the workspace. +when to reap the workspace. The `agent_relay_session_attached` block is +optional; see [Session attachment](#session-attachment). ## Requirements @@ -54,12 +63,12 @@ Use `additional_args` to pass extra arguments to `claude self-hosted-runner`, as list with one element per argument. They are appended after the flags the module sets and before `--exec-path`, in the order written. Each element is passed exactly as written, so a flag can repeat and a value can contain spaces or quotes without escaping. Flags the module already sets -(`--capacity`, `--base-dir`, `--exit-if-unused-min`, `--exec-path`) are rejected, with or without `=value`. +(`--capacity`, `--base-dir`, `--exit-if-unused-min`, `--health-port`, `--exec-path`) are rejected, with or without `=value`. ```tf module "claude_code_runner" { source = "registry.coder.com/coder/agent-relay-claude-code/coder" - version = "0.4.0" + version = "0.5.0" agent_id = coder_agent.main.id additional_args = ["--some-flag", "value", "--some-flag", "another value", "--another-flag"] } @@ -118,3 +127,42 @@ rather than reporting `working` forever. | `failed ` | runner could not start, e.g. `runner-agent-missing` when the CLI is absent | Renaming the `agent_relay_status` key breaks reaping. + +## Session attachment + +The runner serves `GET /healthz` and `GET /metrics` on `health_port` +(default `3286`, chosen over the CLI's `8080` to avoid common clashes). The `/healthz` body carries `active_sessions`, the number +of sessions the runner holds, including between turns. +`session_attached_metadata_script` reads it over loopback and prints one of: + +| value | runner running | session held | meaning | +| ------------ | -------------- | ------------ | ------------------------------------------------------------------ | +| `attached` | yes | yes | holds a session, including between turns | +| `unattached` | yes | no | waiting for its first session, or released one and is still alive | +| `stopped` | no | no | exited, died, never started, or the workspace was created manually | +| `disabled` | not checked | not checked | `health_port` is `0`, so the listener is off and nothing is read | +| error | yes | unknown | `/healthz` cannot be read or has no numeric `active_sessions` | + +Running means the pid the supervisor recorded in `runner-state` is alive +(`kill -0`) and its command line is still the CLI. The supervisor writes +`working ` when it starts the runner and `done ` when it exits, +so a clean exit, a crash, and a reused pid all read as `stopped`. + +An error is a non-zero exit, so the metadata sample carries an error +rather than a value and a consumer can tell unknown from any state above. +The listener starts only after the runner registers, so a fresh runner +reports an error briefly. The image needs `curl`. + +The CLI binds the listener on every interface and has no bind-host option. +Do not publish `health_port` through a `coder_app`, a container port, or a +Kubernetes Service, and make sure the workspace network keeps other hosts +from reaching it. + +```tf +module "claude_code_runner" { + source = "registry.coder.com/coder/agent-relay-claude-code/coder" + version = "0.5.0" + agent_id = coder_agent.main.id + health_port = 3290 +} +``` diff --git a/registry/coder/modules/agent-relay-claude-code/main.test.ts b/registry/coder/modules/agent-relay-claude-code/main.test.ts index 1174a331b..b5fbdd305 100644 --- a/registry/coder/modules/agent-relay-claude-code/main.test.ts +++ b/registry/coder/modules/agent-relay-claude-code/main.test.ts @@ -57,12 +57,14 @@ const setup = async (vars: Record = {}) => { }); const scripts = collectScripts(state); const statusScript = state.outputs.status_metadata_script.value as string; + const attachedScript = state.outputs.session_attached_metadata_script + .value as string; const id = await runContainer("lorello/alpine-bash"); registerCleanup(async () => { await removeContainer(id); }); await stubBinary(id, "/usr/local/bin/coder", "exit 0"); - return { id, scripts, statusScript }; + return { id, scripts, statusScript, attachedScript }; }; type Scripts = { install: string; start: string }; @@ -394,4 +396,127 @@ describe("agent-relay-claude-code", () => { const pwned = await execContainer(id, ["test", "-e", "/tmp/PWNED"]); expect(pwned.exitCode).not.toBe(0); }); + + describe("session_attached_metadata_script", () => { + // Stands in for the runner's listener. The body comes from a file the + // test writes; a missing file is a failed scrape, and any other URL is + // a wiring bug. + const stubHealthz = (id: string, url: string) => + stubBinary( + id, + "/usr/local/bin/curl", + [ + `case "$*" in *"${url}/healthz"*) ;; *) echo "unexpected curl $*" >&2; exit 7 ;; esac`, + "[ -f /tmp/healthz ] || exit 22", + "cat /tmp/healthz", + ].join("\n"), + ); + + const sample = async (id: string, script: string, body?: string) => { + if (body === undefined) { + await execContainer(id, ["rm", "-f", "/tmp/healthz"]); + } else { + await writeFileContainer(id, "/tmp/healthz", body, { user: "root" }); + } + return execContainer(id, ["bash", "-c", script]); + }; + + const runnerArgs = async (id: string) => + (await readFileContainer(id, "/tmp/claude-args")).split("\n"); + + it("reads active_sessions from the live runner's listener", async () => { + const { id, scripts, attachedScript } = await setup({ + health_port: "3290", + }); + await stubClaude(id, 'printf "%s\\n" "$@" >/tmp/claude-args; sleep 30'); + await stubHealthz(id, "http://127.0.0.1:3290"); + await runDispatched(id, scripts); + const args = await runnerArgs(id); + expect(args[args.indexOf("--health-port") + 1]).toBe("3290"); + + // The body shape a real 2.1.310 runner returns. + const held = await sample( + id, + attachedScript, + '{"status":"ok","runner_id":"ccrunner_x","active_sessions":1,"last_poll_at":"2026-10-26T20:22:54.950Z","last_poll_age_ms":24021}', + ); + expect(held.exitCode, held.stderr).toBe(0); + expect(held.stdout.trim()).toBe("attached"); + + const free = await sample( + id, + attachedScript, + '{"status":"ok","active_sessions":0}', + ); + expect(free.exitCode, free.stderr).toBe(0); + expect(free.stdout.trim()).toBe("unattached"); + + // Unknown is an error sample, never false, so the relay can tell a + // broken probe from an ended session. + const unreachable = await sample(id, attachedScript); + expect(unreachable.exitCode).not.toBe(0); + expect(unreachable.stdout.trim()).toBe(""); + + const malformed = await sample(id, attachedScript, '{"status":"ok"}'); + expect(malformed.exitCode).not.toBe(0); + expect(malformed.stdout.trim()).toBe(""); + }); + + it("reports stopped once the runner exits", async () => { + const { id, scripts, attachedScript } = await setup(); + await stubClaude(id, "exit 0"); + await stubHealthz(id, "http://127.0.0.1:3286"); + await runDispatched(id, scripts); + await waitForState(id, /^done \d+$/); + const result = await sample( + id, + attachedScript, + '{"status":"ok","active_sessions":1}', + ); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe("stopped"); + }); + + it("reports stopped without a live runner", async () => { + const { id, attachedScript } = await setup({ install_cli: "false" }); + await stubHealthz(id, "http://127.0.0.1:3286"); + await execContainer(id, ["mkdir", "-p", MODULE_DIR]); + // The listener would claim a session; with no live runner it is + // never consulted. "working 1" is a live pid that is not the CLI, + // as after pid reuse. + const body = '{"status":"ok","active_sessions":1}'; + for (const state of [ + undefined, + "idle", + "done 0", + "failed runner-agent-missing", + "working 999999", + "working 1", + ]) { + if (state === undefined) { + await execContainer(id, ["rm", "-f", STATE_FILE]); + } else { + await writeFileContainer(id, STATE_FILE, `${state}\n`, { + user: "root", + }); + } + const result = await sample(id, attachedScript, body); + expect(result.exitCode, `${state}: ${result.stderr}`).toBe(0); + expect(result.stdout.trim(), String(state)).toBe("stopped"); + } + }); + + it("reports disabled when health_port is 0", async () => { + const { id, scripts, attachedScript } = await setup({ health_port: "0" }); + await stubClaude(id, 'printf "%s\\n" "$@" >/tmp/claude-args; sleep 30'); + await stubBinary(id, "/usr/local/bin/curl", "exit 99"); + await runDispatched(id, scripts); + // The CLI listens on 8080 unless told 0. + const args = await runnerArgs(id); + expect(args[args.indexOf("--health-port") + 1]).toBe("0"); + const result = await execContainer(id, ["bash", "-c", attachedScript]); + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout.trim()).toBe("disabled"); + }); + }); }); diff --git a/registry/coder/modules/agent-relay-claude-code/main.tf b/registry/coder/modules/agent-relay-claude-code/main.tf index 6d355d6b8..adf786fd0 100644 --- a/registry/coder/modules/agent-relay-claude-code/main.tf +++ b/registry/coder/modules/agent-relay-claude-code/main.tf @@ -93,6 +93,17 @@ variable "exit_if_unused_min" { } } +variable "health_port" { + description = "Port for the runner's /healthz and /metrics HTTP listener (the CLI's --health-port). session_attached_metadata_script reads it. The CLI binds every interface and has no bind-host option, so never publish this port. 0 disables the listener, and session_attached_metadata_script then reports disabled." + type = number + default = 3286 + + validation { + condition = var.health_port >= 0 && var.health_port <= 65535 && floor(var.health_port) == var.health_port + error_message = "health_port must be a whole number from 0 to 65535, 0 to disable." + } +} + variable "serving_log_pattern" { type = string default = "Picked up session" @@ -109,12 +120,12 @@ variable "additional_args" { error_message = "additional_args elements must be non-empty and must not contain newlines." } - # The module already sets these, from base_dir, exit_if_unused_min, and the - # permissions wrapper. Keep this list in sync with the `self-hosted-runner` - # flags in start.sh.tftpl. + # The module already sets these, from base_dir, exit_if_unused_min, + # health_port, and the permissions wrapper. Keep this list in sync with the + # `self-hosted-runner` flags in start.sh.tftpl. validation { - condition = !anytrue([for a in var.additional_args : contains(["--capacity", "--base-dir", "--exit-if-unused-min", "--exec-path"], split("=", a)[0])]) - error_message = "additional_args must not set --capacity, --base-dir, --exit-if-unused-min, or --exec-path; the module sets them (use base_dir and exit_if_unused_min instead)." + condition = !anytrue([for a in var.additional_args : contains(["--capacity", "--base-dir", "--exit-if-unused-min", "--health-port", "--exec-path"], split("=", a)[0])]) + error_message = "additional_args must not set --capacity, --base-dir, --exit-if-unused-min, --health-port, or --exec-path; the module sets them (use base_dir, exit_if_unused_min, and health_port instead)." } } @@ -248,6 +259,10 @@ locals { # tree so one directory holds everything a debugger needs. module_directory = "$HOME/.coder-modules/coder/agent-relay-claude-code" + # The listener binds every interface; loopback is how the workspace's own + # scripts reach it. + health_url = var.health_port > 0 ? "http://127.0.0.1:${var.health_port}" : "" + install_script = templatefile("${path.module}/install.sh.tftpl", { cli_binary = var.cli_binary install_cli = var.install_cli @@ -260,6 +275,7 @@ locals { log_file = var.log_file base_dir = var.base_dir exit_if_unused_min = var.exit_if_unused_min + health_port = var.health_port additional_args = base64encode(join("\n", var.additional_args)) }) } @@ -289,6 +305,20 @@ output "status_metadata_script" { }) } +output "session_attached_metadata_script" { + description = "Script body for the agent_relay_session_attached agent metadata item. Prints attached, unattached, stopped, or disabled; see README. Fails when a running runner's listener cannot be read, so an unknown sample is an error." + value = templatefile("${path.module}/session_attached.sh.tftpl", { + cli_binary = var.cli_binary + state_file = var.state_file + health_url = local.health_url + }) +} + +output "health_url" { + description = "Loopback URL of the runner's /healthz and /metrics listener, or empty when health_port is 0." + value = local.health_url +} + output "scripts" { description = "Ordered list of coder exp sync names produced by this module, in run order. A template can `coder exp sync want ` to run its own scripts after the runner is up." value = module.coder_utils.scripts diff --git a/registry/coder/modules/agent-relay-claude-code/main.tftest.hcl b/registry/coder/modules/agent-relay-claude-code/main.tftest.hcl index 673b7c3ac..996fee281 100644 --- a/registry/coder/modules/agent-relay-claude-code/main.tftest.hcl +++ b/registry/coder/modules/agent-relay-claude-code/main.tftest.hcl @@ -311,3 +311,68 @@ run "serving_log_pattern_is_data" { error_message = "serving_log_pattern must be base64-encoded and matched with grep -F" } } + +run "health_port_default" { + command = plan + + # Not the CLI's 8080, which a template image is likely to use already. + # Passed explicitly so the URL the attachment script reads always matches + # the port the runner binds. + assert { + condition = strcontains(local.start_script, "--health-port 3286") && output.health_url == "http://127.0.0.1:3286" + error_message = "the runner must serve /healthz on 3286 by default, not the CLI's 8080" + } + + assert { + condition = strcontains(output.session_attached_metadata_script, "health_url=\"http://127.0.0.1:3286\"") && strcontains(output.session_attached_metadata_script, var.state_file) + error_message = "the attachment script must read the runner's listener and state file" + } +} + +run "health_port_overridden" { + command = plan + + variables { + health_port = 3290 + } + + assert { + condition = strcontains(local.start_script, "--health-port 3290") && output.health_url == "http://127.0.0.1:3290" && strcontains(output.session_attached_metadata_script, "http://127.0.0.1:3290") + error_message = "health_port must select the port the runner binds and the attachment script reads" + } +} + +run "health_port_disabled" { + command = plan + + variables { + health_port = 0 + } + + # The CLI listens on 8080 unless told 0, so disabling must pass 0, and + # the attachment script must say why it cannot observe a session. + assert { + condition = strcontains(local.start_script, "--health-port 0") && output.health_url == "" && strcontains(output.session_attached_metadata_script, "health_url=\"\"") + error_message = "health_port = 0 must disable the listener and the attachment probe" + } +} + +run "health_port_rejects_out_of_range" { + command = plan + + variables { + health_port = 70000 + } + + expect_failures = [var.health_port] +} + +run "additional_args_rejects_health_port" { + command = plan + + variables { + additional_args = ["--health-port=9000"] + } + + expect_failures = [var.additional_args] +} diff --git a/registry/coder/modules/agent-relay-claude-code/session_attached.sh.tftpl b/registry/coder/modules/agent-relay-claude-code/session_attached.sh.tftpl new file mode 100644 index 000000000..1126d208f --- /dev/null +++ b/registry/coder/modules/agent-relay-claude-code/session_attached.sh.tftpl @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Rendered into the agent_relay_session_attached agent metadata item (see +# README). Prints one line that Agent Relay reads to decide whether to keep +# the workspace's autostop deadline moving: +# +# attached the runner is running and holds a session, including +# between turns +# unattached the runner is running and holds no session +# stopped the runner is not running: it exited, died, never started, +# or the workspace was created manually +# disabled health_port is 0, so attachment cannot be observed +# +# When the runner is running but its listener cannot be read, the script +# exits non-zero instead. The sample is then an error, which the relay +# treats as unknown rather than as any of the values above. +# +# Running means the pid the supervisor recorded is alive and is still the +# CLI it launched. The supervisor writes "working " after it starts +# the runner and replaces it with "done " when the runner exits. +set -euo pipefail + +state_file="${state_file}" +health_url="${health_url}" + +if [ -z "$health_url" ]; then + echo "disabled" + exit 0 +fi + +# kill -0 alone would trust a reused pid; the cmdline check ties it to the +# CLI the supervisor launched. +runner_alive() { + [ -n "$${1:-}" ] && kill -0 "$1" 2>/dev/null && + tr '\0' ' ' <"/proc/$1/cmdline" 2>/dev/null | grep -qF -- "$(basename "${cli_binary}")" +} + +state="" +detail="" +if [ -f "$state_file" ]; then + read -r state detail <"$state_file" || true +fi +if [ "$state" != "working" ] || ! runner_alive "$detail"; then + echo "stopped" + exit 0 +fi + +if ! command -v curl >/dev/null 2>&1; then + echo "curl is required to read $health_url/healthz" >&2 + exit 1 +fi + +# The listener starts once the runner registers, so a fresh runner fails +# here briefly. That reads as unknown, which is correct. +if ! body="$(curl -fsS --max-time 3 "$health_url/healthz")"; then + echo "cannot read $health_url/healthz" >&2 + exit 1 +fi + +active="$(printf '%s' "$body" | grep -oE '"active_sessions"[[:space:]]*:[[:space:]]*[0-9]+' | grep -oE '[0-9]+$' || true)" +if [ -z "$active" ]; then + echo "$health_url/healthz has no numeric active_sessions" >&2 + exit 1 +fi + +if [ "$active" -gt 0 ]; then + echo "attached" +else + echo "unattached" +fi diff --git a/registry/coder/modules/agent-relay-claude-code/start.sh.tftpl b/registry/coder/modules/agent-relay-claude-code/start.sh.tftpl index 760b9cbbf..e38af2f96 100644 --- a/registry/coder/modules/agent-relay-claude-code/start.sh.tftpl +++ b/registry/coder/modules/agent-relay-claude-code/start.sh.tftpl @@ -103,6 +103,7 @@ ${cli_binary} self-hosted-runner \\ %{ if exit_if_unused_min > 0 ~} --exit-if-unused-min ${exit_if_unused_min} \\ %{ endif ~} + --health-port ${health_port} \\ $additional_args \\ --exec-path "$wrapper" >"$log_file" 2>&1 & runner_pid=\$!