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