From 3f50ab14d1b4143cbc17de9073722fa48848346f Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 12 Aug 2026 20:43:58 -0700 Subject: [PATCH] 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. --- .../agent-history-resume-target.test.ts | 14 ++ .../agent-history-resume-target.ts | 7 + .../runtime-owned-ssh-session-list.test.ts | 33 ++++- .../runtime-owned-ssh-session-list.ts | 88 +++++++---- src/main/ipc/ai-vault-host-scope-scan.ts | 140 +++++++++++++----- .../ipc/ai-vault-runtime-owned-ssh.test.ts | 46 ++++++ src/main/ipc/ai-vault-runtime-owned-ssh.ts | 20 ++- src/main/ipc/ai-vault.test.ts | 2 +- src/main/ipc/ai-vault.ts | 4 + src/main/ipc/register-core-handlers.ts | 10 +- .../ai-vault-runtime-ssh-scope.repro.test.ts | 6 +- src/main/runtime/rpc/methods/ai-vault.ts | 28 ++-- src/main/runtime/rpc/methods/ssh.test.ts | 8 +- src/main/runtime/rpc/methods/ssh.ts | 17 ++- src/renderer/src/web/web-preload-api.test.ts | 37 +++++ src/renderer/src/web/web-preload-api.ts | 24 +++ 16 files changed, 396 insertions(+), 88 deletions(-) diff --git a/mobile/src/agent-history/agent-history-resume-target.test.ts b/mobile/src/agent-history/agent-history-resume-target.test.ts index d59feb98e15..61571a4b5d5 100644 --- a/mobile/src/agent-history/agent-history-resume-target.test.ts +++ b/mobile/src/agent-history/agent-history-resume-target.test.ts @@ -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' }), diff --git a/mobile/src/agent-history/agent-history-resume-target.ts b/mobile/src/agent-history/agent-history-resume-target.ts index a63a7ca92c7..26ea458139e 100644 --- a/mobile/src/agent-history/agent-history-resume-target.ts +++ b/mobile/src/agent-history/agent-history-resume-target.ts @@ -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, diff --git a/src/main/ai-vault/runtime-owned-ssh-session-list.test.ts b/src/main/ai-vault/runtime-owned-ssh-session-list.test.ts index 48faeef19ef..2effc916147 100644 --- a/src/main/ai-vault/runtime-owned-ssh-session-list.test.ts +++ b/src/main/ai-vault/runtime-owned-ssh-session-list.test.ts @@ -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, diff --git a/src/main/ai-vault/runtime-owned-ssh-session-list.ts b/src/main/ai-vault/runtime-owned-ssh-session-list.ts index f73c8d7c9eb..1b49cb37ccb 100644 --- a/src/main/ai-vault/runtime-owned-ssh-session-list.ts +++ b/src/main/ai-vault/runtime-owned-ssh-session-list.ts @@ -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 { - const response = await callRuntimeEnvironment( - userDataPath, - environmentId, - 'ssh.listTargetSummaries', - undefined, - options.timeoutMs - ) + let response: Awaited> + 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 { 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> + 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 { const executionHostId: ExecutionHostId = toSshExecutionHostId(targetId) - const response = await callRuntimeEnvironment( - userDataPath, - environmentId, - 'aiVault.resolveSessionTitles', - { requests: args.requests, executionHostId } - ) + let response: Awaited> + 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 && diff --git a/src/main/ipc/ai-vault-host-scope-scan.ts b/src/main/ipc/ai-vault-host-scope-scan.ts index ef0577d5c65..e2907cd945c 100644 --- a/src/main/ipc/ai-vault-host-scope-scan.ts +++ b/src/main/ipc/ai-vault-host-scope-scan.ts @@ -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 findRuntimeOwningSshAiVaultHost?: (targetId: string) => Promise 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, + scopePaths: readonly string[], + localSshTargetIds: ReadonlySet, + runtimeHosts: readonly RuntimeAiVaultHostInfo[], + options: AiVaultHostScopeScanOptions +): Promise { + 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, diff --git a/src/main/ipc/ai-vault-runtime-owned-ssh.test.ts b/src/main/ipc/ai-vault-runtime-owned-ssh.test.ts index ba1e45b6365..1ae9d2bb74d 100644 --- a/src/main/ipc/ai-vault-runtime-owned-ssh.test.ts +++ b/src/main/ipc/ai-vault-runtime-owned-ssh.test.ts @@ -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 }) diff --git a/src/main/ipc/ai-vault-runtime-owned-ssh.ts b/src/main/ipc/ai-vault-runtime-owned-ssh.ts index 1b73a6c3454..2955ae1708a 100644 --- a/src/main/ipc/ai-vault-runtime-owned-ssh.ts +++ b/src/main/ipc/ai-vault-runtime-owned-ssh.ts @@ -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 + }) } diff --git a/src/main/ipc/ai-vault.test.ts b/src/main/ipc/ai-vault.test.ts index b0a2910f564..13b2bc51926 100644 --- a/src/main/ipc/ai-vault.test.ts +++ b/src/main/ipc/ai-vault.test.ts @@ -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', diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 428088fcff8..47c83bd76e9 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -48,6 +48,9 @@ type AiVaultHandlerOptions = AiVaultSessionSources & getActiveRuntimeAiVaultHostInfos?: () => readonly RuntimeAiVaultHostInfo[] scanRuntimeAiVaultSessions?: RuntimeAiVaultScanner resolveRuntimeAiVaultSessionTitles?: RuntimeAiVaultSessionTitleResolver + listRuntimeOwnedSshAiVaultTargets?: ( + environmentId: string + ) => Promise findRuntimeOwningSshAiVaultHost?: ( targetId: string ) => Promise @@ -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 diff --git a/src/main/ipc/register-core-handlers.ts b/src/main/ipc/register-core-handlers.ts index 13b3a813aab..aeeed968281 100644 --- a/src/main/ipc/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers.ts @@ -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'), diff --git a/src/main/runtime/rpc/methods/ai-vault-runtime-ssh-scope.repro.test.ts b/src/main/runtime/rpc/methods/ai-vault-runtime-ssh-scope.repro.test.ts index f76d52cee9d..b793bf9119a 100644 --- a/src/main/runtime/rpc/methods/ai-vault-runtime-ssh-scope.repro.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault-runtime-ssh-scope.repro.test.ts @@ -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', diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index 005d6717192..74b214720f8 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -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) } }), diff --git a/src/main/runtime/rpc/methods/ssh.test.ts b/src/main/runtime/rpc/methods/ssh.test.ts index d19f5f85a53..d4cd942490f 100644 --- a/src/main/runtime/rpc/methods/ssh.test.ts +++ b/src/main/runtime/rpc/methods/ssh.test.ts @@ -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') diff --git a/src/main/runtime/rpc/methods/ssh.ts b/src/main/runtime/rpc/methods/ssh.ts index 0e970af6864..07010dbeb1f 100644 --- a/src/main/runtime/rpc/methods/ssh.ts +++ b/src/main/runtime/rpc/methods/ssh.ts @@ -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({ diff --git a/src/renderer/src/web/web-preload-api.test.ts b/src/renderer/src/web/web-preload-api.test.ts index bde6425ccfd..8f4fe422b41 100644 --- a/src/renderer/src/web/web-preload-api.test.ts +++ b/src/renderer/src/web/web-preload-api.test.ts @@ -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> { + 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', () => { diff --git a/src/renderer/src/web/web-preload-api.ts b/src/renderer/src/web/web-preload-api.ts index c824adc24e7..3f96a0c3dfd 100644 --- a/src/renderer/src/web/web-preload-api.ts +++ b/src/renderer/src/web/web-preload-api.ts @@ -1611,6 +1611,12 @@ function createAiVaultApi(): NonNullable['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['repos']> { return { list: async () => {