openvibers/openvibe.bot Commits
Programs on a desk (contracts 0.218.0, Node v0.26.0): exec with a desk goes only to a Node that runs it there, and the audit names the desk and detach; openvibe-contracts v0.218.0 (#185)
Changes
@@@ -187,7 +187,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras BotOpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.<!-- versions:start -->--- openvibe-contracts: v0.217.0++- openvibe-contracts: v0.218.0- openvibe-sdk: v0.65.0- openvibe-shared: v4.15.2<!-- versions:end -->@@@ -5,11 +5,11 @@"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step","port": 4630,"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",-- "contracts": "openvibe-contracts v0.217.0",++ "contracts": "openvibe-contracts v0.218.0","sdk": "openvibe-sdk v0.65.0","shared": "openvibe-shared v4.15.2","packages": [-- "openvibe-contracts v0.217.0",++ "openvibe-contracts v0.218.0","openvibe-sdk v0.65.0","openvibe-shared v4.15.2"],@@@ -13,7 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.217.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.218.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.2","pg": "^8.23.0",@@@ -845,9 +845,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.217.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.217.0",-- "integrity": "sha512-hvLStkPZQmUiXH9MSUo7nzLerg13+xOp4Ear4YXQUOEGtMtCDIbBSXhQxfIvgSkGNox2jR94V8WADY6k8gEmcg==",++ "version": "0.218.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.218.0",++ "integrity": "sha512-/cgAdxibISceSva51oCYz/382iLsXW5RU6kt8Lyt5JWPiL6QhL0193boUZS9Ls4SxnEgT/fiAtMU3IJY4qMKwg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -25,7 +25,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.217.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.218.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.2","pg": "^8.23.0",@@@ -45,6 +45,7 @@ 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_DESK_PROGRAMS = 'running a program on a desk needs OpenVibe Node v0.26.0 or newer (an older one would run it on the person\'s own screen)';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. */@@@ -66,7 +67,7 @@ const COMPUTER_MAX_MS = 600000;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 } : {}), ...(typeof value.kind === 'string' ? { kind: value.kind } : {}), ...(typeof value.profile === 'string' ? { profile: value.profile } : {}), ...(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, ...where, ...(value.detach === true ? { detach: true } : {}) };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 }; } }// Typed text can be anything (a password the person asked for): the audit keeps its length, never the text.@@@ -780,6 +781,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datconst 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 program on a desk (Node v0.26.0: terminal.desks): an older Node would run it in the person's own session.++ if (kind === 'exec' && desk !== 'main' && !(offered.terminal && offered.terminal.desks === true)) return refuse('bot.computer_off', NO_DESK_PROGRAMS, 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') {@@@ -235,6 +235,25 @@ const { boot, check, done } = require('./helpers/app');old.c.close();});++ await check('programs on a desk (contracts 0.218.0, Node v0.26.0): sent to a Node that runs them, audited with the desk; an older Node never gets one', async () => {++ const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4, kinds: ['desktop', 'browser'] };++ const now = await machine(alex, { ...COMPUTER, terminal: { ...COMPUTER.terminal, desks: true }, desk });++ const old = await machine(alex, { ...COMPUTER, desk });++ const run = await cmd(now.deviceId, { kind: 'exec', value: { argv: ['libreoffice', '--writer', 'notes.odt'], desk: 'desk-task-a', detach: true } }, { user: alex });++ assert.strictEqual(run.json.result, 'ack', run.text);++ assert.deepStrictEqual([seen.at(-1).kind, seen.at(-1).value.desk, seen.at(-1).value.detach], ['exec', 'desk-task-a', true]);++ const row = await t.db.one('SELECT value FROM command_audit WHERE robot_id = $1 AND kind = $2 ORDER BY id DESC LIMIT 1', [now.deviceId, 'exec']);++ assert.deepStrictEqual(row.value, { program: 'libreoffice', args: 2, desk: 'desk-task-a', detach: true }, 'never the arguments');++ const before = seen.length;++ const refused = await cmd(old.deviceId, { kind: 'exec', value: { argv: ['xterm'], desk: 'desk-task-a' } }, { user: alex });++ assert.deepStrictEqual([refused.status, refused.json.code], [409, 'bot.computer_off']);++ assert.match(refused.json.detail || refused.json.reason || '', /Node v0\.26\.0/);++ assert.strictEqual(seen.length, before, 'never sent');++ assert.strictEqual((await cmd(old.deviceId, { kind: 'exec', value: { argv: ['echo', 'hi'] } }, { user: alex })).json.result, 'ack', 'its own session as before');++ now.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;
@@ -187,7 +187,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
@@ -187,7 +187,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.
OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.217.0
+- openvibe-contracts: v0.218.0
- openvibe-sdk: v0.65.0
- openvibe-sdk: v0.65.0
- openvibe-shared: v4.15.2
- openvibe-shared: v4.15.2
<!-- versions:end -->
<!-- versions:end -->
@@ -5,11 +5,11 @@
@@ -5,11 +5,11 @@
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"port": 4630,
"port": 4630,
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
- "contracts": "openvibe-contracts v0.217.0",
+ "contracts": "openvibe-contracts v0.218.0",
"sdk": "openvibe-sdk v0.65.0",
"sdk": "openvibe-sdk v0.65.0",
"shared": "openvibe-shared v4.15.2",
"shared": "openvibe-shared v4.15.2",
"packages": [
"packages": [
- "openvibe-contracts v0.217.0",
+ "openvibe-contracts v0.218.0",
"openvibe-sdk v0.65.0",
"openvibe-sdk v0.65.0",
"openvibe-shared v4.15.2"
"openvibe-shared v4.15.2"
],
],
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.217.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.218.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.2",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -845,9 +845,9 @@
@@ -845,9 +845,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.217.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.217.0",
- "integrity": "sha512-hvLStkPZQmUiXH9MSUo7nzLerg13+xOp4Ear4YXQUOEGtMtCDIbBSXhQxfIvgSkGNox2jR94V8WADY6k8gEmcg==",
+ "version": "0.218.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.218.0",
+ "integrity": "sha512-/cgAdxibISceSva51oCYz/382iLsXW5RU6kt8Lyt5JWPiL6QhL0193boUZS9Ls4SxnEgT/fiAtMU3IJY4qMKwg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.217.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.218.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.2",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -45,6 +45,7 @@ const DEVICE_MAX_PAYLOAD = 640 * 1024;
@@ -45,6 +45,7 @@ const DEVICE_MAX_PAYLOAD = 640 * 1024;
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';
const NO_NAMED_DESKS = 'this machine runs one desk only (private): several named desks need OpenVibe Node v0.19.0 or newer';
+const NO_DESK_PROGRAMS = 'running a program on a desk needs OpenVibe Node v0.26.0 or newer (an older one would run it on the person\'s own screen)';
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_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';
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. */
/** The kinds of desk a machine runs (Node v0.23.0: desk.kinds); an older Node's desks are all whole desks. */
@@ -66,7 +67,7 @@ const COMPUTER_MAX_MS = 600000;
@@ -66,7 +67,7 @@ const COMPUTER_MAX_MS = 600000;
function computerSummary(kind, value) {
function computerSummary(kind, value) {
const where = deskOf(value) !== 'main' ? { desk: deskOf(value) } : {};
const where = deskOf(value) !== 'main' ? { desk: deskOf(value) } : {};
if (kind === 'desk') return { action: value.action, ...(typeof value.desk === 'string' ? { desk: value.desk } : {}), ...(typeof value.kind === 'string' ? { kind: value.kind } : {}), ...(typeof value.profile === 'string' ? { profile: value.profile } : {}), ...(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 } : {}), ...(typeof value.profile === 'string' ? { profile: value.profile } : {}), ...(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, ...where, ...(value.detach === true ? { detach: true } : {}) };
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 }; } }
// Typed text can be anything (a password the person asked for): the audit keeps its length, never the text.
// Typed text can be anything (a password the person asked for): the audit keeps its length, never the text.
@@ -780,6 +781,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -780,6 +781,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
const desk = kind === 'desk' ? (typeof value.desk === 'string' ? value.desk : 'private') : deskOf(value);
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' && !(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);
if (desk !== 'main' && desk !== 'private' && !(offered.desk && offered.desk.named === true)) return refuse('bot.computer_off', NO_NAMED_DESKS, summary);
+ // A program on a desk (Node v0.26.0: terminal.desks): an older Node would run it in the person's own session.
+ if (kind === 'exec' && desk !== 'main' && !(offered.terminal && offered.terminal.desks === true)) return refuse('bot.computer_off', NO_DESK_PROGRAMS, summary);
// A desk starts as a kind the machine runs (Desks plan D2): an older Node would quietly start a whole desk
// 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.
// for a browser desk, and a Mac runs browser desks only.
if (kind === 'desk' && value.action === 'start') {
if (kind === 'desk' && value.action === 'start') {
@@ -235,6 +235,25 @@ const { boot, check, done } = require('./helpers/app');
@@ -235,6 +235,25 @@ const { boot, check, done } = require('./helpers/app');
old.c.close();
old.c.close();
});
});
+ await check('programs on a desk (contracts 0.218.0, Node v0.26.0): sent to a Node that runs them, audited with the desk; an older Node never gets one', async () => {
+ const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4, kinds: ['desktop', 'browser'] };
+ const now = await machine(alex, { ...COMPUTER, terminal: { ...COMPUTER.terminal, desks: true }, desk });
+ const old = await machine(alex, { ...COMPUTER, desk });
+ const run = await cmd(now.deviceId, { kind: 'exec', value: { argv: ['libreoffice', '--writer', 'notes.odt'], desk: 'desk-task-a', detach: true } }, { user: alex });
+ assert.strictEqual(run.json.result, 'ack', run.text);
+ assert.deepStrictEqual([seen.at(-1).kind, seen.at(-1).value.desk, seen.at(-1).value.detach], ['exec', 'desk-task-a', true]);
+ const row = await t.db.one('SELECT value FROM command_audit WHERE robot_id = $1 AND kind = $2 ORDER BY id DESC LIMIT 1', [now.deviceId, 'exec']);
+ assert.deepStrictEqual(row.value, { program: 'libreoffice', args: 2, desk: 'desk-task-a', detach: true }, 'never the arguments');
+ const before = seen.length;
+ const refused = await cmd(old.deviceId, { kind: 'exec', value: { argv: ['xterm'], desk: 'desk-task-a' } }, { user: alex });
+ assert.deepStrictEqual([refused.status, refused.json.code], [409, 'bot.computer_off']);
+ assert.match(refused.json.detail || refused.json.reason || '', /Node v0\.26\.0/);
+ assert.strictEqual(seen.length, before, 'never sent');
+ assert.strictEqual((await cmd(old.deviceId, { kind: 'exec', value: { argv: ['echo', 'hi'] } }, { user: alex })).json.result, 'ack', 'its own session as before');
+ now.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 () => {
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).
// 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;
const { screen: _s, input: _i, ...headless } = COMPUTER;