diff --git a/CLAUDE.md b/CLAUDE.md index c559374..7c336a5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,8 @@ limboo/ │ ├── paths.ts # assetPath() resolver │ ├── sendCommand.ts # native menu/tray → renderer command bridge │ ├── window/ # createWindow.ts (frameless, sandbox, icon) + windowState.ts - │ ├── managers/ # Settings, Notification, AppMenu, Tray managers + │ ├── managers/ # Settings, Notification, AppMenu, Tray managers (+ cursor/ auth) + │ ├── secrets/ # SecretStore.ts — safeStorage-encrypted secrets under userData/secrets/ │ └── ipc/ # registry.ts (handle wrapper) + *Handlers + registerAllIpc() ├── preload/ │ └── index.ts # the ONLY bridge — exposes window.limboo.{window,settings,system,app,events} @@ -406,7 +407,12 @@ version is just a dev/baseline placeholder. To release: `git tag vX.Y.Z && git p internal hosts. Resolve+check the target before connecting. - **Secrets / API keys**: encrypt with Electron `safeStorage`, never plaintext files; **redact secrets/tokens** before they reach - [`logger.ts`](src/main/logger.ts). + [`logger.ts`](src/main/logger.ts). This is now implemented: + [`src/main/secrets/SecretStore.ts`](src/main/secrets/SecretStore.ts) is the + safeStorage-backed store (opaque files under `userData/secrets/`, decrypt + only at spawn time, names-only logging) — new secrets go through it, and + `logger.ts` + `AgentManager.redact` already strip `crsr_` / `CURSOR_API_KEY` + material. - **Terminal / Git engines**: spawn with **no `shell: true`** — pass argv arrays (`spawn(cmd, [args])`); validate every `cwd`/path stays inside the session repo (path-traversal guard) before touching the filesystem. @@ -656,7 +662,91 @@ boot revalidation chains after `worktrees.recover().finally(retarget)`). gated to the active session) + `resume:state-changed`. Settings under the **Memory & Search** category (`settings.resume`, bounds in `RESUME_LIMITS`). -**Still open / future** — Repository clone/track UI, a dedicated Permission System +### Agent Adapter Architecture (multi-agent: Claude + Cursor) — IN PROGRESS + +**Build-order item (1), Authentication, is BUILT; the rest is planned.** Limboo +is evolving from "a Claude integration" into a +multi-agent orchestration platform via an **Agent Adapter Architecture**: a thin +translation layer per agent runtime, with **nothing above the adapters changing**. +The UI never knows which agent is running — it only knows "the current session has +an active coding agent". Full research/design doc: +[`docs/agents/cursor-integration.txt`](docs/agents/cursor-integration.txt). + +- **The seam is already narrow.** Claude coupling is concentrated in + `AgentManager.ts`; the `AgentEvent`/`AgentState`/`PermissionRequest` types, all + agent IPC channels, the preload namespace, `useAgentStore`, and the + Composer/permission/plan/timeline UI are provider-neutral and stay frozen. The + planned `AgentAdapter` interface covers exactly: executable/auth detection + (`probeHealth`), run invocation (`run(spec) → AsyncIterable`), + per-provider options mapping (`buildOptions`), wire-format → `AgentEvent` + translation (`handleMessage`), tool-identity/permission gating (`makeCanUseTool` + tool-name sets, plan-capture style), resume-token get/set + (`agent_session_meta`), error classification (`classifyAgentError`), and + utility one-shots (`buildUtilityOptions`). +- **BUILT — build-order item (1), Authentication.** The Cursor auth layer is + live (auth only — Cursor still cannot *run*; `AGENT_MODELS` deliberately has + no Cursor entries, so it is structurally unselectable as the running agent): + - `src/main/managers/cursor/CursorAuthManager.ts` — lazy, fully local + classification (`not-installed` / `not-authenticated` / `authenticated-cli` + / `authenticated-api-key`) honoring `settings.agent.cursor.preferredAuth` + (`auto` = key wins; `api-key` = a stored CLI login never classifies; + `cli-login` = a stored key is kept but ignored), single-flight + `cursor-agent login` child (timeout-killed, `dispose()` on quit), + manual-browser mode (`NO_OPEN_BROWSER=1`; the captured login URL must be + https, credential-free, AND on a Cursor-owned host — `cursor.com` / + `cursor.sh` or subdomain, dot-boundary match), `logout` (also clears stale + login state), API-key set/remove, and `getSpawnEnv()` (the only sanctioned + decrypt site; Phase 2's runtime hook — returns `{}` under `cli-login`). + Broadcasts secret-free `CursorAuthState` on `agent:cursor-auth-changed`. + - `src/main/managers/cursor/exec.ts` — argv-only runner (runGit idiom): + PATH probe + Windows `where.exe` fallback + `~/.local/bin/{cursor-agent,agent}` + install-dir probe (plain `agent` is never PATH-searched — collision risk); + `.cmd` shims run via `%ComSpec%` only with static-whitelisted literal args; + bounded output; `redactCursor()`. + - `src/main/secrets/SecretStore.ts` — the safeStorage secret store (§6). + - IPC: 7 `agent:cursor*` channels (`src/main/ipc/cursorHandlers.ts`, all via + `handle()`; key validated + never echoed), preload `agent.cursor.*` + namespace, `useAgentStore.cursorAuth` + actions. + - UI: `CursorProviderCard` under Settings › Agent › **Providers**, sharing + the provider layout with the Claude row via `ProviderStatusRow` + (`panels/ProviderCard.tsx`) — status renders as a lucide **icon + label** + pill (`cursorStatusMeta`/`lifecycleMeta.icon` in `features/agent/status.ts`; + e.g. Download + "Install CLI", never bare "Not installed" text). Shared + `ActionButton` lives in `settings/controls.tsx`; a `SegmentedControl` + exposes `preferredAuth`. `CursorMark` in `ProviderIcon.tsx`, catalog search + fields. Settings: `agent.cursor` (`preferredAuth`, `manualBrowserLogin`), + `SETTINGS_VERSION` 13, bounds in `CURSOR_LIMITS`. The + **Connection & reliability** section (`agent.connection`) is provider-neutral + and shared by every provider. +- **Cursor adapter, remaining build order:** (1) ~~Authentication~~ (BUILT, + above). (2) **Runtime** — + `@cursor/sdk` local runtime preferred (typed errors, `run.stream()`/`onDelta`, + `Agent.resume`, store under `userData`; native `@cursor/sdk--` + binaries need the same asar-unpack treatment as the Claude SDK executable); + fallback: spawn `cursor-agent --print --output-format stream-json + --stream-partial-output` and translate the NDJSON events. (3) **Permissions** — + translate Limboo's posture into a session-scoped `.cursor/cli.json` + (deny-first) + hooks (`preToolUse`/`beforeShellExecution`/`beforeReadFile`, + `failClosed`) bridged over a named pipe into the existing `PermissionRequest` + flow; `--force` only for the `auto` posture; Cursor sandbox on by default. + (4) **Context injection** — memory/search/resume blocks land via a generated + session-scoped rules file (Cursor auto-loads `AGENTS.md`/`CLAUDE.md`; no + system-prompt preset-append exists). (5) **MCP reuse** — expose + `limboo_memory`/`limboo_search` to Cursor (stdio bridge or SDK `customTools`) + so both agents share the same platform services. (6) **Worktrees** — always + pass `--workspace `, never Cursor's `-w` (Limboo's + WorktreeManager stays the single root resolver). +- **Config surface (remaining):** `AgentProvider` is already widened and the + Cursor glyph exists; still to do when the runtime lands — add Cursor entries + to `AGENT_MODELS`/`providerForModel()` (the deliberate "unselectable" guard), + a provider selector for the running agent, and de-Claude the copy in + Composer/Plan strings. +- **Later:** Cursor Cloud Agents (SSE-streamed remote runs; SSRF-allowlisted + fetch per §6) and an **ACP adapter** (`agent acp`, JSON-RPC over stdio) as the + universal route to any ACP-speaking agent. + +**Still open / future** — the Agent Adapter Architecture above (Cursor as the +second first-class agent), repository clone/track UI, a dedicated Permission System beyond the agent's `canUseTool`, merge-conflict resolution UI, remote management, and stash. Local vector embeddings on top of BM25 (both Memory and Search rankings are already fusion-ready) and recording File Writer mutations into the session activity diff --git a/docs/architecture/security-model.md b/docs/architecture/security-model.md index 5a9a1e8..2f5522b 100644 --- a/docs/architecture/security-model.md +++ b/docs/architecture/security-model.md @@ -37,9 +37,12 @@ specific, implemented defense. 5. **Content-Security-Policy** — strict `self`-only in production (no eval); relaxed only for Vite HMR in development. ([`src/main/index.ts`](../../src/main/index.ts)) -6. **No shell execution** — git and the terminal are spawned with argv arrays via - `execFile` / `node-pty`, never `shell: true`, so there is no shell string to - inject into. ([`managers/git/exec.ts`](../../src/main/managers/git/exec.ts), +6. **No shell execution** — git, the terminal, and the Cursor CLI are spawned with + argv arrays via `execFile` / `spawn` / `node-pty`, never `shell: true`, so there + is no shell string to inject into. Windows `.cmd` shims for `cursor-agent` are + bridged through `%ComSpec%` only when every argument matches a static-literal + whitelist. ([`managers/git/exec.ts`](../../src/main/managers/git/exec.ts), + [`managers/cursor/exec.ts`](../../src/main/managers/cursor/exec.ts), [`TerminalManager.ts`](../../src/main/managers/TerminalManager.ts)) 7. **Path-traversal guards** — every renderer-supplied path is validated to stay @@ -55,9 +58,18 @@ specific, implemented defense. key (settings, workspace config, permission decisions, and the settings deep-merge). -10. **Secret redaction** — secrets and tokens are redacted before anything reaches - the logger; embedded-credential remote URLs are redacted from git results and - logs. ([`logger.ts`](../../src/main/logger.ts)) +10. **Secret redaction** — secrets and tokens (including `crsr_` Cursor keys and + `CURSOR_API_KEY=` values) are redacted before anything reaches the logger; + embedded-credential remote URLs are redacted from git results and logs. + ([`logger.ts`](../../src/main/logger.ts)) + +11. **Encrypted secret storage** — the only credential Limboo holds on the user's + behalf (an optional Cursor API key) lives in the safeStorage-backed + [`SecretStore`](../../src/main/secrets/SecretStore.ts): encrypted at rest under + `userData/secrets/`, gated on `safeStorage.isEncryptionAvailable()`, decrypted + only at child-spawn time into the child **environment** (never argv, never IPC, + never logs), with a stale-blob self-heal on keychain resets. The renderer sees + only a `configured` boolean + timestamp. ## Input caps diff --git a/docs/architecture/subsystems/agent-manager.md b/docs/architecture/subsystems/agent-manager.md index a1a6d82..aa65e89 100644 --- a/docs/architecture/subsystems/agent-manager.md +++ b/docs/architecture/subsystems/agent-manager.md @@ -24,8 +24,33 @@ with `managers/memory/memoryTools.ts`. `probeHealth()` checks for an existing sign-in (the `ANTHROPIC_API_KEY`, `ANTHROPIC_AUTH_TOKEN`, or `CLAUDE_CODE_OAUTH_TOKEN` env vars, or the Claude Code -credentials file) and reports `AgentInstall`. Limboo stores no credentials. -`retryAuth()` forces a re-probe after the user signs in again. +credentials file) and reports `AgentInstall`. Limboo stores no Anthropic +credentials. `retryAuth()` forces a re-probe after the user signs in again. + +### Cursor authentication (Agent Adapter Architecture, Phase 1) + +Cursor is the second provider, currently **authentication only** — it cannot run +yet (`AGENT_MODELS` has no Cursor entries, so it is structurally unselectable as +the running agent). The code lives beside, not inside, this manager: + +- `src/main/managers/cursor/CursorAuthManager.ts` — lazy local classification + (`not-installed` / `not-authenticated` / `authenticated-cli` / + `authenticated-api-key`), the interactive `cursor-agent login` child (single- + flight, timeout-killed, manual-browser mode via `NO_OPEN_BROWSER=1` with a + validated https URL surfaced to the UI), `logout`, and API-key lifecycle. + State broadcasts on `agent:cursor-auth-changed` and never carries secrets. +- `src/main/managers/cursor/exec.ts` — argv-only `cursor-agent` runner (the + `runGit` idiom): PATH resolution with a Windows `where.exe` fallback, batch + shims bridged through `%ComSpec%` only with static-whitelisted literal args, + bounded output, `redactCursor()` on everything captured. +- `src/main/secrets/SecretStore.ts` — the app's safeStorage-backed secret store + (first consumer). The Cursor API key is encrypted at rest under + `userData/secrets/`, decrypted only at child-spawn time (`getSpawnEnv()`), + and never IPC'd, logged, or placed on argv. + +`status --format json` output is parsed defensively (whitelisted scalars only); +`crsr_` tokens and `CURSOR_API_KEY=` values are redacted centrally in +`logger.ts` and in this manager's `redact()`. ## Lifecycle and recovery diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 4f56e5b..85f692c 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -42,6 +42,11 @@ terminal/git drawer widths. These persist (debounced) as you resize. The largest category. Highlights: +- **Providers** — connection status per coding agent. Claude Code reuses its own + local login. **Cursor** (authentication only for now) connects via + `cursor-agent login` — with an optional manual-browser mode that prints the + login URL — or a Cursor API key stored encrypted via the OS keychain + (`safeStorage`); the key is never written to settings files or shown again. - **Model** — the Claude model the agent uses (Opus 4.8, Sonnet 4.6, Haiku 4.5). - **Permission mode** — how aggressively tool calls are auto-approved. - **Web search**, **auto-approve reads**, **max turns**. diff --git a/docs/guides/using-the-agent.md b/docs/guides/using-the-agent.md index de61b98..1118b63 100644 --- a/docs/guides/using-the-agent.md +++ b/docs/guides/using-the-agent.md @@ -23,6 +23,24 @@ If none is found, the agent reports an `auth-required` state. Sign in to Claude as usual, then use "retry auth" so Limboo re-probes. Questions about provider pricing or model behavior belong with the agent / provider, not Limboo. +### Connecting Cursor (preview) + +Settings › Agent › Providers has a **Cursor** card. Running Cursor agents arrives +in a later update — connecting your account now just gets it ready. Two paths: + +- **Sign in** — Limboo runs `cursor-agent login`; the CLI authenticates in your + browser and keeps its own credentials (Limboo never reads or copies them). + Enable **Manual browser login** on remote/headless machines: the login URL is + printed instead, with Copy URL / Open Browser actions. +- **API key** — paste a key from the Cursor Dashboard. It is encrypted with the + OS keychain (Electron `safeStorage`), stored outside the settings file, never + shown again, and injected only into Cursor child processes as `CURSOR_API_KEY`. + A `CURSOR_API_KEY` already present in your environment is detected and wins. + +The card classifies the state locally (no network): Not installed → Sign in +required → Connected (via CLI login or API key). **Refresh** re-probes after you +install the CLI or sign in elsewhere. + ## Plan vs implement - **Plan mode** — the agent produces a review-first plan and stops. You approve, diff --git a/docs/reference/ipc-channels.md b/docs/reference/ipc-channels.md index 3c07f13..207e17e 100644 --- a/docs/reference/ipc-channels.md +++ b/docs/reference/ipc-channels.md @@ -21,6 +21,7 @@ This page mirrors that file. When in doubt, the source file is authoritative. | Worktree | `worktree:list`, `worktree:prune`, `worktree:recreate`, `worktree:detach`, `worktree:getRepoConfig`, `worktree:ackConfig`, `worktree:runSetup` | | Services | `service:list`, `service:start`, `service:stop`, `service:restart`, `script:run` | | Agent | `agent:getInstall`, `agent:getState`, `agent:send`, `agent:stop`, `agent:getSnapshot`, `agent:permissionRespond`, `agent:clarificationRespond`, `agent:clearSession`, `agent:getDiagnostics`, `agent:clearRateLimit`, `agent:retryAuth` | +| Cursor auth | `agent:cursorGetAuthState`, `agent:cursorRefreshAuth`, `agent:cursorLoginStart`, `agent:cursorLoginCancel`, `agent:cursorLogout`, `agent:cursorSetApiKey`, `agent:cursorRemoveApiKey` | | Plan mode | `agent:getPlan`, `agent:approvePlan`, `agent:rejectPlan`, `agent:regeneratePlan`, `agent:setPlanPinned`, `agent:listPlanRevisions`, `agent:restorePlanRevision` | | File system | `fs:index`, `fs:getTree`, `fs:readFile`, `fs:getHistory`, `fs:reveal` | | Terminal | `terminal:create`, `terminal:list`, `terminal:write`, `terminal:resize`, `terminal:kill`, `terminal:rename`, `terminal:clear` | @@ -48,6 +49,7 @@ There is also one fire-and-forget renderer -> main channel (`IpcSends`, via | `agent:event` | A structured agent event (message delta, tool call, file change, ...). | | `agent:permission-request` | The agent needs the user to approve or deny a tool. | | `agent:clarification-request` | The agent (AskUserQuestion) needs clarifying answers. | +| `agent:cursor-auth-changed` | The Cursor provider's auth state changed (probe / login / key set). | | `fs:index-progress` | Progress of an in-flight workspace index pass. | | `fs:tree-changed` | The active workspace's directory tree changed. | | `terminal:data` | A chunk of PTY output (raw VT bytes). | diff --git a/docs/reference/settings.md b/docs/reference/settings.md index fb26883..42e8445 100644 --- a/docs/reference/settings.md +++ b/docs/reference/settings.md @@ -10,7 +10,7 @@ Settings are stored at `settings.json` under the OS user-data directory, deep-me with defaults on load, clamped, and migrated when `SETTINGS_VERSION` changes. See [the Settings subsystem](../architecture/subsystems/settings.md). -The current `SETTINGS_VERSION` is **11**. +The current `SETTINGS_VERSION` is **13**. ## appearance @@ -64,6 +64,12 @@ display toggles. `cursorBlink` `true`, `scrollback` `5000`, `copyOnSelect` `false`, `confirmKill` `true`, `mirrorAgentCommands` `true`. +`agent.cursor` (Cursor provider — authentication only): `preferredAuth` `auto` +(one of `auto` / `api-key` / `cli-login`), `manualBrowserLogin` `false` (print the +login URL instead of auto-opening a browser). **No secrets live here** — the +optional Cursor API key is safeStorage-encrypted in a main-only file under +`userData/secrets/`, never in `settings.json`. Bounds in `CURSOR_LIMITS`. + ## git | Field | Default | Notes | diff --git a/docs/reference/window-limboo-api.md b/docs/reference/window-limboo-api.md index a0dc022..2c57ece 100644 --- a/docs/reference/window-limboo-api.md +++ b/docs/reference/window-limboo-api.md @@ -90,6 +90,13 @@ Coding-agent orchestration and the structured event stream. `retryAuth()`, `respondPermission(decision)` - `onStateChanged(cb)`, `onEvent(cb)`, `onPermissionRequest(cb)` — subscriptions. `onEvent` is the unified streaming timeline; see [Data flow](../architecture/data-flow.md). +- `cursor.*` — Cursor provider authentication (auth only; no run capability yet). + Capability-based: no method ever returns a credential; the API key crosses IPC + exactly once via `setApiKey` and is safeStorage-encrypted in the main process. + - `cursor.getAuthState() -> CursorAuthState`, `cursor.refreshAuth() -> CursorAuthState` + - `cursor.loginStart(manual?)`, `cursor.loginCancel()`, `cursor.logout()` + - `cursor.setApiKey(key)`, `cursor.removeApiKey()` + - `cursor.onAuthChanged(cb)` — subscription. ## fs diff --git a/src/main/index.ts b/src/main/index.ts index ba22b82..2f242b7 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -32,6 +32,8 @@ import { AutoUpdateManager } from './managers/AutoUpdateManager'; import { VoiceManager } from './managers/voice/VoiceManager'; import { VoiceModelManager } from './managers/voice/VoiceModelManager'; import { AttachmentManager } from './managers/attachments/AttachmentManager'; +import { SecretStore } from './secrets/SecretStore'; +import { CursorAuthManager } from './managers/cursor/CursorAuthManager'; import { getDb, closeDb } from './db/database'; import { registerAllIpc } from './ipc'; @@ -92,6 +94,7 @@ function bootstrap(): void { let updates: AutoUpdateManager; let voiceModels: VoiceModelManager; let voice: VoiceManager; + let cursorAuth: CursorAuthManager; let memorySweepTimer: ReturnType | undefined; const windowState = new WindowStateManager(); const appMenu = new AppMenuManager(); @@ -114,6 +117,10 @@ function bootstrap(): void { workspace = new WorkspaceManager(); sessions = new SessionManager(); agent = new AgentManager(workspace, settings, notifications); + // Cursor provider — authentication only (Agent Adapter Architecture Phase 1). + // API keys live safeStorage-encrypted in the SecretStore; probing is lazy + // and classifies per the user's `agent.cursor.preferredAuth` setting. + cursorAuth = new CursorAuthManager(new SecretStore(), settings); fileSystem = new FileSystemManager(workspace); terminal = new TerminalManager(workspace, settings); git = new GitManager(workspace, settings); @@ -224,6 +231,7 @@ function bootstrap(): void { updates, voice, voiceModels, + cursorAuth, }); // Begin capability supervision (probe + heartbeat) once IPC is wired. agent.start(); @@ -323,6 +331,7 @@ function bootstrap(): void { app.on('before-quit', () => { agent?.cleanup(); + cursorAuth?.dispose(); void fileSystem?.dispose(); proxy?.stop(); services?.dispose(); diff --git a/src/main/ipc/cursorHandlers.ts b/src/main/ipc/cursorHandlers.ts new file mode 100644 index 0000000..e044dd9 --- /dev/null +++ b/src/main/ipc/cursorHandlers.ts @@ -0,0 +1,51 @@ +/** + * IPC handlers for the Cursor provider's authentication (Phase 1 — no run + * capability). Reached from the renderer through `window.limboo.agent.cursor.*`. + * + * The surface is capability-based (CLAUDE.md §6): the API key crosses exactly + * once (set), is validated + length-capped here, and is NEVER returned, + * echoed into an error, or logged. Every other channel exchanges only the + * secret-free {@link CursorAuthState}. + */ +import { IpcChannels } from '@shared/ipc-channels'; +import { CURSOR_LIMITS } from '@shared/constants'; +import type { CursorAuthState } from '@shared/types'; +import type { CursorAuthManager } from '../managers/cursor/CursorAuthManager'; +import { handle } from './registry'; + +/** + * Printable non-space ASCII only — anything else (newlines, spaces, control + * bytes) could corrupt env injection. Lenient on the `crsr_` prefix: the key + * format is not contractual. The message deliberately never includes the value. + */ +function assertApiKey(value: unknown): string { + const key = typeof value === 'string' ? value.trim() : ''; + if ( + key.length < CURSOR_LIMITS.apiKeyMin || + key.length > CURSOR_LIMITS.apiKeyMax || + !/^[\x21-\x7e]+$/.test(key) + ) { + throw new Error('That does not look like a valid Cursor API key.'); + } + return key; +} + +export function registerCursorHandlers(cursor: CursorAuthManager): void { + handle<[], CursorAuthState>(IpcChannels.agentCursorGetAuthState, () => cursor.getAuthState()); + + handle<[], CursorAuthState>(IpcChannels.agentCursorRefreshAuth, () => cursor.probe(true)); + + handle<[boolean?], void>(IpcChannels.agentCursorLoginStart, (_event, manual) => + cursor.loginStart(manual === true), + ); + + handle<[], void>(IpcChannels.agentCursorLoginCancel, () => cursor.loginCancel()); + + handle<[], void>(IpcChannels.agentCursorLogout, () => cursor.logout()); + + handle<[string], void>(IpcChannels.agentCursorSetApiKey, (_event, key) => + cursor.setApiKey(assertApiKey(key)), + ); + + handle<[], void>(IpcChannels.agentCursorRemoveApiKey, () => cursor.removeApiKey()); +} diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index f99ccf9..2dab0b2 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -18,6 +18,7 @@ import type { ResumeManager } from '../managers/resume/ResumeManager'; import type { AutoUpdateManager } from '../managers/AutoUpdateManager'; import type { VoiceManager } from '../managers/voice/VoiceManager'; import type { VoiceModelManager } from '../managers/voice/VoiceModelManager'; +import type { CursorAuthManager } from '../managers/cursor/CursorAuthManager'; import { registerWindowHandlers } from './windowHandlers'; import { registerSettingsHandlers } from './settingsHandlers'; import { registerSystemHandlers } from './systemHandlers'; @@ -35,6 +36,7 @@ import { registerSearchHandlers } from './searchHandlers'; import { registerResumeHandlers } from './resumeHandlers'; import { registerUpdateHandlers } from './updateHandlers'; import { registerVoiceHandlers } from './voiceHandlers'; +import { registerCursorHandlers } from './cursorHandlers'; export interface IpcDeps { settings: SettingsManager; @@ -54,6 +56,7 @@ export interface IpcDeps { updates: AutoUpdateManager; voice: VoiceManager; voiceModels: VoiceModelManager; + cursorAuth: CursorAuthManager; } export function registerAllIpc(deps: IpcDeps): void { @@ -74,4 +77,5 @@ export function registerAllIpc(deps: IpcDeps): void { registerResumeHandlers(deps.resume, deps.session); registerUpdateHandlers(deps.updates); registerVoiceHandlers(deps.voice, deps.voiceModels, deps.settings); + registerCursorHandlers(deps.cursorAuth); } diff --git a/src/main/logger.ts b/src/main/logger.ts index a7440b1..6fd5acc 100644 --- a/src/main/logger.ts +++ b/src/main/logger.ts @@ -30,7 +30,7 @@ function resolveLogFile(): string | null { * Cheap trigger pre-check before running the redaction regexes: only lines * containing one of these substrings are scanned at all (logging is hot). */ -const REDACT_TRIGGERS = ['sk-', 'bearer', 'token', 'secret', 'password', 'apikey', 'api_key', 'gh', '://']; +const REDACT_TRIGGERS = ['sk-', 'bearer', 'token', 'secret', 'password', 'apikey', 'api_key', 'gh', '://', 'crsr']; /** * Central secret redaction (CLAUDE.md §6: secrets/tokens are redacted before @@ -45,10 +45,13 @@ const REDACT_PATTERNS: RegExp[] = [ /\b(?:sk|gh[pousr]|github_pat|glpat|xox[baprs])[-_][A-Za-z0-9_-]{8,200}\b/g, // Bearer / token authorization headers. /\b(bearer|authorization)\s*[:=]?\s+[A-Za-z0-9._~+/-]{8,400}=*/gi, - // key=value style secrets (token=, api_key:, password= …). - /\b(token|secret|password|passwd|apikey|api_key|access_key|private_key)\b(\s*[:=]\s*)(["']?)[^\s"'&]{4,400}\3/gi, + // key=value style secrets (token=, api_key:, password= …). CURSOR_API_KEY + // needs its own alternate — the `_` in `cursor_api_key` defeats \bapi_key\b. + /\b(token|secret|password|passwd|apikey|api_key|cursor_api_key|access_key|private_key)\b(\s*[:=]\s*)(["']?)[^\s"'&]{4,400}\3/gi, // URL userinfo credentials (https://user:pass@host). /(\w+:\/\/)([^\s/:@]{1,128}):([^\s/@]{1,256})@/g, + // Cursor API keys (crsr_… — lenient shape; the prefix is not contractual). + /\bcrsr_[A-Za-z0-9_-]{8,200}\b/g, ]; function redactSecrets(line: string): string { @@ -60,6 +63,7 @@ function redactSecrets(line: string): string { out = out.replace(REDACT_PATTERNS[2], '$1 [redacted]'); out = out.replace(REDACT_PATTERNS[3], '$1$2[redacted]'); out = out.replace(REDACT_PATTERNS[4], '$1$2:[redacted]@'); + out = out.replace(REDACT_PATTERNS[5], '[redacted]'); return out; } diff --git a/src/main/managers/AgentManager.ts b/src/main/managers/AgentManager.ts index d5ceddf..7a12ff0 100644 --- a/src/main/managers/AgentManager.ts +++ b/src/main/managers/AgentManager.ts @@ -215,7 +215,8 @@ function filePathOf(input: Record): string | undefined { function redact(text: string): string { return text .replace(/sk-[A-Za-z0-9_-]{10,}/g, 'sk-***') - .replace(/(ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CLAUDE_CODE_OAUTH_TOKEN)=\S+/gi, '$1=***') + .replace(/crsr_[A-Za-z0-9_-]{8,}/g, 'crsr_***') + .replace(/(ANTHROPIC_API_KEY|ANTHROPIC_AUTH_TOKEN|CLAUDE_CODE_OAUTH_TOKEN|CURSOR_API_KEY)=\S+/gi, '$1=***') .replace(/(authorization|bearer)\s*[:=]?\s*[A-Za-z0-9._-]{10,}/gi, '$1 ***'); } diff --git a/src/main/managers/SettingsManager.ts b/src/main/managers/SettingsManager.ts index 461b76c..f7c3a13 100644 --- a/src/main/managers/SettingsManager.ts +++ b/src/main/managers/SettingsManager.ts @@ -129,6 +129,14 @@ export class SettingsManager { ); c.idleTimeout = clamp(c.idleTimeout, L.idleTimeout.min, L.idleTimeout.max); + // Cursor provider (auth only) — whitelist the auth-path enum and coerce the + // manual-login toggle. No secrets here: the API key lives in the SecretStore. + const cursor = merged.agent.cursor; + if (!['auto', 'api-key', 'cli-login'].includes(cursor.preferredAuth)) { + cursor.preferredAuth = 'auto'; + } + cursor.manualBrowserLogin = !!cursor.manualBrowserLogin; + merged.git.maxCheckpoints = Math.round( clamp(merged.git.maxCheckpoints, GIT_LIMITS.maxCheckpoints.min, GIT_LIMITS.maxCheckpoints.max), ); diff --git a/src/main/managers/cursor/CursorAuthManager.ts b/src/main/managers/cursor/CursorAuthManager.ts new file mode 100644 index 0000000..6a57dc0 --- /dev/null +++ b/src/main/managers/cursor/CursorAuthManager.ts @@ -0,0 +1,354 @@ +/** + * CursorAuthManager — authentication state for the Cursor provider (Phase 1 of + * the Agent Adapter Architecture; no run capability yet). + * + * Owns two auth paths, kept deliberately distinct: + * - **CLI login** — spawns `cursor-agent login` (argv-only) and lets the CLI + * own its credentials; Limboo never reads, copies, or exports them. Manual + * browser mode (`NO_OPEN_BROWSER=1`) captures the printed login URL for the + * UI to copy/open through the validated system handlers. + * - **API key** — held encrypted via {@link SecretStore} (Electron + * safeStorage). Classification checks *presence only*; the secret is + * decrypted exclusively by {@link getSpawnEnv} at child-spawn time. + * + * Classification is fully local (PATH resolve + presence checks + one + * `status --format json` spawn) — no network probes. All captured CLI output + * passes {@link redactCursor} before it can reach the logger or state. + */ +import { BrowserWindow } from 'electron'; +import type { ChildProcess } from 'node:child_process'; +import { IpcEvents } from '@shared/ipc-channels'; +import { CURSOR_LIMITS, DEFAULT_SETTINGS } from '@shared/constants'; +import type { AppSettings, CursorAuthState, CursorLoginPhase } from '@shared/types'; +import { logger } from '../../logger'; +import type { SettingsManager } from '../SettingsManager'; +import { SecretStore, CURSOR_API_KEY_SECRET } from '../../secrets/SecretStore'; +import { redactCursor, resolveCursorExecutable, runCursorAgent, spawnCursorLogin } from './exec'; + +const INITIAL_STATE: CursorAuthState = { + status: 'unknown', + apiKey: { configured: false }, + login: { phase: 'idle' }, + encryptionAvailable: false, +}; + +export class CursorAuthManager { + private state: CursorAuthState = { ...INITIAL_STATE }; + private probed = false; + private probing: Promise | null = null; + private loginChild: ChildProcess | null = null; + private loginTimer: NodeJS.Timeout | null = null; + + constructor( + private readonly secrets: SecretStore, + private readonly settings: SettingsManager, + ) {} + + /** The user's Cursor auth preference (`settings.agent.cursor`). */ + private prefs(): AppSettings['agent']['cursor'] { + return this.settings.getAll().agent.cursor ?? DEFAULT_SETTINGS.agent.cursor; + } + + /** Current state; runs the first (lazy) probe — nothing spawns at boot. */ + async getAuthState(): Promise { + if (!this.probed) return this.probe(false); + return this.state; + } + + /** + * Re-classify the local install/auth state. Single-flight; `force` bypasses + * the memo. Never reads secret values — presence only. + */ + async probe(force = true): Promise { + if (this.probing) return this.probing; + if (this.probed && !force) return this.state; + this.probing = this.doProbe(force).finally(() => { + this.probing = null; + }); + return this.probing; + } + + private async doProbe(force: boolean): Promise { + this.probed = true; + const patch: Partial = { + encryptionAvailable: this.secrets.isEncryptionAvailable(), + lastCheckedAt: Date.now(), + error: undefined, + account: undefined, + cliVersion: undefined, + }; + + const exe = await resolveCursorExecutable(force); + if (!exe) { + this.setState({ + ...patch, + status: 'not-installed', + apiKey: this.apiKeyPresence(), + error: + 'Cursor CLI (cursor-agent) not found on PATH. Install it from cursor.com/docs, then hit Refresh.', + }); + return this.state; + } + patch.cliVersion = exe.version; + + // Classification order follows `settings.agent.cursor.preferredAuth`: + // - auto: API key (env, then encrypted) wins; else CLI login. + // - api-key: only the key counts — a stored CLI login is ignored. + // - cli-login: only the CLI login counts — a stored key is kept but + // ignored (and getSpawnEnv never injects it). NOTE: an + // ambient CURSOR_API_KEY env var still reaches any child + // process via the inherited environment — that is outside + // our control and reported in `apiKey.source`. + const prefer = this.prefs().preferredAuth; + const apiKey = this.apiKeyPresence(); + patch.apiKey = apiKey; + if (apiKey.configured && prefer !== 'cli-login') { + this.setState({ ...patch, status: 'authenticated-api-key' }); + return this.state; + } + if (prefer === 'api-key') { + // Key preferred but absent — never spawn the status probe: a stored + // CLI login must not classify as authenticated under this preference. + this.setState({ ...patch, status: 'not-authenticated' }); + return this.state; + } + + // Ask the CLI itself whether a browser login is stored. + const r = await runCursorAgent(['status', '--format', 'json'], { + timeout: CURSOR_LIMITS.statusTimeoutMs, + }); + const parsed = r.ok ? parseStatus(r.stdout) : null; + if (parsed?.authenticated) { + this.setState({ ...patch, status: 'authenticated-cli', account: parsed.account }); + } else { + this.setState({ ...patch, status: 'not-authenticated' }); + if (!r.ok && r.code !== 1) { + logger.warn('CursorAuthManager: status probe failed', redactCursor(r.stderr).slice(0, 300)); + } + } + return this.state; + } + + /** Presence-only API key check: process env first, then the encrypted store. */ + private apiKeyPresence(): CursorAuthState['apiKey'] { + if (process.env.CURSOR_API_KEY) return { configured: true, source: 'env' }; + const meta = this.secrets.metadata(CURSOR_API_KEY_SECRET); + return meta.configured + ? { configured: true, source: 'encrypted', updatedAt: meta.updatedAt } + : { configured: false }; + } + + /* ---------------------------------------------------------------- */ + /* Interactive CLI login */ + /* ---------------------------------------------------------------- */ + + /** + * Start `cursor-agent login`. Single-flight — a second call while a child is + * live is rejected. In manual mode the CLI prints the login URL instead of + * opening a browser; the first validated https URL from stdout is exposed on + * state for the renderer's Copy URL / Open Browser actions. + */ + async loginStart(manual: boolean): Promise { + if (this.loginChild) throw new Error('A Cursor sign-in is already in progress.'); + this.setLogin({ phase: 'launching', url: undefined, error: undefined }); + + const child = await spawnCursorLogin(manual ? { NO_OPEN_BROWSER: '1' } : {}); + if (!child) { + this.setLogin({ phase: 'failed', error: 'Could not start cursor-agent. Is it installed?' }); + void this.probe(true); + return; + } + this.loginChild = child; + this.setLogin({ phase: manual ? 'waiting-manual-url' : 'waiting-browser' }); + + let buffered = ''; + const onOutput = (chunk: Buffer | string) => { + if (buffered.length >= CURSOR_LIMITS.outputMax) return; + buffered = (buffered + String(chunk)).slice(0, CURSOR_LIMITS.outputMax); + if (manual && !this.state.login.url) { + const url = extractLoginUrl(buffered); + if (url) this.setLogin({ phase: 'waiting-manual-url', url }); + } + }; + child.stdout?.on('data', onOutput); + child.stderr?.on('data', onOutput); + + this.loginTimer = setTimeout(() => { + logger.warn('CursorAuthManager: login timed out — killing child'); + this.killLoginChild(); + this.setLogin({ phase: 'failed', error: 'Sign-in timed out. Try again.' }); + }, CURSOR_LIMITS.loginTimeoutMs); + + child.once('exit', (code) => { + this.clearLoginChild(); + // Any exit → verify against the CLI's own view of the world. + this.setLogin({ phase: 'verifying', url: undefined }); + void this.probe(true).then(() => { + if (this.state.status === 'authenticated-cli' || this.state.status === 'authenticated-api-key') { + this.setLogin({ phase: 'idle', error: undefined }); + logger.info('CursorAuthManager: sign-in verified'); + } else { + const reason = redactCursor(buffered).trim().split(/\r?\n/).pop()?.slice(0, 200); + this.setLogin({ + phase: 'failed', + error: code === 0 ? 'Sign-in did not complete.' : reason || `cursor-agent login exited with code ${code}`, + }); + } + }); + }); + child.once('error', (err) => { + this.clearLoginChild(); + this.setLogin({ phase: 'failed', error: redactCursor(err.message).slice(0, 200) }); + }); + } + + /** Cancel an in-flight sign-in (kills the login child). */ + loginCancel(): void { + if (!this.loginChild) return; + this.killLoginChild(); + this.setLogin({ phase: 'idle', url: undefined, error: undefined }); + } + + /** `cursor-agent logout`, then re-probe (clearing any stale login state). */ + async logout(): Promise { + const r = await runCursorAgent(['logout']); + if (!r.ok) logger.warn('CursorAuthManager: logout failed', redactCursor(r.stderr).slice(0, 300)); + this.setLogin({ phase: 'idle', url: undefined, error: undefined }); + await this.probe(true); + } + + /* ---------------------------------------------------------------- */ + /* API key (safeStorage) */ + /* ---------------------------------------------------------------- */ + + /** + * Encrypt + store the user's API key. The value is validated upstream by the + * IPC handler, never logged, never kept on this instance, and never included + * in any state or IPC response. + */ + async setApiKey(key: string): Promise { + this.secrets.set(CURSOR_API_KEY_SECRET, key); + await this.probe(true); + } + + async removeApiKey(): Promise { + this.secrets.remove(CURSOR_API_KEY_SECRET); + await this.probe(true); + } + + /** + * Env overlay for a future Cursor child process — the ONLY sanctioned + * decryption call-site (decrypt-at-spawn, never cached). Unused by Phase 1 + * runs; the Phase-2 runtime adapter composes it into `spawn(..., { env })`. + * Respects `preferredAuth`: under `cli-login` the stored key is never + * injected — the CLI's own credentials must carry the run. + */ + getSpawnEnv(): NodeJS.ProcessEnv { + if (this.prefs().preferredAuth === 'cli-login') return {}; + if (process.env.CURSOR_API_KEY) return {}; + const key = this.secrets.getDecrypted(CURSOR_API_KEY_SECRET); + return key ? { CURSOR_API_KEY: key } : {}; + } + + /** Kill any live login child (app quit). */ + dispose(): void { + this.killLoginChild(); + } + + /* ---------------------------------------------------------------- */ + /* State plumbing */ + /* ---------------------------------------------------------------- */ + + private setState(patch: Partial): void { + this.state = { ...this.state, ...patch }; + this.broadcast(); + } + + private setLogin(patch: Partial & { phase: CursorLoginPhase }): void { + this.state = { ...this.state, login: { ...this.state.login, ...patch } }; + this.broadcast(); + } + + private broadcast(): void { + for (const win of BrowserWindow.getAllWindows()) { + if (!win.isDestroyed()) win.webContents.send(IpcEvents.agentCursorAuthChanged, this.state); + } + } + + private killLoginChild(): void { + this.clearLoginChild(true); + } + + private clearLoginChild(kill = false): void { + if (this.loginTimer) { + clearTimeout(this.loginTimer); + this.loginTimer = null; + } + const child = this.loginChild; + this.loginChild = null; + if (kill && child && !child.killed) { + try { + child.kill(); + } catch { + /* already gone */ + } + } + } +} + +/** + * Parse `cursor-agent status --format json` defensively: JSON.parse in a + * try/catch, then read ONLY whitelisted scalar fields — never merge or iterate + * unknown keys (CLAUDE.md §6 pollution discipline). + */ +function parseStatus(stdout: string): { authenticated: boolean; account?: { email?: string; name?: string } } | null { + try { + const raw: unknown = JSON.parse(stdout.trim()); + if (typeof raw !== 'object' || raw === null) return null; + const obj = raw as Record; + const str = (v: unknown): string | undefined => + typeof v === 'string' && v.length > 0 ? v.slice(0, 200) : undefined; + const email = str(obj.email) ?? str((obj.account as Record | undefined)?.email); + const name = str(obj.name) ?? str((obj.account as Record | undefined)?.name); + const authenticated = + obj.authenticated === true || + obj.loggedIn === true || + obj.logged_in === true || + str(obj.status)?.toLowerCase() === 'authenticated' || + // A status payload that identifies an account implies a stored login. + Boolean(email || name); + const account = email || name ? { email, name } : undefined; + return { authenticated, account }; + } catch { + return null; + } +} + +/** + * Hosts the login URL may point at — Cursor's authenticator is always a + * Cursor-owned domain. Exact hostname or subdomain match only (suffix on a + * dot boundary, never substring), so a compromised or spoofed CLI can't + * steer the card's "Open Browser" action to an arbitrary site. + */ +const LOGIN_URL_DOMAINS = ['cursor.com', 'cursor.sh']; + +function isAllowedLoginHost(hostname: string): boolean { + const host = hostname.toLowerCase(); + return LOGIN_URL_DOMAINS.some((d) => host === d || host.endsWith(`.${d}`)); +} + +/** First validated https URL in the login child's output (manual mode). */ +function extractLoginUrl(text: string): string | undefined { + const match = text.match(/https:\/\/\S+/); + if (!match) return undefined; + const candidate = match[0].replace(/[)\]}>.,'"]+$/, ''); + if (candidate.length > CURSOR_LIMITS.loginUrlMax) return undefined; + try { + const url = new URL(candidate); + if (url.protocol !== 'https:' || url.username || url.password) return undefined; + if (!isAllowedLoginHost(url.hostname)) return undefined; + return url.toString(); + } catch { + return undefined; + } +} diff --git a/src/main/managers/cursor/exec.ts b/src/main/managers/cursor/exec.ts new file mode 100644 index 0000000..88c9068 --- /dev/null +++ b/src/main/managers/cursor/exec.ts @@ -0,0 +1,219 @@ +/** + * Safe `cursor-agent` CLI runner for the Cursor provider (authentication only). + * + * Security (CLAUDE.md §6): the CLI is always invoked via `execFile`/`spawn` + * with an **argv array** — never `shell: true`. Secrets (CURSOR_API_KEY, + * NO_OPEN_BROWSER) ride only in the child **environment**, never on argv + * (argv leaks to OS process listings). Calls are bounded by a timeout and a + * max output buffer, and all captured output must pass {@link redactCursor} + * before it can reach a log line or renderer-visible state. + * + * Windows `.cmd`/`.bat` shims cannot be run by `execFile` without a shell, so + * they are executed through `%ComSpec% /d /s /c "" ` — and ONLY + * when every argument matches {@link SAFE_ARG_RE}. All Phase-1 arguments are + * static literals (`login`, `logout`, `status`, `--format`, `json`, …); this + * module refuses anything else on the cmd path by design. Never route user + * input through it. + */ +import { execFile, spawn, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { CURSOR_LIMITS } from '@shared/constants'; + +export interface CursorResult { + ok: boolean; + stdout: string; + stderr: string; + code: number; +} + +export interface CursorExecutable { + /** Command name or absolute path handed to execFile/spawn. */ + path: string; + /** `cmd` = Windows batch shim (needs the ComSpec bridge). */ + kind: 'exe' | 'cmd'; + /** `cursor-agent --version` output captured during resolution, if any. */ + version?: string; +} + +/** The only argument shape allowed through the Windows ComSpec bridge. */ +const SAFE_ARG_RE = /^[A-Za-z0-9=_.-]+$/; + +/** Strip Cursor credential material from captured CLI output. */ +export function redactCursor(text: string): string { + return text + .replace(/crsr_[A-Za-z0-9_-]{8,}/g, 'crsr_***') + .replace(/(CURSOR_API_KEY)\s*[:=]\s*\S+/gi, '$1=***'); +} + +let resolved: Promise | null = null; + +/** + * Locate the `cursor-agent` CLI. Memoized; pass `force` to re-probe (e.g. the + * user just installed it and hit Refresh). Resolution order: + * 1. Plain `cursor-agent --version` — PATH lookup by the OS itself. + * 2. win32: `where.exe cursor-agent` to find `.exe` (preferred) or `.cmd` shims. + * 3. Direct probe of `~/.local/bin/cursor-agent` then `~/.local/bin/agent` + * (GUI-launched Electron apps often miss the shell PATH that contains the + * documented install dir; newer docs name the binary plain `agent`). + */ +export function resolveCursorExecutable(force = false): Promise { + if (!resolved || force) resolved = probeExecutable(); + return resolved; +} + +async function probeExecutable(): Promise { + // 1. Let the OS PATH search do the work. + const direct = await tryVersion('cursor-agent', 'exe'); + if (direct) return direct; + + // 2. Windows: PATH may only hold a batch shim, which execFile can't start. + if (process.platform === 'win32') { + const where = await new Promise((res) => { + execFile( + 'where.exe', + ['cursor-agent'], + { timeout: CURSOR_LIMITS.versionTimeoutMs, windowsHide: true }, + (err, stdout) => res(err ? '' : String(stdout)), + ); + }); + const hits = where.split(/\r?\n/).map((l) => l.trim()).filter(Boolean); + const exe = hits.find((h) => /\.exe$/i.test(h)); + if (exe) { + const viaExe = await tryVersion(exe, 'exe'); + if (viaExe) return viaExe; + } + const shim = hits.find((h) => /\.(cmd|bat)$/i.test(h)); + if (shim) { + const viaShim = await tryVersion(shim, 'cmd'); + if (viaShim) return viaShim; + } + } + + // 3. The documented install location, for PATH-less GUI launches. Newer + // Cursor docs also ship the binary as plain `agent`; that name is only + // probed inside the documented install dir — never via a bare PATH + // search, where `agent` could collide with an unrelated executable. + for (const candidate of [ + path.join(os.homedir(), '.local', 'bin', 'cursor-agent'), + path.join(os.homedir(), '.local', 'bin', 'cursor-agent.exe'), + path.join(os.homedir(), '.local', 'bin', 'agent'), + path.join(os.homedir(), '.local', 'bin', 'agent.exe'), + ]) { + if (fs.existsSync(candidate)) { + const viaProbe = await tryVersion(candidate, 'exe'); + if (viaProbe) return viaProbe; + } + } + return null; +} + +async function tryVersion(file: string, kind: 'exe' | 'cmd'): Promise { + const exe: CursorExecutable = { path: file, kind }; + const r = await execCursor(exe, ['--version'], { timeout: CURSOR_LIMITS.versionTimeoutMs }); + if (!r.ok) return null; + const version = r.stdout.trim().split(/\r?\n/)[0]?.slice(0, 80); + return { ...exe, version: version || undefined }; +} + +/** + * Run one `cursor-agent` subcommand. Resolves even on non-zero exit (inspect + * `.ok`) and never rejects — the runGit idiom. `opts.env` is overlaid on the + * inherited environment (secrets go HERE, never in `args`). + */ +export async function runCursorAgent( + args: string[], + opts: { timeout?: number; env?: NodeJS.ProcessEnv } = {}, +): Promise { + const exe = await resolveCursorExecutable(); + if (!exe) return { ok: false, stdout: '', stderr: 'cursor-agent not found', code: 127 }; + return execCursor(exe, args, opts); +} + +function execCursor( + exe: CursorExecutable, + args: string[], + opts: { timeout?: number; env?: NodeJS.ProcessEnv } = {}, +): Promise { + let file = exe.path; + let argv = args; + let verbatim = false; + if (exe.kind === 'cmd') { + const bridged = comspecBridge(exe.path, args); + if (!bridged) { + return Promise.resolve({ ok: false, stdout: '', stderr: 'cursor-agent: unsafe argument refused', code: 1 }); + } + ({ file, argv } = bridged); + verbatim = true; + } + return new Promise((resolve) => { + execFile( + file, + argv, + { + timeout: opts.timeout ?? CURSOR_LIMITS.statusTimeoutMs, + maxBuffer: CURSOR_LIMITS.outputMax, + windowsHide: true, + windowsVerbatimArguments: verbatim, + env: { ...process.env, ...opts.env }, + }, + (err, stdout, stderr) => { + const out = typeof stdout === 'string' ? stdout : stdout?.toString() ?? ''; + const errOut = typeof stderr === 'string' ? stderr : stderr?.toString() ?? ''; + if (!err) { + resolve({ ok: true, stdout: out, stderr: errOut, code: 0 }); + return; + } + const code = (err as { code?: number }).code; + resolve({ + ok: false, + stdout: out, + stderr: errOut || err.message, + code: typeof code === 'number' ? code : 1, + }); + }, + ); + }); +} + +/** + * Spawn the interactive `cursor-agent login` child (streaming stdout so manual + * mode can capture the printed URL). Returns null when the CLI is unresolved + * or a cmd-shim argument fails the whitelist. + */ +export async function spawnCursorLogin(env: NodeJS.ProcessEnv): Promise { + const exe = await resolveCursorExecutable(); + if (!exe) return null; + let file = exe.path; + let argv: string[] = ['login']; + let verbatim = false; + if (exe.kind === 'cmd') { + const bridged = comspecBridge(exe.path, argv); + if (!bridged) return null; + ({ file, argv } = bridged); + verbatim = true; + } + return spawn(file, argv, { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + windowsVerbatimArguments: verbatim, + env: { ...process.env, ...env }, + }); +} + +/** + * Build the `%ComSpec% /d /s /c """ "` argv for a batch shim. + * Refuses (returns null) unless every argument is a static-literal token — + * cmd.exe metacharacters can never reach this line. + */ +function comspecBridge(shimPath: string, args: string[]): { file: string; argv: string[] } | null { + if (!args.every((a) => SAFE_ARG_RE.test(a))) return null; + if (/["%^&|<>]/.test(shimPath)) return null; + const file = process.env.ComSpec ?? 'cmd.exe'; + // With /s, cmd strips the outer quotes of the /c payload, leaving the quoted + // shim path + whitelisted literal args. windowsVerbatimArguments keeps Node + // from re-quoting the payload. + const payload = `""${shimPath}" ${args.join(' ')}"`; + return { file, argv: ['/d', '/s', '/c', payload] }; +} diff --git a/src/main/secrets/SecretStore.ts b/src/main/secrets/SecretStore.ts new file mode 100644 index 0000000..14164a7 --- /dev/null +++ b/src/main/secrets/SecretStore.ts @@ -0,0 +1,122 @@ +/** + * SecretStore — the app's safeStorage-backed secret persistence (CLAUDE.md §6: + * secrets are encrypted with Electron `safeStorage`, never plaintext files). + * + * Each secret is one opaque file at `userData/secrets/.bin.json` holding + * `{ v, updatedAt, data }` where `data` is the base64 of + * `safeStorage.encryptString(secret)`. Writes are atomic (tmp + rename, the + * storage.ts idiom) but this module deliberately does NOT reuse storage.ts: + * these files must never share code paths that could log payloads. SecretStore + * logs secret *names* only — never values, never file contents. + * + * Decryption happens exclusively through {@link SecretStore.getDecrypted}, + * which callers may invoke only at child-process spawn / env-build time. The + * decrypted value must never be cached, IPC'd to the renderer, put on argv, or + * logged. + */ +import { app, safeStorage } from 'electron'; +import fs from 'node:fs'; +import path from 'node:path'; +import { logger } from '../logger'; + +/** Secret name for the Cursor provider's user API key. */ +export const CURSOR_API_KEY_SECRET = 'cursor-api-key'; + +/** Defense in depth — names come from main-process constants, never IPC. */ +const NAME_RE = /^[a-z0-9-]{1,64}$/; + +interface SecretFile { + v: 1; + updatedAt: number; + /** base64 of safeStorage.encryptString(secret) — opaque, OS-keychain bound. */ + data: string; +} + +export class SecretStore { + private dir(): string { + return path.join(app.getPath('userData'), 'secrets'); + } + + private fileFor(name: string): string { + if (!NAME_RE.test(name)) throw new Error(`Invalid secret name: ${name}`); + return path.join(this.dir(), `${name}.bin.json`); + } + + isEncryptionAvailable(): boolean { + try { + return safeStorage.isEncryptionAvailable(); + } catch { + return false; + } + } + + /** Encrypt + persist a secret. Throws when OS-level encryption is unavailable. */ + set(name: string, secret: string): void { + if (!this.isEncryptionAvailable()) { + throw new Error('OS keychain encryption is unavailable — secret storage is disabled.'); + } + const target = this.fileFor(name); + const payload: SecretFile = { + v: 1, + updatedAt: Date.now(), + data: safeStorage.encryptString(secret).toString('base64'), + }; + const tmp = `${target}.tmp`; + fs.mkdirSync(this.dir(), { recursive: true }); + fs.writeFileSync(tmp, JSON.stringify(payload), { encoding: 'utf8', mode: 0o600 }); + fs.renameSync(tmp, target); + logger.info(`SecretStore: stored secret "${name}"`); + } + + has(name: string): boolean { + return this.readFile(name) !== null; + } + + /** Presence + timestamp only — safe to surface to the renderer. */ + metadata(name: string): { configured: boolean; updatedAt?: number } { + const file = this.readFile(name); + return file ? { configured: true, updatedAt: file.updatedAt } : { configured: false }; + } + + /** + * Decrypt a stored secret. The ONLY sanctioned call-site is child-process + * env composition at spawn time; the result must never be cached or logged. + * A decrypt failure (OS keychain reset / another machine's blob) removes the + * stale file and degrades to "not configured". + */ + getDecrypted(name: string): string | null { + const file = this.readFile(name); + if (!file) return null; + try { + return safeStorage.decryptString(Buffer.from(file.data, 'base64')); + } catch { + logger.warn(`SecretStore: could not decrypt "${name}" (keychain changed?) — removing stale entry`); + this.remove(name); + return null; + } + } + + remove(name: string): void { + try { + fs.rmSync(this.fileFor(name), { force: true }); + logger.info(`SecretStore: removed secret "${name}"`); + } catch (err) { + logger.warn(`SecretStore: failed to remove "${name}"`, err); + } + } + + private readFile(name: string): SecretFile | null { + try { + const raw = fs.readFileSync(this.fileFor(name), 'utf8'); + const parsed = JSON.parse(raw) as SecretFile; + if (parsed?.v !== 1 || typeof parsed.data !== 'string' || parsed.data.length === 0) return null; + return parsed; + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + // Name only — never the file contents. + logger.warn(`SecretStore: unreadable secret file "${name}"`); + } + return null; + } + } +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 7d644d8..769032b 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -23,6 +23,7 @@ import type { ClarificationDecision, ClarificationRequest, CommandId, + CursorAuthState, DeepPartial, FileHistoryEntry, FileReadResult, @@ -291,6 +292,25 @@ const agentApi = { subscribe(IpcEvents.agentPermissionRequest, cb), onClarificationRequest: (cb: (request: ClarificationRequest) => void): (() => void) => subscribe(IpcEvents.agentClarificationRequest, cb), + /** + * Cursor provider authentication (capability-based — the API key crosses + * exactly once via setApiKey and is never returned by any method). + */ + cursor: { + getAuthState: (): Promise => + ipcRenderer.invoke(IpcChannels.agentCursorGetAuthState), + refreshAuth: (): Promise => + ipcRenderer.invoke(IpcChannels.agentCursorRefreshAuth), + loginStart: (manual?: boolean): Promise => + ipcRenderer.invoke(IpcChannels.agentCursorLoginStart, manual === true), + loginCancel: (): Promise => ipcRenderer.invoke(IpcChannels.agentCursorLoginCancel), + logout: (): Promise => ipcRenderer.invoke(IpcChannels.agentCursorLogout), + setApiKey: (key: string): Promise => + ipcRenderer.invoke(IpcChannels.agentCursorSetApiKey, key), + removeApiKey: (): Promise => ipcRenderer.invoke(IpcChannels.agentCursorRemoveApiKey), + onAuthChanged: (cb: (state: CursorAuthState) => void): (() => void) => + subscribe(IpcEvents.agentCursorAuthChanged, cb), + }, }; const fsApi = { diff --git a/src/renderer/components/brand/ProviderIcon.tsx b/src/renderer/components/brand/ProviderIcon.tsx index b0cdc88..32de0f5 100644 --- a/src/renderer/components/brand/ProviderIcon.tsx +++ b/src/renderer/components/brand/ProviderIcon.tsx @@ -1,8 +1,8 @@ /** - * Provider glyphs for the model pickers. Claude Code is served by Anthropic, so - * today there is a single provider mark. Rendered as inline SVG using - * `currentColor` so it always stays on-palette (no background, no gradient) per - * the product's visual rules. Keyed by the `AgentProvider` union from constants. + * Provider glyphs for the model pickers and provider cards. Rendered as inline + * SVG using `currentColor` so they always stay on-palette (no background, no + * gradient) per the product's visual rules. Keyed by the `AgentProvider` union + * from constants. */ import type { AgentProvider } from '@shared/constants'; @@ -26,6 +26,28 @@ export function AnthropicMark({ size = 14, className }: { size?: number; classNa ); } +/** + * The Cursor mark — the isometric cube, flattened to three `currentColor` + * faces at stepped opacities (flat fills, no gradient) so it inherits the + * surrounding token like every other glyph. + */ +export function CursorMark({ size = 14, className }: { size?: number; className?: string }) { + return ( + + ); +} + export function ProviderIcon({ provider, size = 14, @@ -36,6 +58,8 @@ export function ProviderIcon({ className?: string; }) { switch (provider) { + case 'cursor': + return ; case 'anthropic': default: return ; diff --git a/src/renderer/features/agent/status.ts b/src/renderer/features/agent/status.ts index c768087..981aeea 100644 --- a/src/renderer/features/agent/status.ts +++ b/src/renderer/features/agent/status.ts @@ -3,7 +3,15 @@ * Centralised so the Composer, the Agent settings panel, and the Agent Console * all describe the same lifecycle / request the same way (no off-palette colors). */ -import type { AgentLifecycleStatus, RequestPhase } from '@shared/types'; +import { + CheckCircle2, + CircleAlert, + Download, + KeyRound, + Loader2, + type LucideIcon, +} from 'lucide-react'; +import type { AgentLifecycleStatus, CursorAuthStatus, RequestPhase } from '@shared/types'; export interface LifecycleMeta { /** Tailwind bg token for the status dot. */ @@ -11,6 +19,10 @@ export interface LifecycleMeta { /** Tailwind text token for inline labels. */ text: string; label: string; + /** Status glyph for the settings provider pill (dot stays for inline uses). */ + icon: LucideIcon; + /** The icon should rotate (busy / probing states). */ + spin?: boolean; } /** Lifecycles where the agent is actively doing work for a run. */ @@ -23,30 +35,48 @@ export const BUSY_LIFECYCLES = new Set([ export function lifecycleMeta(lifecycle: AgentLifecycleStatus, installed: boolean): LifecycleMeta { if (!installed || lifecycle === 'not-installed') { - return { dot: 'bg-warning', text: 'text-warning', label: 'Not connected' }; + return { dot: 'bg-warning', text: 'text-warning', label: 'Not connected', icon: Download }; } switch (lifecycle) { case 'starting': case 'initializing': - return { dot: 'bg-muted', text: 'text-muted', label: 'Initializing' }; + return { dot: 'bg-muted', text: 'text-muted', label: 'Initializing', icon: Loader2, spin: true }; case 'busy': - return { dot: 'bg-accent', text: 'text-accent', label: 'Working' }; + return { dot: 'bg-accent', text: 'text-accent', label: 'Working', icon: Loader2, spin: true }; case 'streaming': - return { dot: 'bg-accent', text: 'text-accent', label: 'Streaming' }; + return { dot: 'bg-accent', text: 'text-accent', label: 'Streaming', icon: Loader2, spin: true }; case 'awaiting-permission': - return { dot: 'bg-warning', text: 'text-warning', label: 'Awaiting approval' }; + return { dot: 'bg-warning', text: 'text-warning', label: 'Awaiting approval', icon: KeyRound }; case 'reconnecting': - return { dot: 'bg-warning', text: 'text-warning', label: 'Reconnecting' }; + return { dot: 'bg-warning', text: 'text-warning', label: 'Reconnecting', icon: Loader2, spin: true }; case 'rate-limited': - return { dot: 'bg-warning', text: 'text-warning', label: 'Rate limited' }; + return { dot: 'bg-warning', text: 'text-warning', label: 'Rate limited', icon: CircleAlert }; case 'auth-required': - return { dot: 'bg-warning', text: 'text-warning', label: 'Sign in required' }; + return { dot: 'bg-warning', text: 'text-warning', label: 'Sign in required', icon: KeyRound }; case 'offline': - return { dot: 'bg-danger', text: 'text-danger', label: 'Offline' }; + return { dot: 'bg-danger', text: 'text-danger', label: 'Offline', icon: CircleAlert }; case 'failed': - return { dot: 'bg-danger', text: 'text-danger', label: 'Error' }; + return { dot: 'bg-danger', text: 'text-danger', label: 'Error', icon: CircleAlert }; default: - return { dot: 'bg-success', text: 'text-success', label: 'Ready' }; + return { dot: 'bg-success', text: 'text-success', label: 'Ready', icon: CheckCircle2 }; + } +} + +/** + * Cursor provider auth status → the same meta shape, so the settings Providers + * section renders Claude and Cursor through one shared pill. + */ +export function cursorStatusMeta(status: CursorAuthStatus | 'unknown'): LifecycleMeta { + switch (status) { + case 'authenticated-cli': + case 'authenticated-api-key': + return { dot: 'bg-success', text: 'text-success', label: 'Connected', icon: CheckCircle2 }; + case 'not-authenticated': + return { dot: 'bg-warning', text: 'text-warning', label: 'Sign in required', icon: KeyRound }; + case 'not-installed': + return { dot: 'bg-line-strong', text: 'text-faint', label: 'Install CLI', icon: Download }; + default: + return { dot: 'bg-line-strong', text: 'text-faint', label: 'Checking…', icon: Loader2, spin: true }; } } diff --git a/src/renderer/features/settings/catalog.tsx b/src/renderer/features/settings/catalog.tsx index 1ce8508..6657dc7 100644 --- a/src/renderer/features/settings/catalog.tsx +++ b/src/renderer/features/settings/catalog.tsx @@ -103,9 +103,13 @@ export const SETTINGS_CATALOG: SettingsCategory[] = [ id: 'agent', label: 'Agent', icon: Bot, - keywords: ['claude', 'claude code', 'ai', 'model', 'orchestrate', 'permissions', 'web search', 'connection', 'reliability', 'heartbeat', 'reconnect', 'recovery', 'diagnostics'], + keywords: ['claude', 'claude code', 'cursor', 'ai', 'model', 'provider', 'orchestrate', 'permissions', 'web search', 'connection', 'reliability', 'heartbeat', 'reconnect', 'recovery', 'diagnostics'], fields: [ { id: 'model', label: 'Model', keywords: ['sonnet', 'opus', 'haiku', 'claude', 'anthropic', 'provider'] }, + { id: 'cursorProvider', label: 'Cursor', keywords: ['cursor', 'sign in', 'login', 'logout', 'provider', 'dashboard'] }, + { id: 'cursorPreferredAuth', label: 'Preferred authentication', keywords: ['cursor', 'auth', 'api key', 'cli', 'login', 'auto', 'credential'] }, + { id: 'cursorManualLogin', label: 'Manual browser login', keywords: ['browser', 'url', 'headless', 'cursor'] }, + { id: 'cursorApiKey', label: 'Cursor API key', keywords: ['api key', 'cursor', 'token', 'credential', 'encrypted'] }, { id: 'thinking', label: 'Extended thinking', keywords: ['reasoning', 'adaptive'] }, { id: 'permissionMode', label: 'Approval policy', keywords: ['permissions', 'approve', 'safety'] }, { id: 'autoApproveReads', label: 'Auto-approve reads', keywords: ['read', 'permission'] }, diff --git a/src/renderer/features/settings/controls.tsx b/src/renderer/features/settings/controls.tsx index 4fc0ce0..7f8e8c4 100644 --- a/src/renderer/features/settings/controls.tsx +++ b/src/renderer/features/settings/controls.tsx @@ -125,6 +125,36 @@ export function Meta({ label, value }: { label: string; value: string }) { ); } +/** Compact inline action button for provider cards and other settings rows. */ +export function ActionButton({ + label, + onClick, + danger, + primary, +}: { + label: string; + onClick: () => void; + danger?: boolean; + primary?: boolean; +}) { + return ( + + ); +} + /** * Accessible switch. Sized in **fixed px** (not rem) on purpose: the document * root font-size is `calc(16px * var(--limboo-font-scale))`, so rem-based track/ diff --git a/src/renderer/features/settings/panels/AgentPanel.tsx b/src/renderer/features/settings/panels/AgentPanel.tsx index 304111d..333f920 100644 --- a/src/renderer/features/settings/panels/AgentPanel.tsx +++ b/src/renderer/features/settings/panels/AgentPanel.tsx @@ -12,6 +12,8 @@ import { useSettingsStore } from '@/renderer/stores/useSettingsStore'; import { useAgentStore } from '@/renderer/stores/useAgentStore'; import { lifecycleMeta } from '@/renderer/features/agent/status'; import { Field, Section, Select, SegmentedControl, StackedField, Toggle } from '../controls'; +import { ProviderStatusRow } from './ProviderCard'; +import { CursorProviderCard } from './CursorProviderCard'; export function AgentPanel() { const agent = useSettingsStore((s) => s.settings.agent); @@ -30,26 +32,20 @@ export function AgentPanel() { return (
-
- - - -
- Claude Code - - {install.installed - ? 'Connected — reusing your local Claude Code login.' - : install.error ?? 'Not connected.'} - -
- - - {meta.label} - -
+ +
@@ -148,7 +144,7 @@ export function AgentPanel() {
s.cursorAuth); + const refresh = useAgentStore((s) => s.cursorRefresh); + const loginStart = useAgentStore((s) => s.cursorLoginStart); + const loginCancel = useAgentStore((s) => s.cursorLoginCancel); + const logout = useAgentStore((s) => s.cursorLogout); + const setApiKey = useAgentStore((s) => s.cursorSetApiKey); + const removeApiKey = useAgentStore((s) => s.cursorRemoveApiKey); + const cursorPrefs = useSettingsStore((s) => s.settings.agent.cursor); + const update = useSettingsStore((s) => s.update); + const addToast = useUIStore((s) => s.addToast); + + // The key exists ONLY here between typing and save; cleared on save/unmount. + const [keyDraft, setKeyDraft] = useState(''); + const [showKeyInput, setShowKeyInput] = useState(false); + useEffect(() => () => setKeyDraft(''), []); + + const openExternal = (url: string) => void window.limboo?.system?.openExternal?.(url); + const meta = cursorStatusMeta(auth?.status ?? 'unknown'); + const login = auth?.login ?? { phase: 'idle' as const }; + const loginBusy = login.phase !== 'idle' && login.phase !== 'failed'; + const installed = !!auth && auth.status !== 'not-installed' && auth.status !== 'unknown'; + + const statusLine = (() => { + if (!auth) return 'Checking the Cursor CLI…'; + switch (auth.status) { + case 'not-installed': + return auth.error ?? 'Cursor CLI (cursor-agent) not found on PATH.'; + case 'authenticated-cli': + return `Logged in as ${auth.account?.email ?? auth.account?.name ?? 'your Cursor account'}${ + auth.cliVersion ? ` · CLI ${auth.cliVersion}` : '' + }`; + case 'authenticated-api-key': + return `API key configured${ + auth.apiKey.source === 'env' + ? ' via CURSOR_API_KEY' + : auth.apiKey.updatedAt + ? ` · updated ${new Date(auth.apiKey.updatedAt).toLocaleDateString()}` + : '' + }`; + case 'not-authenticated': + if (cursorPrefs.preferredAuth === 'api-key' && !auth.apiKey.configured) { + return `Installed${auth.cliVersion ? ` (CLI ${auth.cliVersion})` : ''} — API key preferred but none configured yet.`; + } + return `Installed${auth.cliVersion ? ` (CLI ${auth.cliVersion})` : ''} — sign in or add an API key.`; + default: + return 'Checking the Cursor CLI…'; + } + })(); + + const saveKey = async () => { + const ok = await setApiKey(keyDraft.trim()); + if (ok) { + setKeyDraft(''); + setShowKeyInput(false); + } + }; + + return ( +
+ {/* Status row — the shared provider layout (same as the Claude Code card). */} + + + {/* Actions per state. */} + {auth?.status === 'not-installed' && ( +
+ + openExternal(DOCS_URL)} /> +
+ )} + + {(auth?.status === 'authenticated-cli' || auth?.status === 'authenticated-api-key') && ( +
+ {auth.status === 'authenticated-cli' && ( + <> + void logout()} /> + void loginStart(cursorPrefs.manualBrowserLogin)} /> + + )} + {auth.status === 'authenticated-api-key' && auth.apiKey.source === 'encrypted' && ( + <> + setShowKeyInput((v) => !v)} /> + void removeApiKey()} /> + + )} + + + openExternal(auth.status === 'authenticated-api-key' ? API_KEYS_URL : DASHBOARD_URL) + } + /> +
+ )} + + {/* Which credential wins when both exist — consumed by the main-process + probe classification (and the future runtime's spawn env). */} + {installed && ( + + + // Persist first, then re-classify — the probe reads the setting in main. + void update({ agent: { cursor: { preferredAuth: v } } }).then(refresh) + } + /> + + )} + + {auth?.status === 'not-authenticated' && ( + <> + {/* Path 1 — interactive CLI sign-in. */} + + {loginBusy ? ( + + + {login.phase === 'verifying' ? 'Verifying sign-in…' : 'Waiting for sign-in…'} + + + ) : ( + void loginStart(cursorPrefs.manualBrowserLogin)} + /> + )} + + + void update({ agent: { cursor: { manualBrowserLogin: v } } })} + /> + + {login.phase === 'waiting-manual-url' && login.url && ( +
+ {login.url} +
+ { + void window.limboo?.system?.clipboardWrite?.(login.url ?? ''); + addToast({ title: 'Login URL copied', tone: 'info' }); + }} + /> + openExternal(login.url ?? '')} /> + +
+
+ )} + {login.phase === 'failed' && ( +
+ {login.error ?? 'Sign-in failed.'} + void loginStart(cursorPrefs.manualBrowserLogin)} /> +
+ )} + + {/* Path 2 — API key (automation / SDK). */} + + {auth.encryptionAvailable ? ( +
+ setKeyDraft(e.target.value)} + className="w-64 rounded-md border border-line bg-surface-2 px-2 py-1 text-[12px] text-fg placeholder:text-faint focus:border-line-strong focus:outline-none" + /> + void saveKey()} /> + openExternal(API_KEYS_URL)} /> +
+ ) : ( + + OS keychain encryption is unavailable — API key storage is disabled. Use the sign-in + flow or set CURSOR_API_KEY in your environment instead. + + )} +
+ + )} + + {/* Replace-key input (authenticated-api-key state). */} + {auth?.status === 'authenticated-api-key' && showKeyInput && ( +
+ setKeyDraft(e.target.value)} + className="w-64 rounded-md border border-line bg-surface-2 px-2 py-1 text-[12px] text-fg placeholder:text-faint focus:border-line-strong focus:outline-none" + /> + void saveKey()} /> +
+ )} + +

