diff --git a/registry/coder/modules/agent-relay-claude-code/README.md b/registry/coder/modules/agent-relay-claude-code/README.md index 75532c810..6311750cc 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.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 @@ -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 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 371ec46d9..8afec6ff5 100644 --- a/registry/coder/modules/agent-relay-claude-code/main.test.ts +++ b/registry/coder/modules/agent-relay-claude-code/main.test.ts @@ -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"); diff --git a/registry/coder/modules/agent-relay-claude-code/main.tf b/registry/coder/modules/agent-relay-claude-code/main.tf index 55d523441..0c2a82dac 100644 --- a/registry/coder/modules/agent-relay-claude-code/main.tf +++ b/registry/coder/modules/agent-relay-claude-code/main.tf @@ -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" @@ -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 @@ -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)) }) } 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 8c725cbd3..9a58ed439 100644 --- a/registry/coder/modules/agent-relay-claude-code/main.tftest.hcl +++ b/registry/coder/modules/agent-relay-claude-code/main.tftest.hcl @@ -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 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 27fa61015..760b9cbbf 100644 --- a/registry/coder/modules/agent-relay-claude-code/start.sh.tftpl +++ b/registry/coder/modules/agent-relay-claude-code/start.sh.tftpl @@ -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" @@ -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"