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
28 changes: 26 additions & 2 deletions registry/coder/modules/agent-relay-claude-code/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ 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`, `--health-port`, `--exec-path`) are rejected, with or without `=value`.
(`--capacity`, `--base-dir`, `--exit-if-unused-min`, `--release-idle-session-min`, `--health-port`, `--exec-path`) are rejected, with or without `=value`.

```tf
module "claude_code_runner" {
Expand Down Expand Up @@ -105,7 +105,7 @@ runner. Everything lands under `$HOME/.coder-modules/coder/agent-relay-claude-co

## Runner lifecycle

The start step launches `claude self-hosted-runner --capacity 1 --exit-if-unused-min 10` detached and exits,
The start step launches `claude self-hosted-runner --capacity 1 --exit-if-unused-min 10 --release-idle-session-min 60` detached and exits,
so the agent reaches `ready` immediately. The runner is wrapped so every
session runs with `--permission-mode bypassPermissions`; there is no terminal
attached, so an approval prompt would hang it. When the runner exits, Agent
Expand All @@ -128,6 +128,30 @@ rather than reporting `working` forever.

Renaming the `agent_relay_status` key breaks reaping.

## Idle session release

An attached session whose user sends nothing for 60 minutes is released: the
runner parks the session server-side and exits, and Agent Relay deletes the
workspace. The user's next message resumes the session on a fresh runner.
This is the CLI's `--release-idle-session-min`.

`release_idle_session_min` sets the template's default, from `0` to `10080`
minutes. Agent Relay can override it per build by stamping the
`agent_relay_claude_code_release_idle_session_min` parameter, whose default is
the variable, so a relay that does not stamp it gets the template's value. `0`
omits the flag and keeps the CLI's default of never releasing. The value
reaches the runner through the environment, and a value outside the range
fails the start step with `failed invalid-release-idle-session-min`.

```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
release_idle_session_min = 30
}
```

## Session attachment

The runner serves `GET /healthz` and `GET /metrics` on `health_port`
Expand Down
138 changes: 132 additions & 6 deletions registry/coder/modules/agent-relay-claude-code/main.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import {
afterEach,
beforeAll,
Expand All @@ -8,6 +9,7 @@ import {
} from "bun:test";
import {
execContainer,
findResourceInstance,
readFileContainer,
removeContainer,
runContainer,
Expand Down Expand Up @@ -50,23 +52,44 @@ const DISPATCH_ENV = [
"SELF_HOSTED_RUNNER_LOCK_TO_ACCOUNT=acct-123",
];

const setup = async (vars: Record<string, string> = {}) => {
const state = await runTerraformApply(import.meta.dir, {
agent_id: "foo",
...vars,
});
const setup = async (
vars: Record<string, string> = {},
customEnv?: Record<string, string>,
) => {
const state = await runTerraformApply(
import.meta.dir,
{
agent_id: "foo",
...vars,
},
customEnv,
);
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;
// The value the agent would export from the coder_env resource.
const releaseIdleEnv = `AGENT_RELAY_CLAUDE_CODE_RELEASE_IDLE_SESSION_MIN=${
findResourceInstance(
state,
"coder_env",
"agent_relay_claude_code_release_idle_session_min",
).value
}`;
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, attachedScript };
return { id, scripts, statusScript, attachedScript, releaseIdleEnv };
};

// How coderd hands a build's parameter values to the coder provider, so a
// test can stamp a parameter the way Agent Relay does.
const stampParameter = (name: string, value: string) => ({
[`CODER_PARAMETER_${createHash("sha256").update(name).digest("hex")}`]: value,
});

type Scripts = { install: string; start: string };

// coder-utils owns the coder_script resources; find ours by display name.
Expand Down Expand Up @@ -254,6 +277,8 @@ describe("agent-relay-claude-code", () => {
expect(args[0]).toBe("self-hosted-runner");
expect(args[args.indexOf("--capacity") + 1]).toBe("1");
expect(args[args.indexOf("--exit-if-unused-min") + 1]).toBe("10");
// Without the agent's coder_env value, the module default applies.
expect(args[args.indexOf("--release-idle-session-min") + 1]).toBe("60");
expect(args[args.indexOf("--exec-path") + 1]).toBe(
`${MODULE_DIR}/wrapper.sh`,
);
Expand Down Expand Up @@ -397,6 +422,107 @@ describe("agent-relay-claude-code", () => {
expect(pwned.exitCode).not.toBe(0);
});

describe("release_idle_session_min", () => {
const PARAMETER = "agent_relay_claude_code_release_idle_session_min";

// Starts a dispatched runner with the environment the agent would
// export and returns the argv the CLI received.
const runnerArgv = async (
vars: Record<string, string> = {},
customEnv?: Record<string, string>,
) => {
const { id, scripts, releaseIdleEnv } = await setup(vars, customEnv);
await stubClaude(id, 'printf "%s\\n" "$@" >/tmp/claude-args; sleep 30');
const { start } = await runScripts(id, scripts, [
...DISPATCH_ENV,
releaseIdleEnv,
]);
expect(start.exitCode, start.stdout).toBe(0);
return (await readFileContainer(id, "/tmp/claude-args")).split("\n");
};

const releaseValues = (args: string[]) =>
args.flatMap((arg, i) =>
arg === "--release-idle-session-min" ? [args[i + 1]] : [],
);

it("releases an idle session after 60 minutes by default", async () => {
const args = await runnerArgv();
expect(releaseValues(args)).toEqual(["60"]);
});

it("uses the module variable", async () => {
const args = await runnerArgv({ release_idle_session_min: "15" });
expect(releaseValues(args)).toEqual(["15"]);
});

it("omits the flag when 0", async () => {
const args = await runnerArgv({ release_idle_session_min: "0" });
expect(args[0]).toBe("self-hosted-runner");
expect(releaseValues(args)).toEqual([]);
});

it("prefers the value Agent Relay stamps on the build", async () => {
const stamped = await runnerArgv({}, stampParameter(PARAMETER, "30"));
expect(releaseValues(stamped)).toEqual(["30"]);

const disabled = await runnerArgv(
{ release_idle_session_min: "15" },
stampParameter(PARAMETER, "0"),
);
expect(releaseValues(disabled)).toEqual([]);
});

it("rejects a stamp outside the CLI's bound at build time", async () => {
for (const value of ["10081", "-1", "1.5"]) {
await expect(
runTerraformApply(
import.meta.dir,
{ agent_id: "foo" },
stampParameter(PARAMETER, value),
),
value,
).rejects.toThrow(/from 0 to 10080/);
}
});

it("normalizes the value from the environment", async () => {
const { id, scripts } = await setup();
await stubClaude(id, 'printf "%s\\n" "$@" >/tmp/claude-args; sleep 30');
const { start } = await runScripts(id, scripts, [
...DISPATCH_ENV,
"AGENT_RELAY_CLAUDE_CODE_RELEASE_IDLE_SESSION_MIN=+009",
]);
expect(start.exitCode, start.stdout).toBe(0);
const args = (await readFileContainer(id, "/tmp/claude-args")).split(
"\n",
);
expect(releaseValues(args)).toEqual(["9"]);
});

it("refuses an invalid value from the environment", async () => {
// The build validates the parameter; this guards the script against a
// value that reached the environment some other way.
for (const value of ["5; touch /tmp/PWNED", "10081", "-1", "abc"]) {
const { id, scripts } = await setup();
await stubClaude(id, "touch /tmp/runner-started; sleep 30");
const { start } = await runScripts(id, scripts, [
...DISPATCH_ENV,
`AGENT_RELAY_CLAUDE_CODE_RELEASE_IDLE_SESSION_MIN=${value}`,
]);
expect(start.exitCode, value).toBe(1);
expect(start.stdout).toContain("from 0 to 10080");
expect(await readState(id)).toBe(
"failed invalid-release-idle-session-min",
);
for (const marker of ["/tmp/PWNED", "/tmp/runner-started"]) {
const exists = await execContainer(id, ["test", "-e", marker]);
expect(exists.exitCode, `${value}: ${marker}`).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
Expand Down
54 changes: 50 additions & 4 deletions registry/coder/modules/agent-relay-claude-code/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
# - agent_relay_client_platform is persistent attribution: the surface
# that created the session. Stamped on every build; relays that
# predate it simply never set it.
# - agent_relay_claude_code_release_idle_session_min is a persistent
# runner input: minutes an attached session may sit without user input
# before the runner releases it. Its default is release_idle_session_min,
# so a relay that never stamps it gets the template's value.
# - agent_relay_credential, agent_relay_claude_code_lock_to_account,
# and agent_relay_attempt are ephemeral runner inputs, reset
# between builds. An ephemeral value is still recorded on the build
Expand Down Expand Up @@ -93,6 +97,17 @@ variable "exit_if_unused_min" {
}
}

variable "release_idle_session_min" {
description = "Default minutes an attached session may go without user input before the runner releases it (the CLI's --release-idle-session-min). The runner then exits and Agent Relay reaps the workspace; the session parks and resumes on a fresh runner on the user's next message. Agent Relay can override this per build through the agent_relay_claude_code_release_idle_session_min parameter. 0 never releases an idle session."
type = number
default = 60

validation {
condition = var.release_idle_session_min >= 0 && var.release_idle_session_min <= 10080 && floor(var.release_idle_session_min) == var.release_idle_session_min
Comment thread
Emyrk marked this conversation as resolved.
error_message = "release_idle_session_min must be a whole number of minutes from 0 to 10080, 0 to disable."
}
}

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
Expand Down Expand Up @@ -121,11 +136,11 @@ variable "additional_args" {
}

# 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.
# release_idle_session_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", "--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)."
condition = !anytrue([for a in var.additional_args : contains(["--capacity", "--base-dir", "--exit-if-unused-min", "--release-idle-session-min", "--health-port", "--exec-path"], split("=", a)[0])])
error_message = "additional_args must not set --capacity, --base-dir, --exit-if-unused-min, --release-idle-session-min, --health-port, or --exec-path; the module sets them (use base_dir, exit_if_unused_min, release_idle_session_min, and health_port instead)."
}
}

Expand Down Expand Up @@ -185,6 +200,27 @@ data "coder_parameter" "agent_relay_client_platform" {
})
}

data "coder_parameter" "agent_relay_claude_code_release_idle_session_min" {
name = "agent_relay_claude_code_release_idle_session_min"
display_name = "Claude Code idle session release"
description = "Minutes an attached session may go without user input before the runner releases it and exits, which tells the relay to delete the workspace. The session parks and resumes on a fresh runner on the user's next message. Agent Relay sets this from the pool configuration; a human never fills it in. 0 never releases an idle session."
type = "number"
mutable = true
default = tostring(var.release_idle_session_min)
order = 1007
styling = jsonencode({
disabled = true
placeholder = "Set by Agent Relay on dispatch"
})

# The CLI's own bound.
validation {
min = 0
max = 10080
error = "Idle session release must be a whole number of minutes from 0 to 10080, 0 to disable."
}
}

data "coder_parameter" "agent_relay_credential" {
name = "agent_relay_credential"
display_name = "Agent Relay credential"
Expand Down Expand Up @@ -253,6 +289,14 @@ resource "coder_env" "agent_relay_client_platform" {
value = data.coder_parameter.agent_relay_client_platform.value
}

# Parameter values reach the runner through the environment, never through
# the script text, so a stamped value is data and not code.
resource "coder_env" "agent_relay_claude_code_release_idle_session_min" {
agent_id = var.agent_id
name = "AGENT_RELAY_CLAUDE_CODE_RELEASE_IDLE_SESSION_MIN"
value = data.coder_parameter.agent_relay_claude_code_release_idle_session_min.value
}

locals {
# coder-utils requires this exact layout. Scripts land in scripts/ and
# their output in logs/; the runner state and log default to the same
Expand All @@ -277,6 +321,8 @@ locals {
exit_if_unused_min = var.exit_if_unused_min
health_port = var.health_port
additional_args = base64encode(join("\n", var.additional_args))
# Fallback only; the parameter's value arrives through coder_env.
release_idle_session_min = var.release_idle_session_min
})
}

Expand Down
Loading
Loading