openvibers/openvibe.bot Commits

Add the owner's embedpublic flag and a validated BOTEMBEDORIGINS list

OP OpenVibers committed 4 Oct 2026 · 6567b8a3ec · 9 files, +206 −6

Asked: Part of a cross-repository initiative (init-t15-step-r9-first-half-dossi-1004T1836): T15 step R9, first half (dossier /home/workstation/openvibe/agents/ds/prep/T15.md row R9; gated on R8b, now merged as Bot "Add joystick, gamepad, latency meter, camera tiles and panel design"): a Live channel page …
Harness job init-t15-step-r9-firs-1836-s1-183934 (plan T15).

Changes

ddiff --git a/.env.example b/.env.example
iindex f6c3114..1ad281e 100644
---- a/.env.example
++++ b/.env.example
@@@ -86,3 +86,10 @@ OV_OAUTH_CLIENT_SECRET=
#BOT_BILLING_TOKEN=
#BOT_BILLING_INTERVAL_MS=2000
#BOT_BILLING_TIMEOUT_MS=5000
++
++# ── Embeddable panel (plan T15 R9) ──────────────────────────
++# Pages that may frame a robot's read-only panel (CSP frame-ancestors), comma or space separated. Each entry is a
++# bare https origin (no path, query or wildcard); http://localhost:<port> is accepted outside production only.
++# An invalid entry stops the service at boot. Default: OpenVibe.Live. A robot is embeddable only after its owner
++# turns on "embed_public" on its panel.
++#BOT_EMBED_ORIGINS=https://openvibe.live,https://www.openvibe.live
ddiff --git a/README.md b/README.md
iindex d3e9a87..9df6e74 100644
---- a/README.md
++++ b/README.md
@@@ -63,6 +63,8 @@ To run the schema migrations before the service serves, start it once with the d
Set `BOT_PAIRING_AUTHORITY` to `bot` (the default) or `network`; any other value refuses to boot.
++Set `BOT_EMBED_ORIGINS` (comma or space separated; default `https://openvibe.live,https://www.openvibe.live`) to the pages that may frame a robot's read-only panel (the CSP `frame-ancestors` list). Each entry is a bare https origin, with no path, query or wildcard (`http://localhost:<port>` only outside production); an invalid entry refuses to boot. A robot is embeddable only after its owner turns on `embed_public` with the form on its panel (`POST /robots/:id/embed`); the flag is web-only and never appears in `/api/v1`. Migration `0005_embed_public.sql` adds the column, off for every robot.
++
Set `BOT_WHIP_BASE` in `/etc/openvibe/bot.env` to the WHIP ingest base each device publishes to (`whip_url = <base>/<publish key>`, sent once with the pairing; OpenRe's is `https://ingest.openre.stream/whip`); leave it unset to pair devices without video.
The publish key is always an ingest key OpenRe.Stream issued for the robot's stream (OpenRe's WHIP ingest admits no other): set `BOT_OPENRE_URL` and `BOT_OPENRE_TOKEN` (a Network service token holding `openre.stream.read`, `openre.stream.write` and `openre.key.rotate`, or `openre.stream.*` with `openre.key.rotate`; `BOT_OPENRE_TIMEOUT_MS`, default 8000, bounds each call). Pairing creates the robot's OpenRe stream (or rotates the one it has), a credential rotation rotates its key, revoking a device or removing a robot revokes the key and ends the live session. Bot stores the stream id and the key's hint, never the key. With either variable unset, devices still pair but get no publish key and the answer says `"video": "not_configured"`; Bot mints no key of its own.
ddiff --git a/STATUS.json b/STATUS.json
iindex 37b3c49..1f4990c 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -38,7 +38,8 @@
"per-actor limits on capability-boundary writes (openvibe-sdk/limits; shared through Valkey when VALKEY_URL is set)",
"signed-in pages: /robots (list and add), /pair/:id (the code and the one-paste installer command) and /panel/:id, rendered from the robot's profile and driven over /control; a sim.rover robot is driven by an in-process simulated device",
"panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists (a placeholder until OpenRe can play a WHIP stream back); a live online indicator on /pair/:id read from /control",
-- "platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record"
++ "platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
++ "embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live), the embed view itself follows in a later step"
],
"updated": "2026-10-04"
}
ddiff --git a/migrations/0005_embed_public.sql b/migrations/0005_embed_public.sql
nnew file mode 100644
iindex 0000000..1e5f4f2
---- /dev/null
++++ b/migrations/0005_embed_public.sql
@@@ -0,0 +1,8 @@
++-- phase: expand
++--
++-- Embeddable panel (plan T15 step R9): an owner may opt a robot in to anonymous, read-only embedding, so a page
++-- on an allow-listed origin (OpenVibe.Live's channel page) can show the robot's video and readouts to anyone.
++-- Additive only: one new column, off for every existing robot, nothing to backfill. The controls are never
++-- part of it. The flag is web-only: /api/v1 responses do not carry it.
++
++ALTER TABLE robots ADD COLUMN embed_public boolean NOT NULL DEFAULT false;
ddiff --git a/server/config.js b/server/config.js
iindex bea1000..e30fb7f 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -28,6 +28,35 @@ function checkInstallerSource(sourceUrl) {
}
}
++const DEFAULT_EMBED_ORIGINS = ['https://openvibe.live', 'https://www.openvibe.live'];
++
++/**
++ * BOT_EMBED_ORIGINS (comma or space separated): the pages that may frame the embeddable panel. Each entry is a
++ * bare https origin, no path, query or wildcard; http://localhost:<port> and http://127.0.0.1:<port> only
++ * outside production. Anything else refuses to boot, so the CSP frame-ancestors list can never be widened by accident.
++ */
++function parseEmbedOrigins(value, isProduction) {
++ const entries = String(value || '').split(/[\s,]+/).filter(Boolean);
++ if (!entries.length) return [...DEFAULT_EMBED_ORIGINS];
++ const out = [];
++ for (const entry of entries) {
++ let u = null;
++ try { u = new URL(entry); } catch { /* reported below */ }
++ const local = u && u.protocol === 'http:' && ['localhost', '127.0.0.1'].includes(u.hostname) && u.port !== '' && !isProduction;
++ const bare = u && (u.protocol === 'https:' || local) && u.origin === entry && !entry.includes('*');
++ if (!bare) {
++ throw new Error(`BOT_EMBED_ORIGINS entries must be bare https origins like https://openvibe.live (no path, query or wildcard${isProduction ? '' : '; http://localhost:<port> is allowed outside production'}), not ${JSON.stringify(entry)}`);
++ }
++ if (!out.includes(entry)) out.push(entry);
++ }
++ return out;
++}
++
++/** The CSP frame-ancestors source list for the embeddable panel: this site and the configured origins. */
++function frameAncestors(config) {
++ return ["'self'", ...config.embed.origins].join(' ');
++}
++
function loadConfig(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
const isProduction = nodeEnv === 'production';
@@@ -40,6 +69,7 @@ function loadConfig(env = process.env) {
}
const installerSource = String(env.BOT_INSTALLER_SOURCE_URL || 'https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh').trim();
checkInstallerSource(installerSource);
++ const embedOrigins = parseEmbedOrigins(env.BOT_EMBED_ORIGINS, isProduction);
return {
nodeEnv,
isProduction,
@@@ -152,6 +182,8 @@ function loadConfig(env = process.env) {
// prints; sourceUrl is where GET /install sends the client (302). Bot keeps no copy of the script:
// it redirects to OpenVibe.Node's canonical one, and only this config (never a query parameter)
// chooses the target.
++ // Embeddable panel (plan T15 R9): the origins whose pages may frame a robot's read-only panel.
++ embed: { origins: embedOrigins },
installer: {
scriptUrl: trim(env.BOT_INSTALLER_URL || 'https://openvibe.bot/install'),
sourceUrl: installerSource,
@@@ -160,4 +192,4 @@ function loadConfig(env = process.env) {
};
}
--module.exports = { loadConfig };
++module.exports = { loadConfig, frameAncestors };
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 01031cb..f996786 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -162,6 +162,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
await db.query(`UPDATE robots SET ${sets.join(', ')} WHERE id = $1`, args);
return getRobot(id);
}
++ /** Opt a robot in or out of anonymous read-only embedding (the owner check is the caller's; no event). */
++ async function setEmbedPublic(id, value) {
++ await db.query('UPDATE robots SET embed_public = $2, updated_at = $3 WHERE id = $1', [id, !!value, iso(now())]);
++ return getRobot(id);
++ }
/**
* Remove a robot. Its OpenRe stream's key is revoked first (rotated with no grace, its sessions ended), so
* nothing publishes as the robot afterwards; an OpenRe that refuses or does not answer leaves the robot in
@@@ -784,7 +789,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
return {
db, now, config, log, outbox, link,
present: { robot: presentRobot, device: presentDevice, video: presentVideo },
-- robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, remove: removeRobot },
++ robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, remove: removeRobot },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
ddiff --git a/server/web/render.js b/server/web/render.js
iindex b159e6f..e38f649 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -209,6 +209,15 @@ function renderWidget(w, { profile = {}, allowed_commands = [] } = {}) {
return `<section class="widget widget-${esc(w.type)}" data-widget="${esc(w.type)}"${cmd}><h2>${esc(label)}</h2>${inner}</section>`;
}
++/** The owner's embed switch: plain markup, a checkbox posted as embed_public=on (the hidden field makes unchecked off). */
++function embedForm(robot) {
++ return `<form class="embed-form" method="post" action="/robots/${esc(robot.id)}/embed" data-embed-form>
++<input type="hidden" name="embed_public" value="off">
++<label><input type="checkbox" name="embed_public" value="on"${robot.embed_public ? ' checked' : ''}> Let anyone see this robot's video and readouts on a page that embeds it (never the controls)</label>
++<button type="submit">Save</button>
++</form>`;
++}
++
function renderPanel({ robot, profile, role, allowed_commands = [], holdResendMs = 150 }) {
const latched = !!(robot.estop && robot.estop.latched);
const widgets = (profile.widgets || []).map((w, i) => ({ w, i }))
@@@ -229,6 +238,7 @@ ${topbar(`<span class="crumb">${esc(robot.name)}</span>`)}
<div class="widgets">
${widgets.join('\n')}
</div>
++${role === 'owner' ? embedForm(robot) : ''}
</main>`;
return page(robot.name, body, { scripts: ['/panel/panel.js'], bodyClass: 'panel-page' });
}
@@@ -238,7 +248,7 @@ function renderRobotsPage({ robots = [], profiles = [], error = null, values = {
const list = robots.length
? `<ul class="robots">${robots.map((r) => `<li class="robot-card"><a class="robot-name" href="/panel/${esc(r.id)}">${icon('robot')}<span>${esc(r.name)}</span></a>`
+ `<span class="robot-meta"><span class="pill">${esc(names.get(r.profile_id) || r.profile_id)}</span> <span class="pill">${esc(POLICY_LABEL[r.access_policy] || r.access_policy)}</span></span>`
-- + `<span class="robot-actions"><a class="button" href="/panel/${esc(r.id)}">Open panel</a> <a class="button quiet pair" href="/pair/${esc(r.id)}">${icon('plug')}<span>Pair a device</span></a></span></li>`).join('')}</ul>`
++ + `<span class="robot-actions"><a class="button" href="/panel/${esc(r.id)}">Open panel</a> <a class="button quiet pair" href="/pair/${esc(r.id)}">${icon('plug')}<span>Pair a device</span></a></span>${embedForm(r)}</li>`).join('')}</ul>`
: `<div class="empty">${icon('robot', 'icon empty-icon')}<p class="empty-title">No robots yet.</p><p>Add one below. A simulated rover needs no hardware and drives straight away.</p></div>`;
const option = (v, label, selected) => `<option value="${esc(v)}"${selected ? ' selected' : ''}>${esc(label)}</option>`;
const form = `<form class="add-robot card" method="post" action="/robots">
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex b461c27..82187e9 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -10,6 +10,7 @@
* GET /pair/:id the owner only: a fresh pairing code and the installer command (never minted for a
* cross-site navigation or a prefetch: those go to /robots)
* GET /panel/:id a member (or anyone on a `queue` robot): the panel, rendered from the profile
++ * POST /robots/:id/embed the owner only: allow (embed_public=on) or stop (off) anonymous read-only embedding → 303 to its panel
* GET /panel/panel.js, /panel/panel.css the panel client and its sheet (public/, no build step)
*
* Not signed in → 302 to /auth/login?next=<the page>. No access → 403 (bot.not_an_operator / bot.forbidden).
@@@ -68,8 +69,11 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
try { return new URL(origin).origin === new URL(config.baseUrl).origin || new URL(origin).host === req.headers.host; } catch { return false; }
};
++ // The embed flag is web-only: domain.present.robot (and so /api/v1) does not carry it.
++ const presentForPage = (row) => ({ ...domain.present.robot(row), embed_public: !!row.embed_public });
++
async function robotsPage(req, res, me, extra = {}) {
-- const robots = (await domain.robots.list(me.subject)).map(domain.present.robot);
++ const robots = (await domain.robots.list(me.subject)).map(presentForPage);
const profiles = (await listProfiles(domain.db)).map((p) => p.profile);
res.type('html').send(renderRobotsPage({ robots, profiles, ...extra }));
}
@@@ -110,6 +114,20 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
res.type('html').send(renderPairingPage({ robot: domain.present.robot(robot), pairing, profile: await profileOf(robot) }));
}));
++ r.post('/robots/:id/embed', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
++ const me = requireUser(req);
++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
++ const robot = await domain.robots.get(req.params.id);
++ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
++ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner may change embedding');
++ // The hidden "off" comes first, the checked box's "on" after it: the last value wins.
++ const sent = req.body && req.body.embed_public;
++ const value = Array.isArray(sent) ? sent[sent.length - 1] : sent;
++ if (value !== 'on' && value !== 'off') throw new BotError(422, 'bot.invalid_input', 'embed_public must be on or off');
++ await domain.robots.setEmbedPublic(robot.id, value === 'on');
++ res.redirect(303, `/panel/${robot.id}`);
++ }));
++
r.get('/panel/:id', page(async (req, res) => {
const me = requireUser(req);
const robot = await domain.robots.get(req.params.id);
@@@ -123,7 +141,7 @@ function createWebRoutes(config, { domain = null, sim = null, limits = null, log
await startSim(robot, profile);
const { maxCommandMs } = domain.control.effectiveLimits(robot, profile);
res.type('html').send(renderPanel({
-- robot: domain.present.robot(robot), profile, role,
++ robot: presentForPage(robot), profile, role,
allowed_commands: domain.control.allowedFor(robot, role, profile),
// A held control is re-sent well inside the device's deadline, so it never stops between two frames.
holdResendMs: Math.max(50, Math.min(HOLD_RESEND_MS, Math.floor(maxCommandMs / 2))),
ddiff --git a/test/embed-flag.test.js b/test/embed-flag.test.js
nnew file mode 100644
iindex 0000000..7356161
---- /dev/null
++++ b/test/embed-flag.test.js
@@@ -0,0 +1,117 @@
++'use strict';
++// The embed flag (plan T15 step R9, s1): robots.embed_public is the owner's opt-in to anonymous read-only
++// embedding, web-only (never in /api/v1), and BOT_EMBED_ORIGINS is a validated frame-ancestors allow-list.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++const { loadConfig, frameAncestors } = require('../server/config');
++
++(async () => {
++ const DEFAULTS = ['https://openvibe.live', 'https://www.openvibe.live'];
++
++ await check('BOT_EMBED_ORIGINS defaults to Live\'s origins and builds the frame-ancestors list', () => {
++ const c = loadConfig({ NODE_ENV: 'production' });
++ assert.deepStrictEqual(c.embed.origins, DEFAULTS);
++ assert.strictEqual(frameAncestors(c), "'self' https://openvibe.live https://www.openvibe.live");
++ });
++
++ await check('a custom list (commas or spaces) is accepted; localhost only outside production', () => {
++ const c = loadConfig({ NODE_ENV: 'production', BOT_EMBED_ORIGINS: 'https://a.test, https://b.test:8443 https://a.test' });
++ assert.deepStrictEqual(c.embed.origins, ['https://a.test', 'https://b.test:8443']);
++ assert.strictEqual(frameAncestors(c), "'self' https://a.test https://b.test:8443");
++ const dev = loadConfig({ NODE_ENV: 'development', BOT_EMBED_ORIGINS: 'http://localhost:4620,http://127.0.0.1:4620' });
++ assert.deepStrictEqual(dev.embed.origins, ['http://localhost:4620', 'http://127.0.0.1:4620']);
++ assert.throws(() => loadConfig({ NODE_ENV: 'production', BOT_EMBED_ORIGINS: 'http://localhost:4620' }), /BOT_EMBED_ORIGINS/);
++ });
++
++ await check('a wildcard, a path, a query, a bare scheme or plain http in production refuse to boot', () => {
++ for (const bad of ['*', 'https://*.test', 'https://x.test/path', 'https://x.test/', 'https://x.test?a=1', 'http://evil.test', 'https:', 'openvibe.live', 'http://localhost', 'https://openvibe.live,*']) {
++ for (const NODE_ENV of ['production', 'development']) {
++ assert.throws(() => loadConfig({ NODE_ENV, BOT_EMBED_ORIGINS: bad }), /BOT_EMBED_ORIGINS entries must be bare https origins/, `${NODE_ENV}: ${bad}`);
++ }
++ }
++ assert.throws(() => loadConfig({ NODE_ENV: 'production', BOT_EMBED_ORIGINS: 'http://evil.test' }), /"http:\/\/evil\.test"/);
++ });
++
++ const t = await boot();
++ const alex = t.network.newUser('alex');
++ const bob = t.network.newUser('bob');
++ const carol = t.network.newUser('carol');
++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
++ const get = (p, user) => fetch(t.base + p, { redirect: 'manual', headers: user ? cookie(user) : {} });
++ const post = (p, user, form, headers = {}) => fetch(t.base + p, {
++ method: 'POST', redirect: 'manual', body: typeof form === 'string' ? form : new URLSearchParams(form).toString(),
++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers },
++ });
++ const flag = async (id) => (await t.db.maybe('SELECT embed_public FROM robots WHERE id = $1', [id])).embed_public;
++
++ const { robot } = await t.robot(alex, { name: 'Embeddable' });
++ const url = `/robots/${robot.id}/embed`;
++
++ await check('the column is off by default and /api/v1 does not carry it', async () => {
++ assert.strictEqual(await flag(robot.id), false);
++ assert.strictEqual((await t.domain.robots.get(robot.id)).embed_public, false);
++ const r = await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex });
++ assert.strictEqual(r.status, 200);
++ assert.ok(!r.text.includes('embed_public'));
++ });
++
++ await check('the owner turns it on and off; the panel and the list show the switch', async () => {
++ const on = await post(url, alex, 'embed_public=off&embed_public=on');
++ assert.strictEqual(on.status, 303);
++ assert.strictEqual(on.headers.get('location'), `/panel/${robot.id}`);
++ assert.strictEqual(await flag(robot.id), true);
++ for (const p of [`/panel/${robot.id}`, '/robots']) {
++ const html = await (await get(p, alex)).text();
++ assert.match(html, new RegExp(`action="/robots/${robot.id}/embed"`), p);
++ assert.match(html, /name="embed_public" value="on" checked/, p);
++ assert.match(html, /never the controls/, p);
++ }
++ const r = await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex });
++ assert.ok(!r.text.includes('embed_public'));
++ const off = await post(url, alex, { embed_public: 'off' });
++ assert.strictEqual(off.status, 303);
++ assert.strictEqual(await flag(robot.id), false);
++ assert.doesNotMatch(await (await get(`/panel/${robot.id}`, alex)).text(), /value="on" checked/);
++ });
++
++ await check('a member operator and an outsider get 403 and the flag does not move', async () => {
++ await t.call('POST', `/api/v1/robots/${robot.id}/operators`, { user: alex, body: { subject: carol.subject, role: 'operator' } });
++ for (const who of [carol, bob]) {
++ const r = await post(url, who, { embed_public: 'on' });
++ assert.strictEqual(r.status, 403);
++ assert.match(await r.text(), /only the owner/);
++ }
++ assert.strictEqual(await flag(robot.id), false);
++ // A member's panel carries no switch.
++ assert.doesNotMatch(await (await get(`/panel/${robot.id}`, carol)).text(), /data-embed-form/);
++ });
++
++ await check('a cross-site Origin is refused; signed out goes to sign-in; a bad value and a missing robot are refused', async () => {
++ const cross = await post(url, alex, { embed_public: 'on' }, { Origin: 'https://evil.test' });
++ assert.strictEqual(cross.status, 403);
++ assert.strictEqual(await flag(robot.id), false);
++ const same = await post(url, alex, { embed_public: 'on' }, { Origin: 'http://bot.test' });
++ assert.strictEqual(same.status, 303);
++ assert.strictEqual(await flag(robot.id), true);
++ await post(url, alex, { embed_public: 'off' });
++ const anon = await post(url, null, { embed_public: 'on' });
++ assert.strictEqual(anon.status, 302);
++ assert.match(anon.headers.get('location'), /^\/auth\/login/);
++ assert.ok((await post(url, alex, { embed_public: 'yes' })).status >= 400);
++ assert.ok((await post(url, alex, {})).status >= 400);
++ assert.strictEqual(await flag(robot.id), false);
++ assert.strictEqual((await post('/robots/rob_nothing/embed', alex, { embed_public: 'on' })).status, 404);
++ });
++
++ await check('setEmbedPublic writes updated_at and emits no event', async () => {
++ const before = (await t.outboxRows()).length;
++ const row = await t.domain.robots.setEmbedPublic(robot.id, true);
++ assert.strictEqual(row.embed_public, true);
++ assert.strictEqual((await t.outboxRows()).length, before);
++ await t.domain.robots.setEmbedPublic(robot.id, false);
++ });
++
++ t.app.locals.sim.stopAll();
++ await t.close();
++ done();
++})();