fix(ai-vault): harden Cursor scope and read bounds

This commit is contained in:
Brennan Benson
2026-08-10 13:41:51 -07:00
parent d117f938f6
commit 7a9b388bce
8 changed files with 244 additions and 33 deletions
@@ -40,6 +40,7 @@ export async function discoverLocalCursorSidecarsBounded(args: {
issues: AiVaultScanIssue[]
signal?: AbortSignal
pathPlatform?: NodeJS.Platform
resolveScopePaths?: (scopePath: string) => Promise<readonly string[]>
}): Promise<LocalCursorSidecarDiscovery> {
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) {
@@ -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<ReturnType<typeof reconcileCursorCandidates>> {
const groups = buildCursorCandidateSelectionGroups({
candidates: args.candidates,
@@ -165,6 +167,7 @@ async function parseCursorGroups(
executionHostId: ExecutionHostId
issues: AiVaultScanIssue[]
parseStats: SessionParseStats
signal?: AbortSignal
},
verifiedReads: VerifiedReadTracker
): Promise<void> {
@@ -185,6 +188,7 @@ async function parseCursorCandidate(
executionHostId: ExecutionHostId
issues: AiVaultScanIssue[]
parseStats: SessionParseStats
signal?: AbortSignal
},
verifiedReads: VerifiedReadTracker
): Promise<ParsedCursorCandidate | null> {
@@ -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<ParsedCursorCandidate | null> {
@@ -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)
@@ -72,7 +72,7 @@ async function discoverCursorSidecars(args: {
issues: AiVaultScanIssue[]
}): Promise<SessionFileDiscovery> {
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<string>()
const paths = new Set<string>()
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<string>
}): Promise<string[]> {
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]
}
@@ -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)
@@ -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'
})
})
})
+2 -1
View File
@@ -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)
+21 -1
View File
@@ -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<readonly string[]>
}): Promise<readonly string[]> {
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))
}
+10 -10
View File
@@ -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<readonly string[]>
}
export type CursorSidecarScanCandidate = Bucket & {
@@ -62,6 +63,7 @@ export async function discoverCursorSidecarCandidates(args: {
caps: CursorSidecarScanCaps
response: CursorSidecarScanResponse
cancellation: CursorSidecarScanCancellation
resolveScopePaths?: (scopePath: string) => Promise<readonly string[]>
// 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<string>()
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()
}