openvibers/openvibe.bot Commits

Browser desks (Desks plan D2): a desk starts only as a kind its machine runs (an older Node is never sent one), the audit names the kind, and the screen page offers only what the machine has (a desk host no screen of its own, a Mac no whole desk) (#181)

OP OpenVibers committed 17 days ago · 9432b2cef8 · 5 files, +68 −9

Changes

ddiff --git a/server/realtime.js b/server/realtime.js
iindex 7edbc49..914fc0d 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -45,6 +45,10 @@ const DEVICE_MAX_PAYLOAD = 640 * 1024;
const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser', desk: 'desk' };
const NO_DESK = 'this machine has no private desk: it needs OpenVibe Node v0.15.0 or newer on Linux, with Xvfb and allow_desk on';
const NO_NAMED_DESKS = 'this machine runs one desk only (private): several named desks need OpenVibe Node v0.19.0 or newer';
++const NO_BROWSER_DESKS = 'this machine runs no browser desks: they need OpenVibe Node v0.23.0 or newer, with Chromium, Chrome, Edge or Brave';
++const NO_WHOLE_DESKS = 'this machine runs browser desks only (a whole desk needs Linux with Xvfb): start it with kind browser';
++/** The kinds of desk a machine runs (Node v0.23.0: desk.kinds); an older Node's desks are all whole desks. */
++const deskKinds = (offered) => (offered && offered.desk ? (Array.isArray(offered.desk.kinds) ? offered.desk.kinds : ['desktop']) : []);
/** The desk a command names: main (the person's own screen, the default), private or desk-<name> (contracts 0.215.0). */
const deskOf = (value) => (typeof value.desk === 'string' && value.desk ? value.desk : 'main');
// The screen and the mouse and keyboard of a desk are the desk's own (Node's desk.screen, desk.input): a desk host with
@@@ -61,7 +65,7 @@ const COMPUTER_MAX_MS = 600000;
/** What an audit row keeps of a computer command: never a file's content, stdin or arguments (they may hold secrets). */
function computerSummary(kind, value) {
const where = deskOf(value) !== 'main' ? { desk: deskOf(value) } : {};
-- if (kind === 'desk') return { action: value.action, ...(typeof value.desk === 'string' ? { desk: value.desk } : {}), ...(Number.isInteger(value.width) && Number.isInteger(value.height) ? { width: value.width, height: value.height } : {}) };
++ if (kind === 'desk') return { action: value.action, ...(typeof value.desk === 'string' ? { desk: value.desk } : {}), ...(typeof value.kind === 'string' ? { kind: value.kind } : {}), ...(Number.isInteger(value.width) && Number.isInteger(value.height) ? { width: value.width, height: value.height } : {}) };
if (kind === 'exec') return { program: String(value.argv[0]).slice(0, 120), args: value.argv.length - 1 };
if (kind === 'fs') return { op: value.op, path: String(value.path).slice(0, 300), ...(typeof value.content_b64 === 'string' ? { bytes: Math.floor((value.content_b64.length * 3) / 4) } : {}) };
if (kind === 'probe') { try { const u = new URL(value.target); return { scheme: u.protocol.replace(':', ''), host: u.host }; } catch { return { scheme: null }; } }
@@@ -776,6 +780,12 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
const desk = kind === 'desk' ? (typeof value.desk === 'string' ? value.desk : 'private') : deskOf(value);
if (desk !== 'main' && !(offered && offered.desk)) return refuse('bot.computer_off', NO_DESK, summary);
if (desk !== 'main' && desk !== 'private' && !(offered.desk && offered.desk.named === true)) return refuse('bot.computer_off', NO_NAMED_DESKS, summary);
++ // A desk starts as a kind the machine runs (Desks plan D2): an older Node would quietly start a whole desk
++ // for a browser desk, and a Mac runs browser desks only.
++ if (kind === 'desk' && value.action === 'start') {
++ const wanted = typeof value.kind === 'string' ? value.kind : 'desktop';
++ if (!deskKinds(offered).includes(wanted)) return refuse('bot.computer_off', wanted === 'browser' ? NO_BROWSER_DESKS : NO_WHOLE_DESKS, summary);
++ }
// Two hands, one screen: a person's input (the owner, or a share link's holder) holds the screen for a few
// seconds, and an agent's acting command there is refused meanwhile, so it never fights them for the mouse.
if (!screen && actsOnScreen(kind, value)) {
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 503be15..a4923b0 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -822,7 +822,7 @@ ${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.' });
}
--function renderMachineScreenPage({ machine, until = null, error = null, idle = false, shares = [], madeLink = null, madeShare = null, hasDesk = false, desk = 'main' }) {
++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`;
const base = `/machines/${encodeURIComponent(machine.id)}/screen`;
@@@ -836,8 +836,8 @@ function renderMachineScreenPage({ machine, until = null, error = null, idle = f
<h1>${esc(name)}: screen</h1>
${agentDesk ? `<p>Your agent works on a desk of its own on this computer. Watch it live, and take it over whenever you like: while you use the mouse and keyboard, the agent waits; when you stop, it goes on.</p>
<form method="post" action="/machines/${esc(machine.id)}/screen" class="inline-form"><input type="hidden" name="desk" value="${esc(desk)}"><button type="submit" class="primary">Watch your agent's desk</button></form>` : ''}
--<p class="muted">Show this computer's screen here, live, to you only, for 10 minutes. Nothing is recorded, and the computer says on its desktop that its screen is live.</p>
--<form method="post" action="/machines/${esc(machine.id)}/screen" class="inline-form"><button type="submit" class="${agentDesk ? 'button' : 'primary'}">Show my screen</button></form>
++${hasScreen ? `<p class="muted">Show this computer's screen here, live, to you only, for 10 minutes. Nothing is recorded, and the computer says on its desktop that its screen is live.</p>
++<form method="post" action="/machines/${esc(machine.id)}/screen" class="inline-form"><button type="submit" class="${agentDesk ? 'button' : 'primary'}">Show my screen</button></form>` : '<p class="muted">This computer has no screen of its own to show (a desk host): only its desks, where your agents work. A task\'s Watch link opens its desk.</p>'}
${hasDesk ? `<form method="post" action="/machines/${esc(machine.id)}/screen" class="inline-form"><input type="hidden" name="desk" value="private"><button type="submit" class="button">Show the private desk</button></form>
<p class="muted">The private desk is a screen of its own on this computer, where your agent works without touching yours.</p>` : ''}
${sharesBlock(machine, shares, madeLink, madeShare, hasDesk)}
@@@ -865,7 +865,7 @@ ${takeOver}
<form method="post" action="/machines/${esc(machine.id)}/screen/stop"><button type="submit" class="primary">Stop showing it</button></form>
<form method="post" action="/machines/${esc(machine.id)}/screen">${desk !== 'main' ? `<input type="hidden" name="desk" value="${esc(desk)}">` : ''}<button type="submit" class="button quiet">${error ? 'Try again' : 'Start again (10 more minutes)'}</button></form>
</div>
--<p class="muted small">The machine says on its desktop that its screen is live. <code>openvibe-node stop</code> on the machine ends it at once, and it ends on its own after its time.</p>
++<p class="muted small">${desk === 'main' ? 'The machine says on its desktop that its screen is live. ' : 'Nothing of your own screen is shown: this is the desk\'s own. '}<code>openvibe-node stop</code> on the machine ends it at once, and it ends on its own after its time.</p>
${sharesBlock(machine, shares, madeLink, madeShare)}
</main>`;
return page(`${name}: screen`, body, { scripts: ['/panel/panel.js', '/panel/screen-share.js'], description: 'Your machine\'s screen, live, for you only.', canonical: '/machines' });
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 70113ab..ac5266f 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -590,7 +590,16 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
};
const asUser = (me) => ({ kind: 'user', sub: me.subject });
// The machine has a private desk to show (Node v0.15.0 on Linux): its owner may show it, or share it, instead.
-- const hasDesk = (mine) => !!(hub && (hub.computerOf(mine.id) || {}).desk);
++ // The private desk is a whole desk: a machine that runs browser desks only (a Mac, Node v0.23.0) has none to show.
++ const hasDesk = (mine) => {
++ const d = hub && (hub.computerOf(mine.id) || {}).desk;
++ return !!d && (!Array.isArray(d.kinds) || d.kinds.includes('desktop'));
++ };
++ // A screen of its own to show live: a desk host (install.sh --desk-host) has none, only its desks.
++ const hasScreen = (mine) => {
++ const c = hub && hub.computerOf(mine.id);
++ return !!(c && c.screen && c.screen.stream === true);
++ };
r.post('/machines/:id/screen', ...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');
@@@ -603,7 +612,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
} catch (e) {
if (!(e instanceof BotError)) throw e;
res.setHeader('Content-Security-Policy', PANEL_CSP);
-- res.status(e.status).type('html').send(renderMachineScreenPage({ machine: mine, error: e.detail || e.message, hasDesk: hasDesk(mine) }));
++ res.status(e.status).type('html').send(renderMachineScreenPage({ machine: mine, error: e.detail || e.message, hasDesk: hasDesk(mine), hasScreen: hasScreen(mine) }));
}
}));
r.get('/machines/:id/screen', page(async (req, res) => {
@@@ -617,7 +626,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const idle = !until && st.state !== 'live';
res.setHeader('Vary', 'Cookie');
res.setHeader('Content-Security-Policy', PANEL_CSP);
-- res.type('html').send(renderMachineScreenPage({ machine: mine, until, idle, shares: await shares.list(mine, me.subject).catch(() => []), hasDesk: hasDesk(mine), desk: idle ? desk : st.desk }));
++ res.type('html').send(renderMachineScreenPage({ machine: mine, until, idle, shares: await shares.list(mine, me.subject).catch(() => []), hasDesk: hasDesk(mine), hasScreen: hasScreen(mine), desk: idle ? desk : st.desk }));
}));
// The owner's page drives what it shows (public/screen-share.js, owner mode): its state, start, stop and the mouse
// and keyboard ("Take over"), as JSON, same-site only.
@@@ -665,7 +674,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const mine = await myMachine(req, me);
const made = await shares.create(mine, me.subject, { label: req.body && req.body.label, control: req.body && req.body.control === '1', days: (req.body && req.body.days) || 30, desk: req.body && req.body.desk === 'private' ? 'private' : 'main' });
res.setHeader('Content-Security-Policy', PANEL_CSP);
-- res.type('html').send(renderMachineScreenPage({ machine: mine, idle: true, shares: await shares.list(mine, me.subject), madeLink: `${config.baseUrl}/screen/${made.token}`, madeShare: made.share, hasDesk: hasDesk(mine) }));
++ res.type('html').send(renderMachineScreenPage({ machine: mine, idle: true, shares: await shares.list(mine, me.subject), madeLink: `${config.baseUrl}/screen/${made.token}`, madeShare: made.share, hasDesk: hasDesk(mine), hasScreen: hasScreen(mine) }));
}));
r.post('/machines/:id/shares/:share/revoke', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
ddiff --git a/test/machine-computer.test.js b/test/machine-computer.test.js
iindex bffbb7c..965e55e 100644
---- a/test/machine-computer.test.js
++++ b/test/machine-computer.test.js
@@@ -198,6 +198,35 @@ const { boot, check, done } = require('./helpers/app');
single.c.close();
});
++ await check('browser desks (contracts 0.216.0, Node v0.23.0): a desk starts as a kind the machine runs; the audit names it; an older Node is never sent one', async () => {
++ const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 };
++ const both = await machine(alex, { ...COMPUTER, desk: { ...desk, kinds: ['desktop', 'browser'] } });
++ const mac = await machine(alex, { ...COMPUTER, desk: { ...desk, window_manager: null, kinds: ['browser'] } });
++ const old = await machine(alex, { ...COMPUTER, desk });
++ const page = await cmd(both.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a', kind: 'browser' } }, { user: alex });
++ assert.strictEqual(page.json.result, 'ack', page.text);
++ assert.deepStrictEqual(seen.at(-1).value, { action: 'start', desk: 'desk-task-a', kind: 'browser' }, 'the machine is told the kind');
++ assert.strictEqual((await cmd(both.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-b', kind: 'desktop' } }, { user: alex })).json.result, 'ack');
++ assert.strictEqual((await cmd(mac.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a', kind: 'browser' } }, { user: alex })).json.result, 'ack');
++ const before = seen.length;
++ const whole = await cmd(mac.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-b' } }, { user: alex });
++ assert.deepStrictEqual([whole.status, whole.json.code], [409, 'bot.computer_off']);
++ assert.match(whole.json.detail || whole.json.reason || '', /browser desks only/);
++ const older = await cmd(old.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a', kind: 'browser' } }, { user: alex });
++ assert.deepStrictEqual([older.status, older.json.code], [409, 'bot.computer_off'], 'an older Node would start a whole desk instead');
++ assert.match(older.json.detail || older.json.reason || '', /Node v0\.23\.0/);
++ const bad = await cmd(both.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-c', kind: 'vm' } }, { user: alex });
++ assert.strictEqual(bad.status, 422);
++ assert.strictEqual(seen.length, before, 'nothing refused reached a machine');
++ // An older Node still starts its whole desks as before (no kind).
++ assert.strictEqual((await cmd(old.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a' } }, { user: alex })).json.result, 'ack');
++ const audit = await t.db.many('SELECT value FROM command_audit WHERE robot_id = $1 AND kind = $2 AND result = $3 ORDER BY id', [both.deviceId, 'desk', 'ack']);
++ assert.deepStrictEqual(audit.map((r) => r.value.kind), ['browser', 'desktop'], 'the audit names the kind');
++ both.c.close();
++ mac.c.close();
++ old.c.close();
++ });
++
await check('a desk host (Node v0.20.0 --desk-host): no screen of its own, yet its desks are seen and driven', async () => {
// Nobody is signed in to a desk host: it offers no screen or input of its own, only its desks' (desk.screen, desk.input).
const { screen: _s, input: _i, ...headless } = COMPUTER;
ddiff --git a/test/machine-screen.test.js b/test/machine-screen.test.js
iindex 4f9454e..9c08362 100644
---- a/test/machine-screen.test.js
++++ b/test/machine-screen.test.js
@@@ -176,6 +176,17 @@ const { boot, check, done } = require('./helpers/app');
await check('a desk host (Node v0.20.0 --desk-host) shows its desks live though it has no screen of its own', async () => {
const host = await machine(alex, { files: SCREEN.files, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });
++ // Its page offers its desks, never a screen it does not have.
++ const idle = await (await fetch(`${t.base}/machines/${host.deviceId}/screen?desk=desk-task-a`, { headers: cookie(alex) })).text();
++ assert.ok(!idle.includes('Show my screen'), 'no screen of its own to show');
++ assert.ok(idle.includes('no screen of its own'));
++ assert.ok(idle.includes('Show the private desk'));
++ // A Mac runs browser desks only: no private desk (a whole desk) to show.
++ const mac = await machine(alex, { ...SCREEN, desk: { level: 'trusted', window_manager: null, input: true, screen: true, named: true, max_desks: 2, kinds: ['browser'] } });
++ const macPage = await (await fetch(`${t.base}/machines/${mac.deviceId}/screen`, { headers: cookie(alex) })).text();
++ assert.ok(macPage.includes('Show my screen'));
++ assert.ok(!macPage.includes('Show the private desk'), 'a Mac has no whole desk');
++ mac.c.close();
const r = await screen('POST', host.deviceId, { user: alex, body: { seconds: 60, desk: 'desk-task-a' } });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(seen.at(-1).value.desk, 'desk-task-a');