openvibers/openvibe.bot Commits
Let the network's hub turn a machine's switches and show its screen, desks and links (#376)
Changes
@@@ -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, submwaiting: 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, submres.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, submres.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, submconst 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, submres.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).nnew file mode 100644@@@ -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); });
@@ -37,6 +37,10 @@
@@ -37,6 +37,10 @@
* | POST /pair | — (one-time code) | the agent (code is the credential) |
* | POST /pair | — (one-time code) | the agent (code is the credential) |
* | POST /claims | — (anyone) | a device asking to be added: a code |
* | 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/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|
* | 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.
* 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');
@@ -53,7 +57,7 @@ const machineRoles = require('../machine-roles');
const kits = require('../kits');
const kits = require('../kits');
const resourceIndex = require('./resource-index');
const resourceIndex = require('./resource-index');
const resourceControl = require('./resource-control');
const resourceControl = require('./resource-control');
-const { aiOf } = require('../machine-ai');
+const { aiOf, computerCaps } = require('../machine-ai');
const deskLeases = require('./desk-leases');
const deskLeases = require('./desk-leases');
const agentSteps = require('../agent-steps');
const agentSteps = require('../agent-steps');
@@ -538,7 +542,9 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -538,7 +542,9 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
}
}
const machineOut = (device) => {
const machineOut = (device) => {
const st = hub.deviceState(device.id);
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 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);
const pick = (cap) => (offered && offered[cap] && typeof offered[cap] === 'object' ? offered[cap] : null);
return {
return {
@@ -566,6 +572,9 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -566,6 +572,9 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
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 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,
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.
// 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'
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,
? { 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
@@ -578,7 +587,9 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
*/
*/
const machineRunning = async (device) => {
const machineRunning = async (device) => {
const st = hub.deviceState(device.id);
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 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 ai = offered && offered.ai && typeof offered.ai === 'object' ? offered.ai : null;
const out = [];
const out = [];
@@ -640,15 +651,17 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -640,15 +651,17 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.set('Cache-Control', 'no-store');
res.set('Cache-Control', 'no-store');
res.json({ machine_id: device.id, desk, ...hub.deskTurn(device.id, desk) });
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) => {
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) => {
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 });
res.status(201).json({ machine_id: device.id, ...out });
}));
}));
r.get('/machines/:id/screen', wrap(async (req, res) => {
r.get('/machines/:id/screen', wrap(async (req, res) => {
@@ -657,13 +670,19 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -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)) });
res.json({ machine_id: device.id, ...(await machineScreen.state(device, subject)) });
}));
}));
r.delete('/machines/:id/screen', manage, wrap(async (req, res) => {
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);
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) => {
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');
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);
const updated = await domain.machines.setWatchProbe(device.id, req.body.on);
res.json({ machine: machineOut(updated) });
res.json({ machine: machineOut(updated) });
@@ -687,10 +706,10 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -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);
const updated = await domain.machines.setAiModels(device.id, req.body.on);
res.json({ machine: machineOut(updated) });
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) => {
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');
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);
const updated = await domain.machines.setAgentComputer(device.id, req.body.on);
res.json({ machine: machineOut(updated) });
res.json({ machine: machineOut(updated) });
@@ -810,6 +829,67 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -810,6 +829,67 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.json({ machine: await machineView(revoked) });
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 ──
// ── 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.
// 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).
// Every step passes the same gate as a person's command (server/routines.js runSteps).
new file mode 100644
new file mode 100644
@@ -0,0 +1,157 @@
@@ -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); });