openvibers/openvibe.bot Commits

A machine's desks: the card says how many run, and /machines/:id/desks lists them (kind, size, since) with Watch and take over, and Stop (#184)

OP OpenVibers committed 17 days ago · bd9789f259 · 3 files, +103 −3

Changes

ddiff --git a/server/web/render.js b/server/web/render.js
iindex 8f244d1..a14e1b9 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -749,9 +749,13 @@ function renderMachinesPage({ machines = [], pairing = null }) {
if (!c) return '';
const can = [c.terminal && 'terminal', c.files && 'files', c.screen && 'screen', c.input && 'mouse and keyboard'].filter(Boolean).join(', ');
const desks = c.desks ? `<span class="muted">${esc(deskWords(c.desks))}</span>` : '';
++ // The desks running now (the Node's telemetry), and the page that lists them, to watch or stop one.
++ const running = Number.isInteger(m.computer_desks) && m.computer_desks > 0
++ ? ` <span class="pill">${m.computer_desks === 1 ? '1 desk running' : `${m.computer_desks} desks running`}</span>` : '';
++ const deskLink = c.desks && m.online ? `<a class="button quiet" href="/machines/${esc(m.id)}/desks">See its desks</a>` : '';
const waiting = Number.isInteger(m.computer_waiting) && m.computer_waiting > 0
? ` <span class="pill warn">${m.computer_waiting} waiting for you on the machine</span>` : '';
-- return `<div class="machine-computer"><span class="pill">Computer: ${esc(c.level ? LEVELS[c.level] : 'on')}</span><span class="muted">${esc(can)}</span>${desks}${waiting}`
++ return `<div class="machine-computer"><span class="pill">Computer: ${esc(c.level ? LEVELS[c.level] : 'on')}</span><span class="muted">${esc(can)}</span>${desks}${running}${waiting}${deskLink}`
+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`
+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`
+ (c.screenStream && m.online ? `<form method="post" action="/machines/${esc(m.id)}/screen"><button type="submit" class="button quiet">${m.computer_screen_stream === 'live' ? 'Watch my screen' : 'Show my screen'}</button></form>` : '')
@@@ -836,6 +840,38 @@ ${control ? `<div class="share-controls">
return page(`${name}: shared screen`, body, { scripts: ['/panel/panel.js', ...(control ? ['/panel/screen-share.js'] : [])], bodyClass: 'share-body', description: 'A shared computer screen on OpenVibe.Bot.' });
}
++/**
++ * /machines/:id/desks: the desks running on the owner's machine (Desks plan), each a computer of its own for an agent:
++ * its kind, size and start, Watch and take over (the screen page on that desk), and Stop.
++ */
++function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error = null, stopped = null }) {
++ const name = machine.name || machine.id;
++ const id = esc(machine.id);
++ const KIND = { browser: 'Browser desk', desktop: 'Whole desk' };
++ const since = (d) => {
++ const t = Number(d.since);
++ if (!Number.isFinite(t) || t <= 0) return '';
++ const iso = new Date(t * 1000).toISOString();
++ return ` <span class="muted">since <time datetime="${esc(iso)}" data-local-time>${esc(iso.slice(11, 16))} UTC</time></span>`;
++ };
++ const rows = desks.map((d) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span><code>${esc(d.desk)}</code></span></span>`
++ + `<span class="robot-meta"><span class="pill">${esc(KIND[d.kind] || 'Desk')}</span>${Number.isInteger(d.width) ? ` <span class="muted">${d.width}×${d.height}</span>` : ''}${since(d)}</span>`
++ + `<span class="robot-actions"><a class="button" href="/machines/${id}/screen?desk=${encodeURIComponent(d.desk)}">Watch and take over</a>`
++ + `<form method="post" action="/machines/${id}/desks/stop"><input type="hidden" name="desk" value="${esc(d.desk)}"><button type="submit" class="button quiet">Stop</button></form></span></li>`).join('');
++ const list = error ? `<p class="error" role="alert">${esc(error)}</p>`
++ : rows ? `<ul class="robots">${rows}</ul>`
++ : `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No desks are running.</p><p>An agent's task starts a desk of its own here when it needs one, and stops it when it is done.</p></div>`;
++ const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}
++<main class="page">
++<h1>${esc(name)}: desks</h1>
++<p class="muted">Each desk is a computer of its own on this machine, where one of your agents works: its own screen or browser, files and sign-ins. Watch one live and take it over, or stop it; a task whose desk you stop goes on without it.</p>
++${stopped ? `<p class="setting-ok" role="status">${icon('check')}<span>Stopped <code>${esc(stopped)}</code>.</span></p>` : ''}
++${list}
++${Number.isInteger(maxDesks) ? `<p class="muted small">At most ${maxDesks} whole ${maxDesks === 1 ? 'desk' : 'desks'} at once here; a browser desk counts as half.</p>` : ''}
++</main>`;
++ return page(`${name}: desks`, body, { description: 'The desks your agents work on, on your machine.', canonical: '/machines' });
++}
++
function renderMachineScreenPage({ machine, until = null, error = null, idle = false, shares = [], madeLink = null, madeShare = null, hasDesk = false, hasScreen = true, desk = 'main' }) {
const name = machine.name || machine.id;
const stateUrl = `/machines/${encodeURIComponent(machine.id)}/screen.json`;
@@@ -919,4 +955,4 @@ function renderPhonePage() {
return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });
}
--module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderClaimPage, renderClaimApproved, camerasOf, esc };
++module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderLinkPage, renderClaimPage, renderClaimApproved, camerasOf, esc };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex ac5266f..651c502 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -39,7 +39,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const kits = require('../kits');
--const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderClaimPage, renderClaimApproved } = require('./render');
++const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderClaimPage, renderClaimApproved } = require('./render');
const { createMachineScreen } = require('../machine-screen');
const { createScreenShares } = require('../screen-shares');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
@@@ -579,6 +579,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
computer_level: typeof sensors.computer_level === 'string' ? sensors.computer_level : null,
computer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
computer_screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',
++ computer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
};
}));
// ── The live screen (plan T14): the owner watches their machine's screen on their own private stream ──
@@@ -649,6 +650,40 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const mine = await myMachine(req, me);
res.json(await machineScreen.input(mine, me.subject, req.body || {}));
}));
++ // The desks running on the machine (Desks plan): asked of the machine when the owner opens the page, never on a
++ // schedule; each can be watched, taken over or stopped.
++ r.get('/machines/:id/desks', page(async (req, res) => {
++ const me = requireUser(req);
++ const mine = await myMachine(req, me);
++ res.setHeader('Vary', 'Cookie');
++ res.setHeader('Cache-Control', 'private, no-store');
++ let desks = [];
++ let maxDesks = null;
++ let error = null;
++ if (!hub || !hub.isOnline(mine.id)) error = 'The machine is offline: its desks stopped with it, or show here when it is back.';
++ else if (!(hub.computerOf(mine.id) || {}).desk) error = 'This machine runs no desks: it needs OpenVibe Node with the computer plugin (Linux with Xvfb, or Chromium for browser desks).';
++ else {
++ const r = await hub.machineCommand(mine.id, asUser(me), me.subject, { kind: 'desk', value: { action: 'list' }, ms: 8000 });
++ if (r && r.result === 'ack') {
++ const d = r.data || {};
++ desks = (Array.isArray(d.desks) ? d.desks : []).filter((x) => x && machineScreen.DESK.test(String(x.desk || '')) && x.desk !== 'main').slice(0, 50);
++ maxDesks = Number.isInteger(d.max_desks) ? d.max_desks : null;
++ } else error = `The machine did not list its desks: ${(r && (r.reason || r.result)) || 'no answer'}`.slice(0, 300);
++ }
++ const stopped = typeof req.query.stopped === 'string' && machineScreen.DESK.test(req.query.stopped) ? req.query.stopped : null;
++ res.type('html').send(renderMachineDesksPage({ machine: mine, desks, maxDesks, error, stopped }));
++ }));
++ r.post('/machines/:id/desks/stop', ...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 mine = await myMachine(req, me);
++ const desk = req.body && typeof req.body.desk === 'string' ? req.body.desk : '';
++ if (!machineScreen.DESK.test(desk) || desk === 'main') throw new BotError(422, 'bot.invalid_input', 'desk is private or desk-<name>');
++ if (!hub || !hub.isOnline(mine.id)) throw new BotError(409, 'bot.device_offline', 'the machine is offline');
++ const r = await hub.machineCommand(mine.id, asUser(me), me.subject, { kind: 'desk', value: { action: 'stop', desk }, ms: 15000 });
++ if (!r || r.result !== 'ack') throw new BotError(409, 'bot.device_refused', `the machine did not stop it: ${(r && (r.reason || r.result)) || 'no answer'}`.slice(0, 300));
++ res.redirect(303, `/machines/${encodeURIComponent(mine.id)}/desks?stopped=${encodeURIComponent(desk)}`);
++ }));
r.get('/machines/:id/screen.json', page(async (req, res) => {
const me = requireUser(req);
const mine = await myMachine(req, me);
ddiff --git a/test/machine-computer.test.js b/test/machine-computer.test.js
iindex 1a7f77b..6820c2b 100644
---- a/test/machine-computer.test.js
++++ b/test/machine-computer.test.js
@@@ -34,6 +34,7 @@ const { boot, check, done } = require('./helpers/app');
else if (m.kind === 'probe') c.send(frame('ack', { id: m.id, data: { status: 200, body: '{"up":true}', ms: 3 } }));
else if (m.kind === 'input') c.send(frame('ack', { id: m.id, data: { done: true, action: m.value.action } }));
else if (m.kind === 'browser') c.send(frame('ack', { id: m.id, data: { url: m.value.url || 'https://example.org/', title: 'Example', text: 'Example page', truncated: false, links: [] } }));
++ else if (m.kind === 'desk' && m.value.action === 'list') c.send(frame('ack', { id: m.id, data: { desks: [{ desk: 'desk-task-a', kind: 'browser', running: true, width: 1280, height: 800, since: 1793102400 }, { desk: 'private', kind: 'desktop', running: true, display: ':91', width: 1280, height: 800, since: 1793102000 }], max_desks: 4, available: true } }));
else if (m.kind === 'desk') c.send(frame('ack', { id: m.id, data: { running: m.value.action !== 'stop', display: ':91', width: 1280, height: 800 } }));
else if (m.kind === 'screen' && m.value.desk && m.value.desk !== 'main') c.send(frame('ack', { id: m.id, data: { format: 'png', width: 1280, height: 800, content_b64: 'iVBORw0KGgo=' } }));
else c.send(frame('nack', { id: m.id, fault_code: 'not_allowed', message: 'this machine allows observing only' }));
@@@ -339,6 +340,34 @@ const { boot, check, done } = require('./helpers/app');
mac.c.close();
});
++ await check('a machine\'s desks page: its running desks to watch, take over or stop, for its owner only; stop is same-site only', async () => {
++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
++ const host = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4, kinds: ['desktop', 'browser'] } });
++ host.c.send(frame('telemetry', { sensors: { computer_level: 'trusted', computer_waiting: 0, computer_running: 0, computer_desks: 2 } }));
++ await poll(async () => (await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text()).includes('2 desks running'));
++ const list = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
++ assert.ok(list.includes(`href="/machines/${host.deviceId}/desks"`), 'the card links its desks');
++ const page = await fetch(`${t.base}/machines/${host.deviceId}/desks`, { headers: cookie(alex) });
++ assert.strictEqual(page.status, 200);
++ const html = await page.text();
++ assert.deepStrictEqual(seen.at(-1).value, { action: 'list' }, 'asked of the machine when the page opens');
++ assert.ok(html.includes('<code>desk-task-a</code>') && html.includes('Browser desk') && html.includes('Whole desk'));
++ assert.ok(html.includes(`href="/machines/${host.deviceId}/screen?desk=desk-task-a"`), 'Watch and take over that desk');
++ assert.ok(html.includes('name="desk" value="desk-task-a"'), 'its Stop');
++ assert.ok(html.includes('At most 4 whole desks at once here'));
++ assert.strictEqual((await fetch(`${t.base}/machines/${host.deviceId}/desks`, { headers: cookie(bob) })).status, 404, 'nobody else');
++ const stop = (body, headers = {}) => fetch(`${t.base}/machines/${host.deviceId}/desks/stop`, { method: 'POST', redirect: 'manual', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex), ...headers } });
++ assert.strictEqual((await stop('desk=desk-task-a', { Origin: 'https://evil.test' })).status, 403);
++ assert.strictEqual((await stop('desk=main')).status, 422, 'never the person\'s own screen');
++ assert.strictEqual((await stop('desk=desk-../x')).status, 422);
++ const done = await stop('desk=desk-task-a');
++ assert.strictEqual(done.status, 303);
++ assert.match(done.headers.get('location'), /\/desks\?stopped=desk-task-a$/);
++ assert.deepStrictEqual(seen.at(-1).value, { action: 'stop', desk: 'desk-task-a' });
++ assert.ok((await (await fetch(`${t.base}${done.headers.get('location')}`, { headers: cookie(alex) })).text()).includes('Stopped <code>desk-task-a</code>'));
++ host.c.close();
++ });
++
await check('Watch sends only probes, only where the owner let their watches use the computer', async () => {
const watch = t.network.signService({ sub: 'svc:watch', cap: ['bot.computer.control'] });
const asWatch = { token: watch, headers: { 'X-OV-Subject': alex.subject } };