diff --git a/registry/coder/modules/agent-relay-cursor/README.md b/registry/coder/modules/agent-relay-cursor/README.md index a3fe85ba0..90f6b9e7f 100644 --- a/registry/coder/modules/agent-relay-cursor/README.md +++ b/registry/coder/modules/agent-relay-cursor/README.md @@ -16,7 +16,7 @@ parameters the relay stamps on each build and runs the Cursor CLI worker. ```tf module "cursor_worker" { source = "registry.coder.com/coder/agent-relay-cursor/coder" - version = "0.5.0" + version = "0.6.0" agent_id = coder_agent.main.id # Downloads the Cursor CLI at start when it is not in the image. Bake @@ -86,6 +86,31 @@ for example after the idle-release timeout, or when the service account key that minted it is deleted or expires. Its 7-day expiry is a backstop, not a session limit. +## Additional worker arguments + +Use `additional_args` to pass extra flags to `agent worker`, as a map of +flag to value. They are appended after the flags the module sets and before +the `start` subcommand. 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 +(`--pool`, `--idle-release-timeout`, `--auth-token-file`, `--auth-token`, +`--computer-use`, `--share-desktop`) are rejected. Give a flag that takes an optional value an explicit +one, or it consumes `start`. + +```tf +module "cursor_worker" { + source = "registry.coder.com/coder/agent-relay-cursor/coder" + version = "0.6.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-cursor/main.test.ts b/registry/coder/modules/agent-relay-cursor/main.test.ts index 29f869d42..fe04634f6 100644 --- a/registry/coder/modules/agent-relay-cursor/main.test.ts +++ b/registry/coder/modules/agent-relay-cursor/main.test.ts @@ -419,6 +419,28 @@ describe("agent-relay-cursor", () => { expect(args).toContain("start"); }); + it("appends additional_args before start, one argument per flag", async () => { + const { id, scripts } = await setup({ + additional_args: JSON.stringify({ + "--verbose": "", + "--name": "two words $HOME", + "-t": `it"s`, + }), + }); + await stubAgent(id, 'printf "%s\\n" "$@" >/tmp/agent-args; sleep 30'); + const { start } = await runDispatched(id, scripts); + expect(start.exitCode).toBe(0); + const args = (await readFileContainer(id, "/tmp/agent-args")).split("\n"); + expect(args[0]).toBe("worker"); + 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'); + // The subcommand stays last, after every flag. + expect(args.indexOf("start")).toBeGreaterThan(args.indexOf("-t") + 1); + expect(args[args.indexOf("--pool") + 1]).toBe(POOL_NAME); + }); + it("records the exit code when the worker exits", async () => { const { id, scripts } = await setup(); await stubAgent(id, "exit 3"); diff --git a/registry/coder/modules/agent-relay-cursor/main.tf b/registry/coder/modules/agent-relay-cursor/main.tf index e0387ec43..d60e33c13 100644 --- a/registry/coder/modules/agent-relay-cursor/main.tf +++ b/registry/coder/modules/agent-relay-cursor/main.tf @@ -104,6 +104,26 @@ variable "serving_log_pattern" { } } +variable "additional_args" { + description = "Additional flags to pass to `agent worker`, 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. Appended after the flags the module sets and before `start`." + 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: the pool and idle timeout come from Agent + # Relay's parameters, the token is passed by file so it stays off the + # command line, and desktop flags have their own inputs. Keep this list in + # sync with the `worker` flags in start.sh.tftpl. + validation { + condition = length(setintersection(keys(var.additional_args), ["--pool", "--idle-release-timeout", "--auth-token-file", "--auth-token", "--computer-use", "--share-desktop"])) == 0 + error_message = "additional_args must not set --pool, --idle-release-timeout, --auth-token-file, --auth-token, --computer-use, or --share-desktop; the module sets them (use computer_use and share_desktop for the desktop flags)." + } +} + data "coder_parameter" "agent_relay_session_id" { name = "agent_relay_session_id" display_name = "Agent Relay session" @@ -242,6 +262,10 @@ locals { # tree so one directory holds everything a debugger needs. module_directory = "$HOME/.coder-modules/coder/agent-relay-cursor" + # 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 @@ -255,6 +279,7 @@ locals { share_desktop = var.share_desktop state_file = var.state_file log_file = var.log_file + additional_args = base64encode(join("\n", local.additional_args)) }) } diff --git a/registry/coder/modules/agent-relay-cursor/main.tftest.hcl b/registry/coder/modules/agent-relay-cursor/main.tftest.hcl index 78655fb93..be7cc6ba3 100644 --- a/registry/coder/modules/agent-relay-cursor/main.tftest.hcl +++ b/registry/coder/modules/agent-relay-cursor/main.tftest.hcl @@ -235,6 +235,59 @@ run "share_desktop_requires_computer_use" { expect_failures = [var.share_desktop] } +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" + } + } + + # Map keys sort byte-wise, so --flags come before -f flags. + 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 = { "--auth-token" = "secret" } + } + + expect_failures = [var.additional_args] +} + run "install_cli_enabled_by_default" { command = plan diff --git a/registry/coder/modules/agent-relay-cursor/start.sh.tftpl b/registry/coder/modules/agent-relay-cursor/start.sh.tftpl index 172837e8b..cde1473dc 100644 --- a/registry/coder/modules/agent-relay-cursor/start.sh.tftpl +++ b/registry/coder/modules/agent-relay-cursor/start.sh.tftpl @@ -23,6 +23,16 @@ token_file="${module_directory}/worker-token" scripts_dir="${module_directory}/scripts" mkdir -p "$scripts_dir" "$(dirname "$state_file")" "$(dirname "$log_file")" +# additional_args, one argument per line. Shell-quoted here because the +# string is expanded into the supervisor below, which must see each +# argument as one word and never evaluate it. +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) + write_state() { # Write via a temp file so a reader never sees a partial line. printf '%s\n' "$1" >"$state_file.tmp" @@ -126,6 +136,8 @@ write_state() { export PATH="\$HOME/.local/bin:\$PATH" unset AGENT_RELAY_CURSOR_TOKEN +# 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} worker \\ --pool "\$AGENT_RELAY_CURSOR_POOL_NAME" \\ --idle-release-timeout "\$AGENT_RELAY_CURSOR_IDLE_RELEASE_TIMEOUT" \\ @@ -136,6 +148,7 @@ ${cli_binary} worker \\ %{ if share_desktop ~} --share-desktop=view_and_control \\ %{ endif ~} + $additional_args \\ start >"$log_file" 2>&1 & worker_pid=\$! write_state "working \$worker_pid"