From 34bdc307ca068b1716f76eb13e0931cec0518d24 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 29 Aug 2026 12:09:07 -0700 Subject: [PATCH] Split runtime RPC server responsibilities --- config/max-lines-baseline.txt | 1 - src/main/runtime/mobile-rpc-allowlist.test.ts | 5 +- src/main/runtime/runtime-rpc.ts | 1905 +---------------- .../runtime-rpc/runtime-rpc-binary-routing.ts | 114 + .../runtime-rpc/runtime-rpc-lifecycle.ts | 261 +++ .../runtime-rpc/runtime-rpc-long-poll.ts | 58 + .../runtime-rpc-mobile-method-allowlist.ts | 265 +++ .../runtime-rpc/runtime-rpc-mobile-pairing.ts | 260 +++ .../runtime-rpc-network-exposure.ts | 140 ++ .../runtime-rpc/runtime-rpc-pairing-types.ts | 145 ++ .../runtime-rpc/runtime-rpc-pairing.ts | 211 ++ .../runtime-rpc-request-admission.ts | 152 ++ .../runtime-rpc/runtime-rpc-shutdown.ts | 38 + .../runtime-rpc-socket-metadata.ts | 62 + .../runtime/runtime-rpc/runtime-rpc-state.ts | 133 ++ .../runtime-rpc-websocket-dispatch.ts | 157 ++ 16 files changed, 2017 insertions(+), 1890 deletions(-) create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-binary-routing.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-mobile-pairing.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-network-exposure.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-socket-metadata.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-state.ts create mode 100644 src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 607516684a7..eb4336aa6e0 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -18,7 +18,6 @@ inline src/main/runtime/orca-runtime-files.ts inline src/main/runtime/orca-runtime.test.ts inline src/main/runtime/orca-runtime.ts inline src/main/runtime/rpc/methods/orchestration.ts -inline src/main/runtime/runtime-rpc.ts inline src/main/ssh/ssh-channel-multiplexer.ts inline src/main/ssh/ssh-connection.ts inline src/main/ssh/ssh-relay-deploy.ts diff --git a/src/main/runtime/mobile-rpc-allowlist.test.ts b/src/main/runtime/mobile-rpc-allowlist.test.ts index 5d0faab64f9..adf9223b39a 100644 --- a/src/main/runtime/mobile-rpc-allowlist.test.ts +++ b/src/main/runtime/mobile-rpc-allowlist.test.ts @@ -95,7 +95,10 @@ function mobileRpcMethods(): string[] { } function mobileRpcAllowlist(): Set { - const source = readFileSync(join(process.cwd(), 'src/main/runtime/runtime-rpc.ts'), 'utf8') + const source = readFileSync( + join(process.cwd(), 'src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts'), + 'utf8' + ) const allowlist = source.match(/const MOBILE_RPC_METHOD_ALLOWLIST = new Set\(\[([\s\S]*?)\]\)/) if (!allowlist) { throw new Error('MOBILE_RPC_METHOD_ALLOWLIST not found') diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index b508f734c04..e7450ff5720 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -1,1894 +1,23 @@ -/* eslint-disable max-lines -- Why: this file is the single security boundary for the bundled CLI — transport setup, auth-token enforcement, admission control, keepalive framing, and orphan-socket sweeping all co-locate deliberately so a reviewer can audit the boundary in one sitting. Splitting this across files would scatter the invariants without reducing complexity. */ // Why: the single security boundary for the bundled CLI — auth-token enforcement, metadata publication, transport orchestration. -import { randomBytes } from 'node:crypto' -import { readdirSync, rmSync } from 'node:fs' -import { join } from 'node:path' -import type { RuntimeMetadata, RuntimeTransportMetadata } from '../../shared/runtime-bootstrap' -import type { OrcaRuntimeService } from './orca-runtime' -import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from './network-exposure-guidance' -import { writeRuntimeMetadata } from './runtime-metadata' -import { - RUNTIME_METADATA_OWNERSHIP_POLL_MS, - watchRuntimeMetadataOwnership, - type RuntimeMetadataOwnershipWatch -} from './runtime-metadata-ownership-watch' -import { RpcDispatcher } from './rpc/dispatcher' -import { ALL_RPC_METHODS } from './rpc/methods' -import type { RpcAnyMethod, RpcRequest, RpcResponse } from './rpc/core' -import { errorResponse } from './rpc/errors' -import { fingerprintAuthenticatedPairingCredential } from './rpc/orchestration-mutation-executor' -import type { RpcMessageContext, RpcTransport } from './rpc/transport' -import { UnixSocketTransport } from './rpc/unix-socket-transport' -import { WebSocketTransport } from './rpc/ws-transport' -import { readWsFallbackPort, writeWsFallbackPort } from './rpc/ws-fallback-port-store' -import type { WebSocket } from 'ws' -import { DeviceRegistry, type DeviceEntry, type DeviceScope } from './device-registry' -import { loadOrCreateE2EEKeypair, type E2EEKeypair } from './e2ee-keypair' -import { UnpairedDeviceAuthThrottle } from './rpc/unpaired-device-auth-throttle' -import { - MobileSocketWiring, - type AuthenticatedMobileSocket, - type MobileSocketTransportMetadata -} from './rpc/mobile-socket-wiring' -import type { PairingRelay } from '../../shared/mobile-relay-pairing-offer' -import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' -import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' -import { - mobileRelayMintFailureFromUnknown, - type MobileRelayMintFailure -} from '../../shared/mobile-relay-mint-failure' -import { - RelayRevokeOutbox, - type RelayDeviceBinding, - type RelayRevokeOutboxItem -} from './relay/relay-revoke-outbox' -import type { - DeviceCredentialInstalled, - PairingGetEndpointsParams, - PairingGetEndpointsResult, - PairingProvisionRelayParams -} from '../../shared/mobile-relay-credential-contract' -import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../shared/pairing' -import { resolveAdvertisedPairingEndpoint } from './pairing-endpoint' -import type { TerminalStreamFrame } from '../../shared/terminal-stream-protocol' -import { RuntimeBinaryMessageRouter } from './runtime-binary-message-router' +import { RuntimeRpcShutdown } from './runtime-rpc/runtime-rpc-shutdown' +import type { OrcaRuntimeRpcServerOptions } from './runtime-rpc/runtime-rpc-pairing-types' -const DEFAULT_WS_PORT = 6768 +export type { + PairingOfferUnavailableReason, + PairingOfferUnavailable +} from './runtime-rpc/runtime-rpc-pairing-types' +export type { MobilePairingConnectionContext } from './runtime-rpc/runtime-rpc-pairing-types' +export type { RuntimeLongPollClass } from './runtime-rpc/runtime-rpc-long-poll' +export { classifyRuntimeLongPoll } from './runtime-rpc/runtime-rpc-long-poll' -// Why: STA-2370 — the WS listener defaults to loopback so a desktop with no paired device is not -// reachable from the LAN; it widens to all interfaces only on explicit pairing (or `orca serve`). -const WS_BIND_HOST_LOOPBACK = '127.0.0.1' -const WS_BIND_HOST_ALL_INTERFACES = '0.0.0.0' - -// Why brackets: `ws://::1:6768` is not a URL, and every consumer of this endpoint parses it -// with `new URL`. An IPv6 bind address would otherwise publish an unparseable endpoint. -function formatWsEndpoint(host: string, port: number): string { - return `ws://${host.includes(':') ? `[${host}]` : host}:${port}` -} - -type OrcaRuntimeRpcServerOptions = { - runtime: OrcaRuntimeService - userDataPath: string - pid?: number - platform?: NodeJS.Platform - enableWebSocket?: boolean - wsPort?: number - // Why: true when the caller pinned a port (`orca serve --port`) so bind order prefers it over a stale STA-1511 fallback (#8535). - preferPinnedWsPort?: boolean - // Why: STA-2370 — bind the WS listener to all interfaces at startup instead of loopback-until-paired. - // Only `orca serve` (explicit remote opt-in) and E2E set this; the desktop app widens lazily on pairing. - exposeNetworkByDefault?: boolean - /** - * Pin the WS listener to exactly this address for the process's whole life. - * - * Why a pin and not another default: the two paths below both widen on their own — - * `exposeNetworkByDefault` at startup, and a device that has connected once at every - * later startup. An unattended host (orcad) whose operator asked for loopback must - * still be on loopback after a client pairs and the service restarts, so the answer - * has to outrank both, and `ensureNetworkExposure()` has to refuse rather than widen. - */ - pinnedBindHost?: string - webClientRoot?: string - // Why: test-only overrides for the two constants below; production must not pass these (defaults set by §3.1). - keepaliveIntervalMs?: number - longPollCap?: number - // Why: test-only override for the ownership reclaim cadence. - metadataOwnershipPollMs?: number - // Why: tests may inject inert protocol stages before production authorization registers them. - methods?: readonly RpcAnyMethod[] -} - -export type PairingOfferUnavailableReason = - | 'websocket_unavailable' - | 'device_registry_unavailable' - | 'e2ee_key_unavailable' - | 'invalid_advertised_endpoint' - | 'relay_mint_failed' - | 'network_exposure_failed' - -export type PairingOfferUnavailable = { - available: false - reason: PairingOfferUnavailableReason - guidance: string - /** Present when an Anywhere mint refused to silently fall back to LAN-only. */ - relayFailure?: MobileRelayMintFailure -} - -type MobilePairingOfferAvailable = { - available: true - pairingUrl: string - endpoint: string - deviceId: string - webClientUrl: string | null - /** Mode the offer actually encodes. */ - connectionMode: MobilePairingConnectionMode -} - -type MobilePairingOffer = PairingOfferUnavailable | MobilePairingOfferAvailable - -type PairingIdentityInitialization = - | { ok: true; deviceRegistry: DeviceRegistry; e2eeKeypair: E2EEKeypair } - | { ok: false; failure: PairingOfferUnavailable } - -function pairingUnavailable( - reason: PairingOfferUnavailableReason, - guidance: string -): PairingOfferUnavailable { - return { available: false, reason, guidance } -} - -const DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE = - 'The pairing registry is unavailable. Verify that the Orca data directory is writable.' -const E2EE_KEY_UNAVAILABLE_GUIDANCE = - 'The E2EE identity is unavailable. Verify that the Orca data directory is writable.' - -type MobileRelayPairingProvider = { - createPairingRelay( - relayDeviceId: string - ): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }> - onDeviceRevokeQueued(item: RelayRevokeOutboxItem): void - onDemandStateChanged?(): void - getEndpoints( - context: MobilePairingConnectionContext, - params: PairingGetEndpointsParams - ): Promise - provisionRelay( - context: MobilePairingConnectionContext, - params: PairingProvisionRelayParams - ): Promise -} - -export type MobilePairingConnectionContext = Readonly<{ - deviceId: string - connectionId: string - transport: MobileSocketTransportMetadata -}> - -// Why: keepalive frames count as socket activity, resetting both idle timers so long-polls outlive the 30s/60s idle caps. See §3.1. -const KEEPALIVE_INTERVAL_MS = 10_000 - -// Why: cap long-polls at half the 32-slot connection budget so they can't starve short RPCs; overflow → runtime_busy. See §7 risk #2. -const LONG_POLL_CAP = 16 - -// Why: orchestration.ask blocks on a human/agent reply for minutes, an order of -// magnitude longer than terminal.wait or check --wait, so a fleet of asking -// workers would otherwise hold every slot and starve the mobile/web/CLI/relay -// clients sharing this runtime. Reserve half the budget for the other classes. -const ASK_LONG_POLL_SHARE = 0.5 -// Why: eight host slots preserve four-host overlap for two independently paired desktops. -const BROWSER_HOST_LONG_POLL_SHARE = 0.5 -// Why: asks and permanent hosts together retain the prior quarter-budget reservation for waits. -const SPECIALIZED_LONG_POLL_SHARE = 0.75 - -function createWebClientUrl(endpoint: string, pairingUrl: string): string { - const url = new URL(endpoint) - url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:' - url.pathname = webClientPathForEndpoint(url.pathname) - url.search = '' - // Why: pairing URLs carry full credentials; the fragment keeps them out of proxy logs and Referer headers. - url.hash = `pairing=${encodeURIComponent(pairingUrl)}` - return url.toString() -} - -function webClientPathForEndpoint(pathname: string): string { - if (!pathname || pathname === '/') { - return '/web-index.html' - } - return `${pathname.replace(/\/$/, '')}/web-index.html` -} - -const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ - 'accounts.list', - 'accounts.consumeCodexResetCredit', - 'accounts.selectClaude', - 'accounts.selectCodex', - 'accounts.selectCodexForTarget', - 'accounts.subscribe', - 'accounts.unsubscribe', - 'aiVault.listSessions', - 'aiVault.resolveSessionTitles', - 'aiVault.prepareSessionResume', - 'browser.back', - 'browser.dialogAccept', - 'browser.dialogDismiss', - 'browser.forward', - 'browser.goto', - 'browser.keyboardInsertText', - 'browser.keypress', - 'browser.mouseDown', - 'browser.mouseClick', - 'browser.mouseMove', - 'browser.mouseUp', - 'browser.mouseWheel', - 'browser.reload', - 'browser.screencast', - 'browser.screencast.unsubscribe', - 'browser.tabCreate', - 'browser.viewport', - 'clipboard.abortImageUpload', - 'clipboard.appendImageUploadChunk', - 'clipboard.commitImageUpload', - 'clipboard.saveImageAsTempFile', - 'clipboard.startImageUpload', - 'diagnostics.memory', - 'files.browseServerDir', - 'files.createFile', - 'files.list', - 'files.open', - 'files.openDiff', - 'files.read', - 'files.readDocPreview', - 'files.readChunk', - 'files.readDir', - 'files.readPreview', - 'files.readTerminalArtifact', - 'files.readTerminalArtifactPreview', - 'files.resolveTerminalPath', - 'files.searchPaths', - 'files.writeTerminalArtifact', - 'folderWorkspace.list', - 'git.abortMerge', - 'git.abortRebase', - 'git.bulkStage', - 'git.bulkUnstage', - 'git.branchCompare', - 'git.branchDiff', - 'git.cancelGenerateCommitMessage', - 'git.cancelGeneratePullRequestFields', - 'git.checkout', - 'git.commit', - 'git.commitCompare', - 'git.commitDiff', - 'git.discard', - 'git.discoverCommitMessageModels', - 'git.diff', - 'git.fetch', - 'git.forkSync', - 'git.fastForward', - 'git.generateCommitMessage', - 'git.generatePullRequestFields', - 'git.history', - 'git.localBranches', - 'git.pull', - 'git.push', - 'git.rebaseFromBase', - 'git.stage', - 'git.status', - 'git.unstage', - 'git.upstreamStatus', - 'github.createIssue', - 'github.addIssueComment', - 'github.addPRReviewComment', - 'github.addPRReviewCommentReply', - 'github.countWorkItems', - 'github.listAssignableUsers', - 'github.listLabels', - 'github.listWorkItems', - 'github.mergePR', - 'github.setPRAutoMerge', - 'github.requestPRReviewers', - 'github.removePRReviewers', - 'github.project.listAccessible', - 'github.project.listAssignableUsersBySlug', - 'github.project.listIssueTypesBySlug', - 'github.project.listLabelsBySlug', - 'github.project.listViews', - 'github.project.resolveRef', - 'github.project.addIssueCommentBySlug', - 'github.project.updateIssueCommentBySlug', - 'github.project.deleteIssueCommentBySlug', - 'github.project.clearItemField', - 'github.project.updateIssueBySlug', - 'github.project.updateIssueTypeBySlug', - 'github.project.updateItemField', - 'github.project.updatePullRequestBySlug', - 'github.project.viewTable', - 'github.project.workItemDetailsBySlug', - 'github.prForBranch', - 'github.prFileContents', - 'github.prChecks', - 'github.prCheckDetails', - 'github.rerunPRChecks', - 'github.resolveReviewThread', - 'github.setPRFileViewed', - 'github.updateIssue', - 'github.updatePR', - 'github.updatePRTitle', - 'github.updatePRState', - 'github.repoSlug', - 'github.workItem', - // Cross-repo lookup: lets the mobile Smart picker resolve a pasted github.com URL for a different repo. - 'github.workItemByOwnerRepo', - 'github.workItemDetails', - 'gitlab.createIssue', - 'gitlab.addIssueComment', - 'gitlab.addMRComment', - 'gitlab.listWorkItems', - // Mobile Smart picker: resolve a pasted GitLab URL to an exact issue/MR (MR listing reuses gitlab.listWorkItems). - 'gitlab.workItemByPath', - 'gitlab.mergeMR', - 'gitlab.resolveMRDiscussion', - 'gitlab.todos', - 'gitlab.updateIssue', - 'gitlab.updateMR', - 'gitlab.updateMRState', - 'gitlab.workItemDetails', - 'host.gitBash.isAvailable', - 'host.platform', - 'host.pwsh.isAvailable', - 'host.wsl.isAvailable', - 'host.wsl.listDistros', - 'hostedReview.create', - 'hostedReview.createStacked', - 'hostedReview.forBranch', - 'hostedReview.getCreationEligibility', - 'linear.getCustomView', - 'linear.getIssue', - 'linear.getProject', - 'linear.agentSearchIssues', - 'linear.issueContext', - 'linear.resolveCurrentIssue', - 'linear.addIssueComment', - 'linear.connect', - 'linear.createIssue', - 'linear.createProject', - 'linear.issueComments', - 'linear.listCustomViewIssues', - 'linear.listCustomViewProjects', - 'linear.listCustomViews', - 'linear.listIssues', - 'linear.mcpListIssues', - 'linear.listProjectIssues', - 'linear.listProjects', - 'linear.teamLabels', - 'linear.teamMembers', - 'linear.listTeams', - 'linear.searchIssues', - 'linear.selectWorkspace', - 'linear.status', - 'linear.teamStates', - 'linear.updateIssue', - 'markdown.readTab', - 'markdown.saveTab', - 'notifications.getMissedSince', - 'notifications.subscribe', - 'notifications.unsubscribe', - 'pairing.getEndpoints', - 'pairing.provisionRelay', - 'preflight.check', - 'preflight.detectAgents', - 'preflight.detectRemoteAgents', - 'projectGroup.list', - 'repo.baseRefDefault', - 'repo.gitAvailable', - 'repo.hooks', - 'repo.list', - 'repo.saveSparsePreset', - 'repo.searchRefs', - 'repo.sparsePresets', - 'repo.update', - 'runtime.clientEvents.subscribe', - 'runtime.clientEvents.unsubscribe', - 'session.tabs.activate', - 'session.tabs.close', - 'session.tabs.closeLifecycle', - 'session.tabs.createTerminal', - 'session.tabs.list', - 'session.tabs.listAll', - 'session.tabs.move', - 'session.tabs.subscribe', - 'session.tabs.subscribeAll', - 'session.tabs.unsubscribe', - 'session.tabs.unsubscribeAll', - 'nativeChat.readSession', - 'nativeChat.subscribe', - 'nativeChat.unsubscribe', - 'settings.get', - 'settings.getTerminalQuickCommands', - 'settings.update', - 'settings.updateTerminalQuickCommands', - 'ssh.connect', - 'ssh.getState', - 'ssh.listRemovedTargetLabels', - 'ssh.listTargets', - 'ssh.listTargetSummaries', - 'speech.dictation.cancel', - 'speech.dictation.chunk', - 'speech.dictation.finish', - 'speech.dictation.setup', - 'speech.dictation.start', - 'speech.models.delete', - 'speech.models.download', - 'speech.models.list', - 'stats.summary', - 'status.get', - 'agentTeams.prepareLaunch', - 'agentTeams.tmuxCompat', - 'terminal.clearBuffer', - 'terminal.close', - 'terminal.closeTab', - 'terminal.create', - 'terminal.createAgentSession', - 'terminal.ensureAgentSession', - 'terminal.focus', - 'terminal.agentStatus', - 'terminal.adoptOrphans', - 'terminal.getAutoRestoreFit', - 'terminal.isRunningAgent', - 'terminal.list', - 'terminal.multiplex', - 'terminal.read', - 'terminal.rename', - 'terminal.send', - 'terminal.setAutoRestoreFit', - 'terminal.setDisplayMode', - 'terminal.subscribe', - 'terminal.unsubscribe', - 'terminal.updateViewport', - 'terminal.wait', - 'ui.get', - 'ui.recordFeatureInteraction', - 'ui.set', - 'worktree.activate', - 'worktree.create', - 'worktree.forceDeleteBranch', - 'worktree.listRetiredNames', - 'worktree.prefetchCreateBase', - 'worktree.ps', - 'worktree.show', - 'worktree.resolveMrBase', - 'worktree.resolvePrBase', - 'worktree.rm', - 'worktree.set', - 'worktree.sleep' -]) - -// Why: 'ask' is metered separately from 'wait' — same keepalive/abort wiring, its own sub-cap. -export type RuntimeLongPollClass = 'ask' | 'browser-host' | 'wait' - -// Why: single classifier for long-poll requests (handlers that block on an external event), shared by counter/abort/keepalive. See §3.1. -export function classifyRuntimeLongPoll(request: RpcRequest): RuntimeLongPollClass | null { - // Worker start waits for readiness and then verifies the submitted prompt; - // the complete operation can run for 90–110s. Keep every local transport - // (Unix sockets and Windows named pipes) alive for that long poll. - if (request.method === 'orchestration.workerStart') { - return 'wait' - } - if (request.method === 'browser.clientHost.attach') { - return 'browser-host' - } - if (request.method === 'terminal.wait') { - return 'wait' - } - // Agent-prompt submission waits for the PTY's lifecycle transition (up to - // the verification budget); keep the local socket alive for that wait. - if ( - request.method === 'terminal.send' && - typeof request.params === 'object' && - request.params !== null && - (request.params as { agentPrompt?: unknown }).agentPrompt === true - ) { - return 'wait' - } - // Why: orchestration.ask blocks unconditionally (default 600 s) holding the - // RPC open until a reply lands or the deadline passes, so it needs the same - // keepalive as check --wait or the 30 s socket idle timer tears it down. It - // also relies on the abort signal (only wired for long-polls) to release the - // waiter when the asking client disconnects. - if (request.method === 'orchestration.ask') { - return 'ask' - } - if (request.method === 'orchestration.check') { - const params = request.params as { wait?: unknown } | undefined - return params?.wait === true ? 'wait' : null - } - return null -} - -// Why: status.get has no per-connection context in the dispatcher, so stamp the scope here at the transport boundary. -function injectDeviceScope(response: string, scope: DeviceScope): string { - try { - const parsed = JSON.parse(response) as RpcResponse - if (parsed.ok !== true || typeof parsed.result !== 'object' || parsed.result === null) { - return response - } - ;(parsed.result as Record).deviceScope = scope - return JSON.stringify(parsed) - } catch { - return response +export class OrcaRuntimeRpcServer extends RuntimeRpcShutdown { + constructor(options: OrcaRuntimeRpcServerOptions) { + super(options) } } -export class OrcaRuntimeRpcServer { - private readonly runtime: OrcaRuntimeService - private readonly dispatcher: RpcDispatcher - private readonly userDataPath: string - private readonly pid: number - private readonly platform: NodeJS.Platform - private readonly enableWebSocket: boolean - private readonly wsPort: number - private readonly preferPinnedWsPort: boolean - private readonly exposeNetworkByDefault: boolean - private readonly pinnedBindHost: string | null - private readonly webClientRoot: string | undefined - // Why: STA-2370 — the host the WS listener is currently bound to, so pairing can widen loopback→all-interfaces once. - private wsBoundHost: string | null = null - // Why: STA-2370 — in-flight widen so concurrent pairing requests share a single rebind. - private networkExposurePromise: Promise | null = null - // Why: STA-2370 — set by stop() so a racing pairing widen can't recreate a live wide listener into the - // cleared transport arrays after shutdown; stop() also awaits any in-flight widen before snapshotting. - private stopping = false - private readonly authToken = randomBytes(24).toString('hex') - private readonly keepaliveIntervalMs: number - private readonly longPollCap: number - private readonly metadataOwnershipPollMs: number - private readonly askLongPollCap: number - private readonly browserHostLongPollCap: number - private readonly browserHostLongPollCapPerDevice: number - private readonly specializedLongPollCap: number - private readonly relayRevokeOutbox: RelayRevokeOutbox - private deviceRegistry: DeviceRegistry | null = null - private e2eeKeypair: E2EEKeypair | null = null - private pairingInitializationFailure: PairingOfferUnavailable | null = null - private tlsFingerprint: string | null = null - private activeTransports: RpcTransport[] = [] - private transports: RuntimeTransportMetadata[] = [] - private metadataOwnershipWatch: RuntimeMetadataOwnershipWatch | null = null - private mobileSocketWiring: MobileSocketWiring | null = null - // Why: detaches the current WebSocketTransport from the session wiring so a pairing rebind can swap - // transports under the SAME wiring (see ensureMobileSocketWiring) instead of orphaning relay sockets. - private detachWebSocketWiring: (() => void) | null = null - private mobileRelayPairingProvider: MobileRelayPairingProvider | null = null - private mobileRelayPairingOfferQueue: Promise = Promise.resolve() - private mobileRelayPairingOfferInFlight: { - generation: number - address: string | null - rotate: boolean - request: Promise - } | null = null - private mobilePairingOfferGeneration = 0 - private onUnpairedDeviceAuthFailure: (() => void) | null = null - private unpairedDeviceAuthThrottle: UnpairedDeviceAuthThrottle | null = null - private readonly binaryMessageRouter = new RuntimeBinaryMessageRouter() - private readonly wsDispatchAbortStates = new Map< - WebSocket, - { controllers: Set; abortOnClose: () => void } - >() - // Why: separate from server.maxConnections — count only long-running dispatches, not short RPCs. See §3.1 + §7 risk #2. - private activeLongPolls = 0 - // Why: subset of activeLongPolls held by orchestration.ask, fenced by askLongPollCap. - private activeAskLongPolls = 0 - private activeBrowserHostLongPolls = 0 - private readonly activeBrowserHostLongPollsByDevice = new Map() - - constructor({ - runtime, - userDataPath, - pid = process.pid, - platform = process.platform, - enableWebSocket = false, - wsPort = DEFAULT_WS_PORT, - preferPinnedWsPort = false, - exposeNetworkByDefault = false, - pinnedBindHost, - webClientRoot, - keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, - longPollCap = LONG_POLL_CAP, - metadataOwnershipPollMs = RUNTIME_METADATA_OWNERSHIP_POLL_MS, - methods - }: OrcaRuntimeRpcServerOptions) { - this.runtime = runtime - this.dispatcher = new RpcDispatcher({ runtime, methods: methods ?? ALL_RPC_METHODS }) - this.userDataPath = userDataPath - this.pid = pid - this.platform = platform - this.enableWebSocket = enableWebSocket - this.wsPort = wsPort - this.preferPinnedWsPort = preferPinnedWsPort - this.exposeNetworkByDefault = exposeNetworkByDefault - this.pinnedBindHost = pinnedBindHost ?? null - this.webClientRoot = webClientRoot - this.keepaliveIntervalMs = keepaliveIntervalMs - this.longPollCap = longPollCap - this.metadataOwnershipPollMs = metadataOwnershipPollMs - // Why: derived, not configurable — the reservation must hold for whatever cap a caller picks. - this.askLongPollCap = Math.max(1, Math.floor(longPollCap * ASK_LONG_POLL_SHARE)) - this.browserHostLongPollCap = Math.max( - 1, - Math.floor(longPollCap * BROWSER_HOST_LONG_POLL_SHARE) - ) - this.browserHostLongPollCapPerDevice = Math.max(1, Math.floor(this.browserHostLongPollCap / 2)) - this.specializedLongPollCap = Math.max(1, Math.floor(longPollCap * SPECIALIZED_LONG_POLL_SHARE)) - this.relayRevokeOutbox = new RelayRevokeOutbox(userDataPath) - } - - getDeviceRegistry(): DeviceRegistry | null { - return this.deviceRegistry - } - - getTlsFingerprint(): string | null { - return this.tlsFingerprint - } - - getE2EEPublicKey(): string | null { - return this.e2eeKeypair?.publicKeyB64 ?? null - } - - getE2EEKeypair(): E2EEKeypair | null { - return this.e2eeKeypair - } - - getMobileSocketWiring(): MobileSocketWiring | null { - return this.mobileSocketWiring - } - - getRelayRevokeOutbox(): RelayRevokeOutbox { - return this.relayRevokeOutbox - } - - setMobileRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean { - const current = this.deviceRegistry?.getDevice(deviceId) - if ( - current?.scope !== 'mobile' || - this.deviceRegistry?.getMobilePairingConnectionMode(deviceId) === 'local-only' - ) { - return false - } - if ( - current.relayBinding && - (current.relayBinding.relayHostId !== binding.relayHostId || - current.relayBinding.ownerIdentityKey !== binding.ownerIdentityKey) - ) { - // Why: switching the owning account/host must not strand the old cloud credential family, even if that account is offline. - if (!this.queueRelayDeviceRevoke(current.relayBinding)) { - return false - } - } - const updated = this.deviceRegistry?.setRelayBinding(deviceId, binding) ?? false - if (updated) { - this.mobileRelayPairingProvider?.onDemandStateChanged?.() - } - return updated - } - - // Why: only the desktop shell can surface UI; headless serve leaves this unset. - setOnUnpairedDeviceAuthFailure(callback: (() => void) | null): void { - this.onUnpairedDeviceAuthFailure = callback - } - - setMobileRelayPairingProvider(provider: MobileRelayPairingProvider | null): void { - this.mobileRelayPairingProvider = provider - } - - async revokeMobileDevice(deviceId: string): Promise { - const device = this.deviceRegistry?.getDevice(deviceId) - if (device?.scope !== 'mobile') { - return false - } - if (device.relayBinding) { - if (!this.queueRelayDeviceRevoke(device.relayBinding)) { - return false - } - } - if (!this.deviceRegistry?.removeDevice(deviceId)) { - return false - } - this.mobileRelayPairingProvider?.onDemandStateChanged?.() - this.runtime.forgetClientNavigationState(deviceId) - this.mobileSocketWiring?.terminateDeviceConnections(device.token) - return true - } - - revokeRuntimeAccess(deviceId: string): boolean { - const device = this.deviceRegistry?.getDevice(deviceId) - if (device?.scope !== 'runtime' || !this.deviceRegistry?.removeDevice(deviceId)) { - return false - } - this.runtime.forgetClientNavigationState(deviceId) - this.mobileSocketWiring?.terminateDeviceConnections(device.token) - return true - } - - getWebSocketEndpoint(): string | null { - const ws = this.transports.find((t) => t.kind === 'websocket') - return ws?.endpoint ?? null - } - - createPairingOffer(args: { - address?: string | null - name?: string - rotate?: boolean - scope?: DeviceScope - // Why: STA-2370 — recorded on the grant so a "This computer only" client reconnecting cannot make the - // next launch bind every interface. Defaults to network reach, which is what every other caller means. - reach?: RuntimePairingReach - }): - | PairingOfferUnavailable - | { - available: true - pairingUrl: string - endpoint: string - deviceId: string - webClientUrl: string | null - } { - if (this.pairingInitializationFailure) { - return this.pairingInitializationFailure - } - const rawEndpoint = this.getWebSocketEndpoint() - if (!rawEndpoint) { - return pairingUnavailable( - 'websocket_unavailable', - 'WebSocket pairing is unavailable. Inspect preceding runtime errors and choose an unused --port if the listener failed.' - ) - } - if (!this.deviceRegistry) { - return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) - } - const publicKeyB64 = this.getE2EEPublicKey() - if (!publicKeyB64) { - return pairingUnavailable('e2ee_key_unavailable', E2EE_KEY_UNAVAILABLE_GUIDANCE) - } - - const advertised = resolveAdvertisedPairingEndpoint(rawEndpoint, args.address) - if (!advertised.ok) { - return pairingUnavailable(advertised.reason, advertised.guidance) - } - const endpoint = advertised.endpoint - const deviceName = args.name ?? `CLI ${new Date().toLocaleDateString()}` - const scope = args.scope ?? 'runtime' - let device: DeviceEntry - try { - const reach = args.reach ?? 'network' - device = args.rotate - ? this.deviceRegistry.rotatePendingDevice(deviceName, scope, reach) - : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope, reach) - } catch (error) { - console.error('[runtime] Failed to persist pairing credential:', error) - return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) - } - const pairingUrl = encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint, - deviceToken: device.token, - publicKeyB64, - pairedDeviceId: device.deviceId, - scope - }) - return { - available: true, - pairingUrl, - endpoint, - deviceId: device.deviceId, - webClientUrl: - this.webClientRoot && scope === 'runtime' ? createWebClientUrl(endpoint, pairingUrl) : null - } - } - - async createMobilePairingOffer(args: { - address?: string | null - connectionMode?: MobilePairingConnectionMode - name?: string - rotate?: boolean - }): Promise { - // Why: STA-2370 — creating a mobile pairing offer is the user's explicit opt-in to LAN reach, so - // widen the loopback listener before advertising its LAN endpoint in the QR. If the widen fails the - // listener stays on loopback, so report unavailable rather than advertise a dead LAN endpoint. - try { - await this.ensureNetworkExposure() - } catch (error) { - console.error( - '[runtime] Network exposure failed while creating a mobile pairing offer:', - error - ) - return pairingUnavailable('network_exposure_failed', NETWORK_EXPOSURE_FAILED_GUIDANCE) - } - if (args.connectionMode === 'local-only') { - this.mobilePairingOfferGeneration += 1 - return this.createMobilePairingOfferSerial(args, this.mobilePairingOfferGeneration) - } - const address = args.address ?? null - const rotate = args.rotate === true - const inFlight = this.mobileRelayPairingOfferInFlight - if ( - inFlight?.generation === this.mobilePairingOfferGeneration && - inFlight.address === address && - (inFlight.rotate || !rotate) - ) { - return inFlight.request - } - // Why: every request that is not coalesced above supersedes the older one, rotating or not. - const generation = ++this.mobilePairingOfferGeneration - const request = this.mobileRelayPairingOfferQueue.then(() => - generation === this.mobilePairingOfferGeneration - ? this.createMobilePairingOfferSerial(args, generation) - : this.relayPairingRequestSuperseded() - ) - this.mobileRelayPairingOfferQueue = request.then( - () => undefined, - () => undefined - ) - this.mobileRelayPairingOfferInFlight = { generation, address, rotate, request } - void request.then( - () => { - if (this.mobileRelayPairingOfferInFlight?.request === request) { - this.mobileRelayPairingOfferInFlight = null - } - }, - () => { - if (this.mobileRelayPairingOfferInFlight?.request === request) { - this.mobileRelayPairingOfferInFlight = null - } - } - ) - return request - } - - private async createMobilePairingOfferSerial( - args: { - address?: string | null - connectionMode?: MobilePairingConnectionMode - name?: string - rotate?: boolean - }, - generation: number - ): Promise { - // Why: the renderer is outside the trust boundary, so only an explicit local-only value may suppress Relay provisioning. - const connectionMode = args.connectionMode === 'local-only' ? 'local-only' : 'automatic' - const pending = this.deviceRegistry?.getPendingDevice('mobile') - // Why: connection policy is part of the credential, so rotate on any policy switch — an old-policy QR must not pair under the new one. - const switchingPendingMode = - pending != null && - this.deviceRegistry?.getMobilePairingConnectionMode(pending.deviceId) !== connectionMode - if (args.rotate || switchingPendingMode) { - if (pending?.relayBinding) { - // Why: record the durable cloud revoke before rotating the local token so an old relay invite can't outlive the QR. - if (!this.queueRelayDeviceRevoke(pending.relayBinding)) { - return pairingUnavailable( - 'device_registry_unavailable', - 'Could not persist Relay cleanup before rotating the pairing code.' - ) - } - } - } - const direct = this.createPairingOffer({ - ...args, - rotate: args.rotate || switchingPendingMode, - scope: 'mobile' - }) - if (!direct.available) { - return direct - } - const createdNewPendingDevice = pending?.deviceId !== direct.deviceId - let connectionModeStored = false - try { - connectionModeStored = - this.deviceRegistry?.setMobilePairingConnectionMode(direct.deviceId, connectionMode) ?? - false - } catch (error) { - console.error('[runtime] Failed to persist the pairing connection mode:', error) - } - // Why: the mode is part of the credential — a QR whose policy was never stored must not pair under the default one. - if (!connectionModeStored) { - if (createdNewPendingDevice) { - this.discardPendingMobilePairingDevice(direct.deviceId) - } - return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) - } - // Why: explicit LAN path never needs Relay; mint the direct-only offer as selected. - if (connectionMode === 'local-only') { - return { ...direct, connectionMode: 'local-only' } - } - // Why: Anywhere must not silently ship a LAN-only QR under the Relay label. - // Fail closed, drop the unused pending credential, and let the UI offer Use LAN. - const refuseAutomaticWithoutRelay = ( - relayFailure: MobileRelayMintFailure - ): PairingOfferUnavailable => { - if (createdNewPendingDevice) { - this.discardPendingMobilePairingDevice(direct.deviceId) - } - return { - available: false, - reason: 'relay_mint_failed', - guidance: - 'Orca Relay could not create a pairing invite. Use LAN (Tailscale or same Wi‑Fi) or retry Relay.', - relayFailure - } - } - const relayProvider = this.mobileRelayPairingProvider - if (!relayProvider) { - return refuseAutomaticWithoutRelay({ - code: 'relay_provider_unavailable', - stage: 'provider_missing', - message: 'Orca Relay is not available on this desktop' - }) - } - const device = this.deviceRegistry?.getDevice(direct.deviceId) - const publicKeyB64 = this.getE2EEPublicKey() - if (!device || !publicKeyB64) { - return refuseAutomaticWithoutRelay({ - code: 'e2ee_key_unavailable', - stage: 'e2ee_missing', - message: 'E2EE public key unavailable for Relay pairing' - }) - } - let relayPairing: Awaited> - try { - relayPairing = await relayProvider.createPairingRelay(device.deviceId) - } catch (error) { - // Why: the raw provider error can carry request metadata or credentials — log only the validated code. - const relayFailure = mobileRelayMintFailureFromUnknown({ - stage: 'create_pairing_relay', - error, - fallbackCode: 'relay_mint_failed', - fallbackMessage: 'Relay pairing invite request failed' - }) - console.warn(`[runtime] Failed to create Relay pairing invite: ${relayFailure.code}`) - return refuseAutomaticWithoutRelay(relayFailure) - } - const currentDevice = this.deviceRegistry?.getDevice(device.deviceId) - if ( - generation !== this.mobilePairingOfferGeneration || - relayProvider !== this.mobileRelayPairingProvider || - currentDevice?.token !== device.token || - this.deviceRegistry?.getMobilePairingConnectionMode(device.deviceId) !== 'automatic' - ) { - this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding) - if (createdNewPendingDevice) { - this.discardPendingMobilePairingDevice(direct.deviceId) - } - return this.relayPairingRequestSuperseded() - } - try { - if (!this.setMobileRelayBinding(device.deviceId, relayPairing.binding)) { - this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding) - return refuseAutomaticWithoutRelay({ - code: 'relay_binding_failed', - stage: 'binding_failed', - message: 'Could not store Relay binding for the pairing device' - }) - } - } catch (error) { - console.warn('[runtime] Failed to persist Relay pairing binding:', error) - this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding) - return refuseAutomaticWithoutRelay({ - code: 'relay_binding_failed', - stage: 'binding_failed', - message: 'Could not store Relay binding for the pairing device' - }) - } - return { - ...direct, - connectionMode: 'automatic', - pairingUrl: encodePairingOffer({ - v: PAIRING_OFFER_VERSION, - endpoint: direct.endpoint, - deviceToken: device.token, - publicKeyB64, - pairedDeviceId: device.deviceId, - scope: 'mobile', - relay: relayPairing.relay - }) - } - } - - private relayPairingRequestSuperseded(): PairingOfferUnavailable { - return { - available: false, - reason: 'relay_mint_failed', - guidance: 'The Relay pairing request was replaced by a newer connection choice.', - relayFailure: { - code: 'relay_request_superseded', - stage: 'binding_failed', - message: 'Relay pairing request superseded' - } - } - } - - /** Drop a never-scanned mobile pending credential after a failed Anywhere mint. */ - private discardPendingMobilePairingDevice(deviceId: string): void { - const device = this.deviceRegistry?.getDevice(deviceId) - if (!device || device.scope !== 'mobile' || device.lastSeenAt !== 0) { - return - } - if (device.relayBinding) { - if (!this.queueRelayDeviceRevoke(device.relayBinding)) { - return - } - } - try { - this.deviceRegistry?.removeDevice(deviceId) - } catch (error) { - console.error('[runtime] Failed to drop an unused mobile pairing credential:', error) - } - } - - /** - * Why: the outbox is the only durable cleanup record for a minted invite. When it can't be - * written, keep the binding on the device so cleanup keeps a reference instead of orphaning it. - */ - private queueOrRetainRelayDeviceRevoke(deviceId: string, binding: RelayDeviceBinding): void { - if (this.queueRelayDeviceRevoke(binding)) { - return - } - try { - this.deviceRegistry?.setRelayBinding(deviceId, binding) - } catch (error) { - console.error('[runtime] Failed to retain an unrevoked Relay binding:', error) - } - } - - private queueRelayDeviceRevoke(binding: RelayDeviceBinding): boolean { - let item: RelayRevokeOutboxItem - try { - item = this.relayRevokeOutbox.enqueue(binding) - } catch (error) { - console.error('[runtime] Failed to persist Relay device cleanup:', error) - return false - } - try { - this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item) - } catch (error) { - console.warn('[runtime] Failed to notify Relay cleanup worker:', error) - } - return true - } - - private registerBinaryStreamHandler( - connectionId: string | undefined, - streamId: number, - handler: (frame: TerminalStreamFrame) => void - ): () => void { - return this.binaryMessageRouter.registerTerminalStream(connectionId, streamId, handler) - } - - private registerBinaryMessageHandler( - connectionId: string | undefined, - handler: (bytes: Uint8Array) => void - ): () => void { - return this.binaryMessageRouter.registerRawMessage(connectionId, handler) - } - - private handleWebSocketBinaryMessage(bytes: Uint8Array, ws: WebSocket): void { - this.binaryMessageRouter.dispatch(this.mobileSocketWiring?.getConnectionId(ws), bytes) - } - - private registerWebSocketDispatchAbort(ws: WebSocket): { - signal: AbortSignal - dispose: () => void - } { - const abortController = new AbortController() - if (ws.readyState !== ws.OPEN) { - abortController.abort() - return { signal: abortController.signal, dispose: () => {} } - } - - let state = this.wsDispatchAbortStates.get(ws) - if (!state) { - state = { - controllers: new Set(), - abortOnClose: () => this.abortWebSocketDispatches(ws) - } - this.wsDispatchAbortStates.set(ws, state) - // Why: many streaming RPCs share one WebSocket; one socket-level abort fan-out avoids MaxListenersExceededWarning. - ws.on('close', state.abortOnClose) - ws.on('error', state.abortOnClose) - } - state.controllers.add(abortController) - - return { - signal: abortController.signal, - dispose: () => { - const current = this.wsDispatchAbortStates.get(ws) - if (!current) { - return - } - current.controllers.delete(abortController) - if (current.controllers.size > 0) { - return - } - this.wsDispatchAbortStates.delete(ws) - ws.off('close', current.abortOnClose) - ws.off('error', current.abortOnClose) - } - } - } - - private abortWebSocketDispatches(ws: WebSocket): void { - const state = this.wsDispatchAbortStates.get(ws) - if (!state) { - return - } - this.wsDispatchAbortStates.delete(ws) - ws.off('close', state.abortOnClose) - ws.off('error', state.abortOnClose) - for (const controller of state.controllers) { - controller.abort() - } - state.controllers.clear() - } - - private initializePairingIdentity(): PairingIdentityInitialization { - let deviceRegistry: DeviceRegistry - try { - deviceRegistry = new DeviceRegistry(this.userDataPath) - } catch (error) { - console.error('[runtime] Failed to initialize pairing registry:', error) - return { - ok: false, - failure: pairingUnavailable( - 'device_registry_unavailable', - DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE - ) - } - } - let e2eeKeypair: E2EEKeypair - try { - e2eeKeypair = loadOrCreateE2EEKeypair(this.userDataPath) - } catch (error) { - console.error('[runtime] Failed to initialize E2EE identity:', error) - return { - ok: false, - failure: pairingUnavailable('e2ee_key_unavailable', E2EE_KEY_UNAVAILABLE_GUIDANCE) - } - } - return { ok: true, deviceRegistry, e2eeKeypair } - } - - async start(): Promise { - if (this.activeTransports.length > 0) { - return - } - - // Why: SIGKILL/OOM skip stop(), orphaning `o--*.sock` files; sweep them. Skipped on Windows: named pipes leave no filesystem entries. - if (this.platform !== 'win32') { - sweepOrphanedRuntimeSockets(this.userDataPath, this.pid) - } - - const transportMeta = createRuntimeTransportMetadata( - this.userDataPath, - this.pid, - this.platform, - this.runtime.getRuntimeId() - ) - - const socketTransport = new UnixSocketTransport({ - endpoint: transportMeta.endpoint, - kind: transportMeta.kind as 'unix' | 'named-pipe', - keepaliveIntervalMs: this.keepaliveIntervalMs - }) - - // Why: the `.catch` guarantees reply() always fires so a throw can't strand the client or leak the AbortController. - socketTransport.onMessage((msg, reply, context) => { - void this.handleMessage(msg, context) - .then((response) => { - reply(JSON.stringify(response)) - }) - .catch((error) => { - const message = error instanceof Error ? error.message : String(error) - // Why: best-effort id recovery so the client can correlate the error frame to its pending request. - let id = 'unknown' - try { - const parsed = JSON.parse(msg) as { id?: unknown } - if (typeof parsed.id === 'string' && parsed.id.length > 0) { - id = parsed.id - } - } catch { - // ignore — fall through with id='unknown' - } - reply(JSON.stringify(this.buildError(id, 'internal_error', message))) - }) - }) - - await socketTransport.start() - - const activeTransports: RpcTransport[] = [socketTransport] - const transportsMeta: RuntimeTransportMetadata[] = [transportMeta] - - // Why: WebSocket uses per-device tokens + E2EE (tweetnacl) instead of TLS since React Native can't pin self-signed certs. - if (this.enableWebSocket) { - // Why: land any deferred lastSeen write before a replacement registry reads the same file. - this.deviceRegistry?.flushPendingLastSeen() - const pairingIdentity = this.initializePairingIdentity() - if (!pairingIdentity.ok) { - this.deviceRegistry = null - this.e2eeKeypair = null - this.pairingInitializationFailure = pairingIdentity.failure - } else { - this.deviceRegistry = pairingIdentity.deviceRegistry - this.e2eeKeypair = pairingIdentity.e2eeKeypair - this.pairingInitializationFailure = null - try { - const host = this.resolveInitialWebSocketBindHost() - const { transport, endpoint } = await this.startWebSocketTransport({ - host, - port: this.wsPort, - preferPinnedPort: this.preferPinnedWsPort, - // Why: stable fallback port across restarts keeps paired devices' endpoints valid (STA-1511); wsPort 0 = random (E2E). - ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}) - }) - if (this.wsPort !== 0 && transport.resolvedPort !== this.wsPort) { - writeWsFallbackPort(this.userDataPath, transport.resolvedPort) - } - activeTransports.push(transport) - transportsMeta.push({ kind: 'websocket', endpoint }) - } catch (error) { - // Why: WebSocket transport is supplementary; on failure (e.g. port in use) continue with Unix socket only. - console.error('[runtime] Failed to start WebSocket transport:', error) - this.mobileSocketWiring = null - } - } - } - - // Why: set in-memory transport state before writing metadata so the bootstrap file has the real endpoint/token pair. - this.activeTransports = activeTransports - this.transports = transportsMeta - - try { - this.writeMetadata() - } catch (error) { - // Why: a runtime that can't publish metadata is invisible to the CLI — close transports rather than run undiscoverable. - this.activeTransports = [] - this.transports = [] - await Promise.all(activeTransports.map((t) => t.stop().catch(() => {}))).catch(() => {}) - throw error - } - - this.metadataOwnershipWatch = watchRuntimeMetadataOwnership({ - userDataPath: this.userDataPath, - ownedPid: this.pid, - ownedRuntimeId: this.runtime.getRuntimeId(), - pollIntervalMs: this.metadataOwnershipPollMs, - republish: () => { - // Why: never advertise endpoints we already tore down. - if (this.activeTransports.length === 0) { - return - } - this.writeMetadata() - }, - onReclaim: (previous) => { - console.warn( - `[runtime] Reclaimed orca-runtime.json from a dead runtime (pid ${previous?.pid ?? 'none'}); republished pid ${this.pid}.` - ) - } - }) - } - - // Why: STA-2370 — a desktop with no previously-connected device stays on loopback until the user - // explicitly pairs; `orca serve`/E2E (exposeNetworkByDefault) and a reconnecting paired device bind wide. - // A grant minted for "This computer only" is excluded: its client is a browser on this machine, so - // counting it would republish the runtime on every interface one restart after the user declined that. - private resolveInitialWebSocketBindHost(): string { - if (this.pinnedBindHost) { - return this.pinnedBindHost - } - if (this.exposeNetworkByDefault) { - return WS_BIND_HOST_ALL_INTERFACES - } - const hasConnectedNetworkDevice = - this.deviceRegistry - ?.listDevices() - .some((device) => device.lastSeenAt > 0 && device.pairingReach !== 'this-computer') ?? false - return hasConnectedNetworkDevice ? WS_BIND_HOST_ALL_INTERFACES : WS_BIND_HOST_LOOPBACK - } - - // Why: builds and starts a WS transport bound to `host`, wiring the session-scoped mobile socket - // handlers. Shared by initial start() and the pairing-time widen so both paths stay identical. - private async startWebSocketTransport(options: { - host: string - port: number - preferPinnedPort: boolean - fallbackPort?: number - }): Promise<{ transport: WebSocketTransport; endpoint: string }> { - const deviceRegistry = this.deviceRegistry - const e2eeKeypair = this.e2eeKeypair - if (!deviceRegistry || !e2eeKeypair) { - throw new Error('WebSocket transport requires an initialized pairing identity') - } - const wsTransport = new WebSocketTransport({ - host: options.host, - port: options.port, - staticRoot: this.webClientRoot, - ...(options.fallbackPort !== undefined ? { fallbackPort: options.fallbackPort } : {}), - ...(options.preferPinnedPort ? { preferPinnedPort: true } : {}) - }) - const mobileSocketWiring = this.ensureMobileSocketWiring(deviceRegistry, e2eeKeypair) - this.detachWebSocketWiring = mobileSocketWiring.attachTransport(wsTransport) - - try { - await wsTransport.start() - } catch (error) { - // Why: a listener that never bound must not stay attached to the session wiring (it would leak into - // terminateDeviceConnections); detach before propagating so the wiring only tracks live transports. - this.detachWebSocketWiring?.() - this.detachWebSocketWiring = null - throw error - } - this.wsBoundHost = options.host - return { - transport: wsTransport, - endpoint: formatWsEndpoint(options.host, wsTransport.resolvedPort) - } - } - - // Why: one MobileSocketWiring per server session. Direct WS and cloud relay both attach to it, and - // DesktopRelayService captures it once at construction, so a loopback→wide pairing rebind must swap the - // transport under the SAME wiring — replacing the wiring would strand relay sockets (lost connection IDs, - // binary handling, and revocation targeting) on a dead object. - private ensureMobileSocketWiring( - deviceRegistry: DeviceRegistry, - e2eeKeypair: E2EEKeypair - ): MobileSocketWiring { - if (this.mobileSocketWiring) { - return this.mobileSocketWiring - } - // Why: session-scoped so each desktop launch may notify once (a mid-session rebind must not reset it). - this.unpairedDeviceAuthThrottle = new UnpairedDeviceAuthThrottle({ - onTrigger: () => this.onUnpairedDeviceAuthFailure?.() - }) - const mobileSocketWiring = new MobileSocketWiring({ - deviceRegistry, - e2eeKeypair, - onText: (socket, plaintext, reply, sendBinary) => { - void this.handleWebSocketMessage( - plaintext, - reply, - sendBinary, - undefined, - socket.ws, - socket.device.deviceToken, - socket - ) - }, - onBinary: (socket, bytes) => this.handleWebSocketBinaryMessage(bytes, socket.ws), - onReady: () => { - // Why: first authenticated mobile/remote client (direct WS and - // cloud relay both attach here) starts path-candidate tracking. - // Activation is a local-host concern: candidate buffers live on the - // buffer-owning host's runtime, so a remote runtime proxy may - // legitimately lack this method (its own server activates it). - this.runtime.activateRecentPtyPathCandidateTracking?.() - this.mobileRelayPairingProvider?.onDemandStateChanged?.() - }, - onClose: (socket, hasOtherConnections) => { - if (!socket) { - return - } - this.abortWebSocketDispatches(socket.ws) - // Why: subscriptions and binary streams are socket-scoped, but disconnect state is device-scoped across transports. - this.runtime.cleanupSubscriptionsForConnection(socket.connectionId) - this.runtime.cancelMobileDictationForConnection(socket.connectionId) - this.binaryMessageRouter.deleteConnection(socket.connectionId) - if (!hasOtherConnections) { - this.runtime.onClientDisconnected(socket.device.deviceToken) - } - }, - // Why: relay attempts are authorized upstream; only direct failures should prompt local re-pairing. - onUnpairedDeviceAuthFailure: (metadata) => { - if (metadata.transport === 'direct') { - this.unpairedDeviceAuthThrottle?.recordFailure() - } - } - }) - this.mobileSocketWiring = mobileSocketWiring - return mobileSocketWiring - } - - // Why: STA-2370 — widen the loopback listener to all interfaces so a freshly generated pairing - // offer's advertised LAN endpoint is reachable. Idempotent, but it is no longer confined to the first - // pairing action: a "This computer only" link never widens, so live loopback clients can already be - // connected when a later LAN/QR offer opts in. Rebinding terminates them (ws cannot move a listener), - // so the resolved port is reused — already-issued endpoints stay valid and clients reconnect in place. - async ensureNetworkExposure(): Promise { - if (this.pinnedBindHost && this.pinnedBindHost !== WS_BIND_HOST_ALL_INTERFACES) { - // Why throw and not return: callers widen so they can ADVERTISE a LAN endpoint. Returning - // quietly would let them publish one that nothing can reach; the throw lands in their - // existing network_exposure_failed branch, which reports the offer unavailable instead. - throw new Error( - `Runtime bind address is pinned to ${this.pinnedBindHost}; refusing to widen to all interfaces` - ) - } - if ( - !this.enableWebSocket || - this.stopping || - this.wsBoundHost === WS_BIND_HOST_ALL_INTERFACES - ) { - return - } - // Why: concurrent pairing requests must share one rebind — two simultaneous stop/start races would - // fight for the port and strand the listener on a random one. - if (!this.networkExposurePromise) { - this.networkExposurePromise = this.widenWebSocketBind().finally(() => { - this.networkExposurePromise = null - }) - } - return this.networkExposurePromise - } - - private async widenWebSocketBind(): Promise { - const current = this.activeTransports.find( - (transport): transport is WebSocketTransport => transport instanceof WebSocketTransport - ) - if (!current) { - return - } - const index = this.activeTransports.indexOf(current) - const previousPort = current.resolvedPort - let widened: { transport: WebSocketTransport; endpoint: string } - try { - // Why: detach the loopback listener from the session wiring before stopping it so terminateDevice- - // Connections never iterates a dead transport; the new listener re-attaches to the SAME wiring. - this.detachWebSocketWiring?.() - await current.stop() - widened = await this.startWebSocketTransport({ - host: WS_BIND_HOST_ALL_INTERFACES, - port: previousPort, - preferPinnedPort: true - }) - } catch (error) { - // Why: the wide bind failed after the loopback listener was already stopped. Restore a serving - // loopback listener on the same port (wsBoundHost stays loopback so a later offer retries), then - // propagate — the caller must not advertise a LAN endpoint with no LAN listener behind it (STA-2370). - console.error('[runtime] Failed to widen WebSocket transport for pairing:', error) - await this.recoverWebSocketBindAfterFailedWiden(index, previousPort) - throw error - } - // Why: register the live wide transport BEFORE persisting metadata so a metadata-write failure can - // never orphan a running 0.0.0.0 listener outside activeTransports (and thus outside stop()). - this.activeTransports[index] = widened.transport - const metaIndex = this.transports.findIndex((meta) => meta.kind === 'websocket') - if (metaIndex !== -1) { - this.transports[metaIndex] = { kind: 'websocket', endpoint: widened.endpoint } - } - try { - // Why: a rebind that lands on a different port (same-port bind refused) must be persisted so a - // later reconnect from a device paired to this port matches on the next launch (STA-1511). - if (this.wsPort !== 0 && widened.transport.resolvedPort !== this.wsPort) { - writeWsFallbackPort(this.userDataPath, widened.transport.resolvedPort) - } - this.writeMetadata() - } catch (persistError) { - // Why: the wide listener is live and tracked; a persistence failure must not tear it down. Keep - // serving — the in-memory endpoint is already correct. - console.error( - '[runtime] Widened WebSocket listener but failed to persist pairing metadata:', - persistError - ) - } - } - - // Why: a failed widen already tore down the loopback listener; bring one back on the same host/port so the - // runtime keeps serving locally (wsBoundHost stays loopback, so the next pairing offer retries the widen). - private async recoverWebSocketBindAfterFailedWiden( - index: number, - previousPort: number - ): Promise { - let restored: { transport: WebSocketTransport; endpoint: string } - try { - restored = await this.startWebSocketTransport({ - host: WS_BIND_HOST_LOOPBACK, - port: previousPort, - preferPinnedPort: true - }) - } catch (recoveryError) { - // Why: even the loopback restore failed — drop the dead WebSocket transport so we never advertise an - // endpoint with no listener. The Unix socket keeps serving and a restart re-establishes the listener. - console.error( - '[runtime] Failed to restore WebSocket transport after widen failure:', - recoveryError - ) - this.activeTransports.splice(index, 1) - const metaIndex = this.transports.findIndex((meta) => meta.kind === 'websocket') - if (metaIndex !== -1) { - this.transports.splice(metaIndex, 1) - } - this.wsBoundHost = null - try { - this.writeMetadata() - } catch { - // Why: metadata already reflects the torn-down listener; nothing else to recover here. - } - return - } - // Why: register the restored loopback listener BEFORE persisting metadata so a write failure can never - // orphan a live transport outside activeTransports (and thus outside stop()). - this.activeTransports[index] = restored.transport - const metaIndex = this.transports.findIndex((meta) => meta.kind === 'websocket') - if (metaIndex !== -1) { - this.transports[metaIndex] = { kind: 'websocket', endpoint: restored.endpoint } - } - try { - this.writeMetadata() - } catch (persistError) { - // Why: the loopback listener is live and tracked; a persistence failure must not tear it down. - console.error( - '[runtime] Restored loopback WebSocket listener but failed to persist metadata:', - persistError - ) - } - } - - /** Why: test-only seam — runs one ownership check instead of waiting out the poll interval. */ - checkRuntimeMetadataOwnership(): void { - this.metadataOwnershipWatch?.check() - } - - async stop(): Promise { - // Why: STA-2370 — refuse new widens, then let any in-flight pairing widen settle into the live - // transport arrays before snapshotting them, so a racing rebind can't strand a wide 0.0.0.0 listener - // by writing it back into a cleared array after shutdown (see widenWebSocketBind). - this.stopping = true - const pendingExposure = this.networkExposurePromise - if (pendingExposure) { - await pendingExposure.catch(() => { - // Why: a failed widen already recovered/logged; we only need it to finish mutating the arrays. - }) - } - const transports = this.activeTransports - this.activeTransports = [] - this.transports = [] - this.metadataOwnershipWatch?.stop() - this.metadataOwnershipWatch = null - this.mobileSocketWiring = null - this.detachWebSocketWiring = null - const stopResults = await Promise.allSettled( - transports.map(async (transport) => transport.stop()) - ) - // Why: before-quit fences relay input; direct auth can still refresh lastSeen while these transports close. - this.deviceRegistry?.flushPendingLastSeen() - const failedStop = stopResults.find((result) => result.status === 'rejected') - if (failedStop?.status === 'rejected') { - throw failedStop.reason - } - // Why: leave the metadata file on shutdown — shared userData may host another live runtime whose bootstrap file we'd erase. - } - - // Why: Unix socket dispatch is one-shot and auths via the shared token from the 0o600 metadata file. See §3.1. - private async handleMessage( - rawMessage: string, - context?: RpcMessageContext - ): Promise { - // Why: the transport sends an empty message when a client exceeds max size, then closes the connection. - if (!rawMessage) { - return this.buildError('unknown', 'request_too_large', 'RPC request exceeds the maximum size') - } - - const parsed = this.parseAndAuth(rawMessage) - if ('error' in parsed) { - return parsed.error - } - const request = parsed.request - - // Why: long-poll admission fence; short RPCs bypass the counter. See §7 risk #2. - const longPoll = classifyRuntimeLongPoll(request) - const rejection = this.admitLongPoll(longPoll) - if (rejection) { - return this.buildError(request.id, 'runtime_busy', rejection) - } - if (longPoll) { - // Why: arm keepalive only for long-polls; short RPCs never create the setInterval. See §3.1. - context?.startKeepalive() - } - - try { - return await this.dispatcher.dispatch(request, { - signal: longPoll ? context?.signal : undefined - }) - } finally { - this.releaseLongPoll(longPoll) - } - } - - // Why: one fence for both transports — the total cap protects short RPCs, the ask - // sub-cap protects terminal.wait / check --wait from slow reply-blocked asks. - // Returns the rejection message, or null once the slot is reserved. - private admitLongPoll( - longPoll: RuntimeLongPollClass | null, - pairedDeviceId?: string - ): string | null { - if (!longPoll) { - return null - } - if (this.activeLongPolls >= this.longPollCap) { - return 'long-poll capacity reached; retry with backoff' - } - if ( - (longPoll === 'ask' || longPoll === 'browser-host') && - this.activeAskLongPolls + this.activeBrowserHostLongPolls >= this.specializedLongPollCap - ) { - return longPoll === 'ask' - ? 'orchestration.ask capacity reached; retry with backoff' - : 'browser-host capacity reached; retry with backoff' - } - if (longPoll === 'ask' && this.activeAskLongPolls >= this.askLongPollCap) { - return 'orchestration.ask capacity reached; retry with backoff' - } - if ( - longPoll === 'browser-host' && - (this.activeBrowserHostLongPolls >= this.browserHostLongPollCap || - (pairedDeviceId !== undefined && - (this.activeBrowserHostLongPollsByDevice.get(pairedDeviceId) ?? 0) >= - this.browserHostLongPollCapPerDevice)) - ) { - return 'browser-host capacity reached; retry with backoff' - } - this.activeLongPolls += 1 - if (longPoll === 'ask') { - this.activeAskLongPolls += 1 - } else if (longPoll === 'browser-host') { - this.activeBrowserHostLongPolls += 1 - if (pairedDeviceId !== undefined) { - this.activeBrowserHostLongPollsByDevice.set( - pairedDeviceId, - (this.activeBrowserHostLongPollsByDevice.get(pairedDeviceId) ?? 0) + 1 - ) - } - } - return null - } - - private releaseLongPoll(longPoll: RuntimeLongPollClass | null, pairedDeviceId?: string): void { - if (!longPoll) { - return - } - this.activeLongPolls = Math.max(0, this.activeLongPolls - 1) - if (longPoll === 'ask') { - this.activeAskLongPolls = Math.max(0, this.activeAskLongPolls - 1) - } else if (longPoll === 'browser-host') { - this.activeBrowserHostLongPolls = Math.max(0, this.activeBrowserHostLongPolls - 1) - if (pairedDeviceId !== undefined) { - const remaining = (this.activeBrowserHostLongPollsByDevice.get(pairedDeviceId) ?? 1) - 1 - if (remaining > 0) { - this.activeBrowserHostLongPollsByDevice.set(pairedDeviceId, remaining) - } else { - this.activeBrowserHostLongPollsByDevice.delete(pairedDeviceId) - } - } - } - } - - private parseAndAuth(rawMessage: string): { request: RpcRequest } | { error: RpcResponse } { - let request: RpcRequest - try { - request = JSON.parse(rawMessage) as RpcRequest - } catch { - return { error: this.buildError('unknown', 'bad_request', 'Invalid JSON request') } - } - - if (typeof request.id !== 'string' || request.id.length === 0) { - return { error: this.buildError('unknown', 'bad_request', 'Missing request id') } - } - if (typeof request.method !== 'string' || request.method.length === 0) { - return { error: this.buildError(request.id, 'bad_request', 'Missing RPC method') } - } - if (typeof request.authToken !== 'string' || request.authToken.length === 0) { - return { error: this.buildError(request.id, 'unauthorized', 'Missing auth token') } - } - if (request.authToken !== this.authToken) { - return { error: this.buildError(request.id, 'unauthorized', 'Invalid auth token') } - } - - return { request } - } - - // Why: WebSocket dispatch is streaming (multiple responses) and auths via per-device tokens, not the shared token. - private async handleWebSocketMessage( - rawMessage: string, - reply: (response: string) => void, - sendBinary: (response: Uint8Array) => boolean | void, - wsTransport?: WebSocketTransport, - ws?: WebSocket, - authenticatedDeviceToken?: string | null, - authenticatedSocket?: AuthenticatedMobileSocket - ): Promise { - let request: RpcRequest - try { - request = JSON.parse(rawMessage) as RpcRequest - } catch { - reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Invalid JSON request'))) - return - } - - if (typeof request.id !== 'string' || request.id.length === 0) { - reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Missing request id'))) - return - } - if (typeof request.method !== 'string' || request.method.length === 0) { - reply(JSON.stringify(this.buildError(request.id, 'bad_request', 'Missing RPC method'))) - return - } - - const requestToken = - typeof (request as Record).deviceToken === 'string' - ? ((request as Record).deviceToken as string) - : null - if (authenticatedDeviceToken && requestToken && requestToken !== authenticatedDeviceToken) { - reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Device token mismatch'))) - return - } - // Why: E2EE already authenticated the channel; authorize by that bound identity, not a repeated request field. - const token = authenticatedDeviceToken ?? requestToken - if (!token) { - reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Missing device token'))) - return - } - const device = this.deviceRegistry?.validateToken(token) - if (!device) { - reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Invalid device token'))) - return - } - if (device.scope === 'mobile' && !MOBILE_RPC_METHOD_ALLOWLIST.has(request.method)) { - reply( - JSON.stringify( - this.buildError( - request.id, - 'forbidden', - `Method '${request.method}' is not available to mobile clients` - ) - ) - ) - return - } - - // Why: bind deviceToken to this socket so ws.on('close') knows which mobile client disconnected. - if (wsTransport && ws) { - wsTransport.setClientId(ws, token) - } - - const longPoll = classifyRuntimeLongPoll(request) - const rejection = this.admitLongPoll(longPoll, device.deviceId) - if (rejection) { - reply(JSON.stringify(this.buildError(request.id, 'runtime_busy', rejection))) - return - } - - const abortRegistration = ws ? this.registerWebSocketDispatchAbort(ws) : null - - // Why: older pairings may lack scope metadata, so stamp the authenticated scope onto status.get. - const replyForRequest = - request.method === 'status.get' - ? (response: string): void => reply(injectDeviceScope(response, device.scope)) - : reply - - const connectionId = ws ? this.mobileSocketWiring?.getConnectionId(ws) : undefined - const pairingProvider = this.mobileRelayPairingProvider - const pairingContext = - pairingProvider && authenticatedSocket - ? { - getEndpoints: (params: PairingGetEndpointsParams) => - pairingProvider.getEndpoints( - { - deviceId: authenticatedSocket.device.deviceId, - connectionId: authenticatedSocket.connectionId, - transport: authenticatedSocket.transport - }, - params - ), - provisionRelay: (params: PairingProvisionRelayParams) => - pairingProvider.provisionRelay( - { - deviceId: authenticatedSocket.device.deviceId, - connectionId: authenticatedSocket.connectionId, - transport: authenticatedSocket.transport - }, - params - ) - } - : undefined - try { - await this.dispatcher.dispatchStreaming(request, replyForRequest, { - // Why: the validated credential preserves existing federation ownership without trusting request fields. - authenticatedCallerFingerprint: fingerprintAuthenticatedPairingCredential(token), - connectionId, - clientId: token, - pairedDeviceId: device.deviceId, - // Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated. - clientKind: device.scope, - clientCapabilities: authenticatedSocket?.clientCapabilities, - pairing: pairingContext, - signal: abortRegistration?.signal, - sendBinary, - registerBinaryStreamHandler: (streamId, handler) => - this.registerBinaryStreamHandler(connectionId, streamId, handler), - registerBinaryMessageHandler: (handler) => - this.registerBinaryMessageHandler(connectionId, handler) - }) - } finally { - abortRegistration?.dispose() - this.releaseLongPoll(longPoll, device.deviceId) - } - } - - private buildError(id: string, code: string, message: string): RpcResponse { - return errorResponse(id, { runtimeId: this.runtime.getRuntimeId() }, code, message) - } - - private writeMetadata(): void { - const metadata: RuntimeMetadata = { - runtimeId: this.runtime.getRuntimeId(), - pid: this.pid, - transports: this.transports, - authToken: this.authToken, - startedAt: this.runtime.getStartedAt() - } - writeRuntimeMetadata(this.userDataPath, metadata) - } -} - -/** Why: MUST stay in lockstep with createRuntimeTransportMetadata()'s `o-${pid}-${suffix}.sock` shape (unit-test enforced). */ -export const RUNTIME_SOCKET_NAME_REGEX = /^o-(\d+)-[A-Za-z0-9_-]+\.sock$/ - -export function sweepOrphanedRuntimeSockets(userDataPath: string, ownPid: number): void { - let entries: string[] - try { - entries = readdirSync(userDataPath) - } catch { - // Why: first-launch userData may not exist yet; nothing to sweep. - return - } - for (const entry of entries) { - const match = RUNTIME_SOCKET_NAME_REGEX.exec(entry) - if (!match) { - continue - } - const pid = Number(match[1]) - if (!Number.isFinite(pid)) { - continue - } - // Why: never delete our own socket — a bug here would rmSync one we're about to bind. - if (pid === ownPid) { - continue - } - try { - // Why: signal 0 is the POSIX liveness probe (sends nothing); ESRCH = dead pid, EPERM = foreign owner (left alone). - process.kill(pid, 0) - } catch (error) { - if ((error as NodeJS.ErrnoException).code === 'ESRCH') { - try { - rmSync(join(userDataPath, entry), { force: true }) - } catch { - // Why: best-effort sweep; a later start() or OS reboot cleans any socket we can't unlink. - } - } - } - } -} - -export function createRuntimeTransportMetadata( - userDataPath: string, - pid: number, - platform: NodeJS.Platform, - runtimeId = 'runtime' -): RuntimeTransportMetadata { - const endpointSuffix = runtimeId.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 4) || 'rt' - if (platform === 'win32') { - return { - kind: 'named-pipe', - // Why: named pipes lack the chmod hardening of Unix sockets; a per-runtime suffix avoids a stable, guessable endpoint name. - endpoint: `\\\\.\\pipe\\orca-${pid}-${endpointSuffix}` - } - } - return { - kind: 'unix', - endpoint: join(userDataPath, `o-${pid}-${endpointSuffix}.sock`) - } -} +export { + RUNTIME_SOCKET_NAME_REGEX, + sweepOrphanedRuntimeSockets, + createRuntimeTransportMetadata +} from './runtime-rpc/runtime-rpc-socket-metadata' diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-binary-routing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-binary-routing.ts new file mode 100644 index 00000000000..20264112f48 --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-binary-routing.ts @@ -0,0 +1,114 @@ +import type { WebSocket } from 'ws' +import type { TerminalStreamFrame } from '../../../shared/terminal-stream-protocol' +import { DeviceRegistry } from '../device-registry' +import { loadOrCreateE2EEKeypair, type E2EEKeypair } from '../e2ee-keypair' +import { RuntimeRpcState } from './runtime-rpc-state' +import { + DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE, + E2EE_KEY_UNAVAILABLE_GUIDANCE, + pairingUnavailable, + type PairingIdentityInitialization +} from './runtime-rpc-pairing-types' + +export class RuntimeRpcBinaryRouting extends RuntimeRpcState { + protected registerBinaryStreamHandler( + connectionId: string | undefined, + streamId: number, + handler: (frame: TerminalStreamFrame) => void + ): () => void { + return this.binaryMessageRouter.registerTerminalStream(connectionId, streamId, handler) + } + + protected registerBinaryMessageHandler( + connectionId: string | undefined, + handler: (bytes: Uint8Array) => void + ): () => void { + return this.binaryMessageRouter.registerRawMessage(connectionId, handler) + } + + protected handleWebSocketBinaryMessage(bytes: Uint8Array, ws: WebSocket): void { + this.binaryMessageRouter.dispatch(this.mobileSocketWiring?.getConnectionId(ws), bytes) + } + + protected registerWebSocketDispatchAbort(ws: WebSocket): { + signal: AbortSignal + dispose: () => void + } { + const abortController = new AbortController() + if (ws.readyState !== ws.OPEN) { + abortController.abort() + return { signal: abortController.signal, dispose: () => {} } + } + + let state = this.wsDispatchAbortStates.get(ws) + if (!state) { + state = { + controllers: new Set(), + abortOnClose: () => this.abortWebSocketDispatches(ws) + } + this.wsDispatchAbortStates.set(ws, state) + // Why: many streaming RPCs share one WebSocket; one socket-level abort fan-out avoids MaxListenersExceededWarning. + ws.on('close', state.abortOnClose) + ws.on('error', state.abortOnClose) + } + state.controllers.add(abortController) + + return { + signal: abortController.signal, + dispose: () => { + const current = this.wsDispatchAbortStates.get(ws) + if (!current) { + return + } + current.controllers.delete(abortController) + if (current.controllers.size > 0) { + return + } + this.wsDispatchAbortStates.delete(ws) + ws.off('close', current.abortOnClose) + ws.off('error', current.abortOnClose) + } + } + } + + protected abortWebSocketDispatches(ws: WebSocket): void { + const state = this.wsDispatchAbortStates.get(ws) + if (!state) { + return + } + this.wsDispatchAbortStates.delete(ws) + ws.off('close', state.abortOnClose) + ws.off('error', state.abortOnClose) + for (const controller of state.controllers) { + controller.abort() + } + state.controllers.clear() + } + + protected initializePairingIdentity(): PairingIdentityInitialization { + let deviceRegistry: DeviceRegistry + try { + deviceRegistry = new DeviceRegistry(this.userDataPath) + } catch (error) { + console.error('[runtime] Failed to initialize pairing registry:', error) + return { + ok: false, + failure: pairingUnavailable( + 'device_registry_unavailable', + DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE + ) + } + } + let e2eeKeypair: E2EEKeypair + try { + e2eeKeypair = loadOrCreateE2EEKeypair(this.userDataPath) + } catch (error) { + console.error('[runtime] Failed to initialize E2EE identity:', error) + return { + ok: false, + failure: pairingUnavailable('e2ee_key_unavailable', E2EE_KEY_UNAVAILABLE_GUIDANCE) + } + } + return { ok: true, deviceRegistry, e2eeKeypair } + } +} diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts new file mode 100644 index 00000000000..9cfe31251b6 --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -0,0 +1,261 @@ +import type { RuntimeTransportMetadata } from '../../../shared/runtime-bootstrap' +import { watchRuntimeMetadataOwnership } from '../runtime-metadata-ownership-watch' +import type { RpcTransport } from '../rpc/transport' +import { UnixSocketTransport } from '../rpc/unix-socket-transport' +import { WebSocketTransport } from '../rpc/ws-transport' +import { readWsFallbackPort, writeWsFallbackPort } from '../rpc/ws-fallback-port-store' +import type { DeviceRegistry } from '../device-registry' +import type { E2EEKeypair } from '../e2ee-keypair' +import { UnpairedDeviceAuthThrottle } from '../rpc/unpaired-device-auth-throttle' +import { MobileSocketWiring } from '../rpc/mobile-socket-wiring' +import { RuntimeRpcWebSocketDispatch } from './runtime-rpc-websocket-dispatch' +import { + formatWsEndpoint, + WS_BIND_HOST_ALL_INTERFACES, + WS_BIND_HOST_LOOPBACK +} from './runtime-rpc-pairing-types' +import { + createRuntimeTransportMetadata, + sweepOrphanedRuntimeSockets +} from './runtime-rpc-socket-metadata' + +export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { + async start(): Promise { + if (this.activeTransports.length > 0) { + return + } + + // Why: SIGKILL/OOM skip stop(), orphaning `o--*.sock` files; sweep them. Skipped on Windows: named pipes leave no filesystem entries. + if (this.platform !== 'win32') { + sweepOrphanedRuntimeSockets(this.userDataPath, this.pid) + } + + const transportMeta = createRuntimeTransportMetadata( + this.userDataPath, + this.pid, + this.platform, + this.runtime.getRuntimeId() + ) + + const socketTransport = new UnixSocketTransport({ + endpoint: transportMeta.endpoint, + kind: transportMeta.kind as 'unix' | 'named-pipe', + keepaliveIntervalMs: this.keepaliveIntervalMs + }) + + // Why: the `.catch` guarantees reply() always fires so a throw can't strand the client or leak the AbortController. + socketTransport.onMessage((msg, reply, context) => { + void this.handleMessage(msg, context) + .then((response) => { + reply(JSON.stringify(response)) + }) + .catch((error) => { + const message = error instanceof Error ? error.message : String(error) + // Why: best-effort id recovery so the client can correlate the error frame to its pending request. + let id = 'unknown' + try { + const parsed = JSON.parse(msg) as { id?: unknown } + if (typeof parsed.id === 'string' && parsed.id.length > 0) { + id = parsed.id + } + } catch { + // ignore — fall through with id='unknown' + } + reply(JSON.stringify(this.buildError(id, 'internal_error', message))) + }) + }) + + await socketTransport.start() + + const activeTransports: RpcTransport[] = [socketTransport] + const transportsMeta: RuntimeTransportMetadata[] = [transportMeta] + + // Why: WebSocket uses per-device tokens + E2EE (tweetnacl) instead of TLS since React Native can't pin self-signed certs. + if (this.enableWebSocket) { + // Why: land any deferred lastSeen write before a replacement registry reads the same file. + this.deviceRegistry?.flushPendingLastSeen() + const pairingIdentity = this.initializePairingIdentity() + if (!pairingIdentity.ok) { + this.deviceRegistry = null + this.e2eeKeypair = null + this.pairingInitializationFailure = pairingIdentity.failure + } else { + this.deviceRegistry = pairingIdentity.deviceRegistry + this.e2eeKeypair = pairingIdentity.e2eeKeypair + this.pairingInitializationFailure = null + try { + const host = this.resolveInitialWebSocketBindHost() + const { transport, endpoint } = await this.startWebSocketTransport({ + host, + port: this.wsPort, + preferPinnedPort: this.preferPinnedWsPort, + // Why: stable fallback port across restarts keeps paired devices' endpoints valid (STA-1511); wsPort 0 = random (E2E). + ...(this.wsPort !== 0 ? { fallbackPort: readWsFallbackPort(this.userDataPath) } : {}) + }) + if (this.wsPort !== 0 && transport.resolvedPort !== this.wsPort) { + writeWsFallbackPort(this.userDataPath, transport.resolvedPort) + } + activeTransports.push(transport) + transportsMeta.push({ kind: 'websocket', endpoint }) + } catch (error) { + // Why: WebSocket transport is supplementary; on failure (e.g. port in use) continue with Unix socket only. + console.error('[runtime] Failed to start WebSocket transport:', error) + this.mobileSocketWiring = null + } + } + } + + // Why: set in-memory transport state before writing metadata so the bootstrap file has the real endpoint/token pair. + this.activeTransports = activeTransports + this.transports = transportsMeta + + try { + this.writeMetadata() + } catch (error) { + // Why: a runtime that can't publish metadata is invisible to the CLI — close transports rather than run undiscoverable. + this.activeTransports = [] + this.transports = [] + await Promise.all(activeTransports.map((t) => t.stop().catch(() => {}))).catch(() => {}) + throw error + } + + this.metadataOwnershipWatch = watchRuntimeMetadataOwnership({ + userDataPath: this.userDataPath, + ownedPid: this.pid, + ownedRuntimeId: this.runtime.getRuntimeId(), + pollIntervalMs: this.metadataOwnershipPollMs, + republish: () => { + // Why: never advertise endpoints we already tore down. + if (this.activeTransports.length === 0) { + return + } + this.writeMetadata() + }, + onReclaim: (previous) => { + console.warn( + `[runtime] Reclaimed orca-runtime.json from a dead runtime (pid ${previous?.pid ?? 'none'}); republished pid ${this.pid}.` + ) + } + }) + } + + // Why: STA-2370 — a desktop with no previously-connected device stays on loopback until the user + // explicitly pairs; `orca serve`/E2E (exposeNetworkByDefault) and a reconnecting paired device bind wide. + // A grant minted for "This computer only" is excluded: its client is a browser on this machine, so + // counting it would republish the runtime on every interface one restart after the user declined that. + protected resolveInitialWebSocketBindHost(): string { + if (this.pinnedBindHost) { + return this.pinnedBindHost + } + if (this.exposeNetworkByDefault) { + return WS_BIND_HOST_ALL_INTERFACES + } + const hasConnectedNetworkDevice = + this.deviceRegistry + ?.listDevices() + .some((device) => device.lastSeenAt > 0 && device.pairingReach !== 'this-computer') ?? false + return hasConnectedNetworkDevice ? WS_BIND_HOST_ALL_INTERFACES : WS_BIND_HOST_LOOPBACK + } + + // Why: builds and starts a WS transport bound to `host`, wiring the session-scoped mobile socket + // handlers. Shared by initial start() and the pairing-time widen so both paths stay identical. + protected async startWebSocketTransport(options: { + host: string + port: number + preferPinnedPort: boolean + fallbackPort?: number + }): Promise<{ transport: WebSocketTransport; endpoint: string }> { + const deviceRegistry = this.deviceRegistry + const e2eeKeypair = this.e2eeKeypair + if (!deviceRegistry || !e2eeKeypair) { + throw new Error('WebSocket transport requires an initialized pairing identity') + } + const wsTransport = new WebSocketTransport({ + host: options.host, + port: options.port, + staticRoot: this.webClientRoot, + ...(options.fallbackPort !== undefined ? { fallbackPort: options.fallbackPort } : {}), + ...(options.preferPinnedPort ? { preferPinnedPort: true } : {}) + }) + const mobileSocketWiring = this.ensureMobileSocketWiring(deviceRegistry, e2eeKeypair) + this.detachWebSocketWiring = mobileSocketWiring.attachTransport(wsTransport) + + try { + await wsTransport.start() + } catch (error) { + // Why: a listener that never bound must not stay attached to the session wiring (it would leak into + // terminateDeviceConnections); detach before propagating so the wiring only tracks live transports. + this.detachWebSocketWiring?.() + this.detachWebSocketWiring = null + throw error + } + this.wsBoundHost = options.host + return { + transport: wsTransport, + endpoint: formatWsEndpoint(options.host, wsTransport.resolvedPort) + } + } + + // Why: one MobileSocketWiring per server session. Direct WS and cloud relay both attach to it, and + // DesktopRelayService captures it once at construction, so a loopback→wide pairing rebind must swap the + // transport under the SAME wiring — replacing the wiring would strand relay sockets (lost connection IDs, + // binary handling, and revocation targeting) on a dead object. + protected ensureMobileSocketWiring( + deviceRegistry: DeviceRegistry, + e2eeKeypair: E2EEKeypair + ): MobileSocketWiring { + if (this.mobileSocketWiring) { + return this.mobileSocketWiring + } + // Why: session-scoped so each desktop launch may notify once (a mid-session rebind must not reset it). + this.unpairedDeviceAuthThrottle = new UnpairedDeviceAuthThrottle({ + onTrigger: () => this.onUnpairedDeviceAuthFailure?.() + }) + const mobileSocketWiring = new MobileSocketWiring({ + deviceRegistry, + e2eeKeypair, + onText: (socket, plaintext, reply, sendBinary) => { + void this.handleWebSocketMessage( + plaintext, + reply, + sendBinary, + undefined, + socket.ws, + socket.device.deviceToken, + socket + ) + }, + onBinary: (socket, bytes) => this.handleWebSocketBinaryMessage(bytes, socket.ws), + onReady: () => { + // Why: first authenticated mobile/remote client (direct WS and + // cloud relay both attach here) starts path-candidate tracking. + // Activation is a local-host concern: candidate buffers live on the + // buffer-owning host's runtime, so a remote runtime proxy may + // legitimately lack this method (its own server activates it). + this.runtime.activateRecentPtyPathCandidateTracking?.() + this.mobileRelayPairingProvider?.onDemandStateChanged?.() + }, + onClose: (socket, hasOtherConnections) => { + if (!socket) { + return + } + this.abortWebSocketDispatches(socket.ws) + // Why: subscriptions and binary streams are socket-scoped, but disconnect state is device-scoped across transports. + this.runtime.cleanupSubscriptionsForConnection(socket.connectionId) + this.runtime.cancelMobileDictationForConnection(socket.connectionId) + this.binaryMessageRouter.deleteConnection(socket.connectionId) + if (!hasOtherConnections) { + this.runtime.onClientDisconnected(socket.device.deviceToken) + } + }, + // Why: relay attempts are authorized upstream; only direct failures should prompt local re-pairing. + onUnpairedDeviceAuthFailure: (metadata) => { + if (metadata.transport === 'direct') { + this.unpairedDeviceAuthThrottle?.recordFailure() + } + } + }) + this.mobileSocketWiring = mobileSocketWiring + return mobileSocketWiring + } +} diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts b/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts new file mode 100644 index 00000000000..02d8422547e --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-long-poll.ts @@ -0,0 +1,58 @@ +import type { RpcRequest } from '../rpc/core' + +export const KEEPALIVE_INTERVAL_MS = 10_000 + +// Why: cap long-polls at half the 32-slot connection budget so they can't starve short RPCs; overflow → runtime_busy. See §7 risk #2. +export const LONG_POLL_CAP = 16 + +// Why: orchestration.ask blocks on a human/agent reply for minutes, an order of +// magnitude longer than terminal.wait or check --wait, so a fleet of asking +// workers would otherwise hold every slot and starve the mobile/web/CLI/relay +// clients sharing this runtime. Reserve half the budget for the other classes. +export const ASK_LONG_POLL_SHARE = 0.5 +// Why: eight host slots preserve four-host overlap for two independently paired desktops. +export const BROWSER_HOST_LONG_POLL_SHARE = 0.5 +// Why: asks and permanent hosts together retain the prior quarter-budget reservation for waits. +export const SPECIALIZED_LONG_POLL_SHARE = 0.75 + +// Why: 'ask' is metered separately from 'wait' — same keepalive/abort wiring, its own sub-cap. +export type RuntimeLongPollClass = 'ask' | 'browser-host' | 'wait' + +// Why: single classifier for long-poll requests (handlers that block on an external event), shared by counter/abort/keepalive. See §3.1. +export function classifyRuntimeLongPoll(request: RpcRequest): RuntimeLongPollClass | null { + // Worker start waits for readiness and then verifies the submitted prompt; + // the complete operation can run for 90–110s. Keep every local transport + // (Unix sockets and Windows named pipes) alive for that long poll. + if (request.method === 'orchestration.workerStart') { + return 'wait' + } + if (request.method === 'browser.clientHost.attach') { + return 'browser-host' + } + if (request.method === 'terminal.wait') { + return 'wait' + } + // Agent-prompt submission waits for the PTY's lifecycle transition (up to + // the verification budget); keep the local socket alive for that wait. + if ( + request.method === 'terminal.send' && + typeof request.params === 'object' && + request.params !== null && + (request.params as { agentPrompt?: unknown }).agentPrompt === true + ) { + return 'wait' + } + // Why: orchestration.ask blocks unconditionally (default 600 s) holding the + // RPC open until a reply lands or the deadline passes, so it needs the same + // keepalive as check --wait or the 30 s socket idle timer tears it down. It + // also relies on the abort signal (only wired for long-polls) to release the + // waiter when the asking client disconnects. + if (request.method === 'orchestration.ask') { + return 'ask' + } + if (request.method === 'orchestration.check') { + const params = request.params as { wait?: unknown } | undefined + return params?.wait === true ? 'wait' : null + } + return null +} diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts new file mode 100644 index 00000000000..461ec02ba69 --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-method-allowlist.ts @@ -0,0 +1,265 @@ +export const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ + 'accounts.list', + 'accounts.consumeCodexResetCredit', + 'accounts.selectClaude', + 'accounts.selectCodex', + 'accounts.selectCodexForTarget', + 'accounts.subscribe', + 'accounts.unsubscribe', + 'aiVault.listSessions', + 'aiVault.resolveSessionTitles', + 'aiVault.prepareSessionResume', + 'browser.back', + 'browser.dialogAccept', + 'browser.dialogDismiss', + 'browser.forward', + 'browser.goto', + 'browser.keyboardInsertText', + 'browser.keypress', + 'browser.mouseDown', + 'browser.mouseClick', + 'browser.mouseMove', + 'browser.mouseUp', + 'browser.mouseWheel', + 'browser.reload', + 'browser.screencast', + 'browser.screencast.unsubscribe', + 'browser.tabCreate', + 'browser.viewport', + 'clipboard.abortImageUpload', + 'clipboard.appendImageUploadChunk', + 'clipboard.commitImageUpload', + 'clipboard.saveImageAsTempFile', + 'clipboard.startImageUpload', + 'diagnostics.memory', + 'files.browseServerDir', + 'files.createFile', + 'files.list', + 'files.open', + 'files.openDiff', + 'files.read', + 'files.readDocPreview', + 'files.readChunk', + 'files.readDir', + 'files.readPreview', + 'files.readTerminalArtifact', + 'files.readTerminalArtifactPreview', + 'files.resolveTerminalPath', + 'files.searchPaths', + 'files.writeTerminalArtifact', + 'folderWorkspace.list', + 'git.abortMerge', + 'git.abortRebase', + 'git.bulkStage', + 'git.bulkUnstage', + 'git.branchCompare', + 'git.branchDiff', + 'git.cancelGenerateCommitMessage', + 'git.cancelGeneratePullRequestFields', + 'git.checkout', + 'git.commit', + 'git.commitCompare', + 'git.commitDiff', + 'git.discard', + 'git.discoverCommitMessageModels', + 'git.diff', + 'git.fetch', + 'git.forkSync', + 'git.fastForward', + 'git.generateCommitMessage', + 'git.generatePullRequestFields', + 'git.history', + 'git.localBranches', + 'git.pull', + 'git.push', + 'git.rebaseFromBase', + 'git.stage', + 'git.status', + 'git.unstage', + 'git.upstreamStatus', + 'github.createIssue', + 'github.addIssueComment', + 'github.addPRReviewComment', + 'github.addPRReviewCommentReply', + 'github.countWorkItems', + 'github.listAssignableUsers', + 'github.listLabels', + 'github.listWorkItems', + 'github.mergePR', + 'github.setPRAutoMerge', + 'github.requestPRReviewers', + 'github.removePRReviewers', + 'github.project.listAccessible', + 'github.project.listAssignableUsersBySlug', + 'github.project.listIssueTypesBySlug', + 'github.project.listLabelsBySlug', + 'github.project.listViews', + 'github.project.resolveRef', + 'github.project.addIssueCommentBySlug', + 'github.project.updateIssueCommentBySlug', + 'github.project.deleteIssueCommentBySlug', + 'github.project.clearItemField', + 'github.project.updateIssueBySlug', + 'github.project.updateIssueTypeBySlug', + 'github.project.updateItemField', + 'github.project.updatePullRequestBySlug', + 'github.project.viewTable', + 'github.project.workItemDetailsBySlug', + 'github.prForBranch', + 'github.prFileContents', + 'github.prChecks', + 'github.prCheckDetails', + 'github.rerunPRChecks', + 'github.resolveReviewThread', + 'github.setPRFileViewed', + 'github.updateIssue', + 'github.updatePR', + 'github.updatePRTitle', + 'github.updatePRState', + 'github.repoSlug', + 'github.workItem', + // Cross-repo lookup: lets the mobile Smart picker resolve a pasted github.com URL for a different repo. + 'github.workItemByOwnerRepo', + 'github.workItemDetails', + 'gitlab.createIssue', + 'gitlab.addIssueComment', + 'gitlab.addMRComment', + 'gitlab.listWorkItems', + // Mobile Smart picker: resolve a pasted GitLab URL to an exact issue/MR (MR listing reuses gitlab.listWorkItems). + 'gitlab.workItemByPath', + 'gitlab.mergeMR', + 'gitlab.resolveMRDiscussion', + 'gitlab.todos', + 'gitlab.updateIssue', + 'gitlab.updateMR', + 'gitlab.updateMRState', + 'gitlab.workItemDetails', + 'host.gitBash.isAvailable', + 'host.platform', + 'host.pwsh.isAvailable', + 'host.wsl.isAvailable', + 'host.wsl.listDistros', + 'hostedReview.create', + 'hostedReview.createStacked', + 'hostedReview.forBranch', + 'hostedReview.getCreationEligibility', + 'linear.getCustomView', + 'linear.getIssue', + 'linear.getProject', + 'linear.agentSearchIssues', + 'linear.issueContext', + 'linear.resolveCurrentIssue', + 'linear.addIssueComment', + 'linear.connect', + 'linear.createIssue', + 'linear.createProject', + 'linear.issueComments', + 'linear.listCustomViewIssues', + 'linear.listCustomViewProjects', + 'linear.listCustomViews', + 'linear.listIssues', + 'linear.mcpListIssues', + 'linear.listProjectIssues', + 'linear.listProjects', + 'linear.teamLabels', + 'linear.teamMembers', + 'linear.listTeams', + 'linear.searchIssues', + 'linear.selectWorkspace', + 'linear.status', + 'linear.teamStates', + 'linear.updateIssue', + 'markdown.readTab', + 'markdown.saveTab', + 'notifications.getMissedSince', + 'notifications.subscribe', + 'notifications.unsubscribe', + 'pairing.getEndpoints', + 'pairing.provisionRelay', + 'preflight.check', + 'preflight.detectAgents', + 'preflight.detectRemoteAgents', + 'projectGroup.list', + 'repo.baseRefDefault', + 'repo.gitAvailable', + 'repo.hooks', + 'repo.list', + 'repo.saveSparsePreset', + 'repo.searchRefs', + 'repo.sparsePresets', + 'repo.update', + 'runtime.clientEvents.subscribe', + 'runtime.clientEvents.unsubscribe', + 'session.tabs.activate', + 'session.tabs.close', + 'session.tabs.closeLifecycle', + 'session.tabs.createTerminal', + 'session.tabs.list', + 'session.tabs.listAll', + 'session.tabs.move', + 'session.tabs.subscribe', + 'session.tabs.subscribeAll', + 'session.tabs.unsubscribe', + 'session.tabs.unsubscribeAll', + 'nativeChat.readSession', + 'nativeChat.subscribe', + 'nativeChat.unsubscribe', + 'settings.get', + 'settings.getTerminalQuickCommands', + 'settings.update', + 'settings.updateTerminalQuickCommands', + 'ssh.connect', + 'ssh.getState', + 'ssh.listRemovedTargetLabels', + 'ssh.listTargets', + 'ssh.listTargetSummaries', + 'speech.dictation.cancel', + 'speech.dictation.chunk', + 'speech.dictation.finish', + 'speech.dictation.setup', + 'speech.dictation.start', + 'speech.models.delete', + 'speech.models.download', + 'speech.models.list', + 'stats.summary', + 'status.get', + 'agentTeams.prepareLaunch', + 'agentTeams.tmuxCompat', + 'terminal.clearBuffer', + 'terminal.close', + 'terminal.closeTab', + 'terminal.create', + 'terminal.createAgentSession', + 'terminal.ensureAgentSession', + 'terminal.focus', + 'terminal.agentStatus', + 'terminal.adoptOrphans', + 'terminal.getAutoRestoreFit', + 'terminal.isRunningAgent', + 'terminal.list', + 'terminal.multiplex', + 'terminal.read', + 'terminal.rename', + 'terminal.send', + 'terminal.setAutoRestoreFit', + 'terminal.setDisplayMode', + 'terminal.subscribe', + 'terminal.unsubscribe', + 'terminal.updateViewport', + 'terminal.wait', + 'ui.get', + 'ui.recordFeatureInteraction', + 'ui.set', + 'worktree.activate', + 'worktree.create', + 'worktree.forceDeleteBranch', + 'worktree.listRetiredNames', + 'worktree.prefetchCreateBase', + 'worktree.ps', + 'worktree.show', + 'worktree.resolveMrBase', + 'worktree.resolvePrBase', + 'worktree.rm', + 'worktree.set', + 'worktree.sleep' +]) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-pairing.ts new file mode 100644 index 00000000000..de4e1d3ce88 --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-pairing.ts @@ -0,0 +1,260 @@ +import type { MobilePairingConnectionMode } from '../../../shared/mobile-pairing-connection-mode' +import { + mobileRelayMintFailureFromUnknown, + type MobileRelayMintFailure +} from '../../../shared/mobile-relay-mint-failure' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../shared/pairing' +import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../network-exposure-guidance' +import { RuntimeRpcPairing } from './runtime-rpc-pairing' +import { + DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE, + pairingUnavailable, + type MobilePairingOffer, + type MobileRelayPairingProvider, + type PairingOfferUnavailable +} from './runtime-rpc-pairing-types' + +export class RuntimeRpcMobilePairing extends RuntimeRpcPairing { + async createMobilePairingOffer(args: { + address?: string | null + connectionMode?: MobilePairingConnectionMode + name?: string + rotate?: boolean + }): Promise { + // Why: STA-2370 — creating a mobile pairing offer is the user's explicit opt-in to LAN reach, so + // widen the loopback listener before advertising its LAN endpoint in the QR. If the widen fails the + // listener stays on loopback, so report unavailable rather than advertise a dead LAN endpoint. + try { + await this.ensureNetworkExposure() + } catch (error) { + console.error( + '[runtime] Network exposure failed while creating a mobile pairing offer:', + error + ) + return pairingUnavailable('network_exposure_failed', NETWORK_EXPOSURE_FAILED_GUIDANCE) + } + if (args.connectionMode === 'local-only') { + this.mobilePairingOfferGeneration += 1 + return this.createMobilePairingOfferSerial(args, this.mobilePairingOfferGeneration) + } + const address = args.address ?? null + const rotate = args.rotate === true + const inFlight = this.mobileRelayPairingOfferInFlight + if ( + inFlight?.generation === this.mobilePairingOfferGeneration && + inFlight.address === address && + (inFlight.rotate || !rotate) + ) { + return inFlight.request + } + // Why: every request that is not coalesced above supersedes the older one, rotating or not. + const generation = ++this.mobilePairingOfferGeneration + const request = this.mobileRelayPairingOfferQueue.then(() => + generation === this.mobilePairingOfferGeneration + ? this.createMobilePairingOfferSerial(args, generation) + : this.relayPairingRequestSuperseded() + ) + this.mobileRelayPairingOfferQueue = request.then( + () => undefined, + () => undefined + ) + this.mobileRelayPairingOfferInFlight = { generation, address, rotate, request } + void request.then( + () => { + if (this.mobileRelayPairingOfferInFlight?.request === request) { + this.mobileRelayPairingOfferInFlight = null + } + }, + () => { + if (this.mobileRelayPairingOfferInFlight?.request === request) { + this.mobileRelayPairingOfferInFlight = null + } + } + ) + return request + } + + protected async createMobilePairingOfferSerial( + args: { + address?: string | null + connectionMode?: MobilePairingConnectionMode + name?: string + rotate?: boolean + }, + generation: number + ): Promise { + // Why: the renderer is outside the trust boundary, so only an explicit local-only value may suppress Relay provisioning. + const connectionMode = args.connectionMode === 'local-only' ? 'local-only' : 'automatic' + const pending = this.deviceRegistry?.getPendingDevice('mobile') + // Why: connection policy is part of the credential, so rotate on any policy switch — an old-policy QR must not pair under the new one. + const switchingPendingMode = + pending != null && + this.deviceRegistry?.getMobilePairingConnectionMode(pending.deviceId) !== connectionMode + if (args.rotate || switchingPendingMode) { + if (pending?.relayBinding) { + // Why: record the durable cloud revoke before rotating the local token so an old relay invite can't outlive the QR. + if (!this.queueRelayDeviceRevoke(pending.relayBinding)) { + return pairingUnavailable( + 'device_registry_unavailable', + 'Could not persist Relay cleanup before rotating the pairing code.' + ) + } + } + } + const direct = this.createPairingOffer({ + ...args, + rotate: args.rotate || switchingPendingMode, + scope: 'mobile' + }) + if (!direct.available) { + return direct + } + const createdNewPendingDevice = pending?.deviceId !== direct.deviceId + let connectionModeStored = false + try { + connectionModeStored = + this.deviceRegistry?.setMobilePairingConnectionMode(direct.deviceId, connectionMode) ?? + false + } catch (error) { + console.error('[runtime] Failed to persist the pairing connection mode:', error) + } + // Why: the mode is part of the credential — a QR whose policy was never stored must not pair under the default one. + if (!connectionModeStored) { + if (createdNewPendingDevice) { + this.discardPendingMobilePairingDevice(direct.deviceId) + } + return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) + } + // Why: explicit LAN path never needs Relay; mint the direct-only offer as selected. + if (connectionMode === 'local-only') { + return { ...direct, connectionMode: 'local-only' } + } + // Why: Anywhere must not silently ship a LAN-only QR under the Relay label. + // Fail closed, drop the unused pending credential, and let the UI offer Use LAN. + const refuseAutomaticWithoutRelay = ( + relayFailure: MobileRelayMintFailure + ): PairingOfferUnavailable => { + if (createdNewPendingDevice) { + this.discardPendingMobilePairingDevice(direct.deviceId) + } + return { + available: false, + reason: 'relay_mint_failed', + guidance: + 'Orca Relay could not create a pairing invite. Use LAN (Tailscale or same Wi‑Fi) or retry Relay.', + relayFailure + } + } + const relayProvider = this.mobileRelayPairingProvider + if (!relayProvider) { + return refuseAutomaticWithoutRelay({ + code: 'relay_provider_unavailable', + stage: 'provider_missing', + message: 'Orca Relay is not available on this desktop' + }) + } + const device = this.deviceRegistry?.getDevice(direct.deviceId) + const publicKeyB64 = this.getE2EEPublicKey() + if (!device || !publicKeyB64) { + return refuseAutomaticWithoutRelay({ + code: 'e2ee_key_unavailable', + stage: 'e2ee_missing', + message: 'E2EE public key unavailable for Relay pairing' + }) + } + let relayPairing: Awaited> + try { + relayPairing = await relayProvider.createPairingRelay(device.deviceId) + } catch (error) { + // Why: the raw provider error can carry request metadata or credentials — log only the validated code. + const relayFailure = mobileRelayMintFailureFromUnknown({ + stage: 'create_pairing_relay', + error, + fallbackCode: 'relay_mint_failed', + fallbackMessage: 'Relay pairing invite request failed' + }) + console.warn(`[runtime] Failed to create Relay pairing invite: ${relayFailure.code}`) + return refuseAutomaticWithoutRelay(relayFailure) + } + const currentDevice = this.deviceRegistry?.getDevice(device.deviceId) + if ( + generation !== this.mobilePairingOfferGeneration || + relayProvider !== this.mobileRelayPairingProvider || + currentDevice?.token !== device.token || + this.deviceRegistry?.getMobilePairingConnectionMode(device.deviceId) !== 'automatic' + ) { + this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding) + if (createdNewPendingDevice) { + this.discardPendingMobilePairingDevice(direct.deviceId) + } + return this.relayPairingRequestSuperseded() + } + try { + if (!this.setMobileRelayBinding(device.deviceId, relayPairing.binding)) { + this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding) + return refuseAutomaticWithoutRelay({ + code: 'relay_binding_failed', + stage: 'binding_failed', + message: 'Could not store Relay binding for the pairing device' + }) + } + } catch (error) { + console.warn('[runtime] Failed to persist Relay pairing binding:', error) + this.queueOrRetainRelayDeviceRevoke(device.deviceId, relayPairing.binding) + return refuseAutomaticWithoutRelay({ + code: 'relay_binding_failed', + stage: 'binding_failed', + message: 'Could not store Relay binding for the pairing device' + }) + } + return { + ...direct, + connectionMode: 'automatic', + pairingUrl: encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint: direct.endpoint, + deviceToken: device.token, + publicKeyB64, + pairedDeviceId: device.deviceId, + scope: 'mobile', + relay: relayPairing.relay + }) + } + } + + protected relayPairingRequestSuperseded(): PairingOfferUnavailable { + return { + available: false, + reason: 'relay_mint_failed', + guidance: 'The Relay pairing request was replaced by a newer connection choice.', + relayFailure: { + code: 'relay_request_superseded', + stage: 'binding_failed', + message: 'Relay pairing request superseded' + } + } + } + + /** Drop a never-scanned mobile pending credential after a failed Anywhere mint. */ + protected discardPendingMobilePairingDevice(deviceId: string): void { + const device = this.deviceRegistry?.getDevice(deviceId) + if (!device || device.scope !== 'mobile' || device.lastSeenAt !== 0) { + return + } + if (device.relayBinding) { + if (!this.queueRelayDeviceRevoke(device.relayBinding)) { + return + } + } + try { + this.deviceRegistry?.removeDevice(deviceId) + } catch (error) { + console.error('[runtime] Failed to drop an unused mobile pairing credential:', error) + } + } + + /** + * Why: the outbox is the only durable cleanup record for a minted invite. When it can't be + * written, keep the binding on the device so cleanup keeps a reference instead of orphaning it. + */ +} diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-network-exposure.ts b/src/main/runtime/runtime-rpc/runtime-rpc-network-exposure.ts new file mode 100644 index 00000000000..ee7e4c08d1a --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-network-exposure.ts @@ -0,0 +1,140 @@ +import { WebSocketTransport } from '../rpc/ws-transport' +import { writeWsFallbackPort } from '../rpc/ws-fallback-port-store' +import { RuntimeRpcLifecycle } from './runtime-rpc-lifecycle' +import { WS_BIND_HOST_ALL_INTERFACES, WS_BIND_HOST_LOOPBACK } from './runtime-rpc-pairing-types' + +export class RuntimeRpcNetworkExposure extends RuntimeRpcLifecycle { + // Why: STA-2370 — widen the loopback listener to all interfaces so a freshly generated pairing + // offer's advertised LAN endpoint is reachable. Idempotent, but it is no longer confined to the first + // pairing action: a "This computer only" link never widens, so live loopback clients can already be + // connected when a later LAN/QR offer opts in. Rebinding terminates them (ws cannot move a listener), + // so the resolved port is reused — already-issued endpoints stay valid and clients reconnect in place. + async ensureNetworkExposure(): Promise { + if (this.pinnedBindHost && this.pinnedBindHost !== WS_BIND_HOST_ALL_INTERFACES) { + // Why throw and not return: callers widen so they can ADVERTISE a LAN endpoint. Returning + // quietly would let them publish one that nothing can reach; the throw lands in their + // existing network_exposure_failed branch, which reports the offer unavailable instead. + throw new Error( + `Runtime bind address is pinned to ${this.pinnedBindHost}; refusing to widen to all interfaces` + ) + } + if ( + !this.enableWebSocket || + this.stopping || + this.wsBoundHost === WS_BIND_HOST_ALL_INTERFACES + ) { + return + } + // Why: concurrent pairing requests must share one rebind — two simultaneous stop/start races would + // fight for the port and strand the listener on a random one. + if (!this.networkExposurePromise) { + this.networkExposurePromise = this.widenWebSocketBind().finally(() => { + this.networkExposurePromise = null + }) + } + return this.networkExposurePromise + } + + protected async widenWebSocketBind(): Promise { + const current = this.activeTransports.find( + (transport): transport is WebSocketTransport => transport instanceof WebSocketTransport + ) + if (!current) { + return + } + const index = this.activeTransports.indexOf(current) + const previousPort = current.resolvedPort + let widened: { transport: WebSocketTransport; endpoint: string } + try { + // Why: detach the loopback listener from the session wiring before stopping it so terminateDevice- + // Connections never iterates a dead transport; the new listener re-attaches to the SAME wiring. + this.detachWebSocketWiring?.() + await current.stop() + widened = await this.startWebSocketTransport({ + host: WS_BIND_HOST_ALL_INTERFACES, + port: previousPort, + preferPinnedPort: true + }) + } catch (error) { + // Why: the wide bind failed after the loopback listener was already stopped. Restore a serving + // loopback listener on the same port (wsBoundHost stays loopback so a later offer retries), then + // propagate — the caller must not advertise a LAN endpoint with no LAN listener behind it (STA-2370). + console.error('[runtime] Failed to widen WebSocket transport for pairing:', error) + await this.recoverWebSocketBindAfterFailedWiden(index, previousPort) + throw error + } + // Why: register the live wide transport BEFORE persisting metadata so a metadata-write failure can + // never orphan a running 0.0.0.0 listener outside activeTransports (and thus outside stop()). + this.activeTransports[index] = widened.transport + const metaIndex = this.transports.findIndex((meta) => meta.kind === 'websocket') + if (metaIndex !== -1) { + this.transports[metaIndex] = { kind: 'websocket', endpoint: widened.endpoint } + } + try { + // Why: a rebind that lands on a different port (same-port bind refused) must be persisted so a + // later reconnect from a device paired to this port matches on the next launch (STA-1511). + if (this.wsPort !== 0 && widened.transport.resolvedPort !== this.wsPort) { + writeWsFallbackPort(this.userDataPath, widened.transport.resolvedPort) + } + this.writeMetadata() + } catch (persistError) { + // Why: the wide listener is live and tracked; a persistence failure must not tear it down. Keep + // serving — the in-memory endpoint is already correct. + console.error( + '[runtime] Widened WebSocket listener but failed to persist pairing metadata:', + persistError + ) + } + } + + // Why: a failed widen already tore down the loopback listener; bring one back on the same host/port so the + // runtime keeps serving locally (wsBoundHost stays loopback, so the next pairing offer retries the widen). + protected async recoverWebSocketBindAfterFailedWiden( + index: number, + previousPort: number + ): Promise { + let restored: { transport: WebSocketTransport; endpoint: string } + try { + restored = await this.startWebSocketTransport({ + host: WS_BIND_HOST_LOOPBACK, + port: previousPort, + preferPinnedPort: true + }) + } catch (recoveryError) { + // Why: even the loopback restore failed — drop the dead WebSocket transport so we never advertise an + // endpoint with no listener. The Unix socket keeps serving and a restart re-establishes the listener. + console.error( + '[runtime] Failed to restore WebSocket transport after widen failure:', + recoveryError + ) + this.activeTransports.splice(index, 1) + const metaIndex = this.transports.findIndex((meta) => meta.kind === 'websocket') + if (metaIndex !== -1) { + this.transports.splice(metaIndex, 1) + } + this.wsBoundHost = null + try { + this.writeMetadata() + } catch { + // Why: metadata already reflects the torn-down listener; nothing else to recover here. + } + return + } + // Why: register the restored loopback listener BEFORE persisting metadata so a write failure can never + // orphan a live transport outside activeTransports (and thus outside stop()). + this.activeTransports[index] = restored.transport + const metaIndex = this.transports.findIndex((meta) => meta.kind === 'websocket') + if (metaIndex !== -1) { + this.transports[metaIndex] = { kind: 'websocket', endpoint: restored.endpoint } + } + try { + this.writeMetadata() + } catch (persistError) { + // Why: the loopback listener is live and tracked; a persistence failure must not tear it down. + console.error( + '[runtime] Restored loopback WebSocket listener but failed to persist metadata:', + persistError + ) + } + } +} diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts new file mode 100644 index 00000000000..3cd3c1a54fb --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing-types.ts @@ -0,0 +1,145 @@ +import type { OrcaRuntimeService } from '../orca-runtime' +import type { RpcAnyMethod } from '../rpc/core' +import type { DeviceRegistry } from '../device-registry' +import type { E2EEKeypair } from '../e2ee-keypair' +import type { MobileSocketTransportMetadata } from '../rpc/mobile-socket-wiring' +import type { PairingRelay } from '../../../shared/mobile-relay-pairing-offer' +import type { MobilePairingConnectionMode } from '../../../shared/mobile-pairing-connection-mode' +import type { MobileRelayMintFailure } from '../../../shared/mobile-relay-mint-failure' +import type { + DeviceCredentialInstalled, + PairingGetEndpointsParams, + PairingGetEndpointsResult, + PairingProvisionRelayParams +} from '../../../shared/mobile-relay-credential-contract' +import type { RelayDeviceBinding, RelayRevokeOutboxItem } from '../relay/relay-revoke-outbox' + +export const DEFAULT_WS_PORT = 6768 + +// Why: STA-2370 — the WS listener defaults to loopback so a desktop with no paired device is not +// reachable from the LAN; it widens to all interfaces only on explicit pairing (or `orca serve`). +export const WS_BIND_HOST_LOOPBACK = '127.0.0.1' +export const WS_BIND_HOST_ALL_INTERFACES = '0.0.0.0' + +// Why brackets: `ws://::1:6768` is not a URL, and every consumer of this endpoint parses it +// with `new URL`. An IPv6 bind address would otherwise publish an unparseable endpoint. +export function formatWsEndpoint(host: string, port: number): string { + return `ws://${host.includes(':') ? `[${host}]` : host}:${port}` +} + +export type OrcaRuntimeRpcServerOptions = { + runtime: OrcaRuntimeService + userDataPath: string + pid?: number + platform?: NodeJS.Platform + enableWebSocket?: boolean + wsPort?: number + // Why: true when the caller pinned a port (`orca serve --port`) so bind order prefers it over a stale STA-1511 fallback (#8535). + preferPinnedWsPort?: boolean + // Why: STA-2370 — bind the WS listener to all interfaces at startup instead of loopback-until-paired. + // Only `orca serve` (explicit remote opt-in) and E2E set this; the desktop app widens lazily on pairing. + exposeNetworkByDefault?: boolean + /** + * Pin the WS listener to exactly this address for the process's whole life. + * + * Why a pin and not another default: the two paths below both widen on their own — + * `exposeNetworkByDefault` at startup, and a device that has connected once at every + * later startup. An unattended host (orcad) whose operator asked for loopback must + * still be on loopback after a client pairs and the service restarts, so the answer + * has to outrank both, and `ensureNetworkExposure()` has to refuse rather than widen. + */ + pinnedBindHost?: string + webClientRoot?: string + // Why: test-only overrides for the two constants below; production must not pass these (defaults set by §3.1). + keepaliveIntervalMs?: number + longPollCap?: number + // Why: test-only override for the ownership reclaim cadence. + metadataOwnershipPollMs?: number + // Why: tests may inject inert protocol stages before production authorization registers them. + methods?: readonly RpcAnyMethod[] +} + +export type PairingOfferUnavailableReason = + | 'websocket_unavailable' + | 'device_registry_unavailable' + | 'e2ee_key_unavailable' + | 'invalid_advertised_endpoint' + | 'relay_mint_failed' + | 'network_exposure_failed' + +export type PairingOfferUnavailable = { + available: false + reason: PairingOfferUnavailableReason + guidance: string + /** Present when an Anywhere mint refused to silently fall back to LAN-only. */ + relayFailure?: MobileRelayMintFailure +} + +export type MobilePairingOfferAvailable = { + available: true + pairingUrl: string + endpoint: string + deviceId: string + webClientUrl: string | null + /** Mode the offer actually encodes. */ + connectionMode: MobilePairingConnectionMode +} + +export type MobilePairingOffer = PairingOfferUnavailable | MobilePairingOfferAvailable + +export type PairingIdentityInitialization = + | { ok: true; deviceRegistry: DeviceRegistry; e2eeKeypair: E2EEKeypair } + | { ok: false; failure: PairingOfferUnavailable } + +export function pairingUnavailable( + reason: PairingOfferUnavailableReason, + guidance: string +): PairingOfferUnavailable { + return { available: false, reason, guidance } +} + +export const DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE = + 'The pairing registry is unavailable. Verify that the Orca data directory is writable.' +export const E2EE_KEY_UNAVAILABLE_GUIDANCE = + 'The E2EE identity is unavailable. Verify that the Orca data directory is writable.' + +export type MobileRelayPairingProvider = { + createPairingRelay( + relayDeviceId: string + ): Promise<{ relay: PairingRelay; binding: RelayDeviceBinding }> + onDeviceRevokeQueued(item: RelayRevokeOutboxItem): void + onDemandStateChanged?(): void + getEndpoints( + context: MobilePairingConnectionContext, + params: PairingGetEndpointsParams + ): Promise + provisionRelay( + context: MobilePairingConnectionContext, + params: PairingProvisionRelayParams + ): Promise +} + +export type MobilePairingConnectionContext = Readonly<{ + deviceId: string + connectionId: string + transport: MobileSocketTransportMetadata +}> + +// Why: keepalive frames count as socket activity, resetting both idle timers so long-polls outlive the 30s/60s idle caps. See §3.1. + +export function createWebClientUrl(endpoint: string, pairingUrl: string): string { + const url = new URL(endpoint) + url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:' + url.pathname = webClientPathForEndpoint(url.pathname) + url.search = '' + // Why: pairing URLs carry full credentials; the fragment keeps them out of proxy logs and Referer headers. + url.hash = `pairing=${encodeURIComponent(pairingUrl)}` + return url.toString() +} + +export function webClientPathForEndpoint(pathname: string): string { + if (!pathname || pathname === '/') { + return '/web-index.html' + } + return `${pathname.replace(/\/$/, '')}/web-index.html` +} diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts new file mode 100644 index 00000000000..592131779eb --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -0,0 +1,211 @@ +import type { DeviceEntry, DeviceRegistry, DeviceScope } from '../device-registry' +import type { E2EEKeypair } from '../e2ee-keypair' +import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' +import type { + RelayDeviceBinding, + RelayRevokeOutbox, + RelayRevokeOutboxItem +} from '../relay/relay-revoke-outbox' +import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../../../shared/pairing' +import type { RuntimePairingReach } from '../../../shared/runtime-pairing-reach' +import { resolveAdvertisedPairingEndpoint } from '../pairing-endpoint' +import { RuntimeRpcNetworkExposure } from './runtime-rpc-network-exposure' +import { + createWebClientUrl, + DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE, + E2EE_KEY_UNAVAILABLE_GUIDANCE, + pairingUnavailable, + type MobileRelayPairingProvider, + type PairingOfferUnavailable +} from './runtime-rpc-pairing-types' + +export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure { + getDeviceRegistry(): DeviceRegistry | null { + return this.deviceRegistry + } + + getTlsFingerprint(): string | null { + return this.tlsFingerprint + } + + getE2EEPublicKey(): string | null { + return this.e2eeKeypair?.publicKeyB64 ?? null + } + + getE2EEKeypair(): E2EEKeypair | null { + return this.e2eeKeypair + } + + getMobileSocketWiring(): MobileSocketWiring | null { + return this.mobileSocketWiring + } + + getRelayRevokeOutbox(): RelayRevokeOutbox { + return this.relayRevokeOutbox + } + + setMobileRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean { + const current = this.deviceRegistry?.getDevice(deviceId) + if ( + current?.scope !== 'mobile' || + this.deviceRegistry?.getMobilePairingConnectionMode(deviceId) === 'local-only' + ) { + return false + } + if ( + current.relayBinding && + (current.relayBinding.relayHostId !== binding.relayHostId || + current.relayBinding.ownerIdentityKey !== binding.ownerIdentityKey) + ) { + // Why: switching the owning account/host must not strand the old cloud credential family, even if that account is offline. + if (!this.queueRelayDeviceRevoke(current.relayBinding)) { + return false + } + } + const updated = this.deviceRegistry?.setRelayBinding(deviceId, binding) ?? false + if (updated) { + this.mobileRelayPairingProvider?.onDemandStateChanged?.() + } + return updated + } + + // Why: only the desktop shell can surface UI; headless serve leaves this unset. + setOnUnpairedDeviceAuthFailure(callback: (() => void) | null): void { + this.onUnpairedDeviceAuthFailure = callback + } + + setMobileRelayPairingProvider(provider: MobileRelayPairingProvider | null): void { + this.mobileRelayPairingProvider = provider + } + + async revokeMobileDevice(deviceId: string): Promise { + const device = this.deviceRegistry?.getDevice(deviceId) + if (device?.scope !== 'mobile') { + return false + } + if (device.relayBinding) { + if (!this.queueRelayDeviceRevoke(device.relayBinding)) { + return false + } + } + if (!this.deviceRegistry?.removeDevice(deviceId)) { + return false + } + this.mobileRelayPairingProvider?.onDemandStateChanged?.() + this.runtime.forgetClientNavigationState(deviceId) + this.mobileSocketWiring?.terminateDeviceConnections(device.token) + return true + } + + revokeRuntimeAccess(deviceId: string): boolean { + const device = this.deviceRegistry?.getDevice(deviceId) + if (device?.scope !== 'runtime' || !this.deviceRegistry?.removeDevice(deviceId)) { + return false + } + this.runtime.forgetClientNavigationState(deviceId) + this.mobileSocketWiring?.terminateDeviceConnections(device.token) + return true + } + + getWebSocketEndpoint(): string | null { + const ws = this.transports.find((t) => t.kind === 'websocket') + return ws?.endpoint ?? null + } + + createPairingOffer(args: { + address?: string | null + name?: string + rotate?: boolean + scope?: DeviceScope + // Why: STA-2370 — recorded on the grant so a "This computer only" client reconnecting cannot make the + // next launch bind every interface. Defaults to network reach, which is what every other caller means. + reach?: RuntimePairingReach + }): + | PairingOfferUnavailable + | { + available: true + pairingUrl: string + endpoint: string + deviceId: string + webClientUrl: string | null + } { + if (this.pairingInitializationFailure) { + return this.pairingInitializationFailure + } + const rawEndpoint = this.getWebSocketEndpoint() + if (!rawEndpoint) { + return pairingUnavailable( + 'websocket_unavailable', + 'WebSocket pairing is unavailable. Inspect preceding runtime errors and choose an unused --port if the listener failed.' + ) + } + if (!this.deviceRegistry) { + return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) + } + const publicKeyB64 = this.getE2EEPublicKey() + if (!publicKeyB64) { + return pairingUnavailable('e2ee_key_unavailable', E2EE_KEY_UNAVAILABLE_GUIDANCE) + } + + const advertised = resolveAdvertisedPairingEndpoint(rawEndpoint, args.address) + if (!advertised.ok) { + return pairingUnavailable(advertised.reason, advertised.guidance) + } + const endpoint = advertised.endpoint + const deviceName = args.name ?? `CLI ${new Date().toLocaleDateString()}` + const scope = args.scope ?? 'runtime' + let device: DeviceEntry + try { + const reach = args.reach ?? 'network' + device = args.rotate + ? this.deviceRegistry.rotatePendingDevice(deviceName, scope, reach) + : this.deviceRegistry.getOrCreatePendingDevice(deviceName, scope, reach) + } catch (error) { + console.error('[runtime] Failed to persist pairing credential:', error) + return pairingUnavailable('device_registry_unavailable', DEVICE_REGISTRY_UNAVAILABLE_GUIDANCE) + } + const pairingUrl = encodePairingOffer({ + v: PAIRING_OFFER_VERSION, + endpoint, + deviceToken: device.token, + publicKeyB64, + pairedDeviceId: device.deviceId, + scope + }) + return { + available: true, + pairingUrl, + endpoint, + deviceId: device.deviceId, + webClientUrl: + this.webClientRoot && scope === 'runtime' ? createWebClientUrl(endpoint, pairingUrl) : null + } + } + + protected queueOrRetainRelayDeviceRevoke(deviceId: string, binding: RelayDeviceBinding): void { + if (this.queueRelayDeviceRevoke(binding)) { + return + } + try { + this.deviceRegistry?.setRelayBinding(deviceId, binding) + } catch (error) { + console.error('[runtime] Failed to retain an unrevoked Relay binding:', error) + } + } + + protected queueRelayDeviceRevoke(binding: RelayDeviceBinding): boolean { + let item: RelayRevokeOutboxItem + try { + item = this.relayRevokeOutbox.enqueue(binding) + } catch (error) { + console.error('[runtime] Failed to persist Relay device cleanup:', error) + return false + } + try { + this.mobileRelayPairingProvider?.onDeviceRevokeQueued(item) + } catch (error) { + console.warn('[runtime] Failed to notify Relay cleanup worker:', error) + } + return true + } +} diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts b/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts new file mode 100644 index 00000000000..b79b87a5be5 --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-request-admission.ts @@ -0,0 +1,152 @@ +import type { RuntimeMetadata } from '../../../shared/runtime-bootstrap' +import { writeRuntimeMetadata } from '../runtime-metadata' +import type { RpcMessageContext } from '../rpc/transport' +import type { RpcRequest, RpcResponse } from '../rpc/core' +import { errorResponse } from '../rpc/errors' +import { RuntimeRpcBinaryRouting } from './runtime-rpc-binary-routing' +import { classifyRuntimeLongPoll, type RuntimeLongPollClass } from './runtime-rpc-long-poll' + +export class RuntimeRpcRequestAdmission extends RuntimeRpcBinaryRouting { + // Why: Unix socket dispatch is one-shot and auths via the shared token from the 0o600 metadata file. See §3.1. + protected async handleMessage( + rawMessage: string, + context?: RpcMessageContext + ): Promise { + // Why: the transport sends an empty message when a client exceeds max size, then closes the connection. + if (!rawMessage) { + return this.buildError('unknown', 'request_too_large', 'RPC request exceeds the maximum size') + } + + const parsed = this.parseAndAuth(rawMessage) + if ('error' in parsed) { + return parsed.error + } + const request = parsed.request + + // Why: long-poll admission fence; short RPCs bypass the counter. See §7 risk #2. + const longPoll = classifyRuntimeLongPoll(request) + const rejection = this.admitLongPoll(longPoll) + if (rejection) { + return this.buildError(request.id, 'runtime_busy', rejection) + } + if (longPoll) { + // Why: arm keepalive only for long-polls; short RPCs never create the setInterval. See §3.1. + context?.startKeepalive() + } + + try { + return await this.dispatcher.dispatch(request, { + signal: longPoll ? context?.signal : undefined + }) + } finally { + this.releaseLongPoll(longPoll) + } + } + + // Why: one fence for both transports — the total cap protects short RPCs, the ask + // sub-cap protects terminal.wait / check --wait from slow reply-blocked asks. + // Returns the rejection message, or null once the slot is reserved. + protected admitLongPoll( + longPoll: RuntimeLongPollClass | null, + pairedDeviceId?: string + ): string | null { + if (!longPoll) { + return null + } + if (this.activeLongPolls >= this.longPollCap) { + return 'long-poll capacity reached; retry with backoff' + } + if ( + (longPoll === 'ask' || longPoll === 'browser-host') && + this.activeAskLongPolls + this.activeBrowserHostLongPolls >= this.specializedLongPollCap + ) { + return longPoll === 'ask' + ? 'orchestration.ask capacity reached; retry with backoff' + : 'browser-host capacity reached; retry with backoff' + } + if (longPoll === 'ask' && this.activeAskLongPolls >= this.askLongPollCap) { + return 'orchestration.ask capacity reached; retry with backoff' + } + if ( + longPoll === 'browser-host' && + (this.activeBrowserHostLongPolls >= this.browserHostLongPollCap || + (pairedDeviceId !== undefined && + (this.activeBrowserHostLongPollsByDevice.get(pairedDeviceId) ?? 0) >= + this.browserHostLongPollCapPerDevice)) + ) { + return 'browser-host capacity reached; retry with backoff' + } + this.activeLongPolls += 1 + if (longPoll === 'ask') { + this.activeAskLongPolls += 1 + } else if (longPoll === 'browser-host') { + this.activeBrowserHostLongPolls += 1 + if (pairedDeviceId !== undefined) { + this.activeBrowserHostLongPollsByDevice.set( + pairedDeviceId, + (this.activeBrowserHostLongPollsByDevice.get(pairedDeviceId) ?? 0) + 1 + ) + } + } + return null + } + + protected releaseLongPoll(longPoll: RuntimeLongPollClass | null, pairedDeviceId?: string): void { + if (!longPoll) { + return + } + this.activeLongPolls = Math.max(0, this.activeLongPolls - 1) + if (longPoll === 'ask') { + this.activeAskLongPolls = Math.max(0, this.activeAskLongPolls - 1) + } else if (longPoll === 'browser-host') { + this.activeBrowserHostLongPolls = Math.max(0, this.activeBrowserHostLongPolls - 1) + if (pairedDeviceId !== undefined) { + const remaining = (this.activeBrowserHostLongPollsByDevice.get(pairedDeviceId) ?? 1) - 1 + if (remaining > 0) { + this.activeBrowserHostLongPollsByDevice.set(pairedDeviceId, remaining) + } else { + this.activeBrowserHostLongPollsByDevice.delete(pairedDeviceId) + } + } + } + } + + protected parseAndAuth(rawMessage: string): { request: RpcRequest } | { error: RpcResponse } { + let request: RpcRequest + try { + request = JSON.parse(rawMessage) as RpcRequest + } catch { + return { error: this.buildError('unknown', 'bad_request', 'Invalid JSON request') } + } + + if (typeof request.id !== 'string' || request.id.length === 0) { + return { error: this.buildError('unknown', 'bad_request', 'Missing request id') } + } + if (typeof request.method !== 'string' || request.method.length === 0) { + return { error: this.buildError(request.id, 'bad_request', 'Missing RPC method') } + } + if (typeof request.authToken !== 'string' || request.authToken.length === 0) { + return { error: this.buildError(request.id, 'unauthorized', 'Missing auth token') } + } + if (request.authToken !== this.authToken) { + return { error: this.buildError(request.id, 'unauthorized', 'Invalid auth token') } + } + + return { request } + } + + protected buildError(id: string, code: string, message: string): RpcResponse { + return errorResponse(id, { runtimeId: this.runtime.getRuntimeId() }, code, message) + } + + protected writeMetadata(): void { + const metadata: RuntimeMetadata = { + runtimeId: this.runtime.getRuntimeId(), + pid: this.pid, + transports: this.transports, + authToken: this.authToken, + startedAt: this.runtime.getStartedAt() + } + writeRuntimeMetadata(this.userDataPath, metadata) + } +} diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts new file mode 100644 index 00000000000..d6edaf78928 --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-shutdown.ts @@ -0,0 +1,38 @@ +import { RuntimeRpcMobilePairing } from './runtime-rpc-mobile-pairing' + +export class RuntimeRpcShutdown extends RuntimeRpcMobilePairing { + /** Why: test-only seam — runs one ownership check instead of waiting out the poll interval. */ + checkRuntimeMetadataOwnership(): void { + this.metadataOwnershipWatch?.check() + } + + async stop(): Promise { + // Why: STA-2370 — refuse new widens, then let any in-flight pairing widen settle into the live + // transport arrays before snapshotting them, so a racing rebind can't strand a wide 0.0.0.0 listener + // by writing it back into a cleared array after shutdown (see widenWebSocketBind). + this.stopping = true + const pendingExposure = this.networkExposurePromise + if (pendingExposure) { + await pendingExposure.catch(() => { + // Why: a failed widen already recovered/logged; we only need it to finish mutating the arrays. + }) + } + const transports = this.activeTransports + this.activeTransports = [] + this.transports = [] + this.metadataOwnershipWatch?.stop() + this.metadataOwnershipWatch = null + this.mobileSocketWiring = null + this.detachWebSocketWiring = null + const stopResults = await Promise.allSettled( + transports.map(async (transport) => transport.stop()) + ) + // Why: before-quit fences relay input; direct auth can still refresh lastSeen while these transports close. + this.deviceRegistry?.flushPendingLastSeen() + const failedStop = stopResults.find((result) => result.status === 'rejected') + if (failedStop?.status === 'rejected') { + throw failedStop.reason + } + // Why: leave the metadata file on shutdown — shared userData may host another live runtime whose bootstrap file we'd erase. + } +} diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-socket-metadata.ts b/src/main/runtime/runtime-rpc/runtime-rpc-socket-metadata.ts new file mode 100644 index 00000000000..2e9f78b1adc --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-socket-metadata.ts @@ -0,0 +1,62 @@ +import { readdirSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import type { RuntimeTransportMetadata } from '../../../shared/runtime-bootstrap' + +/** Why: MUST stay in lockstep with createRuntimeTransportMetadata()'s `o-${pid}-${suffix}.sock` shape (unit-test enforced). */ +export const RUNTIME_SOCKET_NAME_REGEX = /^o-(\d+)-[A-Za-z0-9_-]+\.sock$/ + +export function sweepOrphanedRuntimeSockets(userDataPath: string, ownPid: number): void { + let entries: string[] + try { + entries = readdirSync(userDataPath) + } catch { + // Why: first-launch userData may not exist yet; nothing to sweep. + return + } + for (const entry of entries) { + const match = RUNTIME_SOCKET_NAME_REGEX.exec(entry) + if (!match) { + continue + } + const pid = Number(match[1]) + if (!Number.isFinite(pid)) { + continue + } + // Why: never delete our own socket — a bug here would rmSync one we're about to bind. + if (pid === ownPid) { + continue + } + try { + // Why: signal 0 is the POSIX liveness probe (sends nothing); ESRCH = dead pid, EPERM = foreign owner (left alone). + process.kill(pid, 0) + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ESRCH') { + try { + rmSync(join(userDataPath, entry), { force: true }) + } catch { + // Why: best-effort sweep; a later start() or OS reboot cleans any socket we can't unlink. + } + } + } + } +} + +export function createRuntimeTransportMetadata( + userDataPath: string, + pid: number, + platform: NodeJS.Platform, + runtimeId = 'runtime' +): RuntimeTransportMetadata { + const endpointSuffix = runtimeId.replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 4) || 'rt' + if (platform === 'win32') { + return { + kind: 'named-pipe', + // Why: named pipes lack the chmod hardening of Unix sockets; a per-runtime suffix avoids a stable, guessable endpoint name. + endpoint: `\\\\.\\pipe\\orca-${pid}-${endpointSuffix}` + } + } + return { + kind: 'unix', + endpoint: join(userDataPath, `o-${pid}-${endpointSuffix}.sock`) + } +} diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-state.ts b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts new file mode 100644 index 00000000000..ca9ab173feb --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-state.ts @@ -0,0 +1,133 @@ +import { randomBytes } from 'node:crypto' +import type { RuntimeTransportMetadata } from '../../../shared/runtime-bootstrap' +import type { OrcaRuntimeService } from '../orca-runtime' +import { RpcDispatcher } from '../rpc/dispatcher' +import { ALL_RPC_METHODS } from '../rpc/methods' +import type { RpcTransport } from '../rpc/transport' +import type { WebSocket } from 'ws' +import type { DeviceRegistry } from '../device-registry' +import type { E2EEKeypair } from '../e2ee-keypair' +import type { UnpairedDeviceAuthThrottle } from '../rpc/unpaired-device-auth-throttle' +import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring' +import { RelayRevokeOutbox } from '../relay/relay-revoke-outbox' +import { RuntimeBinaryMessageRouter } from '../runtime-binary-message-router' +import type { RuntimeMetadataOwnershipWatch } from '../runtime-metadata-ownership-watch' +import { RUNTIME_METADATA_OWNERSHIP_POLL_MS } from '../runtime-metadata-ownership-watch' +import { + ASK_LONG_POLL_SHARE, + BROWSER_HOST_LONG_POLL_SHARE, + KEEPALIVE_INTERVAL_MS, + LONG_POLL_CAP, + SPECIALIZED_LONG_POLL_SHARE +} from './runtime-rpc-long-poll' +import type { + MobilePairingOffer, + MobileRelayPairingProvider, + OrcaRuntimeRpcServerOptions, + PairingOfferUnavailable +} from './runtime-rpc-pairing-types' +import { DEFAULT_WS_PORT } from './runtime-rpc-pairing-types' + +export class RuntimeRpcState { + protected readonly runtime: OrcaRuntimeService + protected readonly dispatcher: RpcDispatcher + protected readonly userDataPath: string + protected readonly pid: number + protected readonly platform: NodeJS.Platform + protected readonly enableWebSocket: boolean + protected readonly wsPort: number + protected readonly preferPinnedWsPort: boolean + protected readonly exposeNetworkByDefault: boolean + protected readonly pinnedBindHost: string | null + protected readonly webClientRoot: string | undefined + // Why: STA-2370 — the host the WS listener is currently bound to, so pairing can widen loopback→all-interfaces once. + protected wsBoundHost: string | null = null + // Why: STA-2370 — in-flight widen so concurrent pairing requests share a single rebind. + protected networkExposurePromise: Promise | null = null + // Why: STA-2370 — set by stop() so a racing pairing widen can't recreate a live wide listener into the + // cleared transport arrays after shutdown; stop() also awaits any in-flight widen before snapshotting. + protected stopping = false + protected readonly authToken = randomBytes(24).toString('hex') + protected readonly keepaliveIntervalMs: number + protected readonly longPollCap: number + protected readonly metadataOwnershipPollMs: number + protected readonly askLongPollCap: number + protected readonly browserHostLongPollCap: number + protected readonly browserHostLongPollCapPerDevice: number + protected readonly specializedLongPollCap: number + protected readonly relayRevokeOutbox: RelayRevokeOutbox + protected deviceRegistry: DeviceRegistry | null = null + protected e2eeKeypair: E2EEKeypair | null = null + protected pairingInitializationFailure: PairingOfferUnavailable | null = null + protected tlsFingerprint: string | null = null + protected activeTransports: RpcTransport[] = [] + protected transports: RuntimeTransportMetadata[] = [] + protected metadataOwnershipWatch: RuntimeMetadataOwnershipWatch | null = null + protected mobileSocketWiring: MobileSocketWiring | null = null + // Why: detaches the current WebSocketTransport from the session wiring so a pairing rebind can swap + // transports under the SAME wiring (see ensureMobileSocketWiring) instead of orphaning relay sockets. + protected detachWebSocketWiring: (() => void) | null = null + protected mobileRelayPairingProvider: MobileRelayPairingProvider | null = null + protected mobileRelayPairingOfferQueue: Promise = Promise.resolve() + protected mobileRelayPairingOfferInFlight: { + generation: number + address: string | null + rotate: boolean + request: Promise + } | null = null + protected mobilePairingOfferGeneration = 0 + protected onUnpairedDeviceAuthFailure: (() => void) | null = null + protected unpairedDeviceAuthThrottle: UnpairedDeviceAuthThrottle | null = null + protected readonly binaryMessageRouter = new RuntimeBinaryMessageRouter() + protected readonly wsDispatchAbortStates = new Map< + WebSocket, + { controllers: Set; abortOnClose: () => void } + >() + // Why: separate from server.maxConnections — count only long-running dispatches, not short RPCs. See §3.1 + §7 risk #2. + protected activeLongPolls = 0 + // Why: subset of activeLongPolls held by orchestration.ask, fenced by askLongPollCap. + protected activeAskLongPolls = 0 + protected activeBrowserHostLongPolls = 0 + protected readonly activeBrowserHostLongPollsByDevice = new Map() + + constructor({ + runtime, + userDataPath, + pid = process.pid, + platform = process.platform, + enableWebSocket = false, + wsPort = DEFAULT_WS_PORT, + preferPinnedWsPort = false, + exposeNetworkByDefault = false, + pinnedBindHost, + webClientRoot, + keepaliveIntervalMs = KEEPALIVE_INTERVAL_MS, + longPollCap = LONG_POLL_CAP, + metadataOwnershipPollMs = RUNTIME_METADATA_OWNERSHIP_POLL_MS, + methods + }: OrcaRuntimeRpcServerOptions) { + this.runtime = runtime + this.dispatcher = new RpcDispatcher({ runtime, methods: methods ?? ALL_RPC_METHODS }) + this.userDataPath = userDataPath + this.pid = pid + this.platform = platform + this.enableWebSocket = enableWebSocket + this.wsPort = wsPort + this.preferPinnedWsPort = preferPinnedWsPort + this.exposeNetworkByDefault = exposeNetworkByDefault + this.pinnedBindHost = pinnedBindHost ?? null + this.webClientRoot = webClientRoot + this.keepaliveIntervalMs = keepaliveIntervalMs + this.longPollCap = longPollCap + this.metadataOwnershipPollMs = metadataOwnershipPollMs + // Why: derived, not configurable — the reservation must hold for whatever cap a caller picks. + this.askLongPollCap = Math.max(1, Math.floor(longPollCap * ASK_LONG_POLL_SHARE)) + this.browserHostLongPollCap = Math.max( + 1, + Math.floor(longPollCap * BROWSER_HOST_LONG_POLL_SHARE) + ) + this.browserHostLongPollCapPerDevice = Math.max(1, Math.floor(this.browserHostLongPollCap / 2)) + this.specializedLongPollCap = Math.max(1, Math.floor(longPollCap * SPECIALIZED_LONG_POLL_SHARE)) + this.relayRevokeOutbox = new RelayRevokeOutbox(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 new file mode 100644 index 00000000000..86732e7430c --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -0,0 +1,157 @@ +import type { WebSocket } from 'ws' +import type { + PairingGetEndpointsParams, + PairingProvisionRelayParams +} from '../../../shared/mobile-relay-credential-contract' +import { fingerprintAuthenticatedPairingCredential } from '../rpc/orchestration-mutation-executor' +import type { AuthenticatedMobileSocket } from '../rpc/mobile-socket-wiring' +import type { RpcRequest, RpcResponse } from '../rpc/core' +import type { WebSocketTransport } from '../rpc/ws-transport' +import type { DeviceScope } from '../device-registry' +import { RuntimeRpcRequestAdmission } from './runtime-rpc-request-admission' +import { classifyRuntimeLongPoll } from './runtime-rpc-long-poll' +import { MOBILE_RPC_METHOD_ALLOWLIST } from './runtime-rpc-mobile-method-allowlist' + +// Why: status.get has no per-connection context in the dispatcher, so stamp the scope here at the transport boundary. +function injectDeviceScope(response: string, scope: DeviceScope): string { + try { + const parsed = JSON.parse(response) as RpcResponse + if (parsed.ok !== true || typeof parsed.result !== 'object' || parsed.result === null) { + return response + } + ;(parsed.result as Record).deviceScope = scope + return JSON.stringify(parsed) + } catch { + return response + } +} + +export class RuntimeRpcWebSocketDispatch extends RuntimeRpcRequestAdmission { + // Why: WebSocket dispatch is streaming (multiple responses) and auths via per-device tokens, not the shared token. + protected async handleWebSocketMessage( + rawMessage: string, + reply: (response: string) => void, + sendBinary: (response: Uint8Array) => boolean | void, + wsTransport?: WebSocketTransport, + ws?: WebSocket, + authenticatedDeviceToken?: string | null, + authenticatedSocket?: AuthenticatedMobileSocket + ): Promise { + let request: RpcRequest + try { + request = JSON.parse(rawMessage) as RpcRequest + } catch { + reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Invalid JSON request'))) + return + } + + if (typeof request.id !== 'string' || request.id.length === 0) { + reply(JSON.stringify(this.buildError('unknown', 'bad_request', 'Missing request id'))) + return + } + if (typeof request.method !== 'string' || request.method.length === 0) { + reply(JSON.stringify(this.buildError(request.id, 'bad_request', 'Missing RPC method'))) + return + } + + const requestToken = + typeof (request as Record).deviceToken === 'string' + ? ((request as Record).deviceToken as string) + : null + if (authenticatedDeviceToken && requestToken && requestToken !== authenticatedDeviceToken) { + reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Device token mismatch'))) + return + } + // Why: E2EE already authenticated the channel; authorize by that bound identity, not a repeated request field. + const token = authenticatedDeviceToken ?? requestToken + if (!token) { + reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Missing device token'))) + return + } + const device = this.deviceRegistry?.validateToken(token) + if (!device) { + reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Invalid device token'))) + return + } + if (device.scope === 'mobile' && !MOBILE_RPC_METHOD_ALLOWLIST.has(request.method)) { + reply( + JSON.stringify( + this.buildError( + request.id, + 'forbidden', + `Method '${request.method}' is not available to mobile clients` + ) + ) + ) + return + } + + // Why: bind deviceToken to this socket so ws.on('close') knows which mobile client disconnected. + if (wsTransport && ws) { + wsTransport.setClientId(ws, token) + } + + const longPoll = classifyRuntimeLongPoll(request) + const rejection = this.admitLongPoll(longPoll, device.deviceId) + if (rejection) { + reply(JSON.stringify(this.buildError(request.id, 'runtime_busy', rejection))) + return + } + + const abortRegistration = ws ? this.registerWebSocketDispatchAbort(ws) : null + + // Why: older pairings may lack scope metadata, so stamp the authenticated scope onto status.get. + const replyForRequest = + request.method === 'status.get' + ? (response: string): void => reply(injectDeviceScope(response, device.scope)) + : reply + + const connectionId = ws ? this.mobileSocketWiring?.getConnectionId(ws) : undefined + const pairingProvider = this.mobileRelayPairingProvider + const pairingContext = + pairingProvider && authenticatedSocket + ? { + getEndpoints: (params: PairingGetEndpointsParams) => + pairingProvider.getEndpoints( + { + deviceId: authenticatedSocket.device.deviceId, + connectionId: authenticatedSocket.connectionId, + transport: authenticatedSocket.transport + }, + params + ), + provisionRelay: (params: PairingProvisionRelayParams) => + pairingProvider.provisionRelay( + { + deviceId: authenticatedSocket.device.deviceId, + connectionId: authenticatedSocket.connectionId, + transport: authenticatedSocket.transport + }, + params + ) + } + : undefined + try { + await this.dispatcher.dispatchStreaming(request, replyForRequest, { + // Why: the validated credential preserves existing federation ownership without trusting request fields. + authenticatedCallerFingerprint: fingerprintAuthenticatedPairingCredential(token), + connectionId, + clientId: token, + pairedDeviceId: device.deviceId, + // Why: gates the mobile-only payload diet so full-screen web/desktop clients aren't truncated. + clientKind: device.scope, + clientCapabilities: authenticatedSocket?.clientCapabilities, + pairing: pairingContext, + signal: abortRegistration?.signal, + sendBinary, + registerBinaryStreamHandler: (streamId, handler) => + this.registerBinaryStreamHandler(connectionId, streamId, handler), + registerBinaryMessageHandler: (handler) => + this.registerBinaryMessageHandler(connectionId, handler) + }) + } finally { + abortRegistration?.dispose() + this.releaseLongPoll(longPoll, device.deviceId) + } + } +}