-- trapos-sandbox Lua client. -- -- Implements the Lua side of the trap-sandbox WS gateway protocol (see -- .plans/trap-sandbox/trap-sandbox-gateway-spec.md). A boot daemon owns one -- persistent websocket connection (startSession) and bridges WS frames to in-OS -- `trapos_sandbox_*` events; programs talk to the daemon through those events via -- request(). Both run as separate coroutines under the same Lua state, so they -- share the single os event queue. -- Server-side application close code emitted when a newer registration displaces an -- older one (see trap-sandbox registry). Treated as terminal: the loser yields rather -- than reconnecting, so two daemons sharing a traposId collapse to one (no eviction loop). local DISPLACED_CLOSE_CODE = 4000; local function defaultOs() return os; end local function isNonEmptyString(value) return type(value) == 'string' and value ~= ''; end -- Empty-payload tolerance: a missing / non-table payload is treated as {}. The -- TS side emits "[]" for an empty object, which decodes to an empty Lua table. local function coercePayload(payload) if type(payload) ~= 'table' then return {}; end return payload; end -- v4 UUID; `random` is injectable for deterministic tests. local function makeUuid(random) random = random or math.random; return (string.gsub('xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx', '[xy]', function(c) local value = (c == 'x') and random(0, 15) or random(8, 11); return string.format('%x', value); end)); end local function buildConnectUrl(base) local trimmed = (string.gsub(base, '/+$', '')); return trimmed .. '/gateway'; end local function createSandbox() local api = {}; api.uuid = makeUuid; function api.traposId(osLike) osLike = osLike or defaultOs(); return tostring(osLike.getComputerID()) .. ':' .. (osLike.getComputerLabel() or ''); end -- Envelope builders. computer_* frames carry traposSenderId; gateway_* frames -- (built only for tests/symmetry) carry traposReceiverId. payload is omitted -- when empty so the wire stays clean and matches the empty-payload contract. function api.buildComputerRequest(traposSenderId, messageId, msgType, payload) local frame = { event = 'computer_request', traposSenderId = traposSenderId, messageId = messageId, type = msgType, }; if type(payload) == 'table' and next(payload) ~= nil then frame.payload = payload; end return frame; end function api.buildComputerResponse(traposSenderId, messageId, msgType, ok, payload, err) local frame = { event = 'computer_response', traposSenderId = traposSenderId, messageId = messageId, type = msgType, ok = ok, }; if type(payload) == 'table' and next(payload) ~= nil then frame.payload = payload; end if err ~= nil then frame.error = err; end return frame; end function api.buildGatewayRequest(traposReceiverId, messageId, msgType, payload) local frame = { event = 'gateway_request', traposReceiverId = traposReceiverId, messageId = messageId, type = msgType, }; if type(payload) == 'table' and next(payload) ~= nil then frame.payload = payload; end return frame; end function api.buildGatewayResponse(traposReceiverId, messageId, msgType, ok, payload, err) local frame = { event = 'gateway_response', traposReceiverId = traposReceiverId, messageId = messageId, type = msgType, ok = ok, }; if type(payload) == 'table' and next(payload) ~= nil then frame.payload = payload; end if err ~= nil then frame.error = err; end return frame; end -- The hello handshake frame: a computer_request carrying the registration -- identity (payload.traposId) and, when set, the shared secret. function api.buildHello(traposId, secret, messageId) local payload = { traposId = traposId }; if isNonEmptyString(secret) then payload.secret = secret; end return api.buildComputerRequest(traposId, messageId, 'hello', payload); end -- Decode and dispatch one inbound frame. Side effects are injected via ctx so -- onMessage stays unit-testable in isolation: -- ctx = { traposId, decode, send=fn(frame), queueEvent=fn, onHello=fn(ok,payload,error) } function api.onMessage(content, ctx) ctx = ctx or {}; local decode = ctx.decode or textutils.unserializeJSON; local ok, frame = pcall(decode, content); if not ok or type(frame) ~= 'table' then return; end if frame.event == 'gateway_request' then -- Reverse direction: built-in `ping` only; everything else is unknown_type. local response; if frame.type == 'ping' then response = api.buildComputerResponse( ctx.traposId, frame.messageId, 'ping', true, { traposId = ctx.traposId }, nil ); else response = api.buildComputerResponse( ctx.traposId, frame.messageId, frame.type, false, nil, { code = 'unknown_type', message = 'unknown type: ' .. tostring(frame.type) } ); end if ctx.send then ctx.send(response); end return; end if frame.event == 'gateway_response' then if frame.type == 'hello' then if ctx.onHello then ctx.onHello(frame.ok == true, coercePayload(frame.payload), frame.error); end elseif ctx.queueEvent then ctx.queueEvent( 'trapos_sandbox_reply', frame.messageId, frame.ok == true, coercePayload(frame.payload), frame.error ); end return; end -- Ignore everything else (frames we should never receive, malformed events). end -- Boot daemon: own one persistent connection, bridge WS<->os-events. Returns -- live status getters. Injectables (eventloop/http/encode/decode/os/queueEvent/ -- random + delays) keep it testable. function api.startSession(opts) opts = opts or {}; local el = assert(opts.eventloop, 'startSession requires an eventloop'); local baseUrl = assert(opts.url, 'startSession requires a url'); local osLike = opts.os or defaultOs(); local httpLike = opts.http or http; local encode = opts.encode or textutils.serializeJSON; local decode = opts.decode or textutils.unserializeJSON; local queueEvent = opts.queueEvent or function(...) return osLike.queueEvent(...); end; local reconnectDelay = opts.reconnectDelay or 5; local helloTimeout = opts.helloTimeout or 10; local secret = opts.secret; -- Lifecycle logger: log(level, msg, fields?). Defaults to a no-op so the daemon -- stays silent unless the caller wires a sink (e.g. a /logs file appender). local log = opts.log or function() end; local url = buildConnectUrl(baseUrl); local traposId = api.traposId(osLike); -- state: connecting | awaiting_hello | ready | unauthorized | displaced -- (unauthorized and displaced are terminal: no reconnect.) local state = 'connecting'; local lastError = nil; local activeWs = nil; -- True while looping on retries. Gates the per-attempt chatter (connecting / socket -- open / connection failed / connection closed) so a long outage logs once on entry -- and once on recovery instead of flooding the file every reconnectDelay seconds. local reconnecting = false; local function sendFrame(frame) if not activeWs then return false; end return pcall(function() activeWs.send(encode(frame)); end); end local function closeActive() if activeWs then pcall(function() activeWs.close(); end); end activeWs = nil; end local function connect() state = 'connecting'; if not reconnecting then log('info', 'connecting', { url = url }); end httpLike.websocketAsync(url); end local function scheduleReconnect() activeWs = nil; if state == 'unauthorized' or state == 'displaced' then return; end state = 'connecting'; -- One line on the false->true transition, then silence: every later retry runs -- through here too, but we keep trying without re-logging. if not reconnecting then reconnecting = true; log('warn', 'reconnect loop started', { delaySeconds = reconnectDelay }); end el.setTimeout(connect, reconnectDelay); end local function onHello(ok, _payload, err) if state ~= 'awaiting_hello' then return; end if ok then state = 'ready'; if reconnecting then reconnecting = false; log('info', 'reconnect loop ended; reconnected', { traposId = traposId }); else log('info', 'connected', { traposId = traposId }); end queueEvent('trapos_sandbox_connected', traposId); else state = 'unauthorized'; lastError = (type(err) == 'table' and err.code) or 'unauthorized'; log('warn', 'hello rejected; yielding', { code = lastError }); closeActive(); end end el.register('websocket_success', function(eventUrl, ws) if eventUrl ~= url then return; end activeWs = ws; state = 'awaiting_hello'; if not reconnecting then log('info', 'socket open; sending hello', { traposId = traposId }); end sendFrame(api.buildHello(traposId, secret, makeUuid(opts.random))); -- hello-ack watchdog: state-guarded, no stored cancel. If hello already -- resolved this is a harmless no-op; closing triggers reconnect. el.setTimeout(function() if state == 'awaiting_hello' then closeActive(); end end, helloTimeout); end); el.register('websocket_message', function(eventUrl, content) if eventUrl ~= url then return; end api.onMessage(content, { traposId = traposId, decode = decode, send = sendFrame, queueEvent = queueEvent, onHello = onHello, }); end); el.register('websocket_failure', function(eventUrl) if eventUrl ~= url then return; end if not reconnecting then log('warn', 'connection failed', { url = url }); end scheduleReconnect(); end); -- CC:T passes (url, reason, code). A displacement close (code 4000) is only -- terminal when it hits our *live* (ready) socket: another computer registered -- our traposId, so we yield rather than reconnect (reconnecting would just -- displace it back, an endless ping-pong). A displacement that arrives while we -- are still connecting/awaiting_hello is for a socket our own reconnect has -- already superseded -- ignoring it keeps the healthy new socket alive instead -- of bricking the daemon into a permanent 'displaced' state. el.register('websocket_closed', function(eventUrl, reason, code) if eventUrl ~= url then return; end if code == DISPLACED_CLOSE_CODE or reason == 'displaced' then if state == 'ready' then activeWs = nil; state = 'displaced'; lastError = 'displaced'; log('warn', 'displaced by a newer registration; yielding', { traposId = traposId }); else log('info', 'ignoring stale displacement during reconnect', { traposId = traposId }); end return; end if not reconnecting then log('info', 'connection closed', { code = code, reason = reason }); end scheduleReconnect(); end); -- Bridge program requests onto the wire. Only emit an immediate reply on -- failure; on success the real reply arrives later as a gateway_response. el.register('trapos_sandbox_send', function(messageId, msgType, payload) if state ~= 'ready' then queueEvent('trapos_sandbox_reply', messageId, false, nil, { code = 'not_connected', message = 'sandbox daemon not connected' }); return; end local sent = sendFrame(api.buildComputerRequest(traposId, messageId, msgType, payload)); if not sent then queueEvent('trapos_sandbox_reply', messageId, false, nil, { code = 'not_connected', message = 'sandbox send failed' }); end end); connect(); return { isReady = function() return state == 'ready'; end, lastError = function() return lastError; end, }; end -- Program-side blocking request: queue a send, then wait (unfiltered pull, to -- catch both the reply and our own timer) for a matching reply or timeout. function api.request(msgType, payload, opts) opts = opts or {}; local queueEvent = opts.queueEvent or os.queueEvent; local pullEvent = opts.pullEvent or os.pullEvent; local startTimer = opts.startTimer or os.startTimer; local cancelTimer = opts.cancelTimer or os.cancelTimer; local timeout = opts.timeout or 10; local messageId = opts.messageId or makeUuid(opts.random); queueEvent('trapos_sandbox_send', messageId, msgType, payload); local timerId = startTimer(timeout); while true do local event = table.pack(pullEvent()); local name = event[1]; if name == 'timer' and event[2] == timerId then return false, { code = 'timeout', message = 'request timed out' }; elseif name == 'trapos_sandbox_reply' then if event[2] == messageId then cancelTimer(timerId); if event[3] then return true, coercePayload(event[4]); end return false, event[5] or { code = 'internal', message = 'request failed' }; end -- Not ours: re-queue so a concurrent waiter can claim it. queueEvent('trapos_sandbox_reply', event[2], event[3], event[4], event[5]); end -- Ignore any other event and keep waiting. end end return api; end return createSandbox;