From 7a9b388bce4c313a96953e5a7e41f2fbc347f67f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:41:51 -0700 Subject: [PATCH] fix(ai-vault): harden Cursor scope and read bounds --- .../session-scanner-cursor-local-files.ts | 4 +- .../session-scanner-cursor-local-pipeline.ts | 26 ++++-- .../session-scanner-cursor-sources.ts | 53 +++++++++-- ...sion-scanner-cursor-storage-budget.test.ts | 92 +++++++++++++++++++ ...-scanner-cursor-wsl-scope-realpath.test.ts | 57 ++++++++++++ src/main/ai-vault/session-scanner.ts | 3 +- src/shared/cursor-sidecar-scan-directory.ts | 22 ++++- src/shared/cursor-sidecar-scan-discovery.ts | 20 ++-- 8 files changed, 244 insertions(+), 33 deletions(-) create mode 100644 src/main/ai-vault/session-scanner-cursor-wsl-scope-realpath.test.ts diff --git a/src/main/ai-vault/session-scanner-cursor-local-files.ts b/src/main/ai-vault/session-scanner-cursor-local-files.ts index badd1b81ca9..324a9057a8c 100644 --- a/src/main/ai-vault/session-scanner-cursor-local-files.ts +++ b/src/main/ai-vault/session-scanner-cursor-local-files.ts @@ -40,6 +40,7 @@ export async function discoverLocalCursorSidecarsBounded(args: { issues: AiVaultScanIssue[] signal?: AbortSignal pathPlatform?: NodeJS.Platform + resolveScopePaths?: (scopePath: string) => Promise }): Promise { const response = emptyLocalScanResponse() // Pass the full scope list so discovery can mark truncation before capping. @@ -57,7 +58,8 @@ export async function discoverLocalCursorSidecarsBounded(args: { caps: LOCAL_CAPS, response, cancellation: cursorSidecarScanCancellationFromSignal(args.signal), - pathPlatform: args.pathPlatform + pathPlatform: args.pathPlatform, + resolveScopePaths: args.resolveScopePaths }) for (const issue of response.issues) { diff --git a/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts b/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts index ad38c199db4..05ff26c7240 100644 --- a/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts +++ b/src/main/ai-vault/session-scanner-cursor-local-pipeline.ts @@ -1,4 +1,4 @@ -import type { AiVaultScanIssue } from '../../shared/ai-vault-types' +import { isAiVaultScanCancelledError, type AiVaultScanIssue } from '../../shared/ai-vault-types' import type { ExecutionHostId } from '../../shared/execution-host' import { CURSOR_REMOTE_MAX_AGGREGATE_BYTES, @@ -16,6 +16,7 @@ import { import { parseCursorSidecarFileCached } from './session-scanner-cursor-sidecar' import type { SessionFileCandidate, SessionFileDiscovery } from './session-scanner-types' import { errorMessage } from './session-scanner-values' +import { throwIfAiVaultScanCancelled } from './ai-vault-scan-cancellation' import { buildCursorCandidateSelectionGroups, canStopCursorGroupSelection, @@ -35,6 +36,7 @@ export async function processLocalCursorCandidates(args: { parseStats: SessionParseStats span: ActiveSpan discoveries?: readonly SessionFileDiscovery[] + signal?: AbortSignal }): Promise> { const groups = buildCursorCandidateSelectionGroups({ candidates: args.candidates, @@ -165,6 +167,7 @@ async function parseCursorGroups( executionHostId: ExecutionHostId issues: AiVaultScanIssue[] parseStats: SessionParseStats + signal?: AbortSignal }, verifiedReads: VerifiedReadTracker ): Promise { @@ -185,6 +188,7 @@ async function parseCursorCandidate( executionHostId: ExecutionHostId issues: AiVaultScanIssue[] parseStats: SessionParseStats + signal?: AbortSignal }, verifiedReads: VerifiedReadTracker ): Promise { @@ -204,7 +208,7 @@ async function parseCursorCandidate( } : null } catch (error) { - if (isCursorSidecarScanCancelledError(error)) { + if (isCursorSidecarScanCancelledError(error) || isAiVaultScanCancelledError(error)) { throw error } args.issues.push({ @@ -223,6 +227,7 @@ async function parseSidecarWithAggregateCap( platform: NodeJS.Platform executionHostId: ExecutionHostId issues: AiVaultScanIssue[] + signal?: AbortSignal }, verifiedReads: VerifiedReadTracker ): Promise { @@ -237,6 +242,7 @@ async function parseSidecarWithAggregateCap( // Gate covers reserve → verified read → commit of actual returned bytes. return withVerifiedReadGate(budget, async () => { + throwIfAiVaultScanCancelled(args.signal) if (budget.returnedBytes >= CURSOR_REMOTE_MAX_AGGREGATE_BYTES) { if (storage) { storage.truncated.sidecarBytes = true @@ -263,18 +269,18 @@ async function parseSidecarWithAggregateCap( if (storage) { // Count the logical verified-read work even when the payload is dropped. storage.counters.boundedReads += 1 - } - if (budget.returnedBytes + readBytes > CURSOR_REMOTE_MAX_AGGREGATE_BYTES) { - if (storage) { - storage.truncated.sidecarBytes = true - } - return null - } - if (storage) { storage.counters.returnedBytes += readBytes } budget.returnedBytes += readBytes + if (budget.returnedBytes > CURSOR_REMOTE_MAX_AGGREGATE_BYTES) { + if (storage) { + storage.truncated.sidecarBytes = true + } + throwIfAiVaultScanCancelled(args.signal) + return null + } } + throwIfAiVaultScanCancelled(args.signal) if (result.issue) { args.issues.push(result.issue) diff --git a/src/main/ai-vault/session-scanner-cursor-sources.ts b/src/main/ai-vault/session-scanner-cursor-sources.ts index 298622f64d6..f990091f221 100644 --- a/src/main/ai-vault/session-scanner-cursor-sources.ts +++ b/src/main/ai-vault/session-scanner-cursor-sources.ts @@ -72,7 +72,7 @@ async function discoverCursorSidecars(args: { issues: AiVaultScanIssue[] }): Promise { const startedAt = Date.now() - const scopePaths = localSidecarScopeCandidates(args) + const scopePaths = localSidecarScopePaths(args) let discovery try { discovery = await discoverLocalCursorSidecarsBounded({ @@ -80,7 +80,13 @@ async function discoverCursorSidecars(args: { scopePaths, issues: args.issues, signal: args.options.signal, - pathPlatform: args.roots.targetPlatform + pathPlatform: args.roots.targetPlatform, + resolveScopePaths: (scopePath) => + resolveLocalSidecarScopePaths({ + scopePath, + storageContextKey: args.roots.storageContextKey, + targetPlatform: args.roots.targetPlatform + }) }) } catch (error) { if ((error as Error).message === 'cursor_sidecar_scan_cancelled') { @@ -216,20 +222,47 @@ async function localScopeCandidates(args: { return [...candidates] } -function localSidecarScopeCandidates(args: { +function localSidecarScopePaths(args: { roots: CursorRootPair options: AiVaultScanOptions }): string[] { - const candidates = new Set() + const paths = new Set() for (const scopePath of args.options.scopePaths ?? []) { - const candidate = cursorContextPathForHash( - scopePath, - args.roots.storageContextKey, - args.roots.targetPlatform - ) - if (candidate) { + const trimmed = scopePath.trim() + if ( + trimmed && + cursorContextPathForHash(trimmed, args.roots.storageContextKey, args.roots.targetPlatform) + ) { + paths.add(trimmed) + } + } + return [...paths] +} + +export async function resolveLocalSidecarScopePaths(args: { + scopePath: string + storageContextKey: string + targetPlatform: NodeJS.Platform + realpathPath?: (path: string) => Promise +}): Promise { + const candidates = new Set( + cursorScopeCwdCandidates({ + scopePath: args.scopePath, + storageContextKey: args.storageContextKey, + platform: args.targetPlatform + }) + ) + try { + const resolved = await (args.realpathPath ?? realpath)(args.scopePath) + for (const candidate of cursorScopeCwdCandidates({ + scopePath: resolved, + storageContextKey: args.storageContextKey, + platform: args.targetPlatform + })) { candidates.add(candidate) } + } catch { + // A scope path need not exist in every selected storage context. } return [...candidates] } diff --git a/src/main/ai-vault/session-scanner-cursor-storage-budget.test.ts b/src/main/ai-vault/session-scanner-cursor-storage-budget.test.ts index bea444aa996..36556fd5b51 100644 --- a/src/main/ai-vault/session-scanner-cursor-storage-budget.test.ts +++ b/src/main/ai-vault/session-scanner-cursor-storage-budget.test.ts @@ -23,6 +23,98 @@ afterEach(async () => { }) describe('Cursor verified-read budgets by storage context', () => { + it('stops after the first verified read when cancellation lands during parsing', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-cursor-parse-cancel-')) + roots.push(root) + const chatsRoot = join(root, '.cursor', 'chats') + const files = await Promise.all([ + addSession(chatsRoot, 'first-session', sidecarPayload(5_000), 2_000), + addSession(chatsRoot, 'second-session', sidecarPayload(5_000), 1_000) + ]) + const discovery = sidecarDiscovery('native', chatsRoot, await realpath(chatsRoot), files) + let cancellationChecks = 0 + const signal = { + get aborted() { + cancellationChecks += 1 + return cancellationChecks > 1 + } + } as AbortSignal + const span = startSpan('cursor-parse-cancel-test') + + try { + await expect( + processLocalCursorCandidates({ + candidates: discoveryCandidates(discovery), + discoveries: [discovery], + executionHostId: 'local', + issues: [], + limit: 20, + parseStats: createSessionParseStats(), + platform: 'linux', + scopeLimit: 20, + signal, + span + }) + ).rejects.toMatchObject({ name: 'AbortError' }) + expect(discovery.cursorDiscoveryCounters?.boundedReads).toBe(1) + } finally { + span.end() + } + }) + + it('charges a raced verified read and skips later candidates after budget exhaustion', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-cursor-storage-race-')) + roots.push(root) + const chatsRoot = join(root, '.cursor', 'chats') + const fillerBytes = CURSOR_SIDECAR_MAX_BYTES - 100 + const fillerCount = Math.floor(CURSOR_REMOTE_MAX_AGGREGATE_BYTES / fillerBytes) + const fillerFiles: FileWithMtime[] = [] + for (let index = 0; index < fillerCount; index += 1) { + fillerFiles.push( + await addSession( + chatsRoot, + `filler-${String(index).padStart(3, '0')}`, + sidecarPayload(fillerBytes), + 10_000 + ) + ) + } + const racedFiles = await Promise.all([ + addSession(chatsRoot, 'race-first', sidecarPayload(1_000), 2_000), + addSession(chatsRoot, 'race-second', sidecarPayload(1_000), 1_000) + ]) + const racedBytes = 8_000 + await Promise.all(racedFiles.map((file) => writeFile(file.path, sidecarPayload(racedBytes)))) + const files = [...fillerFiles, ...racedFiles] + const discovery = sidecarDiscovery('native', chatsRoot, await realpath(chatsRoot), files) + const span = startSpan('cursor-storage-race-test') + + try { + const result = await processLocalCursorCandidates({ + candidates: discoveryCandidates(discovery), + discoveries: [discovery], + executionHostId: 'local', + issues: [], + limit: 100, + parseStats: createSessionParseStats(), + platform: 'linux', + scopeLimit: 100, + span + }) + const fillerTotal = fillerBytes * fillerCount + + expect(result.sessions).toHaveLength(fillerCount) + expect(discovery.cursorDiscoveryCounters?.boundedReads).toBe(fillerCount + 1) + expect(discovery.cursorDiscoveryCounters?.returnedBytes).toBe(fillerTotal + racedBytes) + expect(discovery.cursorDiscoveryCounters?.returnedBytes).toBeGreaterThan( + CURSOR_REMOTE_MAX_AGGREGATE_BYTES + ) + expect(discovery.cursorDiscoveryTruncated?.sidecarBytes).toBe(true) + } finally { + span.end() + } + }, 30_000) + it('does not let native reads consume the WSL ingress budget', async () => { const root = await mkdtemp(join(tmpdir(), 'orca-cursor-storage-budget-')) roots.push(root) diff --git a/src/main/ai-vault/session-scanner-cursor-wsl-scope-realpath.test.ts b/src/main/ai-vault/session-scanner-cursor-wsl-scope-realpath.test.ts new file mode 100644 index 00000000000..ce6359fc1c4 --- /dev/null +++ b/src/main/ai-vault/session-scanner-cursor-wsl-scope-realpath.test.ts @@ -0,0 +1,57 @@ +import { createHash } from 'node:crypto' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { discoverLocalCursorSidecarsBounded } from './session-scanner-cursor-local-files' +import { resolveLocalSidecarScopePaths } from './session-scanner-cursor-sources' + +const roots: string[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('WSL Cursor scope realpath resolution', () => { + it('realpaths the raw UNC path before hashing its resolved Linux cwd', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-cursor-wsl-realpath-')) + roots.push(root) + const chatsDir = join(root, 'chats') + const rawUnc = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\linked-repo' + const resolvedUnc = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\real-repo' + const resolvedCwd = '/home/ada/real-repo' + const bucket = createHash('md5').update(resolvedCwd).digest('hex') + const metaPath = join(chatsDir, bucket, 'resolved-session', 'meta.json') + await mkdir(join(chatsDir, bucket, 'resolved-session'), { recursive: true }) + await Promise.all([ + writeFile(metaPath, JSON.stringify({ createdAtMs: 1, updatedAtMs: 2 })), + writeFile(join(chatsDir, bucket, 'resolved-session', 'store.db'), '') + ]) + const realpathPath = vi.fn(async (path: string) => { + expect(path).toBe(rawUnc) + return resolvedUnc + }) + + const result = await discoverLocalCursorSidecarsBounded({ + chatsDir, + issues: [], + pathPlatform: 'linux', + resolveScopePaths: (scopePath) => + resolveLocalSidecarScopePaths({ + realpathPath, + scopePath, + storageContextKey: 'wsl:Ubuntu', + targetPlatform: 'linux' + }), + scopePaths: [rawUnc] + }) + + expect(realpathPath).toHaveBeenCalledOnce() + expect(result.files.map((file) => file.path)).toContain(metaPath) + expect(result.evidenceByPath.get(metaPath)).toMatchObject({ + bucket, + cwd: resolvedCwd, + kind: 'scope-bucket' + }) + }) +}) diff --git a/src/main/ai-vault/session-scanner.ts b/src/main/ai-vault/session-scanner.ts index 1197fd45f04..05b314e1b83 100644 --- a/src/main/ai-vault/session-scanner.ts +++ b/src/main/ai-vault/session-scanner.ts @@ -124,7 +124,8 @@ export async function scanAiVaultSessions( issues, parseStats, span, - discoveries + discoveries, + signal: options.signal }) // Record after verified reads so counters include boundedReads/returnedBytes. recordLocalCursorDiscoverySpan(span, discoveries) diff --git a/src/shared/cursor-sidecar-scan-directory.ts b/src/shared/cursor-sidecar-scan-directory.ts index de07463a783..8b91c4bed14 100644 --- a/src/shared/cursor-sidecar-scan-directory.ts +++ b/src/shared/cursor-sidecar-scan-directory.ts @@ -1,5 +1,5 @@ import type { Dir, Dirent } from 'node:fs' -import { opendir, readdir } from 'node:fs/promises' +import { opendir, readdir, realpath } from 'node:fs/promises' import { posix, win32 } from 'node:path' /** Per-directory dirent examination budget; keeps cold scans hard-bounded. */ @@ -152,6 +152,26 @@ export function targetPathVariants(value: string, pathPlatform: NodeJS.Platform) : [resolved] } +export async function resolveTargetScopePathVariants(args: { + value: string + pathPlatform: NodeJS.Platform + resolveScopePaths?: (scopePath: string) => Promise +}): Promise { + if (args.resolveScopePaths) { + return args.resolveScopePaths(args.value) + } + const variants = targetPathVariants(args.value, args.pathPlatform) + try { + variants.push(...targetPathVariants(await realpath(args.value), args.pathPlatform)) + } catch (error) { + if ((error as Error).message === 'cursor_sidecar_scan_cancelled') { + throw error + } + // A scope path need not exist on the owning host. + } + return variants +} + export function safeBasename(value: string): boolean { return Boolean(value && value !== '.' && value !== '..' && !/[\\/]/u.test(value)) } diff --git a/src/shared/cursor-sidecar-scan-discovery.ts b/src/shared/cursor-sidecar-scan-discovery.ts index 791e0343270..25e98caeaff 100644 --- a/src/shared/cursor-sidecar-scan-discovery.ts +++ b/src/shared/cursor-sidecar-scan-discovery.ts @@ -6,8 +6,8 @@ import type { CursorSidecarScanRequest, CursorSidecarScanResponse } from './curs import { CURSOR_DIR_MAX_ENTRIES_EXAMINED, listLexicographicDirectoryNames, - safeBasename, - targetPathVariants + resolveTargetScopePathVariants, + safeBasename } from './cursor-sidecar-scan-directory' const BUCKET_PATTERN = /^[0-9a-f]{32}$/u @@ -32,6 +32,7 @@ type ScanArgs = { caps: CursorSidecarScanCaps response: CursorSidecarScanResponse cancellation: CursorSidecarScanCancellation + resolveScopePaths?: (scopePath: string) => Promise } export type CursorSidecarScanCandidate = Bucket & { @@ -62,6 +63,7 @@ export async function discoverCursorSidecarCandidates(args: { caps: CursorSidecarScanCaps response: CursorSidecarScanResponse cancellation: CursorSidecarScanCancellation + resolveScopePaths?: (scopePath: string) => Promise // Why: WSL scopes are Linux paths after UNC conversion while process.platform // remains win32; path flavor must follow the storage context, not the host OS. pathPlatform?: NodeJS.Platform @@ -98,18 +100,16 @@ async function scopeBuckets( args.response.truncated.scopePaths = paths.length > args.caps.scopes const cwds = new Set() for (const scopePath of paths.slice(0, args.caps.scopes)) { - for (const cwd of targetPathVariants(scopePath, pathPlatform)) { - cwds.add(cwd) - } try { args.response.counters.scopeRealpath++ - const resolved = await realpath(scopePath) - for (const cwd of targetPathVariants(resolved, pathPlatform)) { - cwds.add(cwd) - } + const variants = await resolveTargetScopePathVariants({ + value: scopePath, + pathPlatform, + resolveScopePaths: args.resolveScopePaths + }) + variants.forEach((cwd) => cwds.add(cwd)) } catch (error) { rethrowCancel(error) - // Scope paths are allowed to be absent on the owning host. } args.cancellation.throwIfCancelled() }