diff --git a/CHANGELOG.md b/CHANGELOG.md index 54ba7aa..06109d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -133,6 +133,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). - Add the `kebab-case-skills` assembly rule, the full skill-tree normalization of path, frontmatter name, and link retargeting, applied to skills alone. - Enable `kebab-case-skills` on the agentskills provider, because the AgentSkills specification requires lowercase names matching the skill directory. - Deploy authored casing verbatim on every other provider. +- Add `cursor` to `rune run`: `cursor-agent` in print mode, ask mode when read-only, its own sandbox when writing, and a hint when the sandbox hides the login. +- Require `CURSOR_API_KEY` for `rune run cursor --clean`, which moves `HOME` and `CURSOR_CONFIG_DIR` into the clean root. +- Keep only `--endpoint` from a Cursor profile in `rune run`, and refuse a read-only run when Cursor's `approvalMode` is `unrestricted`. +- Add `cursor` to `rune launch`, which starts `cursor-agent` unless `tools.cursor.binary` names another binary. +- Add a built-in `fable@cursor` profile that selects `claude-fable-5-1-high`. A configured profile of that name replaces it. ### Changed diff --git a/docs/changes/cursor-agent-surface/adr.md b/docs/changes/cursor-agent-surface/adr.md new file mode 100644 index 0000000..21233ce --- /dev/null +++ b/docs/changes/cursor-agent-surface/adr.md @@ -0,0 +1,61 @@ +--- +title: "Cursor runs through its official agent CLI" +description: "rune run and rune launch reach Cursor only through cursor-agent in print mode with JSON output, map the run modes onto ask mode and Cursor's sandbox, and require an API key for clean runs" +type: adr +category: architecture +tags: + - cli + - run + - launch +status: proposed +created: 2026-09-29 +updated: 2026-10-02 +author: "@N4M3Z" +project: cli +related: + - "CLI-0024 Interactive and Automated Tool Commands" + - "CLI-0025 Shared Coding Tool Process Supervisor" +responsible: ["@N4M3Z"] +accountable: ["@N4M3Z"] +consulted: ["claude-fable-5-1@claude", "gpt-6-astra@codex", "grok-4.7@claude"] +informed: [] +upstream: [] +change: cursor-agent-surface +--- + +# Cursor runs through its official agent CLI + +## Context and Problem Statement + +The owner wants Cursor models in `rune run` and `rune launch`. Cursor offers two ways in. The official `cursor-agent` CLI has a print mode with one JSON result object. The editor talks to Cursor's backend over a private protocol, and Cursor's terms forbid access through it ([Cursor terms §1.5][TOS]). Claude models on Cursor bill at API price from Cursor's own pool ([Cursor models][MODELS]), so a Cursor route is an extra account, not a cheaper one. + +The CLI differs from the other tools in four ways that shape the adapter. It has no system prompt flag. Its read-only mode, `--mode ask`, is enforced by the CLI and not by the operating system, and the model keeps its Shell and Write tools under it. It accepts options that its help does not list, such as `--data-dir` and `--allowed-tools`. It reads its login from the macOS Keychain, which a process inside the Claude Code sandbox cannot reach, and it reads `~/.cursor/rules` even when `CURSOR_CONFIG_DIR` points elsewhere. Each fact was checked against `cursor-agent` on 2026-09-29. + +## Considered Options + +1. Drive `cursor-agent -p --output-format json` as a sixth adapter, with the prompt on standard input. +2. Talk to Cursor's backend directly with a client for its private protocol. +3. Keep Cursor out of rune and let each user write a wrapper script. + +For a clean run there were three further options: swap `HOME` and accept the lost login, set only `CURSOR_CONFIG_DIR`, or require `CURSOR_API_KEY`. + +## Decision Outcome + +Option 1. `rune run cursor` MUST start `cursor-agent` with `-p --output-format json --workspace --trust`, send the system prompt and the prompt on standard input, and read `result`, `is_error`, and `usage.outputTokens`. A read-only run MUST add `--mode ask` and MUST refuse when Cursor's saved `approvalMode` is `unrestricted`, one of the three values that Cursor documents ([Cursor CLI configuration][CONFIG]). A workspace-write run MUST add `--force --sandbox enabled`. A profile MUST keep only `-e` and `--endpoint`. `rune launch cursor` MUST start `cursor-agent` unless `tools.cursor.binary` says otherwise. A clean run MUST require `CURSOR_API_KEY`, set `HOME` and `CURSOR_CONFIG_DIR` inside the clean root, and refuse with a configuration error when the key is missing. `fable@cursor` MUST work without config: a built-in profile selects the built-in route `fable-cursor`, `claude-fable-5-1-high`, and a configured profile or route of the same name replaces it. + +Option 2 breaks the terms and would break with every Cursor release. Option 3 is the wrapper that the 2026-09-26 retirement of vendor-named shims removed. For clean runs, a `HOME` swap alone fails with "Authentication required", and `CURSOR_CONFIG_DIR` alone still loads the owner's rules, so neither gives a clean run. + +## Consequences + +- Cursor models are reachable from `rune run` under the same owned-argument filter, JSON output, and failure kinds as the other tools. +- A read-only Cursor run relies on `cursor-agent` enforcing ask mode. Three live read-only runs on 2026-10-01 that asked for a file wrote nothing, under `approvalMode` `auto-review` and under a copied `unrestricted` config, and the model named its Shell and Write tools as present but forbidden. No kernel sandbox backs the mode, because `--sandbox enabled` without `--force` asks for approval that print mode cannot give. A caller who needs a kernel boundary for a read-only run must pick another tool. The refusal of `unrestricted` is defense in depth against that enforcement failing: an owner with that saved mode must choose `allowlist`, `auto-review`, or a workspace-write run. +- A Cursor profile cannot pass headers, `--api-key`, or any flag other than the endpoint flag. The key goes in the profile `env` as `CURSOR_API_KEY`. A profile that needs more must use `rune launch`, which passes profile arguments unchanged. +- `--clean` costs the owner an API key. The key lives in the environment, never in the argument list, so it does not appear in `ps` output or in the `rune run --dry-run` argument list. +- A clean run drops the owner's `~/.cursor/hooks`, the same as a clean run of any other tool drops its hooks. +- `cursor-agent` fails inside the Claude Code sandbox. rune adds a hint to that failure but does not leave the sandbox itself. The harness must exclude `rune`, and the owner's template must list `cursor-agent` for direct calls. +- The built-in profile table is new. It is the first profile that rune supplies without config, because Cursor names each Fable effort level as its own id and no tool-neutral `fable` route can serve it. Cursor models on Cursor's list carry the label "NO ZDR", so a `fable@cursor` prompt is not covered by zero data retention. +- Cursor does not document the `usage` block. If it changes, completion tokens become absent, and the run still succeeds. + +[TOS]: https://cursor.com/terms-of-service +[MODELS]: https://cursor.com/docs/models +[CONFIG]: https://cursor.com/docs/cli/reference/configuration diff --git a/docs/changes/cursor-agent-surface/design.md b/docs/changes/cursor-agent-surface/design.md new file mode 100644 index 0000000..514d388 --- /dev/null +++ b/docs/changes/cursor-agent-surface/design.md @@ -0,0 +1,24 @@ +# Cursor agent surface: design + +## Approach + +Cursor joins `rune run` as a sixth tool in the existing adapter set: one `Surface::Cursor` variant, one row in each table, and one module with the argument builder and the parser. It uses the same process request, owned-argument filter, and failure type as the other tools. The alternative was a generic "JSON print tool" adapter driven by config. It lost because each tool differs in prompt transport, access flags, and error shape, and the owned table is the security boundary, which config must not widen. + +## Structure + +- `src/cli/surface/cursor.rs`: `BOOLEAN_OPTIONS` and `OWNED_OPTIONS` hold the owned flags, including `--plugin-dir`, because a plugin can add tools. `keep_allowed` then keeps only the flags in `KEPT_OPTIONS`, `-e` and `--endpoint`, and drops every other profile argument, because `cursor-agent` accepts options that its help does not list and because `--api-key` would put the key in `ps`. Its warnings name a flag or a position, never a value. `check_access` refuses a read-only run when the `cli-config.json` in `config_dir` sets `approvalMode` to `unrestricted`. `config_dir` resolves the directory as `cursor-agent` does: `CURSOR_CONFIG_DIR`, then `$XDG_CONFIG_HOME/cursor`, then `$HOME/.cursor`, reading the launch environment before rune's own. A kept flag without a plain value is dropped, because commander takes the next token as the value whatever it is. `args` builds the argument list from the invocation. `prompt_input` puts the system prompt in front of the prompt. `invoke` sends that text on standard input, and `parse` reads the last result object. `prepare_clean_state` returns the configuration error when `CURSOR_API_KEY` is missing. `sandbox_hint` decides whether standard error shows the Keychain failure of a sandboxed process, and `with_sandbox_hint` puts the hint in front of that standard error. +- `src/cli/surface/mod.rs`: the `Surface::Cursor` variant, `from_tool("cursor")`, the name, the table dispatch, the `prepare_clean_state` and `process_request` match cases, and the `invoke_surface` dispatch. The filter uses `drop_whole`, because Cursor has no generic config channel, and passes its result through `keep_allowed`. `check_access` dispatches the access check, and `run/mod.rs` calls it before the tool starts, so a refusal is a configuration error with exit 2. +- `src/cli/launch/mod.rs`: `cursor` in `KNOWN_TOOLS`, and `default_binary(name)`, which maps `cursor` to `cursor-agent`. Both `resolve_tool` and `list_tools` call it, so the list cannot disagree with the launch. `BUILT_IN_PROFILES` holds the `fable@cursor` profile, which selects the built-in route `fable-cursor` (`claude-fable-5-1-high`). Cursor needs this table because it names each Fable effort level as its own id, so no tool-neutral `fable` route can serve it. `resolve_profile` checks the config first, then this table, and `profile_names` merges both for the missing-profile error and the tool list. The built-in routes move into one `BUILT_IN_ROUTES` table, which both the lookup and the unknown-route error read. `model_args` puts `--model ` in front of the profile arguments of a Cursor launch and records the pair in `ResolvedLaunch::model_args`. `rune run` strips that exact prefix, because it passes the model itself, so no owned-argument warning fires for a model that rune generated. +- `src/cli/run/mod.rs` and the `run` help text: the supported-tool message and the examples name Cursor. + +The argument list for a run is `-p --output-format json --workspace --trust`, then `--mode ask` or `--force --sandbox enabled`, then `--model ` when a model is set, then the kept profile arguments, in the same order as the other tools. The prompt goes on standard input with the system prompt in front of it. + +For a clean run the process request sets `HOME` to the clean root and `CURSOR_CONFIG_DIR` to `/.cursor`. The API key stays in the environment. `has_api_key` checks the launch environment, which holds profile `env` values, before rune's own, because the child gets the launch environment last. + +## Risks + +- **A new Cursor flag widens access.** The allow-list covers it: a profile keeps only the account and endpoint flags, so a new or unlisted flag never reaches the tool. The owned table stays for its value-aware warnings. The bypass table test has Cursor rows for owned and unlisted flags. +- **Ask mode is a CLI rule, not a kernel boundary.** `cursor-agent` enforces it: every live read-only run that asked for a file wrote nothing, including one under a copied `unrestricted` config. The model still lists Shell, Write, and StrReplace as its tools under ask mode. `check_access` refuses `unrestricted` as defense in depth, because it is the one approval mode that would run such a call without asking if the enforcement failed. A read-only run cannot add a kernel sandbox, because `--sandbox enabled` without `--force` still asks for approval, and print mode cannot answer. The ADR records this limit. +- **The JSON shape changes.** Cursor does not document `usage`. The parser makes `usage`, `subtype`, and `is_error` optional. A line without `type: result` or with a non-string `result` is not a result object. An empty or missing `result` fails as an empty reply. +- **The sandbox hint misfires.** The hint matches the certificate-store text, or "Authentication required" only while `SANDBOX_RUNTIME` is set. Outside the sandbox, a real missing login gets no hint. +- **A clean home drops the owner's Cursor hooks.** `~/.cursor/hooks` holds the owner's shell guard. A clean run loses it by design, like every other clean run. Workspace-write clean runs rely on Cursor's own sandbox. diff --git a/docs/changes/cursor-agent-surface/proposal.md b/docs/changes/cursor-agent-surface/proposal.md new file mode 100644 index 0000000..e8b29da --- /dev/null +++ b/docs/changes/cursor-agent-surface/proposal.md @@ -0,0 +1,38 @@ +--- +adr: docs/changes/cursor-agent-surface/adr.md +status: proposed +decisions: ["Cursor runs through its official agent CLI"] +--- + +# Cursor agent surface + +## Why + +Cursor sells access to models that the local proxy does not serve, and its official command-line agent, `cursor-agent`, has a print mode with JSON output. `rune run` supports claude, codex, agy, grok, and opencode, so a Cursor model today needs a hand-written wrapper around `cursor-agent`, with its own flags for access and output. The owner asked on 2026-09-29 for Cursor as a `rune run` *surface*, which is a harness that the run can start and read a result from, and as a `rune launch` tool, through the official CLI only. Cursor's terms forbid access to the service through its private protocol ([Cursor terms §1.5][TOS]). + +## What Changes + +- `rune launch cursor` and `rune launch @cursor` start `cursor-agent`. The default binary is not the tool name, because `cursor` on a Mac opens the editor. `tools.cursor.binary` still overrides it. +- `rune run cursor` starts `cursor-agent -p --output-format json --workspace --trust` and sends the prompt on standard input. Read-only adds `--mode ask`. Workspace-write adds `--force --sandbox enabled`. The run reads `result`, `is_error`, and `usage.outputTokens` from the one JSON object Cursor prints. +- Cursor has no system prompt flag, so the run puts the system prompt in front of the prompt, as it does for codex and agy. +- The flags the run sets for Cursor join the owned table, so a profile that carries `--yolo`, `--force`, or `--mode` loses them with a warning. Beyond that table, a profile keeps only `-e` and `--endpoint`, because `cursor-agent` accepts options that its help does not list and reads a positional as a subcommand or as the prompt. +- A read-only run refuses when Cursor's saved `approvalMode` is `unrestricted`, as defense in depth: it is the one mode that would run a write without asking if ask mode failed. +- `fable@cursor` works without config through a built-in profile and the built-in route `fable-cursor`, `claude-fable-5-1-high`. A configured profile or route of the same name replaces it. A Cursor launch passes the route's model as `--model`. +- `rune run cursor --clean` needs `CURSOR_API_KEY` in the environment. With the key, the run points `HOME` and `CURSOR_CONFIG_DIR` at the clean root. Without it, the run refuses with a configuration error. The reason is in the ADR: Cursor keeps its login in the macOS Keychain and reads `~/.cursor/rules` whatever `CURSOR_CONFIG_DIR` says. +- A Cursor failure that shows the Keychain signature of a sandboxed process gets a hint: run rune as a bare command outside the harness sandbox. + +## Capabilities + +- harness-run-command (modified) +- harness-launch-command (modified) + +## Impact + +- `src/cli/surface/`: a Cursor module with the argument builder, the response parser, the clean-state rules, and the sandbox hint, plus the `Surface` enum, the owned and boolean tables, and the dispatch in `mod.rs`. +- `src/cli/launch/mod.rs`: `cursor` in the known tools, one default-binary function that both the resolver and the tool list use, `BUILT_IN_PROFILES` with `resolve_profile` and `profile_names`, the `fable-cursor` route, and `model_args` for the Cursor launch. +- `src/cli/run/mod.rs` and the `run` help text: the supported-tool message names Cursor. +- `CHANGELOG.md` and the proof under `docs/proofs/cursor-agent-surface/`. +- Outside the repository: the Claude Code settings template gains `cursor-agent *` beside the other harness binaries in `sandbox.excludedCommands`. `rune *` is on that list already, so a bare `rune run cursor` starts outside the sandbox. +- The bench keeps its provider list. Cursor in the bench is a separate change. + +[TOS]: https://cursor.com/terms-of-service diff --git a/docs/changes/cursor-agent-surface/pull-request.md b/docs/changes/cursor-agent-surface/pull-request.md new file mode 100644 index 0000000..b086f4c --- /dev/null +++ b/docs/changes/cursor-agent-surface/pull-request.md @@ -0,0 +1,30 @@ +`rune run` and `rune launch` get Cursor through its official `cursor-agent` CLI, and `fable@cursor` works without config. + +## Plan + +Change `cursor-agent-surface`: proposal, design, ADDED deltas under `harness-run-command` and `harness-launch-command`, tasks, and `adr.md` under `docs/changes/cursor-agent-surface/`. Proof under `docs/proofs/cursor-agent-surface/`. Reviewed by Fable, astra, and grok before the push. Their findings are in, except one rejected in `tasks.md`. + +## Changes + +- Add `Surface::Cursor` and `src/cli/surface/cursor.rs`. A *surface* is one coding tool that `rune run` can start. The run starts `cursor-agent -p --output-format json --workspace --trust` and sends the prompt on standard input, with the system prompt in front, because Cursor has no system prompt flag. +- Map the run modes: read-only adds `--mode ask`, workspace-write adds `--force --sandbox enabled`. Add the Cursor owned table, including `--yolo`, `--sandbox`, `--add-dir`, `--plugin-dir`, and `--approve-mcps`. +- Keep only `-e` and `--endpoint` from a Cursor profile. `--api-key` goes too, so the key never reaches `ps`. The profile `env` carries it. `cursor-agent` accepts options its help does not list (`--data-dir`, `--allowed-tools`, checked live), and reads a positional as a subcommand or the prompt, so an owned table cannot be complete. Warnings name the flag or position, never the value. +- Refuse a read-only run when the `cli-config.json` the tool reads (`CURSOR_CONFIG_DIR`, `$XDG_CONFIG_HOME/cursor`, or `~/.cursor`) sets `approvalMode: unrestricted`. Ask mode held in every live check. This is defense in depth against the one mode that would run a write unasked. +- Parse the one result object: `result` is the text, `usage.outputTokens` the completion tokens. `is_error: true` fails with the message or the `subtype`. An empty `result` or other output fails too. +- Refuse `--clean` without a nonempty `CURSOR_API_KEY` in the child's environment, profile `env` included. The browser login lives in the macOS Keychain, which a clean `HOME` cannot reach. With the key, `HOME` and `CURSOR_CONFIG_DIR` move into the clean root. +- Add a hint to a failure whose standard error shows the Keychain failure of a sandboxed process: run rune as a bare command outside the harness sandbox. +- Add `cursor` to `rune launch`. It starts `cursor-agent` unless `tools.cursor.binary` names another binary, because `cursor` on a Mac opens the editor. A Cursor launch passes the profile route as `--model`, and `rune run` strips that generated pair. +- Add `BUILT_IN_PROFILES` with `fable@cursor`, which selects the new built-in route `fable-cursor` (`claude-fable-5-1-high`). Move the built-in routes into one `BUILT_IN_ROUTES` table. A configured profile or route of the same name replaces the built-in one. The missing-profile error and the tool list name built-in profiles. +- Extend the `run` and `launch` help text and add CHANGELOG lines. + +## Testing + +- `cargo test --no-fail-fast`: every suite passes. The change adds 34 tests in `src/cli/surface/tests.rs`, `src/cli/launch/tests.rs`, and `tests/run.rs`. +- `cargo clippy --all-targets -- -D warnings` and `cargo fmt --check` clean. +- `rune spec validate cursor-agent-surface` and `rune docs check` pass. +- Recorded proof `docs/proofs/cursor-agent-surface/proof.{cast,gif,txt}`: one scene per delta scenario, against a fake `cursor-agent` that echoes its argv, standard input, and homes. +- Live, outside the sandbox on 2026-10-01: `rune run fable@cursor` returned `pong` with no cursor profile in config. A read-only run asked to create `hello.txt` wrote nothing, under `approvalMode` `auto-review` and under a copied `unrestricted` config. A workspace-write run created the file. + +## Release Notes + +- `rune run cursor` and `rune launch cursor` start Cursor's `cursor-agent`. `fable@cursor` selects Claude Fable 5.1 on Cursor without config. Cursor labels its Fable models "NO ZDR". Run rune outside the harness sandbox, because `cursor-agent` reads its login from the Keychain. A read-only run refuses `approvalMode: unrestricted`. diff --git a/docs/changes/cursor-agent-surface/specs/harness-launch-command/spec.md b/docs/changes/cursor-agent-surface/specs/harness-launch-command/spec.md new file mode 100644 index 0000000..c278293 --- /dev/null +++ b/docs/changes/cursor-agent-surface/specs/harness-launch-command/spec.md @@ -0,0 +1,36 @@ +## ADDED Requirements + +### Requirement: Cursor launches the agent CLI + +`cursor` MUST be a known launch tool. When `tools.cursor.binary` is not set, `rune launch cursor` and `rune launch @cursor` MUST start `cursor-agent`, because the `cursor` command on a Mac opens the editor. When `tools.cursor.binary` is set, the launch MUST start that binary. The tool list MUST look up the same binary that the launch starts. + +#### Scenario: Cursor launch without a configured binary + +- **WHEN** the config has no `tools.cursor.binary` and the caller runs `rune launch cursor --dry-run` +- **THEN** the printed command starts with `cursor-agent` +- **AND** `rune launch` without a tool lists `cursor` as installed when `cursor-agent` is on `PATH` + +#### Scenario: Cursor launch with a configured binary + +- **WHEN** `tools.cursor.binary` is `/opt/cursor/bin/agent` and the caller runs `rune launch cursor --dry-run` +- **THEN** the printed command starts with `/opt/cursor/bin/agent` + +### Requirement: Cursor has a built-in Fable profile + +When the config defines no `cursor` profile named `fable`, `fable@cursor` MUST select the built-in model route `fable-cursor`, whose id is `claude-fable-5-1-high`. A Cursor launch whose profile selects a route MUST start the tool with `--model ` in front of the profile arguments. A configured `launch.profiles.cursor.fable` MUST replace the built-in profile, and a configured `launch.models.fable-cursor` MUST replace the built-in route. The error for a missing profile and the `rune launch` tool list MUST name the built-in profiles with the configured ones. + +#### Scenario: Built-in Fable profile for Cursor + +- **WHEN** the config defines no `cursor` profile named `fable` and the caller runs `rune run fable@cursor "Reply with pong"` +- **THEN** the tool starts with `--model claude-fable-5-1-high` +- **AND** `rune launch fable@cursor --dry-run` prints `cursor-agent --model claude-fable-5-1-high` +- **AND** `rune launch` without a tool lists `fable@cursor` + +### Requirement: Cursor commands run outside the harness sandbox + +`rune launch cursor` and `rune run cursor` MUST work when rune starts as a bare command outside the harness sandbox, because `cursor-agent` reads its login from the macOS Keychain. The commands MUST NOT try to detect or leave the sandbox themselves. The harness configuration decides where rune starts. + +#### Scenario: Bare rune command from an agent session + +- **WHEN** an agent session runs `rune run cursor "Reply with pong"` as a bare command and its harness excludes `rune` from the sandbox +- **THEN** `cursor-agent` reads the Keychain login and the run prints the reply diff --git a/docs/changes/cursor-agent-surface/specs/harness-run-command/spec.md b/docs/changes/cursor-agent-surface/specs/harness-run-command/spec.md new file mode 100644 index 0000000..da8d958 --- /dev/null +++ b/docs/changes/cursor-agent-surface/specs/harness-run-command/spec.md @@ -0,0 +1,89 @@ +## ADDED Requirements + +### Requirement: Cursor runs through the official agent CLI + +`rune run cursor` MUST start the resolved binary, `cursor-agent` by default, with `-p --output-format json --workspace --trust`, and MUST send the prompt on standard input. It MUST pass `--model ` when the profile or `--model` sets a model. The system prompt MUST come before the prompt in the same input, because Cursor has no system prompt flag. The run MUST NOT reach Cursor through any interface other than this CLI. + +#### Scenario: Cursor run with a profile model + +- **WHEN** a `max@cursor` profile selects a model route whose id is `claude-fable-5-1-max` and the caller runs `rune run max@cursor "Reply with pong"` +- **THEN** the tool starts as `cursor-agent -p --output-format json --workspace --trust --mode ask --model claude-fable-5-1-max` +- **AND** the prompt reaches the tool on standard input, never in its arguments + +### Requirement: Run mode sets Cursor access + +A read-only Cursor run MUST add `--mode ask` and MUST NOT add `--force`, `--yolo`, or `--sandbox`. A workspace-write run MUST add `--force --sandbox enabled`, so Cursor applies edits without a prompt inside its own sandbox. + +#### Scenario: Read-only run asks for an edit + +- **WHEN** a read-only Cursor run asks the model to create a file +- **THEN** the tool starts with `--mode ask`, no file appears in the repository, and the model's answer is the run's text + +#### Scenario: Workspace-write run creates a file + +- **WHEN** a workspace-write Cursor run asks the model to create `hello.txt` +- **THEN** the tool starts with `--force --sandbox enabled` and the file exists after the run + +### Requirement: Read-only Cursor runs refuse unrestricted approval + +`cursor-agent` enforces ask mode, and the model keeps its Shell and Write tools under it. As defense in depth, a read-only Cursor run MUST refuse with a configuration error before it starts the tool when the `cli-config.json` that the tool reads sets `approvalMode` to `unrestricted`, the one mode that would run a write without asking. The run MUST resolve that file as the tool does: the clean config directory, `CURSOR_CONFIG_DIR`, `$XDG_CONFIG_HOME/cursor` when that variable is set and not blank, then `$HOME/.cursor`, reading each variable from the profile `env` before rune's own. A missing or unreadable file MUST NOT refuse the run. + +#### Scenario: Read-only run under unrestricted approval + +- **WHEN** `~/.cursor/cli-config.json`, or `$XDG_CONFIG_HOME/cursor/cli-config.json` with that variable set, sets `approvalMode` to `unrestricted` and the caller runs `rune run cursor --json "hello"` +- **THEN** the run exits 2 with `kind: configuration_error`, the message names `approvalMode unrestricted`, and `cursor-agent` never starts + +### Requirement: Cursor result comes from its JSON object + +The run MUST read the one JSON object that Cursor prints on standard output. It MUST return `result` as the text and `usage.outputTokens` as the completion tokens. It MUST fail with the tool's message when `is_error` is true, and with the `subtype` when that message is empty. It MUST fail when `result` is empty or when standard output is not that object. A nonzero exit MUST fail with the tool's standard error, as it does for every other tool. + +#### Scenario: Cursor reports an error in its result + +- **WHEN** Cursor exits 0 and prints `{"type":"result","is_error":true,"result":"rate limited"}` +- **THEN** the run fails, the JSON failure names "rate limited", and no success object is printed + +### Requirement: Clean Cursor runs need an API key + +`rune run cursor --clean` MUST refuse with a configuration error before it starts the tool when the tool would get no nonempty `CURSOR_API_KEY`. A profile `env` value counts, and it wins over rune's own environment. The error MUST say that the browser login lives in the macOS Keychain, which a clean home cannot reach, and that `CURSOR_CONFIG_DIR` alone still loads `~/.cursor/rules`. With the key set, the run MUST point `HOME` at the clean root and `CURSOR_CONFIG_DIR` at a directory inside it, and MUST leave the key in the environment, never in the arguments. + +#### Scenario: Clean run without a key + +- **WHEN** `CURSOR_API_KEY` is unset and the caller runs `rune run cursor --clean --json "Reply with pong"` +- **THEN** the run exits 2 with `kind: configuration_error`, the message names `CURSOR_API_KEY` and the Keychain, and `cursor-agent` never starts + +#### Scenario: Clean run with a key + +- **WHEN** `CURSOR_API_KEY` is set and the caller runs `rune run cursor --clean` +- **THEN** the tool starts with `HOME` and `CURSOR_CONFIG_DIR` inside the clean root, and no argument carries the key + +### Requirement: Sandboxed Cursor failure names the fix + +When a Cursor run fails and the tool's standard error shows the Keychain failure of a sandboxed process, the failure message MUST add one line that tells the caller to run rune as a bare command outside the harness sandbox. The signature is "failed to copy trust settings", or "Authentication required" while `SANDBOX_RUNTIME` is set. + +#### Scenario: Cursor run inside the harness sandbox + +- **WHEN** `cursor-agent` exits 1 with "failed to copy trust settings of system certificate" and "Authentication required" on standard error +- **THEN** the failure keeps the tool's standard error and adds a hint to run `rune run` outside the sandbox + +### Requirement: Cursor profile arguments the run owns are dropped + +The owned table MUST list the Cursor flags that the run sets or that change access: `-p`, `--print`, `--output-format`, `--stream-partial-output`, `--model`, `--mode`, `--plan`, `-f`, `--force`, `--yolo`, `--auto-review`, `--sandbox`, `--workspace`, `--trust`, `-w`, `--worktree`, `--worktree-base`, `--resume`, `--continue`, `--add-dir`, `--plugin-dir`, and `--approve-mcps`. Each one MUST be dropped with its value and a warning, as the run does for the other tools. + +#### Scenario: Cursor profile widens access + +- **WHEN** a Cursor profile carries `--yolo` and `--sandbox disabled` and the run is read-only +- **THEN** neither flag reaches the tool and the run prints one warning for each + +### Requirement: Cursor profiles keep only the endpoint flag + +`cursor-agent` accepts options that its help does not list, such as `--allowed-tools`, and reads a positional as a subcommand or the prompt. A Cursor run MUST keep only `-e` and `--endpoint`, each with a plain value in the next token or after `=` on the long form, and MUST drop every other profile argument with a warning. A kept flag without such a value MUST be dropped too, because the tool would take the next token as its value. A warning MUST name the flag or the position and MUST NOT show a dropped value. `--api-key` belongs in the profile `env` as `CURSOR_API_KEY`. + +#### Scenario: Cursor profile carries an unlisted flag + +- **WHEN** a Cursor profile carries `--allowed-tools=shell` +- **THEN** the flag does not reach the tool and the warning names `--allowed-tools` but not `shell` + +#### Scenario: Cursor profile names a subcommand + +- **WHEN** a Cursor profile carries the positional argument `install-shell-integration` +- **THEN** the argument does not reach the tool, the run prints a warning, and the prompt still travels on standard input diff --git a/docs/changes/cursor-agent-surface/tasks.md b/docs/changes/cursor-agent-surface/tasks.md new file mode 100644 index 0000000..843ace9 --- /dev/null +++ b/docs/changes/cursor-agent-surface/tasks.md @@ -0,0 +1,23 @@ +# Tasks + +## 1. Implementation + +- [x] 1.1 `Surface::Cursor` with its name, boolean table, owned table, and dispatch in `surface/mod.rs` +- [x] 1.2 `surface/cursor.rs`: argument builder, standard input prompt, JSON parser, clean-state rule, sandbox hint +- [x] 1.3 `prepare_clean_state` and `process_request` match cases for a clean Cursor run +- [x] 1.4 Launch: `cursor` in `KNOWN_TOOLS` and `default_binary` shared by `resolve_tool` and `list_tools` +- [x] 1.5 `run` supported-tool message and help text name Cursor +- [x] 1.6 CHANGELOG entry under Unreleased +- [x] 1.7 Built-in `fable@cursor` profile and `fable-cursor` route, listed by `rune launch` and named in the missing-profile error + +## 2. Verification + +- [x] 2.1 Tests: read-only and workspace-write argument lists, model flag, standard input prompt, JSON success, `is_error`, empty result, invalid JSON, clean run without a key, clean environment with a key, sandbox hint on and off, owned flags dropped with warnings, launch default binary and override +- [x] 2.2 Live, 2026-10-01 outside the sandbox: `rune run fable@cursor` returned `pong`; a read-only run asked to create `hello.txt` wrote nothing under `approvalMode` `auto-review` and under a copied `unrestricted` config; a workspace-write run created the file +- [x] 2.3 Recorded proof under `docs/proofs/cursor-agent-surface/` with a fake tool binary that echoes its argv +- [x] 2.4 `cargo clippy --all-targets --all-features -- -D warnings`, `cargo fmt --check`, `rune spec validate cursor-agent-surface` +- [x] 2.5 Review by Fable and adversarial review by astra and grok, with each finding fixed or rejected in writing (rejected: zero output tokens stay `null`, as for codex and grok) + +## 3. Record + +- [ ] 3.1 Archive moves `adr.md` to `docs/decisions/` with the next free CLI number diff --git a/docs/proofs/cursor-agent-surface/README.md b/docs/proofs/cursor-agent-surface/README.md new file mode 100644 index 0000000..4acbd2f --- /dev/null +++ b/docs/proofs/cursor-agent-surface/README.md @@ -0,0 +1,31 @@ +# Behavior proof: cursor-agent-surface + +`record.sh` holds one scene per scenario of the `cursor-agent-surface` delta specifications, in the grammar of +`docs/proofs/driver.sh`. `proof.cast` is its recording against the built binary on 2026-10-02, exit 0. `proof.txt` +is the transcript `asciinema convert -f txt` wrote from it, and `proof.gif` the render. + +Transcript digest (`shasum -a 256 proof.txt`): `9018c6039e3955caf8ff2e91410cbab4b2867abee96fbf9b0ac1b5ae400290fb` + +The scenes build their own fixture off camera: a scratch `HOME` with four cursor profiles (`max` with a +configured `fable-max` route, `wide` with `--yolo --sandbox disabled`, `tools` with `--allowed-tools=shell`, `shell` +with the positional `install-shell-integration`), a second `HOME` whose config sets `tools.cursor.binary`, a Cursor +config directory with `approvalMode: unrestricted`, and a fake `cursor-agent` on `PATH`. The fake prints one Cursor +result object whose text names the arguments it received, the last line of its standard input, its `HOME` and +`CURSOR_CONFIG_DIR`, and whether `CURSOR_API_KEY` reached it. `FAKE_CURSOR_ERROR` and `FAKE_CURSOR_SANDBOX` make it +fail the way Cursor reports an error and the way it fails inside the harness sandbox. No model is called. The config +defines no `fable` profile, so `fable@cursor` shows the built-in one. + +Re-record after a change to the scenes or the command: + +```sh +RUNE=target/debug/rune asciinema rec --command "bash docs/proofs/cursor-agent-surface/record.sh" \ + --headless --window-size 100x30 --idle-time-limit 2 --overwrite --return proof.cast +agg --theme github-dark --font-size 12 --fps-cap 4 --last-frame-duration 4 proof.cast proof.gif +asciinema convert -f txt --overwrite proof.cast proof.txt +``` + +Where asciinema cannot open a pseudo terminal, `docs/proofs/cast.py` writes the same cast from the script's output stream: + +```sh +RUNE=target/debug/rune python3 docs/proofs/cast.py docs/proofs/cursor-agent-surface/record.sh proof.cast +``` diff --git a/docs/proofs/cursor-agent-surface/proof.cast b/docs/proofs/cursor-agent-surface/proof.cast new file mode 100644 index 0000000..0bcb2da --- /dev/null +++ b/docs/proofs/cursor-agent-surface/proof.cast @@ -0,0 +1,688 @@ +{"version": 2, "width": 100, "height": 30, "timestamp": 1790971720, "env": {"SHELL": "/bin/bash", "TERM": "xterm-256color"}} +[0.017, "o", "\u001b[2J\u001b[H"] +[0.978, "o", "\r\n\u001b[2m# Scenario: Cursor launch without a configured binary\u001b[0m\r\n"] +[2.187, "o", "\u001b[2m# The cursor tool starts cursor-agent, because the cursor command on a Mac opens the editor.\u001b[0m\r\n"] +[3.396, "o", "\u001b[1;32m\u276f\u001b[0m "] +[3.396, "o", "r"] +[3.421, "o", "u"] +[3.446, "o", "n"] +[3.476, "o", "e"] +[3.504, "o", " "] +[3.531, "o", "l"] +[3.559, "o", "a"] +[3.584, "o", "u"] +[3.613, "o", "n"] +[3.639, "o", "c"] +[3.668, "o", "h"] +[3.694, "o", " "] +[3.722, "o", "c"] +[3.746, "o", "u"] +[3.774, "o", "r"] +[3.802, "o", "s"] +[3.83, "o", "o"] +[3.859, "o", "r"] +[3.885, "o", " "] +[3.913, "o", "-"] +[3.94, "o", "-"] +[3.966, "o", "d"] +[3.995, "o", "r"] +[4.021, "o", "y"] +[4.046, "o", "-"] +[4.071, "o", "r"] +[4.104, "o", "u"] +[4.129, "o", "n"] +[4.169, "o", "\r\n"] +[4.194, "o", "tool: cursor\r\nargv: cursor-agent\r\nenv:\r\nbase_url: \r\nwrap:\r\npre:\r\n"] +[5.747, "o", "\u001b[1;32m\u276f\u001b[0m "] +[5.747, "o", "r"] +[5.776, "o", "u"] +[5.806, "o", "n"] +[5.835, "o", "e"] +[5.863, "o", " "] +[5.89, "o", "l"] +[5.918, "o", "a"] +[5.947, "o", "u"] +[5.975, "o", "n"] +[6.001, "o", "c"] +[6.029, "o", "h"] +[6.056, "o", "\r\n"] +[6.065, "o", " launchable tools\r\n claude installed grok@claude sol@claude\r\n codex installed\r\n agy installed\r\n opencode installed\r\n grok installed\r\n cursor installed fable@cursor max@cursor shell@cursor tools@cursor wide@cursor\r\n ollama installed\r\n"] +[7.576, "o", "\r\n\u001b[2m# Scenario: Cursor launch with a configured binary\u001b[0m\r\n"] +[8.785, "o", "\u001b[2m# A second config sets tools.cursor.binary to /opt/cursor/bin/agent.\u001b[0m\r\n"] +[9.991, "o", "\u001b[1;32m\u276f\u001b[0m "] +[9.991, "o", "r"] +[10.017, "o", "u"] +[10.042, "o", "n"] +[10.07, "o", "e"] +[10.099, "o", " "] +[10.123, "o", "l"] +[10.15, "o", "a"] +[10.176, "o", "u"] +[10.203, "o", "n"] +[10.227, "o", "c"] +[10.255, "o", "h"] +[10.282, "o", " "] +[10.307, "o", "c"] +[10.334, "o", "u"] +[10.357, "o", "r"] +[10.382, "o", "s"] +[10.413, "o", "o"] +[10.443, "o", "r"] +[10.466, "o", " "] +[10.497, "o", "-"] +[10.53, "o", "-"] +[10.564, "o", "d"] +[10.595, "o", "r"] +[10.623, "o", "y"] +[10.653, "o", "-"] +[10.684, "o", "r"] +[10.712, "o", "u"] +[10.736, "o", "n"] +[10.763, "o", "\r\n"] +[10.772, "o", "tool: cursor\r\nargv: /opt/cursor/bin/agent\r\nenv:\r\nbase_url: \r\nwrap:\r\npre:\r\n"] +[12.28, "o", "\r\n\u001b[2m# Scenario: Cursor run with a profile model\u001b[0m\r\n"] +[13.484, "o", "\u001b[2m# The profile model becomes --model, and the prompt travels on standard input.\u001b[0m\r\n"] +[14.693, "o", "\u001b[1;32m\u276f\u001b[0m "] +[14.693, "o", "r"] +[14.722, "o", "u"] +[14.749, "o", "n"] +[14.783, "o", "e"] +[14.81, "o", " "] +[14.839, "o", "r"] +[14.867, "o", "u"] +[14.892, "o", "n"] +[14.917, "o", " "] +[14.942, "o", "m"] +[14.967, "o", "a"] +[14.995, "o", "x"] +[15.023, "o", "@"] +[15.05, "o", "c"] +[15.078, "o", "u"] +[15.108, "o", "r"] +[15.137, "o", "s"] +[15.169, "o", "o"] +[15.196, "o", "r"] +[15.226, "o", " "] +[15.257, "o", "'"] +[15.287, "o", "R"] +[15.316, "o", "e"] +[15.345, "o", "p"] +[15.375, "o", "l"] +[15.405, "o", "y"] +[15.433, "o", " "] +[15.463, "o", "w"] +[15.493, "o", "i"] +[15.522, "o", "t"] +[15.555, "o", "h"] +[15.591, "o", " "] +[15.631, "o", "p"] +[15.676, "o", "o"] +[15.709, "o", "n"] +[15.764, "o", "g"] +[15.856, "o", "'"] +[15.899, "o", "\r\n"] +[16.094, "o", "argv: -p --output-format json --workspace ~/repo --trust --mode ask --model claude-fable-5-1-max | stdin: Reply with pong | HOME=~ CURSOR_CONFIG_DIR=unset key-in-env=no\r\n"] +[17.654, "o", "\r\n\u001b[2m# Scenario: Built-in Fable profile for Cursor\u001b[0m\r\n"] +[18.863, "o", "\u001b[2m# The config defines no fable profile for cursor: the built-in one selects claude-fable-5-1-high.\u001b[0m\r\n"] +[20.073, "o", "\u001b[1;32m\u276f\u001b[0m "] +[20.073, "o", "r"] +[20.103, "o", "u"] +[20.131, "o", "n"] +[20.158, "o", "e"] +[20.184, "o", " "] +[20.213, "o", "r"] +[20.244, "o", "u"] +[20.27, "o", "n"] +[20.301, "o", " "] +[20.328, "o", "f"] +[20.355, "o", "a"] +[20.381, "o", "b"] +[20.406, "o", "l"] +[20.434, "o", "e"] +[20.462, "o", "@"] +[20.487, "o", "c"] +[20.513, "o", "u"] +[20.54, "o", "r"] +[20.568, "o", "s"] +[20.604, "o", "o"] +[20.643, "o", "r"] +[20.696, "o", " "] +[20.738, "o", "'"] +[20.792, "o", "R"] +[20.824, "o", "e"] +[20.857, "o", "p"] +[20.886, "o", "l"] +[20.92, "o", "y"] +[20.966, "o", " "] +[21.018, "o", "w"] +[21.07, "o", "i"] +[21.103, "o", "t"] +[21.149, "o", "h"] +[21.186, "o", " "] +[21.23, "o", "p"] +[21.266, "o", "o"] +[21.298, "o", "n"] +[21.352, "o", "g"] +[21.393, "o", "'"] +[21.434, "o", "\r\n"] +[21.554, "o", "argv: -p --output-format json --workspace ~/repo --trust --mode ask --model claude-fable-5-1-high | stdin: Reply with pong | HOME=~ CURSOR_CONFIG_DIR=unset key-in-env=no\r\n"] +[23.078, "o", "\u001b[1;32m\u276f\u001b[0m "] +[23.078, "o", "r"] +[23.107, "o", "u"] +[23.134, "o", "n"] +[23.162, "o", "e"] +[23.192, "o", " "] +[23.217, "o", "l"] +[23.242, "o", "a"] +[23.268, "o", "u"] +[23.298, "o", "n"] +[23.329, "o", "c"] +[23.358, "o", "h"] +[23.386, "o", " "] +[23.416, "o", "f"] +[23.445, "o", "a"] +[23.47, "o", "b"] +[23.496, "o", "l"] +[23.525, "o", "e"] +[23.554, "o", "@"] +[23.582, "o", "c"] +[23.609, "o", "u"] +[23.638, "o", "r"] +[23.663, "o", "s"] +[23.688, "o", "o"] +[23.717, "o", "r"] +[23.747, "o", " "] +[23.775, "o", "-"] +[23.807, "o", "-"] +[23.833, "o", "d"] +[23.862, "o", "r"] +[23.893, "o", "y"] +[23.917, "o", "-"] +[23.947, "o", "r"] +[23.977, "o", "u"] +[24.005, "o", "n"] +[24.035, "o", "\r\n"] +[24.047, "o", "tool: cursor\r\nmodel: fable-cursor\r\nmodel_id: claude-fable-5-1-high\r\nmodel_context: 1000000\r\nmodel_source: built-in\r\nargv: cursor-agent --model claude-fable-5-1-high\r\nenv:\r\nbase_url: \r\nwrap:\r\npre:\r\n"] +[25.565, "o", "\u001b[1;32m\u276f\u001b[0m r"] +[25.598, "o", "u"] +[25.64, "o", "n"] +[25.678, "o", "e"] +[25.751, "o", " "] +[25.834, "o", "l"] +[25.898, "o", "a"] +[25.947, "o", "u"] +[25.981, "o", "n"] +[26.016, "o", "c"] +[26.053, "o", "h"] +[26.092, "o", "\r\n"] +[26.124, "o", " launchable tools\r\n claude installed grok@claude sol@claude\r\n codex installed\r\n agy installed\r\n opencode installed\r\n grok installed\r\n cursor installed fable@cursor max@cursor shell@cursor tools@cursor wide@cursor\r\n ollama installed\r\n"] +[27.651, "o", "\r\n\u001b[2m# Scenario: Read-only run asks for an edit\u001b[0m\r\n"] +[28.861, "o", "\u001b[2m# Read-only is the default: ask mode, and no --force, --yolo, or --sandbox.\u001b[0m\r\n"] +[30.073, "o", "\u001b[1;32m\u276f\u001b[0m "] +[30.073, "o", "r"] +[30.098, "o", "u"] +[30.126, "o", "n"] +[30.153, "o", "e"] +[30.181, "o", " "] +[30.21, "o", "r"] +[30.241, "o", "u"] +[30.271, "o", "n"] +[30.301, "o", " "] +[30.331, "o", "c"] +[30.36, "o", "u"] +[30.386, "o", "r"] +[30.412, "o", "s"] +[30.441, "o", "o"] +[30.471, "o", "r"] +[30.501, "o", " "] +[30.533, "o", "'"] +[30.567, "o", "C"] +[30.601, "o", "r"] +[30.657, "o", "e"] +[30.69, "o", "a"] +[30.726, "o", "t"] +[30.754, "o", "e"] +[30.795, "o", " "] +[30.828, "o", "h"] +[30.865, "o", "e"] +[30.9, "o", "l"] +[30.935, "o", "l"] +[30.978, "o", "o"] +[31.052, "o", "."] +[31.121, "o", "t"] +[31.196, "o", "x"] +[31.244, "o", "t"] +[31.276, "o", "'"] +[31.308, "o", "\r\n"] +[31.386, "o", "argv: -p --output-format json --workspace ~/repo --trust --mode ask | stdin: Create hello.txt | HOME=~ CURSOR_CONFIG_DIR=unset key-in-env=no\r\n"] +[32.918, "o", "\r\n\u001b[2m# Scenario: Read-only run under unrestricted approval\u001b[0m\r\n"] +[34.127, "o", "\u001b[2m# Defense in depth: a saved approvalMode unrestricted is the one mode that would run a write unasked.\u001b[0m\r\n"] +[35.337, "o", "\u001b[1;32m\u276f\u001b[0m "] +[35.337, "o", "r"] +[35.362, "o", "u"] +[35.392, "o", "n"] +[35.422, "o", "e"] +[35.448, "o", " "] +[35.479, "o", "-"] +[35.509, "o", "-"] +[35.534, "o", "j"] +[35.563, "o", "s"] +[35.595, "o", "o"] +[35.634, "o", "n"] +[35.675, "o", " "] +[35.717, "o", "r"] +[35.768, "o", "u"] +[35.794, "o", "n"] +[35.826, "o", " "] +[35.856, "o", "c"] +[35.889, "o", "u"] +[35.922, "o", "r"] +[35.955, "o", "s"] +[35.995, "o", "o"] +[36.107, "o", "r"] +[36.137, "o", " "] +[36.174, "o", "'"] +[36.205, "o", "h"] +[36.242, "o", "e"] +[36.274, "o", "l"] +[36.304, "o", "l"] +[36.341, "o", "o"] +[36.376, "o", "'"] +[36.405, "o", ";"] +[36.434, "o", " "] +[36.459, "o", "e"] +[36.485, "o", "c"] +[36.512, "o", "h"] +[36.537, "o", "o"] +[36.564, "o", " "] +[36.593, "o", "\""] +[36.621, "o", "e"] +[36.647, "o", "x"] +[36.676, "o", "i"] +[36.705, "o", "t"] +[36.733, "o", " "] +[36.761, "o", "$"] +[36.786, "o", "?"] +[36.814, "o", "\""] +[36.844, "o", "\r\n"] +[36.855, "o", "{\"details\":{\"message\":\"a read-only cursor run refuses approvalMode unrestricted: ask mode is enforced by cursor-agent, and unrestricted is the one approval mode that would run a write without asking if that enforcement failed; set approvalMode to allowlist or auto-review in cli-config.json, or run with --mode workspace-write\"},\"kind\":\"configuration_error\",\"ok\":false,\"tool\":\"cursor\"}\r\nexit 2\r\n"] +[38.389, "o", "\r\n\u001b[2m# Scenario: Workspace-write run creates a file\u001b[0m\r\n"] +[39.597, "o", "\u001b[2m# Workspace-write lets Cursor apply edits inside its own sandbox.\u001b[0m\r\n"] +[40.805, "o", "\u001b[1;32m\u276f\u001b[0m r"] +[40.839, "o", "u"] +[40.872, "o", "n"] +[40.9, "o", "e"] +[40.932, "o", " "] +[40.962, "o", "r"] +[41.007, "o", "u"] +[41.039, "o", "n"] +[41.088, "o", " "] +[41.115, "o", "c"] +[41.151, "o", "u"] +[41.187, "o", "r"] +[41.22, "o", "s"] +[41.251, "o", "o"] +[41.278, "o", "r"] +[41.304, "o", " "] +[41.335, "o", "-"] +[41.364, "o", "-"] +[41.391, "o", "m"] +[41.42, "o", "o"] +[41.447, "o", "d"] +[41.472, "o", "e"] +[41.5, "o", " "] +[41.525, "o", "w"] +[41.55, "o", "o"] +[41.575, "o", "r"] +[41.603, "o", "k"] +[41.627, "o", "s"] +[41.656, "o", "p"] +[41.683, "o", "a"] +[41.711, "o", "c"] +[41.739, "o", "e"] +[41.769, "o", "-"] +[41.797, "o", "w"] +[41.823, "o", "r"] +[41.85, "o", "i"] +[41.875, "o", "t"] +[41.904, "o", "e"] +[41.931, "o", " "] +[41.959, "o", "'"] +[41.987, "o", "C"] +[42.014, "o", "r"] +[42.042, "o", "e"] +[42.07, "o", "a"] +[42.097, "o", "t"] +[42.126, "o", "e"] +[42.158, "o", " "] +[42.182, "o", "h"] +[42.208, "o", "e"] +[42.234, "o", "l"] +[42.262, "o", "l"] +[42.294, "o", "o"] +[42.323, "o", "."] +[42.35, "o", "t"] +[42.376, "o", "x"] +[42.402, "o", "t"] +[42.428, "o", "'"] +[42.454, "o", "\r\n"] +[42.495, "o", "argv: -p --output-format json --workspace ~/repo --trust --force --sandbox enabled | stdin: Create hello.txt | HOME=~ CURSOR_CONFIG_DIR=unset key-in-env=no\r\n"] +[44.011, "o", "\r\n\u001b[2m# Scenario: Cursor reports an error in its result\u001b[0m\r\n"] +[45.221, "o", "\u001b[2m# Cursor exits 0 but sets is_error: the run fails with the tool's message.\u001b[0m\r\n"] +[46.432, "o", "\u001b[1;32m\u276f\u001b[0m r"] +[46.482, "o", "u"] +[46.509, "o", "n"] +[46.545, "o", "e"] +[46.601, "o", " "] +[46.661, "o", "-"] +[46.709, "o", "-"] +[46.751, "o", "j"] +[46.789, "o", "s"] +[46.836, "o", "o"] +[46.884, "o", "n"] +[46.915, "o", " "] +[46.954, "o", "r"] +[46.985, "o", "u"] +[47.018, "o", "n"] +[47.051, "o", " "] +[47.188, "o", "c"] +[47.227, "o", "u"] +[47.263, "o", "r"] +[47.513, "o", "s"] +[47.608, "o", "o"] +[47.673, "o", "r"] +[47.71, "o", " "] +[47.751, "o", "'"] +[47.802, "o", "h"] +[47.841, "o", "e"] +[47.88, "o", "l"] +[47.905, "o", "l"] +[47.937, "o", "o"] +[47.968, "o", "'"] +[48.038, "o", ";"] +[48.089, "o", " "] +[48.122, "o", "e"] +[48.152, "o", "c"] +[48.188, "o", "h"] +[48.221, "o", "o"] +[48.248, "o", " "] +[48.295, "o", "\""] +[48.335, "o", "e"] +[48.378, "o", "x"] +[48.407, "o", "i"] +[48.444, "o", "t"] +[48.481, "o", " "] +[48.511, "o", "$"] +[48.543, "o", "?"] +[48.571, "o", "\""] +[48.603, "o", "\r\n"] +[48.681, "o", "{\"details\":{\"message\":\"cursor-agent reported an error: rate limited\"},\"kind\":\"surface_error\",\"ok\":false,\"tool\":\"cursor\"}\r\nexit 1\r\n"] +[50.209, "o", "\r\n\u001b[2m# Scenario: Clean run without a key\u001b[0m\r\n"] +[51.42, "o", "\u001b[2m# The browser login lives in the Keychain, which a clean home cannot reach.\u001b[0m\r\n"] +[52.629, "o", "\u001b[1;32m\u276f\u001b[0m "] +[52.629, "o", "r"] +[52.658, "o", "u"] +[52.687, "o", "n"] +[52.711, "o", "e"] +[52.74, "o", " "] +[52.768, "o", "-"] +[52.796, "o", "-"] +[52.825, "o", "j"] +[52.853, "o", "s"] +[52.882, "o", "o"] +[52.91, "o", "n"] +[52.939, "o", " "] +[52.965, "o", "r"] +[52.993, "o", "u"] +[53.021, "o", "n"] +[53.049, "o", " "] +[53.074, "o", "c"] +[53.1, "o", "u"] +[53.125, "o", "r"] +[53.153, "o", "s"] +[53.178, "o", "o"] +[53.206, "o", "r"] +[53.234, "o", " "] +[53.261, "o", "-"] +[53.289, "o", "-"] +[53.315, "o", "c"] +[53.342, "o", "l"] +[53.369, "o", "e"] +[53.397, "o", "a"] +[53.425, "o", "n"] +[53.451, "o", " "] +[53.478, "o", "'"] +[53.507, "o", "R"] +[53.531, "o", "e"] +[53.559, "o", "p"] +[53.587, "o", "l"] +[53.614, "o", "y"] +[53.641, "o", " "] +[53.669, "o", "w"] +[53.692, "o", "i"] +[53.723, "o", "t"] +[53.751, "o", "h"] +[53.777, "o", " "] +[53.805, "o", "p"] +[53.833, "o", "o"] +[53.861, "o", "n"] +[53.887, "o", "g"] +[53.915, "o", "'"] +[53.943, "o", ";"] +[53.971, "o", " "] +[53.999, "o", "e"] +[54.027, "o", "c"] +[54.055, "o", "h"] +[54.084, "o", "o"] +[54.111, "o", " "] +[54.139, "o", "\""] +[54.167, "o", "e"] +[54.197, "o", "x"] +[54.23, "o", "i"] +[54.262, "o", "t"] +[54.339, "o", " "] +[54.376, "o", "$"] +[54.409, "o", "?"] +[54.438, "o", "\""] +[54.466, "o", "\r\n"] +[54.479, "o", "{\"details\":{\"message\":\"a clean cursor run needs CURSOR_API_KEY: the cursor-agent browser login lives in the macOS Keychain, which a clean home cannot reach, and CURSOR_CONFIG_DIR alone still loads ~/.cursor/rules; set CURSOR_API_KEY or run without --clean\"},\"kind\":\"configuration_error\",\"ok\":false,\"tool\":\"cursor\"}\r\nexit 2\r\n"] +[56.011, "o", "\r\n\u001b[2m# Scenario: Clean run with a key\u001b[0m\r\n"] +[57.219, "o", "\u001b[2m# HOME and CURSOR_CONFIG_DIR move into the clean root. The key stays in the environment.\u001b[0m\r\n"] +[58.428, "o", "\u001b[1;32m\u276f\u001b[0m "] +[58.428, "o", "C"] +[58.458, "o", "U"] +[58.487, "o", "R"] +[58.515, "o", "S"] +[58.544, "o", "O"] +[58.571, "o", "R"] +[58.599, "o", "_"] +[58.628, "o", "A"] +[58.657, "o", "P"] +[58.686, "o", "I"] +[58.714, "o", "_"] +[58.742, "o", "K"] +[58.77, "o", "E"] +[58.799, "o", "Y"] +[58.827, "o", "="] +[58.855, "o", "p"] +[58.882, "o", "r"] +[58.91, "o", "o"] +[58.941, "o", "o"] +[58.969, "o", "f"] +[58.997, "o", "-"] +[59.025, "o", "k"] +[59.053, "o", "e"] +[59.081, "o", "y"] +[59.11, "o", " "] +[59.138, "o", "r"] +[59.168, "o", "u"] +[59.203, "o", "n"] +[59.245, "o", "e"] +[59.278, "o", " "] +[59.311, "o", "r"] +[59.339, "o", "u"] +[59.368, "o", "n"] +[59.397, "o", " "] +[59.427, "o", "c"] +[59.455, "o", "u"] +[59.483, "o", "r"] +[59.51, "o", "s"] +[59.538, "o", "o"] +[59.565, "o", "r"] +[59.592, "o", " "] +[59.62, "o", "-"] +[59.647, "o", "-"] +[59.672, "o", "c"] +[59.7, "o", "l"] +[59.725, "o", "e"] +[59.749, "o", "a"] +[59.776, "o", "n"] +[59.804, "o", " "] +[59.83, "o", "'"] +[59.855, "o", "R"] +[59.882, "o", "e"] +[59.91, "o", "p"] +[59.938, "o", "l"] +[59.966, "o", "y"] +[59.99, "o", " "] +[60.018, "o", "w"] +[60.045, "o", "i"] +[60.073, "o", "t"] +[60.099, "o", "h"] +[60.127, "o", " "] +[60.151, "o", "p"] +[60.179, "o", "o"] +[60.207, "o", "n"] +[60.235, "o", "g"] +[60.263, "o", "'"] +[60.29, "o", "\r\n"] +[60.33, "o", "argv: -p --output-format json --workspace ~/repo --trust --mode ask | stdin: Reply with pong | HOME=/tmp/claude-501/rune-clean-YePYnQ CURSOR_CONFIG_DIR=/tmp/claude-501/rune-clean-YePYnQ/.cursor key-in-env=yes\r\n"] +[61.858, "o", "\r\n\u001b[2m# Scenario: Cursor run inside the harness sandbox\u001b[0m\r\n"] +[63.067, "o", "\u001b[2m# cursor-agent cannot read the Keychain in the sandbox: the failure keeps its stderr and adds a hint.\u001b[0m\r\n"] +[64.278, "o", "\u001b[1;32m\u276f\u001b[0m "] +[64.278, "o", "r"] +[64.316, "o", "u"] +[64.353, "o", "n"] +[64.385, "o", "e"] +[64.415, "o", " "] +[64.45, "o", "r"] +[64.476, "o", "u"] +[64.504, "o", "n"] +[64.532, "o", " "] +[64.561, "o", "c"] +[64.589, "o", "u"] +[64.614, "o", "r"] +[64.641, "o", "s"] +[64.67, "o", "o"] +[64.697, "o", "r"] +[64.722, "o", " "] +[64.748, "o", "'"] +[64.772, "o", "h"] +[64.801, "o", "e"] +[64.829, "o", "l"] +[64.857, "o", "l"] +[64.885, "o", "o"] +[64.912, "o", "'"] +[64.94, "o", "\r\n"] +[64.981, "o", "error: process Exited(1): hint: cursor-agent cannot read its Keychain login inside the harness sandbox; run `rune run` as a bare command outside the sandbox\r\nERROR: failed to copy trust settings of system certificate-25291\r\nERROR: failed to copy trust settings of system certificate-25291\r\nERROR: failed to copy trust settings of system certificate-25291\r\nERROR: failed to copy trust settings of system certificate-25291\r\nERROR: failed to copy trust settings of system certificate-25291\r\nERROR: failed to copy trust settings of syst\r\n"] +[66.499, "o", "\r\n\u001b[2m# Scenario: Cursor profile widens access\u001b[0m\r\n"] +[67.707, "o", "\u001b[2m# The profile carries --yolo and --sandbox disabled: both are dropped with a warning each.\u001b[0m\r\n"] +[68.914, "o", "\u001b[1;32m\u276f\u001b[0m "] +[68.914, "o", "r"] +[68.944, "o", "u"] +[68.973, "o", "n"] +[69.002, "o", "e"] +[69.028, "o", " "] +[69.057, "o", "r"] +[69.084, "o", "u"] +[69.111, "o", "n"] +[69.139, "o", " "] +[69.166, "o", "w"] +[69.195, "o", "i"] +[69.225, "o", "d"] +[69.306, "o", "e"] +[69.34, "o", "@"] +[69.367, "o", "c"] +[69.397, "o", "u"] +[69.425, "o", "r"] +[69.45, "o", "s"] +[69.478, "o", "o"] +[69.506, "o", "r"] +[69.533, "o", " "] +[69.561, "o", "'"] +[69.589, "o", "h"] +[69.616, "o", "e"] +[69.643, "o", "l"] +[69.672, "o", "l"] +[69.7, "o", "o"] +[69.727, "o", "'"] +[69.755, "o", "\r\n"] +[69.797, "o", "warning: automated cursor execution owns --yolo; the profile flag is dropped\r\nwarning: automated cursor execution owns --sandbox; the profile value disabled is dropped\r\nargv: -p --output-format json --workspace ~/repo --trust --mode ask | stdin: hello | HOME=~ CURSOR_CONFIG_DIR=unset key-in-env=no\r\n"] +[71.318, "o", "\r\n\u001b[2m# Scenario: Cursor profile carries an unlisted flag\u001b[0m\r\n"] +[72.525, "o", "\u001b[2m# cursor-agent accepts --allowed-tools although its help does not list it. The run keeps only --endpoint.\u001b[0m\r\n"] +[73.733, "o", "\u001b[1;32m\u276f\u001b[0m "] +[73.733, "o", "r"] +[73.758, "o", "u"] +[73.782, "o", "n"] +[73.81, "o", "e"] +[73.837, "o", " "] +[73.864, "o", "r"] +[73.89, "o", "u"] +[73.917, "o", "n"] +[73.94, "o", " "] +[73.967, "o", "t"] +[73.995, "o", "o"] +[74.019, "o", "o"] +[74.045, "o", "l"] +[74.072, "o", "s"] +[74.099, "o", "@"] +[74.126, "o", "c"] +[74.153, "o", "u"] +[74.183, "o", "r"] +[74.227, "o", "s"] +[74.264, "o", "o"] +[74.305, "o", "r"] +[74.333, "o", " "] +[74.363, "o", "'"] +[74.395, "o", "h"] +[74.421, "o", "e"] +[74.449, "o", "l"] +[74.477, "o", "l"] +[74.504, "o", "o"] +[74.532, "o", "'"] +[74.559, "o", "\r\n"] +[74.595, "o", "warning: automated cursor execution keeps only --endpoint from a profile; the profile flag --allowed-tools is dropped\r\nargv: -p --output-format json --workspace ~/repo --trust --mode ask | stdin: hello | HOME=~ CURSOR_CONFIG_DIR=unset key-in-env=no\r\n"] +[76.112, "o", "\r\n\u001b[2m# Scenario: Cursor profile names a subcommand\u001b[0m\r\n"] +[77.319, "o", "\u001b[2m# The profile carries install-shell-integration, which cursor-agent would run as a subcommand.\u001b[0m\r\n"] +[78.529, "o", "\u001b[1;32m\u276f\u001b[0m "] +[78.529, "o", "r"] +[78.558, "o", "u"] +[78.585, "o", "n"] +[78.613, "o", "e"] +[78.642, "o", " "] +[78.67, "o", "r"] +[78.698, "o", "u"] +[78.726, "o", "n"] +[78.754, "o", " "] +[78.782, "o", "s"] +[78.811, "o", "h"] +[78.839, "o", "e"] +[78.867, "o", "l"] +[78.895, "o", "l"] +[78.924, "o", "@"] +[78.953, "o", "c"] +[78.979, "o", "u"] +[79.008, "o", "r"] +[79.036, "o", "s"] +[79.065, "o", "o"] +[79.09, "o", "r"] +[79.118, "o", " "] +[79.146, "o", "'"] +[79.175, "o", "h"] +[79.205, "o", "e"] +[79.231, "o", "l"] +[79.265, "o", "l"] +[79.294, "o", "o"] +[79.32, "o", "'"] +[79.35, "o", "\r\n"] +[79.421, "o", "warning: automated cursor execution drops the positional profile argument at position 1; cursor-agent reads it as a subcommand or a prompt\r\nargv: -p --output-format json --workspace ~/repo --trust --mode ask | stdin: hello | HOME=~ CURSOR_CONFIG_DIR=unset key-in-env=no\r\n"] diff --git a/docs/proofs/cursor-agent-surface/proof.gif b/docs/proofs/cursor-agent-surface/proof.gif new file mode 100644 index 0000000..8d5dfd8 Binary files /dev/null and b/docs/proofs/cursor-agent-surface/proof.gif differ diff --git a/docs/proofs/cursor-agent-surface/proof.txt b/docs/proofs/cursor-agent-surface/proof.txt new file mode 100644 index 0000000..8aadd3e --- /dev/null +++ b/docs/proofs/cursor-agent-surface/proof.txt @@ -0,0 +1,122 @@ + +# Scenario: Cursor launch without a configured binary +# The cursor tool starts cursor-agent, because the cursor command on a Mac opens the editor. +❯ rune launch cursor --dry-run +tool: cursor +argv: cursor-agent +env: +base_url: +wrap: +pre: +❯ rune launch + launchable tools + claude installed grok@claude sol@claude + codex installed + agy installed + opencode installed + grok installed + cursor installed fable@cursor max@cursor shell@cursor tools@cursor wide@cursor + ollama installed + +# Scenario: Cursor launch with a configured binary +# A second config sets tools.cursor.binary to /opt/cursor/bin/agent. +❯ rune launch cursor --dry-run +tool: cursor +argv: /opt/cursor/bin/agent +env: +base_url: +wrap: +pre: + +# Scenario: Cursor run with a profile model +# The profile model becomes --model, and the prompt travels on standard input. +❯ rune run max@cursor 'Reply with pong' +argv: -p --output-format json --workspace ~/repo --trust --mode ask --model claude-fable-5-1-max | stdin: Reply with pong | HOME=~ CURSOR_CONFIG_DIR=unset key-in-env=no + +# Scenario: Built-in Fable profile for Cursor +# The config defines no fable profile for cursor: the built-in one selects claude-fable-5-1-high. +❯ rune run fable@cursor 'Reply with pong' +argv: -p --output-format json --workspace ~/repo --trust --mode ask --model claude-fable-5-1-high | stdin: Reply with pong | HOME=~ CURSOR_CONFIG_DIR=unset key-in-env=no +❯ rune launch fable@cursor --dry-run +tool: cursor +model: fable-cursor +model_id: claude-fable-5-1-high +model_context: 1000000 +model_source: built-in +argv: cursor-agent --model claude-fable-5-1-high +env: +base_url: +wrap: +pre: +❯ rune launch + launchable tools + claude installed grok@claude sol@claude + codex installed + agy installed + opencode installed + grok installed + cursor installed fable@cursor max@cursor shell@cursor tools@cursor wide@cursor + ollama installed + +# Scenario: Read-only run asks for an edit +# Read-only is the default: ask mode, and no --force, --yolo, or --sandbox. +❯ rune run cursor 'Create hello.txt' +argv: -p --output-format json --workspace ~/repo --trust --mode ask | stdin: Create hello.txt | HOME=~ CURSOR_CONFIG_DIR=unset key-in-env=no + +# Scenario: Read-only run under unrestricted approval +# Defense in depth: a saved approvalMode unrestricted is the one mode that would run a write unasked. +❯ rune --json run cursor 'hello'; echo "exit $?" +{"details":{"message":"a read-only cursor run refuses approvalMode unrestricted: ask mode is enforced by cursor-agent, and unrestricted is the one approval mode that would run a write without asking if that enforcement failed; set approvalMode to allowlist or auto-review in cli-config.json, or run with --mode workspace-write"},"kind":"configuration_error","ok":false,"tool":"cursor"} +exit 2 + +# Scenario: Workspace-write run creates a file +# Workspace-write lets Cursor apply edits inside its own sandbox. +❯ rune run cursor --mode workspace-write 'Create hello.txt' +argv: -p --output-format json --workspace ~/repo --trust --force --sandbox enabled | stdin: Create hello.txt | HOME=~ CURSOR_CONFIG_DIR=unset key-in-env=no + +# Scenario: Cursor reports an error in its result +# Cursor exits 0 but sets is_error: the run fails with the tool's message. +❯ rune --json run cursor 'hello'; echo "exit $?" +{"details":{"message":"cursor-agent reported an error: rate limited"},"kind":"surface_error","ok":false,"tool":"cursor"} +exit 1 + +# Scenario: Clean run without a key +# The browser login lives in the Keychain, which a clean home cannot reach. +❯ rune --json run cursor --clean 'Reply with pong'; echo "exit $?" +{"details":{"message":"a clean cursor run needs CURSOR_API_KEY: the cursor-agent browser login lives in the macOS Keychain, which a clean home cannot reach, and CURSOR_CONFIG_DIR alone still loads ~/.cursor/rules; set CURSOR_API_KEY or run without --clean"},"kind":"configuration_error","ok":false,"tool":"cursor"} +exit 2 + +# Scenario: Clean run with a key +# HOME and CURSOR_CONFIG_DIR move into the clean root. The key stays in the environment. +❯ CURSOR_API_KEY=proof-key rune run cursor --clean 'Reply with pong' +argv: -p --output-format json --workspace ~/repo --trust --mode ask | stdin: Reply with pong | HOME=/tmp/claude-501/rune-clean-YePYnQ CURSOR_CONFIG_DIR=/tmp/claude-501/rune-clean-YePYnQ/.cursor key-in-env=yes + +# Scenario: Cursor run inside the harness sandbox +# cursor-agent cannot read the Keychain in the sandbox: the failure keeps its stderr and adds a hint. +❯ rune run cursor 'hello' +error: process Exited(1): hint: cursor-agent cannot read its Keychain login inside the harness sandbox; run `rune run` as a bare command outside the sandbox +ERROR: failed to copy trust settings of system certificate-25291 +ERROR: failed to copy trust settings of system certificate-25291 +ERROR: failed to copy trust settings of system certificate-25291 +ERROR: failed to copy trust settings of system certificate-25291 +ERROR: failed to copy trust settings of system certificate-25291 +ERROR: failed to copy trust settings of syst + +# Scenario: Cursor profile widens access +# The profile carries --yolo and --sandbox disabled: both are dropped with a warning each. +❯ rune run wide@cursor 'hello' +warning: automated cursor execution owns --yolo; the profile flag is dropped +warning: automated cursor execution owns --sandbox; the profile value disabled is dropped +argv: -p --output-format json --workspace ~/repo --trust --mode ask | stdin: hello | HOME=~ CURSOR_CONFIG_DIR=unset key-in-env=no + +# Scenario: Cursor profile carries an unlisted flag +# cursor-agent accepts --allowed-tools although its help does not list it. The run keeps only --endpoint. +❯ rune run tools@cursor 'hello' +warning: automated cursor execution keeps only --endpoint from a profile; the profile flag --allowed-tools is dropped +argv: -p --output-format json --workspace ~/repo --trust --mode ask | stdin: hello | HOME=~ CURSOR_CONFIG_DIR=unset key-in-env=no + +# Scenario: Cursor profile names a subcommand +# The profile carries install-shell-integration, which cursor-agent would run as a subcommand. +❯ rune run shell@cursor 'hello' +warning: automated cursor execution drops the positional profile argument at position 1; cursor-agent reads it as a subcommand or a prompt +argv: -p --output-format json --workspace ~/repo --trust --mode ask | stdin: hello | HOME=~ CURSOR_CONFIG_DIR=unset key-in-env=no diff --git a/docs/proofs/cursor-agent-surface/record.sh b/docs/proofs/cursor-agent-surface/record.sh new file mode 100644 index 0000000..9712d7f --- /dev/null +++ b/docs/proofs/cursor-agent-surface/record.sh @@ -0,0 +1,179 @@ +#!/usr/bin/env bash +# Driver for a recorded acceptance proof. asciinema records this script. +# The grammar lives in ../driver.sh; edit only the scenes at the end. +# shellcheck source=../driver.sh +. "$(dirname "$0")/../driver.sh" + +printf '\033[2J\033[H' + +# Off camera: a scratch HOME whose rune config carries the cursor +# profiles max, wide, tools, and shell, a Cursor config directory with +# approvalMode unrestricted, and a fake `cursor-agent` on PATH. The fake +# prints one Cursor result object whose text names the arguments it +# received, the last line of its standard input, and its HOME and +# CURSOR_CONFIG_DIR. FAKE_CURSOR_ERROR makes it report an error, +# FAKE_CURSOR_SANDBOX makes it fail the way cursor-agent fails inside the +# harness sandbox. The fake prints the scratch HOME as `~`, as a shell +# prompt does. No model is called. RUNE names the binary under test. +RUNE=${RUNE:-rune} +unset SANDBOX_RUNTIME CURSOR_API_KEY CURSOR_CONFIG_DIR XDG_CONFIG_HOME +PROOF_HOME=$(cd "$(mktemp -d)" && pwd -P) +BINARY_HOME=$(mktemp -d) +UNRESTRICTED_DIR=$(mktemp -d) +export HOME="$PROOF_HOME" FAKE_CURSOR_HOME="$PROOF_HOME" +mkdir -p "$PROOF_HOME/.config/rune" "$PROOF_HOME/bin" "$PROOF_HOME/repo" "$BINARY_HOME/.config/rune" +cat > "$PROOF_HOME/bin/cursor-agent" <<'SH' +#!/usr/bin/env bash +# Fake cursor-agent: answer with the argv, the prompt, and the homes. +prompt=$(tail -n 1) +if [ -n "${FAKE_CURSOR_SANDBOX:-}" ]; then + for _ in 1 2 3 4 5 6 7 8; do + echo "ERROR: failed to copy trust settings of system certificate-25291" >&2 + done + echo "Error: Authentication required. Please run 'agent login' first." >&2 + exit 1 +fi +if [ -n "${FAKE_CURSOR_ERROR:-}" ]; then + jq -cn --arg r "$FAKE_CURSOR_ERROR" '{type:"result",subtype:"error",is_error:true,result:$r}' + exit 0 +fi +key=no +[ -n "${CURSOR_API_KEY:-}" ] && key=yes +text="argv: $* | stdin: $prompt | HOME=$HOME CURSOR_CONFIG_DIR=${CURSOR_CONFIG_DIR:-unset} key-in-env=$key" +text=${text//$FAKE_CURSOR_HOME/\~} +jq -cn --arg r "$text" '{type:"result",subtype:"success",is_error:false,result:$r,usage:{inputTokens:12,outputTokens:3}}' +SH +chmod +x "$PROOF_HOME/bin/cursor-agent" +export PATH="$PROOF_HOME/bin:$PATH" +cat > "$PROOF_HOME/.config/rune/config.yaml" <<'YAML' +launch: + models: + fable-max: + id: claude-fable-5-1-max + context: 1000000 + profiles: + cursor: + max: + model: fable-max + wide: + args: + - "--yolo" + - "--sandbox" + - "disabled" + tools: + args: + - "--allowed-tools=shell" + shell: + args: + - "install-shell-integration" +YAML +cat > "$BINARY_HOME/.config/rune/config.yaml" <<'YAML' +launch: + tools: + cursor: + binary: /opt/cursor/bin/agent +YAML +printf '{"version":1,"approvalMode":"unrestricted"}\n' > "$UNRESTRICTED_DIR/cli-config.json" +cd "$PROOF_HOME/repo" || exit 1 +preflight "$RUNE" launch + +scenario "Cursor launch without a configured binary" +comment "The cursor tool starts cursor-agent, because the cursor command on a Mac opens the editor." +run "$RUNE launch cursor --dry-run" +expect "cursor-agent" +run "$RUNE launch" +expect "cursor" + +scenario "Cursor launch with a configured binary" +comment "A second config sets tools.cursor.binary to /opt/cursor/bin/agent." +run "$RUNE launch cursor --dry-run" "HOME=$BINARY_HOME $RUNE launch cursor --dry-run" +expect "/opt/cursor/bin/agent" + +scenario "Cursor run with a profile model" +comment "The profile model becomes --model, and the prompt travels on standard input." +run "$RUNE run max@cursor 'Reply with pong'" +expect "argv: -p --output-format json --workspace [^ ]+ --trust --mode ask --model claude-fable-5-1-max \|" +expect "stdin: Reply with pong" +expect_not "argv: [^|]*Reply with pong" + +scenario "Built-in Fable profile for Cursor" +comment "The config defines no fable profile for cursor: the built-in one selects claude-fable-5-1-high." +run "$RUNE run fable@cursor 'Reply with pong'" +expect "argv: .*--model claude-fable-5-1-high \|" +expect_not "warning" +run "$RUNE launch fable@cursor --dry-run" +expect "argv: cursor-agent --model claude-fable-5-1-high" +run "$RUNE launch" +expect "fable@cursor" + +scenario "Read-only run asks for an edit" +comment "Read-only is the default: ask mode, and no --force, --yolo, or --sandbox." +run "$RUNE run cursor 'Create hello.txt'" +expect "argv: .*--mode ask" +expect_not "argv: [^|]*(--force|--yolo|--sandbox)" + +scenario "Read-only run under unrestricted approval" +comment "Defense in depth: a saved approvalMode unrestricted is the one mode that would run a write unasked." +run "$RUNE --json run cursor 'hello'; echo \"exit \$?\"" "CURSOR_CONFIG_DIR=$UNRESTRICTED_DIR $RUNE --json run cursor 'hello'; echo \"exit \$?\"" +expect "configuration_error" +expect "approvalMode unrestricted" +expect "exit 2" +expect_not "argv:" + +scenario "Workspace-write run creates a file" +comment "Workspace-write lets Cursor apply edits inside its own sandbox." +run "$RUNE run cursor --mode workspace-write 'Create hello.txt'" +expect "argv: .*--force --sandbox enabled" +expect_not "argv: [^|]*--mode ask" + +scenario "Cursor reports an error in its result" +comment "Cursor exits 0 but sets is_error: the run fails with the tool's message." +run "$RUNE --json run cursor 'hello'; echo \"exit \$?\"" "FAKE_CURSOR_ERROR='rate limited' $RUNE --json run cursor 'hello'; echo \"exit \$?\"" +expect "rate limited" +expect "exit 1" +expect_not "\"ok\": ?true" + +scenario "Clean run without a key" +comment "The browser login lives in the Keychain, which a clean home cannot reach." +run "$RUNE --json run cursor --clean 'Reply with pong'; echo \"exit \$?\"" +expect "configuration_error" +expect "CURSOR_API_KEY" +expect "Keychain" +expect "exit 2" +expect_not "argv:" + +scenario "Clean run with a key" +comment "HOME and CURSOR_CONFIG_DIR move into the clean root. The key stays in the environment." +run "CURSOR_API_KEY=proof-key $RUNE run cursor --clean 'Reply with pong'" +expect "CURSOR_CONFIG_DIR=[^ ]+/\.cursor" +expect "key-in-env=yes" +expect_not "HOME=~ " +expect_not "argv: [^|]*proof-key" + +scenario "Cursor run inside the harness sandbox" +comment "cursor-agent cannot read the Keychain in the sandbox: the failure keeps its stderr and adds a hint." +run "$RUNE run cursor 'hello'" "FAKE_CURSOR_SANDBOX=1 $RUNE run cursor 'hello'" +expect "hint: cursor-agent cannot read its Keychain login" +expect "failed to copy trust settings" + +scenario "Cursor profile widens access" +comment "The profile carries --yolo and --sandbox disabled: both are dropped with a warning each." +run "$RUNE run wide@cursor 'hello'" +expect "warning: .*--yolo" +expect "warning: .*--sandbox.*disabled" +expect "argv: .*--mode ask" +expect_not "argv: [^|]*(--yolo|disabled)" + +scenario "Cursor profile carries an unlisted flag" +comment "cursor-agent accepts --allowed-tools although its help does not list it. The run keeps only --endpoint." +run "$RUNE run tools@cursor 'hello'" +expect "warning: .*--allowed-tools" +expect_not "warning: .*shell" +expect_not "argv: [^|]*--allowed-tools" + +scenario "Cursor profile names a subcommand" +comment "The profile carries install-shell-integration, which cursor-agent would run as a subcommand." +run "$RUNE run shell@cursor 'hello'" +expect "warning: .*positional profile argument" +expect "stdin: hello" +expect_not "argv: [^|]*install-shell-integration" diff --git a/src/cli/launch/check/tests.rs b/src/cli/launch/check/tests.rs index d533fdc..03e6850 100644 --- a/src/cli/launch/check/tests.rs +++ b/src/cli/launch/check/tests.rs @@ -54,6 +54,7 @@ fn resolved( compact: None, source: ModelSource::Config, }), + model_args: Vec::new(), dry_run: false, check: true, wrap: Vec::new(), diff --git a/src/cli/launch/mod.rs b/src/cli/launch/mod.rs index c11f9a1..b4ff09f 100644 --- a/src/cli/launch/mod.rs +++ b/src/cli/launch/mod.rs @@ -25,7 +25,13 @@ const SENSITIVE_ENV_KEYS: &[&str] = &[ "DYLD_LIBRARY_PATH", ]; -const KNOWN_TOOLS: &[&str] = &["claude", "codex", "agy", "opencode", "grok", "ollama"]; +const KNOWN_TOOLS: &[&str] = &[ + "claude", "codex", "agy", "opencode", "grok", "cursor", "ollama", +]; +/// Profiles that `@` selects when the config defines no +/// profile of that name, as (tool, profile, model route). Config profiles +/// win. +const BUILT_IN_PROFILES: &[(&str, &str, &str)] = &[("cursor", "fable", "fable-cursor")]; const CLAUDE_MODEL_ENV_KEYS: &[&str] = &[ "ANTHROPIC_MODEL", "CLAUDE_CODE_MAX_CONTEXT_TOKENS", @@ -74,7 +80,7 @@ fn resolve_with_config( config: ontology::ResolvedConfig, ) -> Result { let (tool_name, profile_name) = split_invocation(invocation); - let profile = resolve_profile(tool_name, profile_name, &config.launch)?.cloned(); + let profile = resolve_profile(tool_name, profile_name, &config.launch)?; let mut options = parse_cli_tail(rest, &config.launch)?; if let Some(profile) = &profile { if !options.direct { @@ -101,6 +107,8 @@ fn resolve_with_config( if let Some(profile) = &profile { apply_profile_env(profile, &mut plan, context.config.env_file().as_deref())?; } + let model_args = model_args(tool_name, model.as_ref()); + options.args.splice(0..0, model_args.iter().cloned()); let argv = build_argv(&tool, &options.args, &plan); let env = process_env(&tool, &plan); let display_env = final_env(&tool, &plan); @@ -117,6 +125,7 @@ fn resolve_with_config( pre: plan.pre, warnings: plan.warnings, model, + model_args, dry_run: options.dry_run, check: options.check, display_env, @@ -124,6 +133,18 @@ fn resolve_with_config( }) } +/// The arguments that give the tool its route's model, in front of the +/// profile arguments. Claude gets the model as environment instead, and +/// the other tools name it in their profile arguments. +fn model_args(tool: &str, model: Option<&ResolvedModel>) -> Vec { + match model { + Some(model) if tool == "cursor" => { + vec![OsString::from("--model"), OsString::from(&model.id)] + } + _ => Vec::new(), + } +} + #[derive(Debug, Clone, PartialEq, Eq)] struct ParsedLaunchTail { middleware: Vec, @@ -173,6 +194,9 @@ pub(crate) struct ResolvedLaunch { pub(crate) argv: Vec, pub(crate) env: Vec<(OsString, OsString)>, pub(crate) model: Option, + /// The leading tool arguments that `model_args` generated. `rune run` + /// sets the model itself and strips them. + pub(crate) model_args: Vec, pub(crate) dry_run: bool, pub(crate) check: bool, wrap: Vec>, @@ -337,36 +361,63 @@ fn split_invocation(invocation: &str) -> (&str, Option<&str>) { } } -/// A named profile must exist when requested for a non-ollama tool; for -/// ollama the name falls back to a model for `ollama run`. -fn resolve_profile<'config>( +/// A named profile must exist in the config or in `BUILT_IN_PROFILES` when +/// requested for a non-ollama tool; for ollama the name falls back to a +/// model for `ollama run`. +fn resolve_profile( tool: &str, profile: Option<&str>, - launch: &'config Launch, -) -> Result, String> { + launch: &Launch, +) -> Result, String> { let Some(name) = profile else { return Ok(None); }; - let found = launch + if let Some(found) = launch .profiles .get(tool) - .and_then(|profiles| profiles.get(name)); - if found.is_none() && tool != "ollama" { - let known = launch - .profiles - .get(tool) - .map(|profiles| { - let mut names: Vec<&str> = profiles.keys().map(String::as_str).collect(); - names.sort_unstable(); - names.join(", ") - }) - .filter(|names| !names.is_empty()) - .unwrap_or_else(|| "none defined".to_string()); - return Err(format!( - "no launch profile '{name}' for {tool} (profiles: {known})" - )); + .and_then(|profiles| profiles.get(name)) + { + return Ok(Some(found.clone())); } - Ok(found) + if let Some((_, _, route)) = BUILT_IN_PROFILES + .iter() + .find(|(built_in_tool, built_in_name, _)| *built_in_tool == tool && *built_in_name == name) + { + return Ok(Some(ontology::LaunchProfile { + model: Some((*route).to_string()), + ..ontology::LaunchProfile::default() + })); + } + if tool == "ollama" { + return Ok(None); + } + let names = profile_names(tool, launch); + let known = if names.is_empty() { + "none defined".to_string() + } else { + names.join(", ") + }; + Err(format!( + "no launch profile '{name}' for {tool} (profiles: {known})" + )) +} + +/// Configured and built-in profile names for one tool, sorted, each once. +fn profile_names<'launch>(tool: &str, launch: &'launch Launch) -> Vec<&'launch str> { + let mut names: Vec<&str> = launch + .profiles + .get(tool) + .map(|profiles| profiles.keys().map(String::as_str).collect()) + .unwrap_or_default(); + names.extend( + BUILT_IN_PROFILES + .iter() + .filter(|(built_in_tool, _, _)| *built_in_tool == tool) + .map(|(_, built_in_name, _)| *built_in_name), + ); + names.sort_unstable(); + names.dedup(); + names } fn apply_profile_model( @@ -425,52 +476,44 @@ fn apply_profile_model( })) } +/// Model routes that need no config, as (alias, id, context). A configured +/// `models.` replaces the whole entry. +const BUILT_IN_ROUTES: &[(&str, &str, u64)] = &[ + ("sol", "gpt-5.6-sol", 272_000), + ("sol-api", "gpt-5.6-sol", 1_050_000), + ("grok", "grok-4.6", 500_000), + ("lumo", "lumo-max", 131_072), + // Kimi K3 on the Standard tier. The proxy names a `kimi-k3-256k` + // route beside `kimi-k3` and publishes no limit; 262144 is the + // recorded assumption (CLI decision "Model routes are checked + // against the endpoint on demand"). + ("kimi", "kimi-k3", 262_144), + // Cursor names each Fable 5.1 effort level as its own id. `high` is + // the one its model list labels without an effort suffix, as + // "Claude Fable 5.1 1M". + ("fable-cursor", "claude-fable-5-1-high", 1_000_000), +]; + fn resolve_model(alias: &str, launch: &Launch) -> Result<(LaunchModel, ModelSource), String> { if let Some(model) = launch.models.get(alias) { return Ok((model.clone(), ModelSource::Config)); } - let model = match alias { - "sol" => LaunchModel { - id: "gpt-5.6-sol".to_string(), - context: 272_000, - compact: None, - }, - "sol-api" => LaunchModel { - id: "gpt-5.6-sol".to_string(), - context: 1_050_000, - compact: None, - }, - "grok" => LaunchModel { - id: "grok-4.6".to_string(), - context: 500_000, + if let Some((_, id, context)) = BUILT_IN_ROUTES.iter().find(|(name, _, _)| *name == alias) { + let model = LaunchModel { + id: (*id).to_string(), + context: *context, compact: None, - }, - "lumo" => LaunchModel { - id: "lumo-max".to_string(), - context: 131_072, - compact: None, - }, - // Kimi K3 on the Standard tier. The proxy names a `kimi-k3-256k` - // route beside `kimi-k3` and publishes no limit; 262144 is the - // recorded assumption (CLI decision "Model routes are checked - // against the endpoint on demand"). Config `models.kimi` overrides. - "kimi" => LaunchModel { - id: "kimi-k3".to_string(), - context: 262_144, - compact: None, - }, - _ => { - let mut known = vec!["grok", "kimi", "lumo", "sol", "sol-api"]; - known.extend(launch.models.keys().map(String::as_str)); - known.sort_unstable(); - known.dedup(); - return Err(format!( - "unknown launch model route '{alias}' (models: {})", - known.join(", ") - )); - } - }; - Ok((model, ModelSource::BuiltIn)) + }; + return Ok((model, ModelSource::BuiltIn)); + } + let mut known: Vec<&str> = BUILT_IN_ROUTES.iter().map(|(name, _, _)| *name).collect(); + known.extend(launch.models.keys().map(String::as_str)); + known.sort_unstable(); + known.dedup(); + Err(format!( + "unknown launch model route '{alias}' (models: {})", + known.join(", ") + )) } fn validate_model(alias: &str, model: &LaunchModel) -> Result<(), String> { @@ -580,19 +623,14 @@ fn list_tools(launch: &Launch) -> i32 { .tools .get(*tool) .and_then(|configured| configured.binary.clone()) - .unwrap_or_else(|| (*tool).to_string()); + .unwrap_or_else(|| default_binary(tool).to_string()); let installed = which_on_path(&binary); let state = if installed { sheet.green("installed") } else { sheet.dim("not found") }; - let mut profiles: Vec<&str> = launch - .profiles - .get(*tool) - .map(|profiles| profiles.keys().map(String::as_str).collect()) - .unwrap_or_default(); - profiles.sort_unstable(); + let profiles = profile_names(tool, launch); let suffix = if profiles.is_empty() { String::new() } else { @@ -615,12 +653,21 @@ fn which_on_path(binary: &str) -> bool { std::env::split_paths(&paths).any(|directory| directory.join(binary).is_file()) } +/// The binary a tool starts when the config names none. The `cursor` command +/// on a Mac opens the editor, so the Cursor tool starts its agent CLI. +fn default_binary(name: &str) -> &str { + match name { + "cursor" => "cursor-agent", + other => other, + } +} + fn resolve_tool(name: &str, launch: &Launch) -> ResolvedTool { let configured = launch.tools.get(name); let default_base_env = (name == "claude").then(|| "ANTHROPIC_BASE_URL".to_string()); let binary = configured .and_then(|tool| tool.binary.as_ref()) - .map_or_else(|| name.to_string(), Clone::clone); + .map_or_else(|| default_binary(name).to_string(), Clone::clone); let base_url_env = configured .and_then(|tool| tool.base_url_env.as_ref()) .cloned() diff --git a/src/cli/launch/tests.rs b/src/cli/launch/tests.rs index 7cc5aa0..decba7e 100644 --- a/src/cli/launch/tests.rs +++ b/src/cli/launch/tests.rs @@ -514,6 +514,115 @@ fn invocation_splits_as_profile_at_tool() { assert_eq!(split_invocation("claude"), ("claude", None)); } +#[test] +fn cursor_fable_profile_is_built_in() { + let launch = Launch::default(); + let profile = resolve_profile("cursor", Some("fable"), &launch) + .expect("built-in profile") + .expect("profile"); + assert_eq!(profile.model.as_deref(), Some("fable-cursor")); + let model = apply_profile_model("cursor", &profile, &launch, &mut LaunchPlan::default()) + .expect("model route") + .expect("resolved model"); + assert_eq!(model.id, "claude-fable-5-1-high"); + assert_eq!(model.source, ModelSource::BuiltIn); + + let error = resolve_profile("claude", Some("fable"), &launch).unwrap_err(); + assert!( + error.contains("no launch profile 'fable' for claude"), + "{error}" + ); + let error = resolve_profile("cursor", Some("missing"), &launch).unwrap_err(); + assert!(error.contains("(profiles: fable)"), "{error}"); + assert_eq!(profile_names("cursor", &launch), ["fable"]); + assert!(profile_names("claude", &launch).is_empty()); +} + +#[test] +fn cursor_launch_passes_the_route_model_before_profile_arguments() { + let dir = tempfile::tempdir().expect("tempdir"); + let resolved = resolve_with_config( + "fable@cursor", + &[OsString::from("--resume")], + dir.path().to_path_buf(), + dir.path().to_path_buf(), + ontology::ResolvedConfig::default(), + ) + .expect("fable launch"); + + assert_eq!( + resolved.argv, + [ + "cursor-agent", + "--model", + "claude-fable-5-1-high", + "--resume" + ] + .map(OsString::from) + ); + assert_eq!( + resolved.model_args, + ["--model", "claude-fable-5-1-high"].map(OsString::from) + ); + + let plain = resolve_with_config( + "cursor", + &[], + dir.path().to_path_buf(), + dir.path().to_path_buf(), + ontology::ResolvedConfig::default(), + ) + .expect("plain launch"); + assert_eq!(plain.argv, [OsString::from("cursor-agent")]); + assert!(plain.model_args.is_empty()); +} + +#[test] +fn configured_cursor_profile_replaces_the_built_in() { + let mut launch = Launch::default(); + launch.profiles.insert( + "cursor".to_string(), + [( + "fable".to_string(), + ontology::LaunchProfile { + model: Some("fable-max".to_string()), + ..ontology::LaunchProfile::default() + }, + )] + .into_iter() + .collect(), + ); + let profile = resolve_profile("cursor", Some("fable"), &launch) + .expect("configured profile") + .expect("profile"); + assert_eq!(profile.model.as_deref(), Some("fable-max")); +} + +#[test] +fn configured_fable_cursor_route_replaces_the_built_in() { + let mut launch = Launch::default(); + launch.models.insert( + "fable-cursor".to_string(), + LaunchModel { + id: "claude-fable-5-1-thinking-max".to_string(), + context: 1_000_000, + compact: None, + }, + ); + let (model, source) = resolve_model("fable-cursor", &launch).expect("configured route"); + assert_eq!(model.id, "claude-fable-5-1-thinking-max"); + assert_eq!(source, ModelSource::Config); +} + +#[test] +fn unknown_route_error_names_every_built_in_route() { + let error = resolve_model("nope", &Launch::default()).expect_err("unknown route"); + for (alias, _, _) in BUILT_IN_ROUTES { + assert!(error.contains(alias), "{alias}: {error}"); + resolve_model(alias, &Launch::default()).expect("built-in route"); + } +} + #[test] fn ollama_profile_name_falls_back_to_a_model() { let launch = Launch::default(); @@ -852,3 +961,34 @@ fn direct_mode_omits_profile_middleware() { assert!(resolved.wrap.is_empty()); assert!(resolved.pre.is_empty()); } + +#[test] +fn cursor_launches_the_agent_cli_by_default() { + let tool = resolve_tool("cursor", &Launch::default()); + + assert_eq!(tool.binary, OsString::from("cursor-agent")); + assert!(KNOWN_TOOLS.contains(&"cursor")); +} + +#[test] +fn cursor_launches_the_configured_binary() { + let mut launch = Launch::default(); + launch.tools.insert( + "cursor".to_string(), + ontology::LaunchTool { + binary: Some("/opt/cursor/bin/agent".to_string()), + base_url_env: None, + }, + ); + + let tool = resolve_tool("cursor", &launch); + + assert_eq!(tool.binary, OsString::from("/opt/cursor/bin/agent")); +} + +#[test] +fn other_tools_keep_their_own_name_as_binary() { + for name in ["claude", "codex", "agy", "opencode", "grok", "ollama"] { + assert_eq!(default_binary(name), name); + } +} diff --git a/src/cli/mod.rs b/src/cli/mod.rs index d7f063f..e59aa98 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -752,7 +752,7 @@ enum Command { /// Launch a coding tool with composable environment middleware #[command( - after_help = "LAUNCH OPTIONS:\n --with Middleware chain to apply in order\n --pxpipe Legacy sugar for --with pxpipe\n --direct Clear the configured/default middleware chain\n --tmux[=NAME] Wrap the launch in a tmux session\n --dry-run Print the resolved launch plan without spawning\n --check Ask each base URL for its model list and report every\n model the plan sends as served or missing; no spawn.\n Exit 0 served, 1 missing, 2 endpoint failure\n -- ARGS... Arguments passed to the launched tool\n\nPROFILES (~/.config/rune/config.yaml):\n launch:\n profiles:\n claude:\n sol: # rune launch sol@claude\n env:\n ANTHROPIC_BASE_URL: http://localhost:8317 # your Anthropic-compatible proxy\n ANTHROPIC_MODEL: gpt-5.6-sol\n # ANTHROPIC_API_KEY: { from_env: CLIPROXY_API_KEY }\n args: []\n with: []\n codex:\n deep:\n args: [\"-m\", \"gpt-5.6-sol\", \"-c\", \"model_reasoning_effort=xhigh\"]\n\n Env values are literals or { from_env: KEY } references; secrets stay\n out of config. Unset references fall back to the env file (config key\n env, default ~/.env). ollama profiles double as models: rune launch\n llama3@ollama" + after_help = "LAUNCH OPTIONS:\n --with Middleware chain to apply in order\n --pxpipe Legacy sugar for --with pxpipe\n --direct Clear the configured/default middleware chain\n --tmux[=NAME] Wrap the launch in a tmux session\n --dry-run Print the resolved launch plan without spawning\n --check Ask each base URL for its model list and report every\n model the plan sends as served or missing; no spawn.\n Exit 0 served, 1 missing, 2 endpoint failure\n -- ARGS... Arguments passed to the launched tool\n\nPROFILES (~/.config/rune/config.yaml):\n launch:\n profiles:\n claude:\n sol: # rune launch sol@claude\n env:\n ANTHROPIC_BASE_URL: http://localhost:8317 # your Anthropic-compatible proxy\n ANTHROPIC_MODEL: gpt-5.6-sol\n # ANTHROPIC_API_KEY: { from_env: CLIPROXY_API_KEY }\n args: []\n with: []\n codex:\n deep:\n args: [\"-m\", \"gpt-5.6-sol\", \"-c\", \"model_reasoning_effort=xhigh\"]\n\n Env values are literals or { from_env: KEY } references; secrets stay\n out of config. Unset references fall back to the env file (config key\n env, default ~/.env). ollama profiles double as models: rune launch\n llama3@ollama. The cursor tool starts cursor-agent unless\n tools.cursor.binary names another binary." )] Launch { /// Coding tool to launch, such as `claude` or `sol@claude` for a @@ -767,7 +767,7 @@ enum Command { /// Run a coding tool noninteractively with supervised output #[command( - after_help = "PROMPT SOURCES:\n rune run sol@claude \"Review this repository\"\n rune run codex --prompt-file brief.md\n command cat brief.md | rune run lumo@opencode\n rune run kimi@claude --check\n\nNo timeout is applied unless --timeout is set. Read-only mode is the default.\n\nPROFILE ARGUMENTS:\n The run sets the print, output, permission, tool, model, and directory\n flags itself. A profile value for one of them is dropped with a warning,\n never passed through. Codex --config and claude --settings are filtered\n per key: model and access keys are dropped, everything else stays." + after_help = "PROMPT SOURCES:\n rune run sol@claude \"Review this repository\"\n rune run codex --prompt-file brief.md\n command cat brief.md | rune run lumo@opencode\n rune run kimi@claude --check\n rune run fable@cursor \"Review this repository\"\n\nNo timeout is applied unless --timeout is set. Read-only mode is the default.\n\nPROFILE ARGUMENTS:\n The run sets the print, output, permission, tool, model, and directory\n flags itself. A profile value for one of them is dropped with a warning,\n never passed through. Codex --config and claude --settings are filtered\n per key: model and access keys are dropped, everything else stays." )] Run { /// Coding tool to run, such as `codex` or `sol@claude`. diff --git a/src/cli/run/mod.rs b/src/cli/run/mod.rs index eb4ca2a..7a31236 100644 --- a/src/cli/run/mod.rs +++ b/src/cli/run/mod.rs @@ -1,7 +1,7 @@ use crate::cli::launch; use crate::cli::process::{ProcessFailure, ProcessTermination}; use crate::cli::surface::{ - Surface, SurfaceFailure, SurfaceInvocation, filter_surface_args, invoke_surface, + Surface, SurfaceFailure, SurfaceInvocation, check_access, filter_surface_args, invoke_surface, prepare_clean_state, }; use serde_json::{Value, json}; @@ -90,7 +90,7 @@ fn execute_inner(options: &RunOptions) -> Result { let resolved = launch::resolve(&options.tool, &resolve_args)?; let surface = Surface::from_tool(&resolved.tool).ok_or_else(|| { format!( - "automated execution is not supported for '{}'; use claude, codex, agy, grok, or opencode", + "automated execution is not supported for '{}'; use claude, codex, agy, grok, opencode, or cursor", resolved.tool ) })?; @@ -105,6 +105,11 @@ fn execute_inner(options: &RunOptions) -> Result { .argv .split_first() .ok_or_else(|| "resolved launch command is empty".to_string())?; + // The run passes the model itself, so the launch's generated model + // arguments go. Anything else reaches the owned-argument filter. + let extra_args = extra_args + .strip_prefix(resolved.model_args.as_slice()) + .unwrap_or(extra_args); let binary = options.binary.as_ref().map_or_else( || resolved_binary.clone(), |path| path.as_os_str().to_os_string(), @@ -119,8 +124,8 @@ fn execute_inner(options: &RunOptions) -> Result { .or_else(|| resolved.model.as_ref().map(|model| model.id.clone())); if dry_run || resolved.dry_run { - let mut dry_run_argv = resolved.argv.clone(); - dry_run_argv[0].clone_from(&binary); + let mut dry_run_argv = vec![binary.clone()]; + dry_run_argv.extend(extra_args.iter().cloned()); println!( "{}\nrepository: {}\nmode: {}\ntimeout: {}\nnative_timeout: {}", resolved.format_dry_run_with_overrides(&dry_run_argv, options.model.as_deref()), @@ -143,7 +148,7 @@ fn execute_inner(options: &RunOptions) -> Result { .transpose() .map_err(|error| format!("cannot create clean harness state: {error}"))?; if let Some(state) = &clean_state { - prepare_clean_state(surface, state.path(), model.as_deref()) + prepare_clean_state(surface, state.path(), model.as_deref(), &resolved.env) .map_err(|error| error.to_string())?; } let invocation = SurfaceInvocation { @@ -171,6 +176,7 @@ fn execute_inner(options: &RunOptions) -> Result { extra_args: filtered.kept, ..invocation }; + check_access(&invocation).map_err(|error| error.to_string())?; let started = std::time::Instant::now(); match invoke_surface(&invocation) { Ok(reply) => { diff --git a/src/cli/surface/cursor.rs b/src/cli/surface/cursor.rs new file mode 100644 index 0000000..27d9002 --- /dev/null +++ b/src/cli/surface/cursor.rs @@ -0,0 +1,360 @@ +//! The Cursor adapter: the official `cursor-agent` CLI in print mode, which +//! prints one JSON result object on stdout. Cursor has no system prompt flag, +//! so the system prompt travels in front of the prompt on stdin. Serde field +//! names mirror the tool's real stdout and must not change. +use super::{ + AccessMode, FilteredArgs, SurfaceFailure, SurfaceInvocation, SurfaceReply, clean_system_prompt, + combined_prompt, nonempty_text, parse_jsonl, process_request, require_success, shown, +}; +use crate::cli::process::run_process_request; +use serde::Deserialize; +use std::ffi::OsString; +use std::path::{Path, PathBuf}; + +/// The variable that carries a Cursor API key. A clean run needs it, +/// because the browser login lives in the macOS Keychain. +pub(super) const API_KEY_ENV: &str = "CURSOR_API_KEY"; + +const CLEAN_KEY_ERROR: &str = "a clean cursor run needs CURSOR_API_KEY: the cursor-agent browser login lives in the macOS Keychain, which a clean home cannot reach, and CURSOR_CONFIG_DIR alone still loads ~/.cursor/rules; set CURSOR_API_KEY or run without --clean"; + +const UNRESTRICTED_ERROR: &str = "a read-only cursor run refuses approvalMode unrestricted: ask mode is enforced by cursor-agent, and unrestricted is the one approval mode that would run a write without asking if that enforcement failed; set approvalMode to allowlist or auto-review in cli-config.json, or run with --mode workspace-write"; + +pub(super) const CONFIG_DIR_ENV: &str = "CURSOR_CONFIG_DIR"; +const XDG_CONFIG_HOME_ENV: &str = "XDG_CONFIG_HOME"; + +const SANDBOX_HINT: &str = "hint: cursor-agent cannot read its Keychain login inside the harness sandbox; run `rune run` as a bare command outside the sandbox"; + +/// The owned flags that take no value. `--resume` and `--worktree` take an +/// optional value, and the filter never takes a following flag as that value. +pub(super) const BOOLEAN_OPTIONS: &[&str] = &[ + "-p", + "--print", + "--stream-partial-output", + "--plan", + "-f", + "--force", + "--yolo", + "--auto-review", + "--trust", + "--continue", + "--approve-mcps", +]; + +/// The flags the run sets itself, and the flags that widen what the agent +/// can reach: a plugin directory can add tools and servers. +pub(super) const OWNED_OPTIONS: &[&str] = &[ + "-p", + "--print", + "--output-format", + "--stream-partial-output", + "--model", + "--mode", + "--plan", + "-f", + "--force", + "--yolo", + "--auto-review", + "--sandbox", + "--workspace", + "--trust", + "-w", + "--worktree", + "--worktree-base", + "--resume", + "--continue", + "--add-dir", + "--plugin-dir", + "--approve-mcps", +]; + +/// The only flags a profile keeps, each with its value: they pick the +/// endpoint. `cursor-agent` accepts options that its help does not list, +/// such as `--data-dir` and `--allowed-tools`, so a table of owned flags +/// cannot be complete. `--api-key` is not kept: the key belongs in the +/// profile `env` as `CURSOR_API_KEY`, never in the argument list. +const KEPT_OPTIONS: &[&str] = &["-e", "--endpoint"]; + +/// Keep the flags in `KEPT_OPTIONS` and drop every other profile argument +/// that the owned table left. A positional goes too: `cursor-agent` reads +/// it as a subcommand, such as `install-shell-integration` or `worker`, or +/// as the prompt. Warnings name a flag, never a dropped value, because the +/// value of an unknown flag can be a secret. +pub(super) fn keep_allowed(filtered: FilteredArgs) -> FilteredArgs { + let FilteredArgs { + kept: args, + mut warnings, + } = filtered; + let mut kept = Vec::new(); + let mut index = 0; + while index < args.len() { + let text = args[index].to_string_lossy(); + let position = index + 1; + index += 1; + if text == "--" { + warnings.push(format!( + "automated cursor execution drops the profile arguments from position {position} on; cursor-agent reads them as a prompt" + )); + break; + } + if !text.starts_with('-') || text == "-" { + warnings.push(format!( + "automated cursor execution drops the positional profile argument at position {position}; cursor-agent reads it as a subcommand or a prompt" + )); + continue; + } + // A short flag is its first two characters, so a warning for + // `-ksecret` shows `-k`; a long flag ends at `=`. + let name: String = if text.starts_with("--") { + text.split_once('=') + .map_or(text.as_ref(), |(name, _)| name) + .to_string() + } else { + text.chars().take(2).collect() + }; + if !KEPT_OPTIONS.contains(&name.as_str()) { + warnings.push(format!( + "automated cursor execution keeps only --endpoint from a profile; the profile flag {} is dropped", + shown(&name) + )); + continue; + } + // commander takes the next token as the value whatever it looks + // like, so a kept flag without a plain value goes too. `-e=url` + // would give commander the value `=url`. + let inline = text.chars().count() > name.chars().count(); + let inline_short = inline && !name.starts_with("--"); + let next_is_value = !inline + && args + .get(index) + .is_some_and(|value| !value.to_string_lossy().starts_with('-')); + if inline_short || (!inline && !next_is_value) { + warnings.push(format!( + "automated cursor execution drops the profile flag {} because it has no plain value", + shown(&name) + )); + continue; + } + kept.push(args[index - 1].clone()); + if next_is_value { + kept.push(args[index].clone()); + index += 1; + } + } + FilteredArgs { kept, warnings } +} + +pub(super) fn args(invocation: &SurfaceInvocation) -> Vec { + let mut args = vec![ + OsString::from("-p"), + OsString::from("--output-format"), + OsString::from("json"), + OsString::from("--workspace"), + invocation.repository.as_os_str().to_os_string(), + OsString::from("--trust"), + ]; + match invocation.mode { + // Ask mode is Cursor's read-only mode. `--sandbox enabled` cannot + // back it: without `--force` the sandbox asks for an approval that + // print mode cannot give. + AccessMode::ReadOnly => args.extend([OsString::from("--mode"), OsString::from("ask")]), + AccessMode::WorkspaceWrite => args.extend([ + OsString::from("--force"), + OsString::from("--sandbox"), + OsString::from("enabled"), + ]), + } + if let Some(model) = &invocation.model { + args.push(OsString::from("--model")); + args.push(OsString::from(model)); + } + args.extend(invocation.extra_args.clone()); + args +} + +pub(super) fn prompt_input(invocation: &SurfaceInvocation) -> String { + let prompt = if invocation.clean_state_root.is_some() { + format!( + "{}\n\n{}", + clean_system_prompt(&invocation.system_prompt), + invocation.prompt + ) + } else { + combined_prompt(invocation) + }; + format!("{prompt}\n") +} + +pub(super) fn invoke(invocation: &SurfaceInvocation) -> Result { + let output = run_process_request(&process_request( + invocation, + args(invocation), + Some(prompt_input(invocation).into_bytes()), + ))?; + let sandboxed = std::env::var_os("SANDBOX_RUNTIME").is_some(); + let output = + require_success(output).map_err(|failure| with_sandbox_hint(failure, sandboxed))?; + parse(&output.stdout, output.stderr) +} + +#[derive(Deserialize)] +struct CursorResponse { + #[serde(default, rename = "type")] + kind: Option, + #[serde(default)] + subtype: Option, + #[serde(default)] + is_error: bool, + #[serde(default)] + result: String, + #[serde(default)] + usage: Option, +} + +#[derive(Deserialize)] +struct CursorUsage { + #[serde(default, rename = "outputTokens")] + output_tokens: Option, +} + +pub(super) fn parse(stdout: &str, stderr: String) -> Result { + let response = parse_jsonl::(stdout) + .into_iter() + .rev() + .find(|response| response.kind.as_deref() == Some("result")) + .ok_or_else(|| { + SurfaceFailure::Reported("cursor-agent printed no JSON result object".to_string()) + })?; + if response.is_error { + let message = Some(response.result.trim()) + .filter(|result| !result.is_empty()) + .or(response.subtype.as_deref()) + .unwrap_or("no message"); + return Err(SurfaceFailure::Reported(format!( + "cursor-agent reported an error: {message}" + ))); + } + Ok(SurfaceReply { + text: nonempty_text(&response.result, "cursor-agent print")?, + stderr, + completion_tokens: response + .usage + .and_then(|usage| usage.output_tokens) + .filter(|tokens| *tokens > 0.0), + }) +} + +/// A read-only run refuses Cursor's documented `unrestricted` approval +/// mode. Ask mode held in every live check, so this is defense in depth: +/// the model keeps its Shell and Write tools under ask mode, and +/// `unrestricted` is the one approval mode that would run such a call +/// without asking. +pub(super) fn check_access(invocation: &SurfaceInvocation) -> Result<(), SurfaceFailure> { + if invocation.mode != AccessMode::ReadOnly { + return Ok(()); + } + config_dir(invocation).map_or(Ok(()), |dir| check_approval_mode(&dir)) +} + +/// The Cursor config directory the child reads, in the order `cursor-agent` +/// resolves it: the clean one, `CURSOR_CONFIG_DIR`, `$XDG_CONFIG_HOME/cursor` +/// when that variable is set and not blank, then `$HOME/.cursor`. Each +/// variable comes from the launch environment first, then rune's own. +fn config_dir(invocation: &SurfaceInvocation) -> Option { + if let Some(root) = &invocation.clean_state_root { + return Some(root.join(".cursor")); + } + let env = |name: &str| { + invocation + .env + .iter() + .rev() + .find(|(key, _)| *key == *name) + .map(|(_, value)| value.clone()) + .or_else(|| std::env::var_os(name)) + }; + env(CONFIG_DIR_ENV) + .map(PathBuf::from) + .or_else(|| { + env(XDG_CONFIG_HOME_ENV) + .filter(|value| !value.to_string_lossy().trim().is_empty()) + .map(|value| PathBuf::from(value).join("cursor")) + }) + .or_else(|| { + env("HOME") + .filter(|value| !value.is_empty()) + .map(PathBuf::from) + .or_else(dirs::home_dir) + .map(|home| home.join(".cursor")) + }) +} + +/// A missing or unreadable `cli-config.json` passes: Cursor then uses its +/// default mode, and a broken file fails in Cursor itself. +pub(super) fn check_approval_mode(config_dir: &Path) -> Result<(), SurfaceFailure> { + let Ok(text) = std::fs::read_to_string(config_dir.join("cli-config.json")) else { + return Ok(()); + }; + let unrestricted = serde_json::from_str::(&text) + .is_ok_and(|config| config["approvalMode"] == "unrestricted"); + if unrestricted { + return Err(SurfaceFailure::Arguments(UNRESTRICTED_ERROR.to_string())); + } + Ok(()) +} + +/// Whether the child gets a nonempty API key. The launch environment, which +/// holds profile `env` values, is applied last, so it wins over rune's own. +pub(super) fn has_api_key(env: &[(OsString, OsString)]) -> bool { + env.iter() + .rev() + .find(|(key, _)| *key == *API_KEY_ENV) + .map_or_else( + || std::env::var_os(API_KEY_ENV).is_some_and(|key| !key.is_empty()), + |(_, value)| !value.is_empty(), + ) +} + +/// The clean state for a Cursor run: refuse without an API key, otherwise +/// create the config directory that `CURSOR_CONFIG_DIR` names. +pub(super) fn prepare_clean_state(root: &Path, has_key: bool) -> Result<(), SurfaceFailure> { + if !has_key { + return Err(SurfaceFailure::Arguments(CLEAN_KEY_ERROR.to_string())); + } + let config = root.join(".cursor"); + std::fs::create_dir_all(&config).map_err(|error| { + SurfaceFailure::Io(format!( + "cannot create Cursor clean state {}: {error}", + config.display() + )) + }) +} + +/// One line for a failure whose stderr shows a sandboxed process that cannot +/// read the Keychain. "Authentication required" alone is also a real missing +/// login, so it counts only inside the sandbox. +pub(super) fn sandbox_hint(stderr: &str, sandboxed: bool) -> Option<&'static str> { + let keychain = stderr.contains("failed to copy trust settings"); + let login = sandboxed && stderr.contains("Authentication required"); + (keychain || login).then_some(SANDBOX_HINT) +} + +/// The hint goes first, because the failure message cuts stderr at 500 +/// characters and the Keychain errors fill that before the login error. +pub(super) fn with_sandbox_hint(failure: SurfaceFailure, sandboxed: bool) -> SurfaceFailure { + match failure { + SurfaceFailure::Exit { + termination, + stderr, + } => { + let stderr = match sandbox_hint(&stderr, sandboxed) { + Some(hint) => format!("{hint}\n{stderr}"), + None => stderr, + }; + SurfaceFailure::Exit { + termination, + stderr, + } + } + other => other, + } +} diff --git a/src/cli/surface/mod.rs b/src/cli/surface/mod.rs index 1388533..1db7ad2 100644 --- a/src/cli/surface/mod.rs +++ b/src/cli/surface/mod.rs @@ -1,5 +1,5 @@ //! Noninteractive adapters for the coding surfaces `rune run` drives: -//! Claude, Codex, Grok, `agy`, and `OpenCode`. Each adapter owns its argument +//! Claude, Codex, Grok, `agy`, `OpenCode`, and Cursor. Each adapter owns its argument //! contract, prompt transport, and output parsing; process lifecycle comes //! from [`crate::cli::process`]. Serde field names on the event types mirror //! each tool's real stdout and must not change. @@ -12,6 +12,8 @@ use std::fmt; use std::path::{Path, PathBuf}; use std::time::Duration; +mod cursor; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Surface { Claude, @@ -19,6 +21,7 @@ pub(crate) enum Surface { Agy, Grok, Opencode, + Cursor, } impl Surface { @@ -29,6 +32,7 @@ impl Surface { "agy" => Some(Self::Agy), "grok" => Some(Self::Grok), "opencode" => Some(Self::Opencode), + "cursor" => Some(Self::Cursor), _ => None, } } @@ -126,6 +130,7 @@ fn surface_name(surface: Surface) -> &'static str { Surface::Agy => "agy", Surface::Grok => "grok", Surface::Opencode => "opencode", + Surface::Cursor => "cursor", } } @@ -149,6 +154,7 @@ fn boolean_options(surface: Surface) -> &'static [&'static str] { Surface::Grok => &["--always-approve", "--no-plan"], Surface::Agy => &["--dangerously-skip-permissions"], Surface::Opencode => &["--print", "--disable-slash-commands"], + Surface::Cursor => cursor::BOOLEAN_OPTIONS, } } @@ -543,10 +549,12 @@ fn copy_opencode_route_config( }) } +/// `env` is the launch environment the child gets on top of rune's own. pub(crate) fn prepare_clean_state( surface: Surface, root: &Path, model: Option<&str>, + env: &[(OsString, OsString)], ) -> Result<(), SurfaceFailure> { let home = dirs::home_dir() .ok_or_else(|| SurfaceFailure::Io("cannot resolve home directory".to_string()))?; @@ -628,6 +636,7 @@ pub(crate) fn prepare_clean_state( "Claude", ) } + Surface::Cursor => cursor::prepare_clean_state(root, cursor::has_api_key(env)), } } @@ -703,6 +712,16 @@ fn process_request( request.env.push((OsString::from(key), OsString::from("1"))); } } + Surface::Cursor => { + // The API key stays in the inherited environment, never in + // the arguments. `CURSOR_CONFIG_DIR` alone would still load + // `~/.cursor/rules`, so HOME moves too. + let config = PathBuf::from(&root).join(".cursor").into_os_string(); + request + .env + .push((OsString::from(cursor::CONFIG_DIR_ENV), config)); + request.env.push((OsString::from("HOME"), root)); + } } } request.stdin = stdin; @@ -1255,6 +1274,7 @@ fn owned_options(surface: Surface) -> &'static [&'static str] { "-u", "--username", ], + Surface::Cursor => cursor::OWNED_OPTIONS, } } @@ -1270,6 +1290,16 @@ pub(crate) fn invoke_surface( Surface::Agy => invoke_agy(invocation), Surface::Grok => invoke_grok(invocation), Surface::Opencode => invoke_opencode(invocation), + Surface::Cursor => cursor::invoke(invocation), + } +} + +/// Refuse a run whose access mode the tool's own config would defeat, +/// before the tool starts. +pub(crate) fn check_access(invocation: &SurfaceInvocation) -> Result<(), SurfaceFailure> { + match invocation.surface { + Surface::Cursor => cursor::check_access(invocation), + _ => Ok(()), } } @@ -1281,6 +1311,12 @@ pub(crate) fn filter_surface_args(invocation: &SurfaceInvocation) -> FilteredArg match invocation.surface { Surface::Claude => filter_profile_args(invocation, owned, booleans, &claude_settings_key), Surface::Codex => filter_profile_args(invocation, owned, booleans, &codex_config_key), + Surface::Cursor => cursor::keep_allowed(filter_profile_args( + invocation, + owned, + booleans, + &drop_whole, + )), _ => filter_profile_args(invocation, owned, booleans, &drop_whole), } } diff --git a/src/cli/surface/tests.rs b/src/cli/surface/tests.rs index 1be522c..26b5174 100644 --- a/src/cli/surface/tests.rs +++ b/src/cli/surface/tests.rs @@ -272,6 +272,22 @@ fn automated_providers_drop_read_only_bypass_arguments() { (Surface::Agy, "--dangerously-skip-permissions"), (Surface::Opencode, "--attach=http://127.0.0.1:4096"), (Surface::Opencode, "-mproton-lumo/lumo-max"), + (Surface::Cursor, "--yolo"), + (Surface::Cursor, "--force"), + (Surface::Cursor, "-f"), + (Surface::Cursor, "--sandbox=disabled"), + (Surface::Cursor, "--mode=agent"), + (Surface::Cursor, "--workspace=/"), + (Surface::Cursor, "--approve-mcps"), + (Surface::Cursor, "--plugin-dir=/tmp/plugin"), + (Surface::Cursor, "-wscratch"), + (Surface::Cursor, "--allowed-tools=shell"), + (Surface::Cursor, "--data-dir=/tmp/other"), + (Surface::Cursor, "-k"), + ( + Surface::Cursor, + "--header=x-cursor-agent-allowed-tools: shell", + ), ] { let filtered = filter_surface_args(&invocation(surface, &[argument])); assert!( @@ -728,3 +744,332 @@ fn clean_codex_config_with_two_custom_providers_names_none() { assert!(clean["model_providers"].get("a").is_some()); assert!(clean["model_providers"].get("b").is_some()); } + +#[test] +fn cursor_read_only_run_uses_ask_mode_and_the_model() { + let invocation = SurfaceInvocation { + model: Some("claude-fable-5-1-high".to_string()), + repository: PathBuf::from("/repo"), + ..invocation(Surface::Cursor, &["-H", "X-Trace: rune"]) + }; + + assert_eq!( + strings(&cursor::args(&invocation)), + [ + "-p", + "--output-format", + "json", + "--workspace", + "/repo", + "--trust", + "--mode", + "ask", + "--model", + "claude-fable-5-1-high", + "-H", + "X-Trace: rune", + ] + ); +} + +#[test] +fn cursor_workspace_write_run_forces_edits_inside_its_sandbox() { + let invocation = SurfaceInvocation { + mode: AccessMode::WorkspaceWrite, + ..invocation(Surface::Cursor, &[]) + }; + let args = strings(&cursor::args(&invocation)); + + assert!(args.ends_with(&[ + "--force".to_string(), + "--sandbox".to_string(), + "enabled".to_string(), + ])); + assert!(!args.contains(&"--mode".to_string())); + assert!(!args.contains(&"--model".to_string())); +} + +#[test] +fn cursor_prompt_travels_on_stdin_behind_the_system_prompt() { + let plain = SurfaceInvocation { + system_prompt: "Be brief.".to_string(), + ..invocation(Surface::Cursor, &[]) + }; + let clean = SurfaceInvocation { + clean_state_root: Some(PathBuf::from("/clean")), + ..plain.clone() + }; + + assert_eq!(cursor::prompt_input(&plain), "Be brief.\n\nInspect only\n"); + assert_eq!( + cursor::prompt_input(&clean), + format!("{CLEAN_SYSTEM_PROMPT}\n\nBe brief.\n\nInspect only\n") + ); + assert!(!strings(&cursor::args(&plain)).contains(&"Inspect only".to_string())); +} + +#[test] +fn cursor_result_object_gives_text_and_output_tokens() { + let reply = cursor::parse( + r#"{"type":"result","subtype":"success","is_error":false,"result":"pong","usage":{"inputTokens":12874,"outputTokens":3}}"#, + String::new(), + ) + .expect("reply"); + + assert_eq!(reply.text, "pong"); + assert_eq!(reply.completion_tokens, Some(3.0)); +} + +#[test] +fn cursor_result_without_usage_still_succeeds() { + let reply = + cursor::parse(r#"{"type":"result","result":"pong"}"#, String::new()).expect("reply"); + + assert_eq!(reply.text, "pong"); + assert_eq!(reply.completion_tokens, None); +} + +#[test] +fn cursor_reported_error_fails_the_run() { + let failure = cursor::parse( + r#"{"type":"result","is_error":true,"result":"rate limited"}"#, + String::new(), + ) + .expect_err("error result"); + + assert_eq!( + failure, + SurfaceFailure::Reported("cursor-agent reported an error: rate limited".to_string()) + ); +} + +#[test] +fn cursor_output_without_a_result_object_fails_the_run() { + for stdout in [ + "", + "not json", + r#"{"type":"assistant","result":"pong"}"#, + r#"{"type":"result","result":42}"#, + ] { + assert_eq!( + cursor::parse(stdout, String::new()), + Err(SurfaceFailure::Reported( + "cursor-agent printed no JSON result object".to_string() + )), + "{stdout}" + ); + } + assert!(matches!( + cursor::parse(r#"{"type":"result","result":" "}"#, String::new()), + Err(SurfaceFailure::Reported(message)) if message.contains("no final response") + )); +} + +#[test] +fn clean_cursor_state_needs_an_api_key() { + let temporary = tempfile::tempdir().expect("temporary directory"); + + let failure = + cursor::prepare_clean_state(temporary.path(), false).expect_err("missing key refusal"); + assert!(matches!(&failure, SurfaceFailure::Arguments(_))); + let message = failure.to_string(); + assert!(message.contains("CURSOR_API_KEY"), "{message}"); + assert!(message.contains("Keychain"), "{message}"); + assert!(message.contains("~/.cursor/rules"), "{message}"); + assert!(!temporary.path().join(".cursor").exists()); + + cursor::prepare_clean_state(temporary.path(), true).expect("clean state"); + assert!(temporary.path().join(".cursor").is_dir()); +} + +#[test] +fn cursor_api_key_from_the_launch_environment_counts() { + let key = |value: &str| (OsString::from("CURSOR_API_KEY"), OsString::from(value)); + + assert!(cursor::has_api_key(&[key("from-profile")])); + assert!(!cursor::has_api_key(&[key("")])); + assert!(cursor::has_api_key(&[key(""), key("last-wins")])); +} + +#[test] +fn cursor_error_without_a_message_names_its_subtype() { + let failure = cursor::parse( + r#"{"type":"result","subtype":"error","is_error":true,"result":""}"#, + String::new(), + ) + .expect_err("error result"); + + assert_eq!( + failure, + SurfaceFailure::Reported("cursor-agent reported an error: error".to_string()) + ); +} + +#[test] +fn cursor_profile_keeps_only_endpoint_flags() { + for (args, kept, warnings) in [ + (&["install-shell-integration"][..], &[][..], 1), + (&["worker", "--pool", "p"][..], &[][..], 3), + (&["--skip-worktree-setup", "logout"][..], &[][..], 2), + (&["--", "-f", "prompt"][..], &[][..], 2), + ( + &["--data-dir=/tmp/x", "--allowed-tools", "shell", "-k"][..], + &[][..], + 4, + ), + ( + &["--api-key", "k", "-H", "X-A: 1", "--endpoint", "https://e"][..], + &["--endpoint", "https://e"][..], + 4, + ), + ( + &["--api-key=k", "-e", "https://e", "--endpoint=https://g"][..], + &["-e", "https://e", "--endpoint=https://g"][..], + 1, + ), + (&["-ehttps://f", "-e=https://f"][..], &[][..], 2), + ( + &[ + "--endpoint", + "-e", + "https://e", + "--endpoint", + "--", + "x", + "--endpoint", + ][..], + &["-e", "https://e"][..], + 3, + ), + ] { + let filtered = filter_surface_args(&invocation(Surface::Cursor, args)); + assert_eq!( + filtered.kept, + kept.iter().map(OsString::from).collect::>(), + "{args:?}" + ); + assert_eq!( + filtered.warnings.len(), + warnings, + "{args:?}: {:?}", + filtered.warnings + ); + } +} + +#[test] +fn cursor_warnings_never_show_a_dropped_value() { + let filtered = filter_surface_args(&invocation( + Surface::Cursor, + &[ + "--token=secret-value", + "--token", + "secret-value", + "-ksecret-value", + "-\u{e9}secret-value", + ], + )); + + assert!(filtered.kept.is_empty(), "{:?}", filtered.kept); + assert_eq!(filtered.warnings.len(), 5, "{:?}", filtered.warnings); + assert!( + filtered + .warnings + .iter() + .all(|warning| !warning.contains("secret-value")), + "{:?}", + filtered.warnings + ); +} + +#[test] +fn read_only_cursor_run_refuses_unrestricted_approval() { + let config = tempfile::tempdir().expect("config directory"); + cursor::check_approval_mode(config.path()).expect("missing config passes"); + + for (mode, refused) in [ + ("allowlist", false), + ("auto-review", false), + ("unrestricted", true), + ] { + std::fs::write( + config.path().join("cli-config.json"), + format!(r#"{{"version":1,"approvalMode":"{mode}"}}"#), + ) + .expect("config"); + let result = cursor::check_approval_mode(config.path()); + assert_eq!(result.is_err(), refused, "{mode}"); + if let Err(failure) = result { + assert!(failure.to_string().contains("ask mode"), "{failure}"); + } + } +} + +#[test] +fn cursor_profile_with_a_two_token_sandbox_value_keeps_nothing() { + let filtered = filter_surface_args(&invocation( + Surface::Cursor, + &["--yolo", "--sandbox", "disabled"], + )); + + assert!(filtered.kept.is_empty(), "{:?}", filtered.kept); + assert_eq!(filtered.warnings.len(), 2, "{:?}", filtered.warnings); +} + +#[test] +fn clean_cursor_run_moves_home_and_config_and_keeps_the_key_out_of_argv() { + let invocation = SurfaceInvocation { + clean_state_root: Some(PathBuf::from("/clean")), + ..invocation(Surface::Cursor, &[]) + }; + let request = process_request(&invocation, cursor::args(&invocation), None); + + assert!( + request + .env + .contains(&(OsString::from("HOME"), OsString::from("/clean"))) + ); + assert!(request.env.contains(&( + OsString::from("CURSOR_CONFIG_DIR"), + OsString::from("/clean/.cursor") + ))); + assert!( + !request + .args + .iter() + .any(|arg| arg.to_string_lossy().contains("CURSOR_API_KEY")) + ); +} + +#[test] +fn cursor_sandbox_hint_matches_only_the_sandbox_signature() { + let keychain = "ERROR: failed to copy trust settings of system certificate-25291\n"; + let login = "Error: Authentication required. Please run 'agent login' first"; + + assert!(cursor::sandbox_hint(keychain, false).is_some()); + assert!(cursor::sandbox_hint(login, true).is_some()); + assert!(cursor::sandbox_hint(login, false).is_none()); + assert!(cursor::sandbox_hint("rate limited", true).is_none()); +} + +#[test] +fn cursor_sandbox_hint_leads_the_kept_stderr() { + let stderr = format!( + "{}Error: Authentication required.\n", + "ERROR: failed to copy trust settings of system certificate-25291\n".repeat(8) + ); + let failure = cursor::with_sandbox_hint( + SurfaceFailure::Exit { + termination: ProcessTermination::Exited(1), + stderr: stderr.clone(), + }, + true, + ); + + let SurfaceFailure::Exit { stderr: hinted, .. } = &failure else { + panic!("exit failure expected: {failure:?}"); + }; + assert!(hinted.starts_with("hint: "), "{hinted}"); + assert!(hinted.ends_with(&stderr)); + assert!(failure.to_string().contains("outside the sandbox")); +} diff --git a/tests/run.rs b/tests/run.rs index 70cb10d..e93d135 100644 --- a/tests/run.rs +++ b/tests/run.rs @@ -181,3 +181,404 @@ printf '%s\n' '{"response":"provider reply","usage":{"output_tokens":17}}' assert!(input_tokens.is_null()); assert!(total_tokens.is_null()); } + +#[cfg(unix)] +fn cursor_run( + home: &std::path::Path, + repository: &std::path::Path, + provider: &std::path::Path, +) -> Command { + let mut command = rune(home); + command + .env_remove("CURSOR_CONFIG_DIR") + .env_remove("XDG_CONFIG_HOME") + .args([ + "--json", + "run", + "cursor", + "Reply with pong", + "--repo", + repository.to_str().expect("repository path"), + "--binary", + provider.to_str().expect("provider path"), + ]); + command +} + +#[cfg(unix)] +#[test] +fn cursor_run_sends_the_prompt_on_stdin_and_reads_the_result_object() { + let home = tempfile::tempdir().expect("home"); + let repository = tempfile::tempdir().expect("repository"); + let capture = home.path().join("capture"); + let provider = executable_provider( + home.path(), + "fake-cursor-agent", + &format!( + r#"#!/bin/sh +printf '%s\n' "$@" > '{capture}.argv' +cat > '{capture}.stdin' +printf '%s\n' '{{"type":"result","subtype":"success","is_error":false,"result":"pong","usage":{{"inputTokens":9,"outputTokens":2}}}}' +"#, + capture = capture.display() + ), + ); + + let output = cursor_run(home.path(), repository.path(), &provider) + .output() + .expect("run output"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).expect("run JSON"); + assert_eq!(result["kind"], "success"); + assert_eq!(result["tool"], "cursor"); + assert_eq!(result["text"], "pong"); + assert_eq!(result["usage"]["output_tokens"].as_f64(), Some(2.0)); + + let argv = std::fs::read_to_string(capture.with_extension("argv")).expect("argv"); + let repository = std::fs::canonicalize(repository.path()).expect("canonical repository"); + let repository = repository.to_str().expect("repository path"); + assert_eq!( + argv.lines().collect::>(), + [ + "-p", + "--output-format", + "json", + "--workspace", + repository, + "--trust", + "--mode", + "ask", + ] + ); + let stdin = std::fs::read_to_string(capture.with_extension("stdin")).expect("stdin"); + assert!(stdin.ends_with("Reply with pong\n"), "{stdin}"); +} + +#[cfg(unix)] +#[test] +fn fable_at_cursor_selects_fable_without_a_configured_profile() { + let home = tempfile::tempdir().expect("home"); + let repository = tempfile::tempdir().expect("repository"); + let capture = home.path().join("capture"); + let provider = executable_provider( + home.path(), + "fake-cursor-agent", + &format!( + r#"#!/bin/sh +printf '%s\n' "$@" > '{capture}.argv' +cat > /dev/null +printf '%s\n' '{{"type":"result","subtype":"success","is_error":false,"result":"pong"}}' +"#, + capture = capture.display() + ), + ); + + let output = rune(home.path()) + .args([ + "--json", + "run", + "fable@cursor", + "Reply with pong", + "--repo", + repository.path().to_str().expect("repository path"), + "--binary", + provider.to_str().expect("provider path"), + ]) + .env_remove("CURSOR_CONFIG_DIR") + .env_remove("XDG_CONFIG_HOME") + .output() + .expect("run output"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).expect("run JSON"); + assert_eq!(result["resolved_model"], "claude-fable-5-1-high"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(!stderr.contains("warning"), "{stderr}"); + let argv = std::fs::read_to_string(capture.with_extension("argv")).expect("argv"); + let argv: Vec<&str> = argv.lines().collect(); + assert!( + argv.windows(2) + .any(|pair| pair == ["--model", "claude-fable-5-1-high"]), + "{argv:?}" + ); + assert_eq!( + argv.iter() + .filter(|argument| **argument == "--model") + .count(), + 1, + "{argv:?}" + ); +} + +#[cfg(unix)] +#[test] +fn cursor_reported_error_fails_the_run() { + let home = tempfile::tempdir().expect("home"); + let repository = tempfile::tempdir().expect("repository"); + let provider = executable_provider( + home.path(), + "fake-cursor-agent", + r#"#!/bin/sh +cat > /dev/null +printf '%s\n' '{"type":"result","subtype":"error","is_error":true,"result":"model unavailable"}' +"#, + ); + + let output = cursor_run(home.path(), repository.path(), &provider) + .output() + .expect("run output"); + assert_eq!(output.status.code(), Some(1)); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).expect("run JSON"); + assert_eq!(result["kind"], "surface_error"); + assert_eq!( + result["details"]["message"], + "cursor-agent reported an error: model unavailable" + ); +} + +#[cfg(unix)] +#[test] +fn clean_cursor_run_without_an_api_key_never_starts_the_tool() { + let home = tempfile::tempdir().expect("home"); + let repository = tempfile::tempdir().expect("repository"); + let marker = home.path().join("started"); + let provider = executable_provider( + home.path(), + "fake-cursor-agent", + &format!("#!/bin/sh\ntouch '{}'\n", marker.display()), + ); + + let output = cursor_run(home.path(), repository.path(), &provider) + .arg("--clean") + .env_remove("CURSOR_API_KEY") + .output() + .expect("run output"); + assert_eq!(output.status.code(), Some(2)); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).expect("run JSON"); + assert_eq!(result["kind"], "configuration_error"); + let message = result["details"]["message"].as_str().expect("message"); + assert!(message.contains("CURSOR_API_KEY"), "{message}"); + assert!(!marker.exists()); +} + +#[cfg(unix)] +#[test] +fn clean_cursor_run_with_an_api_key_moves_home_and_config() { + let home = tempfile::tempdir().expect("home"); + let repository = tempfile::tempdir().expect("repository"); + let capture = home.path().join("env"); + let provider = executable_provider( + home.path(), + "fake-cursor-agent", + &format!( + r#"#!/bin/sh +cat > /dev/null +printf 'HOME=%s\nCURSOR_CONFIG_DIR=%s\nKEY=%s\n' "$HOME" "$CURSOR_CONFIG_DIR" "$CURSOR_API_KEY" > '{}' +test -d "$CURSOR_CONFIG_DIR" || exit 3 +printf '%s\n' '{{"type":"result","is_error":false,"result":"pong"}}' +"#, + capture.display() + ), + ); + + let output = cursor_run(home.path(), repository.path(), &provider) + .arg("--clean") + .env("CURSOR_API_KEY", "test-cursor-key") + .output() + .expect("run output"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + let seen = std::fs::read_to_string(&capture).expect("environment capture"); + let clean_home = seen + .lines() + .find_map(|line| line.strip_prefix("HOME=")) + .expect("HOME line"); + assert_ne!(clean_home, home.path().to_str().expect("home path")); + assert!( + seen.contains(&format!("CURSOR_CONFIG_DIR={clean_home}/.cursor")), + "{seen}" + ); + assert!(seen.contains("KEY=test-cursor-key"), "{seen}"); +} + +#[cfg(unix)] +#[test] +fn read_only_cursor_run_refuses_unrestricted_approval_before_the_tool_starts() { + let home = tempfile::tempdir().expect("home"); + let repository = tempfile::tempdir().expect("repository"); + let marker = home.path().join("started"); + let provider = executable_provider( + home.path(), + "fake-cursor-agent", + &format!( + "#!/bin/sh\ncat > /dev/null\ntouch '{}'\nprintf '%s\\n' '{{\"type\":\"result\",\"result\":\"pong\"}}'\n", + marker.display() + ), + ); + let cursor = home.path().join(".cursor"); + std::fs::create_dir_all(&cursor).expect("cursor config directory"); + std::fs::write( + cursor.join("cli-config.json"), + r#"{"version":1,"approvalMode":"unrestricted"}"#, + ) + .expect("cursor config"); + + let output = cursor_run(home.path(), repository.path(), &provider) + .output() + .expect("run output"); + assert_eq!(output.status.code(), Some(2)); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).expect("run JSON"); + assert_eq!(result["kind"], "configuration_error"); + let message = result["details"]["message"].as_str().expect("message"); + assert!(message.contains("approvalMode unrestricted"), "{message}"); + assert!(!marker.exists()); + + let xdg = home.path().join("xdg"); + std::fs::create_dir_all(xdg.join("cursor")).expect("xdg cursor directory"); + std::fs::write( + xdg.join("cursor/cli-config.json"), + r#"{"version":1,"approvalMode":"unrestricted"}"#, + ) + .expect("xdg cursor config"); + std::fs::remove_file(cursor.join("cli-config.json")).expect("remove home config"); + let output = cursor_run(home.path(), repository.path(), &provider) + .env("XDG_CONFIG_HOME", &xdg) + .output() + .expect("run output"); + assert_eq!( + output.status.code(), + Some(2), + "XDG_CONFIG_HOME config is read" + ); + assert!(!marker.exists()); + + let output = cursor_run(home.path(), repository.path(), &provider) + .env("XDG_CONFIG_HOME", &xdg) + .args(["--mode", "workspace-write"]) + .output() + .expect("run output"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + assert!(marker.exists()); +} + +#[cfg(unix)] +#[test] +fn clean_cursor_run_accepts_a_key_from_the_profile_environment() { + let home = tempfile::tempdir().expect("home"); + let repository = tempfile::tempdir().expect("repository"); + let capture = home.path().join("env"); + let provider = executable_provider( + home.path(), + "fake-cursor-agent", + &format!( + r#"#!/bin/sh +cat > /dev/null +printf 'KEY=%s\n' "$CURSOR_API_KEY" > '{}' +printf '%s\n' '{{"type":"result","is_error":false,"result":"pong"}}' +"#, + capture.display() + ), + ); + let config = home.path().join(".config/rune"); + std::fs::create_dir_all(&config).expect("config directory"); + std::fs::write( + config.join("config.yaml"), + "launch:\n profiles:\n cursor:\n keyed:\n env:\n CURSOR_API_KEY:\n from_env: PROFILE_CURSOR_KEY\n", + ) + .expect("config"); + + let output = rune(home.path()) + .args([ + "--json", + "run", + "keyed@cursor", + "Reply with pong", + "--clean", + "--repo", + repository.path().to_str().expect("repository path"), + "--binary", + provider.to_str().expect("provider path"), + ]) + .env_remove("CURSOR_API_KEY") + .env("PROFILE_CURSOR_KEY", "profile-cursor-key") + .output() + .expect("run output"); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stdout) + ); + let seen = std::fs::read_to_string(&capture).expect("environment capture"); + assert!(seen.contains("KEY=profile-cursor-key"), "{seen}"); +} + +#[cfg(unix)] +#[test] +fn cursor_keychain_failure_carries_the_sandbox_hint() { + let home = tempfile::tempdir().expect("home"); + let repository = tempfile::tempdir().expect("repository"); + let provider = executable_provider( + home.path(), + "fake-cursor-agent", + r"#!/bin/sh +cat > /dev/null +i=0 +while [ $i -lt 8 ]; do + echo 'ERROR: failed to copy trust settings of system certificate-25291' >&2 + i=$((i + 1)) +done +echo 'Error: Authentication required.' >&2 +exit 1 +", + ); + + let output = cursor_run(home.path(), repository.path(), &provider) + .env_remove("SANDBOX_RUNTIME") + .output() + .expect("run output"); + assert_eq!(output.status.code(), Some(1)); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).expect("run JSON"); + assert_eq!(result["kind"], "exit"); + let stderr = result["details"]["stderr"].as_str().expect("stderr"); + assert!(stderr.starts_with("hint: "), "{stderr}"); + assert!(stderr.contains("Authentication required"), "{stderr}"); +} + +#[cfg(unix)] +#[test] +fn cursor_login_failure_gets_the_hint_only_inside_a_sandbox() { + let home = tempfile::tempdir().expect("home"); + let repository = tempfile::tempdir().expect("repository"); + let provider = executable_provider( + home.path(), + "fake-cursor-agent", + "#!/bin/sh\ncat > /dev/null\necho 'Error: Authentication required.' >&2\nexit 1\n", + ); + + for (sandboxed, hinted) in [(true, true), (false, false)] { + let mut command = cursor_run(home.path(), repository.path(), &provider); + if sandboxed { + command.env("SANDBOX_RUNTIME", "1"); + } else { + command.env_remove("SANDBOX_RUNTIME"); + } + let output = command.output().expect("run output"); + let result: serde_json::Value = serde_json::from_slice(&output.stdout).expect("run JSON"); + let stderr = result["details"]["stderr"].as_str().expect("stderr"); + assert_eq!(stderr.starts_with("hint: "), hinted, "{stderr}"); + } +}