From d777d95cceac0768a8fd9337e50747241bde1883 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 11 Sep 2026 00:48:20 +0000 Subject: [PATCH 1/5] feat(agent-relay-cursor): add the Agent Relay Cursor module Template module that declares the parameter contract Agent Relay stamps on a build and runs the Cursor CLI worker detached, publishing its lifecycle through the agent_relay_status agent metadata item. Moved from coder/agent-relay, where it was developed against the daemon. --- .../modules/agent-relay-cursor/README.md | 169 +++++++++++++ .../coder/modules/agent-relay-cursor/main.tf | 223 ++++++++++++++++++ .../agent-relay-cursor/main.tftest.hcl | 196 +++++++++++++++ .../modules/agent-relay-cursor/run.sh.tftpl | 114 +++++++++ .../agent-relay-cursor/status.sh.tftpl | 57 +++++ 5 files changed, 759 insertions(+) create mode 100644 registry/coder/modules/agent-relay-cursor/README.md create mode 100644 registry/coder/modules/agent-relay-cursor/main.tf create mode 100644 registry/coder/modules/agent-relay-cursor/main.tftest.hcl create mode 100644 registry/coder/modules/agent-relay-cursor/run.sh.tftpl create mode 100644 registry/coder/modules/agent-relay-cursor/status.sh.tftpl diff --git a/registry/coder/modules/agent-relay-cursor/README.md b/registry/coder/modules/agent-relay-cursor/README.md new file mode 100644 index 000000000..259b7f736 --- /dev/null +++ b/registry/coder/modules/agent-relay-cursor/README.md @@ -0,0 +1,169 @@ +--- +display_name: Agent Relay Cursor +description: Serves Cursor cloud agent requests in Coder workspaces that Agent Relay dispatches. +icon: ../../../../.icons/cursor.svg +verified: true +tags: [agent, cursor, agent-relay] +--- + +# Agent Relay Cursor + +Makes a Coder template a target for [Agent Relay](https://coder.com/docs/ai-coder/agent-relay) +Cursor pools: include the module, wire it to your `coder_agent`, and Agent +Relay can dispatch [Cursor cloud agent](https://coder.com/docs/ai-coder/agent-relay/cursor) +requests to workspaces built from the template. + +```tf +module "cursor_worker" { + source = "registry.coder.com/coder/agent-relay-cursor/coder" + version = "1.0.0" + agent_id = coder_agent.main.id + + # Default. Installs the CLI only when the image does not already + # carry it; baking it into the image stays the fastest path. + install_cli = true +} + +resource "coder_agent" "main" { + # ... + metadata { + key = "agent_relay_status" + display_name = "Worker" + script = module.cursor_worker.status_metadata_script + interval = 10 + timeout = 5 + } +} +``` + +The `agent_relay_status` metadata block is required and must live on the +agent: the coder provider has no standalone agent-metadata resource, so +a module cannot declare it. Referencing the module's +`status_metadata_script` output does not create a dependency cycle; the +output does not depend on `agent_id`. + +Requirements for the template and image: + +- The Cursor CLI (`agent`) must be available in the workspace. + `install_cli` defaults to `true` and downloads the latest CLI at + workspace start, but only when the binary is not already on PATH: a + CLI in the image is used as is, never upgraded, and costs nothing. + When the download does run it needs outbound access to cursor.com and + spends part of the claim-to-ready window. `cli_binary` overrides the + path. With no CLI and no successful install, the worker reports + `failed runner-agent-missing`, which + Agent Relay reaps and records. +- For repo-scoped pools the template must clone the repository named + by `agent_relay_cursor_repo_url` and provide SCM credentials before the worker + starts, e.g. via a startup script ordered before this module's + script. Repo-less pools should provide a `.cursor/rules` file in the + worker's working directory instead. +- With `computer_use = true` the image must also carry the + computer-use packages the Cursor CLI expects. +- Builds should complete within the pool's `dispatch_deadline` + (default 10m): pre-pulled images, no persistent volumes. The + deadline cannot exceed 15m: Agent Relay's reaper deletes + workspaces stuck pending or starting after that grace, and it does + so even when `dispatch_deadline` is `"0"` (no dispatcher deadline). + +## Credential exposure + +The pool's service-account API key reaches every dispatched workspace: +it is stamped as the ephemeral `agent_relay_credential` build parameter +and exported as the `CURSOR_API_KEY` environment variable, where the +workspace owner can read it for the lifetime of the workspace. Treat +the key accordingly: + +- Use a dedicated service-account key per pool, scoped to the + repository the pool serves, distinct from any broader Cursor + key your team uses. +- Rotating the key in Agent Relay's config only affects new builds; + running workspaces keep the value they were built with until reaped. + +Sub-token worker authentication (short-lived per-worker credentials +instead of the pool key) is the tracked hardening follow-up. + +The module declares the full parameter contract Agent Relay stamps on +every build; Agent Relay verifies the contract against the template's +active version at startup (dynamic parameters evaluate endpoint) and +refuses to serve a pool whose template does not satisfy it: + +| parameter | kind | value | +| ----------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------- | +| `agent_relay_session_id` | persistent state | Cursor request served by this workspace (dedupe/reconciliation key) | +| `agent_relay_delivery_id` | persistent state | worker id the request was claimed with; exported as `CURSOR_AGENT_WORKER_ID` | +| `agent_relay_pool` | persistent state | Agent Relay pool that dispatched this build | +| `agent_relay_cursor_pool_name` | persistent state | Cursor-side pool the worker registers under | +| `agent_relay_cursor_idle_release_timeout` | persistent state | seconds the worker idles after the agent finishes a turn before releasing and exiting; keep it at or above 300 (see below) | +| `agent_relay_cursor_repo_url` | persistent state | repository the request targets; empty for repo-less pools, but always declared so the contract stays static | +| `agent_relay_credential` | ephemeral | service-account API key the worker authenticates with; exported as `CURSOR_API_KEY` | + +The persistent parameters matter because coderd only stores values the +template declares; Agent Relay's dedupe and reconciliation depend on +querying them with `param:` search filters. + +Every parameter above renders disabled, with a placeholder saying Agent Relay +sets it. That is cosmetic, not a guardrail: it keeps a person creating a +workspace by hand from filling in machine-set fields, and does not stop the +CLI or the API from sending values. The credential is masked for the same +reason. + +## Worker lifecycle reporting + +Agent Relay deletes a workspace once its worker exits, and records the +failure when the worker died instead; Cursor offers no nack, so +observability is the only failure signal. Neither Coder nor Agent Relay +knows what a Cursor session is, so the module publishes the worker's +lifecycle and Agent Relay reads it. + +The worker script starts + +```sh +CURSOR_API_KEY= < token > CURSOR_AGENT_WORKER_ID= < worker id > \ +agent worker --pool < agent_relay_cursor_pool_name > \ + --idle-release-timeout < idle_release_timeout > start +``` + +detached and exits, so the agent reaches the `ready` lifecycle state +instead of sitting in `starting` for the life of the session. The +worker exits `0` on its own once the idle-release timer fires after a +session ends; that clean exit is what tells Agent Relay to reap. Mind the +timer semantics: it starts when the agent finishes a turn, not when +the user closes the chat, so `idle_release_timeout` must cover the +user's reading and typing time. Once released, a follow-up to that +chat errors on Cursor's side until Cursor issues a new pending request +(which Agent Relay then serves with a fresh workspace). Keep the timeout +at or above 300 seconds (Agent Relay warns below that; the default is +600). A +detached supervisor owns the worker process and writes single-line +state to `state_file` (`/tmp/agent-relay/worker-state` by default). +The `agent_relay_status` metadata item reports one of: + +| value | meaning | +| ----------------- | ------------------------------------------------------------------------------------------------------- | +| `pending` | supervisor has not recorded state yet (agent just started) | +| `idle` | no worker token: workspace was created manually | +| `working` | worker alive, no session attached yet | +| `serving` | worker alive and a session has attached | +| `orphaned` | state says working but the process is gone, and the supervisor died too | +| `done ` | worker exited with that status (`0` is the normal idle release; `137` means it was killed) | +| `failed ` | the worker could not be started at all, e.g. `runner-agent-missing` when the binary is not in the image | + +`working` versus `serving` comes from matching `serving_log_pattern` +against the worker log. At default verbosity the Cursor CLI log +carries no session-attach line at all, so the pattern can only match +with verbose worker logs and typically never fires. That is fine: +Agent Relay's status page overlays Cursor's authoritative in-use worker +state (from the fleet worker listing) on top of this metadata, so a +workspace whose worker is in use renders as serving either way, and +reaping decisions rest on the process lifecycle. The metadata-level +distinction is therefore best-effort and purely cosmetic; tune the +pattern only if you run verbose logs and want the distinction inside +the workspace metadata too. + +The script's timings row is not the reaping signal. coderd records it +when the script ends, which is now seconds after the agent starts, long +before the session finishes. + +The `agent_relay_status` key is part of the contract; renaming it breaks +reaping. diff --git a/registry/coder/modules/agent-relay-cursor/main.tf b/registry/coder/modules/agent-relay-cursor/main.tf new file mode 100644 index 000000000..b39b19730 --- /dev/null +++ b/registry/coder/modules/agent-relay-cursor/main.tf @@ -0,0 +1,223 @@ +# Cursor self-hosted worker module for Coder templates. +# +# A Cursor-compatible template includes this module and passes it the +# workspace's coder_agent id. It declares every rich parameter +# Agent Relay stamps on a build and runs `agent worker ... start` +# via a coder_script. +# +# Parameter contract (enforced by Agent Relay at startup via the dynamic +# parameters evaluate endpoint): +# +# - 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 +# 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 +# build (empty for repo-less pools) so the contract stays static. +# - agent_relay_credential is an ephemeral worker input, reset between +# builds. +# +# The worker's lifecycle is published through the agent_relay_status agent +# metadata item, whose script this module renders. The script starts +# the worker detached and exits so the agent reaches the ready +# lifecycle state rather than sitting in starting for the whole +# session. + +terraform { + required_providers { + coder = { + source = "coder/coder" + version = ">= 2.4.0" + } + } +} + +variable "agent_id" { + type = string + description = "ID of the coder_agent that should receive the worker env vars and run the worker script." +} + +variable "cli_binary" { + type = string + default = "agent" + description = "Path to the Cursor CLI binary in the workspace image. Override to test a beta build." +} + +variable "install_cli" { + type = bool + default = true + description = "Install the Cursor CLI (curl https://cursor.com/install -fsSL | bash) when the workspace starts and the CLI is not already on PATH. Defaults to true so a template works against an image that has no CLI. A CLI already in the image is used as is and never upgraded, which is the recommended and fastest path: the download only runs when the binary is missing, and it then needs outbound access to cursor.com and spends part of the claim-to-ready window." +} + +variable "computer_use" { + type = bool + default = false + description = "Start the worker with --computer-use. Requires the computer-use packages in the workspace image." +} + +variable "state_file" { + type = string + default = "/tmp/agent-relay/worker-state" + description = "Path the worker supervisor writes its lifecycle state to, read by the agent_relay_status agent metadata item." +} + +variable "log_file" { + type = string + default = "/tmp/agent-relay/worker.log" + description = "Path the detached worker's output is written to." +} + +variable "serving_log_pattern" { + type = string + default = "in use" + description = "Worker log substring that means a chat session attached. At default verbosity the Cursor CLI log carries no session line, so this only works with verbose worker logs and typically never matches; Agent Relay's status page overlays Cursor's authoritative in-use worker state regardless, so the working versus serving distinction here is best-effort and purely cosmetic. A pattern that never matches degrades to working and affects nothing else." +} + +data "coder_parameter" "agent_relay_session_id" { + name = "agent_relay_session_id" + display_name = "Agent Relay session" + description = "Cursor cloud agent request this workspace serves. Agent Relay sets this when it dispatches the workspace; a human never fills it in. The relay uses it to recognize its own workspaces, dedupe redeliveries, and reconcile state after a restart." + type = "string" + mutable = true + default = "" + order = 1000 + styling = jsonencode({ + disabled = true + placeholder = "Set by Agent Relay on dispatch" + }) +} + +data "coder_parameter" "agent_relay_delivery_id" { + name = "agent_relay_delivery_id" + display_name = "Agent Relay delivery" + description = "Worker identity Agent Relay claimed the request with. Agent Relay sets this when it dispatches the workspace; a human never fills it in. The worker CLI presents it back to Cursor through CURSOR_AGENT_WORKER_ID, which is how Cursor matches the worker to the request." + type = "string" + mutable = true + default = "" + order = 1001 + styling = jsonencode({ + disabled = true + placeholder = "Set by Agent Relay on dispatch" + }) +} + +data "coder_parameter" "agent_relay_pool" { + name = "agent_relay_pool" + display_name = "Agent Relay pool" + description = "Agent Relay worker pool that dispatched this build. Agent Relay sets this when it dispatches the workspace; a human never fills it in. One relay can serve several pools, each with its own Cursor credential, organization, and template." + type = "string" + mutable = true + default = "" + order = 1002 + styling = jsonencode({ + disabled = true + placeholder = "Set by Agent Relay on dispatch" + }) +} + +data "coder_parameter" "agent_relay_cursor_pool_name" { + name = "agent_relay_cursor_pool_name" + display_name = "Cursor pool" + description = "Pool name on Cursor's side that the worker registers under, which is what a developer selects when starting a session. Agent Relay sets this from the pool configuration; a human never fills it in. It is distinct from the relay's own label for the pool." + type = "string" + mutable = true + default = "" + order = 1003 + styling = jsonencode({ + disabled = true + placeholder = "Set by Agent Relay on dispatch" + }) +} + +data "coder_parameter" "agent_relay_cursor_idle_release_timeout" { + name = "agent_relay_cursor_idle_release_timeout" + display_name = "Cursor idle release timeout" + description = "Seconds the worker stays connected after a session ends, waiting for a follow-up, before releasing itself and exiting. Agent Relay sets this from the pool configuration; a human never fills it in. The clean exit is what tells the relay to delete the workspace." + type = "string" + mutable = true + default = "600" + order = 1004 + styling = jsonencode({ + disabled = true + placeholder = "Set by Agent Relay on dispatch" + }) +} + +data "coder_parameter" "agent_relay_cursor_repo_url" { + name = "agent_relay_cursor_repo_url" + display_name = "Cursor repository" + description = "Repository the request targets, empty for pools that are not repo-scoped. Agent Relay sets this from the pool configuration; a human never fills it in. Cloning it and providing SCM credentials is the template's job; refer to the module README." + type = "string" + mutable = true + default = "" + order = 1005 + styling = jsonencode({ + disabled = true + placeholder = "Set by Agent Relay on dispatch" + }) +} + +data "coder_parameter" "agent_relay_credential" { + name = "agent_relay_credential" + display_name = "Agent Relay credential" + description = "Cursor service account API key the worker authenticates with. Agent Relay sets this when it dispatches the workspace; a human never fills it in. Ephemeral: it is supplied per build and is not reused on a later one." + type = "string" + ephemeral = true + mutable = true + default = "" + order = 1006 + styling = jsonencode({ + disabled = true + mask_input = true + placeholder = "Set by Agent Relay on dispatch" + }) +} + +# Environment variable names are the Cursor CLI's contract, not +# Agent Relay's; do not rename them here. +resource "coder_env" "cursor_api_key" { + agent_id = var.agent_id + name = "CURSOR_API_KEY" + value = data.coder_parameter.agent_relay_credential.value +} + +resource "coder_env" "cursor_agent_worker_id" { + agent_id = var.agent_id + name = "CURSOR_AGENT_WORKER_ID" + value = data.coder_parameter.agent_relay_delivery_id.value +} + +resource "coder_script" "worker" { + agent_id = var.agent_id + display_name = "Cursor worker" + icon = "/icon/cursor.svg" + run_on_start = true + script = templatefile("${path.module}/run.sh.tftpl", { + cli_binary = var.cli_binary + install_cli = var.install_cli + computer_use = var.computer_use + state_file = var.state_file + log_file = var.log_file + pool_name = data.coder_parameter.agent_relay_cursor_pool_name.value + idle_release_timeout = data.coder_parameter.agent_relay_cursor_idle_release_timeout.value + }) +} + +# The coder provider has no standalone agent-metadata resource: the +# metadata block belongs to coder_agent, which the template owns. The +# template must add the block below; this output renders its script so +# the state file path stays in one place. See README. +output "status_metadata_script" { + description = "Script body for the agent_relay_status agent metadata item the template must declare on its coder_agent." + value = templatefile("${path.module}/status.sh.tftpl", { + state_file = var.state_file + log_file = var.log_file + serving_log_pattern = var.serving_log_pattern + }) +} + +output "dispatched" { + description = "Whether this workspace was spawned by Agent Relay (credential set) or manually (empty)." + value = data.coder_parameter.agent_relay_credential.value != "" +} diff --git a/registry/coder/modules/agent-relay-cursor/main.tftest.hcl b/registry/coder/modules/agent-relay-cursor/main.tftest.hcl new file mode 100644 index 000000000..9e149e64b --- /dev/null +++ b/registry/coder/modules/agent-relay-cursor/main.tftest.hcl @@ -0,0 +1,196 @@ +# Terraform tests for the parameter contract and the rendered worker +# script. Run with `terraform init && terraform test` in this directory. + +variables { + agent_id = "00000000-0000-0000-0000-000000000000" +} + +run "parameter_contract" { + command = plan + + assert { + condition = data.coder_parameter.agent_relay_session_id.name == "agent_relay_session_id" + error_message = "session id parameter name is part of the Agent Relay contract" + } + + assert { + condition = data.coder_parameter.agent_relay_delivery_id.name == "agent_relay_delivery_id" + error_message = "delivery id parameter name is part of the Agent Relay contract" + } + + assert { + condition = data.coder_parameter.agent_relay_pool.name == "agent_relay_pool" + 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" + } + + assert { + condition = data.coder_parameter.agent_relay_cursor_repo_url.name == "agent_relay_cursor_repo_url" + error_message = "Cursor repository parameter name is part of the Agent Relay contract" + } + + assert { + condition = data.coder_parameter.agent_relay_cursor_idle_release_timeout.name == "agent_relay_cursor_idle_release_timeout" + 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. + 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_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" + } + + # Cosmetic, but the point of it is that a human opening the create form + # cannot type into a machine-set field. + assert { + condition = alltrue([ + for p in [ + data.coder_parameter.agent_relay_session_id.styling, + data.coder_parameter.agent_relay_delivery_id.styling, + data.coder_parameter.agent_relay_pool.styling, + 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 these names; the module only supplies values. + assert { + condition = coder_env.cursor_api_key.name == "CURSOR_API_KEY" + error_message = "the API key env var name is the Cursor CLI's contract" + } + + 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" + } + + assert { + condition = coder_script.worker.run_on_start + error_message = "the worker script must run when the agent starts" + } + + assert { + condition = can(regex("--pool", coder_script.worker.script)) && can(regex("--idle-release-timeout", coder_script.worker.script)) + error_message = "the worker script must start the pool worker" + } + + # Reaping reads this file through the agent_relay_status metadata item, + # so the script and the metadata script must agree on the path. + assert { + condition = can(regex(var.state_file, coder_script.worker.script)) && can(regex(var.state_file, output.status_metadata_script)) + error_message = "the worker script and the status script must read the same state file" + } + + assert { + condition = can(regex("failed runner-agent-missing", coder_script.worker.script)) + error_message = "the missing-binary reason is the vocabulary the reaper grades" + } + + assert { + condition = output.dispatched == false + error_message = "a build with no credential was not dispatched by Agent Relay" + } +} + +run "computer_use_off_by_default" { + command = plan + + assert { + condition = !can(regex("--computer-use", coder_script.worker.script)) + error_message = "computer use requires packages the image may not carry, so it must be opt in" + } +} + +run "computer_use_enabled" { + command = plan + + variables { + computer_use = true + } + + assert { + condition = can(regex("--computer-use", coder_script.worker.script)) + error_message = "computer_use = true must pass the flag to the worker" + } +} + +run "install_cli_enabled_by_default" { + command = plan + + # A CLI already in the image must short-circuit the download, so a + # prepared image spends none of the claim-to-ready window on it. + assert { + condition = can(regex("if command -v agent >/dev/null 2>&1; then\n\techo \"Cursor CLI already present", coder_script.worker.script)) + error_message = "the installer must be guarded by a presence check" + } + + assert { + condition = can(regex("curl https://cursor.com/install -fsSL \\| bash", coder_script.worker.script)) + error_message = "the installer must follow redirects" + } +} + +run "install_cli_disabled" { + command = plan + + variables { + install_cli = false + } + + assert { + condition = !can(regex("cursor.com/install", coder_script.worker.script)) + error_message = "install_cli = false must not download the CLI" + } +} + +run "overridden_paths" { + command = plan + + variables { + cli_binary = "/opt/cursor/agent" + state_file = "/var/run/relay/state" + log_file = "/var/log/relay.log" + serving_log_pattern = "custom pattern" + } + + assert { + condition = can(regex("/opt/cursor/agent worker", coder_script.worker.script)) + error_message = "cli_binary must select the binary the worker starts" + } + + assert { + condition = can(regex("custom pattern", output.status_metadata_script)) && can(regex("/var/log/relay.log", output.status_metadata_script)) + error_message = "the status script must use the configured pattern and log file" + } +} diff --git a/registry/coder/modules/agent-relay-cursor/run.sh.tftpl b/registry/coder/modules/agent-relay-cursor/run.sh.tftpl new file mode 100644 index 000000000..fdcbec741 --- /dev/null +++ b/registry/coder/modules/agent-relay-cursor/run.sh.tftpl @@ -0,0 +1,114 @@ +#!/usr/bin/env bash +# Rendered into a coder_script by the Cursor worker module. Runs at +# agent start with CURSOR_API_KEY (the service-account key) and +# CURSOR_AGENT_WORKER_ID (the claimed worker id) in scope. +# +# Starts the worker detached and exits, so the agent reaches the ready +# lifecycle state instead of sitting in starting for the life of the +# session. The detached supervisor records the worker's lifecycle to +# ${state_file}, which the agent_relay_status agent metadata item publishes +# for Agent Relay. See README. +set -euo pipefail + +state_file="${state_file}" +log_file="${log_file}" +state_dir="$(dirname "$state_file")" +mkdir -p "$state_dir" "$(dirname "$log_file")" + +write_state() { + # Write via a temp file so a reader never sees a partial line. + printf '%s\n' "$1" >"$state_file.tmp" + mv "$state_file.tmp" "$state_file" +} + +if [ -z "$${CURSOR_API_KEY:-}" ]; then + write_state "idle" + 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." + exit 0 +fi + +%{ if install_cli ~} +# install_cli is set, but a CLI already in the image is the fast path: +# check first and only download when it is missing, so a prepared image +# spends none of the claim-to-ready window on a download. The installer +# places the binary under ~/.local/bin, so that directory joins PATH for +# this script and the supervisor. +export PATH="$HOME/.local/bin:$PATH" +if command -v ${cli_binary} >/dev/null 2>&1; then + echo "Cursor CLI already present; skipping the install." +else + echo "Cursor CLI not found; installing the latest release..." + # -L costs nothing today (cursor.com/install answers 200 directly) and + # keeps the pipe from silently installing an empty body if it ever + # starts redirecting, which is how the Claude installer broke. + if ! curl https://cursor.com/install -fsSL | bash >>"$log_file" 2>&1; then + echo "Cursor CLI install failed. See $log_file" >&2 + fi +fi +%{ endif ~} + +# A missing binary is a template problem, so it is reported as a +# terminal state for Agent Relay to observe rather than started +# blind, which would loop as "done 127". Bake the CLI into the image, +# or set install_cli to download it on start. +if ! command -v ${cli_binary} >/dev/null 2>&1; then + write_state "failed runner-agent-missing" + echo "The worker binary '${cli_binary}' is not available." >&2 + echo "Bake the Cursor CLI into the image, set install_cli = true, or set cli_binary. See the module README." >&2 + exit 1 +fi + +# 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 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="$state_dir/supervise.sh" +cat >"$supervisor" <"$state_file.tmp" + mv "$state_file.tmp" "$state_file" +} + +%{ if install_cli ~} +export PATH="\$HOME/.local/bin:\$PATH" +%{ endif ~} +${cli_binary} worker \\ + --pool "${pool_name}" \\ + --idle-release-timeout "${idle_release_timeout}" \\ +%{ if computer_use ~} + --computer-use \\ +%{ endif ~} + start >"$log_file" 2>&1 & +worker_pid=\$! +write_state "working \$worker_pid" + +wait "\$worker_pid" +code=\$? +write_state "done \$code" +SUPERVISOR +chmod +x "$supervisor" + +echo "Starting Cursor worker (detached)..." +setsid nohup "$supervisor" >/dev/null 2>&1 &2 +fi +echo "Worker log: $log_file" diff --git a/registry/coder/modules/agent-relay-cursor/status.sh.tftpl b/registry/coder/modules/agent-relay-cursor/status.sh.tftpl new file mode 100644 index 000000000..a7b5537f8 --- /dev/null +++ b/registry/coder/modules/agent-relay-cursor/status.sh.tftpl @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Rendered into the agent_relay_status agent metadata item (see README). +# Prints one line that Agent Relay reads to decide whether the +# workspace still has work: +# +# pending the worker script has not written state yet +# idle no worker token; created manually, never claimed +# working worker alive, no session attached yet +# serving worker alive and a session has attached +# orphaned state says working but the process is gone +# done worker exited with that status +# failed the worker could not be started at all +# +# working versus serving comes from matching ${serving_log_pattern} +# against the worker log, which is a cosmetic distinction: Agent Relay's +# reaping decisions rest on the process lifecycle, so a pattern that +# stops matching a future Cursor CLI release degrades to "working" and +# changes nothing else. +# +# Keep the vocabulary in sync with Agent Relay's reaper. +set -euo pipefail + +state_file="${state_file}" +log_file="${log_file}" + +if [ ! -f "$state_file" ]; then + echo "pending" + exit 0 +fi + +read -r state detail <"$state_file" || true + +case "$state" in +working) + if [ -z "$${detail:-}" ] || ! kill -0 "$detail" 2>/dev/null; then + # The worker vanished without the supervisor recording an + # exit, e.g. an OOM kill. Distinct from done so Agent Relay can + # grade the failure. + echo "orphaned" + exit 0 + fi + if [ -f "$log_file" ] && grep -q "${serving_log_pattern}" "$log_file"; then + echo "serving" + else + echo "working" + fi + ;; +done) + echo "done $${detail:-0}" + ;; +failed) + echo "failed $${detail:-unknown}" + ;; +*) + echo "$state" + ;; +esac From 29edc1da9834467d092c2c8897617901d863e955 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 11 Sep 2026 01:05:05 +0000 Subject: [PATCH 2/5] docs(agent-relay-cursor): tighten the README --- .../modules/agent-relay-cursor/README.md | 196 ++++++------------ 1 file changed, 61 insertions(+), 135 deletions(-) diff --git a/registry/coder/modules/agent-relay-cursor/README.md b/registry/coder/modules/agent-relay-cursor/README.md index 259b7f736..7b30993bf 100644 --- a/registry/coder/modules/agent-relay-cursor/README.md +++ b/registry/coder/modules/agent-relay-cursor/README.md @@ -9,19 +9,15 @@ tags: [agent, cursor, agent-relay] # Agent Relay Cursor Makes a Coder template a target for [Agent Relay](https://coder.com/docs/ai-coder/agent-relay) -Cursor pools: include the module, wire it to your `coder_agent`, and Agent -Relay can dispatch [Cursor cloud agent](https://coder.com/docs/ai-coder/agent-relay/cursor) -requests to workspaces built from the template. +Cursor pools. Agent Relay dispatches [Cursor cloud agent](https://coder.com/docs/ai-coder/agent-relay/cursor) +requests to workspaces built from the template; the module declares the +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 = "1.0.0" agent_id = coder_agent.main.id - - # Default. Installs the CLI only when the image does not already - # carry it; baking it into the image stays the fastest path. - install_cli = true } resource "coder_agent" "main" { @@ -36,134 +32,64 @@ resource "coder_agent" "main" { } ``` -The `agent_relay_status` metadata block is required and must live on the -agent: the coder provider has no standalone agent-metadata resource, so -a module cannot declare it. Referencing the module's -`status_metadata_script` output does not create a dependency cycle; the -output does not depend on `agent_id`. - -Requirements for the template and image: - -- The Cursor CLI (`agent`) must be available in the workspace. - `install_cli` defaults to `true` and downloads the latest CLI at - workspace start, but only when the binary is not already on PATH: a - CLI in the image is used as is, never upgraded, and costs nothing. - When the download does run it needs outbound access to cursor.com and - spends part of the claim-to-ready window. `cli_binary` overrides the - path. With no CLI and no successful install, the worker reports - `failed runner-agent-missing`, which - Agent Relay reaps and records. -- For repo-scoped pools the template must clone the repository named - by `agent_relay_cursor_repo_url` and provide SCM credentials before the worker - starts, e.g. via a startup script ordered before this module's - script. Repo-less pools should provide a `.cursor/rules` file in the - worker's working directory instead. -- With `computer_use = true` the image must also carry the - computer-use packages the Cursor CLI expects. -- Builds should complete within the pool's `dispatch_deadline` - (default 10m): pre-pulled images, no persistent volumes. The - deadline cannot exceed 15m: Agent Relay's reaper deletes - workspaces stuck pending or starting after that grace, and it does - so even when `dispatch_deadline` is `"0"` (no dispatcher deadline). +The `agent_relay_status` metadata block is required. It has to live on the +`coder_agent`, which the module cannot declare; the relay reads it to decide +when to reap the workspace. -## Credential exposure +## Requirements -The pool's service-account API key reaches every dispatched workspace: -it is stamped as the ephemeral `agent_relay_credential` build parameter -and exported as the `CURSOR_API_KEY` environment variable, where the -workspace owner can read it for the lifetime of the workspace. Treat -the key accordingly: - -- Use a dedicated service-account key per pool, scoped to the - repository the pool serves, distinct from any broader Cursor - key your team uses. -- Rotating the key in Agent Relay's config only affects new builds; - running workspaces keep the value they were built with until reaped. - -Sub-token worker authentication (short-lived per-worker credentials -instead of the pool key) is the tracked hardening follow-up. - -The module declares the full parameter contract Agent Relay stamps on -every build; Agent Relay verifies the contract against the template's -active version at startup (dynamic parameters evaluate endpoint) and -refuses to serve a pool whose template does not satisfy it: - -| parameter | kind | value | -| ----------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------- | -| `agent_relay_session_id` | persistent state | Cursor request served by this workspace (dedupe/reconciliation key) | -| `agent_relay_delivery_id` | persistent state | worker id the request was claimed with; exported as `CURSOR_AGENT_WORKER_ID` | -| `agent_relay_pool` | persistent state | Agent Relay pool that dispatched this build | -| `agent_relay_cursor_pool_name` | persistent state | Cursor-side pool the worker registers under | -| `agent_relay_cursor_idle_release_timeout` | persistent state | seconds the worker idles after the agent finishes a turn before releasing and exiting; keep it at or above 300 (see below) | -| `agent_relay_cursor_repo_url` | persistent state | repository the request targets; empty for repo-less pools, but always declared so the contract stays static | -| `agent_relay_credential` | ephemeral | service-account API key the worker authenticates with; exported as `CURSOR_API_KEY` | - -The persistent parameters matter because coderd only stores values the -template declares; Agent Relay's dedupe and reconciliation depend on -querying them with `param:` search filters. - -Every parameter above renders disabled, with a placeholder saying Agent Relay -sets it. That is cosmetic, not a guardrail: it keeps a person creating a -workspace by hand from filling in machine-set fields, and does not stop the -CLI or the API from sending values. The credential is masked for the same -reason. - -## Worker lifecycle reporting - -Agent Relay deletes a workspace once its worker exits, and records the -failure when the worker died instead; Cursor offers no nack, so -observability is the only failure signal. Neither Coder nor Agent Relay -knows what a Cursor session is, so the module publishes the worker's -lifecycle and Agent Relay reads it. - -The worker script starts - -```sh -CURSOR_API_KEY= < token > CURSOR_AGENT_WORKER_ID= < worker id > \ -agent worker --pool < agent_relay_cursor_pool_name > \ - --idle-release-timeout < idle_release_timeout > start -``` +- 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. +- Repo-scoped pools: the template must clone `agent_relay_cursor_repo_url` and + provide SCM credentials before this module's script runs. +- `computer_use = true` needs the computer-use packages in the image. +- Builds must finish inside the pool's `dispatch_deadline` (default 10m, max + 15m): pre-pulled images, no persistent volumes. + +## Credential exposure -detached and exits, so the agent reaches the `ready` lifecycle state -instead of sitting in `starting` for the life of the session. The -worker exits `0` on its own once the idle-release timer fires after a -session ends; that clean exit is what tells Agent Relay to reap. Mind the -timer semantics: it starts when the agent finishes a turn, not when -the user closes the chat, so `idle_release_timeout` must cover the -user's reading and typing time. Once released, a follow-up to that -chat errors on Cursor's side until Cursor issues a new pending request -(which Agent Relay then serves with a fresh workspace). Keep the timeout -at or above 300 seconds (Agent Relay warns below that; the default is -600). A -detached supervisor owns the worker process and writes single-line -state to `state_file` (`/tmp/agent-relay/worker-state` by default). -The `agent_relay_status` metadata item reports one of: - -| value | meaning | -| ----------------- | ------------------------------------------------------------------------------------------------------- | -| `pending` | supervisor has not recorded state yet (agent just started) | -| `idle` | no worker token: workspace was created manually | -| `working` | worker alive, no session attached yet | -| `serving` | worker alive and a session has attached | -| `orphaned` | state says working but the process is gone, and the supervisor died too | -| `done ` | worker exited with that status (`0` is the normal idle release; `137` means it was killed) | -| `failed ` | the worker could not be started at all, e.g. `runner-agent-missing` when the binary is not in the image | - -`working` versus `serving` comes from matching `serving_log_pattern` -against the worker log. At default verbosity the Cursor CLI log -carries no session-attach line at all, so the pattern can only match -with verbose worker logs and typically never fires. That is fine: -Agent Relay's status page overlays Cursor's authoritative in-use worker -state (from the fleet worker listing) on top of this metadata, so a -workspace whose worker is in use renders as serving either way, and -reaping decisions rest on the process lifecycle. The metadata-level -distinction is therefore best-effort and purely cosmetic; tune the -pattern only if you run verbose logs and want the distinction inside -the workspace metadata too. - -The script's timings row is not the reaping signal. coderd records it -when the script ends, which is now seconds after the agent starts, long -before the session finishes. - -The `agent_relay_status` key is part of the contract; renaming it breaks -reaping. +The pool's service-account API key is stamped on every dispatched workspace as +the ephemeral `agent_relay_credential` parameter and exported as +`CURSOR_API_KEY`, readable by the workspace owner. Use a dedicated key per +pool, scoped to the repository it serves. Rotating it affects new builds only. + +## Parameters + +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. + +| parameter | kind | value | +| ----------------------------------------- | ---------- | ----------------------------------------------------------------- | +| `agent_relay_session_id` | persistent | Cursor request this workspace serves | +| `agent_relay_delivery_id` | persistent | worker id the request was claimed with (`CURSOR_AGENT_WORKER_ID`) | +| `agent_relay_pool` | persistent | Agent Relay pool that dispatched the build | +| `agent_relay_cursor_pool_name` | persistent | Cursor-side pool the worker registers under | +| `agent_relay_cursor_idle_release_timeout` | persistent | seconds the worker idles after a turn before exiting (min 300) | +| `agent_relay_cursor_repo_url` | persistent | repository the request targets; empty for repo-less pools | +| `agent_relay_credential` | ephemeral | service-account API key (`CURSOR_API_KEY`) | + +## Worker lifecycle + +The script starts `agent worker --pool ... --idle-release-timeout ... 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 +finishes a turn, not when the chat closes, so keep the timeout at or above +300 seconds. + +`agent_relay_status` reports one of: + +| value | meaning | +| ----------------- | -------------------------------------------------------------------------- | +| `pending` | no state recorded yet | +| `idle` | no credential: 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 | +| `done ` | worker exited with that status; `0` is the normal idle release | +| `failed ` | worker could not start, e.g. `runner-agent-missing` when the CLI is absent | + +Renaming the `agent_relay_status` key breaks reaping. From 29d9ff37751f6df279bdf6426368e6b277ad9fb3 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 11 Sep 2026 13:04:15 +0000 Subject: [PATCH 3/5] docs(agent-relay-cursor): show install_cli in the usage example --- registry/coder/modules/agent-relay-cursor/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/registry/coder/modules/agent-relay-cursor/README.md b/registry/coder/modules/agent-relay-cursor/README.md index 7b30993bf..fd2a1f8a3 100644 --- a/registry/coder/modules/agent-relay-cursor/README.md +++ b/registry/coder/modules/agent-relay-cursor/README.md @@ -18,6 +18,10 @@ module "cursor_worker" { source = "registry.coder.com/coder/agent-relay-cursor/coder" version = "1.0.0" agent_id = coder_agent.main.id + + # Downloads the Cursor CLI at start when it is not in the image. Bake + # the CLI into the image and set this to false for faster workspaces. + install_cli = true } resource "coder_agent" "main" { From 6fda0e55469fcc21635d2fc48ce1a49f0c75dabc Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 11 Sep 2026 13:11:28 +0000 Subject: [PATCH 4/5] docs(agent-relay-cursor): start at 0.1.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 fd2a1f8a3..2b9535bbb 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 = "1.0.0" + version = "0.1.0" agent_id = coder_agent.main.id # Downloads the Cursor CLI at start when it is not in the image. Bake From b5e71b57aa330d9ff02fbfb03fedf7c42885e11f Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 11 Sep 2026 08:50:43 -0500 Subject: [PATCH 5/5] test(agent-relay-cursor): exercise the worker script lifecycle (#1113) Stacked on #1112. Adds `main.test.ts` for `coder/agent-relay-cursor`. Each case renders the module with `runTerraformApply`, runs the `coder_script` body in a `lorello/alpine-bash` container, and reads back `/tmp/agent-relay/worker-state`. A stub `agent` script stands in for the Cursor CLI so the supervisor lifecycle is observed, not grepped for: | case | asserts | |---|---| | no `CURSOR_API_KEY` | exit 0, `idle` | | no CLI, `install_cli = false` | exit 1, `failed runner-agent-missing` | | CLI on PATH, `install_cli` default | "already present; skipping the install", no download, `working ` | | worker start | argv is `worker --pool ... --idle-release-timeout 600 ... start`, no `--computer-use`; worker env carries `CURSOR_API_KEY` / `CURSOR_AGENT_WORKER_ID` | | `computer_use = true` | `--computer-use` in argv | | worker exits 3 | `done 3` | | `cli_binary` + `state_file` overrides | binary at the override path runs, state lands at the override path | Plus the standard `testRequiredVariables` for `agent_id`. Not covered: the real `curl https://cursor.com/install | bash` and the real worker; both need the network and Cursor's side. 9 pass locally in ~15s. If this merges, the "No `main.test.ts` yet" line in the #1112 body should be dropped. --- _Opened by Coder Agents on behalf of @Emyrk._ --- .../modules/agent-relay-cursor/main.test.ts | 203 ++++++++++++++++++ 1 file changed, 203 insertions(+) create mode 100644 registry/coder/modules/agent-relay-cursor/main.test.ts diff --git a/registry/coder/modules/agent-relay-cursor/main.test.ts b/registry/coder/modules/agent-relay-cursor/main.test.ts new file mode 100644 index 000000000..19cd687bf --- /dev/null +++ b/registry/coder/modules/agent-relay-cursor/main.test.ts @@ -0,0 +1,203 @@ +import { + afterEach, + beforeAll, + describe, + expect, + it, + setDefaultTimeout, +} from "bun:test"; +import { + execContainer, + findResourceInstance, + readFileContainer, + removeContainer, + runContainer, + runTerraformApply, + runTerraformInit, + testRequiredVariables, + writeFileContainer, +} from "~test"; + +// The worker script is exercised inside a throwaway container with a stub +// `agent` binary standing in for the Cursor CLI, so the supervisor lifecycle +// the relay's reaper depends on (idle, working, done, failed) is observed +// rather than grepped for. The real installer and the real worker are out +// of scope: both need the network and Cursor's side. + +let cleanupFunctions: (() => Promise)[] = []; +const registerCleanup = (cleanup: () => Promise) => { + cleanupFunctions.push(cleanup); +}; +afterEach(async () => { + const cleanupFnsCopy = cleanupFunctions.slice().reverse(); + cleanupFunctions = []; + for (const cleanup of cleanupFnsCopy) { + try { + await cleanup(); + } catch (error) { + console.error("Error during cleanup:", error); + } + } +}); + +const STATE_FILE = "/tmp/agent-relay/worker-state"; +const DISPATCH_ENV = [ + "CURSOR_API_KEY=test-service-account-key", + "CURSOR_AGENT_WORKER_ID=worker-123", +]; + +const setup = async (vars: Record = {}) => { + const state = await runTerraformApply(import.meta.dir, { + agent_id: "foo", + ...vars, + }); + const script = findResourceInstance(state, "coder_script").script; + const id = await runContainer("lorello/alpine-bash"); + registerCleanup(async () => { + await removeContainer(id); + }); + return { id, script }; +}; + +// Installs a fake Cursor CLI whose body is the given shell snippet. +const stubAgent = async (id: string, body: string) => { + await writeFileContainer( + id, + "/usr/local/bin/agent", + `#!/usr/bin/env bash\n${body}\n`, + { user: "root" }, + ); + const chmod = await execContainer(id, [ + "chmod", + "755", + "/usr/local/bin/agent", + ]); + expect(chmod.exitCode).toBe(0); +}; + +const runDispatched = (id: string, script: string) => + execContainer(id, ["env", ...DISPATCH_ENV, "bash", "-c", script]); + +const readState = async (id: string) => + (await readFileContainer(id, STATE_FILE)).trim(); + +// The supervisor writes terminal state after the worker exits; poll rather +// than sleep a fixed amount. +const waitForState = async (id: string, pattern: RegExp, timeoutMs = 5000) => { + const deadline = Date.now() + timeoutMs; + let last = ""; + while (Date.now() < deadline) { + last = await readState(id); + if (pattern.test(last)) { + return last; + } + await Bun.sleep(200); + } + throw new Error( + `state never matched ${pattern}; last was ${JSON.stringify(last)}`, + ); +}; + +setDefaultTimeout(60 * 1000); + +describe("agent-relay-cursor", () => { + beforeAll(async () => { + await runTerraformInit(import.meta.dir); + }); + + testRequiredVariables(import.meta.dir, { + agent_id: "foo", + }); + + it("idles when no credential is set", async () => { + const { id, script } = await setup(); + // No CURSOR_API_KEY: this is a workspace a human created by hand. + const exec = await execContainer(id, ["bash", "-c", script]); + expect(exec.exitCode).toBe(0); + expect(exec.stdout).toContain("created manually, not by Agent Relay"); + expect(await readState(id)).toBe("idle"); + }); + + it("reports runner-agent-missing when the CLI is absent", async () => { + const { id, script } = await setup({ install_cli: "false" }); + const exec = await runDispatched(id, script); + expect(exec.exitCode).toBe(1); + expect(exec.stderr).toContain("The worker binary 'agent' is not available"); + expect(await readState(id)).toBe("failed runner-agent-missing"); + }); + + it("skips the download when the CLI is already present", async () => { + // install_cli defaults to true; a binary on PATH must short-circuit it. + const { id, script } = await setup(); + await stubAgent(id, "sleep 30"); + const exec = await runDispatched(id, script); + expect(exec.exitCode).toBe(0); + expect(exec.stdout).toContain( + "Cursor CLI already present; skipping the install.", + ); + expect(exec.stdout).not.toContain("installing the latest release"); + expect(await readState(id)).toMatch(/^working \d+$/); + }); + + it("starts the worker detached with the pool arguments", async () => { + const { id, script } = await setup(); + await stubAgent(id, 'printf "%s\\n" "$@" >/tmp/agent-args; sleep 30'); + const exec = await runDispatched(id, script); + expect(exec.exitCode).toBe(0); + expect(exec.stdout).toContain("Starting Cursor worker (detached)..."); + + const args = (await readFileContainer(id, "/tmp/agent-args")).split("\n"); + expect(args[0]).toBe("worker"); + expect(args).toContain("--pool"); + expect(args).toContain("--idle-release-timeout"); + // The parameter default when Agent Relay has not stamped a value. + expect(args[args.indexOf("--idle-release-timeout") + 1]).toBe("600"); + expect(args).toContain("start"); + expect(args).not.toContain("--computer-use"); + + // The worker inherits the CLI's own env var names, not the relay's. + const env = await execContainer(id, [ + "sh", + "-c", + "cat /proc/$(pgrep -f 'agent worker' | head -1)/environ | tr '\\0' '\\n'", + ]); + expect(env.stdout).toContain("CURSOR_API_KEY=test-service-account-key"); + expect(env.stdout).toContain("CURSOR_AGENT_WORKER_ID=worker-123"); + }); + + it("passes --computer-use when enabled", async () => { + const { id, script } = await setup({ computer_use: "true" }); + await stubAgent(id, 'printf "%s\\n" "$@" >/tmp/agent-args; sleep 30'); + const exec = await runDispatched(id, script); + expect(exec.exitCode).toBe(0); + const args = (await readFileContainer(id, "/tmp/agent-args")).split("\n"); + expect(args).toContain("--computer-use"); + }); + + it("records the exit code when the worker exits", async () => { + const { id, script } = await setup(); + await stubAgent(id, "exit 3"); + const exec = await runDispatched(id, script); + expect(exec.exitCode).toBe(0); + expect(await waitForState(id, /^done \d+$/)).toBe("done 3"); + }); + + it("uses cli_binary and state_file overrides", async () => { + const { id, script } = await setup({ + cli_binary: "/opt/cursor/agent", + state_file: "/var/lib/relay/state", + }); + await execContainer(id, ["mkdir", "-p", "/opt/cursor"]); + await writeFileContainer( + id, + "/opt/cursor/agent", + "#!/usr/bin/env bash\nsleep 30\n", + { user: "root" }, + ); + await execContainer(id, ["chmod", "755", "/opt/cursor/agent"]); + const exec = await runDispatched(id, script); + expect(exec.exitCode).toBe(0); + const state = (await readFileContainer(id, "/var/lib/relay/state")).trim(); + expect(state).toMatch(/^working \d+$/); + }); +});