mirror of
https://github.com/stablyai/orca.git
synced 2026-09-29 08:03:20 +00:00
fix(cli): bound and verify paired host inventory probes
This commit is contained in:
@@ -63,7 +63,13 @@ List every machine the current Orca host can target and the selector for each on
|
||||
orca host list --json
|
||||
```
|
||||
|
||||
The result includes this machine, its registered [SSH targets](/docs/ssh), and paired [Remote Orca Servers](/docs/remote-servers). Use `--host local` for this machine, `--host ssh:<target-id>` for an SSH target, and `--environment <server-name>` for a paired server. SSH and paired-server rows include the detected remote platform (`linux`, `darwin`, or `win32`) after the host responds; older or unavailable hosts report `platform unknown`. They also include `connected` and, when known, the host `connectionStatus`. SSH labels and paired-server names also resolve when they are unique; use the IDs from `host list` when names collide. If you put a machine name on the wrong selector, Orca reports the matching machine and the flag to use instead of returning an empty result.
|
||||
The result includes this machine, all its registered [SSH targets](/docs/ssh), and all paired [Remote Orca Servers](/docs/remote-servers)—not only connected hosts. Use `--host local`, `--host ssh:<target-id>`, or `--environment <environment-id>` respectively. Unique SSH labels and paired-server names also resolve; the printed IDs avoid name collisions.
|
||||
|
||||
Platform (`linux`, `darwin`, or `win32`) comes from the host, not its name or the machine running the CLI. SSH rows use the app's known SSH platform and connection lifecycle. Paired servers are probed with existing `status.get` / `host.platform` RPCs, so a server update is not normally required. If neither RPC supplies the platform, the output says `platform unknown`.
|
||||
|
||||
Paired connectivity is a **fresh CLI probe**, marked `[probe]` in text and `connectionSource: "probe"` in JSON; it does not describe or change the desktop's persistent connection. A successful status probe reports `connected: true`. Failed or unattempted probes retain the row, omit `connected`, and report `connectionStatus: "unknown"` plus a safe `probeError` (`runtime_timeout`, `status_unavailable`, or `probe_failed`). Unknown does not mean the machine or its processes stopped. No stale platform is guessed for an unreachable host.
|
||||
|
||||
Probes use at most four concurrent connections and a five-second total paired-server scan budget, including legacy platform fallbacks. They do not change saved pairing metadata or last-used ordering. Run `orca environment list` for a saved-server listing without network probes.
|
||||
|
||||
## Runtime commands
|
||||
|
||||
|
||||
@@ -57,6 +57,24 @@ ORCA status --json
|
||||
|
||||
Prefer `--json` for agent-driven calls. If the CLI is missing, say so explicitly instead of inspecting source files first.
|
||||
|
||||
## Discover Hosts
|
||||
|
||||
Run `ORCA host list --json` to discover this machine, all registered SSH targets,
|
||||
and all saved paired Orca servers. Use the printed selector: `--host local`,
|
||||
`--host ssh:<target-id>`, or `--environment <environment-id>`.
|
||||
|
||||
Platform comes from the owning host, not its name. SSH connectivity is the local
|
||||
app's known SSH state. Paired connectivity is a fresh read-only CLI probe, marked
|
||||
`connectionSource: "probe"` / `[probe]`, not the desktop's persistent connection.
|
||||
Unknown connectivity omits `connected`; never interpret that as `false` or as
|
||||
evidence that remote processes exited. Failed probes retain the host row and a safe
|
||||
`probeError`. An unavailable SSH inventory produces an explicit incomplete-list warning.
|
||||
|
||||
Paired probes have a five-second scan budget and at most four concurrent sockets;
|
||||
they do not change pairing metadata or last-used ordering. Older servers fall back
|
||||
to `host.platform` when `status.get` lacks platform. Use `ORCA environment list`
|
||||
for a saved-server listing without network probes.
|
||||
|
||||
## Full Handoffs
|
||||
|
||||
A full handoff transfers ownership to another agent or worktree, then the original agent stops. Treat requests phrased as "hand off", "handoff", "handover", "give this to another agent", "give this to another worktree", "another agent", or "another worktree" as full handoffs unless the user explicitly asks to supervise, monitor, wait for results, track completion, coordinate a DAG, use decision gates, or manage ask/reply.
|
||||
|
||||
File diff suppressed because one or more lines are too long
+21
-13
@@ -216,6 +216,8 @@ function localCliErrorData(error: unknown, context: CliErrorContext): unknown {
|
||||
}
|
||||
|
||||
export type HostListEntry = {
|
||||
connectionSource?: 'probe'
|
||||
probeError?: string
|
||||
kind: 'local' | 'ssh' | 'environment'
|
||||
name: string
|
||||
id: string
|
||||
@@ -227,30 +229,36 @@ export type HostListEntry = {
|
||||
|
||||
// Why: the selector column is the point of this command — the name alone is what callers already
|
||||
// had, and passing it on the wrong axis is the mistake this output exists to prevent.
|
||||
export function formatHostList(result: { hosts: HostListEntry[] }): string {
|
||||
export function formatHostList(result: { hosts: HostListEntry[]; warnings?: string[] }): string {
|
||||
const kindLabel: Record<HostListEntry['kind'], string> = {
|
||||
local: 'local',
|
||||
ssh: 'ssh target',
|
||||
environment: 'orca server'
|
||||
}
|
||||
return result.hosts
|
||||
.map(
|
||||
(host) =>
|
||||
`${kindLabel[host.kind].padEnd(11)} ${host.name} ${host.platform ?? 'platform unknown'} ${formatHostConnection(host)} -> ${host.selector}`
|
||||
)
|
||||
.join('\n')
|
||||
const rows = result.hosts.map(
|
||||
(host) =>
|
||||
`${kindLabel[host.kind].padEnd(11)} ${host.name} ${host.platform ?? 'platform unknown'} ${formatHostConnection(host)} -> ${host.selector}`
|
||||
)
|
||||
return [...rows, ...(result.warnings ?? []).map((warning) => `Warning: ${warning}`)].join('\n')
|
||||
}
|
||||
|
||||
function formatHostConnection(host: HostListEntry): string {
|
||||
if (host.kind === 'local') {
|
||||
return ''
|
||||
}
|
||||
if (host.connected === undefined) {
|
||||
return `connection unknown${host.connectionStatus ? ` (${host.connectionStatus})` : ''}`
|
||||
}
|
||||
return host.connected
|
||||
? `connected${host.connectionStatus ? ` (${host.connectionStatus})` : ''}`
|
||||
: `not connected${host.connectionStatus ? ` (${host.connectionStatus})` : ''}`
|
||||
const label =
|
||||
host.connected === undefined
|
||||
? 'connection unknown'
|
||||
: host.connected
|
||||
? 'connected'
|
||||
: 'not connected'
|
||||
const detail =
|
||||
host.probeError ??
|
||||
(host.connectionStatus &&
|
||||
!['connected', 'disconnected', 'unknown'].includes(host.connectionStatus)
|
||||
? host.connectionStatus
|
||||
: undefined)
|
||||
return `${label}${host.connectionSource === 'probe' ? ' [probe]' : ''}${detail ? ` (${detail})` : ''}`
|
||||
}
|
||||
|
||||
export function formatCliStatus(status: CliStatusResult): string {
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import {
|
||||
formatEnvironment,
|
||||
formatEnvironmentList,
|
||||
formatHostList,
|
||||
printResult,
|
||||
type HostListEntry
|
||||
} from '../format'
|
||||
import { formatEnvironment, formatEnvironmentList, formatHostList, printResult } from '../format'
|
||||
import { listSshTargets } from '../host-selector-alternatives'
|
||||
import { getDefaultUserDataPath, RuntimeClientError } from '../runtime-client'
|
||||
import { RuntimeClient } from '../runtime-client'
|
||||
import type { RuntimeRpcSuccess } from '../runtime-client'
|
||||
import { rejectRemoteSelectionFlags } from '../remote-selection-flag-rejection'
|
||||
import {
|
||||
isConnectedRuntimeHostState,
|
||||
runtimeHostConnectionState,
|
||||
type RuntimeHostConnectionState
|
||||
} from '../../shared/runtime-host-connection-state'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
import { listPairedEnvironmentHosts } from './paired-host-inventory'
|
||||
import { redactRuntimeEnvironment } from '../../shared/runtime-environments'
|
||||
import {
|
||||
addEnvironmentFromPairingCode,
|
||||
@@ -53,7 +41,17 @@ export const ENVIRONMENT_HANDLERS: Record<string, CommandHandler> = {
|
||||
'`orca host list`. It answers from this machine\u2019s own pairing store, so a routed answer would name servers paired with a different machine.',
|
||||
'Run `orca host list` on that machine to see the SSH targets registered there.'
|
||||
)
|
||||
const sshTargets = (await listSshTargets(client)).map((target) => ({
|
||||
const warnings: string[] = []
|
||||
const [targets, environments] = await Promise.all([
|
||||
listSshTargets(client, { inventory: true }).catch(() => {
|
||||
warnings.push(
|
||||
'SSH inventory unavailable; this listing is incomplete. Check the local Orca runtime and retry.'
|
||||
)
|
||||
return []
|
||||
}),
|
||||
listPairedEnvironmentHosts(getDefaultUserDataPath())
|
||||
])
|
||||
const sshTargets = targets.map((target) => ({
|
||||
kind: 'ssh' as const,
|
||||
name: target.label,
|
||||
id: target.id,
|
||||
@@ -62,7 +60,6 @@ export const ENVIRONMENT_HANDLERS: Record<string, CommandHandler> = {
|
||||
...(target.connectionStatus ? { connectionStatus: target.connectionStatus } : {}),
|
||||
...(target.remotePlatform ? { platform: target.remotePlatform } : {})
|
||||
}))
|
||||
const environments = await listPairedEnvironmentHosts(getDefaultUserDataPath())
|
||||
const hosts = [
|
||||
{
|
||||
kind: 'local' as const,
|
||||
@@ -74,7 +71,11 @@ export const ENVIRONMENT_HANDLERS: Record<string, CommandHandler> = {
|
||||
...sshTargets,
|
||||
...environments
|
||||
]
|
||||
printResult(localSuccess({ hosts }), json, formatHostList)
|
||||
printResult(
|
||||
localSuccess({ hosts, ...(warnings.length ? { warnings } : {}) }),
|
||||
json,
|
||||
formatHostList
|
||||
)
|
||||
},
|
||||
'environment list': async ({ flags, json }) => {
|
||||
rejectLocalPairingStoreRetargeting(
|
||||
@@ -106,39 +107,6 @@ export const ENVIRONMENT_HANDLERS: Record<string, CommandHandler> = {
|
||||
}
|
||||
}
|
||||
|
||||
async function listPairedEnvironmentHosts(userDataPath: string): Promise<HostListEntry[]> {
|
||||
return Promise.all(
|
||||
listEnvironments(userDataPath).map(async (environment) => {
|
||||
const base = {
|
||||
kind: 'environment' as const,
|
||||
name: environment.name,
|
||||
id: environment.id,
|
||||
selector: `--environment ${environment.name}`
|
||||
}
|
||||
try {
|
||||
const client = new RuntimeClient(userDataPath, 5_000, null, environment.name)
|
||||
const response = await client.call<RuntimeStatus>('status.get')
|
||||
const connectionStatus = runtimeHostConnectionState({
|
||||
hasStatusEntry: true,
|
||||
status: response.result
|
||||
})
|
||||
return {
|
||||
...base,
|
||||
connected: isConnectedRuntimeHostState(connectionStatus),
|
||||
connectionStatus,
|
||||
...(response.result.hostPlatform ? { platform: response.result.hostPlatform } : {})
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
...base,
|
||||
connected: false,
|
||||
connectionStatus: 'disconnected' as RuntimeHostConnectionState
|
||||
}
|
||||
}
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* These two listings are pinned local by `shouldIgnoreRemoteSelection`, so a runtime selector is
|
||||
* dropped for routing. It used to still reach the SSH half of `host list` through the routed
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
closeSharedControlTestServers,
|
||||
createSharedControlTestServer
|
||||
} from '../../shared/remote-runtime-shared-control-test-server'
|
||||
import { encodePairingOffer } from '../../shared/pairing'
|
||||
import { addEnvironmentFromPairingCode, getEnvironmentStorePath } from '../runtime/environments'
|
||||
import { listPairedEnvironmentHosts } from './paired-host-inventory'
|
||||
|
||||
let userDataPath: string
|
||||
beforeEach(() => {
|
||||
userDataPath = mkdtempSync(join(tmpdir(), 'orca-host-inventory-'))
|
||||
})
|
||||
afterEach(async () => {
|
||||
await closeSharedControlTestServers()
|
||||
rmSync(userDataPath, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('paired host inventory over authenticated WebSockets', () => {
|
||||
it('reads Windows and legacy Linux servers without modifying the pairing file', async () => {
|
||||
const windows = await createSharedControlTestServer({
|
||||
results: { 'status.get': { hostPlatform: 'win32' } }
|
||||
})
|
||||
const legacy = await createSharedControlTestServer({
|
||||
results: { 'status.get': {}, 'host.platform': { platform: 'linux' } }
|
||||
})
|
||||
for (const [name, server] of [
|
||||
['windows', windows],
|
||||
['legacy', legacy]
|
||||
] as const) {
|
||||
addEnvironmentFromPairingCode(userDataPath, {
|
||||
name,
|
||||
pairingCode: encodePairingOffer(server.pairing)
|
||||
})
|
||||
}
|
||||
const path = getEnvironmentStorePath(userDataPath)
|
||||
const before = readFileSync(path, 'utf8')
|
||||
const hosts = await listPairedEnvironmentHosts(userDataPath)
|
||||
expect(hosts.find((host) => host.name === 'windows')).toMatchObject({
|
||||
platform: 'win32',
|
||||
connected: true
|
||||
})
|
||||
expect(hosts.find((host) => host.name === 'legacy')).toMatchObject({
|
||||
platform: 'linux',
|
||||
connected: true
|
||||
})
|
||||
expect(windows.requests.map((request) => request.method)).toEqual(['status.get'])
|
||||
expect(legacy.requests.map((request) => request.method)).toEqual([
|
||||
'status.get',
|
||||
'host.platform'
|
||||
])
|
||||
expect(readFileSync(path, 'utf8')).toBe(before)
|
||||
expect(JSON.stringify(hosts)).not.toContain(windows.pairing.deviceToken)
|
||||
await vi.waitFor(() =>
|
||||
expect(windows.activeConnectionCount() + legacy.activeConnectionCount()).toBe(0)
|
||||
)
|
||||
})
|
||||
|
||||
it('retains unreachable rows on a mid-flight connection drop', async () => {
|
||||
const server = await createSharedControlTestServer({ closeBeforeResponse: true })
|
||||
addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'dropped',
|
||||
pairingCode: encodePairingOffer(server.pairing)
|
||||
})
|
||||
const [host] = await listPairedEnvironmentHosts(userDataPath)
|
||||
expect(host).toMatchObject({
|
||||
name: 'dropped',
|
||||
connectionStatus: 'unknown',
|
||||
probeError: 'probe_failed'
|
||||
})
|
||||
expect(host).not.toHaveProperty('connected')
|
||||
await vi.waitFor(() => expect(server.activeConnectionCount()).toBe(0))
|
||||
})
|
||||
|
||||
it('ends a silent handshake within the scan deadline and releases its socket', async () => {
|
||||
const server = await createSharedControlTestServer({ suppressReadyFrame: true })
|
||||
addEnvironmentFromPairingCode(userDataPath, {
|
||||
name: 'silent',
|
||||
pairingCode: encodePairingOffer(server.pairing)
|
||||
})
|
||||
const start = Date.now()
|
||||
const [host] = await listPairedEnvironmentHosts(userDataPath)
|
||||
expect(Date.now() - start).toBeLessThan(6_500)
|
||||
expect(host).toMatchObject({ probeError: 'runtime_timeout', connectionStatus: 'unknown' })
|
||||
await vi.waitFor(() => expect(server.activeConnectionCount()).toBe(0))
|
||||
}, 10_000)
|
||||
})
|
||||
@@ -0,0 +1,152 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { createEnvironmentFromPairingOffer } from '../../shared/runtime-environments'
|
||||
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
|
||||
import { formatHostList } from '../format'
|
||||
import { listPairedEnvironmentHosts } from './paired-host-inventory'
|
||||
|
||||
const { list, send } = vi.hoisted(() => ({ list: vi.fn(), send: vi.fn() }))
|
||||
vi.mock('../runtime/environments', () => ({ listEnvironments: list }))
|
||||
vi.mock('../../shared/remote-runtime-client', () => ({ sendRemoteRuntimeRequest: send }))
|
||||
|
||||
function environment(id = 'env-one') {
|
||||
return createEnvironmentFromPairingOffer({
|
||||
id,
|
||||
name: 'same name',
|
||||
now: 1,
|
||||
offer: {
|
||||
v: 2,
|
||||
endpoint: `ws://${id}:6768`,
|
||||
deviceToken: `secret-${id}`,
|
||||
publicKeyB64: 'private-key'
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const success = (result: unknown) => ({ ok: true, result, _meta: { runtimeId: 'remote' } })
|
||||
|
||||
describe('paired host inventory', () => {
|
||||
beforeEach(() => {
|
||||
list.mockReset().mockReturnValue([environment()])
|
||||
send.mockReset().mockResolvedValue(success({ hostPlatform: 'win32' }))
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it.each(['win32', 'linux', 'darwin'])(
|
||||
'reports the remote %s platform, not the CLI platform',
|
||||
async (platform) => {
|
||||
send.mockResolvedValue(success({ hostPlatform: platform }))
|
||||
const hosts = await listPairedEnvironmentHosts('unused')
|
||||
expect(hosts).toEqual([
|
||||
{
|
||||
kind: 'environment',
|
||||
name: 'same name',
|
||||
id: 'env-one',
|
||||
selector: '--environment env-one',
|
||||
platform,
|
||||
connected: true,
|
||||
connectionStatus: 'connected',
|
||||
connectionSource: 'probe'
|
||||
}
|
||||
])
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
expect(formatHostList({ hosts })).toContain('connected [probe]')
|
||||
expect(formatHostList({ hosts })).not.toContain('connected (connected)')
|
||||
}
|
||||
)
|
||||
|
||||
it('uses captured pairing identities for duplicate names and leaves saved metadata untouched', async () => {
|
||||
const environments = [environment('one'), environment('two')]
|
||||
const before = JSON.stringify(environments)
|
||||
list.mockReturnValue(environments)
|
||||
vi.stubEnv('ORCA_ENVIRONMENT', 'wrong-server')
|
||||
vi.stubEnv('ORCA_REMOTE_PAIRING_CODE', 'wrong-pairing')
|
||||
try {
|
||||
const hosts = await listPairedEnvironmentHosts('unused')
|
||||
expect(hosts.map((host) => host.selector)).toEqual(['--environment one', '--environment two'])
|
||||
expect(send.mock.calls.map((call) => call[0].deviceToken)).toEqual([
|
||||
'secret-one',
|
||||
'secret-two'
|
||||
])
|
||||
expect(JSON.stringify(environments)).toBe(before)
|
||||
expect(JSON.stringify(hosts)).not.toMatch(/secret|private-key|ws:\/\//)
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
|
||||
it('falls back to the existing host.platform RPC on an older server', async () => {
|
||||
send.mockResolvedValueOnce(success({})).mockResolvedValueOnce(success({ platform: 'linux' }))
|
||||
expect((await listPairedEnvironmentHosts('unused'))[0]).toMatchObject({
|
||||
platform: 'linux',
|
||||
connected: true
|
||||
})
|
||||
expect(send.mock.calls.map((call) => call[1])).toEqual(['status.get', 'host.platform'])
|
||||
})
|
||||
|
||||
it('keeps a verified connection when old servers cannot supply platform', async () => {
|
||||
send.mockResolvedValueOnce(success({})).mockRejectedValueOnce(new Error('method_not_found'))
|
||||
const [host] = await listPairedEnvironmentHosts('unused')
|
||||
expect(host).toMatchObject({ connected: true })
|
||||
expect(host).not.toHaveProperty('platform')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'runtime_timeout',
|
||||
'unauthorized',
|
||||
'invalid_runtime_response',
|
||||
'remote_runtime_unavailable'
|
||||
])('preserves failed %s probes as unknown without leaking errors', async (code) => {
|
||||
send.mockRejectedValue(new RemoteRuntimeClientError(code, 'secret endpoint ws://private'))
|
||||
const [host] = await listPairedEnvironmentHosts('unused')
|
||||
expect(host).not.toHaveProperty('connected')
|
||||
expect(host).not.toHaveProperty('platform')
|
||||
expect(host.connectionStatus).toBe('unknown')
|
||||
expect(JSON.stringify(host)).not.toMatch(/secret|ws:\/\//)
|
||||
})
|
||||
|
||||
it('retains rows for RPC refusals and invalid saved endpoints', async () => {
|
||||
list.mockReturnValue([environment('refused'), { ...environment('broken'), endpoints: [] }])
|
||||
send.mockResolvedValue({ ok: false, error: { code: 'unauthorized', message: 'secret' } })
|
||||
const hosts = await listPairedEnvironmentHosts('unused')
|
||||
expect(hosts.map((host) => host.probeError)).toEqual(['status_unavailable', 'probe_failed'])
|
||||
expect(send).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('bounds fanout and the whole scan, aborts in-flight I/O, and never starts expired work', async () => {
|
||||
vi.useFakeTimers()
|
||||
list.mockReturnValue(Array.from({ length: 100 }, (_, index) => environment(String(index))))
|
||||
let active = 0
|
||||
let peak = 0
|
||||
send.mockImplementation(
|
||||
(_pairing, _method, _params, _timeout, _envelope, signal: AbortSignal) => {
|
||||
active++
|
||||
peak = Math.max(peak, active)
|
||||
return new Promise((_, reject) =>
|
||||
signal.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
active--
|
||||
reject(new Error('aborted'))
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
const pending = listPairedEnvironmentHosts('unused')
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
const hosts = await pending
|
||||
expect(hosts).toHaveLength(100)
|
||||
expect(peak).toBe(4)
|
||||
expect(active).toBe(0)
|
||||
expect(send).toHaveBeenCalledTimes(4)
|
||||
expect(hosts.every((host) => host.probeError === 'runtime_timeout')).toBe(true)
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('does no network work without saved servers', async () => {
|
||||
list.mockReturnValue([])
|
||||
expect(await listPairedEnvironmentHosts('unused')).toEqual([])
|
||||
expect(send).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,111 @@
|
||||
import { performance } from 'node:perf_hooks'
|
||||
import { mapWithConcurrency } from '../../shared/map-with-concurrency'
|
||||
import { getPreferredPairingOffer } from '../../shared/runtime-environments'
|
||||
import { RemoteRuntimeClientError } from '../../shared/remote-runtime-client-error'
|
||||
import type { RuntimeStatus } from '../../shared/runtime-types'
|
||||
import type { HostListEntry } from '../format'
|
||||
import { listEnvironments } from '../runtime/environments'
|
||||
|
||||
const INVENTORY_TIMEOUT_MS = 5_000
|
||||
const INVENTORY_CONCURRENCY = 4
|
||||
|
||||
export async function listPairedEnvironmentHosts(userDataPath: string): Promise<HostListEntry[]> {
|
||||
const environments = listEnvironments(userDataPath)
|
||||
if (environments.length === 0) {
|
||||
return []
|
||||
}
|
||||
const { sendRemoteRuntimeRequest } = await import('../../shared/remote-runtime-client.js')
|
||||
const deadline = performance.now() + INVENTORY_TIMEOUT_MS
|
||||
const abort = new AbortController()
|
||||
const timer = setTimeout(() => abort.abort(), INVENTORY_TIMEOUT_MS)
|
||||
try {
|
||||
return await mapWithConcurrency(environments, INVENTORY_CONCURRENCY, async (environment) => {
|
||||
const base: HostListEntry = {
|
||||
kind: 'environment',
|
||||
name: environment.name,
|
||||
id: environment.id,
|
||||
selector: `--environment ${environment.id}`,
|
||||
connectionSource: 'probe'
|
||||
}
|
||||
const remaining = (): number => Math.max(0, Math.ceil(deadline - performance.now()))
|
||||
const unknown = (probeError: string): HostListEntry => ({
|
||||
...base,
|
||||
connectionStatus: 'unknown',
|
||||
probeError
|
||||
})
|
||||
if (abort.signal.aborted || remaining() === 0) {
|
||||
return unknown('runtime_timeout')
|
||||
}
|
||||
try {
|
||||
// Probe the captured identity without changing pairing state or last-used ordering.
|
||||
const pairing = getPreferredPairingOffer(environment)
|
||||
const response = await sendRemoteRuntimeRequest<RuntimeStatus>(
|
||||
pairing,
|
||||
'status.get',
|
||||
undefined,
|
||||
remaining(),
|
||||
undefined,
|
||||
abort.signal
|
||||
)
|
||||
if (!response.ok) {
|
||||
return unknown('status_unavailable')
|
||||
}
|
||||
const host: HostListEntry = { ...base, connected: true, connectionStatus: 'connected' }
|
||||
const platform = response.result?.hostPlatform
|
||||
if (isHostPlatform(platform)) {
|
||||
return { ...host, platform }
|
||||
}
|
||||
// Older servers expose host.platform even when status.get omits hostPlatform.
|
||||
if (remaining() > 0 && !abort.signal.aborted) {
|
||||
try {
|
||||
const legacy = await sendRemoteRuntimeRequest<{ platform?: string }>(
|
||||
pairing,
|
||||
'host.platform',
|
||||
undefined,
|
||||
remaining(),
|
||||
undefined,
|
||||
abort.signal
|
||||
)
|
||||
if (legacy.ok && isHostPlatform(legacy.result?.platform)) {
|
||||
return { ...host, platform: legacy.result.platform }
|
||||
}
|
||||
} catch {
|
||||
// A missing platform does not invalidate the successful status probe.
|
||||
}
|
||||
}
|
||||
return host
|
||||
} catch (error) {
|
||||
if (abort.signal.aborted) {
|
||||
return unknown('runtime_timeout')
|
||||
}
|
||||
// Never publish arbitrary remote error text, endpoint addresses, or credentials.
|
||||
return unknown(
|
||||
error instanceof RemoteRuntimeClientError && error.code === 'runtime_timeout'
|
||||
? 'runtime_timeout'
|
||||
: 'probe_failed'
|
||||
)
|
||||
}
|
||||
})
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
function isHostPlatform(value: unknown): value is NodeJS.Platform {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
[
|
||||
'darwin',
|
||||
'linux',
|
||||
'win32',
|
||||
'aix',
|
||||
'freebsd',
|
||||
'openbsd',
|
||||
'sunos',
|
||||
'android',
|
||||
'haiku',
|
||||
'cygwin',
|
||||
'netbsd'
|
||||
].includes(value)
|
||||
)
|
||||
}
|
||||
@@ -130,10 +130,72 @@ describe('listSshTargets', () => {
|
||||
return { result: { targets: SSH_TARGETS } }
|
||||
})
|
||||
|
||||
await expect(listSshTargets({ call } as unknown as RuntimeClient)).resolves.toEqual([
|
||||
await expect(
|
||||
listSshTargets({ call } as unknown as RuntimeClient, { inventory: true })
|
||||
).resolves.toEqual([
|
||||
{ ...SSH_TARGETS[0], connected: true, connectionStatus: 'connected', remotePlatform: 'win32' }
|
||||
])
|
||||
expect(call).toHaveBeenCalledWith('ssh.getState', { targetId: SSH_TARGETS[0].id })
|
||||
expect(call).toHaveBeenCalledWith(
|
||||
'ssh.getState',
|
||||
{ targetId: SSH_TARGETS[0].id },
|
||||
{ timeoutMs: expect.any(Number) }
|
||||
)
|
||||
})
|
||||
|
||||
it('does not fan out state requests during ordinary legacy selector lookup', async () => {
|
||||
const { RuntimeClientError } = await import('./runtime/types.js')
|
||||
const call = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new RuntimeClientError('method_not_found', 'missing'))
|
||||
.mockResolvedValueOnce({ result: { targets: SSH_TARGETS } })
|
||||
await expect(
|
||||
resolveSshHostTargetId({ call } as unknown as RuntimeClient, 'openclaw', [])
|
||||
).resolves.toBe(SSH_TARGETS[0].id)
|
||||
expect(call).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('enriches old summary-method responses too, but preserves unknown on state failure', async () => {
|
||||
const call = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ result: { targets: SSH_TARGETS } })
|
||||
.mockRejectedValueOnce(new Error('unavailable'))
|
||||
await expect(
|
||||
listSshTargets({ call } as unknown as RuntimeClient, { inventory: true })
|
||||
).resolves.toEqual(SSH_TARGETS)
|
||||
expect(call).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('surfaces inventory failures instead of claiming no targets exist', async () => {
|
||||
const call = vi.fn().mockRejectedValue(new Error('runtime unavailable'))
|
||||
await expect(
|
||||
listSshTargets({ call } as unknown as RuntimeClient, { inventory: true })
|
||||
).rejects.toThrow('runtime unavailable')
|
||||
})
|
||||
|
||||
it('bounds legacy state fanout and avoids redundant queries for complete summaries', async () => {
|
||||
const targets = Array.from({ length: 100 }, (_, index) => ({
|
||||
id: String(index),
|
||||
label: String(index)
|
||||
}))
|
||||
let active = 0
|
||||
let peak = 0
|
||||
const call = vi.fn(async (method: string) => {
|
||||
if (method === 'ssh.listTargetSummaries') {
|
||||
return { result: { targets } }
|
||||
}
|
||||
active++
|
||||
peak = Math.max(peak, active)
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
active--
|
||||
return { result: { state: { status: 'connected', remotePlatform: 'linux' } } }
|
||||
})
|
||||
const rows = await listSshTargets({ call } as unknown as RuntimeClient, { inventory: true })
|
||||
expect(rows).toHaveLength(100)
|
||||
expect(peak).toBe(4)
|
||||
call.mockResolvedValueOnce({ result: { targets: rows } })
|
||||
call.mockClear()
|
||||
await listSshTargets({ call } as unknown as RuntimeClient, { inventory: true })
|
||||
expect(call).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
// Why: this only ever runs to enrich an error we are already reporting; a failure here must
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { RuntimeClient } from './runtime-client'
|
||||
import { mapWithConcurrency } from '../shared/map-with-concurrency'
|
||||
import { performance } from 'node:perf_hooks'
|
||||
|
||||
export type SshTargetSummary = {
|
||||
id: string
|
||||
@@ -101,52 +103,75 @@ export function crossKindNextSteps(
|
||||
|
||||
// Why: only display identity crosses this boundary — the RPC deliberately withholds addresses
|
||||
// and credentials — and an enumeration failure must never mask the error we are explaining.
|
||||
export async function listSshTargets(client: RuntimeClient): Promise<SshTargetSummary[]> {
|
||||
export async function listSshTargets(
|
||||
client: RuntimeClient,
|
||||
options?: { inventory: true }
|
||||
): Promise<SshTargetSummary[]> {
|
||||
const deadline = performance.now() + 5_000
|
||||
const call = (method: string) =>
|
||||
options?.inventory
|
||||
? client.call<{ targets: SshTargetSummary[] }>(method, undefined, {
|
||||
timeoutMs: Math.max(1, Math.ceil(deadline - performance.now()))
|
||||
})
|
||||
: client.call<{ targets: SshTargetSummary[] }>(method)
|
||||
let targets: SshTargetSummary[]
|
||||
try {
|
||||
const result = await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargetSummaries')
|
||||
return result.result.targets
|
||||
targets = (await call('ssh.listTargetSummaries')).result.targets
|
||||
} catch (error) {
|
||||
// Why: hosts predating listTargetSummaries still answer listTargets, and both are served by
|
||||
// the same summariser. Without this an old host looks like one with no SSH targets at all,
|
||||
// which would reject a target id that is actually valid there.
|
||||
if (error instanceof Error && 'code' in error && error.code === 'method_not_found') {
|
||||
try {
|
||||
const legacy = await client.call<{ targets: SshTargetSummary[] }>('ssh.listTargets')
|
||||
return await enrichLegacySshTargetStates(client, legacy.result.targets)
|
||||
} catch {
|
||||
targets = (await call('ssh.listTargets')).result.targets
|
||||
} catch (legacyError) {
|
||||
if (options?.inventory) {
|
||||
throw legacyError
|
||||
}
|
||||
return []
|
||||
}
|
||||
} else {
|
||||
if (options?.inventory) {
|
||||
throw error
|
||||
}
|
||||
return []
|
||||
}
|
||||
return []
|
||||
}
|
||||
return options?.inventory ? enrichLegacySshTargetStates(client, targets, deadline) : targets
|
||||
}
|
||||
|
||||
async function enrichLegacySshTargetStates(
|
||||
client: RuntimeClient,
|
||||
targets: SshTargetSummary[]
|
||||
targets: SshTargetSummary[],
|
||||
deadline: number
|
||||
): Promise<SshTargetSummary[]> {
|
||||
return Promise.all(
|
||||
targets.map(async (target) => {
|
||||
try {
|
||||
const response = await client.call<{
|
||||
state: {
|
||||
status?: string
|
||||
remotePlatform?: 'linux' | 'darwin' | 'win32'
|
||||
} | null
|
||||
}>('ssh.getState', { targetId: target.id })
|
||||
const state = response.result.state
|
||||
return {
|
||||
...target,
|
||||
...(state?.status === undefined
|
||||
? {}
|
||||
: { connected: state.status === 'connected', connectionStatus: state.status }),
|
||||
...(state?.remotePlatform === undefined ? {} : { remotePlatform: state.remotePlatform })
|
||||
}
|
||||
} catch {
|
||||
return target
|
||||
return mapWithConcurrency(targets, 4, async (target) => {
|
||||
if (target.connected !== undefined || performance.now() >= deadline) {
|
||||
return target
|
||||
}
|
||||
try {
|
||||
const response = await client.call<{
|
||||
state: {
|
||||
status?: string
|
||||
remotePlatform?: 'linux' | 'darwin' | 'win32'
|
||||
} | null
|
||||
}>(
|
||||
'ssh.getState',
|
||||
{ targetId: target.id },
|
||||
{ timeoutMs: Math.max(1, Math.ceil(deadline - performance.now())) }
|
||||
)
|
||||
const state = response.result.state
|
||||
return {
|
||||
...target,
|
||||
...(state?.status === undefined
|
||||
? {}
|
||||
: { connected: state.status === 'connected', connectionStatus: state.status }),
|
||||
...(state?.remotePlatform === undefined ? {} : { remotePlatform: state.remotePlatform })
|
||||
}
|
||||
})
|
||||
)
|
||||
} catch {
|
||||
return target
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: `--host ssh:<id>` was never validated, so an unknown target answered ok:true with an
|
||||
|
||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
callMock,
|
||||
probeMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
@@ -12,6 +13,7 @@ const {
|
||||
spawnMock
|
||||
} = vi.hoisted(() => ({
|
||||
callMock: vi.fn(),
|
||||
probeMock: vi.fn(),
|
||||
runtimeClientConstructorMock: vi.fn(),
|
||||
serveOrcaAppMock: vi.fn(),
|
||||
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
|
||||
@@ -39,6 +41,8 @@ vi.mock('./runtime/environments', () => ({
|
||||
resolveEnvironment: resolveEnvironmentMock
|
||||
}))
|
||||
|
||||
vi.mock('../shared/remote-runtime-client', () => ({ sendRemoteRuntimeRequest: probeMock }))
|
||||
|
||||
vi.mock('child_process', async () => {
|
||||
const { createChildProcessModuleMock } = await import('./index-test-harness.js')
|
||||
return createChildProcessModuleMock(spawnMock)
|
||||
@@ -48,7 +52,12 @@ import { main } from './index'
|
||||
import { okFixture, queueFixtures } from './test-fixtures'
|
||||
import { pairRuntimeEnvironment, useWorktreeAwarenessEnvironment } from './index-test-harness'
|
||||
|
||||
const SSH_TARGET = { id: 'ssh-1777360569033-yvz2mp', label: 'openclaw', remotePlatform: 'win32' }
|
||||
const SSH_TARGET = {
|
||||
id: 'ssh-1777360569033-yvz2mp',
|
||||
label: 'openclaw',
|
||||
remotePlatform: 'win32',
|
||||
connected: true
|
||||
}
|
||||
|
||||
/** Every SSH-target lookup answers with the one target only this machine's runtime knows about. */
|
||||
function queueSshTargetLookups(count: number): void {
|
||||
@@ -61,6 +70,7 @@ function queueSshTargetLookups(count: number): void {
|
||||
describe('runtime-selector flags on locally pinned CLI commands', () => {
|
||||
beforeEach(() => {
|
||||
runtimeClientConstructorMock.mockClear()
|
||||
probeMock.mockReset().mockResolvedValue(okFixture('probe', { hostPlatform: 'win32' }))
|
||||
})
|
||||
|
||||
useWorktreeAwarenessEnvironment({
|
||||
@@ -75,7 +85,6 @@ describe('runtime-selector flags on locally pinned CLI commands', () => {
|
||||
it('answers `host list` from this machine and stamps the runtime that actually answered', async () => {
|
||||
pairRuntimeEnvironment(listEnvironmentsMock, 'env-m4air', 'm4air')
|
||||
queueSshTargetLookups(1)
|
||||
queueFixtures(callMock, okFixture('req_m4air_status', { hostPlatform: 'win32' }))
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['host', 'list', '--json'], '/tmp/repo')
|
||||
@@ -99,6 +108,8 @@ describe('runtime-selector flags on locally pinned CLI commands', () => {
|
||||
})
|
||||
// The tell: `runtimeId: local` is only honest if no routed client was ever built.
|
||||
expect(runtimeClientConstructorMock).toHaveBeenCalledWith(null, null)
|
||||
expect(runtimeClientConstructorMock).toHaveBeenCalledTimes(1)
|
||||
expect(probeMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('rejects `host list --environment` instead of answering with a half-routed listing', async () => {
|
||||
@@ -120,6 +131,20 @@ describe('runtime-selector flags on locally pinned CLI commands', () => {
|
||||
process.exitCode = 0
|
||||
})
|
||||
|
||||
it('marks an unavailable SSH inventory as incomplete while retaining paired hosts', async () => {
|
||||
pairRuntimeEnvironment(listEnvironmentsMock, 'env-m4air', 'm4air')
|
||||
callMock.mockRejectedValueOnce(new Error('private local runtime diagnostic'))
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
await main(['host', 'list', '--json'], '/tmp/repo')
|
||||
const printed = JSON.parse(String(logSpy.mock.calls[0]?.[0]))
|
||||
expect(printed.result.hosts.map((host: { id: string }) => host.id)).toEqual([
|
||||
'local',
|
||||
'env-m4air'
|
||||
])
|
||||
expect(printed.result.warnings).toEqual([expect.stringContaining('listing is incomplete')])
|
||||
expect(JSON.stringify(printed)).not.toContain('private local runtime diagnostic')
|
||||
})
|
||||
|
||||
it('rejects `environment list --environment` rather than repeating the local answer', async () => {
|
||||
pairRuntimeEnvironment(listEnvironmentsMock, 'env-m4air', 'm4air')
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
@@ -10,8 +10,9 @@ export const ENVIRONMENT_COMMAND_SPECS: CommandSpec[] = [
|
||||
notes: [
|
||||
'Answers "what can I target and what do I pass" in one place: this machine, the SSH targets registered on it, and the Orca servers paired with it.',
|
||||
'The three kinds are reached differently. A paired Orca server is a connection, selected with --environment <name>. An SSH target is a machine the connected Orca host reaches, selected with --host ssh:<id>. Passing one where the other belongs is the most common way to get an empty or missing-host answer.',
|
||||
'SSH and paired-server rows include the detected remote platform after the host responds (linux, darwin, or win32); older or unavailable hosts report platform unknown.',
|
||||
'SSH and paired-server rows also include whether the target is currently connected and its lifecycle status when known.',
|
||||
'All configured hosts are included, not only connected ones. Platform is host-reported (linux, darwin, or win32), never inferred from the name; missing evidence displays platform unknown.',
|
||||
'SSH rows report the app-owned connection and lifecycle status. Paired-server rows use fresh read-only probes, marked connectionSource: probe / [probe], not the desktop persistent connection. A successful status probe sets connected: true; failed or unattempted probes omit connected and show connection unknown with a safe probeError.',
|
||||
'Paired probes use at most four concurrent connections and a five-second total scan budget. Older servers fall back from status.get hostPlatform to host.platform. Listing does not change saved pairing metadata or last-used ordering; paired selectors use stable environment IDs.',
|
||||
"SSH targets are read from this machine's own Orca runtime, so this lists that machine's targets and not another server's. Run `orca host list` on the other machine to see the targets registered there.",
|
||||
'--environment and --pairing-code are rejected rather than ignored: paired servers come from this machine\u2019s pairing store, so a routed answer would describe two machines at once.'
|
||||
],
|
||||
|
||||
@@ -154,6 +154,7 @@ describe('ssh RPC methods', () => {
|
||||
result: { targets: [{ id: 'ssh-1', label: 'Dev box' }] }
|
||||
})
|
||||
expect(JSON.stringify(response)).not.toContain('remotePlatform')
|
||||
expect(JSON.stringify(response)).not.toContain('connected')
|
||||
})
|
||||
|
||||
it('reports disconnected lifecycle states without calling them connected', async () => {
|
||||
|
||||
@@ -22,7 +22,7 @@ function listRegisteredSshTargetSummaries(): SshTargetSummary[] {
|
||||
id,
|
||||
label,
|
||||
...(generation === undefined ? {} : { generation }),
|
||||
connected: state?.status === 'connected',
|
||||
...(state === undefined ? {} : { connected: state.status === 'connected' }),
|
||||
...(state?.status === undefined ? {} : { connectionStatus: state.status }),
|
||||
...(remotePlatform === undefined ? {} : { remotePlatform })
|
||||
}
|
||||
|
||||
@@ -388,13 +388,13 @@ describe('sendRemoteRuntimeRequest', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('aborts and closes an in-flight one-shot socket', async () => {
|
||||
it('aborts and terminates an in-flight one-shot socket without a close-handshake wait', async () => {
|
||||
let requestObserved: () => void = () => {}
|
||||
const observed = new Promise<void>((resolve) => {
|
||||
requestObserved = resolve
|
||||
})
|
||||
const server = await createOneShotServer({ onRequest: requestObserved })
|
||||
const closeSpy = vi.spyOn(WebSocketClient.prototype, 'close')
|
||||
const terminateSpy = vi.spyOn(WebSocketClient.prototype, 'terminate')
|
||||
const controller = new AbortController()
|
||||
try {
|
||||
const request = sendRemoteRuntimeRequest(
|
||||
@@ -409,9 +409,9 @@ describe('sendRemoteRuntimeRequest', () => {
|
||||
|
||||
controller.abort()
|
||||
await expect(request).rejects.toMatchObject({ name: 'AbortError' })
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
expect(terminateSpy).toHaveBeenCalled()
|
||||
} finally {
|
||||
closeSpy.mockRestore()
|
||||
terminateSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -132,7 +132,12 @@ export async function sendRemoteRuntimeRequestOnSocket<TResult>(
|
||||
clearTimeout(timeout)
|
||||
try {
|
||||
cleanupSocketListeners()
|
||||
ws?.close()
|
||||
if (result.ok) {
|
||||
ws?.close()
|
||||
} else {
|
||||
// A failed or aborted probe must not linger waiting for a half-open peer's close reply.
|
||||
ws?.terminate()
|
||||
}
|
||||
} catch {
|
||||
// ignore best-effort close
|
||||
}
|
||||
|
||||
@@ -15,11 +15,13 @@ export type SharedControlTestServer = {
|
||||
requests: { id: string; method: string; params?: unknown }[]
|
||||
auths: unknown[]
|
||||
connectionCount: () => number
|
||||
activeConnectionCount: () => number
|
||||
flushDelayedResponses: () => void
|
||||
closeClients: () => void
|
||||
}
|
||||
|
||||
type ServerOptions = {
|
||||
results?: Record<string, unknown>
|
||||
delaySubscriptionReady?: boolean
|
||||
sendKeepaliveBeforeResponse?: boolean
|
||||
keepaliveDelayMs?: number
|
||||
@@ -143,6 +145,7 @@ export async function createSharedControlTestServer(
|
||||
requests,
|
||||
auths,
|
||||
connectionCount: () => connectionCount,
|
||||
activeConnectionCount: () => wss.clients.size,
|
||||
flushDelayedResponses: () => delayedResponses.splice(0).forEach((send) => send()),
|
||||
closeClients: () => wss.clients.forEach((client) => client.close(4001, 'test close'))
|
||||
}
|
||||
@@ -174,7 +177,7 @@ function handleRequest(
|
||||
const streaming = isStreamingMethod(request.method)
|
||||
const result = streaming
|
||||
? { type: 'ready', subscriptionId: `${request.method}:subscription` }
|
||||
: { method: request.method }
|
||||
: (options.results?.[request.method] ?? { method: request.method })
|
||||
const sendResponse = (): void => {
|
||||
if (options.sendUnknownResponseBeforeResponse) {
|
||||
sendEncrypted(ws, sharedKey, {
|
||||
|
||||
Reference in New Issue
Block a user