cursor-opencode-provider 0.2.3 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -17,8 +17,8 @@ OpenCode driving a Cursor-routed Grok model through this provider:
17
17
  - **OpenCode integration** — registers a `cursor` provider with auth hooks and cached model list
18
18
  - **Authentication** — browser OAuth (PKCE), or API key from [cursor.com/settings](https://cursor.com/settings)
19
19
  - **Model discovery** — fetches available models from Cursor's API and caches them locally
20
- - **Streaming** — bidirectional Connect-RPC stream for agent runs
21
- - **Tool calls** — maps Cursor exec-server messages to AI SDK / OpenCode tool-call parts, including the Pi read/bash/edit/write/grep/find/ls request/result field range; mirrors finalized display-only todo/plan state into OpenCode; strips OpenCode's `read` XML envelope (`<path>`/`<content>` + `N:` prefixes) before returning content to Cursor so the model cannot echo the wrapper into writes
20
+ - **Streaming** — bidirectional Connect-RPC Runs with stale-session rotation, health checks, semantic/read-idle deadlines, bounded replay-safe recovery, and activity-aware held tool continuations
21
+ - **Tool calls** — maps Cursor exec-server messages to AI SDK / OpenCode tool-call parts, including native subagent/Task execution (Cursor's `generalPurpose` maps to OpenCode `general`; read-oriented `bugbot` reviews map to `explore`) and the Pi read/bash/edit/write/grep/find/ls request/result field range; enforces the exact current OpenCode agent catalog before emitting any tool call; mirrors finalized display-only todo/plan state into OpenCode; strips OpenCode's `read` XML envelope (`<path>`/`<content>` + `N:` prefixes) before returning content to Cursor so the model cannot echo the wrapper into writes
22
22
  - **Thinking / reasoning** — surfaces extended-thinking deltas where the model supports it
23
23
 
24
24
  ## Requirements
@@ -47,7 +47,7 @@ Add the package name to OpenCode config. OpenCode installs npm plugins with Bun
47
47
  }
48
48
  ```
49
49
 
50
- Pin a version if you want: `"cursor-opencode-provider@0.2.1"`.
50
+ Pin a version if you want: `"cursor-opencode-provider@0.2.5"`.
51
51
 
52
52
  ### From a local clone
53
53
 
@@ -124,7 +124,7 @@ opencode run --model cursor/composer-2.5 "Hello from Cursor via OpenCode"
124
124
 
125
125
  Cursor models often expose parameterized variants (effort, thinking, fast, context tier, …). The plugin materializes those as OpenCode **model variants**. In the TUI, pick one from the variant dialog or cycle with OpenCode’s `variant_cycle` keybind (default `ctrl+t`).
126
126
 
127
- The selected variant’s Cursor parameter map is forwarded on the Run as `requested_model.parameters` (isolated under `providerOptions.cursor.cursorVariantParameters` so unrelated OpenCode options are not leaked onto the wire).
127
+ The selected variant’s Cursor parameter map is forwarded on the Run as `requested_model.parameters` (isolated under `providerOptions.cursor.cursorVariantParameters` so unrelated OpenCode options are not leaked onto the wire). The provider validates that explicit selection against the current cached tuple; malformed, reordered, or stale selections fail clearly instead of silently falling back to another variant.
128
128
 
129
129
  #### 1M / long context
130
130
 
@@ -157,13 +157,15 @@ const cursor = createCursor({
157
157
  // apiBaseURL: "https://api2.cursor.sh",
158
158
  // agentBaseURL: "https://agentn.us.api5.cursor.sh", // explicit Run host override
159
159
  // telemetryEnabled: true, // opt in to GetServerConfig telemetry
160
+ // retry: { maxAttempts: 3, baseDelayMs: 500, maxDelayMs: 8_000 },
161
+ // continuation: { heartbeatMs: 5_000, semanticIdleMs: 120_000, hardCapMs: 600_000 },
160
162
  })
161
163
 
162
164
  const model = cursor.languageModel("composer-2.5")
163
165
  // model implements AI SDK LanguageModelV3 (doStream / doGenerate)
164
166
  ```
165
167
 
166
- Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-...` key). Optional: `apiBaseURL`, `agentBaseURL`, `headers`, `telemetryEnabled`. The older `baseURL` option is still accepted as a legacy alias for `agentBaseURL`.
168
+ Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-...` key). Optional: `apiBaseURL`, `agentBaseURL`, `headers`, `telemetryEnabled`, `retry`, and `continuation`. Retries occur only before visible output or stateful server activity; unsafe replay is surfaced instead of risking duplicate text or tool work. Pending-tool inactivity is renewed by OpenCode activity from the session or its descendants. The older `baseURL` option is still accepted as a legacy alias for `agentBaseURL`.
167
169
 
168
170
  ## Environment variables
169
171
 
@@ -173,11 +175,11 @@ Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-
173
175
  | `CURSOR_API_BASE_URL` | Override API base for auth, model discovery, and `GetServerConfig` agent URL resolution (default `https://api2.cursor.sh`) |
174
176
  | `CURSOR_GET_SERVER_CONFIG_TELEMETRY` | Set to `1` or `true` to opt the `GetServerConfig` lookup into telemetry in OpenCode/plugin usage |
175
177
  | `CURSOR_PROVIDER_DEBUG` | Set to `1` or `true` to enable wire-level debug logging |
176
- | `CURSOR_PROVIDER_DEBUG_FILE` | Debug log path (default `/tmp/cursor-provider-debug.log`) |
178
+ | `CURSOR_PROVIDER_DEBUG_FILE` | Debug log path (default: `debug-<pid>.log` under `$TMPDIR/cursor-provider-logs-<uid>/`) |
177
179
  | `XDG_CACHE_HOME` | When set, model/version caches go under `$XDG_CACHE_HOME/opencode/` instead of `~/.cache/opencode/` |
