feat(sandbox): add gateway Lua client
This commit is contained in:
parent
cd6427c454
commit
6666b6955a
@ -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
|
High-level only — each sub-plan owns its granular steps. Tick a milestone when its sub-plan's
|
||||||
verification gate is green.
|
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.*
|
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.*
|
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`
|
- [ ] **M3 — Packages** ([03-packages](./03-packages.md)): legacy renamed, new `trapos-sandbox`
|
||||||
package + meta bump wired; `packages/index.json` consistent.
|
package + meta bump wired; `packages/index.json` consistent.
|
||||||
@ -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()`;
|
- **messageId** is minted by the *initiator*; the reply echoes it. Gateway uses `randomUUID()`;
|
||||||
Lua uses `"<os.epoch('utc')>-<_G seq>"` (a process-global monotonic seq so concurrent commands
|
Lua mints a **v4 UUID** as well (amended during the 02-lua-client grill — supersedes the
|
||||||
never collide). Each side keys its own pending map by messageId.
|
earlier `"<os.epoch('utc')>-<seq>"` 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 }`.
|
- **Error model**: responses carry `ok:boolean` and, when `ok:false`, `error:{ code, message }`.
|
||||||
v1 codes: `unauthorized | not_authenticated | unknown_type | not_connected | internal | timeout`.
|
v1 codes: `unauthorized | not_authenticated | unknown_type | not_connected | internal | timeout`.
|
||||||
- **Empty-payload gotcha**: CraftOS `textutils.serializeJSON({})` emits `"[]"`. The TS parser treats
|
- **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.
|
- **Malformed/unknown frames** are ignored (no crash). Each side ignores events it shouldn't receive.
|
||||||
|
|
||||||
### Handshake (`hello`) — auth + registration + account seam
|
### Handshake (`hello`) — auth + registration + account seam
|
||||||
|
|
||||||
1. On socket open, the daemon sends `computer_request type:"hello" payload:{ traposId, secret }`.
|
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),
|
2. Gateway validates `secret` against `SANDBOX_PASSWORD` (unset ⇒ auth disabled, any secret OK),
|
||||||
runs `resolveAccount(secret) → "local"`, registers `(accountId, traposId)` (**newest-wins**),
|
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"}`.
|
4. Any non-hello frame before a successful hello → `ok:false error:{code:"not_authenticated"}`.
|
||||||
|
|
||||||
### Concrete types
|
### Concrete types
|
||||||
|
|
||||||
- **probe_gateway** (computer→gateway): `computer_request type:"probe_gateway"` → `gateway_response
|
- **probe_gateway** (computer→gateway): `computer_request type:"probe_gateway"` → `gateway_response
|
||||||
type:"probe_gateway" ok:true payload:{ sandboxOk:true, opencodeOk:boolean, opencodeDetail?:string }`.
|
type:"probe_gateway" ok:true payload:{ sandboxOk:true, opencodeOk:boolean, opencodeDetail?:string }`.
|
||||||
`sandboxOk` is trivially true; `opencodeOk` from the probe; `opencodeDetail` (url + status/error)
|
`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 }`.
|
`computer_response type:"ping" ok:true payload:{ traposId }`.
|
||||||
|
|
||||||
### os-event contract (Lua, all `trapos_sandbox_`-prefixed)
|
### os-event contract (Lua, all `trapos_sandbox_`-prefixed)
|
||||||
|
|
||||||
`trapos_sandbox_send(messageId,type,payload)`, `trapos_sandbox_reply(messageId,ok,payload,error)`,
|
`trapos_sandbox_send(messageId,type,payload)`, `trapos_sandbox_reply(messageId,ok,payload,error)`,
|
||||||
`trapos_sandbox_connected(traposId)`.
|
`trapos_sandbox_connected(traposId)`.
|
||||||
|
|
||||||
## Out of scope / future
|
## Out of scope / future
|
||||||
|
|
||||||
- The account model itself (multi-computer-per-account). Seam only: `resolveAccount`, `(accountId,
|
- The account model itself (multi-computer-per-account). Seam only: `resolveAccount`, `(accountId,
|
||||||
traposId)` keying, structured errors, hello as the credential→account home.
|
traposId)` keying, structured errors, hello as the credential→account home.
|
||||||
- Full opencode integration beyond the health probe; rewriting `ai.lua`; cross-module wiring.
|
- Full opencode integration beyond the health probe; rewriting `ai.lua`; cross-module wiring.
|
||||||
|
|
||||||
## Known limitations (v1, documented)
|
## Known limitations (v1, documented)
|
||||||
|
|
||||||
- `traposId = id:label` is **not unique across Minecraft servers/worlds** (computer ids reset per
|
- `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
|
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.
|
computer. Accepted per INIT ("id+label for now"); the future account model is the real fix.
|
||||||
|
|||||||
333
apis/libsandbox.lua
Normal file
333
apis/libsandbox.lua
Normal file
@ -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;
|
||||||
16
packages/trapos-sandbox-legacy/ccpm.json
Normal file
16
packages/trapos-sandbox-legacy/ccpm.json
Normal file
@ -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"]
|
||||||
|
}
|
||||||
@ -1,16 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "trapos-sandbox",
|
"name": "trapos-sandbox",
|
||||||
"version": "0.2.2",
|
"version": "0.2.3",
|
||||||
"description": "TrapOS sandbox programs for ccpm experiments and Lua learning",
|
"description": "TrapOS sandbox gateway client: WS daemon (servers/sandbox) + sandbox command",
|
||||||
"dependencies": ["trapos-core"],
|
"dependencies": ["trapos-core"],
|
||||||
"files": [
|
"files": [
|
||||||
"apis/libcarre.lua",
|
"apis/libsandbox.lua",
|
||||||
"apis/libmcpcomputer.lua",
|
"programs/sandbox.lua",
|
||||||
"programs/carre.lua",
|
"servers/sandbox.lua"
|
||||||
"programs/creeper.lua",
|
|
||||||
"programs/mouton.lua",
|
|
||||||
"programs/mcp-computer.lua",
|
|
||||||
"servers/mcp-computer-server.lua"
|
|
||||||
],
|
],
|
||||||
"autostart": ["servers/mcp-computer-server"]
|
"autostart": ["servers/sandbox"]
|
||||||
}
|
}
|
||||||
|
|||||||
55
programs/sandbox.lua
Normal file
55
programs/sandbox.lua
Normal file
@ -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();
|
||||||
27
servers/sandbox.lua
Normal file
27
servers/sandbox.lua
Normal file
@ -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 .. ').');
|
||||||
98
tests/harness/fake-eventloop.lua
Normal file
98
tests/harness/fake-eventloop.lua
Normal file
@ -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;
|
||||||
323
tests/sandbox.lua
Normal file
323
tests/sandbox.lua
Normal file
@ -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();
|
||||||
Loading…
Reference in New Issue
Block a user