Files
orca/src/main/runtime/runtime-rpc-websocket-bind-host.test.ts
T
Neil 5631aa00dd feat(orcad): items 2–7 — degradation, natives, daemon, ops, deploy (#16398)
* fix(ports): stop joining an undefined resourcesPath on a non-Electron host

`resolveWorkerEntryPath` branched on `isPackaged` alone and joined
`process.resourcesPath`. orcad reports `isPackaged` true — correctly, it is a
production build, and ~15 consumers read it that way to gate HTTPS-only skill
downloads and the real CLI name — but `process.resourcesPath` is Electron-only
and `undefined` under plain Node.

So the packaged branch threw
`TypeError [ERR_INVALID_ARG_TYPE]: The "path" argument must be of type string`
where a clean "worker unavailable" was the honest outcome. The type said
`resourcesPath: string`, which is how it went unnoticed; it is now
`string | undefined`, so the compiler carries the fact.

A host with no Electron resources tree has no asar to look in, so it falls back
to the module directory and lets the caller report a missing worker.

Found by the item 1 agent while auditing the same `isPackaged` defect class in
the watcher. Verified in both directions: reverting the guard reproduces the
TypeError.

* feat(orcad): prove node-pty loads before anything requires it

Of the two ways node-pty fails, only one is catchable. A missing module throws
MODULE_NOT_FOUND. A module built against the wrong libc or Node ABI is refused by
the dynamic loader, and in the worst case takes the process down before any handler
exists — that is #9902, which crashed the desktop app on Ubuntu 20.04 before a
window appeared. There was no libc or ABI precondition anywhere in the tree.

So orcad now proves the load in a CHILD process, from main.ts, before anything
requires node-pty. Whatever the child does — throw, abort, die on a signal — is data
rather than our own death, and the operator gets a sentence naming the host's libc,
Node ABI and prebuild slot plus the command to run. Proven-unloadable exits 78
(EX_CONFIG), so a supervisor does not restart an unequippable host forever. A probe
that never answered is unverifiable, not blocked: refusing to boot on an inconclusive
signal would take down hosts that work.

The child dlopens the file node-pty would have chosen, before requiring the package.
node-pty's loader walks several directories and rethrows only the LAST error, so a
refused binary reads as "Cannot find module ./prebuilds/..." — which sends the
operator to install a module that is already there. It also reports through stdout:
node echoes the whole -e source above a stack trace, and matching tokens against
stderr made the probe's own source text answer for the verdict.

Verdicts reach clients as a terminal_unavailable degradation alongside the existing
browser_unavailable one, through the same cause-registry shape. degradations[].code
is now an open vocabulary; clients already render only `message`.

Prebuilds are compiled from PATCHED sources — the patch IS the glibc-floor fix, so an
upstream tarball reproduces #9902 — into linux-{x64,arm64}-{glibc,musl} and
darwin-{x64,arm64} slots. libc is in the slot name because node-pty's loader falls
back to prebuilds/<platform>-<arch> and cannot tell glibc from musl. orcad installs
the matching slot at boot, so a host with no compiler serves terminals.

The relay's five pure toolchain-diagnosis functions moved to a transport-free module
so the Node bundle can reuse them without dragging ssh2 in behind them; the relay
keeps its API by re-export. macOS gets `xcode-select --install` rather than the
cross-distro apt/dnf/pacman/apk menu, every line of which is wrong there.

* test(orcad): pin the node-pty precondition to ground truth, not a prepared host

CI's test shard runs `vitest` directly, so `ensure-native-runtime --runtime=node`
never prepares node-pty for the Node ABI — `degraded` is the correct verdict
there, and asserting 'ok' encoded an environment the shard does not have.

Asserting whatever it returned would be vacuous, so the expectation is now
derived from an independent require() of node-pty. Verified it still bites:
forcing the precondition to always report 'ok' fails the suite.

* feat(orcad): run the terminal daemon, and the ops contract around it

orcad declared `canRecoverPersistentLocalPtys: () => false` because it did not
run the terminal daemon, so every restart, update and rollback SIGKILLed every
running terminal — on the host whose selling point is that work survives the
client going away. That is the one property `ssh-execution-boundary.md`
recommends the peer model for.

Item 4 — the daemon:

- Port the launch path off electron: `daemon-init.ts`,
  `daemon-host-relocation.ts` and `observability/logs-directory.ts` now read
  the `AppEnvironment` port. Relocation additionally asks whether the app root
  is an asar archive rather than whether the build is packaged, so a Node host
  answering `isPackaged() === true` no longer walks into an Electron-only
  NSIS-escape path (same precedent as `parcel-watcher-entry-path.ts`).
- `build-orcad.mjs` emits `daemon-entry.js` beside `orcad.js`, scans the
  forked children's metafiles for electron/node:sqlite, and load-checks the
  child under plain Node.
- orcad spawns and adopts the daemon; shutdown disconnects and never kills it.
  `canRecoverPersistentLocalPtys` now reads the live provider and is false
  under degraded routing, where fresh terminals would die with the process.

Item 3 — the ops contract (docs/reference/orcad-operations.md):

- Bind policy: `--bind`, default loopback, pinned so neither `orca serve`'s
  wide default nor the connected-device widen can override it, and so a paired
  client cannot rebind the listener from outside.
- Instance lock on the data root before profile load, scoped to the runtime
  role so it never refuses a restart that a live daemon makes worthwhile.
- Supervision: exit codes a supervisor can act on (78 = do not retry),
  second-signal escalation, a shutdown deadline, and crash-loop containment on
  daemon respawn.
- Health in the readiness payload: build hash, Node ABI, and a PTY self-test
  that spans both processes — the daemon spawns a real PTY in its own process
  and the verdict crosses its socket.

Both bundle load-checks now assert on exit codes: these bundles are minified
onto one line, so Node's uncaught-exception report echoes every string literal
in the bundle and the previous message match passed against a bundle that
never loaded.

* feat(orcad): deploy, activate and roll back a versioned orcad install

Plan items 6 and 7 from docs/design/shipping-orcad.html.

Install reuses the relay's transaction verbatim — per-version lock, staged
SFTP write, .install-complete sentinel, stale-lock recovery — under a
parameterized namespace, so orcad-<v>/ sits beside relay-<v>/ permanently
(§06). Parameterizing GC is the trap that creates: each model now collects
only its own directories, enforced twice (prefix-scoped remote listing plus
a local ownership re-check), and a client picks its model from how the host
is registered, never from what it finds on disk.

Activation is separate from installation, because a versioned directory
selects nothing. A candidate is launched, publishes orca_server_ready, and
only becomes active if its cross-process health payload passes: right build
hash, listening, daemon live, PTY self-test green. A rejected candidate is
stopped and the incumbent restarted, so a careful deploy cannot cause the
outage it was being careful about.

Update and rollback are shaped by the daemon. An update restarts orcad, the
daemon outlives it, and the surviving daemon was forked from the outgoing
bundle — so live terminals defer the update rather than proceed, and GC pins
the active version, the rollback target and the live daemon's bundle. Orca's
persisted state carries no schema version, so rollback restores a
pre-activation snapshot rather than trusting backward-readability; the point
past which it is unsafe is the first terminal created after activation,
which the snapshot cannot describe and the surviving daemon still owns.

Running the generated shell for real found two bugs the text assertions
missed: tar members re-quoted inside a shell variable captured nothing, and
kill -0 reports a zombie as alive.

* test(orcad): assert the precondition is self-consistent, not environment-shaped

The real-host case cannot predict a status: CI's shard runs vitest directly, so
node-pty is never built for the Node ABI and 'degraded' is correct there, while a
prepared checkout gives 'ok'.

The previous attempt used require('node-pty') as ground truth, which resolves the
JS wrapper while the native binding loads lazily — it proved strictly less than
the precondition checks, and failed CI for exactly that reason.

What is invariant on a host with node-pty installed: never 'blocked', and never a
degraded verdict carrying an unestablished reason. The injected-input tests keep
the logic coverage.

* fix(orcad): drop an eslint-disable the rule no longer needs

* test(orcad): separate slot placement from the load verdict

Both remaining CI failures were the same shape: tests reaching into node_modules
for a pty.node that only exists after `ensure-native-runtime --runtime=node`,
which CI's shard never runs because it invokes vitest directly.

Slot *placement* is the logic worth checking on every host, so it now uses a
synthetic payload and asserts the verdict stays honest about not loading. The
three assertions that genuinely need a Node-ABI binding are gated on it existing.

Verified: breaking slot installation fails both placement tests; with the real
pty.node hidden the file is 17 passed / 3 skipped instead of ENOENT.

* test(orcad): gate the load-dependent cases on a real load, not on the file existing

CI ships a pty.node built for Electron's ABI, so existsSync was true while require
still failed — the gate ran exactly the tests that host can never satisfy. It now
probes the binding in a child process, so a bad one cannot take the runner down.

The self-consistency assertion also allowed too little: 'blocked' is the honest
verdict for a corrupt binding, alongside 'ok' on a prepared host and 'degraded' on
an unprepared one. What stays invariant is that anything other than 'ok' names an
established cause, so a terminal is never declined for a reason nobody worked out.

Verified against all three host states: prepared (19 passed), unprepared, and a
corrupt binding (17 passed / 3 skipped, no failures).

* test(orcad): gate on the whole premise — binding AND spawn-helper

CI has a loadable pty.node but no spawn-helper, and a slot without the helper is
legitimately 'degraded'. So the previous gate let a test run whose premise ('a
complete slot yields ok') that host cannot satisfy.

Verified in both states: with the helper present 19 pass; with it removed the
load-dependent cases skip (17 passed / 3 skipped) instead of failing.

* fix(orcad): preserve degradation types after rebase
2026-08-27 00:18:51 -07:00

648 lines
24 KiB
TypeScript

import { mkdtempSync } from 'node:fs'
import { writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import WebSocket from 'ws'
import { OrcaRuntimeService } from './orca-runtime'
import { OrcaRuntimeRpcServer } from './runtime-rpc'
import { WebSocketTransport } from './rpc/ws-transport'
import { DeviceRegistry } from './device-registry'
import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files'
vi.mock('../git/worktree', () => {
const worktrees = [
{
path: '/tmp/worktree-a',
head: 'abc',
branch: 'feature/foo',
isBare: false,
isMainWorktree: false
}
]
return {
listWorktrees: vi.fn().mockResolvedValue(worktrees),
listWorktreesStrict: vi.fn().mockResolvedValue(worktrees)
}
})
describe('OrcaRuntimeRpcServer WebSocket bind host (STA-2370)', () => {
const wsTransportOf = (server: OrcaRuntimeRpcServer): WebSocketTransport | undefined =>
(server['activeTransports'] as unknown[]).find(
(transport): transport is WebSocketTransport => transport instanceof WebSocketTransport
)
it('binds the listener to loopback on a fresh desktop with no paired device', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
try {
expect(server.getDeviceRegistry()?.listDevices()).toHaveLength(0)
// Why: the exposure regression — before STA-2370 this bound 0.0.0.0 with zero devices paired.
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
expect(new URL(server.getWebSocketEndpoint()!).hostname).toBe('127.0.0.1')
} finally {
await server.stop()
}
})
it('widens the listener to all interfaces when a mobile pairing offer is created', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
try {
const loopbackPort = wsTransportOf(server)?.resolvedPort
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
const offer = await server.createMobilePairingOffer({
address: '100.64.1.20',
connectionMode: 'local-only'
})
expect(offer.available).toBe(true)
expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0')
expect(new URL(server.getWebSocketEndpoint()!).hostname).toBe('0.0.0.0')
// Why: the widen reuses the same port so the QR's already-advertised endpoint stays valid.
expect(wsTransportOf(server)?.resolvedPort).toBe(loopbackPort)
} finally {
await server.stop()
}
})
it('binds all interfaces at startup when exposeNetworkByDefault is set (orca serve)', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0,
exposeNetworkByDefault: true
})
await server.start()
try {
expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0')
expect(new URL(server.getWebSocketEndpoint()!).hostname).toBe('0.0.0.0')
} finally {
await server.stop()
}
})
it('binds all interfaces at startup when a previously-connected device can reconnect', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
// Why: a device that has actually connected (lastSeenAt > 0) may reconnect, so the listener must be
// reachable at startup without waiting for a new pairing action.
const registry = new DeviceRegistry(userDataPath)
const device = registry.getOrCreatePendingDevice('Paired phone', 'mobile')
registry.updateLastSeen(device.deviceId)
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
try {
expect(
server
.getDeviceRegistry()
?.listDevices()
.some((d) => d.lastSeenAt > 0)
).toBe(true)
expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0')
} finally {
await server.stop()
}
})
it('stays on loopback at startup for a pending device that has never connected', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
// Why: a pending device (offer created but never connected: lastSeenAt === 0) is not a reconnect, so
// the listener must stay loopback — this distinguishes the reconnect widen from a blanket any-device
// widen (a revert to listDevices().length > 0 would wrongly expose the LAN here).
const registry = new DeviceRegistry(userDataPath)
registry.getOrCreatePendingDevice('Pending phone', 'mobile')
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
try {
expect(server.getDeviceRegistry()?.listDevices()).toHaveLength(1)
expect(
server
.getDeviceRegistry()
?.listDevices()
.every((d) => d.lastSeenAt === 0)
).toBe(true)
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
} finally {
await server.stop()
}
})
it('stays on loopback at startup after a "This computer only" grant has connected', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
// Why: the local web client authenticating marks its grant lastSeenAt > 0 like any other socket, so a
// blanket "any connected device" widen republished the runtime on every interface one launch later —
// exactly what the user declined by picking "This computer only".
const registry = new DeviceRegistry(userDataPath)
const device = registry.getOrCreatePendingDevice('Runtime local', 'runtime', 'this-computer')
registry.updateLastSeen(device.deviceId)
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
try {
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
} finally {
await server.stop()
}
})
it('binds all interfaces at startup for a connected device paired before pairingReach existed', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
// Why: registries written by older desktops only ever held network-reach grants; a missing field must
// keep the reconnect widen or an already-paired phone would be stranded by the upgrade.
const legacyDevice = {
deviceId: 'legacy-device',
name: 'Legacy phone',
token: 'legacy-token',
scope: 'mobile',
pairedAt: Date.now(),
lastSeenAt: Date.now()
}
await writeFile(
join(userDataPath, DEVICE_REGISTRY_FILENAME),
JSON.stringify([legacyDevice]),
'utf-8'
)
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
try {
expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0')
} finally {
await server.stop()
}
})
it('upgrades a reused pending grant to network reach so its link survives a relaunch', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
let deviceId: string
try {
const local = server.createPairingOffer({
address: '127.0.0.1',
scope: 'runtime',
reach: 'this-computer'
})
expect(local.available).toBe(true)
// Why: without `rotate` the same pending token is re-advertised, now for off-host reach. The mark must
// widen with it — keeping it this-computer would leave the LAN link unserved after the next launch.
const network = server.createPairingOffer({
address: '100.64.1.20',
scope: 'runtime',
reach: 'network'
})
expect(network.available).toBe(true)
deviceId = network.available ? network.deviceId : ''
expect(deviceId).toBe(local.available ? local.deviceId : '')
server.getDeviceRegistry()?.updateLastSeen(deviceId)
} finally {
await server.stop()
}
const relaunched = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await relaunched.start()
try {
expect(wsTransportOf(relaunched)?.resolvedHost).toBe('0.0.0.0')
} finally {
await relaunched.stop()
}
})
it('keeps the pinned port when a later widen tears down a live loopback client', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
try {
const loopbackPort = wsTransportOf(server)!.resolvedPort
// Why: a "This computer only" link never widens, so unlike before, a local client can already be
// connected when a later LAN offer opts in. The rebind terminates it (ws cannot move a listener), so
// the port must be reused or the already-issued local link could never reconnect.
const client = new WebSocket(`ws://127.0.0.1:${loopbackPort}`)
await new Promise<void>((resolve, reject) => {
client.once('open', () => resolve())
client.once('error', reject)
})
const closed = new Promise<void>((resolve) => client.once('close', () => resolve()))
await server.ensureNetworkExposure()
await closed
expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0')
expect(wsTransportOf(server)?.resolvedPort).toBe(loopbackPort)
const reconnected = new WebSocket(`ws://127.0.0.1:${loopbackPort}`)
await new Promise<void>((resolve, reject) => {
reconnected.once('open', () => resolve())
reconnected.once('error', reject)
})
reconnected.close()
} finally {
await server.stop()
}
})
it('keeps the same MobileSocketWiring instance across a pairing widen (relay capture stays valid)', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
try {
const wiringBeforeWiden = server.getMobileSocketWiring()
expect(wiringBeforeWiden).not.toBeNull()
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
const offer = await server.createMobilePairingOffer({
address: '100.64.1.20',
connectionMode: 'local-only'
})
expect(offer.available).toBe(true)
expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0')
// Why: DesktopRelayService captures the wiring once at construction and hands it to every relay
// broker, so the widen must swap the transport under the SAME wiring — replacing the wiring would
// strand relay sockets on a dead object (lost connection IDs, binary handling, revocation targeting).
expect(server.getMobileSocketWiring()).toBe(wiringBeforeWiden)
// Why: object identity alone would pass even if the post-widen transport were never re-attached to the
// captured wiring. Prove the swap functionally — route a revocation through the pre-widen wiring and
// assert it reaches the NEW (0.0.0.0) transport, confirming attachTransport ran on the same wiring
// after the rebind. A revert that leaves the wiring pointed at the stopped loopback transport fails here.
const widenedTransport = wsTransportOf(server)
expect(widenedTransport).toBeDefined()
const terminateSpy = vi.spyOn(widenedTransport!, 'terminateClientConnections')
wiringBeforeWiden!.terminateDeviceConnections('device-token-xyz')
expect(terminateSpy).toHaveBeenCalledWith('device-token-xyz')
} finally {
await server.stop()
}
})
it('coalesces concurrent pairing widens into a single rebind', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
try {
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
const widenSpy = vi.spyOn(
server as unknown as { widenWebSocketBind: () => Promise<void> },
'widenWebSocketBind'
)
await Promise.all([server.ensureNetworkExposure(), server.ensureNetworkExposure()])
// Why: two racing pairing offers must share one rebind via networkExposurePromise — two competing
// stop/start races would fight for the port and could strand the listener on a random one.
expect(widenSpy).toHaveBeenCalledTimes(1)
expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0')
} finally {
await server.stop()
}
})
it('reports pairing unavailable but keeps a serving loopback listener when the widen bind fails', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
try {
const loopbackPort = wsTransportOf(server)?.resolvedPort
// Why: force the wide bind to throw AFTER the loopback listener is stopped, then let the loopback
// recovery bind through — proving no stranded/closed socket and a retry-able state.
const target = server as unknown as {
startWebSocketTransport: (opts: { host: string }) => Promise<unknown>
wsBoundHost: string | null
}
const original = target.startWebSocketTransport.bind(server)
vi.spyOn(target, 'startWebSocketTransport').mockImplementation(async (opts) => {
if (opts.host === '0.0.0.0') {
throw new Error('injected wide bind failure')
}
return original(opts)
})
const offer = await server.createMobilePairingOffer({
address: '100.64.1.20',
connectionMode: 'local-only'
})
// Why: a failed widen must NOT advertise a LAN endpoint with no LAN listener behind it — the offer is
// reported unavailable (STA-2370). A revert that swallows the widen failure would return available:true.
expect(offer.available).toBe(false)
if (!offer.available) {
expect(offer.reason).toBe('network_exposure_failed')
}
// Why: the listener must keep serving on loopback (same port) rather than being left stranded/closed.
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
expect(wsTransportOf(server)?.resolvedPort).toBe(loopbackPort)
// Why: wsBoundHost stays loopback so a later pairing offer retries the widen.
expect(target.wsBoundHost).toBe('127.0.0.1')
} finally {
await server.stop()
errorSpy.mockRestore()
}
})
it('keeps the widened listener tracked when persisting pairing metadata fails', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
try {
// Why: fail metadata publication AFTER the wide bind already succeeded. The live 0.0.0.0 listener must
// stay tracked in activeTransports — a revert that runs loopback recovery here orphans a running wide
// listener (an untracked 0.0.0.0 socket outside stop(): the exact STA-2370 exposure).
const target = server as unknown as {
writeMetadata: () => void
wsBoundHost: string | null
activeTransports: unknown[]
}
let injected = false
const originalWrite = target.writeMetadata.bind(server)
vi.spyOn(target, 'writeMetadata').mockImplementation(() => {
if (!injected && target.wsBoundHost === '0.0.0.0') {
injected = true
throw new Error('injected metadata write failure')
}
return originalWrite()
})
const offer = await server.createMobilePairingOffer({
address: '100.64.1.20',
connectionMode: 'local-only'
})
expect(injected).toBe(true)
// Why: only metadata persistence failed; the wide bind succeeded, so pairing is available and the wide
// listener is tracked (not orphaned) — bound to all interfaces, exactly one WebSocket transport.
expect(offer.available).toBe(true)
expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0')
expect(target.activeTransports.filter((t) => t instanceof WebSocketTransport)).toHaveLength(1)
} finally {
await server.stop()
errorSpy.mockRestore()
}
})
it('does not strand a wide listener when stop() races an in-flight widen', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
const target = server as unknown as {
startWebSocketTransport: (opts: {
host: string
}) => Promise<{ transport: WebSocketTransport; endpoint: string }>
activeTransports: unknown[]
}
const original = target.startWebSocketTransport.bind(server)
let releaseWideStart: () => void = () => {}
const wideStartGate = new Promise<void>((resolve) => {
releaseWideStart = resolve
})
let wideStopSpy: ReturnType<typeof vi.spyOn> = null
vi.spyOn(target, 'startWebSocketTransport').mockImplementation(async (opts) => {
if (opts.host === '0.0.0.0') {
// Why: hold the widen mid-flight (loopback already stopped) so stop() must race the rebind.
await wideStartGate
const result = await original(opts)
wideStopSpy = vi.spyOn(result.transport, 'stop')
return result
}
return original(opts)
})
// Why: begin a pairing widen but do not await it, then start shutdown while it is still in-flight.
const widen = server.ensureNetworkExposure()
const stopping = server.stop()
releaseWideStart()
await Promise.all([widen.catch(() => {}), stopping])
try {
// Why: STA-2370 — stop() must fence and await the in-flight widen so the freshly-bound wide listener is
// snapshotted and stopped, never written back into the cleared arrays as a live 0.0.0.0 leak. A revert
// (no fence) leaves the wide transport in activeTransports after stop() and never calls its stop().
expect(target.activeTransports).toHaveLength(0)
expect(wideStopSpy).not.toBeNull()
expect(wideStopSpy).toHaveBeenCalled()
} finally {
errorSpy.mockRestore()
}
})
it('honours a pinned bind host over exposeNetworkByDefault (orcad --bind)', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0,
// Why both: an unattended host passes an explicit answer, and it must outrank every
// implicit widen — otherwise "default loopback" is only true until something else wins.
exposeNetworkByDefault: true,
pinnedBindHost: '127.0.0.1'
})
await server.start()
try {
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
expect(new URL(server.getWebSocketEndpoint()!).hostname).toBe('127.0.0.1')
} finally {
await server.stop()
}
})
it('stays pinned to loopback even after a device has connected once', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
// Why this case specifically: the unpinned default widens at the NEXT startup once any
// network-reach device has connected. A loopback orcad would therefore go wide one
// restart after its first client paired — silently, and without the operator asking.
const registry = new DeviceRegistry(userDataPath)
const device = registry.getOrCreatePendingDevice('CLI', 'runtime', 'network')
registry.updateLastSeen(device.deviceId)
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0,
pinnedBindHost: '127.0.0.1'
})
await server.start()
try {
expect(
server
.getDeviceRegistry()
?.listDevices()
.some((d) => d.lastSeenAt > 0)
).toBe(true)
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
} finally {
await server.stop()
}
})
it('refuses a runtime-widening pairing offer while the bind is pinned to loopback', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0,
pinnedBindHost: '127.0.0.1'
})
await server.start()
try {
// A paired client can reach this RPC. Without the pin's refusal it would rebind the
// listener to every interface, undoing the operator's bind policy from the outside.
const offer = await server.createMobilePairingOffer({
address: '100.64.1.20',
connectionMode: 'local-only'
})
expect(offer.available).toBe(false)
if (!offer.available) {
expect(offer.reason).toBe('network_exposure_failed')
}
expect(wsTransportOf(server)?.resolvedHost).toBe('127.0.0.1')
} finally {
await server.stop()
errorSpy.mockRestore()
}
})
it('still widens on request when the operator pinned the wide address', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0,
pinnedBindHost: '0.0.0.0'
})
await server.start()
try {
expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0')
await server.ensureNetworkExposure()
expect(wsTransportOf(server)?.resolvedHost).toBe('0.0.0.0')
} finally {
await server.stop()
}
})
it('refuses to widen a pairing offer that arrives after the server has stopped', async () => {
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-runtime-rpc-'))
const server = new OrcaRuntimeRpcServer({
runtime: new OrcaRuntimeService(),
userDataPath,
enableWebSocket: true,
wsPort: 0
})
await server.start()
const widenSpy = vi.spyOn(
server as unknown as { widenWebSocketBind: () => Promise<void> },
'widenWebSocketBind'
)
await server.stop()
// Why: STA-2370 — the `stopping` fence must reject a widen that arrives on its own AFTER shutdown, not
// only one already in-flight during stop() (covered above via the pending-exposure await). Reverting the
// `stopping` guard alone still passes the race test, but here ensureNetworkExposure would re-enter
// widenWebSocketBind and re-open a 0.0.0.0 listener on a server that is supposed to be down.
await server.ensureNetworkExposure()
expect(widenSpy).not.toHaveBeenCalled()
})
})