mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
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:
@@ -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 () => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user