AGENTS.md@packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth · git:20260817.f42b6ab · 2026-08-17 · sha256 f9b3593706e2d834
AGENTS.md@packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth git:20260817.f42b6abA
Immutable. This exact content is served forever at /api/v1/blob/f9b3593706e2d834.
# cursor-cli-oauth
The native Cursor provider (`cursor`, the api2.cursor.sh protobuf transport shipped in v2026.8.16) is the first-party, primary way to use Cursor from senpi. This extension is the documented FALLBACK lane: reach for it when the native path does not work well (protocol drift, transport failures) or when Cursor's own agent harness - running turns through the official `cursor-agent` CLI in print mode - is explicitly wanted. It never replaces or modifies the native provider, and `/cursor-account status` recommends the native provider whenever both are configured.
Generated: 2026-08-17
## FILE ROLES
| File | Role |
|---|---|
| `index.ts` | Extension entry: registers the `cursor-cli-oauth` provider unconditionally with the offline model catalog (swapping in the probe-backed catalog when it resolves), wires `/cursor-account`, and delegates every turn to `streamSimple` with settings, accounts, and the executable re-resolved per turn |
| `settings.ts` | `cursorCliOauthProvider` settings parsing: fixed defaults (`enabled: false`, `forceExecution: true`, `executionMode: "agent"`, `resumeMode: "auto"`, `contextRecapOnModelSwitch: true`, `modelCatalogTtlHours: 24`), `SENPI_CURSOR_CLI_OAUTH_*` env overrides winning over disk, invalid values silently ignored; exports the sandbox-allowlist validator factory |
| `accounts.ts` | Multi-account slot model: `CursorCliAccountSlot` and the credential shape with sentinel top-level fields, `addAccount`/`removeAccount`/`pinAccount`/`listAccounts`, name validation `^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$`, `assertSentinelInvariant`, `refreshSlot` through `store.modify` |
| `affinity.ts` | HRW (rendezvous) account selection keyed by senpi session id (sha256 `BigUInt64BE` score), pinned account wins unless blocked, expired `rate_limit` blocks cleared while `auth_error` blocks persist, `AllCursorAccountsBlockedError` carrying the soonest unblock time |
| `errors.ts` | Closed ten-kind error union and `classifyCursorCliError`: exact-line matchers on probe-observed stderr wording plus typed `binary_missing`/`malformed_stream` kinds, retryability flags, rate-limit block durations (server hint else 60 s, capped at 48 h); the `context_overflow` wordings list is currently empty (the ceiling probe recorded no verbatim wording), so that kind is defined but unmatched |
| `executable.ts` | `cursor-agent` resolution chain: `SENPI_CURSOR_CLI_OAUTH_EXECUTABLE`, then `CURSOR_AGENT_EXECUTABLE`, then settings `executablePath`, then explicit PATH probing, then the newest `~/.local/share/cursor-agent/versions/*`; typed `CursorAgentNotInstalledError` naming the install command; `probeCursorAgentVersion` under a 10 s deadline |
| `spawn-args.ts` | Pure argv serializer for one print-mode invocation (`-p`, `--output-format stream-json`, `--stream-partial-output`, `--trust`, optional `--model`/`--resume`/`--force`/`--mode plan`/`--sandbox`); applies no execution policy |
| `stream-parser.ts` | Incremental NDJSON parser for the stream-json dialect: typed init/thinking/assistant/tool_call/result events, split-line tolerance, a bounded pending buffer (1 MiB) with `line_overflow` handling, unknown events counted, non-JSON noise routed to a bounded diagnostic ring, `malformed_stream` events instead of throws |
| `transport.ts` | Spawns the resolved executable detached in its own process group with an explicit env allowlist (`HOME` = the account home, `AGENT_CLI_CREDENTIAL_STORE=file`, `PATH`/`TERM`/`LANG`/`LC_ALL`/`FORCE_COLOR`); rejects prompts over 130 KB pre-spawn; abort sends SIGTERM to the group then SIGKILL after 5 s; exposes the pid, parsed events, bounded stderr, and a settled outcome |
| `home-store.ts` | Durable per-account HOMEs under `<agentDir>/cursor-cli-oauth/accounts/<slot>/home`: rewrites `.cursor/auth.json` (`accessToken`/`refreshToken`/`apiKey: null`/`bedrockCredentials: null`) at mode 0600 inside 0700 directories immediately before each run, reads back rotated refresh tokens after; logs byte lengths only; traversal-checked paths; never deletes a HOME |
| `oauth-login.ts` | Provider OAuth config (`check`/`login`/`refreshToken`/`getApiKey`) reusing the `packages/ai` Cursor PKCE flow, first slot named `default`; one `configuredFor` predicate backs both `check` and turn-time lane resolution (`file-store` only, no ambient branch exists); `importLocalCursorCredential` copies the local Cursor store or keychain into a slot and exists only for the explicit `/cursor-account import` |
| `session-router.ts` | Sticky chat routing: per-senpi-session `{accountName, chatId, lastModel}` captured from `system/init`; same- or different-model turns resume via `--resume`, a model switch prepends a one-turn 8 KB context recap, and resume failure or `context_overflow` restarts a fresh chat with the recap plus a notice; prompt and recap are shrunk to the transport ceiling before spawning |
| `failover.ts` | Account rotation around one attempt: `rate_limit` blocks the slot (server hint else 60 s, max 48 h), `auth_error` blocks until re-login; retries only before any visible assistant delta; a replacement account always starts a fresh chat with a user-visible notice and never inherits chat context |
| `models.ts` | Model catalog: cached `cursor-agent models` probe (15 s deadline, full-stdout file capture, ANSI strip, `<id> - <label>` parsing, TTL cache at `<agentDir>/cursor-cli-oauth/models.json`) degrading to the exact 15-entry static fallback; zero cost, text-only input, 64 K max tokens |
| `guardrails.ts` | Execution policy: `--force` only in agent mode with `noApprovalAcknowledgedAt` set (typed `CursorCliExecutionRefusalError` naming the acknowledgement step otherwise); plan mode never forces; force-disabled agent mode and unproven sandbox modes warn once per session; deny lists sanitized to exact full commands and written per-spawn as `permissions.deny` `Shell(...)` entries in the account HOME's `cli-config.json` |
| `stream.ts` | The turn path (`streamCursorCliOauth`): re-resolves settings, accounts, and the executable per turn, refreshes expired tokens, composes failover and the session router, maps events to ordered text/thinking deltas with cumulative-snapshot dedupe, renders tool frames display-only as untrusted output, and applies usage isolation (senpi's own sent-payload estimate plus the CLI's output tokens; CLI input/cache numbers quarantined in a `cursor_cli_oauth_cli_usage` diagnostic) |
| `account-command.ts` | `/cursor-account` command (`list`/`add`/`remove`/`pin`/`unpin`/`import`/`status`): reads account state fresh from the credential store on every invocation, reuses `claude-sdk-oauth`'s account-events emitter (import only; that lane is never edited), and fences every deferred continuation behind the generation guard |
| `diagnostics.ts` | Status collection and rendering for `/cursor-account status` (lane always `file-store`, context owner always `senpi`, selected account, chat id, last model, executable path and version with the `2026.08.11` floor warning, block windows, native-provider recommendation when configured); plus reload/shutdown safety: the generation fence, the tracked-child registry, and the `session_shutdown` teardown with process-group kills |
| `changes.md` | Fork-change record; read before touching anything here |
## INVARIANTS
- Native-first positioning: the native `cursor` provider is the primary path; this lane is the documented fallback and never replaces or modifies it.
- senpi is the context owner in every mode. `usage.input` is senpi's own estimate of the payload it spawned, `usage.output` is the CLI's `outputTokens`, `cacheRead`/`cacheWrite`/`totalTokens` stay 0, and the CLI's `inputTokens`/`cacheReadTokens`/`request_id` live only in the `cursor_cli_oauth_cli_usage` diagnostic. Nothing under `extensions/builtin/compaction/` or core compaction is touched, and no `session_compact` handler is registered.
- File-store-only auth: per-account sandboxed HOMEs with `AGENT_CLI_CREDENTIAL_STORE=file` and sentinel top-level credential fields. No ambient lane exists; the only reader of the user's real Cursor store or keychain is the explicit `/cursor-account import`, once, on request. `CURSOR_API_KEY` is never set and the system keychain is never written.
- The CLI executes its own tools autonomously: `--force` requires the explicit `noApprovalAcknowledgedAt` acknowledgement, and tool frames render display-only as untrusted output, never bridged onto senpi tools.
- Chats are private to each account's HOME; cross-account failover always starts a fresh chat with a notice and never transfers context.
- Process lifecycle: children spawn detached in their own process group and are killed by tracked pid only - never by name matching (senpi #823). Deferred continuations are fenced per extension generation (senpi #866).
- Provider registration never blocks on the executable; `check` reports one of `configured (file-store, <n> accounts)`, `disabled by settings`, `cursor-agent not installed: <guidance>`, or `no accounts: run /login cursor-cli-oauth`.
- Every behavior change adds a `changes.md` section with expected conflict zones.
## TESTS
`packages/coding-agent/test/cursor-cli-oauth/*.test.ts` (20 suites) plus the hermetic `test/fixtures/fake-cursor-agent.mjs`; every unit/integration test points `SENPI_CURSOR_AGENT_EXECUTABLE` at the fixture, so no test needs the real binary, the network, or credentials. Real-CLI probes are opt-in behind `SENPI_CURSOR_CLI_LIVE=1` under `.agents/skills/senpi-qa/scripts/probes/cursor-cli/`.
## MERGE RISK
LOW inside this fork-new directory. The two shared files (`extensions/builtin/index.ts`, `provider-display-names.ts`) are the standard collision points for any new builtin lane; see `changes.md` for the expected zones.