diff --git a/.plans/trap-sandbox/trap-sandbox-gateway-spec.md b/.plans/trap-sandbox/trap-sandbox-gateway-spec.md index 48cb89b..a8d234e 100644 --- a/.plans/trap-sandbox/trap-sandbox-gateway-spec.md +++ b/.plans/trap-sandbox/trap-sandbox-gateway-spec.md @@ -32,9 +32,9 @@ from Basic-Auth-at-upgrade to an **in-protocol `hello` handshake**, which also p High-level only — each sub-plan owns its granular steps. Tick a milestone when its sub-plan's verification gate is green. -- [ ] **M1 — TS gateway** ([01-ts-gateway](./01-ts-gateway.md)): service builds, listens on 4444, +- [x] **M1 — TS gateway** ([01-ts-gateway](./01-ts-gateway.md)): service builds, listens on 4444, TS unit tests green (`npm test`, `npm run lint`/`check` clean). *No Lua dependency — build first.* -- [ ] **M2 — Lua client** ([02-lua-client](./02-lua-client.md)): `libsandbox`/`servers`/`programs` +- [x] **M2 — Lua client** ([02-lua-client](./02-lua-client.md)): `libsandbox`/`servers`/`programs` in place, Lua unit tests green via `just _craftos-test`. *Implements the Protocol contract.* - [ ] **M3 — Packages** ([03-packages](./03-packages.md)): legacy renamed, new `trapos-sandbox` package + meta bump wired; `packages/index.json` consistent. @@ -57,7 +57,7 @@ Dependency order: **M1 → M2 → M3 → M4**. M3 may proceed in parallel with M | Topic | Decision | |---|---| -| Event shape | Explicit `event` field: `computer_request|computer_response|gateway_request|gateway_response`; `type` = redux action | +| Event shape | Explicit `event` field: `computer_request|computer_response|gateway_request|gateway_response`;`type` = redux action | | Bidi scope | All 4 events now; reverse direction (`gateway_request`→`computer_response`) exercised by tests | | Duplicate trapos id | **Replace existing (newest-wins)** — deliberate deviation from INIT's "reject the duplicate" | | Identity | **Trust the frame** — gateway uses the client-declared `traposId`; no connection↔frame cross-validation | @@ -94,8 +94,9 @@ JSON frames. Computer-originated carry `traposSenderId`; gateway-originated carr ``` - **messageId** is minted by the *initiator*; the reply echoes it. Gateway uses `randomUUID()`; - Lua uses `"-<_G seq>"` (a process-global monotonic seq so concurrent commands - never collide). Each side keys its own pending map by messageId. + Lua mints a **v4 UUID** as well (amended during the 02-lua-client grill — supersedes the + earlier `"-"` format; no `_G` counter needed). `messageId` is **opaque to + the gateway** (echoed, never parsed). Each side keys its own pending map by messageId. - **Error model**: responses carry `ok:boolean` and, when `ok:false`, `error:{ code, message }`. v1 codes: `unauthorized | not_authenticated | unknown_type | not_connected | internal | timeout`. - **Empty-payload gotcha**: CraftOS `textutils.serializeJSON({})` emits `"[]"`. The TS parser treats @@ -104,6 +105,7 @@ JSON frames. Computer-originated carry `traposSenderId`; gateway-originated carr - **Malformed/unknown frames** are ignored (no crash). Each side ignores events it shouldn't receive. ### Handshake (`hello`) — auth + registration + account seam + 1. On socket open, the daemon sends `computer_request type:"hello" payload:{ traposId, secret }`. 2. Gateway validates `secret` against `SANDBOX_PASSWORD` (unset ⇒ auth disabled, any secret OK), runs `resolveAccount(secret) → "local"`, registers `(accountId, traposId)` (**newest-wins**), @@ -113,6 +115,7 @@ JSON frames. Computer-originated carry `traposSenderId`; gateway-originated carr 4. Any non-hello frame before a successful hello → `ok:false error:{code:"not_authenticated"}`. ### Concrete types + - **probe_gateway** (computer→gateway): `computer_request type:"probe_gateway"` → `gateway_response type:"probe_gateway" ok:true payload:{ sandboxOk:true, opencodeOk:boolean, opencodeDetail?:string }`. `sandboxOk` is trivially true; `opencodeOk` from the probe; `opencodeDetail` (url + status/error) @@ -122,15 +125,18 @@ JSON frames. Computer-originated carry `traposSenderId`; gateway-originated carr `computer_response type:"ping" ok:true payload:{ traposId }`. ### os-event contract (Lua, all `trapos_sandbox_`-prefixed) + `trapos_sandbox_send(messageId,type,payload)`, `trapos_sandbox_reply(messageId,ok,payload,error)`, `trapos_sandbox_connected(traposId)`. ## Out of scope / future + - The account model itself (multi-computer-per-account). Seam only: `resolveAccount`, `(accountId, traposId)` keying, structured errors, hello as the credential→account home. - Full opencode integration beyond the health probe; rewriting `ai.lua`; cross-module wiring. ## Known limitations (v1, documented) + - `traposId = id:label` is **not unique across Minecraft servers/worlds** (computer ids reset per world; labels may be empty). With newest-wins, a cross-server collision would evict the other computer. Accepted per INIT ("id+label for now"); the future account model is the real fix. diff --git a/apis/libsandbox.lua b/apis/libsandbox.lua new file mode 100644 index 0000000..3658a11 --- /dev/null +++ b/apis/libsandbox.lua @@ -0,0 +1,333 @@ +-- 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. + +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; + + local url = buildConnectUrl(baseUrl); + local traposId = api.traposId(osLike); + + -- state: connecting | awaiting_hello | ready | unauthorized + local state = 'connecting'; + local lastError = nil; + local activeWs = nil; + + 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'; + httpLike.websocketAsync(url); + end + + local function scheduleReconnect() + activeWs = nil; + if state == 'unauthorized' then + return; + end + state = 'connecting'; + el.setTimeout(connect, reconnectDelay); + end + + local function onHello(ok, _payload, err) + if state ~= 'awaiting_hello' then + return; + end + if ok then + state = 'ready'; + queueEvent('trapos_sandbox_connected', traposId); + else + state = 'unauthorized'; + lastError = (type(err) == 'table' and err.code) or 'unauthorized'; + closeActive(); + end + end + + el.register('websocket_success', function(eventUrl, ws) + if eventUrl ~= url then return; end + activeWs = ws; + state = 'awaiting_hello'; + 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 + scheduleReconnect(); + end); + + el.register('websocket_closed', function(eventUrl) + if eventUrl ~= url then return; 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; diff --git a/packages/trapos-sandbox-legacy/ccpm.json b/packages/trapos-sandbox-legacy/ccpm.json new file mode 100644 index 0000000..288b9a2 --- /dev/null +++ b/packages/trapos-sandbox-legacy/ccpm.json @@ -0,0 +1,16 @@ +{ + "name": "trapos-sandbox", + "version": "0.2.2", + "description": "TrapOS sandbox programs for ccpm experiments and Lua learning", + "dependencies": ["trapos-core"], + "files": [ + "apis/libcarre.lua", + "apis/libmcpcomputer.lua", + "programs/carre.lua", + "programs/creeper.lua", + "programs/mouton.lua", + "programs/mcp-computer.lua", + "servers/mcp-computer-server.lua" + ], + "autostart": ["servers/mcp-computer-server"] +} diff --git a/packages/trapos-sandbox/ccpm.json b/packages/trapos-sandbox/ccpm.json index 288b9a2..51a40e7 100644 --- a/packages/trapos-sandbox/ccpm.json +++ b/packages/trapos-sandbox/ccpm.json @@ -1,16 +1,12 @@ { "name": "trapos-sandbox", - "version": "0.2.2", - "description": "TrapOS sandbox programs for ccpm experiments and Lua learning", + "version": "0.2.3", + "description": "TrapOS sandbox gateway client: WS daemon (servers/sandbox) + sandbox command", "dependencies": ["trapos-core"], "files": [ - "apis/libcarre.lua", - "apis/libmcpcomputer.lua", - "programs/carre.lua", - "programs/creeper.lua", - "programs/mouton.lua", - "programs/mcp-computer.lua", - "servers/mcp-computer-server.lua" + "apis/libsandbox.lua", + "programs/sandbox.lua", + "servers/sandbox.lua" ], - "autostart": ["servers/mcp-computer-server"] + "autostart": ["servers/sandbox"] } diff --git a/programs/sandbox.lua b/programs/sandbox.lua new file mode 100644 index 0000000..4cbc431 --- /dev/null +++ b/programs/sandbox.lua @@ -0,0 +1,55 @@ +local createSandbox = require('/apis/libsandbox'); +local createVersion = require('/apis/libversion'); + +local URL_SETTING = 'sandbox.url'; + +local rawArgs = table.pack(...); +local command = rawArgs[1]; + +local function printUsage() + print('sandbox usage:'); + print(); + print(' sandbox health'); + print(' sandbox --version'); + print(' sandbox --help'); + print(); + print('settings required:'); + print(' sandbox.url (gateway origin, e.g. ws://host:4444)'); + print('settings optional:'); + print(' sandbox.password (shared secret for the hello handshake)'); +end + +if command == '--version' or command == '-version' or command == 'version' or command == '-v' then + print('v' .. createVersion().forSelf()); + return; +end + +if command == '--help' or command == '-help' or command == 'help' or command == '-h' then + printUsage(); + return; +end + +if command == 'health' then + local url = settings.get(URL_SETTING); + if type(url) ~= 'string' or url == '' then + print('set sandbox.url first'); + return; + end + + local ok, result = createSandbox().request('probe_gateway'); + if not ok then + local code = (type(result) == 'table' and result.code) or 'internal'; + local message = (type(result) == 'table' and result.message) or ''; + print('sandbox health failed: ' .. tostring(code) .. ' ' .. tostring(message)); + return; + end + + print('sandboxOk: ' .. tostring(result.sandboxOk)); + print('opencodeOk: ' .. tostring(result.opencodeOk)); + if not result.opencodeOk and result.opencodeDetail then + print('opencode: ' .. tostring(result.opencodeDetail)); + end + return; +end + +printUsage(); diff --git a/servers/sandbox.lua b/servers/sandbox.lua new file mode 100644 index 0000000..89de87c --- /dev/null +++ b/servers/sandbox.lua @@ -0,0 +1,27 @@ +local createSandbox = require('/apis/libsandbox'); +local createVersion = require('/apis/libversion'); + +local URL_SETTING = 'sandbox.url'; +local PASSWORD_SETTING = 'sandbox.password'; + +local url = settings.get(URL_SETTING); +if type(url) ~= 'string' or url == '' then + print('sandbox: ' .. URL_SETTING .. ' not set, daemon inactive.'); + return; +end + +if not http or not http.websocket then + print('sandbox: HTTP/WebSocket unavailable, daemon inactive.'); + return; +end + +local secret = settings.get(PASSWORD_SETTING); + +createSandbox().startSession({ + eventloop = _G.bootEventLoop, + url = url, + secret = secret, + os = os, +}); + +print('sandbox v' .. createVersion().forSelf() .. ' started (' .. url .. ').'); diff --git a/tests/harness/fake-eventloop.lua b/tests/harness/fake-eventloop.lua new file mode 100644 index 0000000..5dc98af --- /dev/null +++ b/tests/harness/fake-eventloop.lua @@ -0,0 +1,98 @@ +-- Shared test fakes for the trapos-sandbox Lua client. +-- +-- Lives under tests/harness/ so runtest.lua does NOT auto-discover it as a test +-- (discoverTests skips subdirectories), while /tests is still mounted so it can +-- be required at /tests/harness/fake-eventloop. + +local M = {}; + +function M.fakeOs(id, label) + return { + getComputerID = function() return id; end, + getComputerLabel = function() return label; end, + }; +end + +-- Fake eventloop: captures registered handlers and scheduled timeouts, and lets +-- tests fire them manually to drive the daemon state machine. +function M.fakeEventloop() + local handlers = {}; + local timers = {}; + local fake = { handlers = handlers, timers = timers }; + + function fake.register(name, fn) + handlers[name] = handlers[name] or {}; + handlers[name][#handlers[name] + 1] = fn; + return function() end; + end + + function fake.setTimeout(fn, seconds) + timers[#timers + 1] = { fn = fn, seconds = seconds }; + return function() end; + end + + function fake.fire(name, ...) + local list = handlers[name]; + if not list then return; end + for _, fn in ipairs(list) do + fn(...); + end + end + + function fake.fireTimer(index) + index = index or #timers; + local t = timers[index]; + if t then t.fn(); end + end + + return fake; +end + +-- Fake websocket: records sent frames (raw strings) and close calls. +function M.fakeWs() + local ws = { sent = {}, closed = false }; + function ws.send(text) ws.sent[#ws.sent + 1] = text; end + function ws.close() ws.closed = true; end + return ws; +end + +-- Fake http: records websocketAsync target URLs. +function M.fakeHttp() + local h = { connects = {} }; + function h.websocketAsync(url) h.connects[#h.connects + 1] = url; end + function h.websocket() return nil; end + return h; +end + +-- Scriptable event primitives for request() tests. `scriptedPulls` is an ordered +-- list of packed events ({ n=?, name, p1, ... }) returned by pullEvent in turn. +function M.fakeEvents(scriptedPulls) + local idx = 0; + local api = { queued = {}, timers = {}, cancelled = {} }; + + function api.queueEvent(name, ...) + api.queued[#api.queued + 1] = table.pack(name, ...); + end + + function api.startTimer(seconds) + api.timers[#api.timers + 1] = seconds; + return 'timer-' .. #api.timers; + end + + function api.cancelTimer(id) + api.cancelled[#api.cancelled + 1] = id; + end + + function api.pullEvent() + idx = idx + 1; + local e = scriptedPulls[idx]; + if not e then + error('fakeEvents: no more scripted events', 0); + end + return table.unpack(e, 1, e.n or #e); + end + + return api; +end + +return M; diff --git a/tests/sandbox.lua b/tests/sandbox.lua new file mode 100644 index 0000000..7c1d0da --- /dev/null +++ b/tests/sandbox.lua @@ -0,0 +1,323 @@ +local createLibTest = require('/apis/libtest'); +local createSandbox = require('/apis/libsandbox'); +local fakes = require('/tests/harness/fake-eventloop'); + +local testlib = createLibTest({ ... }); + +local fakeOs = fakes.fakeOs; +local encode = textutils.serializeJSON; + +-- traposId ---------------------------------------------------------------- + +testlib.test('traposId joins id and label', function() + local sandbox = createSandbox(); + testlib.assertEquals(sandbox.traposId(fakeOs(7, 'base')), '7:base'); +end); + +testlib.test('traposId renders empty/nil label as trailing colon', function() + local sandbox = createSandbox(); + testlib.assertEquals(sandbox.traposId(fakeOs(7, nil)), '7:'); + testlib.assertEquals(sandbox.traposId(fakeOs(8, '')), '8:'); +end); + +-- uuid -------------------------------------------------------------------- + +testlib.test('uuid emits a v4-shaped string', function() + local sandbox = createSandbox(); + local id = sandbox.uuid(); + testlib.assertEquals(string.len(id), 36); + testlib.assertEquals(string.sub(id, 15, 15), '4'); + testlib.assertTrue(string.match(id, '^%x%x%x%x%x%x%x%x%-%x%x%x%x%-4') ~= nil); +end); + +-- envelope builders ------------------------------------------------------- + +testlib.test('buildComputerRequest carries sender id and omits empty payload', function() + local sandbox = createSandbox(); + local frame = sandbox.buildComputerRequest('7:base', 'mid-1', 'probe_gateway', nil); + testlib.assertEquals(frame.event, 'computer_request'); + testlib.assertEquals(frame.traposSenderId, '7:base'); + testlib.assertEquals(frame.messageId, 'mid-1'); + testlib.assertEquals(frame.type, 'probe_gateway'); + testlib.assertEquals(frame.payload, nil); + + local withPayload = sandbox.buildComputerRequest('7:base', 'mid-2', 'x', { a = 1 }); + testlib.assertEquals(withPayload.payload.a, 1); +end); + +testlib.test('buildHello includes secret only when non-empty', function() + local sandbox = createSandbox(); + + local withSecret = sandbox.buildHello('7:base', 'hunter2', 'mid'); + testlib.assertEquals(withSecret.event, 'computer_request'); + testlib.assertEquals(withSecret.type, 'hello'); + testlib.assertEquals(withSecret.traposSenderId, '7:base'); + testlib.assertEquals(withSecret.payload.traposId, '7:base'); + testlib.assertEquals(withSecret.payload.secret, 'hunter2'); + + local noSecret = sandbox.buildHello('7:base', nil, 'mid'); + testlib.assertEquals(noSecret.payload.secret, nil); + local emptySecret = sandbox.buildHello('7:base', '', 'mid'); + testlib.assertEquals(emptySecret.payload.secret, nil); +end); + +-- onMessage dispatch ------------------------------------------------------ + +testlib.test('onMessage replies to a reverse ping', function() + local sandbox = createSandbox(); + local sent = {}; + sandbox.onMessage(encode({ event = 'gateway_request', type = 'ping', messageId = 'g-1' }), { + traposId = '7:base', + send = function(frame) sent[#sent + 1] = frame; end, + }); + + testlib.assertEquals(#sent, 1); + testlib.assertEquals(sent[1].event, 'computer_response'); + testlib.assertEquals(sent[1].type, 'ping'); + testlib.assertEquals(sent[1].ok, true); + testlib.assertEquals(sent[1].messageId, 'g-1'); + testlib.assertEquals(sent[1].traposSenderId, '7:base'); + testlib.assertEquals(sent[1].payload.traposId, '7:base'); +end); + +testlib.test('onMessage rejects unknown reverse type with unknown_type', function() + local sandbox = createSandbox(); + local sent = {}; + sandbox.onMessage(encode({ event = 'gateway_request', type = 'dance', messageId = 'g-2' }), { + traposId = '7:base', + send = function(frame) sent[#sent + 1] = frame; end, + }); + + testlib.assertEquals(#sent, 1); + testlib.assertEquals(sent[1].ok, false); + testlib.assertEquals(sent[1].error.code, 'unknown_type'); +end); + +testlib.test('onMessage drives hello via onHello', function() + local sandbox = createSandbox(); + local calls = {}; + sandbox.onMessage(encode({ event = 'gateway_response', type = 'hello', ok = true, payload = { accountId = 'local' } }), { + onHello = function(ok, payload) calls[#calls + 1] = { ok = ok, payload = payload }; end, + }); + + testlib.assertEquals(#calls, 1); + testlib.assertEquals(calls[1].ok, true); + testlib.assertEquals(calls[1].payload.accountId, 'local'); +end); + +testlib.test('onMessage queues a reply for a non-hello gateway_response', function() + local sandbox = createSandbox(); + local queued = {}; + sandbox.onMessage(encode({ + event = 'gateway_response', type = 'probe_gateway', ok = true, + messageId = 'mid-9', payload = { sandboxOk = true }, + }), { + queueEvent = function(...) queued[#queued + 1] = table.pack(...); end, + }); + + testlib.assertEquals(#queued, 1); + testlib.assertEquals(queued[1][1], 'trapos_sandbox_reply'); + testlib.assertEquals(queued[1][2], 'mid-9'); + testlib.assertEquals(queued[1][3], true); + testlib.assertEquals(queued[1][4].sandboxOk, true); +end); + +testlib.test('onMessage coerces a missing payload to an empty table', function() + local sandbox = createSandbox(); + local queued = {}; + sandbox.onMessage(encode({ event = 'gateway_response', type = 'noop', ok = true, messageId = 'mid-x' }), { + queueEvent = function(...) queued[#queued + 1] = table.pack(...); end, + }); + + testlib.assertEquals(#queued, 1); + testlib.assertEquals(type(queued[1][4]), 'table'); + testlib.assertEquals(next(queued[1][4]), nil); +end); + +testlib.test('onMessage stays silent on malformed frames', function() + local sandbox = createSandbox(); + local sent = 0; + local ctx = { + traposId = '7:base', + send = function() sent = sent + 1; end, + queueEvent = function() sent = sent + 1; end, + onHello = function() sent = sent + 1; end, + }; + sandbox.onMessage('not json', ctx); + sandbox.onMessage(encode({ event = 'computer_request', type = 'ping' }), ctx); + testlib.assertEquals(sent, 0); +end); + +-- startSession wiring ----------------------------------------------------- + +local function startSession(extra) + local sandbox = createSandbox(); + local el = fakes.fakeEventloop(); + local http = fakes.fakeHttp(); + local queued = {}; + local opts = { + eventloop = el, + http = http, + url = 'ws://host:4444', + os = fakeOs(7, 'base'), + queueEvent = function(...) queued[#queued + 1] = table.pack(...); end, + }; + if extra then + for k, v in pairs(extra) do opts[k] = v; end + end + local session = sandbox.startSession(opts); + return { sandbox = sandbox, el = el, http = http, queued = queued, session = session, url = http.connects[1] }; +end + +testlib.test('startSession connects to the /gateway path', function() + local ctx = startSession(); + testlib.assertEquals(ctx.url, 'ws://host:4444/gateway'); +end); + +testlib.test('startSession sends hello on open and goes ready on ack', function() + local ctx = startSession({ secret = 'hunter2' }); + local ws = fakes.fakeWs(); + ctx.el.fire('websocket_success', ctx.url, ws); + + testlib.assertEquals(ctx.session.isReady(), false); + testlib.assertEquals(#ws.sent, 1); + local hello = textutils.unserializeJSON(ws.sent[1]); + testlib.assertEquals(hello.type, 'hello'); + testlib.assertEquals(hello.payload.secret, 'hunter2'); + + ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); + testlib.assertEquals(ctx.session.isReady(), true); + + local connected = ctx.queued[#ctx.queued]; + testlib.assertEquals(connected[1], 'trapos_sandbox_connected'); + testlib.assertEquals(connected[2], '7:base'); +end); + +testlib.test('startSession treats unauthorized hello as terminal', function() + local ctx = startSession(); + local ws = fakes.fakeWs(); + ctx.el.fire('websocket_success', ctx.url, ws); + local timersAfterOpen = #ctx.el.timers; -- hello watchdog + + ctx.el.fire('websocket_message', ctx.url, encode({ + event = 'gateway_response', type = 'hello', ok = false, error = { code = 'unauthorized', message = 'bad secret' }, + })); + + testlib.assertEquals(ctx.session.isReady(), false); + testlib.assertEquals(ctx.session.lastError(), 'unauthorized'); + testlib.assertEquals(ws.closed, true); + + -- A subsequent close must NOT schedule a reconnect. + ctx.el.fire('websocket_closed', ctx.url); + testlib.assertEquals(#ctx.el.timers, timersAfterOpen); +end); + +testlib.test('startSession reconnects on a network drop', function() + local ctx = startSession(); + local ws = fakes.fakeWs(); + ctx.el.fire('websocket_success', ctx.url, ws); + testlib.assertEquals(#ctx.http.connects, 1); + + ctx.el.fire('websocket_closed', ctx.url); + ctx.el.fireTimer(#ctx.el.timers); -- the reconnect timer + testlib.assertEquals(#ctx.http.connects, 2); +end); + +testlib.test('hello watchdog closes a socket stuck awaiting ack', function() + local ctx = startSession(); + local ws = fakes.fakeWs(); + ctx.el.fire('websocket_success', ctx.url, ws); + ctx.el.fireTimer(#ctx.el.timers); -- watchdog fires while still awaiting_hello + testlib.assertEquals(ws.closed, true); +end); + +testlib.test('send handler replies not_connected when not ready', function() + local ctx = startSession(); + ctx.el.fire('trapos_sandbox_send', 'mid-1', 'probe_gateway', nil); + local reply = ctx.queued[#ctx.queued]; + testlib.assertEquals(reply[1], 'trapos_sandbox_reply'); + testlib.assertEquals(reply[2], 'mid-1'); + testlib.assertEquals(reply[3], false); + testlib.assertEquals(reply[5].code, 'not_connected'); +end); + +testlib.test('send handler forwards a request when ready without an immediate reply', function() + local ctx = startSession(); + local ws = fakes.fakeWs(); + ctx.el.fire('websocket_success', ctx.url, ws); + ctx.el.fire('websocket_message', ctx.url, encode({ event = 'gateway_response', type = 'hello', ok = true, payload = {} })); + local queuedBefore = #ctx.queued; + + ctx.el.fire('trapos_sandbox_send', 'mid-2', 'probe_gateway', nil); + + testlib.assertEquals(#ctx.queued, queuedBefore); -- no immediate reply + local frame = textutils.unserializeJSON(ws.sent[#ws.sent]); + testlib.assertEquals(frame.event, 'computer_request'); + testlib.assertEquals(frame.type, 'probe_gateway'); + testlib.assertEquals(frame.messageId, 'mid-2'); +end); + +-- request() --------------------------------------------------------------- + +testlib.test('request resolves on a matching reply', function() + local sandbox = createSandbox(); + local events = fakes.fakeEvents({ + { 'trapos_sandbox_reply', 'mid', true, { sandboxOk = true }, n = 4 }, + }); + local ok, payload = sandbox.request('probe_gateway', nil, { + messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent, + startTimer = events.startTimer, cancelTimer = events.cancelTimer, + }); + + testlib.assertEquals(ok, true); + testlib.assertEquals(payload.sandboxOk, true); + testlib.assertEquals(events.cancelled[1], 'timer-1'); +end); + +testlib.test('request returns the error on a failed reply', function() + local sandbox = createSandbox(); + local events = fakes.fakeEvents({ + { 'trapos_sandbox_reply', 'mid', false, nil, { code = 'unauthorized', message = 'x' }, n = 5 }, + }); + local ok, err = sandbox.request('probe_gateway', nil, { + messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent, + startTimer = events.startTimer, cancelTimer = events.cancelTimer, + }); + + testlib.assertEquals(ok, false); + testlib.assertEquals(err.code, 'unauthorized'); +end); + +testlib.test('request re-queues a mismatched reply and keeps waiting', function() + local sandbox = createSandbox(); + local events = fakes.fakeEvents({ + { 'trapos_sandbox_reply', 'other', true, { y = 2 }, n = 4 }, + { 'trapos_sandbox_reply', 'mid', true, { x = 1 }, n = 4 }, + }); + local ok, payload = sandbox.request('probe_gateway', nil, { + messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent, + startTimer = events.startTimer, cancelTimer = events.cancelTimer, + }); + + testlib.assertEquals(ok, true); + testlib.assertEquals(payload.x, 1); + -- queued[1] is the trapos_sandbox_send; queued[2] is the re-queued reply. + testlib.assertEquals(events.queued[2][1], 'trapos_sandbox_reply'); + testlib.assertEquals(events.queued[2][2], 'other'); +end); + +testlib.test('request times out when the timer fires first', function() + local sandbox = createSandbox(); + local events = fakes.fakeEvents({ + { 'timer', 'timer-1', n = 2 }, + }); + local ok, err = sandbox.request('probe_gateway', nil, { + messageId = 'mid', queueEvent = events.queueEvent, pullEvent = events.pullEvent, + startTimer = events.startTimer, cancelTimer = events.cancelTimer, + }); + + testlib.assertEquals(ok, false); + testlib.assertEquals(err.code, 'timeout'); +end); + +testlib.run();