openvibers/openvibe.bot Commits
Named desks (Desks plan D1, contracts 0.215.0): commands, the audit and the live screen name a desk-<name>; a machine on an older Node runs its private desk only; each desk is its own screen for the person-first rule (#176)
Changes
@@@ -38,18 +38,21 @@ function createMachineScreen({ domain, hub, now = () => Date.now() }) {const seconds = whole(opts.seconds, SECONDS, 'seconds');const fps = whole(opts.fps, FPS, 'fps');const desk = opts.desk == null || opts.desk === '' ? 'main' : String(opts.desk);-- if (desk !== 'main' && desk !== 'private') throw new BotError(422, 'bot.invalid_input', 'desk is main or private');++ if (desk !== 'main' && !/^(private|desk-[a-z0-9][a-z0-9-]{0,23})$/.test(desk)) throw new BotError(422, 'bot.invalid_input', 'desk is main, private or desk-<name>');if (!hub.isOnline(device.id)) throw new BotError(409, 'bot.device_offline', 'the machine is offline');// Before any key is made: the machine says whether it can (Node v0.12.0's computer plugin, a capture tool).const offered = hub.computerOf(device.id);if (!(offered && offered.screen && offered.screen.stream === true)) {throw new BotError(409, 'bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your graphical session');}-- if (desk === 'private' && !(offered.desk && offered.desk.screen)) {++ if (desk !== 'main' && !(offered.desk && offered.desk.screen)) {throw new BotError(409, 'bot.computer_off', 'this machine has no private desk to show: it needs OpenVibe Node v0.15.0 or newer on Linux, with Xvfb and allow_desk on');}++ if (desk !== 'main' && desk !== 'private' && offered.desk.named !== true) {++ throw new BotError(409, 'bot.computer_off', 'this machine runs one desk only (private): several named desks need OpenVibe Node v0.19.0 or newer');++ }const key = await domain.machines.screen.key(device, subject);-- const r = await hub.machineScreen(device.id, principal, subject, { action: 'start', whip_url: key.whipUrl, publish_key: key.key, seconds, fps, ...(desk === 'private' ? { desk } : {}) });++ const r = await hub.machineScreen(device.id, principal, subject, { action: 'start', whip_url: key.whipUrl, publish_key: key.key, seconds, fps, ...(desk !== 'main' ? { desk } : {}) });if (r.result !== 'ack') {const code = r.code || (r.result === 'expired' ? 'bot.device_timeout' : 'bot.device_refused');const status = r.result === 'refused' ? (code === 'bot.computer_off' || code === 'bot.device_offline' ? 409 : 422) : r.result === 'expired' ? 504 : 409;@@@ -44,6 +44,9 @@ const DEVICE_MAX_PAYLOAD = 640 * 1024;// screen) act there with `desk: private`, never on the owner's own screen.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';++/** 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');/** A command that acts on a screen (the mouse and keyboard, or the browser doing more than reading it). */const actsOnScreen = (kind, value) => kind === 'input' || (kind === 'browser' && !['read', 'close'].includes(value.action));const COMPUTER_KINDS = Object.keys(COMPUTER_CAP);@@@ -54,8 +57,8 @@ const COMPUTER_DEFAULT_MS = 60000;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 = value.desk === 'private' ? { desk: 'private' } : {};-- if (kind === 'desk') return { action: value.action, ...(Number.isInteger(value.width) && Number.isInteger(value.height) ? { width: value.width, height: value.height } : {}) };++ 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 === '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 }; } }@@@ -750,7 +753,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datif (!check.valid) return refuse('bot.invalid_input', `invalid ${kind}: ${(check.errors && check.errors[0] && (check.errors[0].message || JSON.stringify(check.errors[0]))) || 'bad value'}`.slice(0, 300));}const summary = screen-- ? { action: String(value.action), ...(Number.isInteger(value.seconds) ? { seconds: value.seconds } : {}), ...(Number.isInteger(value.fps) ? { fps: value.fps } : {}), ...(value.desk === 'private' ? { desk: 'private' } : {}) }++ ? { action: String(value.action), ...(Number.isInteger(value.seconds) ? { seconds: value.seconds } : {}), ...(Number.isInteger(value.fps) ? { fps: value.fps } : {}), ...(deskOf(value) !== 'main' ? { desk: deskOf(value) } : {}) }: computerSummary(kind, value);const device = deviceConns.get(deviceId);if (!device || !device.online) return refuse('bot.device_offline', 'the machine is offline', summary);@@@ -762,11 +765,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat} else if (!offered || !offered[COMPUTER_CAP[kind]]) {return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);}-- if (value.desk === 'private' && !(offered && offered.desk)) return refuse('bot.computer_off', NO_DESK, summary);++ // A desk other than the person's own screen needs a machine that has desks; one other than private (a named desk,++ // one per agent task) needs a machine that runs several (Node v0.19.0: desk.named). Kind desk names its desk in++ // value.desk too, private by default.++ 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);// 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)) {-- const turnKey = `${deviceId}|${value.desk === 'private' ? 'private' : 'main'}`;++ const turnKey = `${deviceId}|${deskOf(value)}`; // each desk is its own screen: a person on one never holds anotherconst person = conn.principal.kind === 'user' || conn.principal.kind === 'share';if (person) {if (personTurns.size > 5000) personTurns.clear();@@@ -140,6 +140,44 @@ const { boot, check, done } = require('./helpers/app');withDesk.c.close();});++ await check('named desks (contracts 0.215.0, Node v0.19.0): one per agent task, each its own screen; an older Node runs one', async () => {++ const multi = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });++ const single = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true } });++ const start = await cmd(multi.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a' } }, { user: alex });++ assert.strictEqual(start.json.result, 'ack');++ assert.deepStrictEqual(seen.at(-1).value, { action: 'start', desk: 'desk-task-a' }, 'the machine is told which desk');++ const open = await cmd(multi.deviceId, { kind: 'browser', value: { action: 'open', url: 'https://example.org/', desk: 'desk-task-a' } }, { user: alex });++ assert.strictEqual(open.json.result, 'ack');++ assert.strictEqual(seen.at(-1).value.desk, 'desk-task-a');++ const listed = await cmd(multi.deviceId, { kind: 'desk', value: { action: 'list' } }, { user: alex });++ assert.strictEqual(listed.json.result, 'ack');++ const rows = await t.db.many("SELECT kind, value FROM command_audit WHERE robot_id = $1 AND result = 'ack' ORDER BY id", [multi.deviceId]);++ assert.deepStrictEqual(rows.map((r) => [r.kind, r.value]), [++ ['desk', { action: 'start', desk: 'desk-task-a' }], ['browser', { action: 'open', host: 'example.org', desk: 'desk-task-a' }], ['desk', { action: 'list' }],++ ], 'the audit names the desk');++ // A machine on an older Node runs its private desk only: a named one is refused there, never sent.++ const sent = seen.length;++ const old = await cmd(single.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a' } }, { user: alex });++ assert.deepStrictEqual([old.status, old.json.code], [409, 'bot.computer_off']);++ assert.match(old.json.detail || old.json.reason || '', /Node v0\.19\.0/);++ assert.strictEqual((await cmd(single.deviceId, { kind: 'screen', value: { desk: 'desk-task-a' } }, { user: alex })).json.code, 'bot.computer_off');++ assert.strictEqual(seen.length, sent, 'nothing reached the older machine');++ // Names follow the contract.++ const bad = await cmd(multi.deviceId, { kind: 'screen', value: { desk: 'desk-../x' } }, { user: alex });++ assert.deepStrictEqual([bad.status, bad.json.code], [422, 'bot.invalid_input']);++ // Each desk is its own screen: the owner driving desk-task-a never holds the agent off desk-task-b.++ const token = actor();++ await t.call('POST', `/api/v1/machines/${multi.deviceId}/agent-computer`, { user: alex, body: { on: true } });++ assert.strictEqual((await cmd(multi.deviceId, { kind: 'input', value: { action: 'move', x: 5, y: 5, width: 1280, height: 800, desk: 'desk-task-a' } }, { user: alex })).json.result, 'ack');++ const held = await cmd(multi.deviceId, { kind: 'input', value: { action: 'click', x: 9, y: 9, width: 1280, height: 800, desk: 'desk-task-a' } }, asActor(token, alex.subject));++ assert.strictEqual(held.json.code, 'bot.person_driving', 'the desk the person drives waits');++ const other = await cmd(multi.deviceId, { kind: 'input', value: { action: 'click', x: 9, y: 9, width: 1280, height: 800, desk: 'desk-task-b' } }, asActor(token, alex.subject));++ assert.strictEqual(other.json.result, 'ack', 'another desk goes on');++ await t.call('POST', `/api/v1/machines/${multi.deviceId}/agent-computer`, { user: alex, body: { on: false } });++ multi.c.close();++ single.c.close();++ });++await check('the audit keeps what was asked in short, never stdin, arguments or content', async () => {const rows = await t.db.many('SELECT * FROM command_audit WHERE robot_id = $1 ORDER BY id', [mine.deviceId]);assert.ok(rows.length >= 3);@@@ -158,6 +158,21 @@ const { boot, check, done } = require('./helpers/app');assert.deepStrictEqual(seen.at(-1).value, { action: 'stop' });});++ await check('a desk on the live screen: private, or a named desk on a Node that runs several (contracts 0.215.0); never a bad name', async () => {++ const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true };++ const multi = await machine(alex, { ...SCREEN, desk: { ...desk, named: true, max_desks: 4 } });++ const r = await screen('POST', multi.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', 'the machine streams that desk');++ const single = await machine(alex, { ...SCREEN, desk });++ const old = await screen('POST', single.deviceId, { user: alex, body: { seconds: 60, desk: 'desk-task-a' } });++ assert.deepStrictEqual([old.status, old.json.code], [409, 'bot.computer_off']);++ const bad = await screen('POST', multi.deviceId, { user: alex, body: { seconds: 60, desk: 'desk-../x' } });++ assert.deepStrictEqual([bad.status, bad.json.code], [422, 'bot.invalid_input']);++ multi.c.close();++ single.c.close();++ });++await check('removing the machine archives its screen stream', async () => {const r = await form(`/machines/${mine.deviceId}/remove`, alex);assert.strictEqual(r.status, 303);
@@ -38,18 +38,21 @@ function createMachineScreen({ domain, hub, now = () => Date.now() }) {
@@ -38,18 +38,21 @@ function createMachineScreen({ domain, hub, now = () => Date.now() }) {
const seconds = whole(opts.seconds, SECONDS, 'seconds');
const seconds = whole(opts.seconds, SECONDS, 'seconds');
const fps = whole(opts.fps, FPS, 'fps');
const fps = whole(opts.fps, FPS, 'fps');
const desk = opts.desk == null || opts.desk === '' ? 'main' : String(opts.desk);
const desk = opts.desk == null || opts.desk === '' ? 'main' : String(opts.desk);
- if (desk !== 'main' && desk !== 'private') throw new BotError(422, 'bot.invalid_input', 'desk is main or private');
+ if (desk !== 'main' && !/^(private|desk-[a-z0-9][a-z0-9-]{0,23})$/.test(desk)) throw new BotError(422, 'bot.invalid_input', 'desk is main, private or desk-<name>');
if (!hub.isOnline(device.id)) throw new BotError(409, 'bot.device_offline', 'the machine is offline');
if (!hub.isOnline(device.id)) throw new BotError(409, 'bot.device_offline', 'the machine is offline');
// Before any key is made: the machine says whether it can (Node v0.12.0's computer plugin, a capture tool).
// Before any key is made: the machine says whether it can (Node v0.12.0's computer plugin, a capture tool).
const offered = hub.computerOf(device.id);
const offered = hub.computerOf(device.id);
if (!(offered && offered.screen && offered.screen.stream === true)) {
if (!(offered && offered.screen && offered.screen.stream === true)) {
throw new BotError(409, 'bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your graphical session');
throw new BotError(409, 'bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your graphical session');
}
}
- if (desk === 'private' && !(offered.desk && offered.desk.screen)) {
+ if (desk !== 'main' && !(offered.desk && offered.desk.screen)) {
throw new BotError(409, 'bot.computer_off', 'this machine has no private desk to show: it needs OpenVibe Node v0.15.0 or newer on Linux, with Xvfb and allow_desk on');
throw new BotError(409, 'bot.computer_off', 'this machine has no private desk to show: it needs OpenVibe Node v0.15.0 or newer on Linux, with Xvfb and allow_desk on');
}
}
+ if (desk !== 'main' && desk !== 'private' && offered.desk.named !== true) {
+ throw new BotError(409, 'bot.computer_off', 'this machine runs one desk only (private): several named desks need OpenVibe Node v0.19.0 or newer');
+ }
const key = await domain.machines.screen.key(device, subject);
const key = await domain.machines.screen.key(device, subject);
- const r = await hub.machineScreen(device.id, principal, subject, { action: 'start', whip_url: key.whipUrl, publish_key: key.key, seconds, fps, ...(desk === 'private' ? { desk } : {}) });
+ const r = await hub.machineScreen(device.id, principal, subject, { action: 'start', whip_url: key.whipUrl, publish_key: key.key, seconds, fps, ...(desk !== 'main' ? { desk } : {}) });
if (r.result !== 'ack') {
if (r.result !== 'ack') {
const code = r.code || (r.result === 'expired' ? 'bot.device_timeout' : 'bot.device_refused');
const code = r.code || (r.result === 'expired' ? 'bot.device_timeout' : 'bot.device_refused');
const status = r.result === 'refused' ? (code === 'bot.computer_off' || code === 'bot.device_offline' ? 409 : 422) : r.result === 'expired' ? 504 : 409;
const status = r.result === 'refused' ? (code === 'bot.computer_off' || code === 'bot.device_offline' ? 409 : 422) : r.result === 'expired' ? 504 : 409;
@@ -44,6 +44,9 @@ const DEVICE_MAX_PAYLOAD = 640 * 1024;
@@ -44,6 +44,9 @@ const DEVICE_MAX_PAYLOAD = 640 * 1024;
// screen) act there with `desk: private`, never on the owner's own screen.
// screen) act there with `desk: private`, never on the owner's own screen.
const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser', desk: 'desk' };
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_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';
+/** 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');
/** A command that acts on a screen (the mouse and keyboard, or the browser doing more than reading it). */
/** A command that acts on a screen (the mouse and keyboard, or the browser doing more than reading it). */
const actsOnScreen = (kind, value) => kind === 'input' || (kind === 'browser' && !['read', 'close'].includes(value.action));
const actsOnScreen = (kind, value) => kind === 'input' || (kind === 'browser' && !['read', 'close'].includes(value.action));
const COMPUTER_KINDS = Object.keys(COMPUTER_CAP);
const COMPUTER_KINDS = Object.keys(COMPUTER_CAP);
@@ -54,8 +57,8 @@ const COMPUTER_DEFAULT_MS = 60000;
@@ -54,8 +57,8 @@ const COMPUTER_DEFAULT_MS = 60000;
const COMPUTER_MAX_MS = 600000;
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). */
/** 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) {
function computerSummary(kind, value) {
- const where = value.desk === 'private' ? { desk: 'private' } : {};
- if (kind === 'desk') return { action: value.action, ...(Number.isInteger(value.width) && Number.isInteger(value.height) ? { width: value.width, height: value.height } : {}) };
+ 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 === 'exec') return { program: String(value.argv[0]).slice(0, 120), args: value.argv.length - 1 };
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 === '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 }; } }
if (kind === 'probe') { try { const u = new URL(value.target); return { scheme: u.protocol.replace(':', ''), host: u.host }; } catch { return { scheme: null }; } }
@@ -750,7 +753,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -750,7 +753,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
if (!check.valid) return refuse('bot.invalid_input', `invalid ${kind}: ${(check.errors && check.errors[0] && (check.errors[0].message || JSON.stringify(check.errors[0]))) || 'bad value'}`.slice(0, 300));
if (!check.valid) return refuse('bot.invalid_input', `invalid ${kind}: ${(check.errors && check.errors[0] && (check.errors[0].message || JSON.stringify(check.errors[0]))) || 'bad value'}`.slice(0, 300));
}
}
const summary = screen
const summary = screen
- ? { action: String(value.action), ...(Number.isInteger(value.seconds) ? { seconds: value.seconds } : {}), ...(Number.isInteger(value.fps) ? { fps: value.fps } : {}), ...(value.desk === 'private' ? { desk: 'private' } : {}) }
+ ? { action: String(value.action), ...(Number.isInteger(value.seconds) ? { seconds: value.seconds } : {}), ...(Number.isInteger(value.fps) ? { fps: value.fps } : {}), ...(deskOf(value) !== 'main' ? { desk: deskOf(value) } : {}) }
: computerSummary(kind, value);
: computerSummary(kind, value);
const device = deviceConns.get(deviceId);
const device = deviceConns.get(deviceId);
if (!device || !device.online) return refuse('bot.device_offline', 'the machine is offline', summary);
if (!device || !device.online) return refuse('bot.device_offline', 'the machine is offline', summary);
@@ -762,11 +765,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -762,11 +765,16 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
} else if (!offered || !offered[COMPUTER_CAP[kind]]) {
} else if (!offered || !offered[COMPUTER_CAP[kind]]) {
return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);
return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);
}
}
- if (value.desk === 'private' && !(offered && offered.desk)) return refuse('bot.computer_off', NO_DESK, summary);
+ // A desk other than the person's own screen needs a machine that has desks; one other than private (a named desk,
+ // one per agent task) needs a machine that runs several (Node v0.19.0: desk.named). Kind desk names its desk in
+ // value.desk too, private by default.
+ 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);
// Two hands, one screen: a person's input (the owner, or a share link's holder) holds the screen for a few
// 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.
// seconds, and an agent's acting command there is refused meanwhile, so it never fights them for the mouse.
if (!screen && actsOnScreen(kind, value)) {
if (!screen && actsOnScreen(kind, value)) {
- const turnKey = `${deviceId}|${value.desk === 'private' ? 'private' : 'main'}`;
+ const turnKey = `${deviceId}|${deskOf(value)}`; // each desk is its own screen: a person on one never holds another
const person = conn.principal.kind === 'user' || conn.principal.kind === 'share';
const person = conn.principal.kind === 'user' || conn.principal.kind === 'share';
if (person) {
if (person) {
if (personTurns.size > 5000) personTurns.clear();
if (personTurns.size > 5000) personTurns.clear();
@@ -140,6 +140,44 @@ const { boot, check, done } = require('./helpers/app');
@@ -140,6 +140,44 @@ const { boot, check, done } = require('./helpers/app');
withDesk.c.close();
withDesk.c.close();
});
});
+ await check('named desks (contracts 0.215.0, Node v0.19.0): one per agent task, each its own screen; an older Node runs one', async () => {
+ const multi = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });
+ const single = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true } });
+ const start = await cmd(multi.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a' } }, { user: alex });
+ assert.strictEqual(start.json.result, 'ack');
+ assert.deepStrictEqual(seen.at(-1).value, { action: 'start', desk: 'desk-task-a' }, 'the machine is told which desk');
+ const open = await cmd(multi.deviceId, { kind: 'browser', value: { action: 'open', url: 'https://example.org/', desk: 'desk-task-a' } }, { user: alex });
+ assert.strictEqual(open.json.result, 'ack');
+ assert.strictEqual(seen.at(-1).value.desk, 'desk-task-a');
+ const listed = await cmd(multi.deviceId, { kind: 'desk', value: { action: 'list' } }, { user: alex });
+ assert.strictEqual(listed.json.result, 'ack');
+ const rows = await t.db.many("SELECT kind, value FROM command_audit WHERE robot_id = $1 AND result = 'ack' ORDER BY id", [multi.deviceId]);
+ assert.deepStrictEqual(rows.map((r) => [r.kind, r.value]), [
+ ['desk', { action: 'start', desk: 'desk-task-a' }], ['browser', { action: 'open', host: 'example.org', desk: 'desk-task-a' }], ['desk', { action: 'list' }],
+ ], 'the audit names the desk');
+ // A machine on an older Node runs its private desk only: a named one is refused there, never sent.
+ const sent = seen.length;
+ const old = await cmd(single.deviceId, { kind: 'desk', value: { action: 'start', desk: 'desk-task-a' } }, { user: alex });
+ assert.deepStrictEqual([old.status, old.json.code], [409, 'bot.computer_off']);
+ assert.match(old.json.detail || old.json.reason || '', /Node v0\.19\.0/);
+ assert.strictEqual((await cmd(single.deviceId, { kind: 'screen', value: { desk: 'desk-task-a' } }, { user: alex })).json.code, 'bot.computer_off');
+ assert.strictEqual(seen.length, sent, 'nothing reached the older machine');
+ // Names follow the contract.
+ const bad = await cmd(multi.deviceId, { kind: 'screen', value: { desk: 'desk-../x' } }, { user: alex });
+ assert.deepStrictEqual([bad.status, bad.json.code], [422, 'bot.invalid_input']);
+ // Each desk is its own screen: the owner driving desk-task-a never holds the agent off desk-task-b.
+ const token = actor();
+ await t.call('POST', `/api/v1/machines/${multi.deviceId}/agent-computer`, { user: alex, body: { on: true } });
+ assert.strictEqual((await cmd(multi.deviceId, { kind: 'input', value: { action: 'move', x: 5, y: 5, width: 1280, height: 800, desk: 'desk-task-a' } }, { user: alex })).json.result, 'ack');
+ const held = await cmd(multi.deviceId, { kind: 'input', value: { action: 'click', x: 9, y: 9, width: 1280, height: 800, desk: 'desk-task-a' } }, asActor(token, alex.subject));
+ assert.strictEqual(held.json.code, 'bot.person_driving', 'the desk the person drives waits');
+ const other = await cmd(multi.deviceId, { kind: 'input', value: { action: 'click', x: 9, y: 9, width: 1280, height: 800, desk: 'desk-task-b' } }, asActor(token, alex.subject));
+ assert.strictEqual(other.json.result, 'ack', 'another desk goes on');
+ await t.call('POST', `/api/v1/machines/${multi.deviceId}/agent-computer`, { user: alex, body: { on: false } });
+ multi.c.close();
+ single.c.close();
+ });
+
await check('the audit keeps what was asked in short, never stdin, arguments or content', async () => {
await check('the audit keeps what was asked in short, never stdin, arguments or content', async () => {
const rows = await t.db.many('SELECT * FROM command_audit WHERE robot_id = $1 ORDER BY id', [mine.deviceId]);
const rows = await t.db.many('SELECT * FROM command_audit WHERE robot_id = $1 ORDER BY id', [mine.deviceId]);
assert.ok(rows.length >= 3);
assert.ok(rows.length >= 3);
@@ -158,6 +158,21 @@ const { boot, check, done } = require('./helpers/app');
@@ -158,6 +158,21 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual(seen.at(-1).value, { action: 'stop' });
assert.deepStrictEqual(seen.at(-1).value, { action: 'stop' });
});
});
+ await check('a desk on the live screen: private, or a named desk on a Node that runs several (contracts 0.215.0); never a bad name', async () => {
+ const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true };
+ const multi = await machine(alex, { ...SCREEN, desk: { ...desk, named: true, max_desks: 4 } });
+ const r = await screen('POST', multi.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', 'the machine streams that desk');
+ const single = await machine(alex, { ...SCREEN, desk });
+ const old = await screen('POST', single.deviceId, { user: alex, body: { seconds: 60, desk: 'desk-task-a' } });
+ assert.deepStrictEqual([old.status, old.json.code], [409, 'bot.computer_off']);
+ const bad = await screen('POST', multi.deviceId, { user: alex, body: { seconds: 60, desk: 'desk-../x' } });
+ assert.deepStrictEqual([bad.status, bad.json.code], [422, 'bot.invalid_input']);
+ multi.c.close();
+ single.c.close();
+ });
+
await check('removing the machine archives its screen stream', async () => {
await check('removing the machine archives its screen stream', async () => {
const r = await form(`/machines/${mine.deviceId}/remove`, alex);
const r = await form(`/machines/${mine.deviceId}/remove`, alex);
assert.strictEqual(r.status, 303);
assert.strictEqual(r.status, 303);