178
180
  | `XDG_DATA_HOME` | When set, OpenCode `auth.json` is read from `$XDG_DATA_HOME/opencode/` instead of `~/.local/share/opencode/` |
179
181
 
180
- `createCursor({ agentBaseURL })` overrides the agent Run host. When unset, the provider resolves the host from Cursor's `GetServerConfig` API (`agentUrlConfig.agentnUrl`, region-specific — e.g. `agentn.us.api5.cursor.sh`, `agent-gcpp-uswest.api5.cursor.sh`) once per process and holds it in memory (never written to disk), so a held-open Run stream is never repointed mid-session. Explicit agent overrides and GetServerConfig results are validated as HTTPS `*.cursor.sh` hosts (Cursor's agent hostnames vary and may change); non-`cursor.sh` hosts are rejected. The lookup sends `{ "telem_enabled": false }` by default; set `telemetryEnabled: true` in provider config, or `CURSOR_GET_SERVER_CONFIG_TELEMETRY=1` for OpenCode/plugin usage, to opt in. If the lookup fails or does not return a valid Cursor agent host, the model call fails clearly instead of falling back to `agentn.global.api5.cursor.sh`.
182
+ `createCursor({ agentBaseURL })` overrides the agent Run host. When unset, the provider resolves the host from Cursor's `GetServerConfig` API (`agentUrlConfig.agentnUrl`, region-specific — e.g. `agentn.us.api5.cursor.sh`, `agent-gcpp-uswest.api5.cursor.sh`) once per process and holds it in memory (never written to disk), so a held-open Run stream is never repointed mid-session. Explicit agent overrides and GetServerConfig results are validated as HTTPS `*.cursor.sh` hosts (Cursor's agent hostnames vary and may change); non-`cursor.sh` hosts are rejected. Shared HTTP/2 connections are rotated before they become server-aged, while existing Runs may finish on their original connection. The lookup sends `{ "telem_enabled": false }` by default; set `telemetryEnabled: true` in provider config, or `CURSOR_GET_SERVER_CONFIG_TELEMETRY=1` for OpenCode/plugin usage, to opt in. If the lookup fails or does not return a valid Cursor agent host, the model call fails clearly instead of falling back to `agentn.global.api5.cursor.sh`.
181
183
 
182
184
  ## Development
183
185
 
@@ -186,6 +188,7 @@ bun install # install dependencies
186
188
  bun run build # compile TypeScript → dist/
187
189
  bun run typecheck # type-check without emit
188
190
  bun test # run unit tests
191
+ bun run test:node-http2 # Node-specific HTTP/2 detach regression
189
192
  bun run test:watch # watch mode
190
193
  ```
191
194
 
@@ -214,6 +217,12 @@ OpenCode
214
217
  | `src/transport/connect.ts` | HTTP/2 bidi stream and unary RPC calls |
215
218
  | `src/protocol/` | Protobuf encode/decode, checksum/device ids, exec + display tool-call mapping (`tool-call-bridge.ts`) |
216
219
 
220
+ ### Injected system guidance
221
+
222
+ The provider adds OpenCode-specific system guidance to normal tool-capable conversations, including tool availability, canonical workspace-path grounding, and preferring `edit` / `write` over shell-based file mutation when those tools are available. Compaction keeps its dedicated prompt unchanged.
223
+
224
+ If this guidance causes issues, update `buildOpenCodeInteractionGuidance` in [`src/language-model.ts`](src/language-model.ts) and its focused coverage in [`test/prompt-history.test.ts`](test/prompt-history.test.ts).
225
+
217
226
  ## Package exports
218
227
 
219
228
  | Import path | Export |
@@ -221,8 +230,9 @@ OpenCode
221
230
  | `cursor-opencode-provider` | `createCursor`, `CursorPlugin` (named + default) |
222
231
  | `cursor-opencode-provider/plugin` | `CursorPlugin` (classic Hooks — auth) |
223
232
  | `cursor-opencode-provider/plugin/v2` | OpenCode Effect/Promise v2 plugin (`ctx.aisdk.*`) |
233
+ | `cursor-opencode-provider/errors` | Structured provider error classes |
224
234
 
225
- `CursorPluginV2` is **not** re-exported from the package root the classic plugin loader would treat it as a broken Hooks export. Always load it via `./plugin/v2`.
235
+ The package root intentionally stays plugin-safe for OpenCode's classic loader. `CursorPluginV2` and non-plugin runtime APIs are **not** re-exported from the package root; load them through their dedicated subpaths.
226
236
 
227
237
  ## Troubleshooting
228
238
 
@@ -232,21 +242,30 @@ OpenCode
232
242
  | Auth / 401 errors mid-session | Re-login. OAuth and exchanged API-key JWTs refresh automatically when near expiry; a revoked refresh token needs a fresh login. |
233
243
  | “Too many connections from different devices” | Device IDs are derived from stable OS identifiers (same approach as the Cursor CLI). Avoid running multiple clients that invent different machine fingerprints for the same account. |
234
244
  | Empty or stale model list | Delete `~/.cache/opencode/cursor-models.json` (or under `$XDG_CACHE_HOME/opencode/`) and restart OpenCode. Existing Cursor auth is enough to refill the cache; re-login only if auth itself is broken. Cache TTL is 24h; a failed background refresh keeps serving the previous cache. |
235
- | Stream hangs or HTTP/2 errors | Abort the turn and retry. The agent Run uses a bidirectional HTTP/2 stream; a dropped connection leaves the in-flight session unusable. With debug logging enabled, `interaction_query: replied` means a Cursor UI/approval request was answered; a later heartbeat-only stall without that line usually means OpenCode is running an older build and should be restarted after `bun run build`. |
236
- | No response / silent 200 + close | The provider resolves the Run host from `GetServerConfig` (`agentUrlConfig.agentnUrl`). The URL is resolved once per process and not cached on disk. If resolution fails, the model call reports the endpoint-resolution error instead of falling back to `agentn.global.api5.cursor.sh`, which some accounts reject silently ("This region is not yet available for your team"). Set `CURSOR_PROVIDER_DEBUG=1` to confirm the resolved host in the debug log. |
237
- | Need wire-level logs | Set `CURSOR_PROVIDER_DEBUG=1` (optional `CURSOR_PROVIDER_DEBUG_FILE`, default `/tmp/cursor-provider-debug.log`) and reproduce the issue. |
245
+ | Stream hangs or HTTP/2 errors | The provider keeps Cursor's Run open across OpenCode tool calls, rotates aged shared connections, and transparently rebases once from full OpenCode history if the Run ends before `turn_ended`. Repeated interruption is surfaced as an error instead of a false successful stop; retry the turn after checking connectivity. With debug logging enabled, look for `Run interrupted` and `rebasing fresh Run`. Restart OpenCode after rebuilding a local `file://` install. |
246
+ | No response / silent 200 + close | HTTP 200 alone is not a successful agent turn: the provider now requires Cursor's explicit `turn_ended`, captures HTTP/2 trailers/GOAWAY, and recovers once from bare EOF. The Run host still comes from in-memory `GetServerConfig` resolution; set `CURSOR_PROVIDER_DEBUG=1` to confirm the host and termination reason. |
247
+ | Visible `<shell_metadata>` timeout text | Rebuild and restart a local install. Cursor's shell timeout is carried on its exec request; the provider now removes OpenCode's internal timeout envelope before it is rendered or stored, then returns Cursor's typed timeout or background-handoff event instead of treating the text as successful stdout. |
248
+ | `Unsupported Cursor exec variant …` | The error names the canonical Cursor CLI request field, its expected result field, and this provider's handling classification. `handling=unsupported` is a known Cursor-native capability without a safe OpenCode AI SDK bridge; `unknown request field` indicates new protocol drift; `handling=opencode-tool` or `provider-control` indicates a provider decoder/dispatch regression. Enable the debug log and report the full named error. |
249
+ | Need wire-level logs | Set `CURSOR_PROVIDER_DEBUG=1` (optional `CURSOR_PROVIDER_DEBUG_FILE`; the default is `debug-<pid>.log` under `$TMPDIR/cursor-provider-logs-<uid>/`) and reproduce the issue. |
250
+
251
+ ## Security
252
+
253
+ Project `instructions` may reference absolute or `~/` paths (OpenCode parity). See [SECURITY.md](./SECURITY.md) for the trust model and `OPENCODE_DISABLE_PROJECT_CONFIG`.
238
254
 
