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
26 changes: 25 additions & 1 deletion 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.2.0"
version = "0.3.0"
agent_id = coder_agent.main.id

# Downloads the Claude Code CLI at start when it is not in the image. Bake
Expand Down Expand Up @@ -48,6 +48,30 @@ when to reap the workspace.
- Builds must finish inside Agent Relay's 300s spawn budget: pre-pulled
images, no persistent volumes.

## Additional runner arguments

Use `additional_args` to pass extra flags to `claude self-hosted-runner`, as a
map of flag to value. They are appended after the flags the module sets. Each
flag is passed exactly as written, so use `--name` or `-n` as the CLI expects,
followed by its value as the next argument; an empty value passes the flag
alone. Values are passed verbatim, so spaces and quotes need no escaping.
Terraform orders map keys alphabetically. Flags the module already sets
(`--capacity`, `--base-dir`, `--exit-if-unused-min`, `--exec-path`) are
rejected.

```tf
module "claude_code_runner" {
source = "registry.coder.com/coder/agent-relay-claude-code/coder"
version = "0.3.0"
agent_id = coder_agent.main.id
# Passed as: --another-flag --some-flag value
additional_args = {
"--another-flag" = ""
"--some-flag" = "value"
}
}
```

## Parameters

Agent Relay verifies this contract against the template's active version at
Expand Down
25 changes: 25 additions & 0 deletions registry/coder/modules/agent-relay-claude-code/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,31 @@ describe("agent-relay-claude-code", () => {
expect(env.stdout).toContain("SELF_HOSTED_RUNNER_LOCK_TO_ACCOUNT=acct-123");
});

it("appends additional_args as one argument per flag", async () => {
const { id, scripts } = await setup({
additional_args: JSON.stringify({
"--verbose": "",
"--name": "two words $HOME",
"-t": `it"s`,
}),
});
await stubClaude(id, 'printf "%s\\n" "$@" >/tmp/claude-args; sleep 30');
const { start } = await runDispatched(id, scripts);
expect(start.exitCode).toBe(0);

const args = (await readFileContainer(id, "/tmp/claude-args")).split("\n");
expect(args[0]).toBe("self-hosted-runner");
expect(args).toContain("--verbose");
// Values reach the CLI verbatim: no word splitting or expansion.
expect(args[args.indexOf("--name") + 1]).toBe("two words $HOME");
expect(args[args.indexOf("-t") + 1]).toBe('it"s');
// Module-set flags still come through, and the wrapper stays last.
expect(args[args.indexOf("--capacity") + 1]).toBe("1");
expect(args[args.indexOf("--exec-path") + 1]).toBe(
`${MODULE_DIR}/wrapper.sh`,
);
});

it("records the exit code when the runner exits", async () => {
const { id, scripts } = await setup();
await stubClaude(id, "exit 3");
Expand Down
24 changes: 24 additions & 0 deletions registry/coder/modules/agent-relay-claude-code/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,25 @@ variable "serving_log_pattern" {
description = "Runner log substring that means a session was claimed. Only distinguishes the agent_relay_status values working and serving; a stale pattern degrades to working and affects nothing else."
}

variable "additional_args" {
description = "Additional flags to pass to `claude self-hosted-runner`, as a map of flag to value. Each flag is passed as written (e.g. `--name` or `-n`) followed by its value as the next argument; an empty value passes the flag alone (e.g. `{ \"--some-flag\" = \"value\" }`)."
type = map(string)
default = {}

validation {
condition = alltrue([for k, v in var.additional_args : can(regex("^--?[A-Za-z0-9][A-Za-z0-9._-]*$", k)) && !can(regex("\n", v))])
error_message = "additional_args keys must be flags such as --name or -n (letters, digits, '.', '_', '-'), and values 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.
validation {
condition = length(setintersection(keys(var.additional_args), ["--capacity", "--base-dir", "--exit-if-unused-min", "--exec-path"])) == 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)."
}
}

data "coder_parameter" "agent_relay_session_id" {
name = "agent_relay_session_id"
display_name = "Agent Relay session"
Expand Down Expand Up @@ -229,6 +248,10 @@ locals {
# tree so one directory holds everything a debugger needs.
module_directory = "$HOME/.coder-modules/coder/agent-relay-claude-code"

# Render additional_args as argv: each key as written, followed by its
# value as a separate argument unless the value is empty.
additional_args = flatten([for k, v in var.additional_args : v == "" ? [k] : [k, v]])

install_script = templatefile("${path.module}/install.sh.tftpl", {
cli_binary = var.cli_binary
install_cli = var.install_cli
Expand All @@ -241,6 +264,7 @@ locals {
log_file = var.log_file
base_dir = var.base_dir
exit_if_unused_min = var.exit_if_unused_min
additional_args = base64encode(join("\n", local.additional_args))
})
}

Expand Down
52 changes: 52 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 @@ -212,6 +212,58 @@ run "idle_bound_disabled" {
}
}

run "additional_args_default_empty" {
command = plan

assert {
condition = local.additional_args == [] && strcontains(local.start_script, "done < <(echo -n '' | base64 -d)")
error_message = "additional_args must default to empty"
}
}

run "additional_args_encoded" {
command = plan

variables {
additional_args = {
"--verbose" = ""
"--name" = "it's here"
"-t" = "x"
}
}

assert {
condition = local.additional_args == ["--name", "it's here", "--verbose", "-t", "x"]
error_message = "each flag must be passed as written, followed by its value unless the value is empty"
}

# Base64 keeps quotes in a value from breaking the rendered script.
assert {
condition = strcontains(local.start_script, base64encode(join("\n", local.additional_args))) && !strcontains(local.start_script, "it's here")
error_message = "additional_args must be base64-encoded into the start script"
}
}

run "additional_args_rejects_bad_flag" {
command = plan

variables {
additional_args = { "verbose; rm -rf /" = "" }
}

expect_failures = [var.additional_args]
}

run "additional_args_rejects_module_flags" {
command = plan

variables {
additional_args = { "--capacity" = "2" }
}

expect_failures = [var.additional_args]
}

run "cli_binary_rejects_shell" {
command = plan

Expand Down
11 changes: 11 additions & 0 deletions registry/coder/modules/agent-relay-claude-code/start.sh.tftpl
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,14 @@ set -euo pipefail
state_file="${state_file}"
log_file="${log_file}"
base_dir="${base_dir}"
# One argument per line. Shell-quoted here because the string is expanded
# into the supervisor below, which must see each argument as one word.
additional_args=""
while IFS= read -r arg || [ -n "$arg" ]; do
if [ -n "$arg" ]; then
additional_args+="$(printf '%q' "$arg") "
fi
done < <(echo -n '${additional_args}' | base64 -d)
state_dir="$(dirname "$state_file")"
mkdir -p "$state_dir" "$(dirname "$log_file")" "$base_dir"

Expand Down Expand Up @@ -87,12 +95,15 @@ write_state() {
}

export PATH="\$HOME/.local/bin:\$PATH"
# Keep these flags in sync with the reserved-flag list in additional_args'
# validation in main.tf, so additional_args cannot set them a second time.
${cli_binary} self-hosted-runner \\
--capacity 1 \\
--base-dir "$base_dir" \\
%{ if exit_if_unused_min > 0 ~}
--exit-if-unused-min ${exit_if_unused_min} \\
%{ endif ~}
$additional_args \\
--exec-path "$wrapper" >"$log_file" 2>&1 &
runner_pid=\$!
write_state "working \$runner_pid"
Expand Down
Loading