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..2b9535bbb --- /dev/null +++ b/registry/coder/modules/agent-relay-cursor/README.md @@ -0,0 +1,99 @@ +--- +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. 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 = "0.1.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" { + # ... + 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. It has to live on the +`coder_agent`, which the module cannot declare; the relay reads it to decide +when to reap the workspace. + +## Requirements + +- 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 + +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. 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+$/); + }); +}); 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