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
27 changes: 10 additions & 17 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.3.0"
version = "0.4.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 @@ -50,25 +50,18 @@ when to reap the workspace.

## 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.
Use `additional_args` to pass extra arguments to `claude self-hosted-runner`, as a
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`.

```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"
}
source = "registry.coder.com/coder/agent-relay-claude-code/coder"
version = "0.4.0"
agent_id = coder_agent.main.id
additional_args = ["--some-flag", "value", "--some-flag", "another value", "--another-flag"]
}
```

Expand Down
28 changes: 18 additions & 10 deletions registry/coder/modules/agent-relay-claude-code/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -307,24 +307,32 @@ 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 () => {
it("appends additional_args in order, one argument per element", async () => {
const { id, scripts } = await setup({
additional_args: JSON.stringify({
"--verbose": "",
"--name": "two words $HOME",
"-t": `it"s`,
}),
additional_args: JSON.stringify([
"--name",
"two words $HOME",
"--name",
`it"s`,
"--verbose",
]),
});
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');
// A flag can repeat, and arguments reach the CLI verbatim and in order:
// no word splitting or expansion.
const at = args.indexOf("--name");
expect(args.slice(at, at + 5)).toEqual([
"--name",
"two words $HOME",
"--name",
'it"s',
"--verbose",
]);
// 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(
Expand Down
18 changes: 7 additions & 11 deletions registry/coder/modules/agent-relay-claude-code/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -100,20 +100,20 @@ variable "serving_log_pattern" {
}

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 = {}
description = "Additional arguments to pass to `claude self-hosted-runner`, one list element per argument, in order (e.g. `[\"--header\", \"a: 1\", \"--header\", \"b: 2\", \"--verbose\"]`). Each element is passed as written, so a flag can repeat and a value may contain spaces."
type = list(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."
condition = alltrue([for a in var.additional_args : a != "" && !can(regex("\n", a))])
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.
validation {
condition = length(setintersection(keys(var.additional_args), ["--capacity", "--base-dir", "--exit-if-unused-min", "--exec-path"])) == 0
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)."
}
}
Expand Down Expand Up @@ -248,10 +248,6 @@ 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 @@ -264,7 +260,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))
additional_args = base64encode(join("\n", var.additional_args))
})
}

Expand Down
25 changes: 8 additions & 17 deletions registry/coder/modules/agent-relay-claude-code/main.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ run "additional_args_default_empty" {
command = plan

assert {
condition = local.additional_args == [] && strcontains(local.start_script, "done < <(echo -n '' | base64 -d)")
condition = strcontains(local.start_script, "done < <(echo -n '' | base64 -d)")
error_message = "additional_args must default to empty"
}
}
Expand All @@ -225,30 +225,21 @@ run "additional_args_encoded" {
command = plan

variables {
additional_args = {
"--verbose" = ""
"--name" = "it's here"
"-t" = "x"
}
additional_args = ["--header", "it's here", "--header", "b", "--verbose"]
}

# Base64 keeps quotes in an argument from breaking the rendered script.
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"
condition = strcontains(local.start_script, base64encode("--header\nit's here\n--header\nb\n--verbose")) && !strcontains(local.start_script, "it's here")
error_message = "additional_args must be base64-encoded into the start script, one argument per line, in order"
}
}

run "additional_args_rejects_bad_flag" {
run "additional_args_rejects_newline" {
command = plan

variables {
additional_args = { "verbose; rm -rf /" = "" }
additional_args = ["--verbose\n--capacity"]
}

expect_failures = [var.additional_args]
Expand All @@ -258,7 +249,7 @@ run "additional_args_rejects_module_flags" {
command = plan

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

expect_failures = [var.additional_args]
Expand Down
Loading