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
41 changes: 24 additions & 17 deletions registry/coder/modules/agent-relay-cursor/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,22 @@ 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/<pid>/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.

## Parameters

Expand All @@ -94,16 +100,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
Expand Down
24 changes: 19 additions & 5 deletions registry/coder/modules/agent-relay-cursor/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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; #';
Expand Down Expand Up @@ -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");

Expand All @@ -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.
Expand All @@ -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.
Expand All @@ -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");
});

Expand Down Expand Up @@ -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 () => {
Expand Down
8 changes: 4 additions & 4 deletions registry/coder/modules/agent-relay-cursor/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
10 changes: 5 additions & 5 deletions registry/coder/modules/agent-relay-cursor/main.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
37 changes: 29 additions & 8 deletions registry/coder/modules/agent-relay-cursor/start.sh.tftpl
Original file line number Diff line number Diff line change
@@ -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.
#
Expand All @@ -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.
Expand Down Expand Up @@ -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"
)
Comment thread
matifali marked this conversation as resolved.
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"
Expand All @@ -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 ~}
Expand All @@ -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"
Expand Down
Loading