fix(ai-vault): scan SSH hosts owned by a paired runtime

aiVault.listSessions can now name an ssh: host the runtime owns, and
all-hosts callers can opt into that inventory without changing the
host-local projection old clients still receive.
This commit is contained in:
Brennan Benson
2026-08-12 19:39:13 -07:00
parent f1ee3088a5
commit be5154fcbf
18 changed files with 1255 additions and 201 deletions
@@ -117,7 +117,8 @@ export function useMobileAgentHistoryState(params: MobileAgentHistoryStateParams
const response = await client.sendRequest('aiVault.listSessions', {
limit: MOBILE_AI_VAULT_SESSION_LIMIT,
force: options.force,
scopePaths
scopePaths,
includeOwnedSshHosts: true
})
if (!isCurrent()) {
return
@@ -0,0 +1,162 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
const mocks = vi.hoisted(() => ({
callRuntimeEnvironment: vi.fn(),
listEnvironments: vi.fn()
}))
vi.mock('../../shared/runtime-environment-store', () => ({
listEnvironments: mocks.listEnvironments
}))
vi.mock('../ipc/runtime-environment-transport-routing', () => ({
callRuntimeEnvironment: mocks.callRuntimeEnvironment
}))
const {
findRuntimeOwningSshAiVaultHost,
listRuntimeOwnedSshAiVaultTargets,
scanRuntimeOwnedSshAiVaultSessions
} = await import('./runtime-owned-ssh-session-list')
beforeEach(() => {
vi.clearAllMocks()
mocks.listEnvironments.mockReturnValue([{ id: 'hub-runtime' }])
mocks.callRuntimeEnvironment.mockResolvedValue({
ok: true,
result: { targets: [{ id: 'hub-owned-host', label: 'Hub host' }] }
})
})
describe('runtime-owned SSH AI Vault inventory', () => {
it('lists SSH targets owned by a paired runtime and skips recipe-VM ids', async () => {
mocks.callRuntimeEnvironment.mockResolvedValueOnce({
ok: true,
result: {
targets: [
{ id: 'hub-owned-host', label: 'Hub host' },
{ id: 'runtime-ssh-recipe', label: 'Recipe VM' }
]
}
})
await expect(listRuntimeOwnedSshAiVaultTargets('/user-data', 'hub-runtime')).resolves.toEqual([
{
environmentId: 'hub-runtime',
targetId: 'hub-owned-host',
executionHostId: 'ssh:hub-owned-host'
}
])
})
it('finds which paired runtime registered an SSH target', async () => {
await expect(findRuntimeOwningSshAiVaultHost('/user-data', 'hub-owned-host')).resolves.toEqual({
environmentId: 'hub-runtime',
targetId: 'hub-owned-host',
executionHostId: 'ssh:hub-owned-host'
})
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
'/user-data',
'hub-runtime',
'ssh.listTargetSummaries',
undefined,
undefined
)
})
it('does not treat recipe-VM targets as runtime-owned SSH history hosts', async () => {
await expect(findRuntimeOwningSshAiVaultHost('/user-data', 'runtime-ssh-recipe')).resolves.toBe(
null
)
expect(mocks.callRuntimeEnvironment).not.toHaveBeenCalled()
})
it('asks the owning runtime to scan a named SSH host and keeps the SSH stamp', async () => {
mocks.callRuntimeEnvironment.mockResolvedValueOnce({
ok: true,
result: result([session('local', 'ssh-session')])
})
const scanned = await scanRuntimeOwnedSshAiVaultSessions(
'/user-data',
'hub-runtime',
'hub-owned-host',
{ limit: 25, force: true }
)
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
'/user-data',
'hub-runtime',
'aiVault.listSessions',
{
limit: 25,
force: true,
executionHostId: 'ssh:hub-owned-host'
},
undefined
)
expect(scanned.sessions).toEqual([
expect.objectContaining({
executionHostId: 'ssh:hub-owned-host',
sessionId: 'ssh-session'
})
])
})
it('turns an old-runtime host-id rejection into an SSH-host issue', async () => {
mocks.callRuntimeEnvironment.mockResolvedValueOnce({
ok: false,
error: { message: 'Invalid runtime execution host id' }
})
const scanned = await scanRuntimeOwnedSshAiVaultSessions(
'/user-data',
'hub-runtime',
'hub-owned-host',
{}
)
expect(scanned.sessions).toEqual([])
expect(scanned.issues).toEqual([
expect.objectContaining({
executionHostId: 'ssh:hub-owned-host',
path: 'hub-owned-host',
message: expect.stringContaining('cannot scan Agent Session History on its SSH hosts')
})
])
})
})
function result(sessions: AiVaultSession[]): AiVaultListResult {
return { sessions, issues: [], scannedAt: '2026-08-12T00:00:00.000Z' }
}
function session(
executionHostId: AiVaultSession['executionHostId'],
sessionId: string
): AiVaultSession {
return {
id: `${executionHostId}:codex:${sessionId}:/sessions/${sessionId}.jsonl`,
executionHostId,
executionHostPlatform: 'linux',
agent: 'codex',
sessionId,
title: sessionId,
cwd: '/srv/app',
branch: null,
model: null,
filePath: `/sessions/${sessionId}.jsonl`,
codexHome: null,
createdAt: null,
updatedAt: '2026-08-12T03:00:00.000Z',
modifiedAt: '2026-08-12T00:00:00.000Z',
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: `codex resume ${sessionId}`,
subagent: null
}
}
@@ -0,0 +1,160 @@
import {
AI_VAULT_SCOPE_PATHS_MAX_COUNT,
type AiVaultListArgs,
type AiVaultListResult
} from '../../shared/ai-vault-types'
import type {
AiVaultSessionTitlesArgs,
AiVaultSessionTitlesResult
} from '../../shared/ai-vault-session-title'
import {
isRuntimeOwnedSshTargetId,
toSshExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import { listEnvironments } from '../../shared/runtime-environment-store'
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
import { parseAiVaultListResult } from './session-list-result-validation'
import { parseAiVaultSessionTitlesResult } from './session-title-result-validation'
import { aiVaultScanIssueResult, restampAiVaultListResult } from './session-list-results'
export type RuntimeOwnedSshAiVaultHost = {
environmentId: string
targetId: string
executionHostId: `ssh:${string}`
}
export type RuntimeOwnedSshAiVaultScanOptions = {
timeoutMs?: number
}
const UNSUPPORTED_SSH_HOST_PARAM = /invalid (runtime )?execution host id/i
export async function listRuntimeOwnedSshAiVaultTargets(
userDataPath: string,
environmentId: string,
options: RuntimeOwnedSshAiVaultScanOptions = {}
): Promise<readonly RuntimeOwnedSshAiVaultHost[]> {
const response = await callRuntimeEnvironment(
userDataPath,
environmentId,
'ssh.listTargetSummaries',
undefined,
options.timeoutMs
)
if (response.ok !== true || !isTargetSummaryList(response.result)) {
return []
}
return response.result.targets.flatMap((target) => {
if (typeof target.id !== 'string' || target.id.length === 0) {
return []
}
if (isRuntimeOwnedSshTargetId(target.id)) {
return []
}
return [
{
environmentId,
targetId: target.id,
executionHostId: toSshExecutionHostId(target.id)
}
]
})
}
export async function findRuntimeOwningSshAiVaultHost(
userDataPath: string,
targetId: string,
options: RuntimeOwnedSshAiVaultScanOptions = {}
): Promise<RuntimeOwnedSshAiVaultHost | null> {
if (isRuntimeOwnedSshTargetId(targetId)) {
return null
}
for (const environment of listEnvironments(userDataPath)) {
const hosts = await listRuntimeOwnedSshAiVaultTargets(userDataPath, environment.id, options)
const match = hosts.find((host) => host.targetId === targetId)
if (match) {
return match
}
}
return null
}
export async function scanRuntimeOwnedSshAiVaultSessions(
userDataPath: string,
environmentId: string,
targetId: string,
args: AiVaultListArgs,
options: RuntimeOwnedSshAiVaultScanOptions = {}
): Promise<AiVaultListResult> {
const executionHostId = toSshExecutionHostId(targetId)
const response = await callRuntimeEnvironment(
userDataPath,
environmentId,
'aiVault.listSessions',
{
limit: args.limit,
unlimited: args.unlimited,
force: args.force,
scopePaths: args.scopePaths?.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT),
executionHostId
},
options.timeoutMs
)
if (response.ok !== true) {
return aiVaultScanIssueResult({
executionHostId,
path: targetId,
message: unsupportedSshHostMessage(response.error.message)
})
}
try {
return restampAiVaultListResult(parseAiVaultListResult(response.result), executionHostId)
} catch (error) {
return aiVaultScanIssueResult({
executionHostId,
path: targetId,
message: `Invalid aiVault.listSessions response: ${
error instanceof Error ? error.message : 'unexpected result shape'
}`
})
}
}
export async function resolveRuntimeOwnedSshAiVaultSessionTitles(
userDataPath: string,
environmentId: string,
targetId: string,
args: AiVaultSessionTitlesArgs
): Promise<AiVaultSessionTitlesResult> {
const executionHostId: ExecutionHostId = toSshExecutionHostId(targetId)
const response = await callRuntimeEnvironment(
userDataPath,
environmentId,
'aiVault.resolveSessionTitles',
{ requests: args.requests, executionHostId }
)
if (response.ok !== true) {
return { titles: [] }
}
try {
return parseAiVaultSessionTitlesResult(response.result)
} catch {
return { titles: [] }
}
}
function unsupportedSshHostMessage(message: string): string {
if (UNSUPPORTED_SSH_HOST_PARAM.test(message)) {
return 'This Orca server cannot scan Agent Session History on its SSH hosts. Update the server and try again.'
}
return message
}
function isTargetSummaryList(value: unknown): value is { targets: { id?: unknown }[] } {
return (
typeof value === 'object' &&
value !== null &&
Array.isArray((value as { targets?: unknown }).targets)
)
}
@@ -68,6 +68,27 @@ describe('runtime AI Vault session scanner', () => {
)
})
it('asks the runtime to include SSH hosts it owns when the all-hosts scan opts in', async () => {
await scanRuntimeAiVaultSessions(
'/user-data',
'env-1',
{ limit: 25 },
{ includeOwnedSshHosts: true }
)
expect(mocks.callRuntimeEnvironment).toHaveBeenCalledWith(
'/user-data',
'env-1',
'aiVault.listSessions',
{
limit: 25,
executionHostId: 'runtime:env-1',
includeOwnedSshHosts: true
},
undefined
)
})
it('surfaces project scope truncation from the runtime transport bound', async () => {
const scopePaths = Array.from({ length: 80 }, (_, index) => `/srv/repo-${index}`)
@@ -109,13 +130,42 @@ describe('runtime AI Vault session scanner', () => {
])
expect(scanResult.issues).toEqual([
expect.objectContaining({
executionHostId: 'runtime:env-1',
executionHostId: 'ssh:dev-box',
agent: 'codex',
path: '/sessions/session-1.jsonl'
})
])
})
it('keeps SSH-stamped rows from a runtime that scanned hosts it owns', async () => {
mocks.callRuntimeEnvironment.mockResolvedValueOnce({
ok: true,
result: result(
[session('local', 'session-1'), session('ssh:hub-owned-host', 'session-ssh')],
[]
)
})
const scanResult = await scanRuntimeAiVaultSessions(
'/user-data',
'env-1',
{},
{ includeOwnedSshHosts: true }
)
expect(scanResult.sessions).toEqual([
expect.objectContaining({
executionHostId: 'runtime:env-1',
sessionId: 'session-1'
}),
expect.objectContaining({
id: 'ssh:hub-owned-host:codex:session-ssh:/sessions/session-ssh.jsonl',
executionHostId: 'ssh:hub-owned-host',
sessionId: 'session-ssh'
})
])
})
it('stamps accepted sessions with the requested runtime host', async () => {
const scanResult = await scanRuntimeAiVaultSessions('/user-data', 'env-1', {})
+14 -4
View File
@@ -5,7 +5,7 @@ import {
type AiVaultListResult,
type AiVaultSession
} from '../../shared/ai-vault-types'
import { toRuntimeExecutionHostId } from '../../shared/execution-host'
import { parseExecutionHostId, toRuntimeExecutionHostId } from '../../shared/execution-host'
import { listEnvironments } from '../../shared/runtime-environment-store'
import { callRuntimeEnvironment } from '../ipc/runtime-environment-transport-routing'
import type {
@@ -26,6 +26,7 @@ export type RuntimeAiVaultHostInfo = {
export type RuntimeAiVaultScanOptions = {
timeoutMs?: number
includeOwnedSshHosts?: boolean
}
// Why: zod strips unknown keys, so the repin home must be declared or the
@@ -64,7 +65,8 @@ export async function scanRuntimeAiVaultSessions(
// cap). Dropped paths only lose the older-than-recency-cap guarantee,
// never the recent sessions themselves.
scopePaths: args.scopePaths?.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT),
executionHostId
executionHostId,
...(options.includeOwnedSshHosts === true ? { includeOwnedSshHosts: true } : {})
},
options.timeoutMs
)
@@ -156,8 +158,16 @@ function withRuntimeExecutionHost(
executionHostId: `runtime:${string}`
): AiVaultListResult {
return {
sessions: result.sessions.map((session) => retagRuntimeSession(session, executionHostId)),
issues: result.issues.map((issue) => ({ ...issue, executionHostId })),
sessions: result.sessions.map((session) =>
parseExecutionHostId(session.executionHostId)?.kind === 'ssh'
? session
: retagRuntimeSession(session, executionHostId)
),
issues: result.issues.map((issue) =>
issue.executionHostId && parseExecutionHostId(issue.executionHostId)?.kind === 'ssh'
? issue
: { ...issue, executionHostId }
),
scannedAt: result.scannedAt
}
}
+171
View File
@@ -0,0 +1,171 @@
import { scanSshAiVaultSessions } from '../ai-vault/ssh-session-list'
import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results'
import { requestedAiVaultSessionDepth } from '../../shared/ai-vault-session-depth'
import {
isAiVaultScanCancelledError,
type AiVaultListArgs,
type AiVaultListResult
} from '../../shared/ai-vault-types'
import {
LOCAL_EXECUTION_HOST_ID,
parseExecutionHostId,
toRuntimeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostScope
} from '../../shared/execution-host'
import { getActiveSshAiVaultHostInfos } from './ssh'
import { discoverAiVaultHosts } from './ai-vault-host-discovery'
import {
scanRuntimeAiVaultSessions,
type RuntimeAiVaultHostInfo,
type RuntimeAiVaultScanner
} from './ai-vault-runtime-scan'
import { scanHostLegWithCache } from './ai-vault-host-leg-cache'
import {
scanSshAiVaultSessionsByOwner,
type RuntimeOwnedSshAiVaultScanner
} from './ai-vault-runtime-owned-ssh'
import type { RuntimeOwnedSshAiVaultHost } from '../ai-vault/runtime-owned-ssh-session-list'
export const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000
export const AI_VAULT_ALL_HOST_SSH_RELAY_TIMEOUT_MS = 15_000
export const AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS = 20_000
export type AiVaultHostScopeScanOptions = {
getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[]
scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner
findRuntimeOwningSshAiVaultHost?: (targetId: string) => Promise<RuntimeOwnedSshAiVaultHost | null>
scanRuntimeOwnedSshAiVaultSessions?: RuntimeOwnedSshAiVaultScanner
scanLocal: (
args: AiVaultListArgs | undefined,
signal: AbortSignal | undefined
) => Promise<AiVaultListResult>
}
export async function scanAiVaultSessionsByHostScope(
args: AiVaultListArgs | undefined,
executionHostScope: ExecutionHostScope,
signal: AbortSignal | undefined,
cacheKey: string,
options: AiVaultHostScopeScanOptions
): Promise<AiVaultListResult> {
const depth = requestedAiVaultSessionDepth(args)
const scopePaths = args?.scopePaths ?? []
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
return scanLocalAiVaultSessionsAsIssue(options.scanLocal, args, signal)
}
if (executionHostScope === 'all') {
return scanAllAiVaultHosts(args, signal, cacheKey, depth, scopePaths, options)
}
const parsed = parseExecutionHostId(executionHostScope)
if (parsed?.kind === 'ssh') {
return scanSshAiVaultSessionsByOwner({
targetId: parsed.targetId,
listArgs: args,
signal,
timeoutMs: AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS,
findOwner: options.findRuntimeOwningSshAiVaultHost,
scanOwned: options.scanRuntimeOwnedSshAiVaultSessions
})
}
if (parsed?.kind === 'runtime') {
return scanRuntimeAiVaultSessions({
hostInfo: {
environmentId: parsed.environmentId,
executionHostId: toRuntimeExecutionHostId(parsed.environmentId)
},
scanner: options.scanRuntimeAiVaultSessions,
listArgs: args,
options: { signal }
})
}
return aiVaultScanIssueResult({
executionHostId: executionHostScope,
path: executionHostScope,
message: 'Agent Session History is not available for this execution host.'
})
}
async function scanAllAiVaultHosts(
args: AiVaultListArgs | undefined,
signal: AbortSignal | undefined,
cacheKey: string,
depth: ReturnType<typeof requestedAiVaultSessionDepth>,
scopePaths: readonly string[],
options: AiVaultHostScopeScanOptions
): Promise<AiVaultListResult> {
const runtimeHosts = discoverAiVaultHosts(
() => options.getActiveRuntimeAiVaultHostInfos?.() ?? [],
{ path: 'runtime environments', fallbackMessage: 'Runtime hosts are unavailable.' }
)
const sshHosts = discoverAiVaultHosts(getActiveSshAiVaultHostInfos, {
path: 'SSH hosts',
fallbackMessage: 'SSH hosts are unavailable.'
})
const scannedResults = await Promise.all([
scanLocalAiVaultSessionsAsIssue(options.scanLocal, args, signal),
...sshHosts.hostInfos.map((hostInfo) =>
scanHostLegWithCache({
cacheKey: `${cacheKey}|${toSshExecutionHostId(hostInfo.targetId)}`,
depth,
scopePaths,
force: args?.force === true,
scan: () =>
scanSshAiVaultSessions(hostInfo.targetId, args, {
signal,
timeoutMs: AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS,
relayTimeoutMs: AI_VAULT_ALL_HOST_SSH_RELAY_TIMEOUT_MS
})
})
),
...runtimeHosts.hostInfos.map((hostInfo) =>
scanHostLegWithCache({
cacheKey: `${cacheKey}|${hostInfo.executionHostId}`,
depth,
scopePaths,
force: args?.force === true,
scan: () =>
scanRuntimeAiVaultSessions({
hostInfo,
scanner: options.scanRuntimeAiVaultSessions,
listArgs: args,
options: {
signal,
timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS,
includeOwnedSshHosts: true
}
})
})
)
])
return mergeAiVaultListResults(
[
...scannedResults,
...(runtimeHosts.issue ? [runtimeHosts.issue] : []),
...(sshHosts.issue ? [sshHosts.issue] : [])
],
args?.limit,
args?.unlimited
)
}
async function scanLocalAiVaultSessionsAsIssue(
scanLocal: AiVaultHostScopeScanOptions['scanLocal'],
args: AiVaultListArgs | undefined,
signal: AbortSignal | undefined
): Promise<AiVaultListResult> {
try {
return await scanLocal(args, signal)
} catch (error) {
if (isAiVaultScanCancelledError(error)) {
throw error
}
return aiVaultScanIssueResult({
executionHostId: LOCAL_EXECUTION_HOST_ID,
path: 'this computer',
message: error instanceof Error ? error.message : 'Local session scan failed.'
})
}
}
@@ -0,0 +1,185 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AiVaultListResult, AiVaultSession } from '../../shared/ai-vault-types'
import type { IFilesystemProvider } from '../providers/types'
const mocks = vi.hoisted(() => ({
scanAiVaultSessionsInWorker: vi.fn(),
resolveAiVaultSessionTitlesInWorker: vi.fn(),
scanRemoteAiVaultSessions: vi.fn(),
getSshFilesystemProvider: vi.fn(),
getActiveSshAiVaultHostInfo: vi.fn(),
getActiveSshAiVaultHostInfos: vi.fn(),
requestActiveSshAiVaultSessionList: vi.fn(),
requestActiveSshAiVaultSessionTitles: vi.fn(),
ipcHandle: vi.fn()
}))
vi.mock('electron', () => ({
app: { on: vi.fn() },
ipcMain: { handle: mocks.ipcHandle }
}))
vi.mock('../ai-vault/session-scanner-worker-spawn', () => ({
scanAiVaultSessionsInWorker: mocks.scanAiVaultSessionsInWorker,
resolveAiVaultSessionTitlesInWorker: mocks.resolveAiVaultSessionTitlesInWorker,
resetAiVaultScannerWorkerForTests: vi.fn()
}))
vi.mock('../ai-vault/remote-session-scanner', () => ({
scanRemoteAiVaultSessions: mocks.scanRemoteAiVaultSessions
}))
vi.mock('../wsl', () => ({
getWslHomeAsync: vi.fn(),
listWslDistrosAsync: vi.fn().mockResolvedValue([])
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE:
'Remote connection dropped. Click Reconnect on the SSH target before retrying.',
getSshFilesystemProvider: mocks.getSshFilesystemProvider
}))
vi.mock('./ssh', () => ({
getActiveSshAiVaultHostInfo: mocks.getActiveSshAiVaultHostInfo,
getActiveSshAiVaultHostInfos: mocks.getActiveSshAiVaultHostInfos,
requestActiveSshAiVaultSessionList: mocks.requestActiveSshAiVaultSessionList,
requestActiveSshAiVaultSessionTitles: mocks.requestActiveSshAiVaultSessionTitles
}))
const { _internals, registerAiVaultHandlers } = await import('./ai-vault')
beforeEach(() => {
vi.clearAllMocks()
_internals.resetAiVaultCacheForTests()
mocks.scanAiVaultSessionsInWorker.mockResolvedValue(result([]))
mocks.resolveAiVaultSessionTitlesInWorker.mockResolvedValue({ titles: [] })
mocks.scanRemoteAiVaultSessions.mockResolvedValue(result([]))
mocks.getSshFilesystemProvider.mockReturnValue({} as IFilesystemProvider)
mocks.requestActiveSshAiVaultSessionList.mockResolvedValue(null)
mocks.requestActiveSshAiVaultSessionTitles.mockResolvedValue(null)
mocks.getActiveSshAiVaultHostInfo.mockReturnValue(null)
mocks.getActiveSshAiVaultHostInfos.mockReturnValue([])
})
describe('runtime-owned SSH AI Vault routing', () => {
it('routes an SSH host the local process does not own through its paired runtime', async () => {
const scanRuntimeOwnedSshAiVaultSessions = vi
.fn()
.mockResolvedValue(result([session('ssh:hub-owned-host', 'hub-ssh-session')]))
registerAiVaultHandlers({
findRuntimeOwningSshAiVaultHost: async () => ({
environmentId: 'hub-runtime',
targetId: 'hub-owned-host',
executionHostId: 'ssh:hub-owned-host'
}),
scanRuntimeOwnedSshAiVaultSessions
})
const scanned = await _internals.listAiVaultSessions({
executionHostScope: 'ssh:hub-owned-host'
})
expect(scanRuntimeOwnedSshAiVaultSessions).toHaveBeenCalledWith(
'hub-runtime',
'hub-owned-host',
{ executionHostScope: 'ssh:hub-owned-host' },
{ timeoutMs: 20_000 }
)
expect(mocks.scanRemoteAiVaultSessions).not.toHaveBeenCalled()
expect(scanned.sessions).toEqual([
expect.objectContaining({
executionHostId: 'ssh:hub-owned-host',
sessionId: 'hub-ssh-session'
})
])
})
it('does not send recipe-VM SSH targets through a paired runtime', async () => {
const findRuntimeOwningSshAiVaultHost = vi.fn()
registerAiVaultHandlers({ findRuntimeOwningSshAiVaultHost })
const scanned = await _internals.listAiVaultSessions({
executionHostScope: 'ssh:runtime-ssh-recipe'
})
expect(findRuntimeOwningSshAiVaultHost).not.toHaveBeenCalled()
expect(scanned.sessions).toEqual([])
expect(scanned.issues).toMatchObject([
{
executionHostId: 'ssh:runtime-ssh-recipe',
agent: 'codex',
path: 'runtime-ssh-recipe'
}
])
})
it('resolves SSH titles through the owning runtime when this process has no session', async () => {
mocks.requestActiveSshAiVaultSessionTitles.mockRejectedValue(
new Error('SSH relay is not ready')
)
const titles = {
titles: [{ agent: 'codex' as const, sessionId: 'session-1', title: 'Exact title' }]
}
const requests = [
{ agent: 'codex' as const, sessionId: 'session-1', transcriptPath: '/tmp/session.jsonl' }
]
const resolveRuntimeOwnedSshAiVaultSessionTitles = vi.fn().mockResolvedValue(titles)
registerAiVaultHandlers({
findRuntimeOwningSshAiVaultHost: async () => ({
environmentId: 'hub-runtime',
targetId: 'hub-owned-host',
executionHostId: 'ssh:hub-owned-host'
}),
resolveRuntimeOwnedSshAiVaultSessionTitles
})
await expect(
_internals.resolveAiVaultSessionTitles({
executionHostScope: 'ssh:hub-owned-host',
requests
})
).resolves.toEqual(titles)
expect(resolveRuntimeOwnedSshAiVaultSessionTitles).toHaveBeenCalledWith(
'hub-runtime',
'hub-owned-host',
{
executionHostScope: 'ssh:hub-owned-host',
requests
}
)
})
})
function result(sessions: AiVaultSession[]): AiVaultListResult {
return { sessions, issues: [], scannedAt: new Date().toISOString() }
}
function session(
executionHostId: AiVaultSession['executionHostId'],
sessionId: string
): AiVaultSession {
return {
id: `${executionHostId}:codex:${sessionId}:/tmp/${sessionId}.jsonl`,
executionHostId,
agent: 'codex',
sessionId,
title: sessionId,
cwd: '/repo',
branch: null,
model: null,
filePath: `/tmp/${sessionId}.jsonl`,
codexHome: null,
createdAt: null,
updatedAt: null,
modifiedAt: new Date().toISOString(),
messageCount: 1,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: `codex resume ${sessionId}`,
subagent: null
}
}
@@ -0,0 +1,36 @@
import type { AiVaultListArgs, AiVaultListResult } from '../../shared/ai-vault-types'
import { isRuntimeOwnedSshTargetId } from '../../shared/execution-host'
import { scanSshAiVaultSessions } from '../ai-vault/ssh-session-list'
import type { RuntimeOwnedSshAiVaultHost } from '../ai-vault/runtime-owned-ssh-session-list'
import { getActiveSshAiVaultHostInfo } from './ssh'
export type RuntimeOwnedSshAiVaultScanner = (
environmentId: string,
targetId: string,
args: AiVaultListArgs,
options?: { timeoutMs?: number }
) => Promise<AiVaultListResult>
export async function scanSshAiVaultSessionsByOwner(args: {
targetId: string
listArgs?: AiVaultListArgs
signal?: AbortSignal
timeoutMs: number
findOwner?: (targetId: string) => Promise<RuntimeOwnedSshAiVaultHost | null>
scanOwned?: RuntimeOwnedSshAiVaultScanner
}): Promise<AiVaultListResult> {
if (
!isRuntimeOwnedSshTargetId(args.targetId) &&
!getActiveSshAiVaultHostInfo(args.targetId) &&
args.findOwner &&
args.scanOwned
) {
const owner = await args.findOwner(args.targetId)
if (owner) {
return args.scanOwned(owner.environmentId, owner.targetId, args.listArgs ?? {}, {
timeoutMs: args.timeoutMs
})
}
}
return scanSshAiVaultSessions(args.targetId, args.listArgs, { signal: args.signal })
}
+1
View File
@@ -16,6 +16,7 @@ export type RuntimeAiVaultHostInfo = {
export type RuntimeAiVaultScanOptions = {
timeoutMs?: number
includeOwnedSshHosts?: boolean
}
export type RuntimeAiVaultScanner = (
+52 -11
View File
@@ -3,10 +3,12 @@ import type {
AiVaultSessionTitlesResult
} from '../../shared/ai-vault-session-title'
import {
isRuntimeOwnedSshTargetId,
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostScope,
parseExecutionHostId
} from '../../shared/execution-host'
import type { RuntimeOwnedSshAiVaultHost } from '../ai-vault/runtime-owned-ssh-session-list'
import { resolveLocalAiVaultSessionTitles } from '../ai-vault/session-title-resolver'
import { parseAiVaultSessionTitlesResult } from '../ai-vault/session-title-result-validation'
import { requestActiveSshAiVaultSessionTitles } from './ssh'
@@ -16,9 +18,21 @@ export type RuntimeAiVaultSessionTitleResolver = (
args: AiVaultSessionTitlesArgs
) => Promise<AiVaultSessionTitlesResult>
export type RuntimeOwnedSshAiVaultSessionTitleResolver = (
environmentId: string,
targetId: string,
args: AiVaultSessionTitlesArgs
) => Promise<AiVaultSessionTitlesResult>
export async function resolveAiVaultSessionTitlesByHost(
args: AiVaultSessionTitlesArgs,
resolveRuntime?: RuntimeAiVaultSessionTitleResolver
options: {
resolveRuntime?: RuntimeAiVaultSessionTitleResolver
findRuntimeOwningSshAiVaultHost?: (
targetId: string
) => Promise<RuntimeOwnedSshAiVaultHost | null>
resolveRuntimeOwnedSsh?: RuntimeOwnedSshAiVaultSessionTitleResolver
} = {}
): Promise<AiVaultSessionTitlesResult> {
const executionHostScope = normalizeExecutionHostScope(
args.executionHostScope ?? LOCAL_EXECUTION_HOST_ID
@@ -28,17 +42,44 @@ export async function resolveAiVaultSessionTitlesByHost(
}
const parsed = parseExecutionHostId(executionHostScope)
if (parsed?.kind === 'ssh') {
try {
const result = await requestActiveSshAiVaultSessionTitles(parsed.targetId, {
requests: args.requests
})
return result === null ? { titles: [] } : parseAiVaultSessionTitlesResult(result)
} catch {
return { titles: [] }
}
return resolveSshAiVaultSessionTitles(parsed.targetId, args, options)
}
if (parsed?.kind === 'runtime' && resolveRuntime) {
return resolveRuntime(parsed.environmentId, args).catch(() => ({ titles: [] }))
if (parsed?.kind === 'runtime' && options.resolveRuntime) {
return options.resolveRuntime(parsed.environmentId, args).catch(() => ({ titles: [] }))
}
return { titles: [] }
}
async function resolveSshAiVaultSessionTitles(
targetId: string,
args: AiVaultSessionTitlesArgs,
options: {
findRuntimeOwningSshAiVaultHost?: (
targetId: string
) => Promise<RuntimeOwnedSshAiVaultHost | null>
resolveRuntimeOwnedSsh?: RuntimeOwnedSshAiVaultSessionTitleResolver
}
): Promise<AiVaultSessionTitlesResult> {
try {
const result = await requestActiveSshAiVaultSessionTitles(targetId, {
requests: args.requests
})
if (result !== null) {
return parseAiVaultSessionTitlesResult(result)
}
} catch {
// Fall through to the owning runtime when this process does not hold the SSH session.
}
if (
isRuntimeOwnedSshTargetId(targetId) ||
!options.findRuntimeOwningSshAiVaultHost ||
!options.resolveRuntimeOwnedSsh
) {
return { titles: [] }
}
const owner = await options.findRuntimeOwningSshAiVaultHost(targetId)
if (!owner) {
return { titles: [] }
}
return options.resolveRuntimeOwnedSsh(owner.environmentId, owner.targetId, args)
}
+1 -1
View File
@@ -324,7 +324,7 @@ describe('listAiVaultSessions host routing', () => {
{
executionHostScope: 'runtime:remote-server'
},
expect.objectContaining({ timeoutMs: expect.any(Number) })
expect.objectContaining({ timeoutMs: expect.any(Number), includeOwnedSshHosts: true })
)
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual([
'runtime:remote-server',
+26 -150
View File
@@ -7,12 +7,7 @@ import {
} from '../ai-vault/cached-session-list'
import { deleteAiVaultSession, registerAiVaultDeleteHandler } from './ai-vault-delete'
import { listAiVaultSubagentSessions } from './ai-vault-subagent-list'
import {
aiVaultScanIssueResult,
cancelledAiVaultListResult,
mergeAiVaultListResults
} from '../ai-vault/session-list-results'
import { scanSshAiVaultSessions } from '../ai-vault/ssh-session-list'
import { cancelledAiVaultListResult } from '../ai-vault/session-list-results'
import { AiVaultScanCoordinator } from '../ai-vault/ai-vault-scan-coordinator'
import type { AiVaultDeleteSessionArgs } from '../../shared/ai-vault-session-deletion'
import {
@@ -26,27 +21,15 @@ import {
} from '../../shared/ai-vault-types'
import { handleAiVaultGetFirstUserPrompt } from '../ai-vault/session-first-user-prompt-handler'
import { registerAiVaultResumeHandler, type AiVaultResumeHandlerOptions } from './ai-vault-resume'
import {
LOCAL_EXECUTION_HOST_ID,
normalizeExecutionHostScope,
parseExecutionHostId,
toRuntimeExecutionHostId,
toSshExecutionHostId,
type ExecutionHostScope
} from '../../shared/execution-host'
import { getActiveSshAiVaultHostInfos } from './ssh'
import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostScope } from '../../shared/execution-host'
import { createSenderScopedRequestCancellations } from './sender-scoped-request-cancellation'
import { discoverAiVaultHosts, type AiVaultHostDiscoveryResult } from './ai-vault-host-discovery'
import {
scanRuntimeAiVaultSessions,
type RuntimeAiVaultHostInfo,
type RuntimeAiVaultScanner
} from './ai-vault-runtime-scan'
import type { RuntimeAiVaultHostInfo, RuntimeAiVaultScanner } from './ai-vault-runtime-scan'
import {
invalidateAiVaultHostLegCache,
resetAiVaultHostLegCacheForTests,
scanHostLegWithCache
} from './ai-vault-host-leg-cache'
import { scanAiVaultSessionsByHostScope } from './ai-vault-host-scope-scan'
import { requestedAiVaultSessionDepth } from '../../shared/ai-vault-session-depth'
import type {
AiVaultSessionTitlesArgs,
@@ -54,23 +37,22 @@ import type {
} from '../../shared/ai-vault-session-title'
import {
resolveAiVaultSessionTitlesByHost,
type RuntimeAiVaultSessionTitleResolver
type RuntimeAiVaultSessionTitleResolver,
type RuntimeOwnedSshAiVaultSessionTitleResolver
} from './ai-vault-session-title-routing'
const AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS = 3_000
// Why: a remote home with many agent roots routinely needs seconds to walk,
// stat and parse. The old shared 3s bound emptied healthy SSH hosts in the
// all-hosts view; the relay gets a real scan budget and the whole leg (relay
// attempt plus any legacy crawl) stays bounded so one host can't hold the
// merge open.
const AI_VAULT_ALL_HOST_SSH_RELAY_TIMEOUT_MS = 15_000
const AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS = 20_000
import type { RuntimeOwnedSshAiVaultHost } from '../ai-vault/runtime-owned-ssh-session-list'
import type { RuntimeOwnedSshAiVaultScanner } from './ai-vault-runtime-owned-ssh'
type AiVaultHandlerOptions = AiVaultSessionSources &
AiVaultResumeHandlerOptions & {
getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[]
scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner
resolveRuntimeAiVaultSessionTitles?: RuntimeAiVaultSessionTitleResolver
findRuntimeOwningSshAiVaultHost?: (
targetId: string
) => Promise<RuntimeOwnedSshAiVaultHost | null>
scanRuntimeOwnedSshAiVaultSessions?: RuntimeOwnedSshAiVaultScanner
resolveRuntimeOwnedSshAiVaultSessionTitles?: RuntimeOwnedSshAiVaultSessionTitleResolver
}
let scanCoordinator = new AiVaultScanCoordinator()
@@ -85,7 +67,11 @@ const aiVaultDeleteDeps = {
const resolveAiVaultSessionTitles = (
args: AiVaultSessionTitlesArgs
): Promise<AiVaultSessionTitlesResult> =>
resolveAiVaultSessionTitlesByHost(args, handlerOptions.resolveRuntimeAiVaultSessionTitles)
resolveAiVaultSessionTitlesByHost(args, {
resolveRuntime: handlerOptions.resolveRuntimeAiVaultSessionTitles,
findRuntimeOwningSshAiVaultHost: handlerOptions.findRuntimeOwningSshAiVaultHost,
resolveRuntimeOwnedSsh: handlerOptions.resolveRuntimeOwnedSshAiVaultSessionTitles
})
async function listAiVaultSessions(
args?: AiVaultListArgs,
@@ -115,7 +101,14 @@ async function listAiVaultSessions(
force: args?.force,
signal: options.signal,
start: (scanSignal) => {
const scan = () => scanAiVaultSessionsByHostScope(args, executionHostScope, scanSignal, key)
const scan = () =>
scanAiVaultSessionsByHostScope(args, executionHostScope, scanSignal, key, {
getActiveRuntimeAiVaultHostInfos: handlerOptions.getActiveRuntimeAiVaultHostInfos,
scanRuntimeAiVaultSessions: handlerOptions.scanRuntimeAiVaultSessions,
findRuntimeOwningSshAiVaultHost: handlerOptions.findRuntimeOwningSshAiVaultHost,
scanRuntimeOwnedSshAiVaultSessions: handlerOptions.scanRuntimeOwnedSshAiVaultSessions,
scanLocal: scanLocalAiVaultSessions
})
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
return scan()
}
@@ -130,123 +123,6 @@ async function listAiVaultSessions(
})
}
async function scanAiVaultSessionsByHostScope(
args: AiVaultListArgs | undefined,
executionHostScope: ExecutionHostScope,
signal?: AbortSignal,
cacheKey = ''
): Promise<AiVaultListResult> {
const depth = requestedAiVaultSessionDepth(args)
const scopePaths = args?.scopePaths ?? []
if (executionHostScope === LOCAL_EXECUTION_HOST_ID) {
return scanLocalAiVaultSessionsAsIssue(args, signal)
}
if (executionHostScope === 'all') {
const runtimeHosts = getActiveRuntimeAiVaultHostInfosResult()
const sshHosts = getActiveSshAiVaultHostInfosResult()
const runtimeResults = [
...(runtimeHosts.issue ? [runtimeHosts.issue] : []),
...(sshHosts.issue ? [sshHosts.issue] : [])
]
const scannedResults = await Promise.all([
scanLocalAiVaultSessionsAsIssue(args, signal),
...sshHosts.hostInfos.map((hostInfo) =>
scanHostLegWithCache({
cacheKey: `${cacheKey}|${toSshExecutionHostId(hostInfo.targetId)}`,
depth,
scopePaths,
force: args?.force === true,
scan: () =>
scanSshAiVaultSessions(hostInfo.targetId, args, {
signal,
timeoutMs: AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS,
relayTimeoutMs: AI_VAULT_ALL_HOST_SSH_RELAY_TIMEOUT_MS
})
})
),
...runtimeHosts.hostInfos.map((hostInfo) =>
scanHostLegWithCache({
cacheKey: `${cacheKey}|${hostInfo.executionHostId}`,
depth,
scopePaths,
force: args?.force === true,
scan: () =>
scanRuntimeAiVaultSessions({
hostInfo,
scanner: handlerOptions.scanRuntimeAiVaultSessions,
listArgs: args,
options: { signal, timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS }
})
})
)
])
return mergeAiVaultListResults(
[...scannedResults, ...runtimeResults],
args?.limit,
args?.unlimited
)
}
const parsed = parseExecutionHostId(executionHostScope)
if (parsed?.kind === 'ssh') {
return scanSshAiVaultSessions(parsed.targetId, args, { signal })
}
if (parsed?.kind === 'runtime') {
return scanRuntimeAiVaultSessions({
hostInfo: {
environmentId: parsed.environmentId,
executionHostId: toRuntimeExecutionHostId(parsed.environmentId)
},
scanner: handlerOptions.scanRuntimeAiVaultSessions,
listArgs: args,
options: { signal }
})
}
return aiVaultScanIssueResult({
executionHostId: executionHostScope,
path: executionHostScope,
message: 'Agent Session History is not available for this execution host.'
})
}
function getActiveRuntimeAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<RuntimeAiVaultHostInfo> {
return discoverAiVaultHosts(() => handlerOptions.getActiveRuntimeAiVaultHostInfos?.() ?? [], {
path: 'runtime environments',
fallbackMessage: 'Runtime hosts are unavailable.'
})
}
function getActiveSshAiVaultHostInfosResult(): AiVaultHostDiscoveryResult<{ targetId: string }> {
return discoverAiVaultHosts(getActiveSshAiVaultHostInfos, {
path: 'SSH hosts',
fallbackMessage: 'SSH hosts are unavailable.'
})
}
// Why: the SSH legs already degrade to an issue row so one bad host can't take
// the shared Promise.all down; the local leg can throw too (parse-cache load,
// WSL home resolution, scanner service supervision) and would otherwise discard
// every host's sessions under 'all', or replace the list with a raw error string
// under single-host scope.
async function scanLocalAiVaultSessionsAsIssue(
args: AiVaultListArgs | undefined,
signal: AbortSignal | undefined
): Promise<AiVaultListResult> {
try {
return await scanLocalAiVaultSessions(args, signal)
} catch (error) {
if (isAiVaultScanCancelledError(error)) {
throw error
}
return aiVaultScanIssueResult({
executionHostId: LOCAL_EXECUTION_HOST_ID,
path: 'this computer',
message: error instanceof Error ? error.message : 'Local session scan failed.'
})
}
}
async function scanLocalAiVaultSessions(
args?: AiVaultListArgs,
signal?: AbortSignal
+22
View File
@@ -91,6 +91,11 @@ import {
resolveRuntimeAiVaultSessionTitles,
scanRuntimeAiVaultSessions
} from '../ai-vault/runtime-session-scanner'
import {
findRuntimeOwningSshAiVaultHost,
resolveRuntimeOwnedSshAiVaultSessionTitles,
scanRuntimeOwnedSshAiVaultSessions
} from '../ai-vault/runtime-owned-ssh-session-list'
import type { PluginService } from '../plugins/plugin-service'
import type { PluginMarketplaceHandlerServices } from './plugin-marketplaces'
@@ -224,6 +229,23 @@ export function registerCoreHandlers(
scanRuntimeAiVaultSessions(app.getPath('userData'), environmentId, args, options),
resolveRuntimeAiVaultSessionTitles: async (environmentId, args) =>
resolveRuntimeAiVaultSessionTitles(app.getPath('userData'), environmentId, args),
findRuntimeOwningSshAiVaultHost: async (targetId) =>
findRuntimeOwningSshAiVaultHost(app.getPath('userData'), targetId),
scanRuntimeOwnedSshAiVaultSessions: async (environmentId, targetId, args, options) =>
scanRuntimeOwnedSshAiVaultSessions(
app.getPath('userData'),
environmentId,
targetId,
args,
options
),
resolveRuntimeOwnedSshAiVaultSessionTitles: async (environmentId, targetId, args) =>
resolveRuntimeOwnedSshAiVaultSessionTitles(
app.getPath('userData'),
environmentId,
targetId,
args
),
prepareRuntimeSessionResume: async (environmentId, args) =>
prepareRuntimeAiVaultSessionResume(app.getPath('userData'), environmentId, args)
})
@@ -0,0 +1,228 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { RpcDispatcher } from '../dispatcher'
import type { RpcRequest } from '../core'
import type { OrcaRuntimeService } from '../../orca-runtime'
import type { AiVaultListResult, AiVaultSession } from '../../../../shared/ai-vault-types'
const {
scanAiVaultSessionsInWorker,
resolveAiVaultSessionTitlesInWorker,
scanSshAiVaultSessions,
getActiveSshAiVaultHostInfos,
requestActiveSshAiVaultSessionTitles
} = vi.hoisted(() => ({
scanAiVaultSessionsInWorker: vi.fn(),
resolveAiVaultSessionTitlesInWorker: vi.fn(),
scanSshAiVaultSessions: vi.fn(),
getActiveSshAiVaultHostInfos: vi.fn(),
requestActiveSshAiVaultSessionTitles: vi.fn()
}))
vi.mock('../../../ai-vault/session-scanner-worker-spawn', () => ({
scanAiVaultSessionsInWorker,
resolveAiVaultSessionTitlesInWorker,
resetAiVaultScannerWorkerForTests: vi.fn()
}))
vi.mock('../../../ai-vault/ssh-session-list', () => ({
scanSshAiVaultSessions
}))
vi.mock('../../../ipc/ssh', () => ({
getActiveSshAiVaultHostInfos,
requestActiveSshAiVaultSessionTitles
}))
import { AI_VAULT_METHODS } from './ai-vault'
import {
listAiVaultSessions,
resetAiVaultSessionListCacheForTests
} from '../../../ai-vault/cached-session-list'
const SCANNED_AT = '2026-08-12T00:00:00.000Z'
const SSH_HOST_OWNED_BY_RUNTIME = 'ssh:hub-owned-host'
function makeRequest(method: string, params?: unknown): RpcRequest {
return { id: 'req-1', authToken: 'tok', method, params }
}
function makeRuntimeLocalSession(): AiVaultSession {
return {
id: 'local:claude:sess-hub:/hub/home/.claude/projects/p/t.jsonl',
executionHostId: 'local',
agent: 'claude',
sessionId: 'sess-hub',
title: 'Session on the runtime host itself',
cwd: '/hub/home/projects/p',
branch: null,
model: null,
filePath: '/hub/home/.claude/projects/p/t.jsonl',
codexHome: null,
createdAt: null,
updatedAt: null,
modifiedAt: SCANNED_AT,
messageCount: 2,
totalTokens: 0,
previewMessages: [],
queuedMessageCount: 0,
subagentTranscriptCount: 0,
resumeCommand: 'claude --resume sess-hub',
subagent: null
}
}
function makeSshSession(): AiVaultSession {
return {
...makeRuntimeLocalSession(),
id: 'ssh:hub-owned-host:claude:sess-ssh:/remote/home/.claude/projects/p/t.jsonl',
executionHostId: 'ssh:hub-owned-host',
sessionId: 'sess-ssh',
title: 'Session on an SSH host this runtime owns',
cwd: '/remote/home/projects/p',
filePath: '/remote/home/.claude/projects/p/t.jsonl',
resumeCommand: 'claude --resume sess-ssh'
}
}
function makeDispatcher(): RpcDispatcher {
const runtime = {
getRuntimeId: () => 'hub-runtime',
listAiVaultSessions: (args?: Parameters<typeof listAiVaultSessions>[0]) =>
listAiVaultSessions(args),
resolveAiVaultSessionTitles: (requests: unknown[], signal?: AbortSignal) =>
resolveAiVaultSessionTitlesInWorker(requests, signal)
} as unknown as OrcaRuntimeService
return new RpcDispatcher({ runtime, methods: AI_VAULT_METHODS })
}
describe('a runtime can scan an SSH host that it owns', () => {
beforeEach(() => {
resetAiVaultSessionListCacheForTests()
scanAiVaultSessionsInWorker.mockReset()
scanAiVaultSessionsInWorker.mockResolvedValue({
sessions: [makeRuntimeLocalSession()],
issues: [],
scannedAt: SCANNED_AT
} satisfies AiVaultListResult)
resolveAiVaultSessionTitlesInWorker.mockReset()
resolveAiVaultSessionTitlesInWorker.mockResolvedValue({ titles: [] })
scanSshAiVaultSessions.mockReset()
scanSshAiVaultSessions.mockResolvedValue({
sessions: [makeSshSession()],
issues: [],
scannedAt: SCANNED_AT
} satisfies AiVaultListResult)
getActiveSshAiVaultHostInfos.mockReset()
getActiveSshAiVaultHostInfos.mockReturnValue([{ targetId: 'hub-owned-host' }])
requestActiveSshAiVaultSessionTitles.mockReset()
requestActiveSshAiVaultSessionTitles.mockResolvedValue({
titles: [{ agent: 'claude', sessionId: 'sess-ssh', title: 'SSH title' }]
})
})
afterEach(() => {
resetAiVaultSessionListCacheForTests()
})
it('routes an SSH execution host to that host instead of rejecting it', async () => {
const dispatcher = makeDispatcher()
const scoped = (await dispatcher.dispatch(
makeRequest('aiVault.listSessions', {
limit: 500,
executionHostId: SSH_HOST_OWNED_BY_RUNTIME
})
)) as { ok: boolean; result: AiVaultListResult }
expect(scoped.ok).toBe(true)
expect(scanAiVaultSessionsInWorker).not.toHaveBeenCalled()
expect(scanSshAiVaultSessions).toHaveBeenCalledWith(
'hub-owned-host',
expect.objectContaining({ limit: 500 })
)
expect(scoped.result.sessions.map((session) => session.executionHostId)).toEqual([
'ssh:hub-owned-host'
])
})
it('scans the identical host-local sources for the runtime ids it does accept', async () => {
const dispatcher = makeDispatcher()
await dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 500 }))
const baselineScanArgs = scanAiVaultSessionsInWorker.mock.calls[0]?.[0]
resetAiVaultSessionListCacheForTests()
const scoped = (await dispatcher.dispatch(
makeRequest('aiVault.listSessions', {
limit: 500,
executionHostId: 'runtime:some-other-env'
})
)) as { ok: boolean; result: AiVaultListResult }
const scopedScanArgs = scanAiVaultSessionsInWorker.mock.calls[1]?.[0]
expect(scoped.ok).toBe(true)
expect(scopedScanArgs).toEqual(baselineScanArgs)
expect(scanSshAiVaultSessions).not.toHaveBeenCalled()
expect(scoped.result.sessions.map((session) => session.filePath)).toEqual([
'/hub/home/.claude/projects/p/t.jsonl'
])
})
it('keeps unscoped scans host-local so old clients do not receive SSH rows', async () => {
const dispatcher = makeDispatcher()
const unscoped = (await dispatcher.dispatch(
makeRequest('aiVault.listSessions', { limit: 500 })
)) as { ok: boolean; result: AiVaultListResult }
expect(unscoped.ok).toBe(true)
expect(scanSshAiVaultSessions).not.toHaveBeenCalled()
expect(unscoped.result.sessions.map((session) => session.executionHostId)).toEqual(['local'])
})
it('merges this runtime’s connected SSH hosts only when the caller opts in', async () => {
const dispatcher = makeDispatcher()
const optedIn = (await dispatcher.dispatch(
makeRequest('aiVault.listSessions', {
limit: 500,
executionHostId: 'runtime:hub-runtime',
includeOwnedSshHosts: true
})
)) as { ok: boolean; result: AiVaultListResult }
expect(optedIn.ok).toBe(true)
expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1)
expect(scanSshAiVaultSessions).toHaveBeenCalledWith(
'hub-owned-host',
expect.objectContaining({ limit: 500 })
)
expect(optedIn.result.sessions.map((session) => session.executionHostId).sort()).toEqual([
'runtime:hub-runtime',
'ssh:hub-owned-host'
])
})
it('resolves SSH session titles on the transcript-owning host', async () => {
const dispatcher = makeDispatcher()
const requests = [{ agent: 'claude', sessionId: 'sess-ssh' }]
const response = await dispatcher.dispatch(
makeRequest('aiVault.resolveSessionTitles', {
requests,
executionHostId: SSH_HOST_OWNED_BY_RUNTIME
})
)
expect(response).toMatchObject({
ok: true,
result: { titles: [{ sessionId: 'sess-ssh', title: 'SSH title' }] }
})
expect(requestActiveSshAiVaultSessionTitles).toHaveBeenCalledWith(
'hub-owned-host',
{ requests },
expect.objectContaining({ signal: undefined })
)
expect(resolveAiVaultSessionTitlesInWorker).not.toHaveBeenCalled()
})
})
@@ -164,14 +164,17 @@ describe('aiVault.listSessions params schema', () => {
expect(parsed.success).toBe(false)
})
it('rejects non-runtime execution host ids before dispatch', () => {
it('rejects host ids that are neither a runtime nor an SSH target', () => {
expect(
AiVaultListSessionsParams.safeParse({ executionHostId: 'not-a-runtime-host' }).success
).toBe(false)
expect(AiVaultListSessionsParams.safeParse({ executionHostId: 'local' }).success).toBe(false)
expect(AiVaultListSessionsParams.safeParse({ executionHostId: 'ssh:dev-box' }).success).toBe(
false
true
)
expect(
AiVaultListSessionsParams.safeParse({ executionHostId: 'runtime:hub-runtime' }).success
).toBe(true)
})
})
+68 -24
View File
@@ -1,7 +1,16 @@
import { z } from 'zod'
import { defineMethod, type RpcMethod } from '../core'
import { OptionalBoolean } from '../schemas'
import { restampAiVaultListResult } from '../../../ai-vault/session-list-results'
import {
mergeAiVaultListResults,
restampAiVaultListResult
} from '../../../ai-vault/session-list-results'
import { parseAiVaultSessionTitlesResult } from '../../../ai-vault/session-title-result-validation'
import { scanSshAiVaultSessions } from '../../../ai-vault/ssh-session-list'
import {
getActiveSshAiVaultHostInfos,
requestActiveSshAiVaultSessionTitles
} from '../../../ipc/ssh'
import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT } from '../../../../shared/ai-vault-types'
import { AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT } from '../../../../shared/ai-vault-session-title'
import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../shared/execution-host'
@@ -12,17 +21,19 @@ import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../share
const AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096
const AI_VAULT_LIMIT_MAX = 2000
const executionHostIdSchema = z.string().transform((value, ctx): `runtime:${string}` => {
const parsed = parseExecutionHostId(value)
if (parsed?.kind === 'runtime') {
return parsed.id
}
ctx.addIssue({
code: 'custom',
message: 'Invalid runtime execution host id'
const executionHostIdSchema = z
.string()
.transform((value, ctx): `runtime:${string}` | `ssh:${string}` => {
const parsed = parseExecutionHostId(value)
if (parsed?.kind === 'runtime' || parsed?.kind === 'ssh') {
return parsed.id
}
ctx.addIssue({
code: 'custom',
message: 'Invalid execution host id'
})
return z.NEVER
})
return z.NEVER
})
export const AiVaultListSessionsParams = z
.object({
@@ -42,10 +53,13 @@ export const AiVaultListSessionsParams = z
// desktop parents) that send more than the bound.
.transform((paths) => paths.slice(0, AI_VAULT_SCOPE_PATHS_MAX_COUNT))
.optional(),
// Why: desktop/web callers name the runtime host they are addressing; mobile
// omits it. The scan itself is host-local either way, so the id must never
// change what is scanned — it only restamps the shared cached result.
executionHostId: executionHostIdSchema.optional()
// Why: desktop/web callers name the runtime host they are addressing; a
// paired client can also name an SSH host this runtime owns. Runtime ids
// still only restamp the host-local cache. SSH ids route to that host.
executionHostId: executionHostIdSchema.optional(),
// Why: omitted on old clients so mixed-version peers keep the host-local
// projection. New all-hosts callers opt in to this runtime's SSH inventory.
includeOwnedSshHosts: OptionalBoolean
})
.superRefine((params, ctx) => {
if (params.unlimited !== true && params.limit && params.limit > AI_VAULT_LIMIT_MAX) {
@@ -69,31 +83,61 @@ export const AiVaultSessionTitlesParams = z.object({
transcriptPath: z.string().min(1).max(32_768).optional()
})
)
.max(AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT)
.max(AI_VAULT_SESSION_TITLE_REQUEST_MAX_COUNT),
executionHostId: executionHostIdSchema.optional()
})
export const AI_VAULT_METHODS: RpcMethod[] = [
defineMethod({
name: 'aiVault.resolveSessionTitles',
params: AiVaultSessionTitlesParams,
handler: (params, { runtime, signal }) =>
runtime.resolveAiVaultSessionTitles(params.requests, signal)
handler: async (params, { runtime, signal }) => {
const parsed = params.executionHostId ? parseExecutionHostId(params.executionHostId) : null
if (parsed?.kind === 'ssh') {
try {
const result = await requestActiveSshAiVaultSessionTitles(
parsed.targetId,
{ requests: params.requests },
{ signal }
)
return result === null ? { titles: [] } : parseAiVaultSessionTitlesResult(result)
} catch {
return { titles: [] }
}
}
return runtime.resolveAiVaultSessionTitles(params.requests, signal)
}
}),
defineMethod({
name: 'aiVault.listSessions',
params: AiVaultListSessionsParams,
handler: async (params, { runtime }) => {
const result = await runtime.listAiVaultSessions({
const parsed = params.executionHostId ? parseExecutionHostId(params.executionHostId) : null
const listArgs = {
limit: params.unlimited ? undefined : params.limit,
unlimited: params.unlimited,
force: params.force,
scopePaths: params.scopePaths
})
}
if (parsed?.kind === 'ssh') {
return scanSshAiVaultSessions(parsed.targetId, listArgs)
}
const result = await runtime.listAiVaultSessions(listArgs)
// Why: web clients consume this response directly (no parent-side retag),
// so sessions must come back stamped as the runtime host they addressed.
return params.executionHostId
? restampAiVaultListResult(result, params.executionHostId)
: result
// so host-local sessions must come back stamped as the runtime they addressed.
const stamped =
parsed?.kind === 'runtime' ? restampAiVaultListResult(result, parsed.id) : result
if (params.includeOwnedSshHosts !== true) {
return stamped
}
const sshHosts = getActiveSshAiVaultHostInfos()
if (sshHosts.length === 0) {
return stamped
}
const sshResults = await Promise.all(
sshHosts.map((host) => scanSshAiVaultSessions(host.targetId, listArgs))
)
return mergeAiVaultListResults([stamped, ...sshResults], listArgs.limit, listArgs.unlimited)
}
}),
defineMethod({
+47 -1
View File
@@ -1609,7 +1609,8 @@ describe('web AI Vault preload API', () => {
limit: 25,
force: true,
scopePaths: ['/srv/app'],
executionHostId: 'runtime:web-env-1'
executionHostId: 'runtime:web-env-1',
includeOwnedSshHosts: true
}
}
])
@@ -1652,6 +1653,51 @@ describe('web AI Vault preload API', () => {
})
expect(runtimeCalls).toEqual([])
})
it('forwards SSH host scope to the paired runtime that owns the target', async () => {
const runtimeCalls: { method: string; params: unknown }[] = []
const scanResult = {
sessions: [],
issues: [],
scannedAt: '2026-08-12T00:00:00.000Z'
}
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(method: string, params?: unknown): Promise<RuntimeRpcResponse<unknown>> {
runtimeCalls.push({ method, params })
return Promise.resolve({
id: `call-${runtimeCalls.length}`,
ok: true,
result: scanResult,
_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.aiVault.listSessions({
executionHostScope: 'ssh:hub-owned-host',
limit: 25
})
).resolves.toEqual(scanResult)
expect(runtimeCalls).toEqual([
{
method: 'aiVault.listSessions',
params: {
limit: 25,
executionHostId: 'ssh:hub-owned-host'
}
}
])
})
})
describe('web UI preload API', () => {
+24 -6
View File
@@ -1578,24 +1578,42 @@ function createAiVaultApi(): NonNullable<Partial<PreloadApi>['aiVault']> {
const requestedScope = normalizeExecutionHostScope(
args?.executionHostScope ?? executionHostId
)
const parsedScope = parseExecutionHostId(requestedScope)
if (parsedScope?.kind === 'ssh') {
return callRuntimeResult<AiVaultListResult>('aiVault.listSessions', {
limit: args?.limit,
force: args?.force,
scopePaths: args?.scopePaths,
executionHostId: parsedScope.id
})
}
if (requestedScope !== 'all' && requestedScope !== executionHostId) {
return Promise.resolve(webAiVaultUnavailableResult(requestedScope))
}
// Why: no local filesystem in the browser, so every history scan runs on and is stamped as the paired runtime host.
// Why: no local filesystem in the browser, so every history scan runs on
// the paired runtime. `all` also asks that runtime for SSH hosts it owns.
return callRuntimeResult<AiVaultListResult>('aiVault.listSessions', {
limit: args?.limit,
force: args?.force,
scopePaths: args?.scopePaths,
executionHostId
executionHostId,
...(requestedScope === 'all' ? { includeOwnedSshHosts: true } : {})
})
},
resolveSessionTitles: (args: AiVaultSessionTitlesArgs) => {
const environment = requireActiveEnvironment()
const executionHostId = toRuntimeExecutionHostId(environment.id)
if (
args.executionHostScope &&
normalizeExecutionHostScope(args.executionHostScope) !== executionHostId
) {
const requestedScope = args.executionHostScope
? normalizeExecutionHostScope(args.executionHostScope)
: executionHostId
const parsedScope = parseExecutionHostId(requestedScope)
if (parsedScope?.kind === 'ssh') {
return callRuntimeResult<AiVaultSessionTitlesResult>('aiVault.resolveSessionTitles', {
requests: args.requests,
executionHostId: parsedScope.id
}).catch(() => ({ titles: [] }))
}
if (requestedScope !== executionHostId) {
return Promise.resolve({ titles: [] })
}
return callRuntimeResult<AiVaultSessionTitlesResult>('aiVault.resolveSessionTitles', {