diff --git a/registry/coder/modules/agent-relay-cursor/README.md b/registry/coder/modules/agent-relay-cursor/README.md index 0687a3210..5266ba931 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.1.1" + version = "0.2.0" agent_id = coder_agent.main.id # Downloads the Cursor CLI at start when it is not in the image. Bake @@ -53,17 +53,32 @@ when to reap the workspace. ## Worker credential -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 +By default 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. It +is exported as `AGENT_RELAY_CURSOR_TOKEN` and the worker starts 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 after an hour. It is not refreshed: a -worker that has to reconnect after expiry fails and Cursor re-queues the -request for a fresh workspace. +it. The token acts only as that user, cannot mint further tokens, and expires +after an hour. It is not refreshed: a worker that has to reconnect after expiry +fails and Cursor re-queues the request for a fresh workspace. + +Some Cursor CLI releases refuse a delegated sub-token for pool workers +(`Delegated service-account tokens cannot start pool workers`). Agent Relay's +per-pool `insecure_shared_token` then stamps the service-account key itself, +together with `agent_relay_cursor_credential_kind = api_key`. The CLI accepts +that key only as an API key, never as `--auth-token` +(`Failed to validate worker account settings`), so the supervisor exports the +credential as `CURSOR_API_KEY` and drops `--auth-token` when the stamped kind +says so. Every workspace owner in such a pool can read a team-wide key from +the worker's environment. No template change is needed; the relay stamps the +kind and the module follows it. + +| pool `insecure_shared_token` | stamped `agent_relay_cursor_credential_kind` | credential handed to the CLI as | +| ---------------------------- | -------------------------------------------- | ------------------------------- | +| `false` (default) | `worker_token` | `--auth-token` | +| `true` | `api_key` | `CURSOR_API_KEY` | ## Parameters diff --git a/registry/coder/modules/agent-relay-cursor/main.test.ts b/registry/coder/modules/agent-relay-cursor/main.test.ts index 65d61257a..dda52b7a7 100644 --- a/registry/coder/modules/agent-relay-cursor/main.test.ts +++ b/registry/coder/modules/agent-relay-cursor/main.test.ts @@ -53,6 +53,7 @@ const DISPATCH_ENV = [ "CURSOR_AGENT_WORKER_ID=worker-123", `AGENT_RELAY_CURSOR_POOL_NAME=${POOL_NAME}`, "AGENT_RELAY_CURSOR_IDLE_RELEASE_TIMEOUT=600", + "AGENT_RELAY_CURSOR_CREDENTIAL_KIND=worker_token", ]; const setup = async (vars: Record = {}) => { @@ -365,6 +366,39 @@ describe("agent-relay-cursor", () => { expect(env.stdout).not.toContain("CURSOR_API_KEY="); }); + it("hands a shared service-account key over as CURSOR_API_KEY", async () => { + // insecure_shared_token pools stamp the team key and + // agent_relay_cursor_credential_kind=api_key; the CLI only accepts + // that key as an API key, so --auth-token must be absent and the key + // must reach the worker's environment. + const { id, scripts } = await setup(); + await stubAgent( + id, + 'printf "%s\\n" "$@" >/tmp/agent-args; printf "%s" "$CURSOR_API_KEY" >/tmp/agent-api-key; sleep 30', + ); + const { start } = await runScripts(id, scripts, [ + ...DISPATCH_ENV, + "AGENT_RELAY_CURSOR_CREDENTIAL_KIND=api_key", + ]); + expect(start.exitCode).toBe(0); + const args = (await readFileContainer(id, "/tmp/agent-args")).split("\n"); + expect(args).not.toContain("--auth-token"); + expect(args).toContain("--pool"); + expect(await readFileContainer(id, "/tmp/agent-api-key")).toBe( + "test-user-token", + ); + // The key is still read from the environment at run time, never + // written into the supervisor. + const supervisor = await readFileContainer( + id, + `${MODULE_DIR}/scripts/supervise.sh`, + ); + expect(supervisor).toContain( + 'export CURSOR_API_KEY="$AGENT_RELAY_CURSOR_TOKEN"', + ); + expect(supervisor).not.toContain("test-user-token"); + }); + it("passes --computer-use when enabled", async () => { const { id, scripts } = await setup({ computer_use: "true" }); await stubAgent(id, 'printf "%s\\n" "$@" >/tmp/agent-args; sleep 30'); diff --git a/registry/coder/modules/agent-relay-cursor/main.tf b/registry/coder/modules/agent-relay-cursor/main.tf index 33298200a..2b5f7aae5 100644 --- a/registry/coder/modules/agent-relay-cursor/main.tf +++ b/registry/coder/modules/agent-relay-cursor/main.tf @@ -14,7 +14,8 @@ # # - agent_relay_session_id, agent_relay_delivery_id, agent_relay_pool, # agent_relay_cursor_pool_name, agent_relay_cursor_idle_release_timeout, -# and agent_relay_cursor_repo_url are persistent state: coderd only +# agent_relay_cursor_repo_url, and agent_relay_cursor_credential_kind +# are persistent state: coderd only # stores parameter values the template declares, and Agent Relay's # dedupe and reconciliation query workspaces with `param:` search # filters on them. agent_relay_cursor_repo_url is stamped on every @@ -177,6 +178,29 @@ data "coder_parameter" "agent_relay_cursor_repo_url" { }) } +data "coder_parameter" "agent_relay_cursor_credential_kind" { + name = "agent_relay_cursor_credential_kind" + display_name = "Cursor credential kind" + description = "How the worker hands agent_relay_credential to the Cursor CLI. worker_token passes the per-user sub-token as --auth-token. api_key exports the pool's service-account key as CURSOR_API_KEY, which is the only form the CLI accepts it in; Agent Relay sets this to api_key for pools with insecure_shared_token. A human never fills it in." + type = "string" + mutable = true + default = "worker_token" + order = 1006 + styling = jsonencode({ + disabled = true + placeholder = "Set by Agent Relay on dispatch" + }) + + option { + name = "Worker token (--auth-token)" + value = "worker_token" + } + option { + name = "Service-account API key (CURSOR_API_KEY)" + value = "api_key" + } +} + data "coder_parameter" "agent_relay_credential" { name = "agent_relay_credential" display_name = "Agent Relay credential" @@ -185,7 +209,7 @@ data "coder_parameter" "agent_relay_credential" { ephemeral = true mutable = true default = "" - order = 1006 + order = 1007 styling = jsonencode({ disabled = true mask_input = true @@ -194,10 +218,12 @@ data "coder_parameter" "agent_relay_credential" { } # 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. +# The credential travels under an Agent Relay name and the supervisor +# decides how to hand it to the CLI from the stamped credential kind: a +# per-user worker token has no CLI env var and goes on the command line +# as --auth-token; a team service-account key is only accepted as +# CURSOR_API_KEY. Each is rejected through the other door, which is why +# the relay stamps the kind alongside the credential. resource "coder_env" "agent_relay_cursor_token" { agent_id = var.agent_id name = "AGENT_RELAY_CURSOR_TOKEN" @@ -225,6 +251,12 @@ resource "coder_env" "agent_relay_cursor_idle_release_timeout" { value = data.coder_parameter.agent_relay_cursor_idle_release_timeout.value } +resource "coder_env" "agent_relay_cursor_credential_kind" { + agent_id = var.agent_id + name = "AGENT_RELAY_CURSOR_CREDENTIAL_KIND" + value = data.coder_parameter.agent_relay_cursor_credential_kind.value +} + locals { # coder-utils requires this exact layout. Scripts land in scripts/ and # their output in logs/; the worker state and log default to the same diff --git a/registry/coder/modules/agent-relay-cursor/main.tftest.hcl b/registry/coder/modules/agent-relay-cursor/main.tftest.hcl index 9a5e2495d..36340084d 100644 --- a/registry/coder/modules/agent-relay-cursor/main.tftest.hcl +++ b/registry/coder/modules/agent-relay-cursor/main.tftest.hcl @@ -55,6 +55,7 @@ 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_cursor_credential_kind.ephemeral == false, data.coder_parameter.agent_relay_credential.ephemeral == true, ]) error_message = "parameter persistence does not match the Agent Relay contract" @@ -145,8 +146,14 @@ run "worker_wiring" { # environment; it must not be expanded into the supervisor file the # script writes to disk. assert { - condition = strcontains(local.start_script, "--auth-token \"\\$AGENT_RELAY_CURSOR_TOKEN\"") - error_message = "the worker must authenticate with --auth-token read from the environment at run time" + condition = strcontains(local.start_script, "auth_args=(--auth-token \"\\$AGENT_RELAY_CURSOR_TOKEN\")") && strcontains(local.start_script, "export CURSOR_API_KEY=\"\\$AGENT_RELAY_CURSOR_TOKEN\"") + error_message = "the supervisor must offer both credential paths, each reading the token from the environment at run time" + } + + # The relay stamps which path applies; the supervisor branches on it. + assert { + condition = data.coder_parameter.agent_relay_cursor_credential_kind.name == "agent_relay_cursor_credential_kind" && data.coder_parameter.agent_relay_cursor_credential_kind.default == "worker_token" && coder_env.agent_relay_cursor_credential_kind.name == "AGENT_RELAY_CURSOR_CREDENTIAL_KIND" + error_message = "the credential kind is a stamped parameter defaulting to worker_token, exported for the supervisor" } # Reaping reads this file through the agent_relay_status metadata item, diff --git a/registry/coder/modules/agent-relay-cursor/start.sh.tftpl b/registry/coder/modules/agent-relay-cursor/start.sh.tftpl index 67e32841f..5e70a712f 100644 --- a/registry/coder/modules/agent-relay-cursor/start.sh.tftpl +++ b/registry/coder/modules/agent-relay-cursor/start.sh.tftpl @@ -108,10 +108,21 @@ write_state() { } export PATH="\$HOME/.local/bin:\$PATH" +# The relay stamps how the credential must reach the CLI. A pool with +# insecure_shared_token sends its team service-account key, which the +# CLI accepts only as CURSOR_API_KEY; a per-user worker token is only +# accepted as --auth-token. Both are read from the environment at run +# time so neither value lands in this file. +auth_args=() +if [ "\$${AGENT_RELAY_CURSOR_CREDENTIAL_KIND:-worker_token}" = api_key ]; then + export CURSOR_API_KEY="\$AGENT_RELAY_CURSOR_TOKEN" +else + auth_args=(--auth-token "\$AGENT_RELAY_CURSOR_TOKEN") +fi ${cli_binary} worker \\ --pool "\$AGENT_RELAY_CURSOR_POOL_NAME" \\ --idle-release-timeout "\$AGENT_RELAY_CURSOR_IDLE_RELEASE_TIMEOUT" \\ - --auth-token "\$AGENT_RELAY_CURSOR_TOKEN" \\ + \$${auth_args[@]+"\$${auth_args[@]}"} \\ %{ if computer_use ~} --computer-use \\ %{ endif ~}