extend(ai-vault): support Cursor sessions with host-aware context isolat

- Hide previous client snapshot while next client loads; ignore stale responses
- Extend session-scanner cache keys with execution host ID and file identity (dev/ino/nlink) to prevent reusing host-stamped issues across different execution contexts
- Invalidate all-host cache when connected host set changes via topology snapshot
- Forward WSL distro context through preflight agent detection methods
- Preserve legacy Cursor command when paired with older runtimes via fallback detection
- Fix bounded file read to handle partial reads until EOF
- Add test coverage for snapshot isolation, cache invalidation, and backward compatibility
This commit is contained in:
Jinjing
2026-08-05 12:48:01 -07:00
parent d6a27423fc
commit 1cff3be4e4
16 changed files with 587 additions and 56 deletions
@@ -0,0 +1,137 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import type { RpcResponse } from '../transport/types'
import type { Worktree } from '../worktree/workspace-list-types'
import { useMobileAgentHistoryWorktrees } from './use-mobile-agent-history-worktrees'
describe('useMobileAgentHistoryWorktrees', () => {
let renderer: ReactTestRenderer | null = null
let latest = { worktrees: [] as Worktree[], worktreesLoaded: false }
let consoleSpy: MockInstance
function Harness(props: { client: Pick<RpcClient, 'sendRequest'>; connected?: boolean }): null {
latest = useMobileAgentHistoryWorktrees(props.client, props.connected ?? true)
return null
}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
latest = { worktrees: [], worktreesLoaded: false }
const original = console.error
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
consoleSpy.mockRestore()
})
it('hides a previous client snapshot while the next client loads', async () => {
const firstWorktree = worktree('first')
const secondWorktree = worktree('second')
const firstClient = clientWithResponse(success([firstWorktree]))
const secondResponse = deferred<RpcResponse>()
const secondClient = clientWithResponse(secondResponse.promise)
await render(firstClient)
expect(latest).toEqual({ worktrees: [firstWorktree], worktreesLoaded: true })
act(() => renderer?.update(createElement(Harness, { client: secondClient })))
expect(latest).toEqual({ worktrees: [], worktreesLoaded: false })
await act(async () => {
secondResponse.resolve(success([secondWorktree]))
await Promise.resolve()
})
expect(latest).toEqual({ worktrees: [secondWorktree], worktreesLoaded: true })
})
it('ignores a response from a replaced client', async () => {
const firstResponse = deferred<RpcResponse>()
const secondResponse = deferred<RpcResponse>()
const firstClient = clientWithResponse(firstResponse.promise)
const secondClient = clientWithResponse(secondResponse.promise)
await render(firstClient)
act(() => renderer?.update(createElement(Harness, { client: secondClient })))
await act(async () => {
firstResponse.resolve(success([worktree('stale')]))
await Promise.resolve()
})
expect(latest).toEqual({ worktrees: [], worktreesLoaded: false })
await act(async () => {
secondResponse.resolve(success([worktree('current')]))
await Promise.resolve()
})
expect(latest.worktrees[0]?.worktreeId).toBe('current')
})
it('finishes loading without scope data when the request fails', async () => {
const client = {
sendRequest: vi.fn().mockRejectedValue(new Error('offline'))
} as Pick<RpcClient, 'sendRequest'>
await render(client)
expect(latest).toEqual({ worktrees: [], worktreesLoaded: true })
})
async function render(client: Pick<RpcClient, 'sendRequest'>): Promise<void> {
await act(async () => {
renderer = create(createElement(Harness, { client }))
await Promise.resolve()
await Promise.resolve()
})
}
})
function clientWithResponse(response: Promise<RpcResponse> | RpcResponse) {
return {
sendRequest: vi.fn().mockResolvedValue(response)
} as Pick<RpcClient, 'sendRequest'>
}
function success(worktrees: Worktree[]): RpcResponse {
return {
id: 'response',
ok: true,
result: { worktrees },
_meta: { runtimeId: 'runtime' }
}
}
function worktree(worktreeId: string): Worktree {
return {
worktreeId,
repoId: 'repo',
repo: 'repo',
branch: worktreeId,
displayName: worktreeId,
path: `/repo/${worktreeId}`,
liveTerminalCount: 0,
hasAttachedPty: false,
preview: '',
unread: false,
isPinned: false,
linkedPR: null
}
}
function deferred<T>(): { promise: Promise<T>; resolve: (value: T) => void } {
let resolve!: (value: T) => void
return {
promise: new Promise<T>((settle) => {
resolve = settle
}),
resolve
}
}
@@ -7,8 +7,11 @@ export function useMobileAgentHistoryWorktrees(
client: Pick<RpcClient, 'sendRequest'> | null,
connected: boolean
): { worktrees: Worktree[]; worktreesLoaded: boolean } {
const [worktrees, setWorktrees] = useState<Worktree[]>([])
const [worktreesLoaded, setWorktreesLoaded] = useState(false)
const [snapshot, setSnapshot] = useState<{
client: Pick<RpcClient, 'sendRequest'> | null
worktrees: Worktree[]
loaded: boolean
}>({ client: null, worktrees: [], loaded: false })
useEffect(() => {
if (!client || !connected) {
return
@@ -18,13 +21,21 @@ export function useMobileAgentHistoryWorktrees(
try {
const response = await client.sendRequest('worktree.ps', { limit: 10000 })
if (!cancelled && response.ok) {
setWorktrees(((response as RpcSuccess).result as { worktrees: Worktree[] }).worktrees)
setSnapshot({
client,
worktrees: ((response as RpcSuccess).result as { worktrees: Worktree[] }).worktrees,
loaded: true
})
}
} catch {
// Scope context is best effort; the session scan can still proceed unscoped.
} finally {
if (!cancelled) {
setWorktreesLoaded(true)
setSnapshot((current) =>
current.client === client
? { ...current, loaded: true }
: { client, worktrees: [], loaded: true }
)
}
}
})()
@@ -32,5 +43,7 @@ export function useMobileAgentHistoryWorktrees(
cancelled = true
}
}, [client, connected])
return { worktrees, worktreesLoaded }
return snapshot.client === client && connected
? { worktrees: snapshot.worktrees, worktreesLoaded: snapshot.loaded }
: { worktrees: [], worktreesLoaded: false }
}
@@ -21,12 +21,14 @@ describe('resolveMobileCursorCommand', () => {
it('fails closed without a valid inventory unless an override exists', async () => {
const sendRequest = vi.fn().mockResolvedValue({ ok: false })
await expect(resolveMobileCursorCommand({ client: { sendRequest } })).resolves.toBeNull()
sendRequest.mockRejectedValueOnce(new Error('offline'))
await expect(
resolveMobileCursorCommand({
client: { sendRequest },
settings: { agentCmdOverrides: { cursor: 'cursor-dev' } }
})
).resolves.toBe('cursor-dev')
expect(sendRequest).toHaveBeenCalledTimes(1)
})
it('requests inventory from the exact WSL distro context', async () => {
+6 -5
View File
@@ -9,19 +9,20 @@ export async function resolveMobileCursorCommand(args: {
settings?: MobileAiVaultResumeSettings | null
wslDistro?: string | null
}): Promise<string | null> {
const configured = resolveEffectiveCursorCommand(args.settings?.agentCmdOverrides?.cursor, null)
if (configured) {
return configured
}
const wslDistro = args.wslDistro?.trim()
const response = await args.client.sendRequest(
'preflight.detectAgentInventory',
wslDistro ? { wslDistro } : undefined
)
if (!response.ok) {
return resolveEffectiveCursorCommand(args.settings?.agentCmdOverrides?.cursor, null)
return null
}
const result = detectedAgentInventorySchema.safeParse(response.result)
return resolveEffectiveCursorCommand(
args.settings?.agentCmdOverrides?.cursor,
result.success ? result.data : null
)
return resolveEffectiveCursorCommand(null, result.success ? result.data : null)
}
export async function resolveRequiredMobileCursorResumeCommand(args: {
@@ -0,0 +1,84 @@
import { afterEach, describe, expect, it } from 'vitest'
import { lstat, mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { cursorBucketForCwd } from './session-scanner-cursor-paths'
import {
parseCursorSidecarFileCached,
resetCursorSidecarParseCacheForTests
} from './session-scanner-cursor-sidecar'
const roots: string[] = []
afterEach(async () => {
resetCursorSidecarParseCacheForTests()
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
describe('Cursor sidecar parse cache isolation', () => {
it('does not reuse host-stamped issues across execution hosts', async () => {
const fixture = await createSidecar()
const first = await parseCursorSidecarFileCached({
...fixture,
executionHostId: 'ssh:first'
})
const second = await parseCursorSidecarFileCached({
...fixture,
executionHostId: 'runtime:second'
})
expect(first.issue?.executionHostId).toBe('ssh:first')
expect(second.issue?.executionHostId).toBe('runtime:second')
})
it('revalidates the file when the expected root changes', async () => {
const fixture = await createSidecar()
await parseCursorSidecarFileCached(fixture)
const unrelatedRoot = await mkdtemp(join(tmpdir(), 'orca-cursor-unrelated-'))
roots.push(unrelatedRoot)
await expect(
parseCursorSidecarFileCached({
...fixture,
expectedRootRealPath: await realpath(unrelatedRoot)
})
).rejects.toThrow('verified_file_outside_root')
})
})
async function createSidecar() {
const root = await mkdtemp(join(tmpdir(), 'orca-cursor-cache-'))
roots.push(root)
const chatsRoot = join(root, 'chats')
const bucket = cursorBucketForCwd('/correct', process.platform)
const sessionDir = join(chatsRoot, bucket, 'session')
const metaPath = join(sessionDir, 'meta.json')
const storePath = join(sessionDir, 'store.db')
await mkdir(sessionDir, { recursive: true })
await Promise.all([
writeFile(
metaPath,
JSON.stringify({ createdAtMs: 10, updatedAtMs: 20, hasConversation: true, cwd: '/wrong' })
),
writeFile(storePath, '')
])
const [meta, store, expectedRootRealPath] = await Promise.all([
lstat(metaPath),
lstat(storePath),
realpath(chatsRoot)
])
return {
file: {
path: metaPath,
mtimeMs: meta.mtimeMs,
modifiedAt: meta.mtime.toISOString(),
sizeBytes: meta.size,
cursorStoreMtimeMs: store.mtimeMs,
dev: meta.dev,
ino: meta.ino,
nlink: meta.nlink
},
platform: process.platform,
expectedRootRealPath
}
}
@@ -34,7 +34,12 @@ const sidecarCache = new Map<
mtimeMs: number
sizeBytes: number | null
storeMtimeMs: number | null
dev: number | null
ino: number | null
nlink: number | null
platform: NodeJS.Platform
executionHostId: ExecutionHostId | null
expectedRootRealPath: string | null
result: CursorSidecarParseResult
}
>()
@@ -68,7 +73,12 @@ export async function parseCursorSidecarFileCached(args: {
cached &&
cached.mtimeMs === args.file.mtimeMs &&
cached.storeMtimeMs === (args.file.cursorStoreMtimeMs ?? null) &&
cached.dev === (args.file.dev ?? null) &&
cached.ino === (args.file.ino ?? null) &&
cached.nlink === (args.file.nlink ?? null) &&
cached.platform === args.platform &&
cached.executionHostId === (args.executionHostId ?? null) &&
cached.expectedRootRealPath === (args.expectedRootRealPath ?? null) &&
(cached.sizeBytes === null ||
args.file.sizeBytes === undefined ||
cached.sizeBytes === args.file.sizeBytes)
@@ -83,7 +93,12 @@ export async function parseCursorSidecarFileCached(args: {
mtimeMs: args.file.mtimeMs,
sizeBytes: args.file.sizeBytes ?? null,
storeMtimeMs: args.file.cursorStoreMtimeMs ?? null,
dev: args.file.dev ?? null,
ino: args.file.ino ?? null,
nlink: args.file.nlink ?? null,
platform: args.platform,
executionHostId: args.executionHostId ?? null,
expectedRootRealPath: args.expectedRootRealPath ?? null,
result
})
if (sidecarCache.size > SIDECAR_CACHE_MAX_ENTRIES) {
+17
View File
@@ -276,6 +276,23 @@ describe('listAiVaultSessions host routing', () => {
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual(['ssh:dev-box', 'local'])
})
it('invalidates the all-host cache when the connected host set changes', async () => {
mocks.getActiveSshAiVaultHostInfo.mockImplementation((targetId) => hostInfo(targetId))
mocks.scanRemoteAiVaultSessions.mockImplementation(({ executionHostId }) =>
Promise.resolve(result([session(executionHostId, `${executionHostId}-session`)]))
)
await _internals.listAiVaultSessions({ executionHostScope: 'all' })
mocks.getActiveSshAiVaultHostInfos.mockReturnValue([
hostInfo('dev-box'),
hostInfo('second-box')
])
const scanResult = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
expect(mocks.scanRemoteAiVaultSessions).toHaveBeenCalledTimes(2)
expect(scanResult.sessions.map((entry) => entry.executionHostId)).toContain('ssh:second-box')
})
it('keeps successful hosts when one SSH scan fails', async () => {
mocks.getActiveSshAiVaultHostInfos.mockReturnValue([
hostInfo('unavailable'),
+41 -5
View File
@@ -58,6 +58,11 @@ type AiVaultHandlerOptions = AiVaultSessionSources &
scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner
}
type AllHostScanSnapshot = {
sshHosts: AiVaultHostDiscoveryResult<{ targetId: string }>
runtimeHosts: AiVaultHostDiscoveryResult<RuntimeAiVaultHostInfo>
}
let scanCoordinator = new AiVaultScanCoordinator()
let handlerOptions: AiVaultHandlerOptions = {}
const listCancellations = createSenderScopedRequestCancellations()
@@ -69,17 +74,22 @@ async function listAiVaultSessions(
const executionHostScope = normalizeExecutionHostScope(
args?.executionHostScope ?? LOCAL_EXECUTION_HOST_ID
)
const allHostSnapshot = executionHostScope === 'all' ? captureAllHostScanSnapshot() : undefined
// Scope paths change the result set, so they must be part of the cache key.
// A scanner consumes at most 64 paths, so smaller equivalent workspace sets
// can share a snapshot regardless of which worktree was selected first.
const scopePaths = args?.scopePaths ?? []
const key = JSON.stringify({
const hostLegCacheKey = JSON.stringify({
scopePaths:
scopePaths.length <= AI_VAULT_SCOPE_PATHS_MAX_COUNT
? [...new Set(scopePaths)].sort()
: scopePaths,
executionHostScope
})
const key = JSON.stringify({
hostLegCacheKey,
hostTopology: allHostSnapshot ? allHostTopologyKey(allHostSnapshot) : undefined
})
const depth = requestedAiVaultSessionDepth(args)
const scanKey = JSON.stringify({ key, depth })
const force =
@@ -94,7 +104,14 @@ async function listAiVaultSessions(
force,
signal: options.signal,
start: (scanSignal) => {
const scan = () => scanAiVaultSessionsByHostScope(args, executionHostScope, scanSignal, key)
const scan = () =>
scanAiVaultSessionsByHostScope(
args,
executionHostScope,
scanSignal,
hostLegCacheKey,
allHostSnapshot
)
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
return scan()
}
@@ -113,7 +130,8 @@ async function scanAiVaultSessionsByHostScope(
args: AiVaultListArgs | undefined,
executionHostScope: ExecutionHostScope,
signal?: AbortSignal,
cacheKey = ''
cacheKey = '',
allHostSnapshot?: AllHostScanSnapshot
): Promise<AiVaultListResult> {
const depth = requestedAiVaultSessionDepth(args)
const scopePaths = args?.scopePaths ?? []
@@ -121,8 +139,8 @@ async function scanAiVaultSessionsByHostScope(
return scanLocalAiVaultSessions(args, signal)
}
if (executionHostScope === 'all') {
const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult()
const sshHosts = getActiveSshAiVaultHostInfosResult()
const snapshot = allHostSnapshot ?? captureAllHostScanSnapshot()
const { runtimeHosts, sshHosts } = snapshot
const runtimeResults = [
...(runtimeHosts.issue ? [runtimeHosts.issue] : []),
...(sshHosts.issue ? [sshHosts.issue] : [])
@@ -208,6 +226,24 @@ function getActiveRuntimeAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<Ru
})
}
function captureAllHostScanSnapshot(): AllHostScanSnapshot {
return {
sshHosts: getActiveSshAiVaultHostInfosResult(),
runtimeHosts: getActiveRuntimeAiVaultHostInfosResult()
}
}
function allHostTopologyKey(snapshot: AllHostScanSnapshot): unknown {
return {
ssh: snapshot.sshHosts.hostInfos.map((host) => host.targetId).sort(),
runtime: snapshot.runtimeHosts.hostInfos.map((host) => host.executionHostId).sort(),
issues: [snapshot.sshHosts.issue, snapshot.runtimeHosts.issue]
.flatMap((result) => result?.issues ?? [])
.map((issue) => `${issue.path}:${issue.message}`)
.sort()
}
}
function getActiveSshAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<{ targetId: string }> {
return discoverAiVaultHosts(getActiveSshAiVaultHostInfos, {
path: 'SSH hosts',
@@ -5,6 +5,7 @@ import type { OrcaRuntimeService } from '../../orca-runtime'
import { PREFLIGHT_METHODS } from './preflight'
const {
detectInstalledAgentCommandsWithShellPathHydrationMock,
detectInstalledAgentInventoryWithShellPathHydrationMock,
detectInstalledAgentsWithShellPathHydrationMock,
detectRemoteAgentInventoryMock,
@@ -13,6 +14,7 @@ const {
refreshShellPathAndDetectAgentsMock,
runPreflightCheckMock
} = vi.hoisted(() => ({
detectInstalledAgentCommandsWithShellPathHydrationMock: vi.fn(),
detectInstalledAgentInventoryWithShellPathHydrationMock: vi.fn(),
detectInstalledAgentsWithShellPathHydrationMock: vi.fn(),
detectRemoteAgentInventoryMock: vi.fn(),
@@ -23,6 +25,8 @@ const {
}))
vi.mock('../../../ipc/preflight', () => ({
detectInstalledAgentCommandsWithShellPathHydration:
detectInstalledAgentCommandsWithShellPathHydrationMock,
detectInstalledAgentInventoryWithShellPathHydration:
detectInstalledAgentInventoryWithShellPathHydrationMock,
detectInstalledAgentsWithShellPathHydration: detectInstalledAgentsWithShellPathHydrationMock,
@@ -124,6 +128,38 @@ describe('preflight RPC methods', () => {
expect(detectInstalledAgentInventoryWithShellPathHydrationMock).toHaveBeenCalledWith({})
})
it('forwards WSL context through every local agent detection method', async () => {
detectInstalledAgentsWithShellPathHydrationMock.mockResolvedValueOnce(['cursor'])
detectInstalledAgentCommandsWithShellPathHydrationMock.mockResolvedValueOnce({
agents: ['cursor'],
matchedCommands: { cursor: 'cursor-agent' }
})
refreshShellPathAndDetectAgentsMock.mockResolvedValueOnce({
agents: ['cursor'],
matchedCommands: { cursor: 'cursor-agent' },
addedPathSegments: [],
shellHydrationOk: true,
pathSource: 'sync_seed_only',
pathFailureReason: 'none'
})
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: PREFLIGHT_METHODS })
const context = { wslDistro: 'Ubuntu' }
await expect(
dispatcher.dispatch(makeRequest('preflight.detectAgents', context))
).resolves.toMatchObject({ ok: true, result: ['cursor'] })
await expect(
dispatcher.dispatch(makeRequest('preflight.detectAgentCommands', context))
).resolves.toMatchObject({ ok: true, result: { agents: ['cursor'] } })
await expect(
dispatcher.dispatch(makeRequest('preflight.refreshAgents', context))
).resolves.toMatchObject({ ok: true, result: { agents: ['cursor'] } })
expect(detectInstalledAgentsWithShellPathHydrationMock).toHaveBeenLastCalledWith(context)
expect(detectInstalledAgentCommandsWithShellPathHydrationMock).toHaveBeenLastCalledWith(context)
expect(refreshShellPathAndDetectAgentsMock).toHaveBeenLastCalledWith(context)
})
it('detects agents on remote SSH connections through runtime RPC', async () => {
detectRemoteAgentsMock.mockResolvedValueOnce(['claude'])
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
+6 -6
View File
@@ -33,13 +33,13 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [
}),
defineMethod({
name: 'preflight.detectAgents',
params: null,
handler: async () => detectInstalledAgentsWithShellPathHydration()
params: PreflightLocalAgentContext,
handler: async (params) => detectInstalledAgentsWithShellPathHydration(params)
}),
defineMethod({
name: 'preflight.detectAgentCommands',
params: null,
handler: async () => detectInstalledAgentCommandsWithShellPathHydration()
params: PreflightLocalAgentContext,
handler: async (params) => detectInstalledAgentCommandsWithShellPathHydration(params)
}),
defineMethod({
name: 'preflight.detectAgentInventory',
@@ -68,7 +68,7 @@ export const PREFLIGHT_METHODS: RpcMethod[] = [
}),
defineMethod({
name: 'preflight.refreshAgents',
params: null,
handler: async () => refreshShellPathAndDetectAgents()
params: PreflightLocalAgentContext,
handler: async (params) => refreshShellPathAndDetectAgents(params)
})
]
@@ -105,6 +105,15 @@ describe('createDetectedAgentsSlice WSL context', () => {
})
})
it('preserves the legacy Cursor command when the preload lacks inventory methods', async () => {
detectAgents.mockResolvedValueOnce(['cursor'])
const store = createTestStore()
store.getState().clearLocalDetectedAgents()
await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['cursor'])
expect(store.getState().detectedAgentCommands).toEqual({ cursor: 'cursor-agent' })
expect(store.getState().detectedAgentCommandsByContext.host).toEqual({ cursor: 'cursor-agent' })
})
it('detects local agents inside the active WSL worktree distro', async () => {
const store = createTestStore({
repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })],
@@ -455,6 +464,17 @@ describe('createDetectedAgentsSlice remote detection', () => {
})
})
it('preserves the legacy Cursor command when remote inventory methods are unavailable', async () => {
detectRemoteAgents.mockResolvedValueOnce(['cursor'])
const store = createTestStore()
await expect(store.getState().ensureRemoteDetectedAgents('ssh-legacy')).resolves.toEqual([
'cursor'
])
expect(store.getState().remoteDetectedAgentCommands['ssh-legacy']).toEqual({
cursor: 'cursor-agent'
})
})
it('retains remote detection promises only while requests are in flight', async () => {
const store = createTestStore()
let resolveRemote: (ids: string[]) => void = () => {}
@@ -5,6 +5,7 @@ import {
getLocalAgentPreflightContext,
localPreflightContextKey
} from '@/lib/local-preflight-context'
import { legacyDetectedAgentInventory } from '../../../../shared/detected-agent-inventory'
export type DetectedAgentsSlice = {
detectedAgentIds: TuiAgent[] | null
@@ -89,9 +90,7 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
? window.api.preflight.detectAgentCommands(context)
: window.api.preflight
.detectAgents(context)
.then((agents): { agents: string[]; matchedCommands?: Record<string, string> } => ({
agents
}))
.then((agents) => legacyDetectedAgentInventory(agents as TuiAgent[]))
const pending = detection
.then((result) => {
const typed = result.agents as TuiAgent[]
@@ -223,9 +222,7 @@ export const createDetectedAgentsSlice: StateCreator<AppState, [], [], DetectedA
? window.api.preflight.detectRemoteAgentCommands({ connectionId })
: window.api.preflight
.detectRemoteAgents({ connectionId })
.then((agents): { agents: string[]; matchedCommands?: Record<string, string> } => ({
agents
}))
.then((agents) => legacyDetectedAgentInventory(agents as TuiAgent[]))
const pending = detection
.then((result) => {
const typed = result.agents as TuiAgent[]
@@ -164,6 +164,13 @@ function trackPromiseSettled(promise: Promise<unknown>): () => boolean {
return () => settled
}
function successfulRuntimeResponse<TResult>(
id: string,
result: TResult
): RuntimeRpcResponse<TResult> {
return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } }
}
function installClipboardImageBase64(contentBase64: string): void {
vi.stubGlobal(
'FileReader',
@@ -1600,6 +1607,87 @@ describe('web AI Vault preload API', () => {
})
})
describe('web preflight preload API', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.unstubAllGlobals()
vi.doUnmock('./web-runtime-client')
})
it('preserves Cursor commands when paired with an older runtime', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
runtimeCalls.push({ method, params })
if (method === 'preflight.detectAgents') {
return Promise.resolve(successfulRuntimeResponse(method, ['cursor', 'codex']))
}
if (method === 'preflight.detectRemoteAgents') {
return Promise.resolve(successfulRuntimeResponse(method, ['cursor']))
}
if (method === 'preflight.refreshAgents') {
return Promise.resolve(
successfulRuntimeResponse(method, {
agents: ['cursor'],
addedPathSegments: [],
shellHydrationOk: true,
pathSource: 'shell_hydrate',
pathFailureReason: 'none'
})
)
}
return Promise.resolve({
id: method,
ok: false,
error: { code: 'method_not_found', message: `Unknown method: ${method}` },
_meta: { runtimeId: 'runtime-1' }
})
}
close(): void {}
}
}))
const globals = installBrowserGlobals('Linux')
writeStoredRuntimeEnvironment(globals.storage)
const { installWebPreloadApi } = await import('./web-preload-api')
installWebPreloadApi()
await expect(
globals.window.api.preflight.detectAgentInventory({ wslDistro: 'Ubuntu' })
).resolves.toMatchObject({
agents: ['cursor', 'codex'],
matchedCommands: { cursor: 'cursor-agent' }
})
await expect(
globals.window.api.preflight.detectRemoteAgentCommands({ connectionId: 'ssh-old' })
).resolves.toMatchObject({
agents: ['cursor'],
matchedCommands: { cursor: 'cursor-agent' }
})
await expect(
globals.window.api.preflight.refreshAgents({ wslDistro: 'Ubuntu' })
).resolves.toMatchObject({
agents: ['cursor'],
matchedCommands: { cursor: 'cursor-agent' }
})
expect(runtimeCalls).toEqual([
{ method: 'preflight.detectAgentInventory', params: { wslDistro: 'Ubuntu' } },
{ method: 'preflight.detectAgents', params: undefined },
{
method: 'preflight.detectRemoteAgentCommands',
params: { connectionId: 'ssh-old' }
},
{ method: 'preflight.detectRemoteAgents', params: { connectionId: 'ssh-old' } },
{ method: 'preflight.refreshAgents', params: { wslDistro: 'Ubuntu' } }
])
})
})
describe('web UI preload API', () => {
beforeEach(() => {
vi.resetModules()
+63 -26
View File
@@ -10,6 +10,7 @@ import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
import { parseHostAccessLink } from '../../../shared/remote-pairing-address'
import { verifyRemotePairingRuntimeStatus } from '../../../shared/remote-pairing-verification'
import type { DetectedAgentInventoryV1 } from '../../../shared/detected-agent-inventory'
import { legacyDetectedAgentInventory } from '../../../shared/detected-agent-inventory'
import type { AiVaultListArgs, AiVaultListResult } from '../../../shared/ai-vault-types'
import type {
AiVaultPrepareSessionResumeArgs,
@@ -32,6 +33,7 @@ import type {
RemoveWorktreeResult,
SearchResult,
StatsSummary,
TuiAgent,
Worktree,
WorktreeLineage,
WorkspaceLineage,
@@ -2797,6 +2799,12 @@ function createPreflightApi(): NonNullable<Partial<PreloadApi>['preflight']> {
pathSource: 'sync_seed_only',
pathFailureReason: 'spawn_error'
}
const withLegacyMatchedCommands = (refresh: RefreshAgentsResult): RefreshAgentsResult => ({
...refresh,
matchedCommands:
refresh.matchedCommands ??
legacyDetectedAgentInventory(refresh.agents as TuiAgent[]).matchedCommands
})
type WindowsTerminalCapabilityBridgeResult = {
wslAvailable: boolean
wslDistros: string[]
@@ -2818,39 +2826,63 @@ function createPreflightApi(): NonNullable<Partial<PreloadApi>['preflight']> {
}
return callRuntimeResult<PreflightStatus>('preflight.check', args)
},
detectAgents: async () => {
detectAgents: async (args) => {
if (!requireActiveEnvironmentOrNull()) {
return []
}
return callRuntimeResult<string[]>('preflight.detectAgents').catch(() => [])
try {
return await callRuntimeResult<string[]>('preflight.detectAgents', args)
} catch {
return args ? callRuntimeResult<string[]>('preflight.detectAgents').catch(() => []) : []
}
},
detectAgentInventory: async () => {
detectAgentInventory: async (args) => {
if (!requireActiveEnvironmentOrNull()) {
return { version: 1, agents: [], matchedCommands: {} }
}
return callRuntimeResult<DetectedAgentInventoryV1>('preflight.detectAgentInventory').catch(
() => ({
version: 1,
agents: [],
matchedCommands: {}
})
return callRuntimeResult<DetectedAgentInventoryV1>(
'preflight.detectAgentInventory',
args
).catch(async () =>
legacyDetectedAgentInventory(
(await callRuntimeResult<string[]>('preflight.detectAgents').catch(
() => []
)) as TuiAgent[]
)
)
},
detectAgentCommands: async () => {
detectAgentCommands: async (args) => {
if (!requireActiveEnvironmentOrNull()) {
return { agents: [], matchedCommands: {} }
}
return callRuntimeResult<{
agents: string[]
matchedCommands?: Record<string, string>
}>('preflight.detectAgentCommands').catch(() => ({ agents: [], matchedCommands: {} }))
}>('preflight.detectAgentCommands', args).catch(async () =>
legacyDetectedAgentInventory(
(await callRuntimeResult<string[]>('preflight.detectAgents').catch(
() => []
)) as TuiAgent[]
)
)
},
refreshAgents: async (args) => {
if (!requireActiveEnvironmentOrNull()) {
return fallbackRefreshAgents
}
try {
return withLegacyMatchedCommands(
await callRuntimeResult<RefreshAgentsResult>('preflight.refreshAgents', args)
)
} catch {
if (!args) {
return fallbackRefreshAgents
}
return callRuntimeResult<RefreshAgentsResult>('preflight.refreshAgents')
.then(withLegacyMatchedCommands)
.catch(() => fallbackRefreshAgents)
}
},
refreshAgents: () =>
requireActiveEnvironmentOrNull()
? callRuntimeResult('preflight.refreshAgents')
.then((result) => result as RefreshAgentsResult)
.catch(() => fallbackRefreshAgents)
: Promise.resolve(fallbackRefreshAgents),
detectRemoteAgents: async (args) =>
requireActiveEnvironmentOrNull()
? callRuntimeResult<string[]>('preflight.detectRemoteAgents', args).catch(() => [])
@@ -2860,21 +2892,26 @@ function createPreflightApi(): NonNullable<Partial<PreloadApi>['preflight']> {
? callRuntimeResult<DetectedAgentInventoryV1>(
'preflight.detectRemoteAgentInventory',
args
).catch(() => ({
version: 1,
agents: [],
matchedCommands: {}
}))
).catch(async () =>
legacyDetectedAgentInventory(
(await callRuntimeResult<string[]>('preflight.detectRemoteAgents', args).catch(
() => []
)) as TuiAgent[]
)
)
: { version: 1, agents: [], matchedCommands: {} },
detectRemoteAgentCommands: async (args) =>
requireActiveEnvironmentOrNull()
? callRuntimeResult<{
agents: string[]
matchedCommands?: Record<string, string>
}>('preflight.detectRemoteAgentCommands', args).catch(() => ({
agents: [],
matchedCommands: {}
}))
}>('preflight.detectRemoteAgentCommands', args).catch(async () =>
legacyDetectedAgentInventory(
(await callRuntimeResult<string[]>('preflight.detectRemoteAgents', args).catch(
() => []
)) as TuiAgent[]
)
)
: { agents: [], matchedCommands: {} },
detectRemoteWindowsTerminalCapabilities: async (args) =>
requireActiveEnvironmentOrNull()
@@ -0,0 +1,36 @@
import { describe, expect, it, vi } from 'vitest'
import type { FileHandle } from 'node:fs/promises'
import { readBoundedFileHandle } from './node-verified-bounded-text-file'
describe('readBoundedFileHandle', () => {
it('continues after short reads until EOF', async () => {
const source = Buffer.from('abcdef')
const read = vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
const bytesRead = Math.min(2, length, source.length - position)
if (bytesRead > 0) {
source.copy(buffer, offset, position, position + bytesRead)
}
return { bytesRead, buffer }
})
await expect(
readBoundedFileHandle({ read } as unknown as FileHandle, source.length)
).resolves.toEqual(source)
expect(read).toHaveBeenCalledTimes(4)
})
it('detects an oversized file even when the first read is short', async () => {
const source = Buffer.from('abcdefg')
const read = vi.fn(async (buffer: Buffer, offset: number, length: number, position: number) => {
const bytesRead = Math.min(2, length, source.length - position)
if (bytesRead > 0) {
source.copy(buffer, offset, position, position + bytesRead)
}
return { bytesRead, buffer }
})
await expect(
readBoundedFileHandle({ read } as unknown as FileHandle, source.length - 1)
).rejects.toThrow('file_too_large')
})
})
+15 -3
View File
@@ -77,11 +77,23 @@ async function assertRegularDescendantPath(rootPath: string, filePath: string):
export async function readBoundedFileHandle(handle: FileHandle, maxBytes: number): Promise<Buffer> {
const safeLimit = validatedByteLimit(maxBytes)
const buffer = Buffer.alloc(safeLimit + 1)
const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
if (bytesRead > safeLimit) {
let totalBytesRead = 0
while (totalBytesRead < buffer.length) {
const { bytesRead } = await handle.read(
buffer,
totalBytesRead,
buffer.length - totalBytesRead,
totalBytesRead
)
if (bytesRead === 0) {
break
}
totalBytesRead += bytesRead
}
if (totalBytesRead > safeLimit) {
throw new Error('file_too_large')
}
return buffer.subarray(0, bytesRead)
return buffer.subarray(0, totalBytesRead)
}
export async function openNoFollow(filePath: string): Promise<FileHandle> {