From 78e4fb55bb7f550c263812dce0422586d032ee4f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 22 Sep 2026 19:53:37 +0900 Subject: [PATCH 1/2] fix(cursor): bound installed capability reads and refresh cached version Carries lidge-jun/opencodex#5233 at 9b3a5db3131a65ab8a3e7626c830c41e517c1218, including the outdated review finding fixed by its version-refresh follow-up. Sources: 6d2fdc430a0475667bdb13c9fd87cad7596bed78 and 9b3a5db3131a65ab8a3e7626c830c41e517c1218. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> (cherry picked from commit 1ec4729ffb4265ee36b9665973879149fc1e55cb) --- src/integrations/cursor-effort-table.ts | 63 +++++++++++++++---- structure/clients/integrations.md | 8 +++ .../cursor/cursor-effort-table.test.ts | 48 +++++++++++--- 3 files changed, 98 insertions(+), 21 deletions(-) diff --git a/src/integrations/cursor-effort-table.ts b/src/integrations/cursor-effort-table.ts index bca6a53c305..b69de7dfef6 100644 --- a/src/integrations/cursor-effort-table.ts +++ b/src/integrations/cursor-effort-table.ts @@ -8,7 +8,7 @@ * instead of a hand-copied mirror. Read-only, size-bounded, cached by (path, mtime, size); * any parse failure yields null so the caller falls back to the static mirror. */ -import { readFileSync, statSync } from "node:fs"; +import { closeSync, constants, fstatSync, openSync, readSync } from "node:fs"; import { join } from "node:path"; import type { CursorInstall } from "./cursor-detect"; @@ -111,32 +111,69 @@ function splitStrings(list: string): string[] { export interface CursorEffortTableDeps { platform: string; - stat(path: string): { mtimeMs: number; size: number } | null; - readText(path: string): string | null; + readBundle(path: string, cached?: { mtimeMs: number; size: number }): { mtimeMs: number; size: number; text: string | null } | null; } export function realCursorEffortTableDeps(): CursorEffortTableDeps { return { platform: process.platform, - stat: path => { try { const s = statSync(path); return { mtimeMs: s.mtimeMs, size: s.size }; } catch { return null; } }, - readText: path => { try { return readFileSync(path, "utf8"); } catch { return null; } }, + readBundle: readCursorBundle, }; } -let cache: { key: string; table: CursorEffortTable | null } | null = null; +function readCursorBundle(path: string, cached?: { mtimeMs: number; size: number }): { mtimeMs: number; size: number; text: string | null } | null { + let fd: number | null = null; + try { + // O_NOFOLLOW binds the validation and read to the same regular file. O_NONBLOCK + // keeps opening a substituted special file from stalling before fstat rejects it. + fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); + const stat = fstatSync(fd); + if (!stat.isFile() || stat.size > BUNDLE_MAX_BYTES) return null; + if (cached?.mtimeMs === stat.mtimeMs && cached.size === stat.size) { + return { mtimeMs: stat.mtimeMs, size: stat.size, text: null }; + } + + const chunks: Buffer[] = []; + let size = 0; + while (size <= BUNDLE_MAX_BYTES) { + const chunk = Buffer.allocUnsafe(Math.min(64 * 1024, BUNDLE_MAX_BYTES + 1 - size)); + const bytesRead = readSync(fd, chunk, 0, chunk.length, null); + if (bytesRead === 0) break; + chunks.push(chunk.subarray(0, bytesRead)); + size += bytesRead; + } + if (size > BUNDLE_MAX_BYTES) return null; + return { mtimeMs: stat.mtimeMs, size, text: Buffer.concat(chunks, size).toString("utf8") }; + } catch { + return null; + } finally { + if (fd !== null) closeSync(fd); + } +} + +let cache: { key: string; path: string; mtimeMs: number; size: number; table: CursorEffortTable | null } | null = null; /** Table from the Private Inference install, else null (caller falls back to the static mirror). */ export function loadCursorEffortTable(install: CursorInstall | undefined, deps: CursorEffortTableDeps = realCursorEffortTableDeps()): CursorEffortTable | null { if (!install) return null; const bundlePath = cursorAgentBundlePath(install, deps.platform); - const st = deps.stat(bundlePath); - if (!st || st.size > BUNDLE_MAX_BYTES) return null; - const key = `${bundlePath}|${st.mtimeMs}|${st.size}`; - if (cache?.key === key) return cache.table; - const text = deps.readText(bundlePath); - const parsed = text ? parseCursorEffortTable(text) : null; + const cachedMetadata = cache?.path === bundlePath + ? { mtimeMs: cache.mtimeMs, size: cache.size } + : undefined; + const bundle = deps.readBundle(bundlePath, cachedMetadata); + if (!bundle) return null; + const key = `${bundlePath}|${bundle.mtimeMs}|${bundle.size}`; + if (cache?.key === key) { + // The cache key covers bundle identity only; install.version comes from + // product.json and can change or resolve without touching the bundle. + const cached = cache.table; + return cached && cached.version !== install.version + ? { ...cached, version: install.version } + : cached; + } + const parsed = bundle.text ? parseCursorEffortTable(bundle.text) : null; const table = parsed ? { ...parsed, version: install.version, bundlePath } : null; - cache = { key, table }; + cache = { key, path: bundlePath, mtimeMs: bundle.mtimeMs, size: bundle.size, table }; return table; } diff --git a/structure/clients/integrations.md b/structure/clients/integrations.md index 277bc619912..92da578aa76 100644 --- a/structure/clients/integrations.md +++ b/structure/clients/integrations.md @@ -29,6 +29,14 @@ parsing and ownership rules below. | `src/integrations/mutation-plan.ts` | The shared observation both a preview and a mutation read, and the value-free plan an operator confirms. It owns no IO of its own, takes no lock, and must never import `writer.ts`. | | `src/integrations/store.ts` / `journal.ts` | One-root persistence for ownership records, operation history, snapshots, and retention maintenance. | +## Cursor installed capability reads + +`src/integrations/cursor-effort-table.ts` reads the installed agent bundle through one regular-file +handle, refuses final symlinks where supported, and caps bytes read even if the file grows after +inspection. Failure retains the static-table fallback. Parsed content is cached by path, mtime and +size; the returned table always uses the current install version, including on a cache hit. +`tests/providers/cursor/cursor-effort-table.test.ts` covers cache reuse, version refresh and unsafe files. + ## Data Flow ```text diff --git a/tests/providers/cursor/cursor-effort-table.test.ts b/tests/providers/cursor/cursor-effort-table.test.ts index 973941d5744..828c3bec5cf 100644 --- a/tests/providers/cursor/cursor-effort-table.test.ts +++ b/tests/providers/cursor/cursor-effort-table.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join } from "node:path"; import { + cursorAgentBundlePath, loadCursorEffortTable, parseCursorEffortTable, resetCursorEffortTableCacheForTests, @@ -65,15 +68,13 @@ describe("Cursor installed-bundle effort table", () => { test("activates the static fallback for missing installs, missing literals, and malformed regexes", () => { const missingStat: CursorEffortTableDeps = { platform: "darwin", - stat: () => null, - readText: () => { throw new Error("readText must not run without a stat"); }, + readBundle: () => null, }; expect(loadCursorEffortTable(INSTALL, missingStat)).toBeNull(); const loadSource = (source: string, mtimeMs: number) => loadCursorEffortTable(INSTALL, { platform: "darwin", - stat: () => ({ mtimeMs, size: source.length }), - readText: () => source, + readBundle: () => ({ mtimeMs, size: source.length, text: source }), }); expect(loadSource("function unrelated(){}", 1)).toBeNull(); expect(loadSource(FIXTURE.replace("/^claude-opus-5$/u", "/[/u"), 2)).toBeNull(); @@ -102,10 +103,12 @@ describe("Cursor installed-bundle effort table", () => { let reads = 0; const deps: CursorEffortTableDeps = { platform: "darwin", - stat: () => ({ mtimeMs, size: FIXTURE.length }), - readText: () => { + readBundle: (_path, cached) => { + if (cached?.mtimeMs === mtimeMs && cached.size === FIXTURE.length) { + return { ...cached, text: null }; + } reads += 1; - return FIXTURE; + return { mtimeMs, size: FIXTURE.length, text: FIXTURE }; }, }; expect(loadCursorEffortTable(INSTALL, deps)?.families).toHaveLength(16); @@ -115,4 +118,33 @@ describe("Cursor installed-bundle effort table", () => { expect(loadCursorEffortTable(INSTALL, deps)?.families).toHaveLength(16); expect(reads).toBe(2); }); + + test("refreshes the reported version on a bundle cache hit", () => { + const deps: CursorEffortTableDeps = { + platform: "darwin", + readBundle: () => ({ mtimeMs: 1, size: FIXTURE.length, text: FIXTURE }), + }; + expect(loadCursorEffortTable(INSTALL, deps)?.version).toBe("3.18.25"); + const upgraded = { ...INSTALL, version: "3.19.0" }; + expect(loadCursorEffortTable(upgraded, deps)?.version).toBe("3.19.0"); + }); + + test("rejects symlinks and special files without blocking", () => { + if (process.platform === "win32") return; + const root = `${tmpdir()}/ocx-cursor-bundle-${process.pid}-${Date.now()}`; + const install = { ...INSTALL, path: root }; + const bundlePath = cursorAgentBundlePath(install, process.platform); + const target = `${root}/target.js`; + mkdirSync(bundlePath.slice(0, bundlePath.lastIndexOf("/")), { recursive: true }); + writeFileSync(target, FIXTURE); + try { + symlinkSync(target, bundlePath); + expect(loadCursorEffortTable(install)).toBeNull(); + rmSync(bundlePath); + execFileSync("mkfifo", [bundlePath]); + expect(loadCursorEffortTable(install)).toBeNull(); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); From 799ebc5d5d6a8c75eeb99f358db5c64906117584 Mon Sep 17 00:00:00 2001 From: Epinephrine Date: Sun, 20 Sep 2026 15:17:39 +0900 Subject: [PATCH 2/2] fix(cursor): budget buffered textual tool calls (cherry picked from commit 755d50b139f7ecda576e0ce5617ac8b29a419418) (cherry picked from commit 2a53a5772edcf14d798a213ef25b2705bcf9fe2c) --- src/adapters/cursor/protobuf-events.ts | 52 ++++++++++++++----- structure/providers/cursor.md | 5 +- .../cursor/cursor-protobuf-events.test.ts | 28 +++++++++- 3 files changed, 70 insertions(+), 15 deletions(-) diff --git a/src/adapters/cursor/protobuf-events.ts b/src/adapters/cursor/protobuf-events.ts index 1bc771a0098..c10c3f20ad1 100644 --- a/src/adapters/cursor/protobuf-events.ts +++ b/src/adapters/cursor/protobuf-events.ts @@ -189,8 +189,8 @@ export interface CursorProtobufEventState { pendingTextToolCall?: string; /** Constant-space scanner used after an incomplete textual marker exceeds its retained byte cap. */ suppressedTextToolCall?: SuppressedTextToolCallScan; - /** Parsed textual fallback calls held until turn finalization establishes that no real frame won. */ - bufferedTextToolCalls?: DrainedTextToolCall[]; + /** Budgeted textual fallback calls held until turn finalization establishes that no real frame won. */ + bufferedTextToolCalls?: Array; /** True once this turn carries any real client-tool frame, including an incomplete one. */ sawRealClientToolCall?: boolean; /** Monotonic id suffix for tool calls promoted from text markers. */ @@ -1077,7 +1077,10 @@ export function mapSyntheticMcpExecToToolEvents( ): CursorServerMessage[] { if (args.providerIdentifier !== OCX_RESPONSES_TOOL_PROVIDER) return []; if (options.state?.terminated) return []; - if (options.state) options.state.sawRealClientToolCall = true; + if (options.state) { + discardBufferedTextToolCalls(options.state); + options.state.sawRealClientToolCall = true; + } if (options.allowEmptyArgs !== true && !hasMcpArgBytes(args)) return []; const cursorWireName = mcpWireNameFromArgs(args); if (!cursorWireName) return [{ type: "error", message: "Cursor requested a Responses tool without a tool name" }]; @@ -1148,10 +1151,16 @@ function recordToolCall(state: CursorProtobufEventState, callId: string, cursorW } function recordRealToolCall(state: CursorProtobufEventState, callId: string, cursorWireName: string): CursorServerMessage[] { + discardBufferedTextToolCalls(state); state.sawRealClientToolCall = true; return recordToolCall(state, callId, cursorWireName); } +function discardBufferedTextToolCalls(state: CursorProtobufEventState): void { + for (const call of state.bufferedTextToolCalls ?? []) state.translatorBudget?.closeCall(call.callId); + delete state.bufferedTextToolCalls; +} + /** * Emit a completed client tool call as one atomic unit: `tool_call_start` (deferred from open time), * the full normalized arguments delta when present, then `tool_call_end`. The call must already be @@ -1313,10 +1322,21 @@ export function mapCursorProtobufServerMessage( || !advertised || (state.bufferedTextToolCalls?.length ?? 0) >= state.maxClientToolCalls ) continue; - (state.bufferedTextToolCalls ??= []).push({ - name: advertised, - args: normalizeJsonText(call.args, advertised, state), - }); + const args = normalizeJsonText(call.args, advertised, state); + state.textToolCallSeq = (state.textToolCallSeq ?? 0) + 1; + const callId = `textcall_${state.textToolCallSeq}`; + state.translatorBudget?.openCall(callId); + try { + const reservation = state.translatorBudget?.reserveTransient( + Buffer.byteLength(args), + { kind: "tool_args", callId }, + ); + reservation?.commitRetained(); + (state.bufferedTextToolCalls ??= []).push({ name: advertised, args, callId }); + } catch (error) { + state.translatorBudget?.closeCall(callId); + throw error; + } } return out; } @@ -1346,7 +1366,10 @@ export function mapCursorProtobufServerMessage( const out: CursorServerMessage[] = []; if (state.completedToolCalls.has(update.value.callId)) return []; const name = mcpCursorWireName(update.value.toolCall); - if (name) state.sawRealClientToolCall = true; + if (name) { + discardBufferedTextToolCalls(state); + state.sawRealClientToolCall = true; + } const args = mcpArgsFromToolCall(update.value.toolCall); const openBeforeStart = state.openToolCalls.get(update.value.callId); // Empty-arg completion handling: @@ -1454,6 +1477,7 @@ export function finalizeTurnEvents(state: CursorProtobufEventState): CursorServe const bufferedTextToolCalls = state.bufferedTextToolCalls ?? []; delete state.bufferedTextToolCalls; if (state.openToolCalls.size > 0) { + for (const call of bufferedTextToolCalls) state.translatorBudget?.closeCall(call.callId); const openCallIds = [...state.openToolCalls.keys()]; const openIds = openCallIds.join(", "); // Clear so a second turnEnded (should not happen, but defensive) doesn't re-emit. @@ -1464,11 +1488,15 @@ export function finalizeTurnEvents(state: CursorProtobufEventState): CursorServe const out: CursorServerMessage[] = []; if (!state.sawRealClientToolCall) { for (const call of bufferedTextToolCalls) { - state.textToolCallSeq = (state.textToolCallSeq ?? 0) + 1; - const callId = `textcall_${state.textToolCallSeq}`; - out.push(...recordToolCall(state, callId, call.name)); - if (state.openToolCalls.has(callId)) out.push(...commitToolCall(state, callId, call.args)); + out.push(...recordToolCall(state, call.callId, call.name)); + const open = state.openToolCalls.get(call.callId); + if (open) { + open.args = call.args; + out.push(...commitToolCall(state, call.callId, call.args)); + } else state.translatorBudget?.closeCall(call.callId); } + } else { + for (const call of bufferedTextToolCalls) state.translatorBudget?.closeCall(call.callId); } // Surface the absolute context size (when Cursor reported a checkpoint) as both totalTokens and // the estimated input side of Codex's visible `input + output` counter. Codex status lines can diff --git a/structure/providers/cursor.md b/structure/providers/cursor.md index 7252130843a..c007749f5ef 100644 --- a/structure/providers/cursor.md +++ b/structure/providers/cursor.md @@ -159,9 +159,10 @@ markers up to a byte-counted cap, then switches to a constant-space suppressed s the JSON object closes; neither an oversized tail nor a malformed payload returns to prose. Malformed argument diagnostics contain only the failure class and an optional tool name, never the argument content. `src/adapters/cursor/protobuf-events.ts` buffers advertised textual calls -until turn finalization. It flushes them onto the atomic tool-call path only when the turn +until turn finalization, charging each retained argument immediately against the normal per-call +and per-turn translator budgets. It flushes them onto the atomic tool-call path only when the turn contained no real client-tool frame; any real frame, including one left incomplete, wins and -drops the whole textual buffer. A missing advertised-name set is fail-closed. Finalize also +drops the whole textual buffer and releases its charges. A missing advertised-name set is fail-closed. Finalize also clears any held or suppressed prefix. Coverage lives in `tests/providers/cursor/cursor-protobuf-events.test.ts`. diff --git a/tests/providers/cursor/cursor-protobuf-events.test.ts b/tests/providers/cursor/cursor-protobuf-events.test.ts index 4dae165c066..3c3b2e2eaf1 100644 --- a/tests/providers/cursor/cursor-protobuf-events.test.ts +++ b/tests/providers/cursor/cursor-protobuf-events.test.ts @@ -1257,7 +1257,8 @@ describe("textual pseudo tool-call marker quarantine", () => { }); test("a real frame wins over a textual echo in the same turn", () => { - const state = createCursorProtobufEventState({ clientToolNames: ["grep"] }); + const budget = createTranslatorBudget(); + const state = createCursorProtobufEventState({ clientToolNames: ["grep"], translatorBudget: budget }); expect(mapCursorProtobufServerMessage( textDelta('[TOOL_CALL]grep[ARGS]{"pattern":"echo"}'), state, @@ -1272,6 +1273,31 @@ describe("textual pseudo tool-call marker quarantine", () => { { type: "tool_call_start", id: "call_1", name: "grep" }, ]); expect(events.some(event => event.type === "tool_call_delta" && event.arguments.includes("echo"))).toBe(false); + expect(budget.snapshot().currentBytes).toBe(0); + budget.dispose(); + }); + + test("complete textual fallbacks are budgeted before they are retained", () => { + const budget = createTranslatorBudget({ maxCallArgumentBytes: 32, maxTurnBytes: 40 }); + const state = createCursorProtobufEventState({ clientToolNames: ["grep"], translatorBudget: budget }); + try { + expect(() => mapCursorProtobufServerMessage( + textDelta(`[TOOL_CALL]grep[ARGS]{"pattern":"${"x".repeat(40)}"}`), + state, + )).toThrow("translator tool_args buffer exceeded 32 bytes"); + expect(state.bufferedTextToolCalls).toBeUndefined(); + expect(budget.snapshot().currentBytes).toBe(0); + + mapCursorProtobufServerMessage(textDelta('[TOOL_CALL]grep[ARGS]{"pattern":"12345678"}'), state); + expect(budget.snapshot().currentBytes).toBeGreaterThan(0); + expect(() => mapCursorProtobufServerMessage( + textDelta('[TOOL_CALL]grep[ARGS]{"pattern":"abcdefgh"}'), + state, + )).toThrow("translator tool_args buffer exceeded 40 bytes"); + expect(state.bufferedTextToolCalls).toHaveLength(1); + } finally { + budget.dispose(); + } }); test("a split marker is dropped when an incomplete real frame appears", () => {