Split runtime RPC server responsibilities

This commit is contained in:
Neil
2026-08-29 12:09:07 -07:00
parent 4c61bc533f
commit 34bdc307ca
16 changed files with 2017 additions and 1890 deletions
-1
View File
@@ -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
@@ -95,7 +95,10 @@ function mobileRpcMethods(): string[] {
}
function mobileRpcAllowlist(): Set<string> {
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')
File diff suppressed because it is too large Load Diff
@@ -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<ArrayBufferLike>) => void
): () => void {
return this.binaryMessageRouter.registerRawMessage(connectionId, handler)
}
protected handleWebSocketBinaryMessage(bytes: Uint8Array<ArrayBufferLike>, 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 }
}
}
@@ -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<void> {
if (this.activeTransports.length > 0) {
return
}
// Why: SIGKILL/OOM skip stop(), orphaning `o-<pid>-*.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
}
}
@@ -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
}
@@ -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'
])
@@ -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<MobilePairingOffer> {
// 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<MobilePairingOffer> {
// 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<ReturnType<MobileRelayPairingProvider['createPairingRelay']>>
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.
*/
}
@@ -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<void> {
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<void> {
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<void> {
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
)
}
}
}
@@ -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<PairingGetEndpointsResult>
provisionRelay(
context: MobilePairingConnectionContext,
params: PairingProvisionRelayParams
): Promise<DeviceCredentialInstalled>
}
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`
}
@@ -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<boolean> {
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
}
}
@@ -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<RpcResponse> {
// 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)
}
}
@@ -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<void> {
// 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.
}
}
@@ -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`)
}
}
@@ -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<void> | 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<void> = Promise.resolve()
protected mobileRelayPairingOfferInFlight: {
generation: number
address: string | null
rotate: boolean
request: Promise<MobilePairingOffer>
} | 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<AbortController>; 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<string, number>()
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)
}
}
@@ -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<string, unknown>).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<ArrayBufferLike>) => boolean | void,
wsTransport?: WebSocketTransport,
ws?: WebSocket,
authenticatedDeviceToken?: string | null,
authenticatedSocket?: AuthenticatedMobileSocket
): Promise<void> {
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<string, unknown>).deviceToken === 'string'
? ((request as Record<string, unknown>).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)
}
}
}