From 9797e710e4e27b071b0cebd5259e8c7a68b101d3 Mon Sep 17 00:00:00 2001 From: "coder[bot]" <137810501+coder[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:38:48 +0000 Subject: [PATCH 1/4] fix(coder/agent-relay-cursor): read the session token from a file Start the worker with --auth-token-file instead of --auth-token. The token is written owner-only under the module directory, removed when the worker exits, and unset from the worker's environment, so it no longer appears in the worker's command line or in the environment the agent's commands inherit. The CLI re-reads the file on reconnect, so a replacement token takes effect without a restart, and the CLI no longer logs that the token cannot be refreshed. The README and the agent_relay_credential description described the old one-hour user sub-token. Agent Relay has stamped a per-claim Cursor session token since 0.11.0: it ends with the claim and has a 7-day backstop expiry. --- .../modules/agent-relay-cursor/README.md | 48 ++++++++++++------- .../modules/agent-relay-cursor/main.test.ts | 24 ++++++++-- .../coder/modules/agent-relay-cursor/main.tf | 8 ++-- .../agent-relay-cursor/main.tftest.hcl | 10 ++-- .../modules/agent-relay-cursor/start.sh.tftpl | 37 ++++++++++---- 5 files changed, 87 insertions(+), 40 deletions(-) diff --git a/registry/coder/modules/agent-relay-cursor/README.md b/registry/coder/modules/agent-relay-cursor/README.md index 29fefc442..8faacdc67 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.5.1" agent_id = coder_agent.main.id # Downloads the Cursor CLI at start when it is not in the image. Bake @@ -69,16 +69,27 @@ created. ## 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 -`--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. +The pool's service-account API key never leaves Agent Relay. When the relay +claims a request, Cursor mints a +[session token](https://cursor.com/docs/cloud-agent/self-hosted/pool#session-tokens) +for that claim, and the relay stamps it as the ephemeral +`agent_relay_credential` parameter. The module exports it as +`AGENT_RELAY_CURSOR_TOKEN`, writes it to `worker-token` (mode `0600`), and +starts the worker with `--auth-token-file`. The token stays off the worker's +command line, and the supervisor unsets the variable so the worker and the +agent's commands do not inherit it. The supervisor deletes the file when the +worker exits. + +A session token connects only the claimed worker id, and Cursor refuses it on +every endpoint a worker does not call. It stops working when the claim ends, +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. The worker re-reads the file when it reconnects, so a +replacement token written to the same path takes effect without a restart. + +Versions before 0.5.1 passed the token with `--auth-token`, which makes the +CLI log `Auth tokens cannot be refreshed automatically`. That warning is +printed for any `--auth-token` start and is not a one-hour limit. ## Parameters @@ -94,16 +105,17 @@ an install step that downloads the CLI when `install_cli` is set and the binary is missing (a no-op otherwise), then a start step that launches the worker. Everything lands under `$HOME/.coder-modules/coder/agent-relay-cursor`: -| path | contents | -| ---------------------------------------- | ---------------------------------------------- | -| `scripts/install.sh`, `scripts/start.sh` | the install and start steps as they ran | -| `scripts/supervise.sh` | the detached supervisor that owns the worker | -| `logs/*.log` | output of each step, plus the worker's own log | -| `worker-state` | the supervisor's lifecycle line (`state_file`) | +| path | contents | +| ---------------------------------------- | ------------------------------------------------ | +| `scripts/install.sh`, `scripts/start.sh` | the install and start steps as they ran | +| `scripts/supervise.sh` | the detached supervisor that owns the worker | +| `logs/*.log` | output of each step, plus the worker's own log | +| `worker-state` | the supervisor's lifecycle line (`state_file`) | +| `worker-token` | the session token, removed when the worker exits | ## Worker lifecycle -The start step launches `agent worker --pool ... --idle-release-timeout ... --auth-token ... start` +The start step launches `agent worker --pool ... --idle-release-timeout ... --auth-token-file ... start` detached and exits, so the agent reaches `ready` immediately. The worker exits `0` when its idle-release timer fires after a session; that clean exit is what tells Agent Relay to delete the workspace. The timer starts when the agent diff --git a/registry/coder/modules/agent-relay-cursor/main.test.ts b/registry/coder/modules/agent-relay-cursor/main.test.ts index 395892d1d..29f869d42 100644 --- a/registry/coder/modules/agent-relay-cursor/main.test.ts +++ b/registry/coder/modules/agent-relay-cursor/main.test.ts @@ -45,6 +45,7 @@ afterEach(async () => { // coder-utils pins the layout; the container runs as root. const MODULE_DIR = "/root/.coder-modules/coder/agent-relay-cursor"; const STATE_FILE = `${MODULE_DIR}/worker-state`; +const TOKEN_FILE = `${MODULE_DIR}/worker-token`; // The pool name carries shell metacharacters to prove the value reaches // the worker as a single argument rather than being parsed as code. const POOL_NAME = 'safe"; touch /tmp/injected; #'; @@ -337,8 +338,14 @@ describe("agent-relay-cursor", () => { // The metacharacters in the pool name must not have executed. const injected = await execContainer(id, ["test", "-e", "/tmp/injected"]); expect(injected.exitCode).not.toBe(0); - // The token reaches the worker as --auth-token, not as an API key. - expect(args[args.indexOf("--auth-token") + 1]).toBe("test-user-token"); + // The token reaches the worker as a file, never as an argument or an + // API key. + expect(args[args.indexOf("--auth-token-file") + 1]).toBe(TOKEN_FILE); + expect(args).not.toContain("--auth-token"); + expect(args).not.toContain("test-user-token"); + expect(await readFileContainer(id, TOKEN_FILE)).toBe("test-user-token"); + const mode = await execContainer(id, ["stat", "-c", "%a", TOKEN_FILE]); + expect(mode.stdout.trim()).toBe("600"); expect(args).toContain("start"); expect(args).not.toContain("--computer-use"); @@ -348,7 +355,7 @@ describe("agent-relay-cursor", () => { id, `${MODULE_DIR}/scripts/supervise.sh`, ); - expect(supervisor).toContain('--auth-token "$AGENT_RELAY_CURSOR_TOKEN"'); + expect(supervisor).toContain(`--auth-token-file "${TOKEN_FILE}"`); expect(supervisor).not.toContain("test-user-token"); // Parameter values are likewise read from the environment, never // written into the file where they would be parsed as shell. @@ -363,9 +370,12 @@ describe("agent-relay-cursor", () => { ]); expect(env.stdout).toContain("CURSOR_AGENT_WORKER_ID=worker-123"); expect(env.stdout).not.toContain("CURSOR_API_KEY="); + // The agent's commands run as children of the worker, so the token + // must not be in its environment either. + expect(env.stdout).not.toContain("AGENT_RELAY_CURSOR_TOKEN="); }); - it("passes the credential as --auth-token even with a stale credential kind", async () => { + it("passes the credential as --auth-token-file even with a stale credential kind", async () => { // Module 0.2.0 to 0.4.0 exported AGENT_RELAY_CURSOR_CREDENTIAL_KIND // and switched to CURSOR_API_KEY on api_key. A leftover value in the // environment must not change how the credential reaches the CLI. @@ -380,7 +390,8 @@ describe("agent-relay-cursor", () => { ]); expect(start.exitCode).toBe(0); const args = (await readFileContainer(id, "/tmp/agent-args")).split("\n"); - expect(args[args.indexOf("--auth-token") + 1]).toBe("test-user-token"); + expect(args[args.indexOf("--auth-token-file") + 1]).toBe(TOKEN_FILE); + expect(await readFileContainer(id, TOKEN_FILE)).toBe("test-user-token"); expect(await readFileContainer(id, "/tmp/agent-api-key")).toBe("unset"); }); @@ -414,6 +425,9 @@ describe("agent-relay-cursor", () => { const { start } = await runDispatched(id, scripts); expect(start.exitCode).toBe(0); expect(await waitForState(id, /^done \d+$/)).toBe("done 3"); + // The claim is over, so the supervisor removes the token. + const token = await execContainer(id, ["test", "-e", TOKEN_FILE]); + expect(token.exitCode).not.toBe(0); }); it("uses cli_binary and state_file overrides", async () => { diff --git a/registry/coder/modules/agent-relay-cursor/main.tf b/registry/coder/modules/agent-relay-cursor/main.tf index b3b72d2ba..e0387ec43 100644 --- a/registry/coder/modules/agent-relay-cursor/main.tf +++ b/registry/coder/modules/agent-relay-cursor/main.tf @@ -191,7 +191,7 @@ 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." + description = "Cursor session token the worker authenticates with. Agent Relay mints it with the claim from the pool's service account key; a human never fills it in. It serves only this request's claim and stops working when the claim ends. Ephemeral: not reused on a later build." type = "string" ephemeral = true mutable = true @@ -205,9 +205,9 @@ 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 +# The session token has no CLI env var, so it travels under an Agent +# Relay name and the start script writes it to a file for +# --auth-token-file. 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 diff --git a/registry/coder/modules/agent-relay-cursor/main.tftest.hcl b/registry/coder/modules/agent-relay-cursor/main.tftest.hcl index 4b175a0f6..78655fb93 100644 --- a/registry/coder/modules/agent-relay-cursor/main.tftest.hcl +++ b/registry/coder/modules/agent-relay-cursor/main.tftest.hcl @@ -141,12 +141,12 @@ run "worker_wiring" { error_message = "the worker must read pool name and idle timeout from the environment at run time" } - # The token reaches the worker as --auth-token from the supervisor's - # environment; it must not be expanded into the supervisor file the - # script writes to disk. + # The token reaches the worker through a file, never on its command + # line, and is not 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-token-file \"$token_file\"") && !strcontains(local.start_script, "--auth-token ") + error_message = "the worker must authenticate with --auth-token-file, not --auth-token" } # 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 a0bb3be24..172837e8b 100644 --- a/registry/coder/modules/agent-relay-cursor/start.sh.tftpl +++ b/registry/coder/modules/agent-relay-cursor/start.sh.tftpl @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Start step, run by coder-utils after the install step. Runs at -# agent start with AGENT_RELAY_CURSOR_TOKEN (a short-lived worker token -# minted for the requesting user), CURSOR_AGENT_WORKER_ID (the claimed +# agent start with AGENT_RELAY_CURSOR_TOKEN (the Cursor session token +# Agent Relay minted with the claim), CURSOR_AGENT_WORKER_ID (the claimed # worker id), AGENT_RELAY_CURSOR_POOL_NAME, and # AGENT_RELAY_CURSOR_IDLE_RELEASE_TIMEOUT in scope. # @@ -14,6 +14,9 @@ set -euo pipefail state_file="${state_file}" log_file="${log_file}" +# The worker reads its session token from this file. Always under the +# module directory, so an override of state_file does not move it. +token_file="${module_directory}/worker-token" # Runtime-materialized scripts live under the module directory's # scripts/ regardless of where state_file points, so an override of # state_file moves only the state. @@ -88,13 +91,27 @@ if ! command -v ${cli_binary} >/dev/null 2>&1; then exit 1 fi +# The worker authenticates with --auth-token-file rather than +# --auth-token: the token stays off the worker's command line, and the +# CLI re-reads the file when it reconnects, so a replacement written to +# the same path takes effect without a restart. Written owner-only and +# renamed into place so the worker never reads a partial token. +( + umask 077 + printf '%s' "$AGENT_RELAY_CURSOR_TOKEN" >"$token_file.tmp" +) +mv "$token_file.tmp" "$token_file" + # The supervisor outlives this script: it owns the worker process and # is the only writer of terminal state. Written to disk rather than -# inlined so setsid gets a clean argv. The token, pool name, and idle -# timeout are read from the supervisor's environment at run time, -# escaped here so they are never rendered into the file. Parameter -# values are user input; interpolating them into the file would let -# shell metacharacters run as code. The worker exits 0 when its +# inlined so setsid gets a clean argv. The pool name and idle timeout +# are read from the supervisor's environment at run time, escaped here +# so they are never rendered into the file. Parameter values are user +# input; interpolating them into the file would let shell +# metacharacters run as code. The token itself never reaches the file +# or the worker's environment: the worker reads it from token_file, and +# the supervisor unsets the variable so the agent's commands, which the +# worker spawns, do not inherit it. The worker exits 0 when its # idle-release timer fires after a session ends; that clean exit is # what tells Agent Relay to reap the workspace. supervisor="$scripts_dir/supervise.sh" @@ -108,10 +125,11 @@ write_state() { } export PATH="\$HOME/.local/bin:\$PATH" +unset AGENT_RELAY_CURSOR_TOKEN ${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-token-file "$token_file" \\ %{ if computer_use ~} --computer-use \\ %{ endif ~} @@ -124,6 +142,9 @@ write_state "working \$worker_pid" wait "\$worker_pid" code=\$? +# The claim is over once the worker exits, so its token is no use to +# anyone; do not leave it on disk. +rm -f "$token_file" write_state "done \$code" SUPERVISOR chmod +x "$supervisor" From 01db41c0a3a1271867907a427f51446f5c149a71 Mon Sep 17 00:00:00 2001 From: "coder[bot]" <137810501+coder[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 07:42:21 +0000 Subject: [PATCH 2/4] docs(coder/agent-relay-cursor): drop the note about the old --auth-token warning --- registry/coder/modules/agent-relay-cursor/README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/registry/coder/modules/agent-relay-cursor/README.md b/registry/coder/modules/agent-relay-cursor/README.md index 8faacdc67..2fef60460 100644 --- a/registry/coder/modules/agent-relay-cursor/README.md +++ b/registry/coder/modules/agent-relay-cursor/README.md @@ -87,10 +87,6 @@ that minted it is deleted or expires. Its 7-day expiry is a backstop, not a session limit. The worker re-reads the file when it reconnects, so a replacement token written to the same path takes effect without a restart. -Versions before 0.5.1 passed the token with `--auth-token`, which makes the -CLI log `Auth tokens cannot be refreshed automatically`. That warning is -printed for any `--auth-token` start and is not a one-hour limit. - ## Parameters Agent Relay verifies this contract against the template's active version at From f287c5be6f3fd06b1a1818fc224887aa93ca25ec Mon Sep 17 00:00:00 2001 From: "coder[bot]" <137810501+coder[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:30:22 +0000 Subject: [PATCH 3/4] chore(coder/agent-relay-cursor): ship as the unreleased 0.5.0 --- registry/coder/modules/agent-relay-cursor/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/registry/coder/modules/agent-relay-cursor/README.md b/registry/coder/modules/agent-relay-cursor/README.md index 2fef60460..7d5a849d3 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.1" + version = "0.5.0" agent_id = coder_agent.main.id # Downloads the Cursor CLI at start when it is not in the image. Bake From eec59a6a66475d344a9be43e3473f74f4b00c4f8 Mon Sep 17 00:00:00 2001 From: Muhammad Atif Ali Date: Thu, 15 Oct 2026 22:56:14 +0500 Subject: [PATCH 4/4] docs(coder/agent-relay-cursor): drop the token re-read note The module is used through Agent Relay, which does not rotate the token. --- registry/coder/modules/agent-relay-cursor/README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/registry/coder/modules/agent-relay-cursor/README.md b/registry/coder/modules/agent-relay-cursor/README.md index 7d5a849d3..a3fe85ba0 100644 --- a/registry/coder/modules/agent-relay-cursor/README.md +++ b/registry/coder/modules/agent-relay-cursor/README.md @@ -84,8 +84,7 @@ A session token connects only the claimed worker id, and Cursor refuses it on every endpoint a worker does not call. It stops working when the claim ends, 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. The worker re-reads the file when it reconnects, so a -replacement token written to the same path takes effect without a restart. +session limit. ## Parameters