openvibers/openvibe.bot Commits

Let the network's hub turn a machine's switches and show its screen, desks and links (#376)

OP OpenVibers committed 3 days ago · 78daea3a80 · 2 files, +253 −16

The Link hub calls Bot as the person (svc:network, bot.machine.manage, X-OV-Subject). The owner's agent and
watch switches, the live screen (10 minutes by default), its mouse and keyboard, the desks (list, stop, live on
the owner's channel) and the screen-link list and revoke now answer that call as the person's own session does.
Bot still checks the machine is the person's own (404 otherwise), and only the network's own service may act for
them. Offline, a machine's computer capabilities come from what it last declared, so its owner can still turn
its agent off while it is away.

Changes

ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 5441f97..14e814a 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -37,6 +37,10 @@
* | POST /pair | — (one-time code) | the agent (code is the credential) |
* | POST /claims | — (anyone) | a device asking to be added: a code |
* | POST /machines/:id/watch-links | bot.machine.watch_link | the owner, or their agent for them |
++ * | POST /machines/:id/agent-computer, watch-probe| — (the owner or the network's hub) | as the person |
++ * | POST /machines/:id/screen | — (the owner or the network's hub) | the owner only |
++ * | GET /machines/:id/desks, /shares | — (the owner or the network's hub) | the owner only |
++ * | POST /machines/:id/desks/:desk/stop, /live | — (the owner or the network's hub) | the owner only |
* | GET /claims/:id | — (X-OV-Claim-Secret) | that device: pending, or its pairing|
*
* With BOT_PAIRING_AUTHORITY=network the pairing codes are Network's and POST /pair answers 410 bot.pairing_moved.
@@@ -53,7 +57,7 @@ const machineRoles = require('../machine-roles');
const kits = require('../kits');
const resourceIndex = require('./resource-index');
const resourceControl = require('./resource-control');
--const { aiOf } = require('../machine-ai');
++const { aiOf, computerCaps } = require('../machine-ai');
const deskLeases = require('./desk-leases');
const agentSteps = require('../agent-steps');
@@@ -538,7 +542,9 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
}
const machineOut = (device) => {
const st = hub.deviceState(device.id);
-- const offered = hub.computerOf(device.id);
++ // Live first; offline, what the machine last declared (so its owner still sees and can turn off
++ // what it was allowed to do while it is away).
++ const offered = hub.computerOf(device.id) || computerCaps(device.capabilities);
const sensors = st.telemetry && st.telemetry.sensors && typeof st.telemetry.sensors === 'object' ? st.telemetry.sensors : {};
const pick = (cap) => (offered && offered[cap] && typeof offered[cap] === 'object' ? offered[cap] : null);
return {
@@@ -566,6 +572,9 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
// Desks running now (the Link's telemetry): where an agent's next desk has room (Desks plan D6, placement).
desks_running: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
++ // Whether its screen is being shown right now (the Link's telemetry, as the deleted page read it): the
++ // hub offers Watch instead of Show when it is live.
++ screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',
// How fast its last model answered there (Link v0.29.0's ai plugin): for choosing where a model runs.
ai_speed: typeof sensors.ai_tokens_per_s === 'number' && sensors.ai_tokens_per_s > 0 && typeof sensors.ai_model === 'string'
? { model: sensors.ai_model.slice(0, 120), tokens_per_s: sensors.ai_tokens_per_s } : null,
@@@ -578,7 +587,9 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
*/
const machineRunning = async (device) => {
const st = hub.deviceState(device.id);
-- const offered = hub.computerOf(device.id);
++ // Live first; offline, what the machine last declared (so its owner still sees and can turn off
++ // what it was allowed to do while it is away).
++ const offered = hub.computerOf(device.id) || computerCaps(device.capabilities);
const sensors = st.telemetry && st.telemetry.sensors && typeof st.telemetry.sensors === 'object' ? st.telemetry.sensors : {};
const ai = offered && offered.ai && typeof offered.ai === 'object' ? offered.ai : null;
const out = [];
@@@ -640,15 +651,17 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.set('Cache-Control', 'no-store');
res.json({ machine_id: device.id, desk, ...hub.deskTurn(device.id, desk) });
}));
-- // The live screen (plan T14): the machine's owner, signed in as themselves, watches it on their own private stream.
-- // Never an agent, a watch or another service: screen_stream is not a bot.command@1 kind, and only this route sends it.
++ // The live screen (plan T14): the machine's owner watches it on their own private stream, signed in as
++ // themselves, or the network's hub acting for them from their own session (plan L34). Never an agent, a watch
++ // or an app: screen_stream is not a bot.command@1 kind, and only these routes send it.
const screenOwner = async (req) => {
-- if (req.principal.kind !== 'user') fail(403, 'bot.forbidden', 'only the machine\'s owner, signed in, shows its screen');
-- return computerMachine(req, { agentNeedsOptIn: false });
++ if (req.principal.kind === 'user') return { ...(await computerMachine(req, { agentNeedsOptIn: false })), principal: req.principal };
++ const { device, subject } = await hubMachine(req);
++ return { device, subject, principal: { kind: 'user', sub: subject } };
};
r.post('/machines/:id/screen', manage, wrap(async (req, res) => {
-- const { device, subject } = await screenOwner(req);
-- const out = await machineScreen.start(device, subject, req.principal, { seconds: req.body?.seconds, fps: req.body?.fps, desk: req.body?.desk, live: req.body?.live === true });
++ const { device, subject, principal } = await screenOwner(req);
++ const out = await machineScreen.start(device, subject, principal, { seconds: req.body?.seconds, fps: req.body?.fps, desk: req.body?.desk, live: req.body?.live === true });
res.status(201).json({ machine_id: device.id, ...out });
}));
r.get('/machines/:id/screen', wrap(async (req, res) => {
@@@ -657,13 +670,19 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.json({ machine_id: device.id, ...(await machineScreen.state(device, subject)) });
}));
r.delete('/machines/:id/screen', manage, wrap(async (req, res) => {
++ const { device, subject, principal } = await screenOwner(req);
++ res.json({ machine_id: device.id, ...(await machineScreen.stop(device, subject, principal)) });
++ }));
++ // The owner's own mouse or keyboard on the screen shown now ("Take over", Desks plan D4), as the deleted page
++ // did: sent as the owner, so Bot's person-first rule holds an agent off the screen while they use it.
++ r.post('/machines/:id/screen/input', manage, wrap(async (req, res) => {
const { device, subject } = await screenOwner(req);
-- res.json({ machine_id: device.id, ...(await machineScreen.stop(device, subject, req.principal)) });
++ res.json({ machine_id: device.id, ...(await machineScreen.input(device, subject, req.body || {})) });
}));
-- // The owner lets their watches probe from the machine, or stops them (never Watch itself).
++ // The owner lets their watches probe from the machine, or stops them (never Watch itself). The network's hub
++ // does the same for the person from their own session (plan L34), never for an app acting for them.
r.post('/machines/:id/watch-probe', manage, wrap(async (req, res) => {
-- if (req.principal.kind !== 'user') fail(403, 'bot.forbidden', 'only the machine\'s owner turns this on or off');
-- const { device } = await computerMachine(req, { agentNeedsOptIn: false });
++ const { device } = req.principal.kind === 'user' ? await computerMachine(req, { agentNeedsOptIn: false }) : await hubMachine(req);
if (typeof req.body?.on !== 'boolean') fail(422, 'bot.invalid_input', 'on is true or false');
const updated = await domain.machines.setWatchProbe(device.id, req.body.on);
res.json({ machine: machineOut(updated) });
@@@ -687,10 +706,10 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
const updated = await domain.machines.setAiModels(device.id, req.body.on);
res.json({ machine: machineOut(updated) });
}));
-- // The owner turns their agent's use of the machine on or off (never the agent itself).
++ // The owner turns their agent's use of the machine on or off (never the agent itself). The network's hub does
++ // the same for the person from their own session (plan L34), never for an app acting for them.
r.post('/machines/:id/agent-computer', manage, wrap(async (req, res) => {
-- if (req.principal.kind !== 'user') fail(403, 'bot.forbidden', 'only the machine\'s owner turns this on or off');
-- const { device } = await computerMachine(req, { agentNeedsOptIn: false });
++ const { device } = req.principal.kind === 'user' ? await computerMachine(req, { agentNeedsOptIn: false }) : await hubMachine(req);
if (typeof req.body?.on !== 'boolean') fail(422, 'bot.invalid_input', 'on is true or false');
const updated = await domain.machines.setAgentComputer(device.id, req.body.on);
res.json({ machine: machineOut(updated) });
@@@ -810,6 +829,67 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.json({ machine: await machineView(revoked) });
}));
++ // The desks running on the machine (the removed page's desk list): asked of the machine when the person
++ // opens them, never on a schedule, and only for named desks. Watching one is the screen route with its name;
++ // stopping one or putting it on the owner's Live channel is here.
++ const NAMED_DESK = (value) => {
++ const desk = String(value || '');
++ return machineScreen.DESK.test(desk) && desk !== 'main' ? desk : null;
++ };
++ r.get('/machines/:id/desks', wrap(async (req, res) => {
++ const { device, subject } = await hubMachine(req);
++ res.set('Cache-Control', 'private, no-store');
++ if (!hub || !hub.isOnline(device.id)) fail(409, 'bot.device_offline', 'the machine is offline: its desks stopped with it, or show here when it is back');
++ const offered = hub.computerOf(device.id);
++ if (!(offered && offered.desk)) fail(409, 'bot.computer_off', 'this machine runs no desks: it needs OpenVibe Link with the computer plugin');
++ const answer = await hub.machineCommand(device.id, { kind: 'user', sub: subject }, subject, { kind: 'desk', value: { action: 'list' }, ms: 8000 });
++ if (!answer || answer.result !== 'ack') {
++ fail(409, 'bot.device_refused', `the machine did not list its desks: ${(answer && (answer.reason || answer.result)) || 'no answer'}`.slice(0, 300));
++ }
++ const data = answer.data && typeof answer.data === 'object' ? answer.data : {};
++ const desks = (Array.isArray(data.desks) ? data.desks : [])
++ .filter((desk) => desk && NAMED_DESK(desk.desk)).slice(0, 50)
++ .map((desk) => ({
++ desk: String(desk.desk),
++ ...(typeof desk.kind === 'string' ? { kind: desk.kind.slice(0, 20) } : {}),
++ ...(Number.isInteger(desk.since) ? { since: desk.since } : {}),
++ ...(Number.isInteger(desk.width) ? { width: desk.width } : {}),
++ ...(Number.isInteger(desk.height) ? { height: desk.height } : {}),
++ }));
++ res.json({ machine_id: device.id, desks, max_desks: Number.isInteger(data.max_desks) ? data.max_desks : null });
++ }));
++ r.post('/machines/:id/desks/:desk/stop', manage, wrap(async (req, res) => {
++ const { device, subject } = await hubMachine(req);
++ const desk = NAMED_DESK(req.params.desk);
++ if (!desk) fail(422, 'bot.invalid_input', 'desk is private or desk-<name>');
++ if (!hub || !hub.isOnline(device.id)) fail(409, 'bot.device_offline', 'the machine is offline');
++ const answer = await hub.machineCommand(device.id, { kind: 'user', sub: subject }, subject, { kind: 'desk', value: { action: 'stop', desk }, ms: 15000 });
++ if (!answer || answer.result !== 'ack') {
++ fail(409, 'bot.device_refused', `the machine did not stop it: ${(answer && (answer.reason || answer.result)) || 'no answer'}`.slice(0, 300));
++ }
++ res.json({ machine_id: device.id, stopped: desk });
++ }));
++ // A desk on the owner's OpenVibe Live channel for up to 30 minutes (Desks plan D8): everyone can watch this
++ // one, so the hub says so before the person presses it, never on a page load.
++ r.post('/machines/:id/desks/:desk/live', manage, wrap(async (req, res) => {
++ const { device, subject } = await hubMachine(req);
++ const desk = NAMED_DESK(req.params.desk);
++ if (!desk) fail(422, 'bot.invalid_input', 'desk is private or desk-<name>');
++ const out = await machineScreen.start(device, subject, { kind: 'user', sub: subject }, { desk, live: true, seconds: machineScreen.limits.SECONDS.max });
++ res.status(201).json({ machine_id: device.id, ...out });
++ }));
++ // The screen links the owner made (the removed page listed them; an agent still makes them through
++ // POST /machines/:id/watch-links). Only what is live and unrevoked comes back, never the token itself.
++ r.get('/machines/:id/shares', wrap(async (req, res) => {
++ const { device, subject } = await hubMachine(req);
++ res.set('Cache-Control', 'private, no-store');
++ res.json({ machine_id: device.id, shares: await shares.list(device, subject) });
++ }));
++ r.delete('/machines/:id/shares/:share', manage, wrap(async (req, res) => {
++ const { device, subject } = await hubMachine(req);
++ const out = await shares.revoke(device, subject, req.params.share);
++ res.json({ machine_id: device.id, ...out });
++ }));
// ── Steps (the owner's agent): plain step lines, run now as the owner and answered when they end ──
// The owner themself, or an agent service (OpenVibe.Actor) acting for the owner of a robot with agent_control on.
// Every step passes the same gate as a person's command (server/routines.js runSteps).
ddiff --git a/test/machine-controls-hub.test.js b/test/machine-controls-hub.test.js
nnew file mode 100644
iindex 0000000..b3aea81
---- /dev/null
++++ b/test/machine-controls-hub.test.js
@@@ -0,0 +1,157 @@
++'use strict';
++// The Link hub's machine controls (the page removed in #371): the network's hub (svc:network, bot.machine.manage,
++// X-OV-Subject) acts for the owner exactly as the person's own session does — the agent and watch switches, the
++// live screen (10 minutes by default), the mouse and keyboard, the machine's desks, and its screen links. Bot owns
++// every check: a machine that is not that person's is 404, and only the network's own service may act for a person.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0', BOT_WHIP_BASE: 'https://ingest.openre.test/whip/' } });
++ const alex = t.network.newUser('alex');
++ const sam = t.network.newUser('sam');
++ let seq = 0;
++ const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });
++ const poll = async (fn, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(30); } return null; };
++ const COMPUTER = {
++ level: 'ask',
++ screen: { level: 'ask', tool: 'grim', stream: true },
++ terminal: { level: 'ask' }, files: { level: 'ask', roots: ['/home/alex'] },
++ desk: { level: 'ask', max_desks: 2, kinds: ['browser'], isolation: 'sandbox', screen: true, named: true },
++ probe: { level: 'observe' },
++ input: { level: 'ask' },
++ };
++ const seen = [];
++
++ /** A machine of `owner` whose fake Link answers the computer plugin, its desks, the screen and the input. */
++ async function machine(owner) {
++ const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });
++ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
++ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;
++ const config = await c.waitFor((m) => m.type === 'config');
++ c.send(frame('status', { capabilities: { computer: COMPUTER }, drivers: [], faults: [] }));
++ c.send(frame('telemetry', { sensors: { computer_level: 'ask', computer_waiting: 0, computer_desks: 1, computer_screen_stream: 'off' } }));
++ c.ws.on('message', (raw) => {
++ let m; try { m = JSON.parse(raw.toString()); } catch { return; }
++ if (m.type !== 'command') return;
++ seen.push(m);
++ if (m.kind === 'screen_stream' && m.value.action === 'start') {
++ c.send(frame('ack', { id: m.id, data: { streaming: true, until_ms: Date.now() + m.value.seconds * 1000 } }));
++ } else if (m.kind === 'screen_stream') c.send(frame('ack', { id: m.id, data: { streaming: false, stopped: true } }));
++ else if (m.kind === 'desk' && m.value.action === 'list') {
++ c.send(frame('ack', { id: m.id, data: { desks: [{ desk: 'desk-a', kind: 'browser', since: 1700000000, width: 1280, height: 720 }], max_desks: 2 } }));
++ } else if (m.kind === 'desk' && m.value.action === 'stop') c.send(frame('ack', { id: m.id, data: { stopped: m.value.desk } }));
++ else if (m.kind === 'input') c.send(frame('ack', { id: m.id, data: { done: true } }));
++ else c.send(frame('nack', { id: m.id, fault_code: 'unsupported' }));
++ });
++ const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, Math.max(100, Math.floor(config.heartbeat_ms / 2)));
++ c.ws.on('close', () => clearInterval(beat));
++ await poll(() => t.hub.computerOf(deviceId) !== null);
++ return { c, deviceId };
++ }
++ /** The hub's own call: Network acting for the person. */
++ const hub = (method, path, { body, subject = alex.subject } = {}) =>
++ t.call(method, path, { body, sub: 'svc:network', cap: ['bot.machine.manage'], headers: { 'X-OV-Subject': subject } });
++
++ const mine = await machine(alex);
++ const theirs = await machine(sam);
++
++ await check('the hub turns the owner\'s switches, exactly as the person does', async () => {
++ const listed = await hub('GET', '/api/v1/machines');
++ assert.strictEqual(listed.status, 200, listed.text);
++ const row = listed.json.machines.find((m) => m.id === mine.deviceId);
++ assert.deepStrictEqual([row.agent_computer, row.watch_probe], [false, false], 'off until the owner turns them on');
++ const on = await hub('POST', `/api/v1/machines/${mine.deviceId}/agent-computer`, { body: { on: true } });
++ assert.strictEqual(on.status, 200, on.text);
++ assert.strictEqual(on.json.machine.agent_computer, true);
++ assert.strictEqual((await t.db.maybe('SELECT agent_computer FROM devices WHERE id = $1', [mine.deviceId])).agent_computer, true);
++ const watch = await hub('POST', `/api/v1/machines/${mine.deviceId}/watch-probe`, { body: { on: true } });
++ assert.strictEqual(watch.status, 200, watch.text);
++ assert.strictEqual(watch.json.machine.watch_probe, true);
++ // The person themselves still may, and a machine that is not theirs is 404, never 403.
++ const own = await t.call('POST', `/api/v1/machines/${mine.deviceId}/agent-computer`, { user: alex, body: { on: false } });
++ assert.strictEqual(own.status, 200, own.text);
++ assert.strictEqual(own.json.machine.agent_computer, false);
++ for (const [method, path, body] of [
++ ['POST', `/api/v1/machines/${theirs.deviceId}/agent-computer`, { on: true }],
++ ['POST', `/api/v1/machines/${theirs.deviceId}/watch-probe`, { on: true }],
++ ['POST', `/api/v1/machines/${theirs.deviceId}/screen`, {}],
++ ['GET', `/api/v1/machines/${theirs.deviceId}/desks`],
++ ]) {
++ const other = await hub(method, path, { body });
++ assert.strictEqual(other.status, 404, `${method} ${path}: ${other.text}`);
++ assert.strictEqual(other.json.code, 'bot.device_not_found');
++ }
++ // Another service with the capability cannot act for a person.
++ const stranger = await t.call('POST', `/api/v1/machines/${mine.deviceId}/agent-computer`, {
++ body: { on: true }, sub: 'svc:codes', cap: ['bot.machine.manage'], headers: { 'X-OV-Subject': alex.subject },
++ });
++ assert.strictEqual(stranger.status, 403, stranger.text);
++ assert.strictEqual(stranger.json.code, 'bot.forbidden');
++ });
++
++ await check('the live screen: the hub starts it for ten minutes, reads it, takes the input and stops it', async () => {
++ const start = await hub('POST', `/api/v1/machines/${mine.deviceId}/screen`, { body: {} });
++ assert.strictEqual(start.status, 201, start.text);
++ assert.deepStrictEqual([start.json.streaming, start.json.seconds, start.json.fps], [true, 600, 2], '10 minutes at 2 frames a second by default');
++ let sent = seen.filter((m) => m.kind === 'screen_stream').at(-1);
++ assert.deepStrictEqual([sent.value.action, sent.value.seconds], ['start', 600]);
++ assert.ok(sent.value.publish_key && sent.value.whip_url, 'the machine gets the key, never the page');
++ const state = await hub('GET', `/api/v1/machines/${mine.deviceId}/screen`);
++ assert.strictEqual(state.status, 200, state.text);
++ assert.deepStrictEqual([state.json.state, state.json.online], ['off', true]);
++ const input = await hub('POST', `/api/v1/machines/${mine.deviceId}/screen/input`, { body: { action: 'click', x: 2, y: 3, width: 100, height: 100 } });
++ assert.strictEqual(input.status, 200, input.text);
++ assert.strictEqual(input.json.done, true);
++ assert.strictEqual(seen.filter((m) => m.kind === 'input').at(-1).value.action, 'click');
++ const stopped = await hub('DELETE', `/api/v1/machines/${mine.deviceId}/screen`);
++ assert.deepStrictEqual([stopped.status, stopped.json.stopped], [200, true]);
++ });
++
++ await check('the desks: listed through the machine, stopped, and put on the owner\'s Live channel', async () => {
++ const list = await hub('GET', `/api/v1/machines/${mine.deviceId}/desks`);
++ assert.strictEqual(list.status, 200, list.text);
++ assert.deepStrictEqual(list.json.desks.map((d) => [d.desk, d.kind, d.width, d.height]), [['desk-a', 'browser', 1280, 720]]);
++ assert.strictEqual(list.json.max_desks, 2);
++ const stop = await hub('POST', `/api/v1/machines/${mine.deviceId}/desks/desk-a/stop`, { body: {} });
++ assert.strictEqual(stop.status, 200, stop.text);
++ assert.strictEqual(stop.json.stopped, 'desk-a');
++ assert.deepStrictEqual(seen.filter((m) => m.kind === 'desk').at(-1).value, { action: 'stop', desk: 'desk-a' });
++ const live = await hub('POST', `/api/v1/machines/${mine.deviceId}/desks/desk-a/live`, { body: {} });
++ assert.strictEqual(live.status, 201, live.text);
++ assert.deepStrictEqual([live.json.desk, live.json.seconds], ['desk-a', 1800], 'the Live channel gets it for 30 minutes');
++ assert.ok(live.json.live && /^https:\/\/openvibe\.live\/@/.test(live.json.live.channel_url || ''), live.text);
++ });
++
++ await check('the screen links: made by the owner, listed without the token, and revoked', async () => {
++ const made = await t.call('POST', `/api/v1/machines/${mine.deviceId}/watch-links`, { user: alex, body: { label: 'Staff room', minutes: 60 } });
++ assert.strictEqual(made.status, 201, made.text);
++ const shareId = made.json.share_id;
++ assert.ok(shareId, 'the link has its id');
++ const list = await hub('GET', `/api/v1/machines/${mine.deviceId}/shares`);
++ assert.strictEqual(list.status, 200, list.text);
++ assert.deepStrictEqual(list.json.shares.map((s) => [s.id, s.label, s.control, s.desk]), [[shareId, 'Staff room', false, 'main']]);
++ assert.ok(!JSON.stringify(list.json).includes(made.json.url.split('/screen/')[1]), 'never the token');
++ const revoked = await hub('DELETE', `/api/v1/machines/${mine.deviceId}/shares/${shareId}`);
++ assert.deepStrictEqual([revoked.status, revoked.json.revoked], [200, true]);
++ assert.deepStrictEqual((await hub('GET', `/api/v1/machines/${mine.deviceId}/shares`)).json.shares, []);
++ });
++
++ await check('only the owner\'s own machine: another person\'s screen, desks and shares are not there', async () => {
++ const otherScreen = await hub('GET', `/api/v1/machines/${theirs.deviceId}/screen`, { subject: sam.subject });
++ assert.strictEqual(otherScreen.status, 200, 'its owner may read it, of course');
++ for (const [method, path] of [
++ ['GET', `/api/v1/machines/${theirs.deviceId}/screen`],
++ ['DELETE', `/api/v1/machines/${theirs.deviceId}/screen`],
++ ['GET', `/api/v1/machines/${theirs.deviceId}/shares`],
++ ['GET', `/api/v1/machines/${theirs.deviceId}/desks`],
++ ['POST', `/api/v1/machines/${theirs.deviceId}/desks/desk-a/stop`],
++ ]) {
++ const r = await hub(method, path, { body: method === 'POST' ? {} : undefined });
++ assert.strictEqual(r.status, 404, `${method} ${path}: ${r.text}`);
++ assert.strictEqual(r.json.code, 'bot.device_not_found');
++ }
++ });
++
++ await done();
++})().catch((e) => { console.error(e); process.exit(1); });