Expand description
Cursor agent backend: drives the Cursor CLI headless
(agent --print --output-format stream-json).
Ground truth is the decided route in docs/scoping/cursor-cli-backend.md
(decision revised 2026-07-09: direct-parser) and the committed wire
fixture docs/scoping/cursor-probe-evidence/fixture-stream-json.jsonl,
captured from a real write-capable run. The parser is built against THAT
shape first; the event mapping table in the scoping doc’s implementation
brief is the authority for every arm below.
This module is single-shot only: --resume exists on the CLI but was
never exercised by the probe, and there is no streaming-input mode, so
CursorSession::send_user_message and a resumed SessionSpec are
both rejected at the seam rather than translated into flags.
Several SessionSpec fields are claude-isms with no cursor equivalent and
are deliberately ignored when building argv: json_schema,
max_budget_usd, resume, permission_mode, allowed_tools /
disallowed_tools, tools, settings_json, and effort — the CLI has
no --effort flag, and the --model bracket-override syntax
('model[effort=high]') is documented only for parameterized models and
was never probed, so effort is NOT munged into the model id.
Permission posture (probe item 6, observed): read-only sessions map to
--mode ask (turn-level read-only, the validator role), writable sessions
to default mode + --force (writes/shell proceed unprompted, the worker
role; --yolo is only a documented alias of --force, never separately
live-tested, so --force is the emitted spelling). --trust rides every
session: headless --print otherwise prompts for workspace trust. The
CLI’s own --sandbox flag is NEVER emitted — the probe observed it make
no difference to outbound network access or writes outside --workspace,
so it is not a kranz isolation boundary. The no-push/no-publish/
no-main-write invariants therefore hold exactly the way the scoping doc
prescribes: turn-level read-only modes for validators, throwaway
--workspace directories, scoped credentials, and (when requested) the
engine’s external process sandbox — never this flag. Because the resolved
OS sandbox is not applied by this backend,
BackendKind::Cursor::supports_sandbox_enforcement is false and
config::validate fails closed on enforced-sandbox pairings.
Auth posture (probe, verified): the CLI’s login state does not survive a
relocated $HOME (HOME=/tmp/x agent status reports “Not logged in”),
and on macOS the credential itself is Keychain-backed (the sandboxed probe
crashed with SecItemCopyMatching failed -50) — no credential FILE exists
under ~/.cursor to copy. The scratch-HOME seed therefore carries only
the small account-identity/CLI-config files ([CURSOR_SEED_ENTRIES]),
never transcripts or caches, and the one ambient var a cursor session may
authenticate with — CURSOR_API_KEY, the scoping doc’s sanctioned
headless channel — is injected explicitly, never the ambient set. A
session whose seed+key is insufficient fails auth loudly
(“Authentication required”, pre-billing), which the stream watcher turns
into an honest configuration-style failure rather than a retryable one
(probe item 5). One macOS addendum found by the first live mission
(m-a5a8fd, 2026-08-08): the CLI consults the login keychain at startup
EVEN with CURSOR_API_KEY set, and the keychain domain resolves through
HOME, so a relocated HOME without Library/Keychains/login.keychain-db
dies pre-auth with security exit 154. Both spawn branches therefore
seed an EMPTY login keychain (ensure_session_login_keychain) — never
a link to the operator’s real keychain.
Hook-status lane (ticket agent-hooks-status-signals,
crate::hook_status): when the runner seeds
SessionSpec::hook_status (mission config hookStatus.enabled AND
this hook-capable backend), CursorBackend::start installs the lane
into the session-private HOME BEFORE spawning: <home>/.cursor/ hooks.json (the CLI’s documented user-level hook file — verified
2026-08-06 against https://cursor.com/docs/hooks: version: 1 with
per-event [{command, timeout}] handlers, payloads delivered on stdin,
exit 0 = ok / 2 = block / other = fail-open; there is NO HTTP hook
type, so delivery to kranz’s endpoint is the installed
kranz hook-status relay) plus the per-session spec file the relay
reads. The install NEVER touches the workspace’s tracked
.cursor/hooks.json — the project-level file is the operator’s own,
and mutating it as a side effect of spawning would be exactly the
silent tracked-tree write the ticket forbids. Every install failure
degrades to NO lane (loud warning, ordinary session): hooks are
non-authoritative observability, and the lane disabled is the
byte-identical default. SessionSpec::hook_status is the one field
this backend consumes beyond argv/env; the claude-ism fields stay
ignored as documented above.
Structs§
- Cursor
Backend - The
AgentBackendforagent --print --output-format stream-json: single-shot with the--mode ask/--forcepermission posture selected from the session role. - Cursor
Session - A live
agent --print --output-format stream-jsonsession (theAgentSessionimpl).
Functions§
- build_
args - Build the argv (excluding the binary itself) for one session.
- discover_
cursor_ binary - Locate a working cursor
agentbinary. - parse_
cursor_ line - Parse one stdout line into zero or more
AgentEvents.modelis the configured model id, used as theInitfallback (the wire’s init event carries a model DISPLAY string, e.g. “GPT-5.6 Luna 272K Low”, which is preferred when present) and as the pricing key for the terminal event’s client-side cost computation. - parse_
cursor_ value - Map one parsed
stream-jsonvalue to events (see module docs / docs/scoping/cursor-cli-backend.md’s event-to-AgentEventtable). Unrecognizedtype/subtypecombinations route toAgentEvent::Otherrather than being guessed at.