From 6d8e9bdab3aab856001af3e106d657ac9b7cba24 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Mon, 12 Oct 2026 17:06:33 +0000 Subject: [PATCH] feat(registry/coder/modules): receive Agent Relay credentials as workspace secrets Drops the agent_relay_credential parameter and its coder_env from the agent-relay-claude-code and agent-relay-cursor modules. Agent Relay now sends the work order and worker token as ephemeral workspace secrets that the agent injects as SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET and AGENT_RELAY_CURSOR_TOKEN. The dispatched output keys off agent_relay_delivery_id instead. Breaking: a relay that still stamps the credential parameter refuses templates on these versions, so both modules move to 1.0.0. --- .../modules/agent-relay-claude-code/README.md | 15 ++++-- .../modules/agent-relay-claude-code/main.tf | 46 +++++++------------ .../agent-relay-claude-code/main.tftest.hcl | 21 +-------- .../agent-relay-claude-code/start.sh.tftpl | 2 +- .../modules/agent-relay-cursor/README.md | 18 +++++--- .../coder/modules/agent-relay-cursor/main.tf | 43 ++++++----------- .../agent-relay-cursor/main.tftest.hcl | 27 ++--------- .../modules/agent-relay-cursor/start.sh.tftpl | 2 +- 8 files changed, 60 insertions(+), 114 deletions(-) diff --git a/registry/coder/modules/agent-relay-claude-code/README.md b/registry/coder/modules/agent-relay-claude-code/README.md index 75532c810..83b52b229 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 = "1.0.0" agent_id = coder_agent.main.id # Downloads the Claude Code CLI at start when it is not in the image. Bake @@ -42,6 +42,12 @@ when to reap the workspace. ## Requirements +- A Coder deployment that supports workspace build secrets, and an Agent + Relay release that sends the work order as one. The work order reaches the + runner as the `SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET` workspace secret, + never as a build parameter. A relay that still sends it as the + `agent_relay_credential` parameter refuses to serve a template on this + version; pin `0.2.0` until the relay and Coder are upgraded. - The `claude` CLI must be in the workspace. `install_cli` (default `true`) downloads it at start only when it is not already on PATH; bake it into the image for the fastest start. `cli_binary` overrides the path. @@ -52,8 +58,9 @@ when to reap the workspace. Agent Relay verifies this contract against the template's active version at startup and refuses to serve a pool that does not satisfy it. Every parameter -renders disabled with a "Set by Agent Relay on dispatch" placeholder; the -credential is masked. +renders disabled with a "Set by Agent Relay on dispatch" placeholder. The +work order is not among them: it is a workspace secret, so it never appears +in the build's parameters or Terraform state. `agent_relay_client_platform` is attribution rather than contract: the relay stamps the surface the session was created from (`web_claude_ai`, @@ -93,7 +100,7 @@ rather than reporting `working` forever. | value | meaning | | ----------------- | -------------------------------------------------------------------------- | | `pending` | no state recorded yet | -| `idle` | no credential: workspace was created manually | +| `idle` | no work order: workspace was created manually | | `working` | runner alive, no session picked up | | `serving` | runner alive, session picked up (best effort, see `serving_log_pattern`) | | `orphaned` | runner process gone without recording an exit | diff --git a/registry/coder/modules/agent-relay-claude-code/main.tf b/registry/coder/modules/agent-relay-claude-code/main.tf index 55d523441..2aea56ecb 100644 --- a/registry/coder/modules/agent-relay-claude-code/main.tf +++ b/registry/coder/modules/agent-relay-claude-code/main.tf @@ -15,11 +15,16 @@ # - 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_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 -# that set it, which is how Agent Relay reads the attempt back when -# it reaps a dead workspace. +# - 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 that set it, which is how +# Agent Relay reads the attempt back when it reaps a dead workspace. +# +# The work-order JWT is not a parameter. Agent Relay sends it as an +# ephemeral workspace secret, which the agent injects as +# SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET. It never reaches the +# provisioner, Terraform state, or the build's parameters, so nothing +# in this module can read it. # # The runner's lifecycle is published through the agent_relay_status agent # metadata item, whose script this module renders. The script starts the @@ -155,22 +160,6 @@ data "coder_parameter" "agent_relay_client_platform" { }) } -data "coder_parameter" "agent_relay_credential" { - name = "agent_relay_credential" - display_name = "Agent Relay credential" - description = "Single-use work order JWT the runner authenticates to Anthropic with. Agent Relay sets this when it dispatches the workspace; a human never fills it in. Ephemeral: it is valid for one session and is not reused on a later build." - type = "string" - ephemeral = true - mutable = true - default = "" - order = 1003 - styling = jsonencode({ - disabled = true - mask_input = true - placeholder = "Set by Agent Relay on dispatch" - }) -} - data "coder_parameter" "agent_relay_claude_code_lock_to_account" { name = "agent_relay_claude_code_lock_to_account" display_name = "Claude Code account lock" @@ -202,13 +191,10 @@ data "coder_parameter" "agent_relay_attempt" { } # Environment variable names are the claude CLI's contract, not -# Agent Relay's; do not rename them here. -resource "coder_env" "runner_environment_secret" { - agent_id = var.agent_id - name = "SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET" - value = data.coder_parameter.agent_relay_credential.value -} - +# Agent Relay's; do not rename them here. The CLI also reads +# SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET, which the agent injects from +# the workspace secret Agent Relay sends; a coder_env of the same name +# would be overridden by it. resource "coder_env" "agent_relay_claude_code_lock_to_account" { agent_id = var.agent_id name = "SELF_HOSTED_RUNNER_LOCK_TO_ACCOUNT" @@ -275,8 +261,8 @@ output "scripts" { } output "dispatched" { - description = "Whether this workspace was spawned by Agent Relay (credential set) or manually (empty)." - value = data.coder_parameter.agent_relay_credential.value != "" + description = "Whether this workspace was spawned by Agent Relay (delivery set) or manually (empty)." + value = data.coder_parameter.agent_relay_delivery_id.value != "" } output "client_platform" { 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..b796d83b6 100644 --- a/registry/coder/modules/agent-relay-claude-code/main.tftest.hcl +++ b/registry/coder/modules/agent-relay-claude-code/main.tftest.hcl @@ -25,11 +25,6 @@ run "parameter_contract" { error_message = "pool parameter name is part of the Agent Relay contract" } - assert { - condition = data.coder_parameter.agent_relay_credential.name == "agent_relay_credential" - error_message = "credential parameter name is part of the Agent Relay contract" - } - assert { condition = data.coder_parameter.agent_relay_claude_code_lock_to_account.name == "agent_relay_claude_code_lock_to_account" error_message = "account lock parameter name is part of the Agent Relay contract" @@ -47,14 +42,13 @@ run "parameter_contract" { # The relay reads these back off a build long after dispatch, so they # must not be ephemeral; the rest must be, so a manual build never - # inherits a stale credential, account lock, or attempt. + # inherits a stale account lock or attempt. assert { condition = alltrue([ data.coder_parameter.agent_relay_session_id.ephemeral == false, data.coder_parameter.agent_relay_delivery_id.ephemeral == false, data.coder_parameter.agent_relay_pool.ephemeral == false, data.coder_parameter.agent_relay_client_platform.ephemeral == false, - data.coder_parameter.agent_relay_credential.ephemeral == true, data.coder_parameter.agent_relay_claude_code_lock_to_account.ephemeral == true, data.coder_parameter.agent_relay_attempt.ephemeral == true, ]) @@ -70,29 +64,18 @@ run "parameter_contract" { data.coder_parameter.agent_relay_delivery_id.styling, data.coder_parameter.agent_relay_pool.styling, data.coder_parameter.agent_relay_client_platform.styling, - data.coder_parameter.agent_relay_credential.styling, data.coder_parameter.agent_relay_claude_code_lock_to_account.styling, data.coder_parameter.agent_relay_attempt.styling, ] : can(regex("\"disabled\":true", p)) ]) error_message = "every relay parameter must render disabled" } - - assert { - condition = can(regex("\"mask_input\":true", data.coder_parameter.agent_relay_credential.styling)) - error_message = "the credential must be masked" - } } run "runner_wiring" { command = plan # The claude CLI owns these names; the module only supplies values. - assert { - condition = coder_env.runner_environment_secret.name == "SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET" - error_message = "the pool secret env var name is the claude CLI's contract" - } - assert { condition = coder_env.agent_relay_claude_code_lock_to_account.name == "SELF_HOSTED_RUNNER_LOCK_TO_ACCOUNT" error_message = "the account lock env var name is the claude CLI's contract" @@ -151,7 +134,7 @@ run "runner_wiring" { assert { condition = output.dispatched == false - error_message = "a build with no credential was not dispatched by Agent Relay" + error_message = "a build with no delivery was not dispatched by Agent Relay" } } 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..7f08c9a9d 100644 --- a/registry/coder/modules/agent-relay-claude-code/start.sh.tftpl +++ b/registry/coder/modules/agent-relay-claude-code/start.sh.tftpl @@ -30,7 +30,7 @@ if [ -z "$${SELF_HOSTED_RUNNER_ENVIRONMENT_SECRET:-}" ]; then write_state "idle" fi echo "No work order set. This workspace was created manually, not by Agent Relay." - echo "Idling. Delete this workspace manually or set the agent_relay_credential parameter." + echo "Idling. Delete this workspace manually; only Agent Relay can deliver a work order." exit 0 fi diff --git a/registry/coder/modules/agent-relay-cursor/README.md b/registry/coder/modules/agent-relay-cursor/README.md index 29fefc442..8fca2705e 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 = "1.0.0" agent_id = coder_agent.main.id # Downloads the Cursor CLI at start when it is not in the image. Bake @@ -52,6 +52,10 @@ created. ## Requirements +- A Coder deployment that supports workspace build secrets, and an Agent + Relay release that sends the worker token as one. A relay that still sends + it as the `agent_relay_credential` parameter refuses to serve a template on + this version; pin `0.5.0` until the relay and Coder are upgraded. - The Cursor CLI (`agent`) must be in the workspace. `install_cli` (default `true`) downloads it at start only when it is not already on PATH; bake it into the image for the fastest start. `cli_binary` overrides the path. @@ -71,9 +75,10 @@ created. The pool's service-account API key never leaves Agent Relay. At dispatch the relay exchanges it for a Cursor sub-token scoped to the requesting user and -stamps that as the ephemeral `agent_relay_credential` parameter. The module -exports it as `AGENT_RELAY_CURSOR_TOKEN` and starts the worker with -`--auth-token`. It is never written to disk, though as a command-line argument +sends that as an ephemeral workspace secret, which the agent injects as +`AGENT_RELAY_CURSOR_TOKEN`. It never appears in the build's parameters or +Terraform state. The module starts the worker with `--auth-token`. It is never +written to disk, though as a command-line argument it is visible in the worker's `/proc//cmdline` to any process running as the same user inside the workspace; the CLI offers no environment variable for it. The token acts only as that user, cannot mint further tokens, and expires @@ -84,8 +89,7 @@ fails and Cursor re-queues the request for a fresh workspace. Agent Relay verifies this contract against the template's active version at startup and refuses to serve a pool that does not satisfy it. Every parameter -renders disabled with a "Set by Agent Relay on dispatch" placeholder; the -credential is masked. +renders disabled with a "Set by Agent Relay on dispatch" placeholder. ## Scripts and logs @@ -115,7 +119,7 @@ finishes a turn, not when the chat closes, so keep the timeout at or above | value | meaning | | ----------------- | -------------------------------------------------------------------------- | | `pending` | start step running, supervisor has not written yet | -| `idle` | no credential: workspace was created manually | +| `idle` | no worker token: workspace was created manually | | `working` | worker alive, no session attached | | `serving` | worker alive, session attached (best effort, see `serving_log_pattern`) | | `orphaned` | worker process gone without recording an exit | diff --git a/registry/coder/modules/agent-relay-cursor/main.tf b/registry/coder/modules/agent-relay-cursor/main.tf index b3b72d2ba..bb450bcb1 100644 --- a/registry/coder/modules/agent-relay-cursor/main.tf +++ b/registry/coder/modules/agent-relay-cursor/main.tf @@ -19,8 +19,12 @@ # dedupe and reconciliation query workspaces with `param:` search # filters on them. agent_relay_cursor_repo_url is stamped on every # build (empty for repo-less pools) so the contract stays static. -# - agent_relay_credential is an ephemeral worker input, reset between -# builds. +# +# The worker token is not a parameter. Agent Relay sends it as an +# ephemeral workspace secret, which the agent injects as +# AGENT_RELAY_CURSOR_TOKEN. It never reaches the provisioner, Terraform +# state, or the build's parameters, so nothing in this module can read +# it. # # The worker's lifecycle is published through the agent_relay_status agent # metadata item, whose script this module renders. The script starts @@ -188,33 +192,12 @@ data "coder_parameter" "agent_relay_cursor_repo_url" { }) } -data "coder_parameter" "agent_relay_credential" { - name = "agent_relay_credential" - display_name = "Agent Relay credential" - description = "Short-lived Cursor worker token, scoped to the user who requested the agent, that the worker authenticates with. Agent Relay mints it from the pool's service account key when it dispatches the workspace; a human never fills it in. Ephemeral: it expires an hour after minting and is not reused on a later build." - type = "string" - ephemeral = true - mutable = true - default = "" - order = 1006 - styling = jsonencode({ - disabled = true - mask_input = true - placeholder = "Set by Agent Relay on dispatch" - }) -} - # CURSOR_AGENT_WORKER_ID is the Cursor CLI's contract; do not rename it. -# The worker token has no CLI env var (only the --auth-token flag), so it -# travels under an Agent Relay name and the script passes it on the -# command line. It is never CURSOR_API_KEY: the CLI would treat the -# token as a service account key and reject it. -resource "coder_env" "agent_relay_cursor_token" { - agent_id = var.agent_id - name = "AGENT_RELAY_CURSOR_TOKEN" - value = data.coder_parameter.agent_relay_credential.value -} - +# The worker token has no CLI env var (only the --auth-token flag), so +# Agent Relay delivers it as the AGENT_RELAY_CURSOR_TOKEN workspace +# secret and the script passes it on the command line. It is never +# CURSOR_API_KEY: the CLI would treat the token as a service account key +# and reject it. resource "coder_env" "cursor_agent_worker_id" { agent_id = var.agent_id name = "CURSOR_AGENT_WORKER_ID" @@ -290,8 +273,8 @@ output "scripts" { } output "dispatched" { - description = "Whether this workspace was spawned by Agent Relay (credential set) or manually (empty)." - value = data.coder_parameter.agent_relay_credential.value != "" + description = "Whether this workspace was spawned by Agent Relay (delivery set) or manually (empty)." + value = data.coder_parameter.agent_relay_delivery_id.value != "" } output "session_id" { diff --git a/registry/coder/modules/agent-relay-cursor/main.tftest.hcl b/registry/coder/modules/agent-relay-cursor/main.tftest.hcl index 4b175a0f6..62b3364f2 100644 --- a/registry/coder/modules/agent-relay-cursor/main.tftest.hcl +++ b/registry/coder/modules/agent-relay-cursor/main.tftest.hcl @@ -25,11 +25,6 @@ run "parameter_contract" { error_message = "pool parameter name is part of the Agent Relay contract" } - assert { - condition = data.coder_parameter.agent_relay_credential.name == "agent_relay_credential" - error_message = "credential parameter name is part of the Agent Relay contract" - } - assert { condition = data.coder_parameter.agent_relay_cursor_pool_name.name == "agent_relay_cursor_pool_name" error_message = "Cursor pool parameter name is part of the Agent Relay contract" @@ -45,8 +40,8 @@ run "parameter_contract" { error_message = "Cursor idle release timeout parameter name is part of the Agent Relay contract" } - # Only the credential is ephemeral: the rest are queried back off the - # workspace with `param:` filters, which only sees declared values. + # None are ephemeral: they are queried back off the workspace with + # `param:` filters, which only sees declared values. assert { condition = alltrue([ data.coder_parameter.agent_relay_session_id.ephemeral == false, @@ -55,7 +50,6 @@ run "parameter_contract" { data.coder_parameter.agent_relay_cursor_pool_name.ephemeral == false, data.coder_parameter.agent_relay_cursor_repo_url.ephemeral == false, data.coder_parameter.agent_relay_cursor_idle_release_timeout.ephemeral == false, - data.coder_parameter.agent_relay_credential.ephemeral == true, ]) error_message = "parameter persistence does not match the Agent Relay contract" } @@ -71,28 +65,17 @@ run "parameter_contract" { data.coder_parameter.agent_relay_cursor_pool_name.styling, data.coder_parameter.agent_relay_cursor_repo_url.styling, data.coder_parameter.agent_relay_cursor_idle_release_timeout.styling, - data.coder_parameter.agent_relay_credential.styling, ] : can(regex("\"disabled\":true", p)) ]) error_message = "every relay parameter must render disabled" } - - assert { - condition = can(regex("\"mask_input\":true", data.coder_parameter.agent_relay_credential.styling)) - error_message = "the credential must be masked" - } } run "worker_wiring" { command = plan - # The Cursor CLI owns the worker id name; the token has no CLI env var - # and must not masquerade as CURSOR_API_KEY. - assert { - condition = coder_env.agent_relay_cursor_token.name == "AGENT_RELAY_CURSOR_TOKEN" - error_message = "the worker token env var is Agent Relay's, not a Cursor API key" - } - + # The Cursor CLI owns the worker id name. The token arrives as a + # workspace secret, not through this module. assert { condition = coder_env.cursor_agent_worker_id.name == "CURSOR_AGENT_WORKER_ID" error_message = "the worker id env var name is the Cursor CLI's contract" @@ -170,7 +153,7 @@ run "worker_wiring" { assert { condition = output.dispatched == false - error_message = "a build with no credential was not dispatched by Agent Relay" + error_message = "a build with no delivery was not dispatched by Agent Relay" } assert { diff --git a/registry/coder/modules/agent-relay-cursor/start.sh.tftpl b/registry/coder/modules/agent-relay-cursor/start.sh.tftpl index a0bb3be24..29314d24c 100644 --- a/registry/coder/modules/agent-relay-cursor/start.sh.tftpl +++ b/registry/coder/modules/agent-relay-cursor/start.sh.tftpl @@ -41,7 +41,7 @@ if [ -z "$${AGENT_RELAY_CURSOR_TOKEN:-}" ]; then write_state "idle" fi echo "No worker token set. This workspace was created manually, not by Agent Relay." - echo "Idling. Delete this workspace manually or set the agent_relay_credential parameter." + echo "Idling. Delete this workspace manually; only Agent Relay can deliver a worker token." exit 0 fi