mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
fix(ai-vault): harden Cursor scope and read bounds
This commit is contained in:
@@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user