Loading files
This file is pinned to the selected release. Download the original to open it in your design tool.
279918da1This file is pinned to the selected release. Download the original to open it in your design tool.
/**
* Backend hub — web client terminating endpoint (`/api/web-ws`).
*
* Two auth modes share the path (the subprotocol carries the credential — browsers can't set WS headers):
*
* - PER-USER (current): the subprotocol is the user's SSO access token → ONE socket per user.
* The socket starts UNATTACHED; the client scopes it with `machine_select {machineId}` (ownership-checked
* against `agentBinding.userId`), which detaches the previous hub client and attaches a fresh one
* (new connId — keeps the (agent, connId) invariant that E2EE and reply-targeting rely on). While
* attached, frames flow exactly like the legacy mode (sendDown / up:{machineId} fan-out). The socket
* also live-watches ALL the user's agents and pushes `machines_status` frames (node_status transitions
* tagged with machineId) so the Machines list stays live without REST polling.
*
* - LEGACY (fallback): the subprotocol is an agent apiKey → the socket is bound to that ONE agent for
* its whole life. Kept for stale tabs running the old bundle during a rolling deploy. Collision-safe:
* a 64-hex apiKey is recognized before any SSO profile request, so agent keys are never sent to
* the external identity service.
*/
import type { IncomingMessage } from 'http'
import type { Duplex } from 'stream'
import { WebSocket, type RawData } from 'ws'
import { createWss, WS_LIMITS } from './wsServer.js'
import { extractKey } from '../utils/crypto.js'
import { machineIdFromKey } from '../utils/crypto.js'
import { prisma, machineAlive } from './prisma.js'
import { getAgentPresence, getAgentPresenceMany, subscribeStatus, getDevicePresence, subscribeDeviceStatus, subscribeDeviceMachineListChanged, subscribeDeviceE2eePair } from './bus.js'
import { attachHubClient, trackSocketLiveness, type HubClient } from './hub.js'
import { authenticateAccessToken, SsoAuthError, type AuthUser } from './ssoAuth.js'
import type { Frame } from './tunnel.js'
import { machineOnline } from './providerLink.js'
import { logger } from '../utils/logger.js'
import { fireAndForget } from '../utils/async.js'
import { guardedSendJson } from './wsSend.js'
import { createOrderedInbox } from './orderedInbox.js'
import { ensureMachineReady, getMachineLifecycleStatus, type MachineLifecycleStatus } from './machineLifecycle.js'
import type { Machine } from '@prisma/client'
import { machineBillingAllowsDataPlane } from './billingState.js'
import { parseAutonomousEnvironment, storedAutonomousEnvironment } from './autonomousEnvironment.js'
import {
isEncryptedTerminalFrame,
TERMINAL_DOWN_TYPES,
TERMINAL_ENVELOPE_MAX_BYTES,
terminalFrameBytes,
terminalFrameNeedsWake,
TerminalRateGuard,
} from './terminalRelay.js'
import { parseTerminalClientFrame, TerminalBinaryKind, TERMINAL_BINARY_CLIENT_UP_KINDS } from './terminalBinary.js'
import {
isEncryptedP2pFrame,
P2P_DOWN_TYPES,
P2P_SIGNAL_MAX_BYTES,
p2pFrameBytes,
P2pSignalRateGuard,
terminalP2pPolicy,
} from './p2pSignaling.js'
import { recordRemoteUsage } from './dailyTracking.js'
/**
* Down-frames the backend mints for an adapter, which a web client must never be able to forge.
*
* The `__` prefix already marks most of these; these two predate that convention and are not
* prefixed, so they fell through this handler's namespace checks into the verbatim forward at the
* bottom. See the block in `handleFrame` for what each one does when forged.
*
* Senders, all backend-side: `lib/adapterWs.ts` (on connect) and `services/MachineService.ts`
* (rename, revoke).
*/
export const BACKEND_ONLY_DOWN_TYPES = new Set(['machine_meta', 'machine_revoked'])
const wss = createWss(WS_LIMITS.web, { echoFirstProtocol: true })
// Device-status re-seed: covers the silent-offline case (worker crash → presence TTL 45s ages out
// with no transition published) — the watcher re-reads presence keys on this cadence.
const DEVICE_RESEED_MS = 30_000
// Trailing debounce for list re-derivations poked by machines_watch (see scheduleWatchAgents).
const WATCH_DEBOUNCE_MS = 250
/** HTTP upgrade hook for `/api/web-ws`. Subprotocol = SSO access token or legacy agent apiKey. */
export function handleWebUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void {
const cred = extractKey(req)
if (!cred) { socket.destroy(); return }
// LEGACY per-agent mode: classify the fixed 64-hex key first so it is never sent to SSO.
if (/^[a-f0-9]{64}$/i.test(cred)) {
const machineId = machineIdFromKey(cred)
void (async () => {
const binding = await prisma.machine.findUnique({ where: { machineId: machineId } })
if (!binding || binding.deletedAt || !machineBillingAllowsDataPlane(binding)) { socket.destroy(); return }
wss.handleUpgrade(req, socket, head, (ws) => attachWebClient(ws, machineId))
})().catch((err) => { logger.warn('web-ws legacy upgrade failed', { error: String(err) }); socket.destroy() })
return
}
// PER-USER mode: profile validation is async, so authenticate before completing the upgrade.
void (async () => {
try {
const requestedEnvironment = parseAutonomousEnvironment(
new URL(req.url ?? '/api/web-ws', 'http://backend.local').searchParams.get('autonomousEnv'),
)
const user = await authenticateAccessToken(cred, requestedEnvironment)
if (socket.destroyed) return
wss.handleUpgrade(req, socket, head, (ws) => attachUserClient(ws, user))
} catch (err) {
if (socket.destroyed) return
const invalid = err instanceof SsoAuthError && err.code === 'INVALID_TOKEN'
const mismatch = err instanceof SsoAuthError &&
(err.code === 'AUTONOMOUS_ENV_MISMATCH' || err.code === 'AUTONOMOUS_ENV_NOT_ALLOWED')
const code = invalid ? 4401 : mismatch ? 4403 : 1013
const reason = invalid
? 'auth expired'
: mismatch
? `env:${err.requiredEnv ?? 'prod'}`
: 'auth service unavailable'
wss.handleUpgrade(req, socket, head, (ws) => { try { ws.close(code, reason) } catch { /* ignore */ } })
}
})().catch((err) => { logger.warn('web-ws upgrade failed', { error: String(err) }); socket.destroy() })
}
/** PER-USER socket: starts unattached; `machine_select` scopes it to one owned machine at a time. */
function attachUserClient(ws: WebSocket, user: AuthUser): void {
let client: HubClient | null = null
let currentAgentId: string | null = null
let currentBinding: Machine | null = null
let closed = false
// Monotonic guards: async lookups from two rapid selects (or watch refreshes) must not finish
// out of order and leave the socket bound to the older target.
let selectGen = 0
let watchGen = 0
const terminalRate = new TerminalRateGuard()
const p2pSignalRate = new P2pSignalRateGuard()
const send = (obj: unknown): boolean => guardedSendJson(ws, obj, 'must', { userId: user.sub })
logger.info('web user connected', { userId: user.sub })
send({ type: 'connected', payload: { userId: user.sub } })
// No daily-presence write here. This socket is not the person: the desktop app never dials it (the
// local daemon does, one per FOREIGN machine it relays), so counting upgrades measured relay
// reconnects and missed every single-machine user. `user_daily_presence` is fed by the app's own
// `app_presence` ping through the daemon's adapter-ws instead (lib/adapterWs.ts).
// An unattached socket (user parked on the Machines page, no agent selected) holds no hub client, so a
// registry-driven sweep can't see it — it would be killed by LB idle timeouts, or never reaped when
// dead. This used to be a second heartbeat loop here, carefully disabled while attached so the two
// sweeps couldn't race; the hub now tracks the socket itself, which is one sweep for both states.
const releaseLiveness = trackSocketLiveness(ws)
// ── Live Machines-list feed ──────────────────────────────────────────────────────────────────
// Subscribe node_status for EVERY agent the user owns and forward it tagged with the machineId (the
// raw node_status frame doesn't carry one). Re-armed via `machines_watch` (sent after create/delete).
// The agent set is ALWAYS re-derived from the DB — the client never supplies it.
const statusSubs = new Map<string, () => void>()
const sendStatuses = (statuses: Array<{ machineId: string; online: boolean; status?: MachineLifecycleStatus; reason?: string }>): void => {
if (ws.readyState !== WebSocket.OPEN || statuses.length === 0) return
send({ type: 'machines_status', payload: { statuses } })
}
const watchAgents = async (): Promise<void> => {
const gen = ++watchGen
const bindings = await prisma.machine.findMany({
where: { userId: user.sub, autonomousEnv: user.autonomousEnv, ...machineAlive },
select: { machineId: true, billingStatus: true },
})
if (gen !== watchGen || closed) return // superseded / socket gone
const wanted = new Set(bindings.filter(machineBillingAllowsDataPlane).map((b) => b.machineId))
for (const [id, unsub] of statusSubs) {
if (!wanted.has(id)) { unsub(); statusSubs.delete(id) } // agent deleted
}
for (const id of wanted) {
if (statusSubs.has(id)) continue
// `status:{machineId}` carries only node_status / machine_app_status transitions — not the
// machine's whole turn stream, which is what subscribing `up:` here used to pull in for every
// machine the user owns, whether or not anyone was looking at it.
const unsub = await subscribeStatus(id, (frame) => {
const f = frame as { type?: string; payload?: { online?: boolean; status?: MachineLifecycleStatus; reason?: string } }
if (f?.type === 'node_status') {
sendStatuses([{ machineId: id, online: f.payload?.online === true, ...(f.payload?.status ? { status: f.payload.status } : {}), ...(f.payload?.reason ? { reason: f.payload.reason } : {}) }])
}
})
if (closed || gen !== watchGen) { unsub(); return } // raced a close / newer watch reconciles
statusSubs.set(id, unsub)
}
// Seed the full current picture (presence keys = source of truth) so the list is right immediately.
// One MGET for every presence key, not one GET per machine.
const ids = [...wanted]
const presence = await getAgentPresenceMany(ids)
const statuses = await Promise.all(
ids.map(async (id) => {
// Same assertion as the per-agent seeds: a provider machine has no presence key and is always
// online, so reading presence alone would list it as offline on the machine cards too.
const online = await machineOnline(id, !!presence.get(id))
const lifecycle = online ? 'running' : await getMachineLifecycleStatus(id)
return { machineId: id, online, status: lifecycle === 'unknown' ? 'offline' as const : lifecycle }
}),
)
if (gen === watchGen && !closed) sendStatuses(statuses)
}
// A machine create/rename/delete pokes every open tab of the user at once (`machines_changed` +
// the client's `machines_watch` reply); several in a row (a bulk rename) must not re-derive the
// list each time. Trailing debounce: the last poke wins, ≤250ms later.
let watchAgentsTimer: NodeJS.Timeout | null = null
const scheduleWatchAgents = (): void => {
if (watchAgentsTimer) clearTimeout(watchAgentsTimer)
watchAgentsTimer = setTimeout(() => {
watchAgentsTimer = null
if (closed) return
void watchAgents().catch((err) => logger.warn('web-ws watchAgents failed', { userId: user.sub, error: String(err) }))
}, WATCH_DEBOUNCE_MS)
}
void watchAgents().catch((err) => logger.warn('web-ws watchAgents failed', { userId: user.sub, error: String(err) }))
// ── Live device-status feed (mirror of watchAgents; per-DEVICE statuses over one per-user sub) ──
// Transitions ride the user-scoped `devstatus:{userId}` channel (deviceWs publishes on connect/
// disconnect), so a device paired AFTER the watch armed still reports without re-deriving the set.
// The 30s re-seed poll covers the silent case: a worker holding a device socket crashes → nobody
// publishes offline, the presence key just ages out (TTL 45s) — the poll notices within ~30s.
const sendDeviceStatuses = (statuses: Array<{ deviceId: string; online: boolean; lastSeenAt?: string | null }>): void => {
if (ws.readyState !== WebSocket.OPEN || statuses.length === 0) return
send({ type: 'devices_status', payload: { statuses } })
}
let deviceStatusUnsub: (() => void) | null = null
let deviceSeedTimer: NodeJS.Timeout | null = null
const seedDeviceStatuses = async (): Promise<void> => {
const rows = await prisma.deviceBinding.findMany({ where: { userId: user.sub }, select: { deviceId: true, lastSeenAt: true } })
if (closed) return
const statuses = await Promise.all(
rows.map(async (d) => ({ deviceId: d.deviceId, online: await getDevicePresence(d.deviceId), lastSeenAt: d.lastSeenAt?.toISOString() ?? null })),
)
if (!closed) sendDeviceStatuses(statuses)
}
const watchDevices = async (): Promise<void> => {
if (!deviceStatusUnsub) {
deviceStatusUnsub = await subscribeDeviceStatus(user.sub, (msg) => {
sendDeviceStatuses([{ deviceId: msg.deviceId, online: msg.online === true, ...(msg.lastSeenAt ? { lastSeenAt: msg.lastSeenAt } : {}) }])
})
if (closed) { deviceStatusUnsub(); deviceStatusUnsub = null; return }
}
if (!deviceSeedTimer) {
deviceSeedTimer = setInterval(() => {
void seedDeviceStatuses().catch(() => { /* ignore */ })
}, DEVICE_RESEED_MS)
}
await seedDeviceStatuses()
}
void watchDevices().catch((err) => logger.warn('web-ws watchDevices failed', { userId: user.sub, error: String(err) }))
// ── Live machine-LIST invalidation ─────────────────────────────────────────────────────────────
// The `devmachines:{userId}` channel (despite the device-era name) is the per-user "machine list
// changed" bus: create/delete/rename publish here from whatever worker served the REST call.
// Forward it as `machines_changed` so every open tab re-fetches the list (names stay in sync
// across tabs without F5).
let machineListUnsub: (() => void) | null = null
void (async () => {
machineListUnsub = await subscribeDeviceMachineListChanged(user.sub, (msg) => {
if (ws.readyState !== WebSocket.OPEN) return
if (msg.reason === 'environment_changed' && msg.autonomousEnv && msg.autonomousEnv !== user.autonomousEnv) {
try { ws.close(4403, `env:${msg.autonomousEnv}`) } catch { /* ignore */ }
return
}
send({ type: 'machines_changed', payload: { reason: msg.reason } })
})
if (closed) { machineListUnsub(); machineListUnsub = null }
})().catch((err) => logger.warn('web-ws machine-list watch failed', { userId: user.sub, error: String(err) }))
// ── User-level E2EE device-pair requests ─────────────────────────────────────────────────────
// Not tied to current machine selection: any logged-in page can receive the notice, then the frontend
// checks whether this browser already trusts the target machine before showing the global popup.
let deviceE2eePairUnsub: (() => void) | null = null
void (async () => {
deviceE2eePairUnsub = await subscribeDeviceE2eePair(user.sub, (msg) => {
if (ws.readyState !== WebSocket.OPEN) return
const { kind, ...payload } = msg
const type = kind === 'cleared' ? 'device_e2ee_pair_cleared' : 'device_e2ee_pair_pending'
send({ type, payload })
})
if (closed) { deviceE2eePairUnsub(); deviceE2eePairUnsub = null }
})().catch((err) => logger.warn('web-ws device-e2ee-pair watch failed', { userId: user.sub, error: String(err) }))
// ── Agent selection ──────────────────────────────────────────────────────────────────────────
const ack = (machineId: string): void => {
send({
type: 'connected',
payload: { machineId: machineId, p2p: terminalP2pPolicy(user.sub, machineId) },
})
// Authoritative node-liveness for the newly-scoped agent (same seed the legacy mode sends).
fireAndForget((async () => {
const mgr = await getAgentPresence(machineId)
if (ws.readyState !== WebSocket.OPEN || currentAgentId !== machineId) return
const online = await machineOnline(machineId, !!mgr)
const lifecycle = await getMachineLifecycleStatus(machineId)
if (ws.readyState !== WebSocket.OPEN || currentAgentId !== machineId) return
send({ type: 'node_status', payload: { online, status: online ? 'running' : lifecycle === 'unknown' ? 'offline' : lifecycle } })
})(), 'web-ws seed node_status', { userId: user.sub, machineId })
}
const bindAgent = async (machineIdRaw: unknown): Promise<void> => {
if (typeof machineIdRaw !== 'string' || !machineIdRaw) return
const machineId = machineIdRaw
const gen = ++selectGen
const binding = await prisma.machine.findUnique({ where: { machineId: machineId } })
if (closed || gen !== selectGen) return // superseded by a newer select
if (!binding || binding.deletedAt || binding.userId !== user.sub) {
logger.warn('machine_select rejected', { userId: user.sub, machineId })
send({ type: 'machine_select_error', payload: { machineId: machineId, error: 'NOT_YOUR_MACHINE' } })
return
}
if (storedAutonomousEnvironment(binding.autonomousEnv) !== user.autonomousEnv) {
logger.warn('machine_select environment rejected', { userId: user.sub, machineId, autonomousEnv: user.autonomousEnv })
send({ type: 'machine_select_error', payload: { machineId: machineId, error: 'MACHINE_ENV_MISMATCH' } })
return
}
if (binding.billingStatus === 'pending') {
send({ type: 'machine_select_error', payload: { machineId: machineId, error: 'MACHINE_PAYMENT_PENDING' } })
return
}
if (binding.billingStatus === 'suspended') {
send({ type: 'machine_select_error', payload: { machineId: machineId, error: 'MACHINE_SUBSCRIPTION_REQUIRED' } })
return
}
try {
await ensureMachineReady(binding)
} catch (err) {
if (!closed && gen === selectGen) {
const code = err instanceof Error && 'code' in err ? String((err as { code?: unknown }).code) : 'MACHINE_START_FAILED'
send({ type: 'machine_select_error', payload: { machineId: machineId, error: code } })
}
return
}
if (closed || gen !== selectGen) return
// Idempotent same-agent re-select keeps the connId, but still passed through ensureMachineReady so
// selecting a deliberately stopped machine wakes it before the readiness ack.
if (client && machineId === currentAgentId) { currentBinding = binding; ack(machineId); return }
client?.detach()
client = attachHubClient(ws, machineId, 'web')
currentAgentId = machineId
currentBinding = binding
logger.info('web user selected agent', { userId: user.sub, machineId, connId: client.connId })
ack(machineId)
}
// Presence, remembered briefly. Every non-terminal frame used to cost one Redis GET on the hot path;
// the key itself has a 30s TTL, so a 2s memory of "it was there" is well inside its own staleness.
// Only a POSITIVE answer is cached — an absent key must keep triggering the wake path.
const PRESENCE_MEMORY_MS = 2_000
let presenceSeen: { machineId: string; at: number } | null = null
const presentRecently = async (machineId: string): Promise<boolean> => {
const now = Date.now()
if (presenceSeen && presenceSeen.machineId === machineId && now - presenceSeen.at < PRESENCE_MEMORY_MS) return true
const present = !!(await getAgentPresence(machineId))
presenceSeen = present ? { machineId, at: now } : null
return present
}
const handleFrame = async (frame: Frame): Promise<void> => {
const type = frame.type as string | undefined
// Double-underscore frames are backend-to-Harness control messages. A web
// client must never be able to forge its own lifecycle notification.
if (typeof type === 'string' && type.startsWith('__')) return
// ⚠️ Same rule, for the two control frames that are NOT `__`-prefixed and so escaped it. Anything
// reaching this handler holds a valid access token for the account and nothing more, while the
// frames below are instructions the adapter obeys as the backend's own — everything else here
// falls through to `client.sendDown(frame)`, which forwards verbatim:
// - `machine_meta` names the account's private grid, i.e. the inference endpoint every agent on
// that computer is then pointed at. Forged, it redirects the account's work.
// - `machine_revoked` makes the adapter clear its stored session and exit.
// Both are minted here (`adapterWs.ts`, `services/MachineService.ts`); no client in this
// repository sends either, so refusing them costs nothing. The adapter refuses them from
// non-backend transports too (`cli/src/backendSocket.ts`, BACKEND_ONLY_DOWN_TYPES) — but it
// cannot tell a frame the backend decided on from one the backend relayed for a web client, so
// that check alone does not cover this path. This is where that distinction still exists.
if (typeof type === 'string' && BACKEND_ONLY_DOWN_TYPES.has(type)) return
const isTerminal = typeof type === 'string' && TERMINAL_DOWN_TYPES.has(type)
const terminalNamespace = typeof type === 'string' && type.startsWith('terminal_')
if (terminalNamespace && !isTerminal) {
send({ type: 'terminal_transport_error', payload: { code: 'TERMINAL_TYPE_REJECTED' } })
return
}
if (isTerminal) {
const bytes = terminalFrameBytes(frame)
if (bytes > TERMINAL_ENVELOPE_MAX_BYTES || !isEncryptedTerminalFrame(frame) || !terminalRate.allow(type!, bytes)) {
send({ type: 'terminal_transport_error', payload: { code: 'TERMINAL_FRAME_REJECTED' } })
return
}
}
const p2pNamespace = typeof type === 'string' && type.startsWith('p2p_')
const isP2pSignal = typeof type === 'string' && P2P_DOWN_TYPES.has(type)
if (type === 'p2p_result') {
const payload = (frame.payload ?? {}) as Record<string, unknown>
const outcome = typeof payload.outcome === 'string' ? payload.outcome : ''
const allowed = new Set(['direct', 'timeout', 'failed', 'dropped', 'relay'])
if (client && currentBinding && allowed.has(outcome)) {
const setupMs = Number(payload.setupMs)
logger.info('terminal p2p result', {
userId: user.sub,
machineId: currentBinding.machineId,
outcome,
...(Number.isFinite(setupMs) && setupMs >= 0 && setupMs <= 60_000 ? { setupMs: Math.round(setupMs) } : {}),
...(typeof payload.reason === 'string' ? { reason: payload.reason.slice(0, 64) } : {}),
})
}
return
}
if (p2pNamespace && !isP2pSignal) {
send({ type: 'p2p_transport_error', payload: { code: 'P2P_TYPE_REJECTED' } })
return
}
if (isP2pSignal) {
const bytes = p2pFrameBytes(frame)
const policyEnabled = currentBinding
? terminalP2pPolicy(user.sub, currentBinding.machineId).enabled
: false
if (!policyEnabled || bytes > P2P_SIGNAL_MAX_BYTES
|| !isEncryptedP2pFrame(frame) || !p2pSignalRate.allow(bytes)) {
send({ type: 'p2p_transport_error', payload: { code: 'P2P_SIGNAL_REJECTED' } })
return
}
// Remote-usage signal: a p2p_offer actually starts a new p2p session (vs. the ICE/abort
// frames that follow one), so it's the one point that means "used remote to stream terminal".
if (type === 'p2p_offer' && currentBinding) {
recordRemoteUsage(user.sub, currentBinding.machineId, new Date())
.catch((err) => logger.warn('remote usage tracking failed', { userId: user.sub, machineId: currentBinding?.machineId, error: String(err) }))
}
}
if (type === 'machine_select') {
const machineId = (frame.payload as { machineId?: unknown } | undefined)?.machineId
await bindAgent(machineId)
return
}
if (type === 'machine_deselect') {
selectGen++ // cancel any in-flight bindAgent
client?.detach()
client = null
currentAgentId = null
currentBinding = null
return
}
if (type === 'machines_watch') {
scheduleWatchAgents()
return
}
if (type === 'devices_watch') {
void watchDevices().catch(() => { /* logged above */ })
return
}
if (client && currentBinding) {
// A selected machine can idle-stop while the tab remains open. Turn-producing messages always
// refresh the wake lease; other frames wake only when presence says the node is down.
try {
if ((isTerminal && terminalFrameNeedsWake(type!)) || (!isTerminal && (type === 'message' || !(await presentRecently(currentBinding.machineId))))) {
await ensureMachineReady(currentBinding)
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Machine could not be started'
send({ type: 'error', payload: { message } })
return
}
client.sendDown(frame)
return
}
// No agent selected: fast-fail RPCs (echo the requestId) so wsClient.request doesn't burn its
// 20s timeout; drop fire-and-forget frames.
const requestId = (frame.payload as { requestId?: unknown } | undefined)?.requestId
if (typeof requestId === 'string' && typeof type === 'string') {
send({ type: `${type}_result`, payload: { requestId, error: 'NO_MACHINE_SELECTED' } })
}
}
// STRICT in-order frame processing. `bindAgent` awaits a DB lookup; without this chain a frame
// arriving in that gap (the web sends `agent_select` then its RPCs back-to-back) would be routed
// by the PREVIOUS selection's client — an agent-switch could leak the old agent's projects/replies
// into the new view, or lose the RPC entirely. The chain makes every frame wait for the frames
// (incl. selects) before it, restoring the single-socket ordering guarantee end-to-end.
// Bounded (see orderedInbox.ts): past the ceiling a frame is refused and answered right away.
let lastOverflowLogAt = 0
const inbox = createOrderedInbox<Frame>(
handleFrame,
(err, frame) => logger.warn('web-ws frame failed', { userId: user.sub, type: frame.type, error: String(err) }),
(frame, inflight) => {
const type = frame.type as string | undefined
const requestId = (frame.payload as { requestId?: unknown } | undefined)?.requestId
if (typeof requestId === 'string' && typeof type === 'string') {
send({ type: `${type}_result`, payload: { requestId, error: 'INBOUND_OVERFLOW' } })
} else {
send({ type: 'error', payload: { code: 'INBOUND_OVERFLOW', message: 'too many frames in flight' } })
}
const now = Date.now()
if (now - lastOverflowLogAt > 10_000) {
lastOverflowLogAt = now
logger.warn('web-ws inbound overflow — refusing frames', { userId: user.sub, machineId: currentAgentId ?? undefined, inflight, type })
}
},
)
ws.on('message', (raw: RawData, isBinary: boolean) => {
if (isBinary) {
const parsed = parseTerminalClientFrame(new Uint8Array(raw as Buffer))
// A web/relay client only ever originates input, or a paste/imagePaste/pasteFile chunk —
// output/keyframe/sync flow the other way. This gate is `parseTerminalClientFrame`'s own kind
// check (which just validates wire shape and accepts every kind) plus that directionality —
// paste/imagePaste/pasteFile were missing here even after being added to the kind enum and to
// that parser, so every such frame was rejected as TERMINAL_BINARY_REJECTED, freezing the pane.
const rateKey = parsed?.kind === TerminalBinaryKind.input ? 'terminal_input' : 'terminal_paste'
if (!client || !currentBinding || !parsed || !TERMINAL_BINARY_CLIENT_UP_KINDS.has(parsed.kind)
|| !terminalRate.allow(rateKey, parsed.bytes.length)) {
send({ type: 'terminal_transport_error', payload: { code: 'TERMINAL_BINARY_REJECTED' } })
return
}
client.sendTerminalDown(parsed.bytes)
return
}
let frame: Frame
try { frame = JSON.parse(raw.toString()) as Frame } catch { return }
inbox.enqueue(frame)
})
const cleanup = (): void => {
if (closed) return
closed = true
releaseLiveness()
client?.detach()
client = null
currentBinding = null
for (const [, unsub] of statusSubs) unsub()
statusSubs.clear()
if (watchAgentsTimer) { clearTimeout(watchAgentsTimer); watchAgentsTimer = null }
if (deviceSeedTimer) { clearInterval(deviceSeedTimer); deviceSeedTimer = null }
if (deviceStatusUnsub) { deviceStatusUnsub(); deviceStatusUnsub = null }
if (machineListUnsub) { machineListUnsub(); machineListUnsub = null }
if (deviceE2eePairUnsub) { deviceE2eePairUnsub(); deviceE2eePairUnsub = null }
logger.info('web user disconnected', { userId: user.sub, machineId: currentAgentId ?? undefined })
}
ws.on('close', cleanup)
ws.on('error', cleanup)
}
/** LEGACY per-agent socket (old bundle during rolling deploys): bound to one agent for life. */
function attachWebClient(ws: WebSocket, machineId: string): void {
const client = attachHubClient(ws, machineId, 'web')
const terminalRate = new TerminalRateGuard()
const send = (obj: unknown): boolean => guardedSendJson(ws, obj, 'must', { machineId, connId: client.connId })
logger.info('web client connected (legacy)', { machineId, connId: client.connId })
send({ type: 'connected', payload: { machineId: machineId } })
// Authoritative node-liveness signal on connect (presence key = source of truth, set on register +
// refreshed by the manager ping). Explicit BOTH ways so a client that connects while the node is
// already up gets `online:true` immediately, not just inferred from the absence of an offline event.
fireAndForget((async () => {
const mgr = await getAgentPresence(machineId)
const online = await machineOnline(machineId, !!mgr)
if (ws.readyState === WebSocket.OPEN) {
send({ type: 'node_status', payload: { online } })
}
})(), 'web-ws seed node_status (legacy)', { machineId })
ws.on('message', (raw: RawData, isBinary: boolean) => {
if (isBinary) {
const parsed = parseTerminalClientFrame(new Uint8Array(raw as Buffer))
// Same client-originated kinds as the current per-user handler above — see its comment.
const rateKey = parsed?.kind === TerminalBinaryKind.input ? 'terminal_input' : 'terminal_paste'
if (parsed && TERMINAL_BINARY_CLIENT_UP_KINDS.has(parsed.kind) && terminalRate.allow(rateKey, parsed.bytes.length)) {
client.sendTerminalDown(parsed.bytes)
}
return
}
let frame: Frame
try { frame = JSON.parse(raw.toString()) as Frame } catch { return }
const type = frame.type as string | undefined
if (typeof type === 'string' && type.startsWith('__')) return
if (typeof type === 'string' && type.startsWith('terminal_') && !TERMINAL_DOWN_TYPES.has(type)) return
if (typeof type === 'string' && TERMINAL_DOWN_TYPES.has(type)) {
const bytes = terminalFrameBytes(frame)
if (bytes > TERMINAL_ENVELOPE_MAX_BYTES || !isEncryptedTerminalFrame(frame) || !terminalRate.allow(type, bytes)) return
}
client.sendDown(frame)
})
let closed = false
const cleanup = (): void => {
if (closed) return // 'error' is followed by 'close': detach (and its __client_disconnected) only once
closed = true
client.detach()
logger.info('web client disconnected (legacy)', { machineId, connId: client.connId })
}
ws.on('close', cleanup)
ws.on('error', cleanup)
}