Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 52 additions & 4 deletions registry/coder/modules/agent-relay-claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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"]
}
Expand Down Expand Up @@ -118,3 +127,42 @@ rather than reporting `working` forever.
| `failed <reason>` | 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 <pid>` when it starts the runner and `done <code>` 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
}
```
127 changes: 126 additions & 1 deletion registry/coder/modules/agent-relay-claude-code/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,12 +57,14 @@ const setup = async (vars: Record<string, string> = {}) => {
});
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 };
Expand Down Expand Up @@ -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");
});
});
});
40 changes: 35 additions & 5 deletions registry/coder/modules/agent-relay-claude-code/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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)."
}
}

Expand Down Expand Up @@ -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
Expand All @@ -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))
})
}
Expand Down Expand Up @@ -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 <self> <these>` to run its own scripts after the runner is up."
value = module.coder_utils.scripts
Expand Down
65 changes: 65 additions & 0 deletions registry/coder/modules/agent-relay-claude-code/main.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -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]
}
Loading
Loading