+ Running Cursor agents arrives in a later update — connect your account now so it's ready. +

+
+ ); +} diff --git a/src/renderer/features/settings/panels/ProviderCard.tsx b/src/renderer/features/settings/panels/ProviderCard.tsx new file mode 100644 index 0000000..0bc81e6 --- /dev/null +++ b/src/renderer/features/settings/panels/ProviderCard.tsx @@ -0,0 +1,43 @@ +/** + * Shared provider status row for Settings › Agent › Providers. Claude and + * Cursor (and any future adapter) render the same icon-tile + name + status + * line + icon-pill layout, driven by the shared {@link LifecycleMeta} shape + * from features/agent/status.ts — one status vocabulary, no per-provider + * pill styling. + */ +import type { AgentProvider } from '@shared/constants'; +import { cn } from '@/renderer/lib/cn'; +import { ProviderIcon } from '@/renderer/components/brand/ProviderIcon'; +import type { LifecycleMeta } from '@/renderer/features/agent/status'; + +export function ProviderStatusRow({ + provider, + name, + statusLine, + meta, +}: { + provider: AgentProvider; + name: string; + statusLine: string; + meta: LifecycleMeta; +}) { + const Icon = meta.icon; + return ( +
+ + + +
+ {name} + {statusLine} +
+ + + {meta.label} + +
+ ); +} diff --git a/src/renderer/stores/useAgentStore.ts b/src/renderer/stores/useAgentStore.ts index 507567a..40b3035 100644 --- a/src/renderer/stores/useAgentStore.ts +++ b/src/renderer/stores/useAgentStore.ts @@ -21,6 +21,7 @@ import type { AgentSessionSnapshot, ChatMessage, ClarificationRequest, + CursorAuthState, FileChange, PermissionRequest, PlanRevision, @@ -70,6 +71,8 @@ interface AgentStoreState { * Owned here (not Composer-local state) so plan approval can flip a session * out of Plan mode the moment implementation begins. */ composerModeBySession: Record; + /** Cursor provider auth state (secret-free); null until the lazy probe lands. */ + cursorAuth: CursorAuthState | null; hydrated: boolean; setComposerMode: (sessionId: string, mode: SessionPermissionMode) => void; @@ -87,6 +90,12 @@ interface AgentStoreState { clear: (sessionId: string) => void; clearRateLimit: () => void; retryAuth: () => void; + cursorRefresh: () => void; + cursorLoginStart: (manual: boolean) => Promise; + cursorLoginCancel: () => void; + cursorLogout: () => Promise; + cursorSetApiKey: (key: string) => Promise; + cursorRemoveApiKey: () => Promise; respond: (id: string, behavior: 'allow' | 'deny', remember?: boolean) => void; respondClarification: ( id: string, @@ -266,6 +275,7 @@ export const useAgentStore = create((set, get) => { pendingBySession: {}, pendingClarificationBySession: {}, composerModeBySession: {}, + cursorAuth: null, hydrated: false, setComposerMode: (sessionId, mode) => @@ -330,6 +340,10 @@ export const useAgentStore = create((set, get) => { })), ); + // Cursor provider auth — lazy probe + live updates (secret-free state). + api.cursor?.onAuthChanged?.((cursorAuth) => set({ cursorAuth })); + void api.cursor?.getAuthState?.().then((cursorAuth) => set({ cursorAuth })); + // Seed the diagnostics console with recent history. void get().loadDiagnostics(); }, @@ -426,6 +440,68 @@ export const useAgentStore = create((set, get) => { void window.limboo?.agent?.retryAuth?.(); }, + cursorRefresh: () => { + void window.limboo?.agent?.cursor?.refreshAuth?.(); + }, + + cursorLoginStart: async (manual) => { + try { + await window.limboo?.agent?.cursor?.loginStart?.(manual); + } catch (err) { + useUIStore.getState().addToast({ + title: 'Could not start Cursor sign-in', + description: err instanceof Error ? err.message : String(err), + tone: 'danger', + }); + } + }, + + cursorLoginCancel: () => { + void window.limboo?.agent?.cursor?.loginCancel?.(); + }, + + cursorLogout: async () => { + try { + await window.limboo?.agent?.cursor?.logout?.(); + useUIStore.getState().addToast({ title: 'Signed out of Cursor', tone: 'info' }); + } catch (err) { + useUIStore.getState().addToast({ + title: 'Cursor sign-out failed', + description: err instanceof Error ? err.message : String(err), + tone: 'danger', + }); + } + }, + + // Returns true on success so the card can clear its local input field. + cursorSetApiKey: async (key) => { + try { + await window.limboo?.agent?.cursor?.setApiKey?.(key); + useUIStore.getState().addToast({ title: 'Cursor API key saved', tone: 'success' }); + return true; + } catch (err) { + useUIStore.getState().addToast({ + title: 'Could not save the API key', + description: err instanceof Error ? err.message : String(err), + tone: 'danger', + }); + return false; + } + }, + + cursorRemoveApiKey: async () => { + try { + await window.limboo?.agent?.cursor?.removeApiKey?.(); + useUIStore.getState().addToast({ title: 'Cursor API key removed', tone: 'info' }); + } catch (err) { + useUIStore.getState().addToast({ + title: 'Could not remove the API key', + description: err instanceof Error ? err.message : String(err), + tone: 'danger', + }); + } + }, + respond: (id, behavior, remember) => { const { pendingBySession } = get(); const entry = Object.values(pendingBySession).find((r) => r.id === id); diff --git a/src/shared/constants.ts b/src/shared/constants.ts index f82ef05..98ba4f7 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -1,10 +1,15 @@ import type { AppSettings, WorkspaceConfig } from './types'; /** Bumped whenever the {@link AppSettings} shape changes incompatibly. */ -export const SETTINGS_VERSION = 12; +export const SETTINGS_VERSION = 13; -/** The agent providers Limboo can show a glyph for (Claude Code = Anthropic). */ -export type AgentProvider = 'anthropic'; +/** + * The agent providers Limboo can show a glyph for (Claude Code = Anthropic, + * Cursor = the cursor-agent CLI). Cursor is authentication-only for now — it + * has no {@link AGENT_MODELS} entries, so it can never be selected as the + * running agent until its runtime adapter lands. + */ +export type AgentProvider = 'anthropic' | 'cursor'; /** Selectable Claude models for the agent (id + short label + provider). */ export const AGENT_MODELS = [ @@ -44,6 +49,27 @@ export const AGENT_CONNECTION_LIMITS = { idleTimeout: { min: 0, max: 1_800_000, default: 300_000 }, } as const; +/** + * Bounds + caps for the Cursor provider (authentication only). All + * `cursor-agent` invocations are argv-only and bounded by these caps; the API + * key is validated leniently (the `crsr_` prefix is not contractual). + */ +export const CURSOR_LIMITS = { + /** Accepted API-key length range (lenient — format not guaranteed by docs). */ + apiKeyMin: 8, + apiKeyMax: 512, + /** Deadline for a `cursor-agent status --format json` probe. */ + statusTimeoutMs: 10_000, + /** Deadline for the `cursor-agent --version` / PATH resolution probe. */ + versionTimeoutMs: 5_000, + /** A stuck interactive `cursor-agent login` child is killed after this. */ + loginTimeoutMs: 300_000, + /** Cap on captured CLI stdout/stderr (bytes). */ + outputMax: 64 * 1024, + /** Cap on the manual-login URL captured from the CLI's stdout. */ + loginUrlMax: 2_048, +} as const; + /** Hard limits the renderer and main process both clamp against. */ export const LAYOUT_LIMITS = { left: { min: 200, max: 420, default: 264 }, @@ -395,6 +421,10 @@ export const DEFAULT_SETTINGS: AppSettings = { confirmKill: true, mirrorAgentCommands: true, }, + cursor: { + preferredAuth: 'auto', + manualBrowserLogin: false, + }, }, git: { userName: '', diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index f21dcf7..5e827b6 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -85,6 +85,16 @@ export const IpcChannels = { agentClearRateLimit: 'agent:clearRateLimit', agentRetryAuth: 'agent:retryAuth', + // Cursor provider — authentication only (no run capability yet). The API + // key crosses exactly once (set) and is never returned by any channel. + agentCursorGetAuthState: 'agent:cursorGetAuthState', + agentCursorRefreshAuth: 'agent:cursorRefreshAuth', + agentCursorLoginStart: 'agent:cursorLoginStart', + agentCursorLoginCancel: 'agent:cursorLoginCancel', + agentCursorLogout: 'agent:cursorLogout', + agentCursorSetApiKey: 'agent:cursorSetApiKey', + agentCursorRemoveApiKey: 'agent:cursorRemoveApiKey', + // Plan Mode — review-first workflow over the coding agent. agentGetPlan: 'agent:getPlan', agentApprovePlan: 'agent:approvePlan', @@ -243,6 +253,8 @@ export const IpcEvents = { sessionActiveChanged: 'session:active-changed', /** The agent runtime state changed (status / install / active session). */ agentStateChanged: 'agent:state-changed', + /** The Cursor provider's auth state changed (probe / login / key set). */ + agentCursorAuthChanged: 'agent:cursor-auth-changed', /** A structured agent event (message delta, tool call, file change, …). */ agentEvent: 'agent:event', /** The agent needs the user to approve or deny a tool call. */ diff --git a/src/shared/types.ts b/src/shared/types.ts index 8fd7f56..d717dbb 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -83,9 +83,11 @@ export interface AppSettings { notifications: boolean; }; /** - * Coding-agent (Claude Code) orchestration preferences. Limboo never stores - * Anthropic credentials — Claude Code owns authentication. These knobs only - * shape how the local agent process is driven. + * Coding-agent orchestration preferences. Limboo never stores Anthropic + * credentials — Claude Code owns its own authentication. An optional Cursor + * API key is held encrypted via Electron `safeStorage` in a main-process-only + * file under `userData/secrets/` — never in this settings file, never sent to + * the renderer. These knobs only shape how the local agent process is driven. */ agent: { /** Anthropic model id passed to the Claude Code runtime. */ @@ -195,6 +197,16 @@ export interface AppSettings { /** Mirror agent-run shell commands into the integrated terminal. */ mirrorAgentCommands: boolean; }; + /** + * Cursor provider preferences (authentication only). No secrets live here — + * the API key is safeStorage-encrypted in a main-only file. + */ + cursor: { + /** Which auth path the health probe prefers when both are available. */ + preferredAuth: 'auto' | 'api-key' | 'cli-login'; + /** Print the login URL instead of auto-opening a browser (NO_OPEN_BROWSER). */ + manualBrowserLogin: boolean; + }; }; /** * Git integration preferences. Local-only — no network, no tokens. Commit @@ -1694,6 +1706,67 @@ export interface AgentState { }; } +/* ------------------------------------------------------------------ */ +/* Cursor provider — authentication only (no run capability yet) */ +/* ------------------------------------------------------------------ */ + +/** + * Classification of the local Cursor CLI / credential state. Computed entirely + * from local signals (PATH resolution, credential *presence*, and + * `cursor-agent status --format json`) — no network probes, and the secret + * itself is never read for classification. + */ +export type CursorAuthStatus = + | 'unknown' // never probed yet + | 'not-installed' // cursor-agent not found on PATH + | 'not-authenticated' // CLI present, no login and no API key + | 'authenticated-cli' // the CLI reports a signed-in Cursor account + | 'authenticated-api-key'; // a CURSOR_API_KEY is configured (env or encrypted) + +/** Phase of an in-flight interactive `cursor-agent login` child. */ +export type CursorLoginPhase = + | 'idle' + | 'launching' // spawn issued, no signal yet + | 'waiting-browser' // CLI opened the user's browser; awaiting completion + | 'waiting-manual-url' // NO_OPEN_BROWSER mode; URL captured for the UI + | 'verifying' // login child exited; re-probing auth state + | 'failed'; + +/** + * The Cursor provider's auth state, broadcast to every window on change. + * NEVER carries the API key or any credential material — only presence + * booleans, whitelisted account scalars, and redacted diagnostics. + */ +export interface CursorAuthState { + status: CursorAuthStatus; + /** `cursor-agent --version` output, when resolvable. */ + cliVersion?: string; + /** Whitelisted scalars from `status --format json` (never the raw payload). */ + account?: { email?: string; name?: string }; + /** API-key presence metadata — the key itself never leaves the main process. */ + apiKey: { + configured: boolean; + /** Where the key comes from: the process env or the encrypted SecretStore. */ + source?: 'env' | 'encrypted'; + /** Epoch ms the encrypted key was last written (SecretStore metadata). */ + updatedAt?: number; + }; + /** Interactive login progress (single-flight). */ + login: { + phase: CursorLoginPhase; + /** Validated https login URL captured in manual-browser mode. */ + url?: string; + /** Short redacted reason when phase === 'failed'. */ + error?: string; + }; + /** Whether Electron safeStorage encryption is available on this OS. */ + encryptionAvailable: boolean; + /** Epoch ms of the last completed probe. */ + lastCheckedAt?: number; + /** Human-readable diagnostic from the last probe (already redacted). */ + error?: string; +} + /** Severity for a diagnostics console line. */ export type DiagnosticSeverity = 'debug' | 'info' | 'warning' | 'error';