239
255
  ## Known limitations
240
256
 
241
257
  - **Personal use / ToS** — this provider speaks Cursor’s private agent protocol (CLI-shaped client identity). Use only with an account you own; Cursor may change or restrict the API without notice.
242
- - **`request_context` from OpenCode** — each Run sends Cursor `RequestContext` built from OpenCode project context (workspace env, `AGENTS.md` / `instructions`, `.opencode` agents/skills/plugins, git, layout, plus `.claude`/`.agents` skill fallbacks). Same discovery as OpenCode — including `.cursor/` paths only when listed in `instructions`. Cursor-only cloud/sandbox marketplace surfaces are omitted.
243
- - **Configured MCP tools keep their upstream server id** — OpenCode builtins and plugin/custom tools are advertised under a synthetic `opencode` MCP server. Tools whose flattened name matches an MCP server in merged `opencode.json` configuration (`github_create_pull_request`, …) are grouped into that server's `mcp_descriptors` / `provider_identifier` (`github`, …). Unknown underscore-containing names stay under `opencode` rather than being guessed incorrectly. Cursor's MCP-state exec probe is answered from the same advertised descriptors before the actual tool request, and exec still reconstructs the full OpenCode tool id.
244
- - **Display completions are notifications, not execution requests** — Cursor `tool_call_*` frames use a typed `ToolCall` oneof. The provider decodes them for diagnostics but only mirrors finalized todo/plan state (`update_todos_tool_call` / `create_plan_tool_call`) into advertised OpenCode `todowrite`; the completed payload already contains the authoritative final list. Interactive, data-returning, and side-effecting completions are never replayed as new tools because their result could not be returned to Cursor. Exec-backed Pi read/bash/edit/write/grep/find/ls calls use their typed request/result fields instead. Unknown display variants are logged; unknown exec variants fail the turn explicitly rather than receiving a guessed response that could deadlock the Run.
245
- - **Cursor-native interaction queries remain headless** — Cursor UI/approval *queries* (as distinct from display tool calls) still cannot be surfaced through the AI SDK provider interface. The normal system prompt redirects questions, planning, plan-mode transitions, and known-URL fetching to equivalent OpenCode tools only when they are advertised (`question`, `todowrite`, `plan_enter` / `plan_exit`, `webfetch`); native web/PR/MCP/image/SCM requests are declined so they remain behind OpenCode's tools and permissions. Compaction prompts are unchanged. Unknown future interaction variants fail the turn explicitly instead of hanging the Run stream.
246
- - **Compaction resets Cursor conversation state** — the classic plugin marks OpenCode's `compaction` agent explicitly. On those turns the provider mints an isolated Cursor `conversation_id`, drops the prior checkpoint + KV blobs, preserves real tool-result text in the seed history, and re-advertises the session's last tool catalog while refusing execution during the summary itself. The first normal turn then rebases once more onto a fresh conversation seeded with OpenCode's compacted prompt and normal system instructions, so the summary-agent checkpoint cannot suppress later tool calls. Ordinary no-tool / `toolChoice:none` calls do not reset conversation state.
258
+ - **`request_context` from OpenCode** — each Run sends Cursor `RequestContext` built from OpenCode project context (workspace env, `AGENTS.md` / `instructions`, `.opencode` agents/skills/plugins, git, layout, plus `.claude`/`.agents` skill fallbacks). Its canonical root is also used by the [injected system guidance](#injected-system-guidance). Same discovery as OpenCode — including `.cursor/` paths only when listed in `instructions`. Cursor-only cloud/sandbox marketplace surfaces are omitted. OpenCode remains the permission authority: its coarse allow/ask/deny configuration is not fabricated into Cursor's unrelated allow/block instruction-list messages.
259
+ - **Configured MCP tools keep their upstream server id** — OpenCode builtins and plugin/custom tools are advertised under a synthetic `opencode` MCP server. Tools whose flattened name matches an MCP server in merged `opencode.json` configuration (`github_create_pull_request`, …) are grouped into that server's `mcp_descriptors` / `provider_identifier` (`github`, …). Unknown underscore-containing names stay under `opencode` rather than being guessed incorrectly. Cursor's MCP-state exec probe is answered from the same advertised descriptors before the actual tool request, using the full canonical tool-definition identity required by native `get_mcp_tools`; exec still reconstructs the full OpenCode tool id.
260
+ - **Display completions are notifications, not execution requests** — Cursor `tool_call_*` frames use a typed `ToolCall` oneof. The provider decodes them for diagnostics but only mirrors finalized todo/plan state (`update_todos_tool_call` / `create_plan_tool_call`) into advertised OpenCode `todowrite`; the completed payload already contains the authoritative final list. Interactive, data-returning, and side-effecting completions are never replayed as new tools because their result could not be returned to Cursor. Exec-backed native subagent/Task and Pi read/bash/edit/write/grep/find/ls calls use their typed request/result fields instead. Unknown display variants are logged. All 37 Cursor CLI exec request/result pairs are inventoried by field and name; known-but-unsupported and future unknown exec variants fail explicitly rather than receiving a guessed response that could deadlock the Run.
261
+ - **Tool availability is per OpenCode agent** — Cursor can request native capabilities such as Task even when a child or restricted OpenCode agent did not advertise the corresponding host tool. The provider prompts Cursor with the exact current catalog and checks every decoded host-tool exec request against it. An unavailable request is answered on Cursor's correlated typed result channel and is never emitted as OpenCode's `invalid` tool.
262
+ - **Background shells are non-interactive** — Cursor's native background-shell spawn is bridged through OpenCode's foreground-only `bash` tool by detaching the requested command, redirecting its output to `${TMPDIR:-/tmp}/cursor-opencode-bg.*`, and returning the real PID in Cursor's typed field-16 result. Shell-stream requests also preserve Cursor's foreground timeout, `timeout_behavior`, and `hard_timeout`: cancellation becomes a typed aborted exit, while a soft timeout requested with background behavior becomes a typed background handoff. OpenCode's internal `<shell_metadata>` envelope and the bridge's private markers are stripped before the result reaches the UI. Requests that require `write_shell_stdin` are rejected explicitly because OpenCode does not expose an interactive background-process lifecycle through its AI SDK tool interface.
263
+ - **Cursor-native interaction queries remain headless** — Cursor UI/approval *queries* (as distinct from display tool calls) still cannot be surfaced through the AI SDK provider interface. The normal system prompt redirects questions, planning, plan-mode transitions, and known-URL fetching to equivalent OpenCode tools only when they are advertised (`question`, `todowrite`, `plan_enter` / `plan_exit`, `webfetch`); native web/PR/MCP/image/SCM requests are declined so they remain behind OpenCode's tools and permissions. Separately from display `create_plan_tool_call` → `todowrite` mirroring, interaction `create_plan_request_query` is auto-acked (CLI headless parity) with success and an empty `plan_uri`, so Cursor may treat the plan as accepted without an OpenCode UI confirm. Compaction prompts are unchanged. Unknown future interaction variants fail the turn explicitly instead of hanging the Run stream.
264
+ - **Compaction resets Cursor conversation state** — the classic plugin marks OpenCode's `compaction` agent explicitly. On those turns the provider mints an isolated Cursor `conversation_id`, drops the prior checkpoint + KV blobs, preserves real tool outputs as OpenCode-host observations in the seed history, and re-advertises the session's last tool catalog while refusing execution during the summary itself. The first normal turn then rebases once more onto a fresh conversation seeded with OpenCode's compacted prompt and normal system instructions, so the summary-agent checkpoint cannot suppress later tool calls. Ordinary no-tool / `toolChoice:none` calls do not reset conversation state.
247
265
  - **Conversation bindings and compaction catalogs are bounded** — process-global per-session bindings, prior tool catalogs, and pending post-compaction rebases use a 256-session LRU bound. Evicting a conversation binding also drops its checkpoint and KV blobs.
266
+ - **Interrupted Runs rebase once** — a remote EOF, Connect end-stream, trailer error, failed continuation write, or closed pending-tool session is never emitted as a successful `stop`. The provider starts one fresh Cursor conversation seeded from the OpenCode prompt, retaining the live user request and trailing tool results as host observations without replaying stale historical tool output, re-advertises the same tools, and preserves compaction's no-execution behavior. A second interruption is returned as an explicit error. Recovery cannot see tokens already streamed to the UI on the interrupted attempt, so mid-generation recovery may briefly duplicate visible text/reasoning even though the rebase prompt asks the model not to repeat completed work.
248
267
  - **No fallback models** — if Cursor’s `AvailableModels` API is unreachable and there is no local cache, the provider exposes no models.
249
268
 
250
269
  ## License
251
270
 
252
- MIT
271
+ MIT
package/SECURITY.md ADDED
@@ -0,0 +1,42 @@
1
+ # Security notes
2
+
3
+ This provider mirrors OpenCode’s trust model for project configuration. That has implications for what ends up in Cursor context.
4
+
5
+ ## Project `instructions` can include arbitrary local paths
6
+
7
+ OpenCode’s `opencode.json` / `opencode.jsonc` `instructions` array may list:
8
+
9
+ - relative paths / globs (e.g. `.cursor/rules/*.md`)
10
+ - absolute paths
11
+ - home-relative paths (`~/…`)
12
+ - remote URLs (`https://…`; this provider fetches HTTPS only)
13
+
14
+ OpenCode itself expands `~/`, accepts absolute paths, and injects those file contents into the model prompt for **every** provider. This package does the same discovery when building Cursor `RequestContext.rules`.
15
+
16
+ So a project config like:
17
+
18
+ ```json
19
+ {
20
+ "instructions": ["~/.ssh/id_rsa", "/etc/passwd"]
21
+ }
22
+ ```
23
+
24
+ can cause those files to be read and sent to the model provider (OpenCode prompt path and/or this provider’s Cursor `RequestContext`). That is intentional OpenCode parity, not a Cursor-only hole. There is no path allowlist that rejects absolute/`~/` instruction paths (matching OpenCode; OpenCode has not treated this as a defect).
25
+
26
+ Treat project `opencode.json` as **trusted**. Do not open untrusted repositories with project config enabled if that is unacceptable.
27
+
28
+ ## Mitigation: disable project config
29
+
30
+ To ignore project-level OpenCode config (including project `instructions` and project `AGENTS.md` / `CLAUDE.md` / `CONTEXT.md` discovery), set:
31
+
32
+ ```bash
33
+ export OPENCODE_DISABLE_PROJECT_CONFIG=1
34
+ ```
35
+
36
+ OpenCode honors this flag when loading config and assembling prompts. This provider honors the same flag when collecting rules for Cursor `RequestContext`, so project `instructions` are not merged and project instruction files are not auto-discovered. Global config under `~/.config/opencode` (and `~/.claude/CLAUDE.md` when applicable) still applies.
37
+
38
+ Truthy values: `1` or `true` (case-insensitive), same as OpenCode.
39
+
40
+ ## Related hardening in this provider
41
+
42
+ - Remote `instructions` URLs are **HTTPS-only** (`http://` is skipped). Redirects (including to a local proxy) are intentional and not blocked.
@@ -0,0 +1,15 @@
1
+ export type SessionActivitySource = {
2
+ lastActivityAt(sessionId: string): number | undefined;
3
+ };
4
+ /** Tracks OpenCode message progress and propagates it through subagent ancestry. */
5
+ export declare class SessionActivityTracker implements SessionActivitySource {
6
+ private readonly parentBySession;
7
+ private readonly lastActivityBySession;
8
+ linkSession(sessionId: string, parentId?: string): void;
9
+ recordActivity(sessionId: string, at?: number): void;
10
+ lastActivityAt(sessionId: string): number | undefined;
11
+ removeSession(sessionId: string): void;
12
+ clear(): void;
13
+ private prune;
14
+ }
15
+ export declare const sessionActivity: SessionActivityTracker;
@@ -0,0 +1,76 @@
1
+ const MAX_ANCESTRY_DEPTH = 64;
2
+ const MAX_TRACKED_SESSIONS = 1_024;
3
+ const ACTIVITY_RETENTION_MS = 24 * 60 * 60 * 1_000;
4
+ /** Tracks OpenCode message progress and propagates it through subagent ancestry. */
5
+ export class SessionActivityTracker {
6
+ parentBySession = new Map();
7
+ lastActivityBySession = new Map();
8
+ linkSession(sessionId, parentId) {
9
+ if (!sessionId)
10
+ return;
11
+ this.prune(Date.now());
12
+ if (parentId && parentId !== sessionId)
13
+ this.parentBySession.set(sessionId, parentId);
14
+ else
15
+ this.parentBySession.delete(sessionId);
16
+ const existing = this.lastActivityBySession.get(sessionId);
17
+ if (existing !== undefined)
18
+ this.recordActivity(sessionId, existing);
19
+ this.prune(Date.now());
20
+ }
21
+ recordActivity(sessionId, at = Date.now()) {
22
+ if (!sessionId || !Number.isFinite(at))
23
+ return;
24
+ this.prune(at);
25
+ const visited = new Set();
26
+ let current = sessionId;
27
+ for (let depth = 0; current && depth < MAX_ANCESTRY_DEPTH; depth++) {
28
+ if (visited.has(current))
29
+ return;
30
+ visited.add(current);
31
+ const previous = this.lastActivityBySession.get(current);
32
+ if (previous === undefined || at > previous) {
33
+ // Map insertion order is our least-recently-active eviction order.
34
+ this.lastActivityBySession.delete(current);
35
+ this.lastActivityBySession.set(current, at);
36
+ }
37
+ current = this.parentBySession.get(current);
38
+ }
39
+ this.prune(at);
40
+ }
41
+ lastActivityAt(sessionId) {
42
+ this.prune(Date.now());
43
+ return this.lastActivityBySession.get(sessionId);
44
+ }
45
+ removeSession(sessionId) {
46
+ this.parentBySession.delete(sessionId);
47
+ this.lastActivityBySession.delete(sessionId);
48
+ }
49
+ clear() {
50
+ this.parentBySession.clear();
51
+ this.lastActivityBySession.clear();
52
+ }
53
+ prune(now) {
54
+ const oldestAllowed = now - ACTIVITY_RETENTION_MS;
55
+ for (const [sessionId, activityAt] of this.lastActivityBySession) {
56
+ if (activityAt >= oldestAllowed)
57
+ break;
58
+ this.lastActivityBySession.delete(sessionId);
59
+ this.parentBySession.delete(sessionId);
60
+ }
61
+ while (this.lastActivityBySession.size > MAX_TRACKED_SESSIONS) {
62
+ const oldest = this.lastActivityBySession.keys().next().value;
63
+ if (!oldest)
64
+ break;
65
+ this.lastActivityBySession.delete(oldest);
66
+ this.parentBySession.delete(oldest);
67
+ }
68
+ while (this.parentBySession.size > MAX_TRACKED_SESSIONS) {
69
+ const oldest = this.parentBySession.keys().next().value;
70
+ if (!oldest)
71
+ break;
72
+ this.parentBySession.delete(oldest);
73
+ }
74
+ }
75
+ }
76
+ export const sessionActivity = new SessionActivityTracker();
package/dist/auth.d.ts CHANGED
@@ -15,6 +15,8 @@ export declare class AuthTimeoutError extends Error {
15
15
  }
16
16
  export declare function isExpiringSoon(jwt: string, thresholdS?: number): boolean;
17
17
  export declare function decodeJwtPayload(jwt: string): Record<string, unknown> | null;
18
+ /** JWT `exp` claim as epoch milliseconds, or null if missing/malformed. */
19
+ export declare function decodeJwtExpiryMs(jwt: string): number | null;
18
20
  export declare function useAuthToken(token: string): {
19
21
  accessToken: string;
20
22
  };
@@ -45,4 +47,3 @@ export declare function generatePkceParams(): PkceParams;
45
47
  export declare function generatePkceChallenge(verifier: string): Promise<string>;
46
48
  export declare function buildLoginUrl(challenge: string, uuid: string, websiteUrl?: string): string;
47
49
  export declare function pollForTokens(uuid: string, verifier: string, baseUrl?: string, signal?: AbortSignal, maxAttempts?: number): Promise<TokenPair>;
48
- export declare function loginWithBrowser(websiteUrl?: string, apiBaseUrl?: string, signal?: AbortSignal): Promise<TokenPair>;
package/dist/auth.js CHANGED
@@ -32,22 +32,35 @@ export class AuthTimeoutError extends Error {
32
32
  }
33
33
  // ── Helpers ──
34
34
  export function isExpiringSoon(jwt, thresholdS = 300) {
35
- try {
36
- const payload = JSON.parse(atob(jwt.split(".")[1]));
37
- return (payload.exp * 1000 - Date.now()) < thresholdS * 1000;
38
- }
39
- catch {
35
+ const payload = decodeJwtPayload(jwt);
36
+ if (!payload || typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
40
37
  return true;
41
38
  }
39
+ return payload.exp * 1000 - Date.now() < thresholdS * 1000;
42
40
  }
43
41
  export function decodeJwtPayload(jwt) {
44
42
  try {
45
- return JSON.parse(atob(jwt.split(".")[1]));
43
+ const segment = jwt.split(".")[1];
44
+ if (!segment)
45
+ return null;
46
+ const json = Buffer.from(segment, "base64url").toString("utf8");
47
+ const payload = JSON.parse(json);
48
+ if (!payload || typeof payload !== "object" || Array.isArray(payload))
49
+ return null;
50
+ return payload;
46
51
  }
47
52
  catch {
48
53
  return null;
49
54
  }
50
55
  }
56
+ /** JWT `exp` claim as epoch milliseconds, or null if missing/malformed. */
57
+ export function decodeJwtExpiryMs(jwt) {
58
+ const payload = decodeJwtPayload(jwt);
59
+ if (!payload || typeof payload.exp !== "number" || !Number.isFinite(payload.exp)) {
60
+ return null;
61
+ }
62
+ return payload.exp * 1000;
63
+ }
51
64
  function base64url(bytes) {
52
65
  return btoa(String.fromCharCode(...bytes))
53
66
  .replace(/\+/g, "-")
@@ -191,10 +204,3 @@ export async function pollForTokens(uuid, verifier, baseUrl = API_BASE, signal,
191
204
  }
192
205
  throw new AuthTimeoutError(`Poll timed out after ${maxAttempts} attempts (~5 min)`);
193
206
  }
194
- // ── Combined login (Mode C, one-shot) ──
195
- export async function loginWithBrowser(websiteUrl, apiBaseUrl, signal) {
196
- const params = generatePkceParams();
197
- const challenge = await generatePkceChallenge(params.verifier);
198
- const loginUrl = buildLoginUrl(challenge, params.uuid, websiteUrl);
199
- return await pollForTokens(params.uuid, params.verifier, apiBaseUrl, signal);
200
- }
@@ -27,11 +27,6 @@ export async function buildRequestContext(input) {
27
27
  const mcpServerNames = Object.keys(config.mcp ?? {});
28
28
  const flat = toolsToDescriptors(tools, providerIdentifier, mcpServerNames);
29
29
  const nested = toolsToMcpDescriptors(tools, providerIdentifier, mcpServerNames);
30
- const permission = config.permission;
31
- const autoRun = permission === "allow" ||
32
- (typeof permission === "object" &&
33
- permission !== null &&
34
- permission["*"] === "allow");
35
30
  const ctx = {
36
31
  env: buildEnv(workspaceRoot),
37
32
  tools: flat,
@@ -72,8 +67,6 @@ export async function buildRequestContext(input) {
72
67
  custom_subagents_info_complete: true,
73
68
  mcp_file_system_info_complete: true,
74
69
  mcp_info_complete: true,
75
- user_permissions_auto_run: autoRun,
76
- project_permissions_auto_run: autoRun,
77
70
  };
78
71
  if (plugins.length > 0) {
79
72
  ctx.hooks_additional_context = plugins
@@ -9,6 +9,10 @@ export type OpencodeJson = {
9
9
  plugins?: string[];
10
10
  mcp?: Record<string, unknown>;
11
11
  };
12
+ /** Same truthy rule as OpenCode's Flag.OPENCODE_DISABLE_PROJECT_CONFIG. */
13
+ export declare function isProjectConfigDisabled(): boolean;
14
+ /** Fetch a remote instruction with one deadline covering headers and body. */
15
+ export declare function fetchRemoteInstruction(url: string, timeoutMs?: number): Promise<string | undefined>;
12
16
  export declare function loadMergedConfig(workspaceRoot: string): Promise<OpencodeJson>;
13
17
  /**
14
18
  * Collect OpenCode instruction files.
@@ -129,8 +129,40 @@ async function expandGlob(pattern, workspaceRoot) {
129
129
  await walk(startDir, 0);
130
130
  return out;
131
131
  }
132
+ /** Same truthy rule as OpenCode's Flag.OPENCODE_DISABLE_PROJECT_CONFIG. */
133
+ export function isProjectConfigDisabled() {
134
+ const value = process.env.OPENCODE_DISABLE_PROJECT_CONFIG?.toLowerCase();
135
+ return value === "true" || value === "1";
136
+ }
137
+ /** Fetch a remote instruction with one deadline covering headers and body. */
138
+ export async function fetchRemoteInstruction(url, timeoutMs = 5000) {
139
+ const ctrl = new AbortController();
140
+ const timer = setTimeout(() => ctrl.abort(), Math.max(1, timeoutMs));
141
+ try {
142
+ const res = await fetch(url, { signal: ctrl.signal });
143
+ if (!res.ok)
144
+ return undefined;
145
+ return await res.text();
146
+ }
147
+ catch {
148
+ return undefined;
149
+ }
150
+ finally {
151
+ clearTimeout(timer);
152
+ }
153
+ }
132
154
  export async function loadMergedConfig(workspaceRoot) {
133
155
  const globalConfig = await readJsonConfig(opencodeGlobalConfigDir());
156
+ if (isProjectConfigDisabled()) {
157
+ return {
158
+ ...globalConfig,
159
+ instructions: [...(globalConfig.instructions ?? [])],
160
+ plugin: [...(globalConfig.plugin ?? [])],
161
+ plugins: [...(globalConfig.plugins ?? [])],
162
+ mcp: { ...(globalConfig.mcp ?? {}) },
163
+ permission: globalConfig.permission,
164
+ };
165
+ }
134
166
  const projectConfig = await readJsonConfig(workspaceRoot);
135
167
  return {
136
168
  ...globalConfig,
@@ -162,33 +194,37 @@ export async function collectRules(workspaceRoot) {
162
194
  seen.add(resolved);
163
195
  rules.push(rule);
164
196
  };
165
- for (const name of ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"]) {
166
- const hit = await findUp(name, workspaceRoot, worktree);
167
- if (hit) {
168
- await add(hit);
169
- break;
197
+ // Match OpenCode: OPENCODE_DISABLE_PROJECT_CONFIG skips project AGENTS/CLAUDE/CONTEXT
198
+ // discovery and project opencode.json (see loadMergedConfig).
199
+ if (!isProjectConfigDisabled()) {
200
+ for (const name of ["AGENTS.md", "CLAUDE.md", "CONTEXT.md"]) {
201
+ const hit = await findUp(name, workspaceRoot, worktree);
202
+ if (hit) {
203
+ await add(hit);
204
+ break;
205
+ }
170
206
  }
171
207
  }
172
208
  await add(path.join(opencodeGlobalConfigDir(), "AGENTS.md"));
173
209
  await add(path.join(homedir(), ".claude", "CLAUDE.md"));
174
210
  for (const raw of config.instructions ?? []) {
175
211
  if (raw.startsWith("http://") || raw.startsWith("https://")) {
212
+ // HTTPS-only. Redirects (incl. to a local proxy) are intentional — do not
213
+ // disable follow-redirects or reject localhost/private/metadata hosts.
214
+ let remoteUrl;
176
215
  try {
177
- const ctrl = new AbortController();
178
- const t = setTimeout(() => ctrl.abort(), 5000);
179
- const res = await fetch(raw, { signal: ctrl.signal });
180
- clearTimeout(t);
181
- if (!res.ok)
182
- continue;
183
- const content = await res.text();
184
- if (!content.trim() || seen.has(raw))
185
- continue;
186
- seen.add(raw);
187
- rules.push({ fullPath: raw, content });
216
+ remoteUrl = new URL(raw);
188
217
  }
189
218
  catch {
190
- /* ignore */
219
+ continue;
191
220
  }
221
+ if (remoteUrl.protocol !== "https:")
222
+ continue;
223
+ const content = await fetchRemoteInstruction(remoteUrl.href);
224
+ if (!content?.trim() || seen.has(raw))
225
+ continue;
226
+ seen.add(raw);
227
+ rules.push({ fullPath: raw, content });
192
228
  continue;
193
229
  }
194
230
  const expanded = resolveHomeRelative(raw);
package/dist/debug.d.ts CHANGED
@@ -1 +1,10 @@
1
+ /** Resolve the debug log path (env override or per-uid tmpdir default). */
2
+ export declare function resolveDebugLogPath(): string;
3
+ /**
4
+ * Ensure the log directory is 0o700 and create/truncate the log file as 0o600.
5
+ * Exported for tests; callers normally go through `trace`.
6
+ */
7
+ export declare function ensureSecureDebugLog(filePath: string, options?: {
8
+ secureParent?: boolean;
9
+ }): void;
1
10
  export declare function trace(msg: string): void;
package/dist/debug.js CHANGED
@@ -1,20 +1,60 @@
1
1
  import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
2
4
  // Wire-level diagnostics. Opt in with CURSOR_PROVIDER_DEBUG=1 (or "true").
3
- // Writes to CURSOR_PROVIDER_DEBUG_FILE (default /tmp/cursor-provider-debug.log).
5
+ // Default path mirrors Cursor CLI: $TMPDIR/cursor-provider-logs-<uid>/debug-<pid>.log
6
+ // with directory mode 0o700 and file mode 0o600. Override with CURSOR_PROVIDER_DEBUG_FILE.
4
7
  // Truncated once per process. Tokens / checksums should be redacted by callers.
5
8
  const DEBUG_ENABLED = process.env.CURSOR_PROVIDER_DEBUG === "1" ||
6
9
  process.env.CURSOR_PROVIDER_DEBUG === "true";
7
- const DEBUG_FILE = process.env.CURSOR_PROVIDER_DEBUG_FILE || "/tmp/cursor-provider-debug.log";
8
10
  let _traceInitialized = false;
11
+ let _debugFile;
12
+ let _debugFileUsesManagedDirectory = false;
13
+ /** Resolve the debug log path (env override or per-uid tmpdir default). */
14
+ export function resolveDebugLogPath() {
15
+ if (process.env.CURSOR_PROVIDER_DEBUG_FILE) {
16
+ return process.env.CURSOR_PROVIDER_DEBUG_FILE;
17
+ }
18
+ const uid = typeof process.getuid === "function" ? process.getuid() : process.pid;
19
+ return path.join(os.tmpdir(), `cursor-provider-logs-${uid}`, `debug-${process.pid}.log`);
20
+ }
21
+ /**
22
+ * Ensure the log directory is 0o700 and create/truncate the log file as 0o600.
23
+ * Exported for tests; callers normally go through `trace`.
24
+ */
25
+ export function ensureSecureDebugLog(filePath, options = {}) {
26
+ const dir = path.dirname(filePath);
27
+ const secureParent = options.secureParent ?? true;
28
+ fs.mkdirSync(dir, secureParent
29
+ ? { recursive: true, mode: 0o700 }
30
+ : { recursive: true });
31
+ if (secureParent) {
32
+ const stat = fs.lstatSync(dir);
33
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
34
+ throw new Error(`Debug log directory is not a real directory: ${dir}`);
35
+ }
36
+ if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
37
+ throw new Error(`Debug log directory is not owned by the current user: ${dir}`);
38
+ }
39
+ fs.chmodSync(dir, 0o700);
40
+ }
41
+ fs.writeFileSync(filePath, "", { mode: 0o600 });
42
+ fs.chmodSync(filePath, 0o600);
43
+ }
9
44
  export function trace(msg) {
10
45
  if (!DEBUG_ENABLED)
11
46
  return;
12
47
  try {
48
+ if (!_debugFile) {
49
+ _debugFileUsesManagedDirectory = !process.env.CURSOR_PROVIDER_DEBUG_FILE;
50
+ _debugFile = resolveDebugLogPath();
51
+ }
13
52
  if (!_traceInitialized) {
53
+ ensureSecureDebugLog(_debugFile, { secureParent: _debugFileUsesManagedDirectory });
54
+ fs.writeFileSync(_debugFile, `--- cursor-provider debug (pid ${process.pid}) ${new Date().toISOString()} ---\n`, { mode: 0o600 });
14
55
  _traceInitialized = true;
15
- fs.writeFileSync(DEBUG_FILE, `--- cursor-provider debug (pid ${process.pid}) ${new Date().toISOString()} ---\n`);
16
56
  }
17
- fs.appendFileSync(DEBUG_FILE, `[${new Date().toISOString()}] ${msg}\n`);
57
+ fs.appendFileSync(_debugFile, `[${new Date().toISOString()}] ${msg}\n`);
18
58
  }
19
59
  catch {
20
60
  /* ignore */
@@ -0,0 +1,59 @@
1
+ export type CursorErrorOrigin = "local-cancel" | "transport" | "server" | "protocol" | "auth";
2
+ export type CursorErrorDiagnostics = {
3
+ statusCode?: number;
4
+ grpcStatus?: number | string;
5
+ rstCode?: number;
6
+ code?: string;
7
+ retryAfterMs?: number;
8
+ };
9
+ export type CursorProviderErrorOptions = CursorErrorDiagnostics & {
10
+ origin: CursorErrorOrigin;
11
+ transient: boolean;
12
+ replaySafe: boolean;
13
+ cause?: unknown;
14
+ };
15
+ /** Structured provider failure with retry and transport diagnostics. */
16
+ export declare class CursorProviderError extends Error {
17
+ readonly origin: CursorErrorOrigin;
18
+ readonly transient: boolean;
19
+ replaySafe: boolean;
20
+ readonly statusCode?: number;
21
+ readonly grpcStatus?: number | string;
22
+ readonly rstCode?: number;
23
+ readonly code?: string;
24
+ readonly retryAfterMs?: number;
25
+ constructor(message: string, options: CursorProviderErrorOptions);
26
+ }
27
+ export declare class CursorLocalCancellationError extends CursorProviderError {
28
+ constructor(message?: string, cause?: unknown);
29
+ }
30
+ export declare class CursorTransportError extends CursorProviderError {
31
+ constructor(message: string, options?: Omit<CursorProviderErrorOptions, "origin">);
32
+ }
33
+ export declare class CursorServerError extends CursorProviderError {
34
+ constructor(message: string, options: Omit<CursorProviderErrorOptions, "origin">);
35
+ }
36
+ export declare class CursorProtocolError extends CursorProviderError {
37
+ constructor(message: string, options?: Partial<CursorErrorDiagnostics> & {
38
+ cause?: unknown;
39
+ });
40
+ }
41
+ export declare class CursorAuthError extends CursorProviderError {
42
+ constructor(message?: string, options?: Partial<CursorErrorDiagnostics> & {
43
+ cause?: unknown;
44
+ });
45
+ }
46
+ export declare class CursorRetryExhaustedError extends CursorProviderError {
47
+ readonly attempts: number;
48
+ constructor(attempts: number, last: CursorProviderError);
49
+ }
50
+ export declare function isTransientGrpcStatus(status: number | string): boolean;
51
+ export declare function isAuthGrpcStatus(status: number | string): boolean;
52
+ export declare function cursorHttpError(operation: string, statusCode: number, diagnostics?: Omit<CursorErrorDiagnostics, "statusCode">): CursorProviderError;
53
+ export declare function cursorGrpcError(operation: string, grpcStatus: number | string, diagnostics?: Omit<CursorErrorDiagnostics, "grpcStatus">): CursorProviderError;
54
+ export declare function errorCode(error: unknown): string | undefined;
55
+ export declare function toCursorProviderError(error: unknown, options?: {
56
+ replaySafe: boolean;
57
+ fallback?: string;
58
+ }): CursorProviderError;
59
+ export declare function retrySuppressedError(cause: CursorProviderError, reason: string, attempt: number, maxAttempts: number): CursorProviderError;