openvibers/openvibe.bot Commits

Bind browser tasks to origins and cap page content (#397)

OP OpenVibers committed 2 days ago · eac0789e47 · 8 files, +174 −12

* Bind browser commands to session origins and cap page content

* Fence and cap receipt-only browser results

Changes

ddiff --git a/migrations/0035_desk_task_origins.sql b/migrations/0035_desk_task_origins.sql
nnew file mode 100644
iindex 0000000..eeeaa83
---- /dev/null
++++ b/migrations/0035_desk_task_origins.sql
@@@ -0,0 +1,4 @@
++-- phase: expand
++-- The Run session's allowed browser origins are fixed when its desk is leased.
++-- Null is an older unrestricted lease; an empty array permits no network origin.
++ALTER TABLE desk_leases ADD COLUMN IF NOT EXISTS task_origins JSONB;
ddiff --git a/server/api/desk-leases.js b/server/api/desk-leases.js
iindex 3891a82..d86039f 100644
---- a/server/api/desk-leases.js
++++ b/server/api/desk-leases.js
@@@ -1,4 +1,5 @@
'use strict';
++const { parseOrigins } = require('../browser-origins');
/**
* Desk leases for OpenVibe.Run's sessions (contracts 0.223.0, capability bot.desk.session; the Desks plan's D6). Run,
* with a service token naming the session's person in X-OV-Subject, leases one named browser desk on a machine:
@@@ -118,20 +119,25 @@ function router({ domain, apiAuth, hub, config = {}, limit = (req, res, next) =>
r.post('/machines/:id/desk-leases', limit, express.json({ limit: '8kb' }), wrap(async (req, res) => {
const subject = runFor(req);
const body = req.body && typeof req.body === 'object' ? req.body : {};
-- const check = validate('bot.desk-lease-create-request@1', body);
++ let origins;
++ try { origins = parseOrigins(body.origins); } catch (e) { fail(422, 'bot.invalid_input', e.message); }
++ const { origins: _boundary, ...leaseBody } = body;
++ const check = validate('bot.desk-lease-create-request@1', leaseBody);
if (!check.valid) fail(422, 'bot.invalid_input', `invalid lease: ${(check.errors && check.errors[0] && check.errors[0].message) || 'bad value'}`.slice(0, 300));
const u = await machineFor(req, subject);
const owner = `user:${subject}`;
const mine = await domain.deskLeases.forSession(body.session);
if (mine) {
-- if (mine.machine_id === u.device.id && mine.desk === body.desk && mine.owner === owner) return res.status(200).json(mine);
++ const oldOrigins = await domain.deskLeases.origins(mine.machine_id, mine.desk);
++ if (mine.machine_id === u.device.id && mine.desk === body.desk && mine.owner === owner && JSON.stringify(oldOrigins) === JSON.stringify(origins)) return res.status(200).json(mine);
fail(409, 'bot.lease_conflict', 'that session holds another desk');
}
++ if (u.shared && origins !== null) fail(409, 'bot.origin_boundary_unavailable', 'this shared desk cannot enforce a task origin list');
if (!(hub.isOnline && hub.isOnline(u.device.id))) fail(409, 'bot.device_offline', 'the machine is offline');
if ((await room(u.device, u.offered)).free < 1) fail(409, 'bot.machine_busy', 'the machine has no room for another desk right now');
const width = Number.isInteger(body.width) ? body.width : 1280;
const height = Number.isInteger(body.height) ? body.height : 800;
-- const lease = await domain.deskLeases.create({ deviceId: u.device.id, desk: body.desk, session: body.session, owner, width, height, shared: u.shared });
++ const lease = await domain.deskLeases.create({ deviceId: u.device.id, desk: body.desk, session: body.session, owner, width, height, shared: u.shared, origins });
if (!lease) fail(409, 'bot.desk_busy', 'that desk is in use');
// The desk starts now; a machine that refuses it gives the lease back at once.
let result;
@@@ -162,8 +168,10 @@ function router({ domain, apiAuth, hub, config = {}, limit = (req, res, next) =>
const value = req.body && req.body.value && typeof req.body.value === 'object' && !Array.isArray(req.body.value) ? req.body.value : null;
if (!ACTS.has(kind) || !value) fail(422, 'bot.invalid_input', 'kind is screen, input or browser, with a value');
if ('desk' in value) fail(422, 'bot.invalid_input', 'a session acts on its own desk only: leave out desk');
++ if ('origins' in value) fail(422, 'bot.invalid_input', 'a session uses the origin list fixed when its desk was leased');
++ const origins = kind === 'browser' ? await domain.deskLeases.origins(device.id, lease.desk) : null;
const result = await hub.machineCommand(device.id, principalFor(req, subject), subject, {
-- id: req.body.id, kind, value: { ...value, desk: lease.desk }, ms: req.body.ms,
++ id: req.body.id, kind, value: { ...value, desk: lease.desk, ...(origins !== null ? { origins } : {}) }, ms: req.body.ms,
});
res.json({ machine_id: device.id, desk: lease.desk, ...result });
}));
ddiff --git a/server/browser-content.js b/server/browser-content.js
nnew file mode 100644
iindex 0000000..c4f6b7d
---- /dev/null
++++ b/server/browser-content.js
@@@ -0,0 +1,80 @@
++'use strict';
++
++const { randomBytes } = require('crypto');
++const MAX_PAGE = 48 * 1024;
++const MAX_TEXT = 20 * 1024;
++const FENCED = /^<<<ov-text nonce:([0-9a-f]{32}) origin:[^\n]*>>>\n([\s\S]*)\n<<<ov-text-end nonce:\1>>>$/;
++
++function cap(value, bytes) {
++ const text = typeof value === 'string' ? value : '';
++ const raw = Buffer.from(text);
++ if (raw.length <= bytes) return [text, false];
++ const cut = raw.subarray(0, bytes).toString('utf8').replace(/\uFFFD$/, '');
++ return [`${cut}\n…[truncated ${raw.length - bytes} bytes]`, true];
++}
++
++function plain(value) {
++ const m = typeof value === 'string' ? FENCED.exec(value) : null;
++ return m ? m[2] : value;
++}
++
++function originOf(url) {
++ try {
++ const u = new URL(url);
++ return ['http:', 'https:'].includes(u.protocol) && /^[a-z0-9.:[\]-]+$/i.test(u.hostname) ? u.origin : 'unknown';
++ } catch { return 'unknown'; }
++}
++
++function boundPage(data) {
++ if (!data || typeof data !== 'object' || Array.isArray(data)) return data;
++ const nonce = randomBytes(16).toString('hex');
++ const origin = originOf(data.url);
++ const fence = (value) => `<<<ov-text nonce:${nonce} origin:${origin}>>>\n${value}\n<<<ov-text-end nonce:${nonce}>>>`;
++ if (typeof data.text !== 'string') {
++ if (!data.receipt || typeof data.receipt !== 'object') return data;
++ const appeared = Array.isArray(data.receipt.appeared) ? data.receipt.appeared : [];
++ return { done: data.done === true, truncated: appeared.length > 8,
++ receipt: { url_changed: data.receipt.url_changed === true,
++ appeared: appeared.slice(0, 8).map((line) => fence(cap(plain(line), 160)[0])),
++ gone: Number.isInteger(data.receipt.gone) ? Math.max(0, data.receipt.gone) : 0,
++ ...(typeof data.receipt.page === 'string' ? { page: cap(data.receipt.page, 32)[0] } : {}) } };
++ }
++ const [body, bodyCut] = cap(plain(data.text), MAX_TEXT);
++ const [title, titleCut] = cap(data.title, 512);
++ const [url, urlCut] = cap(data.url, 2048);
++ const out = { url, title, text: fence(body), fence: nonce, origin, links: [], elements: [],
++ truncated: Boolean(data.truncated || bodyCut || titleCut || urlCut) };
++ for (const item of Array.isArray(data.links) ? data.links : []) {
++ if (!item || typeof item !== 'object') { out.truncated = true; continue; }
++ const [label, labelCut] = cap(item.text, 160);
++ const [href, hrefCut] = cap(item.href, 2048);
++ out.links.push({ text: label, href });
++ out.truncated ||= labelCut || hrefCut;
++ if (Buffer.byteLength(JSON.stringify(out)) > MAX_PAGE) { out.links.pop(); out.truncated = true; break; }
++ }
++ for (const item of Array.isArray(data.elements) ? data.elements : []) {
++ const [line, lineCut] = cap(item, 256);
++ out.elements.push(line);
++ out.truncated ||= lineCut;
++ if (Buffer.byteLength(JSON.stringify(out)) > MAX_PAGE) { out.elements.pop(); out.truncated = true; break; }
++ }
++ if (data.receipt && typeof data.receipt === 'object' && !Array.isArray(data.receipt)) {
++ const appeared = Array.isArray(data.receipt.appeared) ? data.receipt.appeared : [];
++ out.receipt = { url_changed: data.receipt.url_changed === true,
++ appeared: appeared.slice(0, 8).map((line) => fence(cap(plain(line), 160)[0])),
++ gone: Number.isInteger(data.receipt.gone) ? Math.max(0, data.receipt.gone) : 0,
++ ...(typeof data.receipt.page === 'string' ? { page: cap(data.receipt.page, 32)[0] } : {}) };
++ out.truncated ||= appeared.length > 8;
++ }
++ // Body text is the last adjustable field; preserve both markers if a verbose old Link exceeded the cap.
++ while (Buffer.byteLength(JSON.stringify(out)) > MAX_PAGE && out.elements.length) { out.elements.pop(); out.truncated = true; }
++ while (Buffer.byteLength(JSON.stringify(out)) > MAX_PAGE && out.links.length) { out.links.pop(); out.truncated = true; }
++ if (Buffer.byteLength(JSON.stringify(out)) > MAX_PAGE) {
++ const excess = Buffer.byteLength(JSON.stringify(out)) - MAX_PAGE;
++ out.text = fence(cap(body, Math.max(0, Buffer.byteLength(body) - excess - 256))[0]);
++ out.truncated = true;
++ }
++ return out;
++}
++
++module.exports = { boundPage, MAX_PAGE };
ddiff --git a/server/browser-origins.js b/server/browser-origins.js
nnew file mode 100644
iindex 0000000..04d97fb
---- /dev/null
++++ b/server/browser-origins.js
@@@ -0,0 +1,18 @@
++'use strict';
++
++// Null means the caller did not name a boundary (old clients). An explicit
++// list is exact scheme + host + port; no paths, credentials or wildcards.
++function parseOrigins(raw) {
++ if (raw === undefined) return null;
++ if (!Array.isArray(raw) || raw.length > 32) throw new Error('origins is a list of at most 32 http(s) origins');
++ return raw.map((item) => {
++ if (typeof item !== 'string' || item.length > 2048 || /[\s\u0000-\u001f]/.test(item)) throw new Error('invalid origin');
++ let u;
++ try { u = new URL(item); } catch { throw new Error('invalid origin'); }
++ if (!['http:', 'https:'].includes(u.protocol) || !u.hostname || u.username || u.password ||
++ u.pathname !== '/' || u.search || u.hash || /[^a-z0-9.:[\]-]/i.test(u.hostname)) throw new Error('invalid origin');
++ return u.origin;
++ });
++}
++
++module.exports = { parseOrigins };
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 95e20df..75c8da2 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -923,14 +923,18 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
const deskLeases = {
async get(deviceId, desk) { return leaseOut(await db.maybe('SELECT * FROM desk_leases WHERE device_id = $1 AND desk = $2', [deviceId, desk])); },
async forSession(sessionId) { return leaseOut(await db.maybe('SELECT * FROM desk_leases WHERE session_id = $1', [sessionId])); },
++ async origins(deviceId, desk) {
++ const row = await db.maybe('SELECT task_origins FROM desk_leases WHERE device_id = $1 AND desk = $2', [deviceId, desk]);
++ return row && row.task_origins != null ? (typeof row.task_origins === 'string' ? JSON.parse(row.task_origins) : row.task_origins) : null;
++ },
async count(deviceId) { return Number((await db.one('SELECT count(*)::int AS n FROM desk_leases WHERE device_id = $1', [deviceId])).n); },
async list(deviceId) { return (await db.many('SELECT * FROM desk_leases WHERE device_id = $1 ORDER BY created_at', [deviceId])).map(leaseOut); },
/** The lease, or null when the desk is someone else's already (the caller answers 409). */
-- async create({ deviceId, desk, session, owner, width, height, shared }) {
++ async create({ deviceId, desk, session, owner, width, height, shared, origins = null }) {
const r = await db.maybe(
-- `INSERT INTO desk_leases (device_id, desk, session_id, owner_subject, kind, width, height, shared, created_at)
-- VALUES ($1, $2, $3, $4, 'browser', $5, $6, $7, $8) ON CONFLICT DO NOTHING RETURNING *`,
-- [deviceId, desk, session, owner, width, height, !!shared, iso(now())]);
++ `INSERT INTO desk_leases (device_id, desk, session_id, owner_subject, kind, width, height, shared, task_origins, created_at)
++ VALUES ($1, $2, $3, $4, 'browser', $5, $6, $7, $8, $9) ON CONFLICT DO NOTHING RETURNING *`,
++ [deviceId, desk, session, owner, width, height, !!shared, origins === null ? null : JSON.stringify(origins), iso(now())]);
return leaseOut(r);
},
async remove(deviceId, desk) { return leaseOut(await db.maybe('DELETE FROM desk_leases WHERE device_id = $1 AND desk = $2 RETURNING *', [deviceId, desk])); },
ddiff --git a/server/realtime.js b/server/realtime.js
iindex c50085f..53f0ab0 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -48,6 +48,8 @@ const DEVICE_MAX_PAYLOAD = 640 * 1024;
// 0.214.0, Link v0.15.0) starts, stops or reports the machine's private desk; screen, input and browser (and the live
// screen) act there with `desk: private`, never on the owner's own screen.
const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser', desk: 'desk', ai: 'ai' };
++const { parseOrigins } = require('./browser-origins');
++const { boundPage } = require('./browser-content');
const NO_DESK = 'this machine has no private desk: it needs OpenVibe Link v0.15.0 or newer on Linux, with Xvfb and allow_desk on';
const NO_NAMED_DESKS = 'this machine runs one desk only (private): several named desks need OpenVibe Link v0.19.0 or newer';
const NO_DESK_PROGRAMS = 'running a program on a desk needs OpenVibe Link v0.26.0 or newer (an older one would run it on the person\'s own screen)';
@@@ -518,7 +520,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
// no data back), so 4096 cached results never hold 4096 screens.
cacheResult(p.key, { result, reason: msg.fault_code || null });
await domain.audit.record({ robotId: p.robotId, deviceId: p.deviceId, subject: p.subject, operatorKind: p.operatorKind, role: p.role, kind: p.kind, value: p.value, result, reason: msg.fault_code || null, latencyMs: latency });
-- const data = p.computer && result === 'ack' && msg.data && typeof msg.data === 'object' && !Array.isArray(msg.data) ? msg.data : null;
++ const rawData = p.computer && result === 'ack' && msg.data && typeof msg.data === 'object' && !Array.isArray(msg.data) ? msg.data : null;
++ const data = rawData && p.kind === 'browser' ? boundPage(rawData) : rawData;
const message = p.computer && result === 'nack' && typeof msg.message === 'string' ? msg.message.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 300) : null;
sendFrame(p.conn, 'command_result', { id: p.opId, result, reason: message || msg.fault_code || null, latency_ms: latency, ...(data ? { data } : {}) });
}
@@@ -806,15 +809,23 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
};
const screen = msg.screen === true && kind === SCREEN_KIND;
if (!screen && !COMPUTER_CAP[kind]) return refuse('bot.invalid_input', `kind is ${COMPUTER_KINDS.join(', ')}`);
++ if (kind === 'browser' && Object.hasOwn(value, 'origins')) {
++ try { parseOrigins(value.origins); } catch (e) { return refuse('bot.invalid_input', e.message); }
++ }
const profileAction = kind === 'browser' && ['profile_export', 'profile_import', 'profile_sign_out'].includes(value.action);
if (profileAction) {
const lease = /^desk-[a-z0-9][a-z0-9-]{0,23}$/.test(value.desk || '') ? await domain.deskLeases.get(deviceId, value.desk) : null;
if (conn.principal.kind !== 'service' || conn.principal.sub !== 'svc:run' || !lease || lease.owner !== `user:${subject}` || !capabilities.grants(conn.principal.cap, 'bot.desk.session')) return refuse('bot.forbidden', 'Saved sign-ins belong to Run and its leased desk.');
-- const keys = value.action === 'profile_import' ? ['action', 'desk', 'profile'] : value.action === 'profile_sign_out' ? ['action', 'desk', 'site'] : ['action', 'desk'];
++ const keys = value.action === 'profile_import' ? ['action', 'desk', 'profile', 'origins'] : value.action === 'profile_sign_out' ? ['action', 'desk', 'site', 'origins'] : ['action', 'desk', 'origins'];
if (Object.keys(value).some(k => !keys.includes(k)) || JSON.stringify(value).length > 48000 || (value.action === 'profile_import' && (!value.profile || !Array.isArray(value.profile.cookies) || !Array.isArray(value.profile.origins))) || (value.action === 'profile_sign_out' && !/^[a-z0-9.-]{1,253}$/.test(value.site || ''))) return refuse('bot.invalid_input', 'Invalid saved sign-ins.');
}
if (!screen && !profileAction) {
-- const check = validate('bot.command@1', { v: 1, seq: 0, ts: 0, type: 'command', id, kind, value });
++ let checkedValue = value;
++ if (kind === 'browser' && Object.hasOwn(value, 'origins')) {
++ const { origins: _boundary, ...rest } = value;
++ checkedValue = rest; // the released contract predates the additive task boundary
++ }
++ const check = validate('bot.command@1', { v: 1, seq: 0, ts: 0, type: 'command', id, kind, value: checkedValue });
if (!check.valid) return refuse('bot.invalid_input', `invalid ${kind}: ${(check.errors && check.errors[0] && (check.errors[0].message || JSON.stringify(check.errors[0]))) || 'bad value'}`.slice(0, 300));
}
const summary = screen
ddiff --git a/test/desk-leases.test.js b/test/desk-leases.test.js
iindex ed6e4bb..47b1a3d 100644
---- a/test/desk-leases.test.js
++++ b/test/desk-leases.test.js
@@@ -87,6 +87,8 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(bad.status, 422, 'never main or private');
const notShared = await t.call('POST', `/api/v1/machines/${plain.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { ...body, session: SES(4) } });
assert.strictEqual(notShared.status, 404, 'a host that does not sandbox its desks is never leased to someone else');
++ const bounded = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { ...body, session: SES(9), desk: 'desk-s09', origins: ['https://example.org'] } });
++ assert.deepStrictEqual([bounded.status, bounded.json.code], [409, 'bot.origin_boundary_unavailable'], 'a shared sandbox cannot silently skip the origin boundary');
});
await check('commands reach the leased desk alone, for its person alone', async () => {
@@@ -138,8 +140,18 @@ const { boot, check, done } = require('./helpers/app');
await t.domain.machines.setSharedDesks(host.deviceId, false);
const r = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { session: SES(7), desk: 'desk-s07', kind: 'browser' } });
assert.strictEqual(r.status, 404);
-- const own2 = await t.call('POST', `/api/v1/machines/${own.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { session: SES(8), desk: 'desk-s08', kind: 'browser' } });
++ const body = { session: SES(8), desk: 'desk-s08', kind: 'browser', origins: ['https://example.org'] };
++ const own2 = await t.call('POST', `/api/v1/machines/${own.deviceId}/desk-leases`, { ...asRun(bob.subject), body });
assert.deepStrictEqual([own2.status, own2.json.shared], [201, false], 'a person\'s own machine, where agents may work');
++ assert.equal((await t.call('POST', `/api/v1/machines/${own.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { ...body, origins: [] } })).status, 409, 'a retry cannot widen or change the boundary');
++ const url = `/api/v1/machines/${own.deviceId}/desk-leases/desk-s08/commands`;
++ const action = await t.call('POST', url, { ...asRun(bob.subject), body: { kind: 'browser', value: { action: 'open', url: 'https://example.org/' } } });
++ assert.equal(action.status, 200, action.text);
++ assert.deepStrictEqual(seen.at(-1).value.origins, ['https://example.org'], 'Bot attaches the stored list to every browser command');
++ const profile = await t.call('POST', url, { ...asRun(bob.subject), body: { kind: 'browser', value: { action: 'profile_export' } } });
++ assert.equal(profile.status, 200, profile.text);
++ assert.deepStrictEqual(seen.at(-1).value.origins, ['https://example.org']);
++ assert.equal((await t.call('POST', url, { ...asRun(bob.subject), body: { kind: 'browser', value: { action: 'read', origins: [] } } })).status, 422, 'the caller cannot change the list per command');
});
for (const m of [host, plain, own]) m.c.close();
ddiff --git a/test/machine-computer.test.js b/test/machine-computer.test.js
iindex 7e9bb31..469acbf 100644
---- a/test/machine-computer.test.js
++++ b/test/machine-computer.test.js
@@@ -7,6 +7,7 @@
const assert = require('assert');
const { boot, check, done } = require('./helpers/app');
const { withDelivery } = require('../server/command-delivery');
++const { boundPage, MAX_PAGE } = require('../server/browser-content');
(async () => {
await check('a timeout or in-flight repeat never claims a safe retry', async () => {
@@@ -95,6 +96,8 @@ const { withDelivery } = require('../server/command-delivery');
await check('the browser kind reaches the machine; a file: address is refused before it leaves', async () => {
const open = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'open', url: 'https://example.org/account?token=abc' } }, { user: alex });
assert.deepStrictEqual([open.status, open.json.result, open.json.data.title], [200, 'ack', 'Example']);
++ assert.match(open.json.data.text, /^<<<ov-text nonce:[0-9a-f]{32} origin:https:\/\/example.org>>>\nExample page\n<<<ov-text-end nonce:[0-9a-f]{32}>>>$/);
++ assert.equal(open.json.data.origin, 'https://example.org');
const fill = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'fill', selector: '#password', text: 'hunter3 secret' } }, { user: alex });
assert.strictEqual(fill.json.result, 'ack');
assert.deepStrictEqual(seen.filter((m) => m.kind === 'browser').map((m) => m.value.action), ['open', 'fill']);
@@@ -104,6 +107,28 @@ const { withDelivery } = require('../server/command-delivery');
assert.deepStrictEqual(rows.map((r) => r.value), [{ action: 'open', host: 'example.org' }, { action: 'fill', selector: '#password', chars: 14 }],
'the audit keeps the host (never the path or query) and how much was typed (never the text)');
assert.ok(!JSON.stringify(rows).includes('hunter3') && !JSON.stringify(rows).includes('token=abc'));
++ const bounded = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'read', origins: ['https://example.org'] } }, { user: alex });
++ assert.equal(bounded.status, 200, bounded.text);
++ assert.deepStrictEqual(seen.at(-1).value.origins, ['https://example.org']);
++ const invalidOrigins = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'read', origins: ['https://example.org/path'] } }, { user: alex });
++ assert.deepStrictEqual([invalidOrigins.status, invalidOrigins.json.code], [422, 'bot.invalid_input']);
++ });
++
++ await check('a verbose page is bounded before it reaches a caller', async () => {
++ const out = boundPage({ url: 'https://example.org/', title: 'A', text: 'é'.repeat(100000),
++ links: Array.from({ length: 60 }, () => ({ text: 'x', href: 'https://example.org/' + 'y'.repeat(5000) })),
++ elements: Array(120).fill('e1 button ' + 'z'.repeat(500)),
++ untrusted_extra: 'x'.repeat(200000), receipt: { appeared: ['a'.repeat(200000)], extra: 'y'.repeat(200000) } });
++ assert.equal(out.truncated, true);
++ assert.ok(Buffer.byteLength(JSON.stringify(out)) <= MAX_PAGE);
++ assert.equal(out.untrusted_extra, undefined);
++ assert.equal(out.receipt.extra, undefined);
++ assert.match(out.text, /<<<ov-text-end nonce:[0-9a-f]{32}>>>$/);
++ const receiptOnly = boundPage({ done: true, receipt: { appeared: ['a'.repeat(200000)], gone: 1, extra: 'x'.repeat(200000) } });
++ assert.ok(Buffer.byteLength(JSON.stringify(receiptOnly)) <= MAX_PAGE);
++ assert.equal(receiptOnly.receipt.gone, 1);
++ assert.equal(receiptOnly.receipt.extra, undefined);
++ assert.match(receiptOnly.receipt.appeared[0], /<<<ov-text-end nonce:[0-9a-f]{32}>>>$/);
});
await check('the private desk (contracts 0.214.0): start, stop and status reach a machine that has one, and desk: private acts there; one without says so', async () => {