From dbd854c9a58cb4c6350ab212681e8ff1b9ecd1d7 Mon Sep 17 00:00:00 2001 From: kambarakun Date: Wed, 7 Oct 2026 01:07:09 +0900 Subject: [PATCH 1/4] feat(status): report remote server listener and authenticated clients Expose bounded server diagnostics only when an authorized CLI requests them. Keep ordinary status probes and mobile responses unchanged, and distinguish unavailable data from an empty registry. --- docs/site/content/docs/remote-servers.mdx | 37 ++ src/cli/runtime/client.ts | 3 +- src/cli/runtime/status.test.ts | 118 +++--- src/cli/runtime/status.ts | 8 +- src/cli/runtime/websocket-transport.test.ts | 22 +- .../runtime/device-registry-relay-binding.ts | 24 ++ src/main/runtime/device-registry.ts | 37 +- src/main/runtime/remote-server-status.test.ts | 185 ++++++++++ src/main/runtime/remote-server-status.ts | 112 ++++++ src/main/runtime/rpc/core.ts | 3 + .../runtime/rpc/dispatcher-stream-options.ts | 2 + src/main/runtime/rpc/methods/status.ts | 8 +- .../runtime/rpc/mobile-socket-wiring.test.ts | 29 ++ src/main/runtime/rpc/mobile-socket-wiring.ts | 14 + ...mote-server-status-socket-lifetime.test.ts | 338 ++++++++++++++++++ .../runtime-rpc-request-admission.ts | 5 +- .../runtime/runtime-rpc/runtime-rpc-state.ts | 15 + .../runtime-rpc-websocket-dispatch.ts | 2 + src/shared/remote-server-status.ts | 42 +++ .../rpc-params-catalog.generated.ts | 3 +- src/shared/rpc-contract/status-params.ts | 3 + src/shared/runtime-session-contracts.ts | 4 + 22 files changed, 936 insertions(+), 78 deletions(-) create mode 100644 src/main/runtime/device-registry-relay-binding.ts create mode 100644 src/main/runtime/remote-server-status.test.ts create mode 100644 src/main/runtime/remote-server-status.ts create mode 100644 src/main/runtime/runtime-rpc-remote-server-status-socket-lifetime.test.ts create mode 100644 src/shared/remote-server-status.ts create mode 100644 src/shared/rpc-contract/status-params.ts diff --git a/docs/site/content/docs/remote-servers.mdx b/docs/site/content/docs/remote-servers.mdx index 25ad464f0028..490568191798 100644 --- a/docs/site/content/docs/remote-servers.mdx +++ b/docs/site/content/docs/remote-servers.mdx @@ -181,6 +181,43 @@ orca-ide serve --pairing-address 100.64.1.20 --mobile-pairing Keep the phone on the same tailnet, open Orca Mobile, choose **Pair**, and scan the terminal QR code or paste the printed link. +## Inspect the server from the CLI + +Run `orca status --json` on the server (`orca-ide status --json` on Linux), +or select a paired runtime with `orca --environment status --json`. +The CLI explicitly requests the optional `remoteServer` block. Routine RPC status +probes omit it. The block describes the server answering that request: + +- `listener` reports `listening` with the actual bind `address` and `port`, + `not_listening`, or `disabled`. A wildcard address is a bind address, not a + connection URL. This does not test firewall/Tailscale reachability or retain a + previously advertised pairing URL. +- `grants` reports `total`, `pending`, `byScope`, and `entries`. Each entry has + `deviceId`, `name`, `scope`, `createdAt`, and `lastSeenAt`. Times are Unix + milliseconds; `lastSeenAt: null` means never used. A past connection is not + evidence that a client is connected now. +- `connectedClients` reports distinct pairing identities in `count`, authenticated + open sockets in `connectionCount`, and `byScope`. Its entries identify clients + and count their `direct` and `relay` connections. Multiple sockets from one + grant count as one client; separate grants on one physical computer count + separately. The requesting runtime connection is included. Network loss is + reflected when the existing connection/heartbeat handling detects it. + +Both lists return up to 100 entries and indicate omitted entries with +`truncated: true`; totals still cover all observed records. Names are limited to +160 characters and stripped of terminal controls. Tokens, keys, pairing URLs, +relay credentials, and push registrations are never included. + +Diagnostics are available to the local CLI authenticated with this host's owner +token and to paired **runtime** clients. Mobile clients keep their existing +status response without these diagnostics. A section with +`state: "unavailable"` could not be observed; it does not mean zero clients or +grants. An absent `remoteServer` means an older host or an unsupported/unauthorized +path, including an SSH host's `orca` CLI (which reaches the controlling desktop +only with a scoped bridge credential) and the legacy in-process SSH bridge. It is +never filled with another host's data. Reading status does not create grants or +widen the listener. + ## Desktop app or `orca serve`? | | Desktop app on the server | `orca serve` | diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index 5efe54c7ba35..c36df160a01e 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -206,7 +206,7 @@ export class RuntimeClient { async getCliStatus(): Promise> { if (this.remotePairing) { - const response = await this.call('status.get') + const response = await this.call('status.get', { includeRemoteServer: true }) this.remoteCompat.noteVerifiedStatus(response.result) const graphState = response.result.graphStatus return { @@ -218,6 +218,7 @@ export class RuntimeClient { environment: this.environmentSelector ?? 'pairing-code' }, app: projectRemoteAppStatus(response.result), + ...(response.result.remoteServer ? { remoteServer: response.result.remoteServer } : {}), runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, diff --git a/src/cli/runtime/status.test.ts b/src/cli/runtime/status.test.ts index 68d6f392a401..107605ba0ed3 100644 --- a/src/cli/runtime/status.test.ts +++ b/src/cli/runtime/status.test.ts @@ -30,60 +30,76 @@ afterEach(async () => { // Why: legacy runtime metadata compatibility only applies to local Unix socket // metadata; Windows uses named pipes and cannot run this fixture directly. describe.skipIf(process.platform === 'win32')('CLI runtime status', () => { - it('uses the legacy singular runtime transport when reporting status', async () => { - const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-status-')) - const endpoint = join(userDataPath, 'runtime.sock') - const server = createServer((socket) => { - sockets.add(socket) - socket.once('close', () => sockets.delete(socket)) - socket.once('data', (data) => { - const request = JSON.parse(String(data).trim()) as { id: string } - socket.write( - `${JSON.stringify({ - id: request.id, - ok: true, - result: { - runtimeId: 'runtime-legacy', - rendererGraphEpoch: 1, - graphStatus: 'ready', - authoritativeWindowId: null, - liveTabCount: 0, - degradations: [ - { - code: 'browser_unavailable', - capability: 'browser.headless.v1', - message: 'Browser automation is unavailable.' - } - ] - }, - _meta: { runtimeId: 'runtime-legacy' } - })}\n` - ) - }) - }) - servers.add(server) - await new Promise((resolve) => server.listen(endpoint, resolve)) - writeFileSync( - getRuntimeMetadataPath(userDataPath), - JSON.stringify({ - runtimeId: 'runtime-legacy', - pid: process.pid, - transport: { kind: 'unix', endpoint }, - authToken: 'token', - startedAt: Date.now() + it.each([ + undefined, + { + listener: { state: 'disabled' as const }, + grants: { state: 'unavailable' as const }, + connectedClients: { state: 'unavailable' as const } + } + ])( + 'preserves optional server diagnostics through legacy local metadata (%j)', + async (remoteServer) => { + const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-status-')) + const endpoint = join(userDataPath, 'runtime.sock') + const server = createServer((socket) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + socket.once('data', (data) => { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture reads requests emitted by RuntimeClient; the diagnostic assertion requires its opt-in parameter. + const request = JSON.parse(String(data).trim()) as { + id: string + params?: { includeRemoteServer?: boolean } + } + socket.write( + `${JSON.stringify({ + id: request.id, + ok: true, + result: { + runtimeId: 'runtime-legacy', + ...(request.params?.includeRemoteServer && remoteServer ? { remoteServer } : {}), + rendererGraphEpoch: 1, + graphStatus: 'ready', + authoritativeWindowId: null, + liveTabCount: 0, + degradations: [ + { + code: 'browser_unavailable', + capability: 'browser.headless.v1', + message: 'Browser automation is unavailable.' + } + ] + }, + _meta: { runtimeId: 'runtime-legacy' } + })}\n` + ) + }) }) - ) + servers.add(server) + await new Promise((resolve) => server.listen(endpoint, resolve)) + writeFileSync( + getRuntimeMetadataPath(userDataPath), + JSON.stringify({ + runtimeId: 'runtime-legacy', + pid: process.pid, + transport: { kind: 'unix', endpoint }, + authToken: 'token', + startedAt: Date.now() + }) + ) - const status = await new RuntimeClient(userDataPath).getCliStatus() + const status = await new RuntimeClient(userDataPath).getCliStatus() - expect(status.result.runtime).toMatchObject({ - reachable: true, - connectionState: 'connected', - runtimeId: 'runtime-legacy', - state: 'ready', - degradations: [expect.objectContaining({ code: 'browser_unavailable' })] - }) - }) + expect(status.result.remoteServer).toEqual(remoteServer) + expect(status.result.runtime).toMatchObject({ + reachable: true, + connectionState: 'connected', + runtimeId: 'runtime-legacy', + state: 'ready', + degradations: [expect.objectContaining({ code: 'browser_unavailable' })] + }) + } + ) }) // Why: `kill(pid, 0)` answers EPERM when the pid exists under another uid — an Orca the diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index f01ffe842df2..9cfb8f04f086 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -38,13 +38,19 @@ export async function getCliStatus( } try { - const response = await sendRequest(metadata, 'status.get', undefined, 1000) + const response = await sendRequest( + metadata, + 'status.get', + { includeRemoteServer: true }, + 1000 + ) if (response.ok === false) { throw new RuntimeRpcFailureError(response) } const graphState = response.result.graphStatus const desktopWindowStatus = resolveDesktopWindowStatus(response.result) return buildCliStatusResponse({ + ...(response.result.remoteServer ? { remoteServer: response.result.remoteServer } : {}), app: { running: true, pid: metadata.pid, diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 5c814f91fc63..feaa13b736a8 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -101,6 +101,11 @@ describe('CLI remote WebSocket transport', () => { it('accepts a bare pairing payload as well as the orca URL wrapper', async () => { const runtime = await startTestRuntime('runtime-ws-2', { + remoteServer: { + listener: { state: 'listening', address: '192.0.2.10', port: 31337 }, + grants: { state: 'unavailable' }, + connectedClients: { state: 'unavailable' } + }, appVersion: '1.5.0', remoteUpdateSupport: { installMode: 'unsupported-headless-serve', @@ -134,6 +139,11 @@ describe('CLI remote WebSocket transport', () => { expect(status.result.app).toEqual({ running: false, pid: null }) expect(status.result.runtime.reachable).toBe(true) expect(status.result.runtime.runtimeId).toBe('runtime-ws-2') + expect(status.result.remoteServer).toEqual({ + listener: { state: 'listening', address: '192.0.2.10', port: 31337 }, + grants: { state: 'unavailable' }, + connectedClients: { state: 'unavailable' } + }) expect(status.result.runtime).toMatchObject({ appVersion: '1.5.0', remoteUpdateSupport: { automatic: false, reason: 'manual-service-update-required' }, @@ -184,6 +194,7 @@ describe('CLI remote WebSocket transport', () => { expect(status.result.app).toEqual({ running: false, pid: null }) expect(status.result.runtime.reachable).toBe(true) expect(status.result.runtime.runtimeId).toBe('runtime-env-1') + expect(status.result).not.toHaveProperty('remoteServer') }) it('blocks remote RPCs when the server protocol is too old', async () => { @@ -261,6 +272,7 @@ async function startTestRuntime( runtimeProtocolVersion?: number minCompatibleRuntimeClientVersion?: number desktopWindowStatus?: 'available' | 'openable' | 'initializing' | 'blocked' + remoteServer?: RuntimeStatus['remoteServer'] appVersion?: string remoteUpdateSupport?: { installMode: 'unsupported-headless-serve' @@ -318,7 +330,12 @@ async function startTestRuntime( return } - const request = JSON.parse(plaintext) as { id: string; method: string } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fixture reads authenticated requests emitted by RuntimeClient; tests assert the resulting status. + const request = JSON.parse(plaintext) as { + id: string + method: string + params?: { includeRemoteServer?: boolean } + } requestMethods.push(request.method) const response = request.method === 'status.get' @@ -338,6 +355,9 @@ async function startTestRuntime( minCompatibleRuntimeClientVersion: statusOverrides.minCompatibleRuntimeClientVersion ?? MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + ...(request.params?.includeRemoteServer + ? { remoteServer: statusOverrides.remoteServer } + : {}), appVersion: statusOverrides.appVersion, remoteUpdateSupport: statusOverrides.remoteUpdateSupport, capabilities: statusOverrides.capabilities, diff --git a/src/main/runtime/device-registry-relay-binding.ts b/src/main/runtime/device-registry-relay-binding.ts new file mode 100644 index 000000000000..2261c7b558d5 --- /dev/null +++ b/src/main/runtime/device-registry-relay-binding.ts @@ -0,0 +1,24 @@ +import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' + +export function validRelayBinding( + value: unknown, + deviceId: string +): RelayDeviceBinding | undefined { + if (!value || typeof value !== 'object') { + return undefined + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: value is a non-null object and every field read below is type-checked before use. + const binding = value as Partial + return binding.relayDeviceId === deviceId && + typeof binding.relayHostId === 'string' && + typeof binding.ownerIdentityKey === 'string' + ? { + relayHostId: binding.relayHostId, + relayDeviceId: binding.relayDeviceId, + ownerIdentityKey: binding.ownerIdentityKey, + ...(typeof binding.inviteExpiresAt === 'number' && Number.isFinite(binding.inviteExpiresAt) + ? { inviteExpiresAt: binding.inviteExpiresAt } + : {}) + } + : undefined +} diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index 5e310ff19ce6..027fe485fde2 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -3,7 +3,7 @@ // compromising one device doesn't expose others. The registry is a simple // JSON file with hardened permissions matching the runtime metadata pattern. import { randomBytes, randomUUID } from 'node:crypto' -import { existsSync, readFileSync } from 'node:fs' +import { existsSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' import { hardenExistingSecureFile, @@ -14,6 +14,7 @@ import type { DeviceScope } from '../../shared/runtime-types' import { removeStaleDurableWriteTempFiles } from '../durable-file-write' import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' import type { RelayDeviceBinding } from './relay/relay-revoke-outbox' +import { validRelayBinding } from './device-registry-relay-binding' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' import { RUNTIME_DEVICE_GRANTS, type RuntimeDeviceGrant } from './rpc/rpc-method-permission' @@ -62,25 +63,6 @@ function sameGrants(entry: DeviceEntry, grants: readonly RuntimeDeviceGrant[]): return current.length === grants.length && current.every((grant) => grants.includes(grant)) } -function validRelayBinding(value: unknown, deviceId: string): RelayDeviceBinding | undefined { - if (!value || typeof value !== 'object') { - return undefined - } - const binding = value as Partial - return binding.relayDeviceId === deviceId && - typeof binding.relayHostId === 'string' && - typeof binding.ownerIdentityKey === 'string' - ? { - relayHostId: binding.relayHostId, - relayDeviceId: binding.relayDeviceId, - ownerIdentityKey: binding.ownerIdentityKey, - ...(typeof binding.inviteExpiresAt === 'number' && Number.isFinite(binding.inviteExpiresAt) - ? { inviteExpiresAt: binding.inviteExpiresAt } - : {}) - } - : undefined -} - // Why: a lastSeen refresh is pure bookkeeping, so coalesce reconnect bursts into one write instead of // paying a secure-file rewrite (two synchronous PowerShell ACL spawns on Windows) per connection. const LAST_SEEN_FLUSH_DELAY_MS = 250 @@ -91,6 +73,7 @@ export class DeviceRegistry { private devices: DeviceEntry[] = [] /** Set when the registry exists but could not be read, which makes `devices` a lie to save from. */ private registryUnreadable = false + private diagnosticsAvailable = true private pendingLastSeenFlush: NodeJS.Timeout | null = null constructor(userDataPath: string) { @@ -269,6 +252,10 @@ export class DeviceRegistry { return device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic' } + get statusAvailable(): boolean { + return this.diagnosticsAvailable + } + listDevices(): readonly DeviceEntry[] { return this.devices } @@ -346,6 +333,14 @@ export class DeviceRegistry { private load(): void { if (!existsSync(this.registryPath)) { + // existsSync also returns false for access errors; only ENOENT proves an empty registry. + this.diagnosticsAvailable = false + try { + statSync(this.registryPath) + } catch (error) { + this.diagnosticsAvailable = + error instanceof Error && 'code' in error && error.code === 'ENOENT' + } this.devices = [] return } @@ -372,6 +367,7 @@ export class DeviceRegistry { } catch (error) { // "Cannot read" is not "is empty". Saving an empty list over a registry we were merely // denied would erase every paired device's bearer token, and the write would succeed. + this.diagnosticsAvailable = false this.registryUnreadable = isUnreadableError(error) this.devices = [] } @@ -384,6 +380,7 @@ export class DeviceRegistry { ) } writeSecureJsonFile(this.registryPath, devices) + this.diagnosticsAvailable = true // Why: every registry save includes the latest in-memory timestamps, so a later timer would rewrite it. this.cancelPendingLastSeenFlush() } diff --git a/src/main/runtime/remote-server-status.test.ts b/src/main/runtime/remote-server-status.test.ts new file mode 100644 index 000000000000..f27d8b14f1b5 --- /dev/null +++ b/src/main/runtime/remote-server-status.test.ts @@ -0,0 +1,185 @@ +import * as fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { DeviceRegistry } from './device-registry' +import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' +import { collectRemoteServerStatus } from './remote-server-status' + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, existsSync: vi.fn(actual.existsSync), statSync: vi.fn(actual.statSync) } +}) + +const paths: string[] = [] +afterEach(() => { + vi.restoreAllMocks() + for (const path of paths.splice(0)) { + fs.rmSync(path, { recursive: true, force: true }) + } +}) +function profile(): string { + const path = fs.mkdtempSync(join(tmpdir(), 'orca-status-registry-')) + paths.push(path) + return path +} +const listener = { state: 'not_listening' } as const + +it('projects only allowed grant fields, deduplicates transports and never infers connections from lastSeenAt', () => { + const path = profile() + const device = { + deviceId: 'test-phone', + name: '\x1b]52;c;ignored\x07Phone\n\x1b[31m', + scope: 'mobile', + token: 'SECRET-TOKEN', + pairedAt: 123, + lastSeenAt: 456, + relayBinding: { + relayHostId: 'SECRET-RELAY', + relayDeviceId: 'test-phone', + ownerIdentityKey: 'SECRET-OWNER-KEY' + }, + pushRegistration: { registrationId: 'SECRET-PUSH', filter: {}, expiresAt: 999 } + } + fs.writeFileSync(join(path, DEVICE_REGISTRY_FILENAME), JSON.stringify([device])) + const registry = new DeviceRegistry(path) + const connections: { deviceId: string; scope: 'mobile'; transport: 'direct' | 'relay' }[] = [ + { deviceId: 'test-phone', scope: 'mobile', transport: 'direct' }, + { deviceId: 'test-phone', scope: 'mobile', transport: 'relay' } + ] + const wiring = { getAuthenticatedConnections: () => connections } + const status = collectRemoteServerStatus(listener, registry, wiring) + expect(status).toEqual({ + listener, + grants: { + state: 'available', + total: 1, + pending: 0, + byScope: { runtime: 0, mobile: 1 }, + truncated: false, + entries: [ + { deviceId: 'test-phone', name: 'Phone', scope: 'mobile', createdAt: 123, lastSeenAt: 456 } + ] + }, + connectedClients: { + state: 'available', + count: 1, + connectionCount: 2, + byScope: { runtime: 0, mobile: 1 }, + truncated: false, + entries: [ + { + deviceId: 'test-phone', + name: 'Phone', + scope: 'mobile', + connectionCount: 2, + transports: { direct: 1, relay: 1 } + } + ] + } + }) + for (const secret of ['SECRET-TOKEN', 'SECRET-RELAY', 'SECRET-OWNER-KEY', 'SECRET-PUSH']) { + expect(JSON.stringify(status)).not.toContain(secret) + } + if (status.grants.state !== 'available') { + throw new Error('Missing grants') + } + status.grants.entries[0]!.name = 'Changed copy' + expect(registry.listDevices()[0]?.name).toBe(device.name) + connections.length = 0 + expect(collectRemoteServerStatus(listener, registry, wiring).connectedClients).toMatchObject({ + count: 0, + connectionCount: 0 + }) + connections.push({ deviceId: 'test-phone', scope: 'mobile', transport: 'direct' }) + registry.removeDevice('test-phone') + expect(collectRemoteServerStatus(listener, registry, wiring).connectedClients).toMatchObject({ + count: 0, + connectionCount: 0 + }) +}) + +it('distinguishes a missing registry from malformed and inaccessible registries without rewriting them', () => { + const path = profile() + const file = join(path, DEVICE_REGISTRY_FILENAME) + const wiring = { getAuthenticatedConnections: () => [] } + expect( + collectRemoteServerStatus(listener, new DeviceRegistry(path), wiring).grants + ).toMatchObject({ state: 'available', total: 0 }) + for (const contents of ['{broken', '{}', '[null]', '[{"deviceId":"invalid"}]']) { + fs.writeFileSync(file, contents) + expect(collectRemoteServerStatus(listener, new DeviceRegistry(path), wiring).grants).toEqual({ + state: 'unavailable' + }) + expect(fs.readFileSync(file, 'utf8')).toBe(contents) + } + const original = fs.statSync + vi.spyOn(fs, 'existsSync').mockImplementation((candidate) => candidate !== file) + vi.spyOn(fs, 'statSync').mockImplementation((...args) => { + if (args[0] === file) { + throw Object.assign(new Error('denied'), { code: 'EACCES' }) + } + return original(...args) + }) + expect(collectRemoteServerStatus(listener, new DeviceRegistry(path), wiring).grants).toEqual({ + state: 'unavailable' + }) +}) + +it('bounds returned detail while retaining exact totals, and does not manufacture zeros for absent providers', () => { + expect(collectRemoteServerStatus({ state: 'disabled' }, null, null)).toEqual({ + listener: { state: 'disabled' }, + grants: { state: 'unavailable' }, + connectedClients: { state: 'unavailable' } + }) + const path = profile() + fs.writeFileSync( + join(path, DEVICE_REGISTRY_FILENAME), + JSON.stringify( + Array.from({ length: 105 }, (_, i) => ({ + deviceId: `test-${i}`, + name: 'a'.repeat(300), + scope: 'runtime', + token: `secret-${i}`, + pairedAt: 1, + lastSeenAt: 0 + })) + ) + ) + const registry = new DeviceRegistry(path) + const snapshot = collectRemoteServerStatus(listener, registry, { + getAuthenticatedConnections: () => [] + }) + expect(snapshot.grants).toMatchObject({ + state: 'available', + total: 105, + pending: 105, + truncated: true + }) + if (snapshot.grants.state !== 'available') { + throw new Error('Missing grants') + } + expect(snapshot.grants.entries).toHaveLength(100) + expect(snapshot.grants.entries[0]?.name).toHaveLength(160) + const connected = collectRemoteServerStatus(listener, registry, { + getAuthenticatedConnections: () => + registry.listDevices().map((device) => ({ + deviceId: device.deviceId, + scope: device.scope, + transport: 'direct' as const + })) + }).connectedClients + expect(connected).toMatchObject({ + state: 'available', + count: 105, + connectionCount: 105, + truncated: true + }) + if (connected.state !== 'available') { + throw new Error('Missing clients') + } + expect(connected.entries).toHaveLength(100) + expect(collectRemoteServerStatus(listener, registry, null).connectedClients).toEqual({ + state: 'unavailable' + }) +}) diff --git a/src/main/runtime/remote-server-status.ts b/src/main/runtime/remote-server-status.ts new file mode 100644 index 000000000000..049daf5ba701 --- /dev/null +++ b/src/main/runtime/remote-server-status.ts @@ -0,0 +1,112 @@ +import { + stripAnsiEscapeSequences, + TERMINAL_CONTROL_CHARACTER_PATTERN +} from '../../shared/ansi-escape-sequences' +import type { + RemoteServerStatus, + RemoteServerGrant, + RemoteServerClient +} from '../../shared/remote-server-status' +import type { DeviceEntry, DeviceRegistry } from './device-registry' +import type { MobileSocketWiring } from './rpc/mobile-socket-wiring' + +const MAX_STATUS_ENTRIES = 100 +const MAX_STATUS_NAME_LENGTH = 160 + +function validGrant(device: DeviceEntry): boolean { + return ( + !!device && + typeof device.deviceId === 'string' && + device.deviceId.length > 0 && + device.deviceId.length <= 160 && + typeof device.name === 'string' && + (device.scope === 'runtime' || device.scope === 'mobile') && + Number.isSafeInteger(device.pairedAt) && + device.pairedAt >= 0 && + Number.isSafeInteger(device.lastSeenAt) && + device.lastSeenAt >= 0 + ) +} + +function grantStatus(device: DeviceEntry): RemoteServerGrant { + return { + deviceId: device.deviceId, + name: stripAnsiEscapeSequences(device.name) + .replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '') + .replace(/[\r\n\t]+/g, ' ') + .trim() + .slice(0, MAX_STATUS_NAME_LENGTH), + scope: device.scope, + createdAt: device.pairedAt, + lastSeenAt: device.lastSeenAt > 0 ? device.lastSeenAt : null + } +} + +export function collectRemoteServerStatus( + listener: RemoteServerStatus['listener'], + registry: Pick | null, + wiring: Pick | null +): RemoteServerStatus { + const devices = registry?.listDevices() + if (!registry?.statusAvailable || !devices || !devices.every(validGrant)) { + return { + listener, + grants: { state: 'unavailable' }, + connectedClients: { state: 'unavailable' } + } + } + const grants: Extract = { + state: 'available', + total: devices.length, + pending: 0, + byScope: { runtime: 0, mobile: 0 }, + entries: [], + truncated: devices.length > MAX_STATUS_ENTRIES + } + const byId = new Map() + for (const device of devices) { + byId.set(device.deviceId, device) + grants.byScope[device.scope] += 1 + if (device.lastSeenAt === 0) { + grants.pending += 1 + } + if (grants.entries.length < MAX_STATUS_ENTRIES) { + grants.entries.push(grantStatus(device)) + } + } + if (!wiring) { + return { listener, grants, connectedClients: { state: 'unavailable' } } + } + const clients = new Map() + let connectionCount = 0 + const byScope = { runtime: 0, mobile: 0 } + for (const socket of wiring.getAuthenticatedConnections()) { + const device = byId.get(socket.deviceId) + // Revocation can precede the socket close callback. + if (!device || device.scope !== socket.scope) { + continue + } + let client = clients.get(device.deviceId) + if (!client) { + const { deviceId, name, scope } = grantStatus(device) + client = { deviceId, name, scope, connectionCount: 0, transports: { direct: 0, relay: 0 } } + clients.set(deviceId, client) + byScope[scope] += 1 + } + client.connectionCount += 1 + client.transports[socket.transport] += 1 + connectionCount += 1 + } + return { + listener, + grants, + connectedClients: { + state: 'available', + count: clients.size, + connectionCount, + byScope, + entries: Array.from(clients.values()).slice(0, MAX_STATUS_ENTRIES), + truncated: clients.size > MAX_STATUS_ENTRIES + } + } +} diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index 88b6b5220a7f..6cea993684d2 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -1,3 +1,4 @@ +import type { RemoteServerStatus } from '../../../shared/remote-server-status' // Why: single boundary between raw RPC frames and OrcaRuntimeService; keeps schema, handler, and result type on one object. import { ZodError, type ZodType } from 'zod' import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' @@ -81,6 +82,8 @@ export type RpcContext = { caller?: RpcCallerIdentity // Why: lets handlers gate mobile payload truncation to phones only; undefined for in-process callers → treat as full-class (no clip). clientKind?: 'mobile' | 'runtime' + /** Supplied only by an authenticated server-owner boundary, never request params. */ + readRemoteServerStatus?: () => RemoteServerStatus // Why: negotiation is bound to the authenticated socket, never asserted by a destructive request. clientCapabilities?: readonly RuntimeCapability[] // Why: mobile v2 auth is exact-key validated; capability upgrades must mutate only the authenticated socket after auth. diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index 6587825ac82c..1ceb4ef84edd 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -13,6 +13,7 @@ export type RpcDispatchStreamingOptions = Pick< | 'pairedDeviceId' | 'caller' | 'clientKind' + | 'readRemoteServerStatus' | 'clientCapabilities' | 'updateClientCapabilities' | 'pairing' @@ -44,6 +45,7 @@ export function rpcContextFromTransport( pairedDeviceId: options?.pairedDeviceId, caller: resolveRpcCallerIdentity(options), clientKind: options?.clientKind, + readRemoteServerStatus: options?.readRemoteServerStatus, clientCapabilities: options?.clientCapabilities, updateClientCapabilities: options?.updateClientCapabilities, authenticatedCallerFingerprint: options?.authenticatedCallerFingerprint, diff --git a/src/main/runtime/rpc/methods/status.ts b/src/main/runtime/rpc/methods/status.ts index cad7cded626b..95cd93b9e927 100644 --- a/src/main/runtime/rpc/methods/status.ts +++ b/src/main/runtime/rpc/methods/status.ts @@ -1,18 +1,22 @@ import { defineMethod } from '../core' +import { StatusGet } from '../../../../shared/rpc-contract/status-params' import { getRemoteServerUpdaterSnapshot } from '../../remote-server-updater' export const STATUS_METHODS = [ defineMethod({ name: 'status.get', permission: 'workspace', - params: null, - handler: async (_params, { runtime, pairedDeviceId }) => { + params: StatusGet, + handler: async (params, { runtime, pairedDeviceId, readRemoteServerStatus }) => { // Why: a status answered while the friendly-name lookup is still in flight publishes the bare // hostname; the wait is capped below the CLI's status probe so a slow lookup never reads as down. await runtime.machineNameReady() const snapshot = getRemoteServerUpdaterSnapshot(runtime.getRuntimeId()) return { ...runtime.getStatus(), + ...(params?.includeRemoteServer && readRemoteServerStatus + ? { remoteServer: readRemoteServerStatus() } + : {}), ...(pairedDeviceId ? { pairedDeviceId } : {}), appVersion: snapshot.appVersion, remoteUpdateSupport: snapshot.support diff --git a/src/main/runtime/rpc/mobile-socket-wiring.test.ts b/src/main/runtime/rpc/mobile-socket-wiring.test.ts index 5ce3325083cd..9fc7cbefc948 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.test.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.test.ts @@ -288,6 +288,7 @@ describe('MobileSocketWiring', () => { notificationError ) expect(transport.setClientId).not.toHaveBeenCalled() + expect(wiring.getAuthenticatedConnections()).toEqual([]) expect(ws.close).toHaveBeenCalledWith(4001, 'Unauthorized') expect(wiring.channelCount).toBe(0) consoleError.mockRestore() @@ -469,6 +470,34 @@ describe('MobileSocketWiring', () => { }, 0n ) + expect(wiring.getAuthenticatedConnections()).toEqual([ + { deviceId: 'device-1', scope: 'mobile', transport: 'relay' } + ]) + const direct = new FakeTransport() + const directSocket = new FakeSocket() + wiring.attachTransport(direct) + direct.receive( + directSocket, + JSON.stringify({ + type: 'e2ee_hello', + publicKeyB64: Buffer.from(phone.publicKey).toString('base64') + }) + ) + expect(wiring.getAuthenticatedConnections()).toHaveLength(1) + direct.receive( + directSocket, + encrypt( + JSON.stringify({ type: 'e2ee_auth', deviceToken: 'valid-token' }), + deriveSharedKey(phone.secretKey, desktop.publicKey) + ) + ) + expect(wiring.getAuthenticatedConnections()).toEqual([ + { deviceId: 'device-1', scope: 'mobile', transport: 'relay' }, + { deviceId: 'device-1', scope: 'mobile', transport: 'direct' } + ]) + directSocket.readyState = 2 + expect(wiring.getAuthenticatedConnections()).toHaveLength(1) + direct.disconnect(directSocket) const capabilityFrame = { type: 'e2ee_client_capabilities', v: 1, diff --git a/src/main/runtime/rpc/mobile-socket-wiring.ts b/src/main/runtime/rpc/mobile-socket-wiring.ts index 2d536b15038a..78201f2a432c 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.ts @@ -122,6 +122,20 @@ export class MobileSocketWiring { return this.connectionIds.size } + getAuthenticatedConnections(): { + deviceId: string + scope: DeviceEntry['scope'] + transport: 'direct' | 'relay' + }[] { + return Array.from(this.authenticatedSockets.values()) + .filter(({ ws }) => ws.readyState === ws.OPEN) + .map(({ device, transport }) => ({ + deviceId: device.deviceId, + scope: device.scope, + transport: transport.transport + })) + } + terminateDeviceConnections(deviceToken: string): number { let terminated = 0 for (const transport of this.transports) { diff --git a/src/main/runtime/runtime-rpc-remote-server-status-socket-lifetime.test.ts b/src/main/runtime/runtime-rpc-remote-server-status-socket-lifetime.test.ts new file mode 100644 index 000000000000..9aec33637bbe --- /dev/null +++ b/src/main/runtime/runtime-rpc-remote-server-status-socket-lifetime.test.ts @@ -0,0 +1,338 @@ +import { once } from 'node:events' +import { mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { WebSocketTransport } from './rpc/ws-transport' +import WebSocket from 'ws' +import { sendRequest } from './runtime-rpc-test-harness' +import { RemoteRuntimeRequestConnection } from '../../shared/remote-runtime-request-connection' +import { parsePairingCode } from '../../shared/pairing' +import type { RuntimeStatus } from '../../shared/runtime-types' +import { OrcaRuntimeService } from './orca-runtime' +import { OrcaRuntimeRpcServer } from './runtime-rpc' +import { readRuntimeMetadata } from './runtime-metadata' +import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files' +import { RpcDispatcher } from './rpc/dispatcher' +import { STATUS_METHODS } from './rpc/methods/status' +import { sshBridgeCredentials } from './rpc/ssh-bridge-credentials' + +async function readLocalStatus(path: string): Promise<{ result: RuntimeStatus }> { + const metadata = readRuntimeMetadata(path)! + const response = await sendRequest(metadata.transports[0]!.endpoint, { + id: 'status-test', + method: 'status.get', + authToken: metadata.authToken, + params: { includeRemoteServer: true } + }) + expect(response.ok).toBe(true) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture invokes the real status handler; assertions below validate its returned fields. + return { result: response.result as RuntimeStatus } +} + +async function withServer( + run: (server: OrcaRuntimeRpcServer, path: string, runtime: OrcaRuntimeService) => Promise +): Promise { + const path = mkdtempSync(join(tmpdir(), 'orca-server-status-')) + const runtime = new OrcaRuntimeService() + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath: path, + enableWebSocket: true, + wsPort: 0, + pinnedBindHost: '127.0.0.1' + }) + try { + await server.start() + await run(server, path, runtime) + } finally { + await server.stop() + rmSync(path, { recursive: true, force: true }) + } +} + +function connect( + server: OrcaRuntimeRpcServer, + scope: 'runtime' | 'mobile' +): RemoteRuntimeRequestConnection { + const offer = server.createPairingOffer({ + name: `${scope} test client`, + scope, + reach: 'this-computer' + }) + if (!offer.available) { + throw new Error('Test pairing unavailable') + } + const pairing = parsePairingCode(offer.pairingUrl) + if (!pairing) { + throw new Error('Invalid test pairing') + } + return new RemoteRuntimeRequestConnection(pairing) +} + +it('reports the actual loopback listener through local CLI status without changing exposure', async () => { + await withServer(async (server, path) => { + const endpoint = server.getWebSocketEndpoint() + const status = await readLocalStatus(path) + expect(status.result).toMatchObject({ + remoteServer: { + listener: { + state: 'listening', + address: '127.0.0.1', + port: Number(new URL(endpoint!).port) + }, + grants: { state: 'available', total: 0, pending: 0, entries: [] }, + connectedClients: { state: 'available', count: 0, connectionCount: 0, entries: [] } + } + }) + expect(server.getWebSocketEndpoint()).toBe(endpoint) + expect(server.getDeviceRegistry()?.listDevices()).toEqual([]) + }) +}) + +it('separates grants from authenticated identities and sockets, respects caller scope, disconnect and revocation', async () => { + await withServer(async (server, path, runtime) => { + // Create both connections before authentication so they share the same pending grant. + const first = connect(server, 'runtime') + const second = connect(server, 'runtime') + const phone = connect(server, 'mobile') + const unauthenticated = new WebSocket(server.getWebSocketEndpoint()!) + const readStatus = async () => (await readLocalStatus(path)).result.remoteServer + try { + await once(unauthenticated, 'open') + const hello = once(unauthenticated, 'message') + unauthenticated.send( + JSON.stringify({ type: 'e2ee_hello', publicKeyB64: server.getE2EEPublicKey() }) + ) + await hello + expect(await readStatus()).toMatchObject({ + grants: { total: 2, pending: 2 }, + connectedClients: { count: 0, connectionCount: 0 } + }) + const reply = await first.request( + 'status.get', + { includeRemoteServer: true }, + 5000 + ) + expect(reply).toMatchObject({ + ok: true, + result: { remoteServer: { connectedClients: { count: 1, connectionCount: 1 } } } + }) + await second.request('status.get', undefined, 5000) + const mobileReply = await phone.request( + 'status.get', + { clientKind: 'runtime', includeRemoteServer: true }, + 5000 + ) + expect(mobileReply.ok).toBe(true) + if (!mobileReply.ok) { + throw new Error('Mobile status failed') + } + expect(mobileReply.result).not.toHaveProperty('remoteServer') + const status = await readStatus() + expect(status).toMatchObject({ + grants: { total: 2, pending: 0, byScope: { runtime: 1, mobile: 1 } }, + connectedClients: { count: 2, connectionCount: 3, byScope: { runtime: 1, mobile: 1 } } + }) + expect(status?.connectedClients.state).toBe('available') + if (status?.connectedClients.state !== 'available') { + throw new Error('Missing clients') + } + expect(status.connectedClients.entries).toEqual( + expect.arrayContaining([ + { + deviceId: expect.any(String), + name: 'runtime test client', + scope: 'runtime', + connectionCount: 2, + transports: { direct: 2, relay: 0 } + }, + { + deviceId: expect.any(String), + name: 'mobile test client', + scope: 'mobile', + connectionCount: 1, + transports: { direct: 1, relay: 0 } + } + ]) + ) + const registry = server.getDeviceRegistry()! + for (const device of registry.listDevices()) { + expect(JSON.stringify(status)).not.toContain(device.token) + } + registry.flushPendingLastSeen() + const stored = readFileSync(join(path, DEVICE_REGISTRY_FILENAME), 'utf8') + await readStatus() + await readStatus() + expect(readFileSync(join(path, DEVICE_REGISTRY_FILENAME), 'utf8')).toBe(stored) + const metadata = readRuntimeMetadata(path)! + const denied = await sendRequest(metadata.transports[0]!.endpoint, { + id: 'denied', + method: 'status.get', + authToken: 'invalid', + params: { includeDetails: true } + }) + expect(denied).toMatchObject({ ok: false, error: { code: 'unauthorized' } }) + expect(denied).not.toHaveProperty('result') + const untrusted = new RpcDispatcher({ runtime, methods: STATUS_METHODS }) + const response = await untrusted.dispatch( + { + id: 'internal', + authToken: '', + method: 'status.get', + params: { clientKind: 'runtime', includeRemoteServer: true } + }, + { clientKind: 'runtime' } + ) + expect(response).toMatchObject({ ok: true }) + if (!response.ok) { + throw new Error('Internal status failed') + } + expect(response.result).not.toHaveProperty('remoteServer') + second.close() + await expect + .poll(async () => (await readStatus())?.connectedClients) + .toMatchObject({ count: 2, connectionCount: 2 }) + const runtimeDevice = registry.listDevices().find((device) => device.scope === 'runtime')! + expect(server.revokeRuntimeAccess(runtimeDevice.deviceId)).toBe(true) + await expect + .poll(async () => (await readStatus())?.connectedClients) + .toMatchObject({ count: 1, connectionCount: 1 }) + phone.close() + await expect + .poll(async () => (await readStatus())?.connectedClients) + .toMatchObject({ count: 0, connectionCount: 0 }) + expect(await readStatus()).toMatchObject({ + grants: { total: 1, pending: 0 }, + listener: { address: '127.0.0.1' } + }) + } finally { + first.close() + second.close() + phone.close() + unauthenticated.terminate() + } + }) +}) + +it('does not retain configured or stale listener ports across start and stop', async () => { + const path = mkdtempSync(join(tmpdir(), 'orca-listener-status-')) + const runtime = new OrcaRuntimeService() + const disabled = new OrcaRuntimeRpcServer({ runtime, userDataPath: path }) + expect(disabled['readRemoteServerStatus']().listener).toEqual({ state: 'disabled' }) + const server = new OrcaRuntimeRpcServer({ + runtime, + userDataPath: path, + enableWebSocket: true, + wsPort: 0, + pinnedBindHost: '127.0.0.1' + }) + expect(server['readRemoteServerStatus']().listener).toEqual({ state: 'not_listening' }) + try { + await server.start() + expect(server['readRemoteServerStatus']().listener).toMatchObject({ + state: 'listening', + address: '127.0.0.1' + }) + } finally { + await server.stop() + expect(server['readRemoteServerStatus']().listener).toEqual({ state: 'not_listening' }) + rmSync(path, { recursive: true, force: true }) + } +}) + +it('reports listener failure without inventing a port, and keeps two server instances separate', async () => { + const fail = vi + .spyOn(WebSocketTransport.prototype, 'start') + .mockRejectedValueOnce(new Error('synthetic bind failure')) + const log = vi.spyOn(console, 'error').mockImplementation(() => {}) + try { + await withServer(async (_server, path) => { + expect((await readLocalStatus(path)).result.remoteServer).toMatchObject({ + listener: { state: 'not_listening' }, + grants: { state: 'available', total: 0 }, + connectedClients: { state: 'unavailable' } + }) + }) + } finally { + fail.mockRestore() + log.mockRestore() + } + await withServer(async (first, firstPath, firstRuntime) => { + first.getDeviceRegistry()!.addDevice('First server only', 'runtime', 'this-computer') + await withServer(async (_second, secondPath, secondRuntime) => { + const a = (await readLocalStatus(firstPath)).result + const b = (await readLocalStatus(secondPath)).result + expect(a.runtimeId).toBe(firstRuntime.getRuntimeId()) + expect(b.runtimeId).toBe(secondRuntime.getRuntimeId()) + expect(a.remoteServer?.grants).toMatchObject({ total: 1, pending: 1 }) + expect(b.remoteServer?.grants).toMatchObject({ total: 0, pending: 0 }) + }) + }) +}) + +it('keeps routine status probes free of diagnostics even for authorized callers', async () => { + await withServer(async (server, path) => { + const metadata = readRuntimeMetadata(path)! + for (const params of [undefined, { includeRemoteServer: false }]) { + const response = await sendRequest(metadata.transports[0]!.endpoint, { + id: 'probe', + method: 'status.get', + authToken: metadata.authToken, + params + }) + expect(response.ok).toBe(true) + expect(response.result).not.toHaveProperty('remoteServer') + } + const client = connect(server, 'runtime') + try { + const probe = await client.request('status.get', undefined, 5000) + expect(probe.ok).toBe(true) + if (!probe.ok) { + throw new Error('Probe failed') + } + expect(probe.result).not.toHaveProperty('remoteServer') + const requested = await client.request( + 'status.get', + { includeRemoteServer: true }, + 5000 + ) + expect(requested).toMatchObject({ + ok: true, + result: { remoteServer: { connectedClients: { count: 1 } } } + }) + } finally { + client.close() + } + }) +}) + +it('withholds server diagnostics from SSH bridge credentials on the owner socket', async () => { + await withServer(async (server, path) => { + const metadata = readRuntimeMetadata(path)! + const read = vi.fn(server['readRemoteServerStatus'].bind(server)) + server['readRemoteServerStatus'] = read + for (const remoteCliControl of [false, true]) { + const credential = sshBridgeCredentials.mint({ + kind: 'ssh-bridge', + targetId: 'box-1', + remoteCliControl + }) + try { + const response = await sendRequest(metadata.transports[0]!.endpoint, { + id: 'bridge', + method: 'status.get', + authToken: credential.token, + params: { includeRemoteServer: true } + }) + expect(response.ok).toBe(true) + expect(response.result).not.toHaveProperty('remoteServer') + } finally { + credential.revoke() + } + } + expect(read).not.toHaveBeenCalled() + expect((await readLocalStatus(path)).result.remoteServer).toBeDefined() + expect(read).toHaveBeenCalledOnce() + }) +}) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts b/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts index ad1bd49cbad5..b7894b551849 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts @@ -39,7 +39,10 @@ export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting { try { return await this.dispatcher.dispatch(request, { signal: longPoll ? context?.signal : undefined, - callerScope + callerScope, + // Why: an SSH bridge credential authenticates here too, but server diagnostics are owner-only. + readRemoteServerStatus: + callerScope.kind === 'owner' ? () => this.readRemoteServerStatus() : undefined }) } finally { this.releaseLongPoll(longPoll) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts index b6492a6683b7..a14ed6045da0 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -1,4 +1,7 @@ import { randomBytes } from 'node:crypto' +import { WebSocketTransport } from '../rpc/ws-transport' +import { collectRemoteServerStatus } from '../remote-server-status' +import type { RemoteServerStatus } from '../../../shared/remote-server-status' import type { RuntimeTransportMetadata } from '../../../shared/runtime-bootstrap' import type { OrcaRuntimeService } from '../orca-runtime' import { RpcDispatcher } from '../rpc/dispatcher' @@ -94,6 +97,18 @@ export class RuntimeRpcState { protected clientRequestsInFlight = 0 protected lastClientRequestAt = Date.now() + protected readRemoteServerStatus(): RemoteServerStatus { + const transport = this.activeTransports.find( + (candidate) => candidate instanceof WebSocketTransport + ) + const address = transport?.resolvedHost + const listener: RemoteServerStatus['listener'] = + address && transport + ? { state: 'listening', address, port: transport.resolvedPort } + : { state: this.enableWebSocket ? 'not_listening' : 'disabled' } + return collectRemoteServerStatus(listener, this.deviceRegistry, this.mobileSocketWiring) + } + constructor({ runtime, userDataPath, diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index 67869763db85..0c3d4d3d9074 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -138,6 +138,8 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { // Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated. clientKind: device.scope, callerScope, + readRemoteServerStatus: + device.scope === 'runtime' ? () => this.readRemoteServerStatus() : undefined, clientCapabilities: authenticatedSocket?.clientCapabilities, updateClientCapabilities: authenticatedSocket && device.scope === 'mobile' diff --git a/src/shared/remote-server-status.ts b/src/shared/remote-server-status.ts new file mode 100644 index 000000000000..1ee22d75c15b --- /dev/null +++ b/src/shared/remote-server-status.ts @@ -0,0 +1,42 @@ +import type { DeviceScope } from './runtime-session-contracts' + +export type RemoteServerGrant = { + deviceId: string + name: string + scope: DeviceScope + /** Grant creation time, in Unix milliseconds; not the first connection time. */ + createdAt: number + lastSeenAt: number | null +} + +export type RemoteServerClient = Pick & { + connectionCount: number + transports: { direct: number; relay: number } +} + +export type RemoteServerStatus = { + listener: + | { state: 'listening'; address: string; port: number } + | { state: 'disabled' | 'not_listening' } + grants: + | { state: 'unavailable' } + | { + state: 'available' + total: number + pending: number + byScope: Record + entries: RemoteServerGrant[] + truncated: boolean + } + connectedClients: + | { state: 'unavailable' } + | { + state: 'available' + /** Distinct pairing identities, not physical devices. */ + count: number + connectionCount: number + byScope: Record + entries: RemoteServerClient[] + truncated: boolean + } +} diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index c49c6b38d6a6..0a9b24f268f7 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -488,6 +488,7 @@ import { SpeechModelAction } from './speech-params' import { SshTarget } from './ssh-params' +import { StatusGet } from './status-params' import { ContinueInterruptedParams } from './structured-agent-session-continue-params' import { AcknowledgeAttentionParams, @@ -1186,7 +1187,7 @@ export const RPC_PARAMS_BY_METHOD = { 'ssh.listTargetSummaries': null, 'ssh.listTargets': null, 'stats.summary': null, - 'status.get': null, + 'status.get': StatusGet, 'terminal.adoptOrphans': TerminalAdoptOrphans, 'terminal.agentStatus': TerminalHandle, 'terminal.clearBuffer': TerminalHandle, diff --git a/src/shared/rpc-contract/status-params.ts b/src/shared/rpc-contract/status-params.ts new file mode 100644 index 000000000000..38f0e6ec14b2 --- /dev/null +++ b/src/shared/rpc-contract/status-params.ts @@ -0,0 +1,3 @@ +import { z } from 'zod' + +export const StatusGet = z.object({ includeRemoteServer: z.boolean().optional() }).optional() diff --git a/src/shared/runtime-session-contracts.ts b/src/shared/runtime-session-contracts.ts index 9780ff1487a1..163dd0771a9e 100644 --- a/src/shared/runtime-session-contracts.ts +++ b/src/shared/runtime-session-contracts.ts @@ -1,3 +1,4 @@ +import type { RemoteServerStatus } from './remote-server-status' import type { AgentStatusOrchestrationContext } from './agent-status-types' import type { RemoteServerUpdateSupport } from './remote-server-update' import type { RemoteRuntimeSharedConnectionDiagnostics } from './remote-runtime-shared-control-types' @@ -63,6 +64,8 @@ export function browserUnavailableMessage( } export type RuntimeStatus = { + /** Absent for older hosts or callers without server diagnostics access. */ + remoteServer?: RemoteServerStatus runtimeId: string /** Authenticated requester identity. Missing for in-process callers and older hosts. */ pairedDeviceId?: string @@ -109,6 +112,7 @@ export type CliRuntimeState = | 'stale_bootstrap' export type CliStatusResult = { + remoteServer?: RemoteServerStatus target?: { kind: 'local' } | { kind: 'environment'; environment: string } app: { running: boolean From 5c8c8506be6f24aa3b185f98abe834bd08d7ff66 Mon Sep 17 00:00:00 2001 From: kambarakun Date: Wed, 7 Oct 2026 01:39:35 +0900 Subject: [PATCH 2/4] fix(status): limit server diagnostics to explicit JSON status --- .../recorded-request-params.test.ts | 4 ++-- src/cli/handlers/core.test.ts | 24 +++++++++++++++++++ src/cli/handlers/core.ts | 2 +- src/cli/runtime/client.ts | 15 ++++++++---- src/cli/runtime/status.test.ts | 7 +++++- src/cli/runtime/status.ts | 11 ++++++--- src/cli/runtime/websocket-transport.test.ts | 9 ++++++- 7 files changed, 59 insertions(+), 13 deletions(-) diff --git a/mobile/src/test-support/rpc-recording/recorded-request-params.test.ts b/mobile/src/test-support/rpc-recording/recorded-request-params.test.ts index 97b4dd9e7d1b..e0f89f611988 100644 --- a/mobile/src/test-support/rpc-recording/recorded-request-params.test.ts +++ b/mobile/src/test-support/rpc-recording/recorded-request-params.test.ts @@ -157,7 +157,7 @@ describe('recorded requests against the host params contract', () => { [ request('nothing.here', {}), request('worktree.activate', {}), - request('status.get', { verbose: true }), + request('preflight.detectAgents', { verbose: true }), request('terminal.close', { terminal: 'term-1', extra: 1 }) ], [{ method: 'terminal.close', path: '.gone', reason: 'made up' }] @@ -165,7 +165,7 @@ describe('recorded requests against the host params contract', () => { expect(problems).toEqual([ expect.stringContaining('the host has no method nothing.here'), expect.stringContaining('the host refuses it'), - expect.stringContaining('status.get takes no params; the host ignores these'), + expect.stringContaining('preflight.detectAgents takes no params; the host ignores these'), expect.stringContaining('the host drops .extra'), 'terminal.close .gone: no recorded request sends it any more' ]) diff --git a/src/cli/handlers/core.test.ts b/src/cli/handlers/core.test.ts index ba2daf90c30e..2390aec4da42 100644 --- a/src/cli/handlers/core.test.ts +++ b/src/cli/handlers/core.test.ts @@ -177,3 +177,27 @@ describe('orca claude-teams CLI handler', () => { expect(spawnEnv.ANTHROPIC_API_KEY).toBe('sk-ant-system') }) }) + +it.each([true, false])('requests diagnostics only for JSON status (json=%s)', async (json) => { + const getCliStatus = vi.fn().mockResolvedValue({ + id: 'status', + ok: true, + result: { + app: { running: false, pid: null }, + runtime: { state: 'not_running', reachable: false, runtimeId: null }, + graph: { state: 'not_running' } + }, + _meta: { runtimeId: 'none' } + }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the unreachable status fixture makes this handler use only getCliStatus. + const client = { getCliStatus } as unknown as RuntimeClient + const output = vi.spyOn(console, 'log').mockImplementation(() => {}) + const exitCode = process.exitCode + try { + await CORE_HANDLERS.status({ client, json, flags: new Map(), cwd: '/tmp/repo' }) + expect(getCliStatus).toHaveBeenCalledExactlyOnceWith({ includeRemoteServer: json }) + } finally { + output.mockRestore() + process.exitCode = exitCode + } +}) diff --git a/src/cli/handlers/core.ts b/src/cli/handlers/core.ts index ac9097b2d13e..d2721081d482 100644 --- a/src/cli/handlers/core.ts +++ b/src/cli/handlers/core.ts @@ -129,7 +129,7 @@ export const CORE_HANDLERS: Record = { process.exitCode = exitCode }, status: async ({ client, json }) => { - const result = await client.getCliStatus() + const result = await client.getCliStatus({ includeRemoteServer: json }) if (!json && !result.result.runtime.reachable) { process.exitCode = 1 } diff --git a/src/cli/runtime/client.ts b/src/cli/runtime/client.ts index c36df160a01e..fdec4afabcd4 100644 --- a/src/cli/runtime/client.ts +++ b/src/cli/runtime/client.ts @@ -11,7 +11,7 @@ import { import type { PairingOffer } from '../../shared/pairing' import { launchOrcaApp } from './launch' import { getDefaultUserDataPath, readMetadata } from './metadata' -import { getCliStatus, projectRemoteAppStatus } from './status' +import { getCliStatus, projectRemoteAppStatus, type CliStatusOptions } from './status' import { sendRequest } from './transport' import { RuntimeClientError, RuntimeRpcFailureError, type RuntimeRpcSuccess } from './types' import { @@ -204,9 +204,12 @@ export class RuntimeClient { return this.requestTimeoutMs } - async getCliStatus(): Promise> { + async getCliStatus(options: CliStatusOptions = {}): Promise> { if (this.remotePairing) { - const response = await this.call('status.get', { includeRemoteServer: true }) + const response = await this.call( + 'status.get', + options.includeRemoteServer ? { includeRemoteServer: true } : undefined + ) this.remoteCompat.noteVerifiedStatus(response.result) const graphState = response.result.graphStatus return { @@ -218,7 +221,9 @@ export class RuntimeClient { environment: this.environmentSelector ?? 'pairing-code' }, app: projectRemoteAppStatus(response.result), - ...(response.result.remoteServer ? { remoteServer: response.result.remoteServer } : {}), + ...(options.includeRemoteServer && response.result.remoteServer + ? { remoteServer: response.result.remoteServer } + : {}), runtime: { state: graphState === 'ready' ? 'ready' : 'graph_not_ready', reachable: true, @@ -241,7 +246,7 @@ export class RuntimeClient { _meta: response._meta } } - return getCliStatus(this.userDataPath) + return getCliStatus(this.userDataPath, options) } private async ensureOrchestrationContractCompatible(timeoutMs: number): Promise { diff --git a/src/cli/runtime/status.test.ts b/src/cli/runtime/status.test.ts index 107605ba0ed3..45e802a1590b 100644 --- a/src/cli/runtime/status.test.ts +++ b/src/cli/runtime/status.test.ts @@ -42,6 +42,7 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => { async (remoteServer) => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-status-')) const endpoint = join(userDataPath, 'runtime.sock') + const requestParams: unknown[] = [] const server = createServer((socket) => { sockets.add(socket) socket.once('close', () => sockets.delete(socket)) @@ -51,6 +52,7 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => { id: string params?: { includeRemoteServer?: boolean } } + requestParams.push(request.params) socket.write( `${JSON.stringify({ id: request.id, @@ -88,8 +90,11 @@ describe.skipIf(process.platform === 'win32')('CLI runtime status', () => { }) ) - const status = await new RuntimeClient(userDataPath).getCliStatus() + const client = new RuntimeClient(userDataPath) + expect((await client.getCliStatus()).result).not.toHaveProperty('remoteServer') + const status = await client.getCliStatus({ includeRemoteServer: true }) + expect(requestParams).toEqual([undefined, { includeRemoteServer: true }]) expect(status.result.remoteServer).toEqual(remoteServer) expect(status.result.runtime).toMatchObject({ reachable: true, diff --git a/src/cli/runtime/status.ts b/src/cli/runtime/status.ts index 9cfb8f04f086..41fdca375e9a 100644 --- a/src/cli/runtime/status.ts +++ b/src/cli/runtime/status.ts @@ -12,8 +12,11 @@ import { isProcessRunning } from './runtime-pid-liveness' export { projectRemoteAppStatus, resolveDesktopWindowStatus } +export type CliStatusOptions = { includeRemoteServer?: boolean } + export async function getCliStatus( - userDataPath: string + userDataPath: string, + options: CliStatusOptions = {} ): Promise> { const metadata = tryReadMetadata(userDataPath) const transport = metadata ? findTransport(metadata, 'unix', 'named-pipe') : null @@ -41,7 +44,7 @@ export async function getCliStatus( const response = await sendRequest( metadata, 'status.get', - { includeRemoteServer: true }, + options.includeRemoteServer ? { includeRemoteServer: true } : undefined, 1000 ) if (response.ok === false) { @@ -50,7 +53,9 @@ export async function getCliStatus( const graphState = response.result.graphStatus const desktopWindowStatus = resolveDesktopWindowStatus(response.result) return buildCliStatusResponse({ - ...(response.result.remoteServer ? { remoteServer: response.result.remoteServer } : {}), + ...(options.includeRemoteServer && response.result.remoteServer + ? { remoteServer: response.result.remoteServer } + : {}), app: { running: true, pid: metadata.pid, diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index feaa13b736a8..1e55f6e4cb8d 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -45,6 +45,7 @@ type TestRuntime = { deviceToken: string authFrames: Record[] requestMethods: string[] + requestParams: unknown[] connectionCount: () => number close: () => Promise } @@ -134,8 +135,11 @@ describe('CLI remote WebSocket transport', () => { )! const client = new RuntimeClient('/tmp/unused', 5_000, barePayload) - const status = await client.getCliStatus() + expect((await client.getCliStatus()).result).not.toHaveProperty('remoteServer') + expect((await client.openOrca()).result).not.toHaveProperty('remoteServer') + const status = await client.getCliStatus({ includeRemoteServer: true }) + expect(runtime.requestParams).toEqual([undefined, undefined, { includeRemoteServer: true }]) expect(status.result.app).toEqual({ running: false, pid: null }) expect(status.result.runtime.reachable).toBe(true) expect(status.result.runtime.runtimeId).toBe('runtime-ws-2') @@ -289,6 +293,7 @@ async function startTestRuntime( const wss = new WebSocketServer({ server: httpServer }) const authFrames: Record[] = [] const requestMethods: string[] = [] + const requestParams: unknown[] = [] let connectionCount = 0 wss.on('connection', (ws) => { @@ -337,6 +342,7 @@ async function startTestRuntime( params?: { includeRemoteServer?: boolean } } requestMethods.push(request.method) + requestParams.push(request.params) const response = request.method === 'status.get' ? { @@ -387,6 +393,7 @@ async function startTestRuntime( deviceToken, authFrames, requestMethods, + requestParams, connectionCount: () => connectionCount, close: async () => { await new Promise((resolve) => { From ae613f78148b8c76da4258e88c059509420711ea Mon Sep 17 00:00:00 2001 From: kambarakun Date: Wed, 7 Oct 2026 01:51:08 +0900 Subject: [PATCH 3/4] test(status): verify lightweight startup and terminal probes --- src/cli/runtime-client.test.ts | 11 +++++----- src/cli/runtime/websocket-transport.test.ts | 22 ++++++++++++++++++- ...mote-server-status-socket-lifetime.test.ts | 4 ++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/cli/runtime-client.test.ts b/src/cli/runtime-client.test.ts index 2fa6130ce3d7..f4e8c6a5f027 100644 --- a/src/cli/runtime-client.test.ts +++ b/src/cli/runtime-client.test.ts @@ -319,14 +319,15 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { it('openOrca waits for a reachable headless runtime to expose a desktop window', async () => { const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-client-')) const endpoint = join(userDataPath, 'runtime.sock') - let statusRequests = 0 + const requestParams: unknown[] = [] const server = createServer((socket) => { sockets.add(socket) socket.once('close', () => sockets.delete(socket)) socket.once('data', (data) => { - const request = JSON.parse(String(data).trim()) as { id: string } - statusRequests += 1 - const available = statusRequests > 1 + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: this fixture reads requests emitted by RuntimeClient and asserts every probe's params. + const request = JSON.parse(String(data).trim()) as { id: string; params?: unknown } + requestParams.push(request.params) + const available = requestParams.length > 2 socket.write( `${JSON.stringify({ id: request.id, @@ -354,7 +355,7 @@ describe.skipIf(process.platform === 'win32')('RuntimeClient', () => { expect(launchOrcaApp).toHaveBeenCalledOnce() expect(status.result.app.desktopWindowStatus).toBe('available') - expect(statusRequests).toBeGreaterThan(1) + expect(requestParams).toEqual([undefined, undefined, undefined]) }) it('openOrca fails explicitly when the serve owner cannot promote safely', async () => { diff --git a/src/cli/runtime/websocket-transport.test.ts b/src/cli/runtime/websocket-transport.test.ts index 1e55f6e4cb8d..b68fabf07473 100644 --- a/src/cli/runtime/websocket-transport.test.ts +++ b/src/cli/runtime/websocket-transport.test.ts @@ -15,6 +15,7 @@ import { publicKeyToBase64 } from '../../shared/e2ee-crypto' import { RuntimeClient } from './client' +import { terminalSendHandler } from '../handlers/terminal-send' import { launchOrcaApp } from './launch' import { addEnvironmentFromPairingCode } from './environments' import { RuntimeClientError } from './types' @@ -137,9 +138,28 @@ describe('CLI remote WebSocket transport', () => { const client = new RuntimeClient('/tmp/unused', 5_000, barePayload) expect((await client.getCliStatus()).result).not.toHaveProperty('remoteServer') expect((await client.openOrca()).result).not.toHaveProperty('remoteServer') + await expect( + terminalSendHandler({ + client, + flags: new Map([ + ['terminal', 'term-test'], + ['text', 'synthetic prompt'], + ['enter', true], + ['wait-submit', '1'] + ]), + cwd: '/tmp/unused', + json: true + }) + ).rejects.toMatchObject({ code: 'incompatible_runtime' }) const status = await client.getCliStatus({ includeRemoteServer: true }) - expect(runtime.requestParams).toEqual([undefined, undefined, { includeRemoteServer: true }]) + expect(runtime.requestMethods).toEqual(Array(4).fill('status.get')) + expect(runtime.requestParams).toEqual([ + undefined, + undefined, + undefined, + { includeRemoteServer: true } + ]) expect(status.result.app).toEqual({ running: false, pid: null }) expect(status.result.runtime.reachable).toBe(true) expect(status.result.runtime.runtimeId).toBe('runtime-ws-2') diff --git a/src/main/runtime/runtime-rpc-remote-server-status-socket-lifetime.test.ts b/src/main/runtime/runtime-rpc-remote-server-status-socket-lifetime.test.ts index 9aec33637bbe..0b2b229f3d90 100644 --- a/src/main/runtime/runtime-rpc-remote-server-status-socket-lifetime.test.ts +++ b/src/main/runtime/runtime-rpc-remote-server-status-socket-lifetime.test.ts @@ -273,6 +273,7 @@ it('reports listener failure without inventing a port, and keeps two server inst it('keeps routine status probes free of diagnostics even for authorized callers', async () => { await withServer(async (server, path) => { + const inventory = vi.spyOn(server.getDeviceRegistry()!, 'listDevices') const metadata = readRuntimeMetadata(path)! for (const params of [undefined, { includeRemoteServer: false }]) { const response = await sendRequest(metadata.transports[0]!.endpoint, { @@ -292,6 +293,7 @@ it('keeps routine status probes free of diagnostics even for authorized callers' throw new Error('Probe failed') } expect(probe.result).not.toHaveProperty('remoteServer') + expect(inventory).not.toHaveBeenCalled() const requested = await client.request( 'status.get', { includeRemoteServer: true }, @@ -301,7 +303,9 @@ it('keeps routine status probes free of diagnostics even for authorized callers' ok: true, result: { remoteServer: { connectedClients: { count: 1 } } } }) + expect(inventory).toHaveBeenCalledOnce() } finally { + inventory.mockRestore() client.close() } }) From ce7136c6c0c1911907eb387327216f0a54f036f3 Mon Sep 17 00:00:00 2001 From: kambarakun Date: Wed, 7 Oct 2026 02:25:13 +0900 Subject: [PATCH 4/4] fix(status): reject malformed diagnostic grant inventories --- docs/site/content/docs/remote-servers.mdx | 3 +- src/main/runtime/remote-server-status.test.ts | 34 +++++++++++++++++++ src/main/runtime/remote-server-status.ts | 9 ++++- 3 files changed, 44 insertions(+), 2 deletions(-) diff --git a/docs/site/content/docs/remote-servers.mdx b/docs/site/content/docs/remote-servers.mdx index 490568191798..d19b0e1991b6 100644 --- a/docs/site/content/docs/remote-servers.mdx +++ b/docs/site/content/docs/remote-servers.mdx @@ -204,7 +204,8 @@ probes omit it. The block describes the server answering that request: reflected when the existing connection/heartbeat handling detects it. Both lists return up to 100 entries and indicate omitted entries with -`truncated: true`; totals still cover all observed records. Names are limited to +`truncated: true`; totals still cover all observed records. This status endpoint +has no pagination or continuation for retrieving omitted entries. Names are limited to 160 characters and stripped of terminal controls. Tokens, keys, pairing URLs, relay credentials, and push registrations are never included. diff --git a/src/main/runtime/remote-server-status.test.ts b/src/main/runtime/remote-server-status.test.ts index f27d8b14f1b5..df57002fef71 100644 --- a/src/main/runtime/remote-server-status.test.ts +++ b/src/main/runtime/remote-server-status.test.ts @@ -126,6 +126,40 @@ it('distinguishes a missing registry from malformed and inaccessible registries }) }) +it.each(['missing token', 'empty token', 'duplicate identity'])( + 'reports an unavailable inventory for a registry with %s', + (invalid) => { + const path = profile() + const file = join(path, DEVICE_REGISTRY_FILENAME) + const device = { + deviceId: 'test-client', + name: 'Client A', + scope: 'runtime', + token: invalid === 'missing token' ? undefined : invalid === 'empty token' ? '' : 'token-a', + pairedAt: 1, + lastSeenAt: 2 + } + const devices = [device] + if (invalid === 'duplicate identity') { + devices.push({ ...device, name: 'Client B', token: 'token-b' }) + } + const contents = JSON.stringify(devices) + fs.writeFileSync(file, contents) + const registry = new DeviceRegistry(path) + const getAuthenticatedConnections = vi.fn(() => [ + { deviceId: device.deviceId, scope: 'runtime' as const, transport: 'direct' as const } + ]) + + expect(collectRemoteServerStatus(listener, registry, { getAuthenticatedConnections })).toEqual({ + listener, + grants: { state: 'unavailable' }, + connectedClients: { state: 'unavailable' } + }) + expect(getAuthenticatedConnections).not.toHaveBeenCalled() + expect(fs.readFileSync(file, 'utf8')).toBe(contents) + } +) + it('bounds returned detail while retaining exact totals, and does not manufacture zeros for absent providers', () => { expect(collectRemoteServerStatus({ state: 'disabled' }, null, null)).toEqual({ listener: { state: 'disabled' }, diff --git a/src/main/runtime/remote-server-status.ts b/src/main/runtime/remote-server-status.ts index 049daf5ba701..7fe6bb89b9a6 100644 --- a/src/main/runtime/remote-server-status.ts +++ b/src/main/runtime/remote-server-status.ts @@ -19,6 +19,8 @@ function validGrant(device: DeviceEntry): boolean { typeof device.deviceId === 'string' && device.deviceId.length > 0 && device.deviceId.length <= 160 && + typeof device.token === 'string' && + device.token.length > 0 && typeof device.name === 'string' && (device.scope === 'runtime' || device.scope === 'mobile') && Number.isSafeInteger(device.pairedAt) && @@ -48,7 +50,12 @@ export function collectRemoteServerStatus( wiring: Pick | null ): RemoteServerStatus { const devices = registry?.listDevices() - if (!registry?.statusAvailable || !devices || !devices.every(validGrant)) { + if ( + !registry?.statusAvailable || + !devices || + !devices.every(validGrant) || + new Set(devices.map((device) => device.deviceId)).size !== devices.length + ) { return { listener, grants: { state: 'unavailable' },