fix(ai-vault): isolate runtime-owned SSH history hops

Keep desktop all-hosts runtime legs on the 3s budget and scan only
connected HUB-owned SSH hosts as their own timed legs. Contain owner-hop
transport failures as host issues, and block mobile resume of SSH-stamped
sessions onto the local worktree.
This commit is contained in:
Brennan Benson
2026-08-14 15:40:19 -07:00
parent aaa31c20e9
commit 3f50ab14d1
16 changed files with 396 additions and 88 deletions
@@ -160,6 +160,20 @@ describe('mobile AI Vault resume target guards', () => {
expect(mobileAiVaultResumeTargetBlockMessage('unknown')).toContain('local workspace')
})
it('does not resume an SSH-stamped session onto the active local worktree', () => {
const target = resolveMobileAiVaultSessionResumeTarget({
session: session({
executionHostId: 'ssh:hub-owned-host',
cwd: '/Users/ada/repo/app'
}),
activeWorktreeId: 'route-wt',
worktrees: [worktree({ worktreeId: 'route-wt', path: '/Users/ada/repo/app' })],
repos
})
expect(target.status).toBe('blocked')
expect(target.status === 'blocked' && target.message).toContain('SSH host')
})
it('resolves project-scope rows to the matched session worktree before the route worktree', () => {
const target = resolveMobileAiVaultSessionResumeTarget({
session: session({ cwd: '/Users/ada/repo/feature/src' }),
@@ -122,6 +122,13 @@ export function resolveMobileAiVaultSessionResumeTarget(args: {
folderWorkspaces?: readonly MobileAiVaultResumeFolderWorkspace[]
projectGroups?: readonly MobileAiVaultResumeProjectGroup[]
}): MobileAiVaultSessionResumeTarget {
if (parseExecutionHostId(args.session.executionHostId)?.kind === 'ssh') {
return {
status: 'blocked',
message:
'This session lives on an SSH host. Open it from the desktop app connected to that host.'
}
}
const sessionWorktree = resolveMobileAgentHistorySessionWorktree({
session: args.session,
worktrees: args.worktrees,
@@ -35,8 +35,9 @@ describe('runtime-owned SSH AI Vault inventory', () => {
ok: true,
result: {
targets: [
{ id: 'hub-owned-host', label: 'Hub host' },
{ id: 'runtime-ssh-recipe', label: 'Recipe VM' }
{ id: 'hub-owned-host', label: 'Hub host', connected: true },
{ id: 'offline-host', label: 'Offline', connected: false },
{ id: 'runtime-ssh-recipe', label: 'Recipe VM', connected: true }
]
}
})
@@ -45,7 +46,14 @@ describe('runtime-owned SSH AI Vault inventory', () => {
{
environmentId: 'hub-runtime',
targetId: 'hub-owned-host',
executionHostId: 'ssh:hub-owned-host'
executionHostId: 'ssh:hub-owned-host',
connected: true
},
{
environmentId: 'hub-runtime',
targetId: 'offline-host',
executionHostId: 'ssh:offline-host',
connected: false
}
])
})
@@ -104,6 +112,25 @@ describe('runtime-owned SSH AI Vault inventory', () => {
])
})
it('turns a runtime transport throw into an SSH-host issue', async () => {
mocks.callRuntimeEnvironment.mockRejectedValueOnce(new Error('runtime connect timed out'))
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',
message: 'runtime connect timed out'
})
])
})
it('turns an old-runtime host-id rejection into an SSH-host issue', async () => {
mocks.callRuntimeEnvironment.mockResolvedValueOnce({
ok: false,
@@ -22,6 +22,7 @@ export type RuntimeOwnedSshAiVaultHost = {
environmentId: string
targetId: string
executionHostId: `ssh:${string}`
connected?: boolean
}
export type RuntimeOwnedSshAiVaultScanOptions = {
@@ -35,13 +36,18 @@ export async function listRuntimeOwnedSshAiVaultTargets(
environmentId: string,
options: RuntimeOwnedSshAiVaultScanOptions = {}
): Promise<readonly RuntimeOwnedSshAiVaultHost[]> {
const response = await callRuntimeEnvironment(
userDataPath,
environmentId,
'ssh.listTargetSummaries',
undefined,
options.timeoutMs
)
let response: Awaited<ReturnType<typeof callRuntimeEnvironment>>
try {
response = await callRuntimeEnvironment(
userDataPath,
environmentId,
'ssh.listTargetSummaries',
undefined,
options.timeoutMs
)
} catch {
return []
}
if (response.ok !== true || !isTargetSummaryList(response.result)) {
return []
}
@@ -56,7 +62,8 @@ export async function listRuntimeOwnedSshAiVaultTargets(
{
environmentId,
targetId: target.id,
executionHostId: toSshExecutionHostId(target.id)
executionHostId: toSshExecutionHostId(target.id),
...(typeof target.connected === 'boolean' ? { connected: target.connected } : {})
}
]
})
@@ -70,8 +77,13 @@ export async function findRuntimeOwningSshAiVaultHost(
if (isRuntimeOwnedSshTargetId(targetId)) {
return null
}
for (const environment of listEnvironments(userDataPath)) {
const hosts = await listRuntimeOwnedSshAiVaultTargets(userDataPath, environment.id, options)
const environments = listEnvironments(userDataPath)
const inventories = await Promise.all(
environments.map((environment) =>
listRuntimeOwnedSshAiVaultTargets(userDataPath, environment.id, options)
)
)
for (const hosts of inventories) {
const match = hosts.find((host) => host.targetId === targetId)
if (match) {
return match
@@ -88,19 +100,28 @@ export async function scanRuntimeOwnedSshAiVaultSessions(
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
)
let response: Awaited<ReturnType<typeof callRuntimeEnvironment>>
try {
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
)
} catch (error) {
return aiVaultScanIssueResult({
executionHostId,
path: targetId,
message: error instanceof Error ? error.message : 'Remote Orca server is unavailable.'
})
}
if (response.ok !== true) {
return aiVaultScanIssueResult({
executionHostId,
@@ -128,12 +149,17 @@ export async function resolveRuntimeOwnedSshAiVaultSessionTitles(
args: AiVaultSessionTitlesArgs
): Promise<AiVaultSessionTitlesResult> {
const executionHostId: ExecutionHostId = toSshExecutionHostId(targetId)
const response = await callRuntimeEnvironment(
userDataPath,
environmentId,
'aiVault.resolveSessionTitles',
{ requests: args.requests, executionHostId }
)
let response: Awaited<ReturnType<typeof callRuntimeEnvironment>>
try {
response = await callRuntimeEnvironment(
userDataPath,
environmentId,
'aiVault.resolveSessionTitles',
{ requests: args.requests, executionHostId }
)
} catch {
return { titles: [] }
}
if (response.ok !== true) {
return { titles: [] }
}
@@ -151,7 +177,9 @@ function unsupportedSshHostMessage(message: string): string {
return message
}
function isTargetSummaryList(value: unknown): value is { targets: { id?: unknown }[] } {
function isTargetSummaryList(
value: unknown
): value is { targets: { id?: unknown; connected?: unknown }[] } {
return (
typeof value === 'object' &&
value !== null &&
+107 -33
View File
@@ -1,4 +1,5 @@
import { scanSshAiVaultSessions } from '../ai-vault/ssh-session-list'
import { abandonRemoteSessionScanOnCancel } from '../ai-vault/ai-vault-scan-cancellation'
import { aiVaultScanIssueResult, mergeAiVaultListResults } from '../ai-vault/session-list-results'
import { requestedAiVaultSessionDepth } from '../../shared/ai-vault-session-depth'
import {
@@ -34,6 +35,9 @@ export const AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS = 20_000
export type AiVaultHostScopeScanOptions = {
getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[]
scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner
listRuntimeOwnedSshAiVaultTargets?: (
environmentId: string
) => Promise<readonly RuntimeOwnedSshAiVaultHost[]>
findRuntimeOwningSshAiVaultHost?: (targetId: string) => Promise<RuntimeOwnedSshAiVaultHost | null>
scanRuntimeOwnedSshAiVaultSessions?: RuntimeOwnedSshAiVaultScanner
scanLocal: (
@@ -104,45 +108,60 @@ async function scanAllAiVaultHosts(
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: {
// Why: keep runtime host-local legs on the 3s budget. Folding that runtime's
// SSH inventory into the same RPC made one slow SSH host fail the runtime row.
const localSshTargetIds = new Set(sshHosts.hostInfos.map((hostInfo) => hostInfo.targetId))
const [scannedResults, runtimeOwnedSshResults] = await Promise.all([
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_RUNTIME_TIMEOUT_MS,
includeOwnedSshHosts: true
}
})
})
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
}
})
})
)
]),
scanRuntimeOwnedSshHosts(
args,
signal,
cacheKey,
depth,
scopePaths,
localSshTargetIds,
runtimeHosts.hostInfos,
options
)
])
return mergeAiVaultListResults(
[
...scannedResults,
...runtimeOwnedSshResults,
...(runtimeHosts.issue ? [runtimeHosts.issue] : []),
...(sshHosts.issue ? [sshHosts.issue] : [])
],
@@ -151,6 +170,61 @@ async function scanAllAiVaultHosts(
)
}
async function scanRuntimeOwnedSshHosts(
args: AiVaultListArgs | undefined,
signal: AbortSignal | undefined,
cacheKey: string,
depth: ReturnType<typeof requestedAiVaultSessionDepth>,
scopePaths: readonly string[],
localSshTargetIds: ReadonlySet<string>,
runtimeHosts: readonly RuntimeAiVaultHostInfo[],
options: AiVaultHostScopeScanOptions
): Promise<AiVaultListResult[]> {
const listTargets = options.listRuntimeOwnedSshAiVaultTargets
const scanOwned = options.scanRuntimeOwnedSshAiVaultSessions
if (!listTargets || !scanOwned || runtimeHosts.length === 0) {
return []
}
const inventories = await Promise.all(
runtimeHosts.map(async (hostInfo) => {
try {
return await listTargets(hostInfo.environmentId)
} catch {
return []
}
})
)
const seen = new Set(localSshTargetIds)
const ownedHosts = inventories.flat().filter((host) => {
// Old hosts omit `connected`; those cannot scan ssh: ids anyway.
if (host.connected !== true) {
return false
}
if (seen.has(host.targetId)) {
return false
}
seen.add(host.targetId)
return true
})
return Promise.all(
ownedHosts.map((host) =>
scanHostLegWithCache({
cacheKey: `${cacheKey}|${host.executionHostId}`,
depth,
scopePaths,
force: args?.force === true,
scan: () =>
abandonRemoteSessionScanOnCancel(
scanOwned(host.environmentId, host.targetId, args ?? {}, {
timeoutMs: AI_VAULT_ALL_HOST_SSH_TIMEOUT_MS
}),
signal
)
})
)
)
}
async function scanLocalAiVaultSessionsAsIssue(
scanLocal: AiVaultHostScopeScanOptions['scanLocal'],
args: AiVaultListArgs | undefined,
@@ -95,6 +95,52 @@ describe('runtime-owned SSH AI Vault routing', () => {
])
})
it('scans runtime-owned SSH hosts as their own all-hosts legs', async () => {
const listRuntimeOwnedSshAiVaultTargets = vi.fn().mockResolvedValue([
{
environmentId: 'hub-runtime',
targetId: 'hub-owned-host',
executionHostId: 'ssh:hub-owned-host',
connected: true
},
{
environmentId: 'hub-runtime',
targetId: 'offline-host',
executionHostId: 'ssh:offline-host',
connected: false
}
])
const scanRuntimeOwnedSshAiVaultSessions = vi
.fn()
.mockResolvedValue(result([session('ssh:hub-owned-host', 'hub-ssh-session')]))
registerAiVaultHandlers({
getActiveRuntimeAiVaultHostInfos: () => [
{ environmentId: 'hub-runtime', executionHostId: 'runtime:hub-runtime' }
],
listRuntimeOwnedSshAiVaultTargets,
scanRuntimeOwnedSshAiVaultSessions
})
const scanned = await _internals.listAiVaultSessions({ executionHostScope: 'all' })
expect(listRuntimeOwnedSshAiVaultTargets).toHaveBeenCalledWith('hub-runtime')
expect(scanRuntimeOwnedSshAiVaultSessions).toHaveBeenCalledTimes(1)
expect(scanRuntimeOwnedSshAiVaultSessions).toHaveBeenCalledWith(
'hub-runtime',
'hub-owned-host',
{ executionHostScope: 'all' },
{ timeoutMs: 20_000 }
)
expect(scanned.sessions).toEqual(
expect.arrayContaining([
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 })
+14 -6
View File
@@ -25,12 +25,20 @@ export async function scanSshAiVaultSessionsByOwner(args: {
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
})
try {
const owner = await args.findOwner(args.targetId)
if (owner) {
return await args.scanOwned(owner.environmentId, owner.targetId, args.listArgs ?? {}, {
timeoutMs: args.timeoutMs
})
}
} catch {
// Fall through to the local SSH issue path so one dead pairing cannot
// discard sibling all-hosts legs.
}
}
return scanSshAiVaultSessions(args.targetId, args.listArgs, { signal: args.signal })
return scanSshAiVaultSessions(args.targetId, args.listArgs, {
signal: args.signal,
timeoutMs: args.timeoutMs
})
}
+1 -1
View File
@@ -324,7 +324,7 @@ describe('listAiVaultSessions host routing', () => {
{
executionHostScope: 'runtime:remote-server'
},
expect.objectContaining({ timeoutMs: expect.any(Number), includeOwnedSshHosts: true })
expect.objectContaining({ timeoutMs: expect.any(Number) })
)
expect(result.sessions.map((entry) => entry.executionHostId)).toEqual([
'runtime:remote-server',
+4
View File
@@ -48,6 +48,9 @@ type AiVaultHandlerOptions = AiVaultSessionSources &
getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[]
scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner
resolveRuntimeAiVaultSessionTitles?: RuntimeAiVaultSessionTitleResolver
listRuntimeOwnedSshAiVaultTargets?: (
environmentId: string
) => Promise<readonly RuntimeOwnedSshAiVaultHost[]>
findRuntimeOwningSshAiVaultHost?: (
targetId: string
) => Promise<RuntimeOwnedSshAiVaultHost | null>
@@ -103,6 +106,7 @@ async function listAiVaultSessions(
scanAiVaultSessionsByHostScope(args, executionHostScope, scanSignal, key, {
getActiveRuntimeAiVaultHostInfos: handlerOptions.getActiveRuntimeAiVaultHostInfos,
scanRuntimeAiVaultSessions: handlerOptions.scanRuntimeAiVaultSessions,
listRuntimeOwnedSshAiVaultTargets: handlerOptions.listRuntimeOwnedSshAiVaultTargets,
findRuntimeOwningSshAiVaultHost: handlerOptions.findRuntimeOwningSshAiVaultHost,
scanRuntimeOwnedSshAiVaultSessions: handlerOptions.scanRuntimeOwnedSshAiVaultSessions,
scanLocal: scanLocalAiVaultSessions
+9 -1
View File
@@ -25,6 +25,7 @@ import { registerRuntimeHandlers } from './runtime'
import { registerRuntimeEnvironmentHandlers } from './runtime-environments'
import { registerEphemeralVmHandlers } from './ephemeral-vm'
import { registerAiVaultHandlers } from './ai-vault'
import { AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS } from './ai-vault-host-scope-scan'
import { registerNativeChatHandlers } from './native-chat'
import { registerNotificationHandlers } from './notifications'
import { registerNotebookHandlers } from './notebook'
@@ -93,6 +94,7 @@ import {
} from '../ai-vault/runtime-session-scanner'
import {
findRuntimeOwningSshAiVaultHost,
listRuntimeOwnedSshAiVaultTargets,
resolveRuntimeOwnedSshAiVaultSessionTitles,
scanRuntimeOwnedSshAiVaultSessions
} from '../ai-vault/runtime-owned-ssh-session-list'
@@ -229,8 +231,14 @@ export function registerCoreHandlers(
scanRuntimeAiVaultSessions(app.getPath('userData'), environmentId, args, options),
resolveRuntimeAiVaultSessionTitles: async (environmentId, args) =>
resolveRuntimeAiVaultSessionTitles(app.getPath('userData'), environmentId, args),
listRuntimeOwnedSshAiVaultTargets: async (environmentId) =>
listRuntimeOwnedSshAiVaultTargets(app.getPath('userData'), environmentId, {
timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS
}),
findRuntimeOwningSshAiVaultHost: async (targetId) =>
findRuntimeOwningSshAiVaultHost(app.getPath('userData'), targetId),
findRuntimeOwningSshAiVaultHost(app.getPath('userData'), targetId, {
timeoutMs: AI_VAULT_ALL_HOST_RUNTIME_TIMEOUT_MS
}),
scanRuntimeOwnedSshAiVaultSessions: async (environmentId, targetId, args, options) =>
scanRuntimeOwnedSshAiVaultSessions(
app.getPath('userData'),
@@ -138,7 +138,8 @@ describe('a runtime can scan an SSH host that it owns', () => {
expect(scanAiVaultSessionsInWorker).not.toHaveBeenCalled()
expect(scanSshAiVaultSessions).toHaveBeenCalledWith(
'hub-owned-host',
expect.objectContaining({ limit: 500 })
expect.objectContaining({ limit: 500 }),
{ timeoutMs: 20_000 }
)
expect(scoped.result.sessions.map((session) => session.executionHostId)).toEqual([
'ssh:hub-owned-host'
@@ -195,7 +196,8 @@ describe('a runtime can scan an SSH host that it owns', () => {
expect(scanAiVaultSessionsInWorker).toHaveBeenCalledTimes(1)
expect(scanSshAiVaultSessions).toHaveBeenCalledWith(
'hub-owned-host',
expect.objectContaining({ limit: 500 })
expect.objectContaining({ limit: 500 }),
{ timeoutMs: 20_000 }
)
expect(optedIn.result.sessions.map((session) => session.executionHostId).sort()).toEqual([
'runtime:hub-runtime',
+19 -9
View File
@@ -20,6 +20,9 @@ import { LOCAL_EXECUTION_HOST_ID, parseExecutionHostId } from '../../../../share
// traversal); the count/length caps mirror the worktree-schemas bounding style.
const AI_VAULT_SCOPE_PATH_MAX_LENGTH = 4096
const AI_VAULT_LIMIT_MAX = 2000
// Keep in lockstep with desktop all-hosts SSH legs so a stuck relay cannot hang
// web/mobile includeOwnedSshHosts scans or a named ssh: hop.
const OWNED_SSH_SCAN_TIMEOUT_MS = 20_000
const executionHostIdSchema = z
.string()
@@ -120,23 +123,30 @@ export const AI_VAULT_METHODS: RpcMethod[] = [
scopePaths: params.scopePaths
}
if (parsed?.kind === 'ssh') {
return scanSshAiVaultSessions(parsed.targetId, listArgs)
return scanSshAiVaultSessions(parsed.targetId, listArgs, {
timeoutMs: OWNED_SSH_SCAN_TIMEOUT_MS
})
}
const sshHosts = params.includeOwnedSshHosts === true ? getActiveSshAiVaultHostInfos() : []
const sshPromise =
sshHosts.length > 0
? Promise.all(
sshHosts.map((host) =>
scanSshAiVaultSessions(host.targetId, listArgs, {
timeoutMs: OWNED_SSH_SCAN_TIMEOUT_MS
})
)
)
: Promise.resolve([])
const result = await runtime.listAiVaultSessions(listArgs)
// Why: web clients consume this response directly (no parent-side retag),
// 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) {
const sshResults = await sshPromise
if (sshResults.length === 0) {
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)
}
}),
+7 -1
View File
@@ -116,6 +116,12 @@ describe('ssh RPC methods', () => {
}
]
listRegisteredSshTargetsMock.mockReturnValueOnce(targets)
getRegisteredSshStateMock.mockReturnValueOnce({
targetId: 'ssh-1',
status: 'connected',
error: null,
reconnectAttempt: 0
})
const runtime = { getRuntimeId: () => 'test-runtime' } as unknown as OrcaRuntimeService
const dispatcher = new RpcDispatcher({ runtime, methods: SSH_METHODS })
@@ -123,7 +129,7 @@ describe('ssh RPC methods', () => {
expect(response).toMatchObject({
ok: true,
result: { targets: [{ id: 'ssh-1', label: 'Dev box' }] }
result: { targets: [{ id: 'ssh-1', label: 'Dev box', connected: true }] }
})
expect(JSON.stringify(response)).not.toContain('dev.internal')
expect(JSON.stringify(response)).not.toContain('/secret/key')
+15 -2
View File
@@ -12,10 +12,22 @@ const SshTarget = z.object({
targetId: z.string().min(1)
})
function listRegisteredSshTargetSummaries(): { id: string; label: string }[] {
function listRegisteredSshTargetIdentities(): { id: string; label: string }[] {
return listRegisteredSshTargets().map(({ id, label }) => ({ id, label }))
}
function listRegisteredSshTargetSummaries(): {
id: string
label: string
connected: boolean
}[] {
return listRegisteredSshTargets().map(({ id, label }) => ({
id,
label,
connected: getRegisteredSshState(id)?.status === 'connected'
}))
}
export const SSH_METHODS: RpcMethod[] = [
defineMethod({
name: 'ssh.getState',
@@ -40,12 +52,13 @@ export const SSH_METHODS: RpcMethod[] = [
name: 'ssh.listTargets',
params: null,
// Why: legacy clients can call this method directly, so it must preserve the same HUB-private secret boundary.
handler: () => ({ targets: listRegisteredSshTargetSummaries() })
handler: () => ({ targets: listRegisteredSshTargetIdentities() })
}),
defineMethod({
name: 'ssh.listTargetSummaries',
params: null,
// Why: paired clients need display identity only; SSH addresses, jump chains, and credentials remain HUB-private.
// `connected` is optional for old readers and lets all-hosts skip disconnected targets.
handler: () => ({ targets: listRegisteredSshTargetSummaries() })
}),
defineMethod({
@@ -1954,6 +1954,43 @@ describe('web AI Vault preload API', () => {
}
])
})
it('turns an old-runtime SSH host-id rejection into a host issue', async () => {
vi.doMock('./web-runtime-client', () => ({
WebRuntimeClient: class {
call(): Promise<RuntimeRpcResponse<unknown>> {
return Promise.resolve({
id: 'call-1',
ok: false,
error: { code: 'invalid_params', message: 'Invalid runtime execution host id' },
_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'
})
).resolves.toEqual({
sessions: [],
issues: [
expect.objectContaining({
executionHostId: 'ssh:hub-owned-host',
message: expect.stringContaining('cannot scan Agent Session History on its SSH hosts')
})
],
scannedAt: expect.any(String)
})
})
})
describe('web UI preload API', () => {
+24
View File
@@ -1611,6 +1611,12 @@ function createAiVaultApi(): NonNullable<Partial<PreloadApi>['aiVault']> {
force: args?.force,
scopePaths: args?.scopePaths,
executionHostId: parsedScope.id
}).catch((error: unknown) => {
const message = error instanceof Error ? error.message : ''
if (/invalid (runtime )?execution host id/i.test(message)) {
return webAiVaultUnsupportedSshHostResult(parsedScope.id)
}
throw error
})
}
if (requestedScope !== 'all' && requestedScope !== executionHostId) {
@@ -1687,6 +1693,24 @@ function webAiVaultUnavailableResult(executionHostId: ExecutionHostId): AiVaultL
}
}
function webAiVaultUnsupportedSshHostResult(executionHostId: ExecutionHostId): AiVaultListResult {
return {
sessions: [],
issues: [
{
executionHostId,
agent: 'codex',
path: executionHostId,
message: translate(
'auto.web.webPreloadApi.aiVaultUnsupportedSshHost',
'This Orca server cannot scan Agent Session History on its SSH hosts. Update the server and try again.'
)
}
],
scannedAt: new Date().toISOString()
}
}
function createReposApi(): NonNullable<Partial<PreloadApi>['repos']> {
return {
list: async () => {