cursor-opencode-provider 0.2.4 → 0.2.6

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
@@ -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.4"`.
50
+ Pin a version if you want: `"cursor-opencode-provider@0.2.6"`.
51
51
 
52
52
  ### From a local clone
53
53
 
@@ -165,7 +165,7 @@ const model = cursor.languageModel("composer-2.5")
165
165
  // model implements AI SDK LanguageModelV3 (doStream / doGenerate)
166
166
  ```
167
167
 
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`.
168
+ Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-...` key). Optional: `apiBaseURL`, `agentBaseURL`, `headers`, `telemetryEnabled`, `retry`, and `continuation`. Transient failures resume from the latest checkpoint produced by that Run, matching Cursor CLI; without an eligible checkpoint, retries remain limited to replay-safe attempts so completed text or tool work is not duplicated. 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`.
169
169
 
170
170
  ## Environment variables
171
171
 
@@ -175,7 +175,7 @@ Pass either `accessToken` (JWT from OAuth or key exchange) or `apiKey` (raw `sk-
175
175
  | `CURSOR_API_BASE_URL` | Override API base for auth, model discovery, and `GetServerConfig` agent URL resolution (default `https://api2.cursor.sh`) |
176
176
  | `CURSOR_GET_SERVER_CONFIG_TELEMETRY` | Set to `1` or `true` to opt the `GetServerConfig` lookup into telemetry in OpenCode/plugin usage |
177
177
  | `CURSOR_PROVIDER_DEBUG` | Set to `1` or `true` to enable wire-level debug logging |
178
- | `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>/`) |
179
179
  | `XDG_CACHE_HOME` | When set, model/version caches go under `$XDG_CACHE_HOME/opencode/` instead of `~/.cache/opencode/` |
180
180
  | `XDG_DATA_HOME` | When set, OpenCode `auth.json` is read from `$XDG_DATA_HOME/opencode/` instead of `~/.local/share/opencode/` |
181
181
 
@@ -219,7 +219,7 @@ OpenCode
219
219
 
220
220
  ### Injected system guidance
221
221
 
222
- The provider adds OpenCode-specific system guidance to normal tool-capable conversations, including tool availability and canonical workspace-path grounding. Compaction keeps its dedicated prompt unchanged.
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
223
 
224
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
225
 
@@ -242,11 +242,15 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader.
242
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. |
243
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. |
244
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. |
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. |
245
+ | Stream hangs or HTTP/2 errors | The provider keeps Cursor's Run open across OpenCode tool calls, rotates aged shared connections, resumes transient interruptions from the latest eligible Cursor checkpoint, and falls back to a fresh-history rebase only before stateful output when no checkpoint exists. 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`, `resuming checkpoint`, or `rebasing fresh Run`. Restart OpenCode after rebuilding a local `file://` install. |
246
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
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
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`, default `/tmp/cursor-provider-debug.log`) and reproduce the issue. |
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`.
250
254
 
251
255
  ## Known limitations
252
256
 
@@ -255,13 +259,13 @@ The package root intentionally stays plugin-safe for OpenCode's classic loader.
255
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.
256
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.
257
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.
258
- - **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.
259
- - **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.
260
- - **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.
262
+ - **Background shells are non-interactive** — Cursor's native background-shell spawn and soft-background shell-stream timeouts are bridged through OpenCode's foreground-only `bash` tool. The OpenCode UI keeps the original command; wrapping happens via the classic plugin's `shell.env` injectors (`BASH_ENV` / `ZDOTDIR`) so permissions still see the real user command. Spawn/soft-bg wrappers detach with `nohup`, redirect output under `${TMPDIR:-/tmp}/cursor-opencode-{bg,shell}.*`, and return the real PID (or typed timeout/exit) to Cursor. Private markers and OpenCode's `<shell_metadata>` envelope are stripped before storage/render, with a short still-running / started / timed-out status line left for the bash bubble. 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. The POSIX wrap path is not implemented for native Windows PowerShell/`cmd`.
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.
261
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.
262
- - **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 complete OpenCode prompt (including trailing tool results and the live user request), 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.
266
+ - **Interrupted Runs resume from checkpoints** — a remote EOF, Connect end-stream, or trailer error is never emitted as a successful `stop`. When the failed Run produced an eligible checkpoint, the provider opens a new RPC for the same conversation and sends that state with `ResumeAction`, so completed text and tool work are not replayed. Before any stateful output, an interruption without a checkpoint can still rebase from OpenCode history. Stateful interruptions without a checkpoint are surfaced because replay would be ambiguous; retry exhaustion remains explicit. A transport closure after `turn_ended` is treated as successful completion.
263
267
  - **No fallback models** — if Cursor’s `AvailableModels` API is unreachable and there is no local cache, the provider exposes no models.
264
268
 
265
269
  ## License
266
270
 
267
- 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.
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
- }
@@ -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 */
@@ -20,10 +20,17 @@ export declare function pumpWithRecovery(input: {
20
20
  abortSignal?: AbortSignal;
21
21
  promptTokens?: number;
22
22
  retryPolicy?: CursorRetryPolicy;
23
- recover: () => Promise<CursorSession>;
23
+ recover: (recovery: CursorRunRecovery) => Promise<CursorSession>;
24
24
  onSession?: (session: CursorSession) => void;
25
25
  maxRecoveries?: number;
26
26
  }): Promise<CursorSession>;
27
+ export type CursorRunRecovery = {
28
+ kind: "rebase";
29
+ } | {
30
+ kind: "resume";
31
+ conversationId: string;
32
+ checkpoint: Uint8Array;
33
+ };
27
34
  /**
28
35
  * OpenCode re-sends the full tool-result history on every continuation. Prefer
29
36
  * the newest result that still has a live pending exec on its tagged session.
@@ -50,6 +57,9 @@ export declare function pump(session: CursorSession, controller: ReadableStreamD
50
57
  textId: string;
51
58
  reasoningId: string;
52
59
  promptTokens?: number;
60
+ requestUsage?: {
61
+ outputChars: number;
62
+ };
53
63
  }, abortSignal?: AbortSignal): Promise<void>;
54
64
  type ExtractedToolResult = {
55
65
  toolCallId: string;
@@ -87,12 +97,16 @@ export declare function cursorTurnEndedProviderMetadata(te: Record<string, unkno
87
97
  };
88
98
  };
89
99
  /**
90
- * Prior prompt turns for a seed ConversationStateStructure after compaction
91
- * reset. Drops the trailing user message (live action), but preserves tool
92
- * result payloads as labeled assistant observations for Cursor's text history.
100
+ * Prior prompt turns for a seed ConversationStateStructure. Tool results must
101
+ * never be replayed as assistant-authored prose: that teaches the model to
102
+ * counterfeit `Tool result (...)` text instead of emitting a real tool call.
103
+ * Normal rebases omit old results; compaction can retain all results and
104
+ * interrupted continuations retain only the trailing live result suffix as
105
+ * explicit OpenCode-host observations.
93
106
  */
94
107
  export declare function extractPromptHistory(prompt: LanguageModelV3CallOptions["prompt"], options?: {
95
108
  preserveTrailingUser?: boolean;
109
+ toolResults?: "omit" | "all" | "trailing";
96
110
  }): SeedHistoryMessage[];
97
111
  /** OpenCode session id header, if present. */
98
112
  export declare function opencodeSessionKey(callOptions: LanguageModelV3CallOptions): string | undefined;