openvibers/openvibe.bot Commits

Bot: a person's own computer for their agents — machine commands (exec, fs, screen) with their answer, the owner's switch, and the machines page (#102)

OP OpenVibers committed 30 days ago · 32a5e4b3a2 · 13 files, +368 −14

Plan T14/T17: a person's computer for their agents. This is Bot's side of Node#41 (the `computer` plugin) and Contracts 0.166.0.

**API**
- `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` to a machine's computer plugin. The value is checked against `bot.command@1`, and the machine's answer comes back in `data`.
- Who may send:
  - the machine's owner;
  - their agent (`svc:actor` with `bot.computer.control`, acting as the owner), only after the owner turns "Let my agent use this computer" on.
- Nobody else reaches the machine (404 for another person, 403 for another service), and a robot's operators can never send these kinds.
- `GET /api/v1/machines` lists the machines an agent may use (all of the person's machines for the person) with each one's level, capabilities and waiting count. `GET /api/v1/machines/:id/computer` returns one.
- `POST /api/v1/machines/:id/agent-computer` lets the owner (never the agent) turn the switch on or off.

**What the machine decides:** the level set on the machine (Node docs/computer.md) controls what may happen there; `ask` waits for `openvibe-node approve`. A machine whose plugin is off answers `409 bot.computer_off`.

**Hub**
- Computer commands share the robot commands' per-subject idempotency and in-flight guard. A repeated id answers its result without the data, so the result cache never holds screens.
- Default reply window 60 s, at most 10 min.
- The audit row keeps the program name and argument count, or the file op and path. It never keeps stdin, arguments or content.
- The device socket now takes frames up to 640 KB, because the Node's answer may be up to 512 KB. Before, a frame over 64 KB closed the socket.
- The device config lists the computer kinds for a machine that serves no robot, or for a device whose status declares the plugin. A robot that turns the plugin on gets a fresh config.
- The commands route alone takes bodies up to 400 KB (a file write).

**Data:** migration 0014, an expand that adds `devices.agent_computer` (default false).

**Machines page**
- Each machine shows its level, what it offers and what waits for approval on it, plus the switch, in its own full-width row of the card.
- The add-machine card also shows the `--computer ask` install command.
- Checked at 390 and 1280 px: no overflow.

**Also:** contracts pinned to v0.166.0; STATUS.json and README updated.

**Tests:** `test/machine-computer.test.js` with a fake Node:
- the owner's exec and fs, with data;
- a 200 KB answer;
- a nack's message;
- the audit summary never holds stdin;
- nobody else; bad values; plugin off;
- the agent's opt-in, acting as the owner only, and the capability;
- the page and the form switch (owner only, same-site);
- robot operators refused.

The full suite passes (40/40).

Changes

ddiff --git a/README.md b/README.md
iindex d528169..c76934a 100644
---- a/README.md
++++ b/README.md
@@@ -42,6 +42,7 @@ Sitemap entries use the release commit time for these code-backed pages.
- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe.Node `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe.Node `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.bot/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).
++- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe.Node's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-node approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).
- A server-side ONVIF camera (`camera.onvif`) is not installed on a machine: it gets no `--driver` and Bot attaches it itself from `BOT_ONVIF_CAMERAS` (see Deploy), so its panel works as soon as the camera answers.
- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe.Node's `docs/hardware-adeept.md` (T15 step 1).
- A robot can use its own `local.rob_…` profile, stored with that robot and validated against `bot.robot-profile@1` and Bot's capability, driver and widget registries. On the owner's robot page, the Buttons form adds or removes rows and saves each button's name, label, optional keyboard key, cooldown in milliseconds and hold setting. The Video click checkbox enables a separate point cooldown. Only the owner can save or return to the robot's catalogue profile; each change is audited.
@@@ -82,6 +83,7 @@ per export and deletion in `account_data_events` (migration 0008).
|---|---|
| `bot.robot.read` | `GET /robots`, `GET /robots/:id`, operators, devices, the audit and the streaming toggles, for a service acting for an owner |
| `bot.robot.manage` | create, patch and delete a robot, pairing codes, operators, and the streaming toggles (`POST /robots/:id/streaming`) |
++| `bot.computer.control` | an agent service (OpenVibe.Actor) uses a person's own machine: `GET /machines` (the person's machines whose owner allowed it), `GET /machines/:id/computer`, `POST /machines/:id/commands` (exec, fs, screen) acting as that owner, only where the owner turned "Let my agent use this computer" on |
| `bot.robot.control` | the e-stop set and clear (clear is owner-only), the control gate and `POST /robots/:id/commands` (so a bound channel's chat can forward a command over HTTP instead of a hardware socket) |
| `bot.device.connect` | rotate and revoke a device credential |
| `bot.job.dispatch` | the internal Run → Bot jobs API (`POST /jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`); services only |
ddiff --git a/STATUS.json b/STATUS.json
iindex 5d661b3..2a9179e 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,7 +5,7 @@
"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.161.0",
++ "contracts": "openvibe-contracts v0.166.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.1.3",
"packages": [
@@@ -17,6 +17,7 @@
"bot.robot.read",
"bot.robot.manage",
"bot.robot.control",
++ "bot.computer.control",
"bot.device.connect",
"bot.job.dispatch",
"bot.resource.read"
ddiff --git a/migrations/0014_machine_agent_computer.sql b/migrations/0014_machine_agent_computer.sql
nnew file mode 100644
iindex 0000000..155f391
---- /dev/null
++++ b/migrations/0014_machine_agent_computer.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- Plan T14/T17: the owner lets their agent (OpenVibe.Actor, bot.computer.control) use this machine's computer plugin
++-- (terminal, files, screen). Off until the owner turns it on, on openvibe.bot/machines; the machine's own control level
++-- (set on the machine) still decides what may happen there.
++ALTER TABLE devices ADD COLUMN agent_computer boolean NOT NULL DEFAULT false;
ddiff --git a/package-lock.json b/package-lock.json
iindex ecbdd21..f2c26c7 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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.161.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.166.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
@@@ -845,9 +845,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.161.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.161.0",
-- "integrity": "sha512-2F1KBisvjjMbLI72G4TLsL1Z5aehb+69/nYQGFtF2tcmCeF5ecNvVJaJLRmHnB1cK/vJt+rxIZssE3xdyoFGrQ==",
++ "version": "0.166.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.166.0",
++ "integrity": "sha512-62U8JXv2sf2YVJaO4T4l8eszcm9qSpNCKMgbRX3xxeSFxytyR6jwcuB+GRhkVzp0DLssk9AIks7pUxG/n2fDtw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex 797a196..e4edd30 100644
---- a/package.json
++++ b/package.json
@@@ -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.161.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.166.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
ddiff --git a/public/panel.css b/public/panel.css
iindex a23e12a..3c627ab 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -39,6 +39,7 @@ input[type="range"] { width: 100%; accent-color: var(--accent); }
input[type="checkbox"] { width: 1.4rem; height: 1.4rem; min-height: 0; min-width: 0; accent-color: var(--accent); }
.pill { display: inline-flex; align-items: center; gap: 0.4rem; min-height: 1.75rem; padding: 0 0.65rem; border-radius: 999px; background: var(--raised); border: 1px solid var(--line); color: var(--muted); font-size: 0.85rem; white-space: nowrap; }
++.pill.warn { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 55%, var(--line)); }
.dot { width: 0.6rem; height: 0.6rem; border-radius: 50%; background: var(--muted); flex: none; }
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 1rem; }
.notice, .error { padding: 0.65rem 0.9rem; border-radius: 10px; background: rgba(210, 153, 34, 0.12); border: 1px solid rgba(210, 153, 34, 0.5); color: #f0d48a; }
@@@ -194,7 +195,9 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.robot-name .icon { color: var(--accent); }
.robot-meta { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.robot-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
--@media (min-width: 36rem) { .robot-card { grid-template-columns: minmax(0, 1fr) auto; align-items: center; } .robot-meta { grid-column: 1; grid-row: 2; } .robot-actions { grid-column: 2; grid-row: 1 / span 2; } }
++/* A machine's computer plugin (Node docs/computer.md): its level, what waits on it, and the agent switch, full width. */
++.machine-computer { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--line); }
++@media (min-width: 36rem) { .robot-card { grid-template-columns: minmax(0, 1fr) auto; align-items: center; } .robot-meta { grid-column: 1; grid-row: 2; } .robot-actions { grid-column: 2; grid-row: 1 / span 2; } .machine-computer { grid-column: 1 / -1; grid-row: 3; } .machine-computer form { margin-left: auto; } }
.empty { text-align: center; padding: 2rem 1rem; margin-bottom: 1.5rem; border: 1px dashed var(--line); border-radius: var(--radius); color: var(--muted); }
.empty-icon { width: 3rem; height: 3rem; stroke-width: 1.5; color: var(--accent); }
.empty-title { margin: 0.5rem 0 0.25rem; font-size: 1.15rem; font-weight: 600; color: var(--text); }
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 633ffce..aa1462f 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -48,6 +48,7 @@ const CAP = {
control: 'bot.robot.control',
connect: 'bot.device.connect',
dispatch: 'bot.job.dispatch',
++ computer: 'bot.computer.control',
};
/** Services that act as a person's agent (plan T15/T17): they drive a robot only where its owner turned agent_control on. */
@@@ -364,6 +365,68 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.json({ robot_id: req.params.id, ...result });
}));
++ // ── A person's own computer (the Node's computer plugin, plan T14/T17) ─────────────────────────────
++ // The machine's owner, or their agent (an agent service with bot.computer.control, acting for that owner) where the
++ // owner turned "Let my agent use this computer" on. What may happen there is the owner's level on the machine.
++ async function computerMachine(req, { agentNeedsOptIn = true } = {}) {
++ const subject = actedFor(req);
++ if (req.principal.kind === 'service') {
++ if (!AGENT_SERVICES.has(req.principal.sub)) fail(403, 'bot.forbidden', 'a machine is used by its owner and their agent only');
++ if (!apiAuth.granted(req.principal, CAP.computer)) fail(403, 'capability.denied', `${CAP.computer} not granted`);
++ }
++ const device = await domain.devices.get(String(req.params.id || '').slice(0, 64));
++ const ownerSubject = device && !device.revoked_at ? await domain.devices.ownerOf(device) : null;
++ if (!ownerSubject || ownerSubject !== subject) fail(404, 'bot.device_not_found', 'no such machine of yours');
++ if (agentNeedsOptIn && req.principal.kind === 'service' && !device.agent_computer) fail(403, 'bot.agent_not_allowed', 'the owner has not let their agent use this computer');
++ return { device, subject };
++ }
++ const machineOut = (device) => {
++ const st = hub.deviceState(device.id);
++ const offered = hub.computerOf(device.id);
++ 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 {
++ id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer,
++ computer: offered ? {
++ level: typeof sensors.computer_level === 'string' ? sensors.computer_level : ((pick('files') || pick('terminal') || pick('screen') || {}).level || null),
++ terminal: pick('terminal'), files: pick('files'), screen: pick('screen'),
++ waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
++ } : null,
++ };
++ };
++ // The person's machines (theirs or their robots'); an agent sees only those whose owner let it use them.
++ r.get('/machines', wrap(async (req, res) => {
++ const subject = actedFor(req, req.query.owner);
++ if (req.principal.kind === 'service') {
++ if (!AGENT_SERVICES.has(req.principal.sub)) fail(403, 'bot.forbidden', 'machines are listed for their owner and their agent');
++ if (!apiAuth.granted(req.principal, CAP.computer)) fail(403, 'capability.denied', `${CAP.computer} not granted`);
++ }
++ const rows = await domain.machines.list(subject);
++ const mine = req.principal.kind === 'service' ? rows.filter((d) => d.agent_computer) : rows;
++ res.json({ machines: mine.map(machineOut) });
++ }));
++ r.get('/machines/:id/computer', wrap(async (req, res) => {
++ const { device } = await computerMachine(req);
++ res.json({ machine: machineOut(device) });
++ }));
++ // One exec, fs or screen command, answered with the machine's answer in `data` (bot.command-result@1). A repeated
++ // id answers its first result without data; an `ask`-level machine answers once its owner approves it there.
++ r.post('/machines/:id/commands', commandControl, wrap(async (req, res) => {
++ const { device, subject } = await computerMachine(req);
++ const principal = req.principal.kind === 'service' ? { ...req.principal, subject } : req.principal;
++ const result = await hub.machineCommand(device.id, principal, subject, { id: req.body?.id, kind: req.body?.kind, value: req.body?.value, ms: req.body?.ms });
++ if (result.result === 'refused') fail(commandRefusalStatus(result.code), result.code, result.reason || null);
++ res.json({ machine_id: device.id, ...result });
++ }));
++ // The owner turns their agent's use of the machine on or off (never the agent itself).
++ 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 });
++ 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) });
++ }));
++
// ── 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).
@@@ -463,7 +526,7 @@ function commandRefusalStatus(code) {
if (code === 'bot.robot_not_found') return 404;
if (code === 'bot.not_an_operator' || code === 'bot.read_only' || code === 'bot.forbidden' || code === 'bot.not_your_turn') return 403;
if (code === 'bot.cooldown' || code === 'bot.turn_budget') return 429;
-- if (code === 'bot.device_offline' || code === 'bot.estop_latched' || code === 'bot.command_pending') return 409;
++ if (code === 'bot.device_offline' || code === 'bot.estop_latched' || code === 'bot.command_pending' || code === 'bot.computer_off') return 409;
return 422; // unknown_command, command_not_allowed, unknown_actuator, invalid_input, text_too_long
}
ddiff --git a/server/app.js b/server/app.js
iindex 2dcdc90..a162609 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -144,6 +144,8 @@ function createApp(opts = {}) {
release.mount(app, { registry: metrics.registry });
const limits = createActorLimits({ config, valkey, now: opts.limitsNow || now, registry: metrics.registry, log });
++ // A computer command may carry a file to write (≤ 256 KB, base64): its route alone takes a larger body.
++ app.use(/^\/api\/v1\/machines\/[^/]+\/commands$/, express.json({ limit: '400kb' }));
app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub, jobs, config, submissions, routines, webhooks }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
// ── The legal pages: Terms, Privacy and DMCA on openvibe.bot itself (openvibe-shared/legal). ──
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 19bfd5e..d1b2605 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -663,6 +663,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
SELECT 1 FROM robots r WHERE r.owner_subject = $1 AND d.robot_ids @> jsonb_build_array(r.id))))
ORDER BY d.created_at DESC`, [subject]);
}
++ /** Let the owner's agent use this machine's computer plugin, or stop it (the owner check is the caller's). */
++ async function setAgentComputer(deviceId, value) {
++ return db.maybe('UPDATE devices SET agent_computer = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
++ }
/** Whose machine a device is: a worker machine's owner, else its (first) robot's owner; null when neither. */
async function ownerOf(device) {
if (!device) return null;
@@@ -1080,7 +1084,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline, ownerOf, onChange },
-- machines: { mint: mintMachine, list: listMachines, workerRef },
++ machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer },
streaming: { get: streaming, set: setStreaming },
video: { live: liveVideo },
estop: { set: setEstop, clear: clearEstop },
ddiff --git a/server/realtime.js b/server/realtime.js
iindex 9444b22..47e3bd8 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -23,7 +23,7 @@
*/
const { WebSocketServer } = require('ws');
const { createControlTickets, ticketFromProtocols, PROTOCOL: TICKET_PROTOCOL } = require('./control-tickets');
--const { capabilities, ids } = require('openvibe-contracts');
++const { capabilities, ids, validate } = require('openvibe-contracts');
const { json, iso, prefixedId } = require('./util');
const { userPrincipal, verifyService, verifyNode, isNodeToken, PRINCIPAL_SUB, decodePayload } = require('./api/auth');
const { getProfile } = require('./profiles');
@@@ -34,6 +34,21 @@ const ACCESS_COOKIE = 'ov_token';
const DEVICE_KINDS = new Set(['onboard', 'bridge', 'server']);
const MAX_AGENT_VERSION = 40;
const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leave
++// A machine's answer to a computer command (exec output, a file, a screen) is at most 512 KB of JSON in its ack
++// (OpenVibe.Node protocol.MaxAckData), so a device frame may be that large; ws closes a socket on a larger frame.
++const DEVICE_MAX_PAYLOAD = 640 * 1024;
++// The Node's computer plugin (plan T14/T17): the kinds a person's machine takes, the capability each needs in its
++// status, and the reply window (the owner's approval on the machine plus the program's own run).
++const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen' };
++const COMPUTER_KINDS = Object.keys(COMPUTER_CAP);
++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) {
++ 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) } : {}) };
++ return { max_width: value.max_width || null };
++}
function parseCookies(header) {
const out = {};
@@@ -83,7 +98,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
// A browser device (a phone, public/phone-device.js) cannot set the upgrade's Authorization header: it offers its
// credential as a subprotocol (`ov.device`, `ov.device.<credential>`) and gets `ov.device` back.
-- const deviceWss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024, perMessageDeflate: false,
++ const deviceWss = new WebSocketServer({ noServer: true, maxPayload: DEVICE_MAX_PAYLOAD, perMessageDeflate: false,
handleProtocols: (protocols) => (protocols.has(DEVICE_PROTOCOL) ? DEVICE_PROTOCOL : false) });
// A socket that offers a control ticket gets the ticket protocol back (a browser drops a socket whose offered
// subprotocol is not answered); a plain one, none.
@@@ -269,7 +284,13 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
const profileRow = robot ? await getProfile(domain.db, robot.profile_id, robot.profile_version) : null;
const profile = profileRow ? profileRow.profile : null;
const limits = robot ? domain.control.deviceLimits(robot, profile) : { max_command_ms: config.control.maxCommandMs, heartbeat_ms: config.device.heartbeatMs };
-- const allowed = robot ? domain.control.allowedFor(robot, 'owner', profile) : ['halt'];
++ // The computer kinds, for a machine that serves no robot or a device whose computer plugin is on: they reach it
++ // only through POST /machines/:id/commands (its owner, or their agent where they allowed it), never a robot's
++ // operators, and the machine's computer plugin applies its owner's level.
++ const declared = (conn.status && conn.status.capabilities) || json(conn.device.capabilities, {}) || {};
++ conn.computerAllowed = !robot || !!declared.computer;
++ const base = robot ? domain.control.allowedFor(robot, 'owner', profile) : ['halt'];
++ const allowed = conn.computerAllowed ? [...new Set([...base, ...COMPUTER_KINDS])] : base;
sendFrame(conn, 'config', {
heartbeat_ms: config.device.heartbeatMs, limits, allowed_commands: allowed,
estop_latched: robot ? !!robot.estop_latched : false,
@@@ -327,6 +348,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
case 'telemetry': return onTelemetry(conn, msg);
case 'status':
conn.status = msg;
++ // A robot's machine that turns its computer plugin on is told it takes the computer kinds.
++ if (!conn.computerAllowed && msg.capabilities && msg.capabilities.computer) await sendConfig(conn);
for (const r of deviceRobotIds(conn.device)) broadcast(r);
return conn.device.node_principal ? persistDeclared(conn, msg) : undefined;
case 'reauth': if (conn.node) return onReauth(conn, msg); return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);
@@@ -380,9 +403,13 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
if (p.timer) clearTimeout(p.timer);
const result = msg.type === 'ack' ? 'ack' : 'nack';
const latency = now() - p.at;
++ // A computer command's answer goes to its caller once; the cache keeps the result only (a repeated id gets
++ // no data back), so 4096 cached results never hold 4096 screens.
cacheResult(p.key, { result, reason: msg.fault_code || null });
await domain.audit.record({ robotId: p.robotId, deviceId: p.deviceId, subject: p.subject, operatorKind: p.operatorKind, role: p.role, kind: p.kind, value: p.value, result, reason: msg.fault_code || null, latencyMs: latency });
-- sendFrame(p.conn, 'command_result', { id: p.opId, result, reason: msg.fault_code || null, latency_ms: latency });
++ const data = p.computer && result === 'ack' && msg.data && typeof msg.data === 'object' && !Array.isArray(msg.data) ? msg.data : null;
++ const message = p.computer && result === 'nack' && typeof msg.message === 'string' ? msg.message.replace(/[\u0000-\u001f\u007f]/g, ' ').slice(0, 300) : null;
++ sendFrame(p.conn, 'command_result', { id: p.opId, result, reason: message || msg.fault_code || null, latency_ms: latency, ...(data ? { data } : {}) });
}
// A device reports its own latch. It may latch a robot it is attached to; it never clears one — latched:false
@@@ -571,6 +598,82 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
});
}
++ /** What a machine's computer plugin offers, from its last status: { terminal?, files?, screen? }, or null. */
++ function computerOf(deviceId) {
++ const c = deviceConns.get(deviceId);
++ const caps = c && c.status && c.status.capabilities && c.status.capabilities.computer;
++ return caps && typeof caps === 'object' && !Array.isArray(caps) ? caps : null;
++ }
++
++ /**
++ * One computer command (exec, fs, screen) to a person's machine, off POST /machines/:id/commands (api/v1.js has
++ * already checked the caller: the machine's owner, or their agent where the owner allowed it). Idempotent per
++ * subject and id like a robot command; audited under the machine's device id with a summary only. The machine's
++ * computer plugin applies its owner's level, and its answer comes back in `data`.
++ * → { id, result, code?, reason?, latency_ms?, data?, cached? }
++ */
++ function machineCommand(deviceId, principal, subject, msg) {
++ return new Promise((resolve, reject) => {
++ let settled = false;
++ const finish = (r) => { if (!settled) { settled = true; resolve(r); } };
++ const conn = {
++ ws: {
++ readyState: OPEN,
++ send(raw) {
++ let frame; try { frame = JSON.parse(String(raw)); } catch { return; }
++ if (frame.type !== 'command_result') return;
++ const r = { id: frame.id != null ? String(frame.id) : null, result: frame.result };
++ for (const k of ['code', 'reason', 'latency_ms', 'data']) if (frame[k] != null) r[k] = frame[k];
++ if (frame.cached) r.cached = true;
++ finish(r);
++ },
++ },
++ seq: 0, principal, subject, robotId: null, role: 'owner',
++ };
++ sendMachineCommand(conn, deviceId, msg).catch((e) => { if (!settled) { settled = true; reject(e); } });
++ });
++ }
++
++ async function sendMachineCommand(conn, deviceId, msg) {
++ const subject = conn.subject;
++ const id = (msg.id != null ? String(msg.id) : prefixedId('op', now())).slice(0, 64);
++ const key = `${subject}|machine|${id}`;
++ const cached = results.get(key);
++ if (cached) return sendFrame(conn, 'command_result', { id, result: cached.result, reason: cached.reason, cached: true });
++ if (inflight.has(key)) return sendFrame(conn, 'command_result', { id, result: 'refused', code: 'bot.command_pending', reason: 'the same command id is still in flight' });
++ inflight.set(key, null);
++ try {
++ const kind = String(msg.kind || '');
++ const value = msg.value && typeof msg.value === 'object' && !Array.isArray(msg.value) ? msg.value : {};
++ const refuse = async (code, reason, summary = null) => {
++ await domain.audit.record({ robotId: deviceId, deviceId, subject, operatorKind: conn.principal.kind, role: 'owner', kind: COMPUTER_CAP[kind] ? kind : 'unknown', value: summary || {}, result: 'refused', reason: code });
++ return sendFrame(conn, 'command_result', { id, result: 'refused', code, reason });
++ };
++ if (!COMPUTER_CAP[kind]) return refuse('bot.invalid_input', `kind is ${COMPUTER_KINDS.join(', ')}`);
++ const check = validate('bot.command@1', { v: 1, seq: 0, ts: 0, type: 'command', id, kind, value });
++ 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 = computerSummary(kind, value);
++ const device = deviceConns.get(deviceId);
++ if (!device || !device.online) return refuse('bot.device_offline', 'the machine is offline', summary);
++ const offered = computerOf(deviceId);
++ if (!offered || !offered[COMPUTER_CAP[kind]]) return refuse('bot.computer_off', `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);
++ const requested = Number(msg.ms);
++ const windowMs = Number.isInteger(requested) && requested > 0 ? Math.min(Math.max(requested, 1000), COMPUTER_MAX_MS) : COMPUTER_DEFAULT_MS;
++ const deviceCmdId = prefixedId('cmd', now());
++ const deadlineMs = now() + windowMs;
++ const frame = { v: 1, type: 'command', seq: nextSeq(device), ts: now(), id: deviceCmdId, ref: id, kind, value, deadline_ms: deadlineMs, operator: { subject, role: 'owner' } };
++ if (!send(device.ws, frame)) return refuse('bot.device_offline', 'the machine is offline', summary);
++ const p = { id: deviceCmdId, opId: id, key, deviceId, conn, robotId: deviceId, subject, operatorKind: conn.principal.kind, role: 'owner', kind, value: summary, at: now(), timer: null, computer: true };
++ pendingCmds.set(deviceCmdId, p);
++ inflight.set(key, deviceCmdId);
++ p.timer = setTimeout(() => expireCommand(deviceCmdId), windowMs + 1500);
++ if (p.timer.unref) p.timer.unref();
++ return undefined;
++ } finally {
++ if (inflight.get(key) === null) inflight.delete(key);
++ }
++ }
++
async function onOperatorEstop(conn, latched) {
if (!conn.robotId) return sendError(conn, 'bot.not_joined', 'join a robot first');
const robotId = conn.robotId;
@@@ -787,6 +890,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
/** A one-time ticket for the embedded panel's Drive (POST /panel/:id/ticket), or null. */
issueControlTicket(principal, robotId) { return tickets.issue(principal, robotId); },
isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, dropSubject, operatorCommand,
++ machineCommand, computerOf,
// Real devices only: a simulator is not a machine anyone runs.
onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online && !c.sim) n++; return n; },
devices() { return [...deviceConns.values()].filter((c) => !c.sim).map((c) => ({ device_id: c.device.id, online: c.online })); },
ddiff --git a/server/web/render.js b/server/web/render.js
iindex f32a852..4fdcf49 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -588,10 +588,29 @@ function renderMachinesPage({ machines = [], pairing = null }) {
const w = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).worker || {};
return Array.isArray(w.runtime_classes) ? w.runtime_classes : [];
};
++ // The computer plugin (Node docs/computer.md): the level its owner set on the machine, and whether their agent may use it.
++ const LEVELS = { observe: 'Observe', ask: 'Ask first', trusted: 'Trusted', full: 'Full control' };
++ const computerOf = (m) => {
++ const c = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).computer;
++ if (!c || typeof c !== 'object') return null;
++ const level = m.computer_level || ((c.files || c.terminal || c.screen || {}).level) || null;
++ return { level: LEVELS[level] ? level : null, terminal: !!c.terminal, files: !!c.files, screen: !!c.screen };
++ };
++ const computerRow = (m) => {
++ const c = computerOf(m);
++ if (!c) return '';
++ const can = [c.terminal && 'terminal', c.files && 'files', c.screen && 'screen'].filter(Boolean).join(', ');
++ 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>${waiting}`
++ + `<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></div>`;
++ };
const list = machines.length
? `<ul class="robots">${machines.map((m) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span>${esc(m.name || m.id)}</span></span>`
+ `<span class="robot-meta"><span class="pill">${m.online ? 'Online' : 'Offline'}</span>${classesOf(m).map((c) => ` <span class="pill">${esc(c)} jobs</span>`).join('')}`
+ `${m.agent_version ? ` <span class="pill">Node ${esc(m.agent_version)}</span>` : ''}${m.robot ? ` <span class="pill">Serves ${esc(m.robot)}</span>` : ''}</span>`
++ + computerRow(m)
+ `<span class="robot-actions"><form method="post" action="/machines/${esc(m.id)}/remove"><button type="submit" class="button quiet">Remove</button></form></span></li>`).join('')}</ul>`
: `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No machines yet.</p><p>Add your computer or a server you own. It runs the jobs you start, such as a coding task on openvibe.codes, and nobody else's.</p></div>`;
const add = pairing
@@@ -599,6 +618,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {
<pre class="installer"><code data-copy-source>${esc(pairing.installer)}</code></pre>
<button type="button" class="copy" data-copy>${icon('copy')}<span data-copy-label>Copy the command</span></button>
<p class="muted">Linux with systemd. It installs OpenVibe Node, turns its job worker on (jobs run as a separate user, in a sandbox, with the limits you set) and pairs the machine with your account. For coding jobs the machine also needs Node.js 22 or newer and git, and a coding agent: Claude Code, Codex, OpenCode, or a DeepSeek key.</p></li>
++<li class="card"><h2>Or let your agents use this computer</h2>
++<pre class="installer"><code>${esc(pairing.installer.replace(/ --worker$/, ' --computer ask'))}</code></pre>
++<p class="muted">Instead of running jobs, the machine's terminal, files and screen become usable by your agents, at the level you set on the machine: <code>ask</code> waits for <code>openvibe-node approve</code> there before anything runs or changes. The Node runs as your own account, never root. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md">How it works</a>.</p></li>
<li class="card"><h2>Or enter the pairing code</h2><p><strong class="code">${esc(pairing.code)}</strong></p>
<p class="muted">Valid until <time datetime="${esc(pairing.expires_at)}">${esc(pairing.expires_at)}</time>. It works once.</p></li></ol>
<p class="muted">Reload this page once the installer says it is paired: the machine shows up above.</p>`
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 8be95f9..0dce6b0 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -427,7 +427,12 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const machineRows = async (subject) => Promise.all((await domain.machines.list(subject)).map(async (d) => {
const robotId = Array.isArray(d.robot_ids) ? d.robot_ids[0] : null;
const robot = robotId ? await domain.robots.get(robotId) : null;
-- return { ...d, online: !!(hub && hub.isOnline(d.id)), robot: robot ? robot.name : null };
++ const sensors = hub ? ((hub.deviceState(d.id).telemetry || {}).sensors || {}) : {};
++ return {
++ ...d, online: !!(hub && hub.isOnline(d.id)), robot: robot ? robot.name : null,
++ computer_level: typeof sensors.computer_level === 'string' ? sensors.computer_level : null,
++ computer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
++ };
}));
r.get('/machines', frameCsp, page(async (req, res) => {
const me = requireUser(req);
@@@ -440,6 +445,15 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const pairing = await domain.machines.mint(me.subject);
res.status(201).type('html').send(renderMachinesPage({ machines: await machineRows(me.subject), pairing }));
}));
++ // The owner lets their agent (OpenVibe.Actor) use this machine's computer plugin, or stops it.
++ r.post('/machines/:id/agent-computer', ...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 domain.machines.list(me.subject)).find((d) => d.id === req.params.id);
++ if (!mine) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');
++ await domain.machines.setAgentComputer(mine.id, req.body && req.body.on === '1');
++ res.redirect(303, '/machines');
++ }));
r.post('/machines/:id/remove', ...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');
ddiff --git a/test/machine-computer.test.js b/test/machine-computer.test.js
nnew file mode 100644
iindex 0000000..ce4894c
---- /dev/null
++++ b/test/machine-computer.test.js
@@@ -0,0 +1,134 @@
++'use strict';
++// A person's own computer (plan T14/T17, contracts 0.166.0): POST /api/v1/machines/:id/commands sends exec, fs and
++// screen to the machine's computer plugin for its owner, or for their agent (svc:actor with bot.computer.control) once
++// the owner turned "Let my agent use this computer" on; the machine's answer comes back in data (larger than the
++// old 64 KB frame cap); nobody else reaches it; the audit keeps a summary, never content; a robot's operators never
++// send these kinds; a machine whose plugin is off answers bot.computer_off.
++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' } });
++ const alex = t.network.newUser('alex');
++ const bob = t.network.newUser('bob');
++ 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 = { terminal: { level: 'trusted', timeout_s: 30 }, files: { level: 'trusted', roots: ['/home/alex'] }, screen: { level: 'trusted', tool: 'grim' } };
++ const seen = [];
++
++ /** A Network-paired machine of `owner` whose fake Node answers computer commands like the plugin would. */
++ async function machine(owner, computer = COMPUTER) {
++ 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: 'trusted', computer_waiting: 0, computer_running: 0 } }));
++ 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 === 'exec') c.send(frame('ack', { id: m.id, latency_ms: 5, data: { exit_code: 0, stdout: m.value.argv.includes('big') ? 'x'.repeat(200 * 1024) : 'hi\n', stderr: '', truncated: false, timed_out: false, duration_ms: 4 } }));
++ else if (m.kind === 'fs') c.send(frame('ack', { id: m.id, data: { path: m.value.path, entries: [], total: 0, truncated: false } }));
++ else c.send(frame('nack', { id: m.id, fault_code: 'not_allowed', message: 'this machine allows observing only' }));
++ });
++ await poll(() => t.hub.computerOf(deviceId) !== null || !computer);
++ return { c, deviceId, config };
++ }
++ const cmd = (deviceId, body, opts) => t.call('POST', `/api/v1/machines/${deviceId}/commands`, { body, ...opts });
++ const actor = (cap = ['bot.computer.control']) => t.network.signService({ sub: 'svc:actor', cap });
++ const asActor = (token, subject) => ({ token, headers: { 'X-OV-Subject': subject } });
++
++ let mine;
++ await check('the owner runs a program and reads a folder; the answer is in data', async () => {
++ mine = await machine(alex);
++ assert.ok(['exec', 'fs', 'screen'].every((k) => mine.config.allowed_commands.includes(k)), 'the machine is told it takes the computer kinds');
++ const r = await cmd(mine.deviceId, { id: 'a1', kind: 'exec', value: { argv: ['echo', 'hi'], stdin: 'secret input' } }, { user: alex });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.json.result, r.json.data.stdout, r.json.machine_id], ['ack', 'hi\n', mine.deviceId]);
++ const sent = seen.at(-1);
++ assert.deepStrictEqual([sent.kind, sent.operator.role, sent.value.argv], ['exec', 'owner', ['echo', 'hi']]);
++ assert.ok(sent.deadline_ms - Date.now() > 50000, 'a long reply window by default');
++ const ls = await cmd(mine.deviceId, { kind: 'fs', value: { op: 'list', path: '/home/alex' } }, { user: alex });
++ assert.deepStrictEqual([ls.status, ls.json.data.path], [200, '/home/alex']);
++ const again = await cmd(mine.deviceId, { id: 'a1', kind: 'exec', value: { argv: ['echo', 'hi'] } }, { user: alex });
++ assert.deepStrictEqual([again.json.result, again.json.cached, again.json.data], ['ack', true, undefined], 'a repeated id: its result, never the data again');
++ });
++
++ await check('an answer larger than 64 KB comes through; a refusal on the machine says why', async () => {
++ const big = await cmd(mine.deviceId, { kind: 'exec', value: { argv: ['cat', 'big'] } }, { user: alex });
++ assert.deepStrictEqual([big.status, big.json.data.stdout.length], [200, 200 * 1024]);
++ const nack = await cmd(mine.deviceId, { kind: 'screen', value: {} }, { user: alex });
++ assert.deepStrictEqual([nack.status, nack.json.result, nack.json.reason], [200, 'nack', 'this machine allows observing only']);
++ });
++
++ 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);
++ const exec = rows.find((r) => r.kind === 'exec');
++ assert.deepStrictEqual(exec.value, { program: 'echo', args: 1 });
++ assert.ok(!JSON.stringify(rows).includes('secret input'));
++ });
++
++ await check('nobody else reaches it; a bad value and a missing plugin are refused', async () => {
++ assert.strictEqual((await cmd(mine.deviceId, { kind: 'exec', value: { argv: ['id'] } }, { user: bob })).status, 404);
++ assert.strictEqual((await cmd(mine.deviceId, { kind: 'exec', value: { argv: ['id'] } }, { sub: 'svc:live', cap: ['bot.*'], headers: { 'X-OV-Subject': alex.subject } })).status, 403);
++ const shell = await cmd(mine.deviceId, { kind: 'exec', value: { argv: 'rm -rf /' } }, { user: alex });
++ assert.deepStrictEqual([shell.status, shell.json.code], [422, 'bot.invalid_input']);
++ const fly = await cmd(mine.deviceId, { kind: 'drive', value: { throttle: 1 } }, { user: alex });
++ assert.strictEqual(fly.status, 422);
++ const off = await machine(bob, null);
++ const r = await cmd(off.deviceId, { kind: 'fs', value: { op: 'list', path: '/' } }, { user: bob });
++ assert.deepStrictEqual([r.status, r.json.code], [409, 'bot.computer_off']);
++ off.c.close();
++ });
++
++ await check('the owner\'s agent uses it only after the owner turns it on, and only for that owner', async () => {
++ const token = actor();
++ const before = await cmd(mine.deviceId, { kind: 'screen', value: {} }, asActor(token, alex.subject));
++ assert.deepStrictEqual([before.status, before.json.code], [403, 'bot.agent_not_allowed']);
++ assert.deepStrictEqual((await t.call('GET', '/api/v1/machines', asActor(token, alex.subject))).json.machines, [], 'an agent sees no machine before it is allowed');
++ const self = await t.call('POST', `/api/v1/machines/${mine.deviceId}/agent-computer`, { ...asActor(token, alex.subject), body: { on: true } });
++ assert.strictEqual(self.status, 403, 'the agent never turns itself on');
++ const on = await t.call('POST', `/api/v1/machines/${mine.deviceId}/agent-computer`, { user: alex, body: { on: true } });
++ assert.deepStrictEqual([on.status, on.json.machine.agent_computer], [200, true]);
++ const ok = await cmd(mine.deviceId, { kind: 'fs', value: { op: 'stat', path: '/home/alex' } }, asActor(token, alex.subject));
++ assert.deepStrictEqual([ok.status, ok.json.result], [200, 'ack']);
++ assert.strictEqual(seen.at(-1).operator.subject, alex.subject);
++ assert.strictEqual((await cmd(mine.deviceId, { kind: 'fs', value: { op: 'stat', path: '/' } }, asActor(token, bob.subject))).status, 404, 'acting for someone else');
++ assert.strictEqual((await cmd(mine.deviceId, { kind: 'fs', value: { op: 'stat', path: '/' } }, asActor(actor(['bot.robot.control']), alex.subject))).status, 403, 'needs bot.computer.control');
++ const listed = (await t.call('GET', '/api/v1/machines', asActor(token, alex.subject))).json.machines;
++ assert.deepStrictEqual(listed.map((m) => [m.id, m.computer.level, !!m.computer.terminal, m.computer.waiting]), [[mine.deviceId, 'trusted', true, 0]]);
++ const one = await t.call('GET', `/api/v1/machines/${mine.deviceId}/computer`, asActor(token, alex.subject));
++ assert.deepStrictEqual([one.status, one.json.machine.online, one.json.machine.computer.files.roots], [200, true, ['/home/alex']]);
++ await t.call('POST', `/api/v1/machines/${mine.deviceId}/agent-computer`, { user: alex, body: { on: false } });
++ assert.strictEqual((await cmd(mine.deviceId, { kind: 'screen', value: {} }, asActor(token, alex.subject))).status, 403, 'off again');
++ });
++
++ await check('the machines page shows the level and the agent switch; only the owner flips it, same-site only', async () => {
++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
++ const form = (p, user, body, headers = {}) => fetch(t.base + p, { method: 'POST', redirect: 'manual', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(user), ...headers } });
++ const html = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
++ assert.match(html, /Computer: Trusted/);
++ assert.match(html, /terminal, files, screen/);
++ assert.match(html, /Let my agent use this computer/);
++ assert.strictEqual((await form(`/machines/${mine.deviceId}/agent-computer`, alex, 'on=1', { Origin: 'https://evil.test' })).status, 403);
++ assert.strictEqual((await form(`/machines/${mine.deviceId}/agent-computer`, bob, 'on=1')).status, 404);
++ assert.strictEqual((await form(`/machines/${mine.deviceId}/agent-computer`, alex, 'on=1')).status, 303);
++ assert.strictEqual((await t.db.maybe('SELECT agent_computer FROM devices WHERE id = $1', [mine.deviceId])).agent_computer, true);
++ assert.match(await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text(), /Stop my agent using this computer/);
++ await form(`/machines/${mine.deviceId}/agent-computer`, alex, 'on=0');
++ assert.strictEqual((await t.db.maybe('SELECT agent_computer FROM devices WHERE id = $1', [mine.deviceId])).agent_computer, false);
++ });
++
++ await check('a robot\'s operators never send computer kinds', async () => {
++ const { robot } = await t.robot(alex);
++ const r = await t.call('POST', `/api/v1/robots/${robot.id}/commands`, { user: alex, body: { kind: 'exec', value: { argv: ['id'] } } });
++ assert.strictEqual(r.status, 422, r.text);
++ });
++
++ mine.c.close();
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });