openvibers/openvibe.bot Commits
Bot: Watch may probe from a person's machine (read-only, its own switch) — the node carrier's Bot side (#104)
Changes
@@@ -42,7 +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 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. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. 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.@@@ -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.166.0",++ "contracts": "openvibe-contracts v0.167.0","sdk": "openvibe-sdk v0.44.0","shared": "openvibe-shared v4.1.3","packages": [nnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- Plan T18 step 7 (contracts 0.167.0): the owner lets their watches (OpenVibe.Watch's node source) check things on++-- this machine's own network with the computer plugin's read-only `probe`. Off until the owner turns it on, on++-- openvibe.bot/machines; Watch may send nothing else.++ALTER TABLE devices ADD COLUMN watch_probe boolean NOT NULL DEFAULT false;@@@ -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.166.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.167.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.166.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.166.0",-- "integrity": "sha512-62U8JXv2sf2YVJaO4T4l8eszcm9qSpNCKMgbRX3xxeSFxytyR6jwcuB+GRhkVzp0DLssk9AIks7pUxG/n2fDtw==",++ "version": "0.167.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.167.0",++ "integrity": "sha512-CVV3IX+jS6WJM5+7lYS3KK8ZJUNkTyBY55Pal3CG0hZlSDpIqGa/wFw1uSVgoo4vE4vkljE6hIjz6cGvopLrnQ==","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.166.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.167.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",@@@ -53,6 +53,14 @@ const CAP = {/** Services that act as a person's agent (plan T15/T17): they drive a robot only where its owner turned agent_control on. */const AGENT_SERVICES = new Set(['svc:actor']);++/**++ * Services that use a person's machine, each only for the kinds it is allowed and only where the owner turned on its++ * switch on openvibe.bot/machines: the agent (any computer kind) and Watch's node source (read-only probes).++ */++const MACHINE_SERVICES = {++ 'svc:actor': { kinds: null, flag: 'agent_computer', off: 'the owner has not let their agent use this computer' },++ 'svc:watch': { kinds: new Set(['probe']), flag: 'watch_probe', off: 'the owner has not let their watches check things from this computer' },++};function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null }) {const r = express.Router();@@@ -368,16 +376,18 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm// ── 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 } = {}) {++ async function computerMachine(req, { agentNeedsOptIn = true, kind = null } = {}) {const subject = actedFor(req);++ const svc = req.principal.kind === 'service' ? MACHINE_SERVICES[req.principal.sub] : null;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 (!svc) fail(403, 'bot.forbidden', 'a machine is used by its owner, their agent and their watches only');if (!apiAuth.granted(req.principal, CAP.computer)) fail(403, 'capability.denied', `${CAP.computer} not granted`);++ if (kind != null && svc.kinds && !svc.kinds.has(String(kind))) fail(403, 'bot.forbidden', `${req.principal.sub} may send ${[...svc.kinds].join(', ')} only`);}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');++ if (agentNeedsOptIn && svc && !device[svc.flag]) fail(403, 'bot.agent_not_allowed', svc.off);return { device, subject };}const machineOut = (device) => {@@@ -386,10 +396,10 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submconst 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,++ id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer, watch_probe: !!device.watch_probe,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'),++ terminal: pick('terminal'), files: pick('files'), screen: pick('screen'), probe: pick('probe'),waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,} : null,};@@@ -397,12 +407,13 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm// 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);++ const svc = req.principal.kind === 'service' ? MACHINE_SERVICES[req.principal.sub] : null;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 (!svc) fail(403, 'bot.forbidden', 'machines are listed for their owner, their agent and their watches');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;++ const mine = svc ? rows.filter((d) => d[svc.flag]) : rows;res.json({ machines: mine.map(machineOut) });}));r.get('/machines/:id/computer', wrap(async (req, res) => {@@@ -412,12 +423,20 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm// 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 { device, subject } = await computerMachine(req, { kind: req.body?.kind });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 lets their watches probe from the machine, or stops them (never Watch itself).++ r.post('/machines/:id/watch-probe', 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.setWatchProbe(device.id, req.body.on);++ res.json({ machine: machineOut(updated) });++ }));// 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');@@@ -534,4 +553,4 @@ function auditPresent(a) {return { id: a.id, robot_id: a.robot_id, device_id: a.device_id, operator_subject: a.operator_subject, operator_kind: a.operator_kind, role: a.role, kind: a.kind, value: a.value, result: a.result, reason: a.reason, latency_ms: a.latency_ms, at: a.at };}--module.exports = { v1Router, CAP, AGENT_SERVICES };++module.exports = { v1Router, CAP, AGENT_SERVICES, MACHINE_SERVICES };@@@ -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 watches probe from this machine, or stop them (the owner check is the caller's). */++ async function setWatchProbe(deviceId, value) {++ return db.maybe('UPDATE devices SET watch_probe = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);++ }/** 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())]);@@@ -1084,7 +1088,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, setAgentComputer },++ machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setWatchProbe },streaming: { get: streaming, set: setStreaming },video: { live: liveVideo },estop: { set: setEstop, clear: clearEstop },@@@ -39,7 +39,7 @@ const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leaveconst 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_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe' };const COMPUTER_KINDS = Object.keys(COMPUTER_CAP);const COMPUTER_DEFAULT_MS = 60000;const COMPUTER_MAX_MS = 600000;@@@ -47,6 +47,7 @@ const COMPUTER_MAX_MS = 600000;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) } : {}) };++ if (kind === 'probe') { try { const u = new URL(value.target); return { scheme: u.protocol.replace(':', ''), host: u.host }; } catch { return { scheme: null }; } }return { max_width: value.max_width || null };}@@@ -384,7 +384,8 @@ ${code(COMPUTER_APPROVE)}<p>The local stop drops the computer to observe at once: what runs is killed, what waits is denied, and nothing runs or changes until you resume.</p></section><section class="card" aria-labelledby="agent-c-h"><h2 id="agent-c-h">Your agent</h2>--<p>On ${inLink('/machines', 'Your machines')}, <b>Let my agent use this computer</b> is off until you turn it on. With it on, a task you give ${out('https://openvibe.actor', 'OpenVibe.Actor')}, such as "what changed in ~/site today?", can list and read files, run programs and write files there, as you and at your level. The ${out('https://openvibe.actor/console', 'Console')} shows what waits for you. Turning the switch off stops it at once.</p></section>++<p>On ${inLink('/machines', 'Your machines')}, <b>Let my agent use this computer</b> is off until you turn it on. With it on, a task you give ${out('https://openvibe.actor', 'OpenVibe.Actor')}, such as "what changed in ~/site today?", can list and read files, run programs and write files there, as you and at your level. The ${out('https://openvibe.actor/console', 'Console')} shows what waits for you. Turning the switch off stops it at once.</p>++<p><b>Let my watches check things from this computer</b> is a separate switch. It lets ${out('https://openvibe.watch', 'OpenVibe.Watch')} probe your own network from the computer for your watches, for example "tell me when my NAS stops answering". It allows only probes: one GET or one TCP connect, to private addresses only.</p></section><section class="card" aria-labelledby="api-c-h"><h2 id="api-c-h">The API</h2><p>Your own token reaches your own computers. The answer comes back in <code>data</code>:</p>@@@ -393,6 +394,7 @@ ${code(COMPUTER_CALL)}<li><code>exec</code>: <code>argv</code>, <code>cwd</code>, <code>timeout_s</code>, <code>stdin</code> → <code>exit_code</code>, <code>stdout</code>, <code>stderr</code>, <code>truncated</code>, <code>timed_out</code>.</li><li><code>fs</code>: <code>op</code> list, stat, read (<code>offset</code>, <code>length</code>), write (<code>content_b64</code>, <code>mode</code> create, overwrite or append) or mkdir, and an absolute <code>path</code>.</li><li><code>screen</code>: <code>max_width</code> → a JPEG in <code>content_b64</code>.</li>++<li><code>probe</code>: <code>target</code> (<code>http(s)://…</code> or <code>tcp://host:port</code> on the computer's own network) → the status and body of one GET, or whether the port is open. It is read-only and runs at every level.</li></ul><p class="muted"><code>GET /api/v1/machines</code> lists your machines with their level. A machine whose plugin is off answers <code>409 bot.computer_off</code>; a refusal on the computer answers <code>result: "nack"</code> with the reason. The audit keeps the program name or the file and path, never its content.</p></section>`;@@@ -594,7 +594,7 @@ function renderMachinesPage({ machines = [], pairing = null }) {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 };++ return { level: LEVELS[level] ? level : null, terminal: !!c.terminal, files: !!c.files, screen: !!c.screen, probe: !!c.probe };};const computerRow = (m) => {const c = computerOf(m);@@@ -604,7 +604,10 @@ function renderMachinesPage({ machines = [], pairing = null }) {? ` <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>`;++ + `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`++ + (c.probe ? `<form method="post" action="/machines/${esc(m.id)}/watch-probe"><input type="hidden" name="on" value="${m.watch_probe ? '0' : '1'}">`++ + `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from 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>`@@@ -451,6 +451,15 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst pairing = await domain.machines.mint(me.subject);res.status(201).type('html').send(renderMachinesPage({ machines: await machineRows(me.subject), pairing }));}));++ // The owner lets their watches (OpenVibe.Watch's node source) probe from this machine, or stops them.++ r.post('/machines/:id/watch-probe', ...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.setWatchProbe(mine.id, req.body && req.body.on === '1');++ res.redirect(303, '/machines');++ }));// 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);@@@ -14,7 +14,7 @@ const { boot, check, done } = require('./helpers/app');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 COMPUTER = { terminal: { level: 'trusted', timeout_s: 30 }, files: { level: 'trusted', roots: ['/home/alex'] }, screen: { level: 'trusted', tool: 'grim' }, probe: { lan_only: true } };const seen = [];/** A Network-paired machine of `owner` whose fake Node answers computer commands like the plugin would. */@@@ -31,6 +31,7 @@ const { boot, check, done } = require('./helpers/app');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 if (m.kind === 'probe') c.send(frame('ack', { id: m.id, data: { status: 200, body: '{"up":true}', ms: 3 } }));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);@@@ -122,6 +123,30 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual((await t.db.maybe('SELECT agent_computer FROM devices WHERE id = $1', [mine.deviceId])).agent_computer, false);});++ await check('Watch sends only probes, only where the owner let their watches use the computer', async () => {++ const watch = t.network.signService({ sub: 'svc:watch', cap: ['bot.computer.control'] });++ const asWatch = { token: watch, headers: { 'X-OV-Subject': alex.subject } };++ const probe = { kind: 'probe', value: { target: 'http://192.168.1.20/status' } };++ const before = await cmd(mine.deviceId, probe, asWatch);++ assert.deepStrictEqual([before.status, before.json.code], [403, 'bot.agent_not_allowed']);++ assert.match(before.json.detail || before.text, /watches/);++ assert.deepStrictEqual((await t.call('GET', '/api/v1/machines', asWatch)).json.machines, []);++ assert.strictEqual((await t.call('POST', `/api/v1/machines/${mine.deviceId}/watch-probe`, { ...asWatch, body: { on: true } })).status, 403, 'Watch never turns itself on');++ const on = await t.call('POST', `/api/v1/machines/${mine.deviceId}/watch-probe`, { user: alex, body: { on: true } });++ assert.deepStrictEqual([on.status, on.json.machine.watch_probe, !!on.json.machine.computer.probe], [200, true, true]);++ const ok = await cmd(mine.deviceId, probe, asWatch);++ assert.deepStrictEqual([ok.status, ok.json.result, ok.json.data.status], [200, 'ack', 200]);++ const exec = await cmd(mine.deviceId, { kind: 'exec', value: { argv: ['id'] } }, asWatch);++ assert.deepStrictEqual([exec.status, exec.json.code], [403, 'bot.forbidden'], 'Watch may send probes only');++ assert.deepStrictEqual((await t.call('GET', '/api/v1/machines', asWatch)).json.machines.map((m) => m.id), [mine.deviceId]);++ const audit = await t.db.maybe("SELECT value FROM command_audit WHERE robot_id = $1 AND kind = 'probe' ORDER BY id DESC LIMIT 1", [mine.deviceId]);++ assert.deepStrictEqual(audit.value, { scheme: 'http', host: '192.168.1.20' }, 'the audit keeps the host, not the path');++ const actorToo = await cmd(mine.deviceId, probe, { token: t.network.signService({ sub: 'svc:actor', cap: ['bot.computer.control'] }), headers: { 'X-OV-Subject': alex.subject } });++ assert.strictEqual(actorToo.status, 403, 'the watches switch does not open the computer to the agent');++ await t.call('POST', `/api/v1/machines/${mine.deviceId}/watch-probe`, { user: alex, body: { on: false } });++ assert.strictEqual((await cmd(mine.deviceId, probe, asWatch)).status, 403, 'off again');++ });++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'] } } });
@@ -42,7 +42,7 @@ Sitemap entries use the release commit time for these code-backed pages.
@@ -42,7 +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 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 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 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. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. 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.
- 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).
- 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.
- 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.
@@ -5,7 +5,7 @@
@@ -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",
"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.166.0",
+ "contracts": "openvibe-contracts v0.167.0",
"sdk": "openvibe-sdk v0.44.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.1.3",
"shared": "openvibe-shared v4.1.3",
"packages": [
"packages": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- Plan T18 step 7 (contracts 0.167.0): the owner lets their watches (OpenVibe.Watch's node source) check things on
+-- this machine's own network with the computer plugin's read-only `probe`. Off until the owner turns it on, on
+-- openvibe.bot/machines; Watch may send nothing else.
+ALTER TABLE devices ADD COLUMN watch_probe boolean NOT NULL DEFAULT false;
@@ -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.166.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.167.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"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.166.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.166.0",
- "integrity": "sha512-62U8JXv2sf2YVJaO4T4l8eszcm9qSpNCKMgbRX3xxeSFxytyR6jwcuB+GRhkVzp0DLssk9AIks7pUxG/n2fDtw==",
+ "version": "0.167.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.167.0",
+ "integrity": "sha512-CVV3IX+jS6WJM5+7lYS3KK8ZJUNkTyBY55Pal3CG0hZlSDpIqGa/wFw1uSVgoo4vE4vkljE6hIjz6cGvopLrnQ==",
"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.166.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.167.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -53,6 +53,14 @@ const CAP = {
@@ -53,6 +53,14 @@ const CAP = {
/** Services that act as a person's agent (plan T15/T17): they drive a robot only where its owner turned agent_control on. */
/** Services that act as a person's agent (plan T15/T17): they drive a robot only where its owner turned agent_control on. */
const AGENT_SERVICES = new Set(['svc:actor']);
const AGENT_SERVICES = new Set(['svc:actor']);
+/**
+ * Services that use a person's machine, each only for the kinds it is allowed and only where the owner turned on its
+ * switch on openvibe.bot/machines: the agent (any computer kind) and Watch's node source (read-only probes).
+ */
+const MACHINE_SERVICES = {
+ 'svc:actor': { kinds: null, flag: 'agent_computer', off: 'the owner has not let their agent use this computer' },
+ 'svc:watch': { kinds: new Set(['probe']), flag: 'watch_probe', off: 'the owner has not let their watches check things from this computer' },
+};
function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null }) {
function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null }) {
const r = express.Router();
const r = express.Router();
@@ -368,16 +376,18 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -368,16 +376,18 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
// ── A person's own computer (the Node's computer plugin, plan T14/T17) ─────────────────────────────
// ── 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
// 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.
// 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 } = {}) {
+ async function computerMachine(req, { agentNeedsOptIn = true, kind = null } = {}) {
const subject = actedFor(req);
const subject = actedFor(req);
+ const svc = req.principal.kind === 'service' ? MACHINE_SERVICES[req.principal.sub] : null;
if (req.principal.kind === 'service') {
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 (!svc) fail(403, 'bot.forbidden', 'a machine is used by its owner, their agent and their watches only');
if (!apiAuth.granted(req.principal, CAP.computer)) fail(403, 'capability.denied', `${CAP.computer} not granted`);
if (!apiAuth.granted(req.principal, CAP.computer)) fail(403, 'capability.denied', `${CAP.computer} not granted`);
+ if (kind != null && svc.kinds && !svc.kinds.has(String(kind))) fail(403, 'bot.forbidden', `${req.principal.sub} may send ${[...svc.kinds].join(', ')} only`);
}
}
const device = await domain.devices.get(String(req.params.id || '').slice(0, 64));
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;
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 (!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');
+ if (agentNeedsOptIn && svc && !device[svc.flag]) fail(403, 'bot.agent_not_allowed', svc.off);
return { device, subject };
return { device, subject };
}
}
const machineOut = (device) => {
const machineOut = (device) => {
@@ -386,10 +396,10 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -386,10 +396,10 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
const sensors = st.telemetry && st.telemetry.sensors && typeof st.telemetry.sensors === 'object' ? st.telemetry.sensors : {};
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);
const pick = (cap) => (offered && offered[cap] && typeof offered[cap] === 'object' ? offered[cap] : null);
return {
return {
- id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer,
+ id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer, watch_probe: !!device.watch_probe,
computer: offered ? {
computer: offered ? {
level: typeof sensors.computer_level === 'string' ? sensors.computer_level : ((pick('files') || pick('terminal') || pick('screen') || {}).level || null),
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'),
+ terminal: pick('terminal'), files: pick('files'), screen: pick('screen'), probe: pick('probe'),
waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
} : null,
} : null,
};
};
@@ -397,12 +407,13 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -397,12 +407,13 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
// The person's machines (theirs or their robots'); an agent sees only those whose owner let it use them.
// 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) => {
r.get('/machines', wrap(async (req, res) => {
const subject = actedFor(req, req.query.owner);
const subject = actedFor(req, req.query.owner);
+ const svc = req.principal.kind === 'service' ? MACHINE_SERVICES[req.principal.sub] : null;
if (req.principal.kind === 'service') {
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 (!svc) fail(403, 'bot.forbidden', 'machines are listed for their owner, their agent and their watches');
if (!apiAuth.granted(req.principal, CAP.computer)) fail(403, 'capability.denied', `${CAP.computer} not granted`);
if (!apiAuth.granted(req.principal, CAP.computer)) fail(403, 'capability.denied', `${CAP.computer} not granted`);
}
}
const rows = await domain.machines.list(subject);
const rows = await domain.machines.list(subject);
- const mine = req.principal.kind === 'service' ? rows.filter((d) => d.agent_computer) : rows;
+ const mine = svc ? rows.filter((d) => d[svc.flag]) : rows;
res.json({ machines: mine.map(machineOut) });
res.json({ machines: mine.map(machineOut) });
}));
}));
r.get('/machines/:id/computer', wrap(async (req, res) => {
r.get('/machines/:id/computer', wrap(async (req, res) => {
@@ -412,12 +423,20 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -412,12 +423,20 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
// One exec, fs or screen command, answered with the machine's answer in `data` (bot.command-result@1). A repeated
// 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.
// 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) => {
r.post('/machines/:id/commands', commandControl, wrap(async (req, res) => {
- const { device, subject } = await computerMachine(req);
+ const { device, subject } = await computerMachine(req, { kind: req.body?.kind });
const principal = req.principal.kind === 'service' ? { ...req.principal, subject } : req.principal;
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 });
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);
if (result.result === 'refused') fail(commandRefusalStatus(result.code), result.code, result.reason || null);
res.json({ machine_id: device.id, ...result });
res.json({ machine_id: device.id, ...result });
}));
}));
+ // The owner lets their watches probe from the machine, or stops them (never Watch itself).
+ r.post('/machines/:id/watch-probe', 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.setWatchProbe(device.id, req.body.on);
+ res.json({ machine: machineOut(updated) });
+ }));
// The owner turns their agent's use of the machine on or off (never the agent itself).
// 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) => {
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');
if (req.principal.kind !== 'user') fail(403, 'bot.forbidden', 'only the machine\'s owner turns this on or off');
@@ -534,4 +553,4 @@ function auditPresent(a) {
@@ -534,4 +553,4 @@ function auditPresent(a) {
return { id: a.id, robot_id: a.robot_id, device_id: a.device_id, operator_subject: a.operator_subject, operator_kind: a.operator_kind, role: a.role, kind: a.kind, value: a.value, result: a.result, reason: a.reason, latency_ms: a.latency_ms, at: a.at };
return { id: a.id, robot_id: a.robot_id, device_id: a.device_id, operator_subject: a.operator_subject, operator_kind: a.operator_kind, role: a.role, kind: a.kind, value: a.value, result: a.result, reason: a.reason, latency_ms: a.latency_ms, at: a.at };
}
}
-module.exports = { v1Router, CAP, AGENT_SERVICES };
+module.exports = { v1Router, CAP, AGENT_SERVICES, MACHINE_SERVICES };
@@ -663,6 +663,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -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))))
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]);
ORDER BY d.created_at DESC`, [subject]);
}
}
+ /** Let the owner's watches probe from this machine, or stop them (the owner check is the caller's). */
+ async function setWatchProbe(deviceId, value) {
+ return db.maybe('UPDATE devices SET watch_probe = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
+ }
/** Let the owner's agent use this machine's computer plugin, or stop it (the owner check is the caller's). */
/** 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) {
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())]);
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())]);
@@ -1084,7 +1088,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1084,7 +1088,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
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 },
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, setAgentComputer },
+ machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setWatchProbe },
streaming: { get: streaming, set: setStreaming },
streaming: { get: streaming, set: setStreaming },
video: { live: liveVideo },
video: { live: liveVideo },
estop: { set: setEstop, clear: clearEstop },
estop: { set: setEstop, clear: clearEstop },
@@ -39,7 +39,7 @@ const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leave
@@ -39,7 +39,7 @@ const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leave
const DEVICE_MAX_PAYLOAD = 640 * 1024;
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
// 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).
// 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_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe' };
const COMPUTER_KINDS = Object.keys(COMPUTER_CAP);
const COMPUTER_KINDS = Object.keys(COMPUTER_CAP);
const COMPUTER_DEFAULT_MS = 60000;
const COMPUTER_DEFAULT_MS = 60000;
const COMPUTER_MAX_MS = 600000;
const COMPUTER_MAX_MS = 600000;
@@ -47,6 +47,7 @@ const COMPUTER_MAX_MS = 600000;
@@ -47,6 +47,7 @@ const COMPUTER_MAX_MS = 600000;
function computerSummary(kind, value) {
function computerSummary(kind, value) {
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 }; } }
return { max_width: value.max_width || null };
return { max_width: value.max_width || null };
}
}
@@ -384,7 +384,8 @@ ${code(COMPUTER_APPROVE)}
@@ -384,7 +384,8 @@ ${code(COMPUTER_APPROVE)}
<p>The local stop drops the computer to observe at once: what runs is killed, what waits is denied, and nothing runs or changes until you resume.</p></section>
<p>The local stop drops the computer to observe at once: what runs is killed, what waits is denied, and nothing runs or changes until you resume.</p></section>
<section class="card" aria-labelledby="agent-c-h"><h2 id="agent-c-h">Your agent</h2>
<section class="card" aria-labelledby="agent-c-h"><h2 id="agent-c-h">Your agent</h2>
-<p>On ${inLink('/machines', 'Your machines')}, <b>Let my agent use this computer</b> is off until you turn it on. With it on, a task you give ${out('https://openvibe.actor', 'OpenVibe.Actor')}, such as "what changed in ~/site today?", can list and read files, run programs and write files there, as you and at your level. The ${out('https://openvibe.actor/console', 'Console')} shows what waits for you. Turning the switch off stops it at once.</p></section>
+<p>On ${inLink('/machines', 'Your machines')}, <b>Let my agent use this computer</b> is off until you turn it on. With it on, a task you give ${out('https://openvibe.actor', 'OpenVibe.Actor')}, such as "what changed in ~/site today?", can list and read files, run programs and write files there, as you and at your level. The ${out('https://openvibe.actor/console', 'Console')} shows what waits for you. Turning the switch off stops it at once.</p>
+<p><b>Let my watches check things from this computer</b> is a separate switch. It lets ${out('https://openvibe.watch', 'OpenVibe.Watch')} probe your own network from the computer for your watches, for example "tell me when my NAS stops answering". It allows only probes: one GET or one TCP connect, to private addresses only.</p></section>
<section class="card" aria-labelledby="api-c-h"><h2 id="api-c-h">The API</h2>
<section class="card" aria-labelledby="api-c-h"><h2 id="api-c-h">The API</h2>
<p>Your own token reaches your own computers. The answer comes back in <code>data</code>:</p>
<p>Your own token reaches your own computers. The answer comes back in <code>data</code>:</p>
@@ -393,6 +394,7 @@ ${code(COMPUTER_CALL)}
@@ -393,6 +394,7 @@ ${code(COMPUTER_CALL)}
<li><code>exec</code>: <code>argv</code>, <code>cwd</code>, <code>timeout_s</code>, <code>stdin</code> → <code>exit_code</code>, <code>stdout</code>, <code>stderr</code>, <code>truncated</code>, <code>timed_out</code>.</li>
<li><code>exec</code>: <code>argv</code>, <code>cwd</code>, <code>timeout_s</code>, <code>stdin</code> → <code>exit_code</code>, <code>stdout</code>, <code>stderr</code>, <code>truncated</code>, <code>timed_out</code>.</li>
<li><code>fs</code>: <code>op</code> list, stat, read (<code>offset</code>, <code>length</code>), write (<code>content_b64</code>, <code>mode</code> create, overwrite or append) or mkdir, and an absolute <code>path</code>.</li>
<li><code>fs</code>: <code>op</code> list, stat, read (<code>offset</code>, <code>length</code>), write (<code>content_b64</code>, <code>mode</code> create, overwrite or append) or mkdir, and an absolute <code>path</code>.</li>
<li><code>screen</code>: <code>max_width</code> → a JPEG in <code>content_b64</code>.</li>
<li><code>screen</code>: <code>max_width</code> → a JPEG in <code>content_b64</code>.</li>
+<li><code>probe</code>: <code>target</code> (<code>http(s)://…</code> or <code>tcp://host:port</code> on the computer's own network) → the status and body of one GET, or whether the port is open. It is read-only and runs at every level.</li>
</ul>
</ul>
<p class="muted"><code>GET /api/v1/machines</code> lists your machines with their level. A machine whose plugin is off answers <code>409 bot.computer_off</code>; a refusal on the computer answers <code>result: "nack"</code> with the reason. The audit keeps the program name or the file and path, never its content.</p></section>
<p class="muted"><code>GET /api/v1/machines</code> lists your machines with their level. A machine whose plugin is off answers <code>409 bot.computer_off</code>; a refusal on the computer answers <code>result: "nack"</code> with the reason. The audit keeps the program name or the file and path, never its content.</p></section>
`;
`;
@@ -594,7 +594,7 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -594,7 +594,7 @@ function renderMachinesPage({ machines = [], pairing = null }) {
const c = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).computer;
const c = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).computer;
if (!c || typeof c !== 'object') return null;
if (!c || typeof c !== 'object') return null;
const level = m.computer_level || ((c.files || c.terminal || c.screen || {}).level) || 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 };
+ return { level: LEVELS[level] ? level : null, terminal: !!c.terminal, files: !!c.files, screen: !!c.screen, probe: !!c.probe };
};
};
const computerRow = (m) => {
const computerRow = (m) => {
const c = computerOf(m);
const c = computerOf(m);
@@ -604,7 +604,10 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -604,7 +604,10 @@ function renderMachinesPage({ machines = [], pairing = null }) {
? ` <span class="pill warn">${m.computer_waiting} waiting for you on the machine</span>` : '';
? ` <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}`
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'}">`
+ `<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>`;
+ + `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`
+ + (c.probe ? `<form method="post" action="/machines/${esc(m.id)}/watch-probe"><input type="hidden" name="on" value="${m.watch_probe ? '0' : '1'}">`
+ + `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from this computer'}</button></form>` : '')
+ + '</div>';
};
};
const list = machines.length
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>`
? `<ul class="robots">${machines.map((m) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span>${esc(m.name || m.id)}</span></span>`
@@ -451,6 +451,15 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -451,6 +451,15 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const pairing = await domain.machines.mint(me.subject);
const pairing = await domain.machines.mint(me.subject);
res.status(201).type('html').send(renderMachinesPage({ machines: await machineRows(me.subject), pairing }));
res.status(201).type('html').send(renderMachinesPage({ machines: await machineRows(me.subject), pairing }));
}));
}));
+ // The owner lets their watches (OpenVibe.Watch's node source) probe from this machine, or stops them.
+ r.post('/machines/:id/watch-probe', ...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.setWatchProbe(mine.id, req.body && req.body.on === '1');
+ res.redirect(303, '/machines');
+ }));
// The owner lets their agent (OpenVibe.Actor) use this machine's computer plugin, or stops it.
// 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) => {
r.post('/machines/:id/agent-computer', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
@@ -14,7 +14,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -14,7 +14,7 @@ const { boot, check, done } = require('./helpers/app');
let seq = 0;
let seq = 0;
const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });
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 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 COMPUTER = { terminal: { level: 'trusted', timeout_s: 30 }, files: { level: 'trusted', roots: ['/home/alex'] }, screen: { level: 'trusted', tool: 'grim' }, probe: { lan_only: true } };
const seen = [];
const seen = [];
/** A Network-paired machine of `owner` whose fake Node answers computer commands like the plugin would. */
/** A Network-paired machine of `owner` whose fake Node answers computer commands like the plugin would. */
@@ -31,6 +31,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -31,6 +31,7 @@ const { boot, check, done } = require('./helpers/app');
seen.push(m);
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 } }));
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 if (m.kind === 'fs') c.send(frame('ack', { id: m.id, data: { path: m.value.path, entries: [], total: 0, truncated: false } }));
+ else if (m.kind === 'probe') c.send(frame('ack', { id: m.id, data: { status: 200, body: '{"up":true}', ms: 3 } }));
else c.send(frame('nack', { id: m.id, fault_code: 'not_allowed', message: 'this machine allows observing only' }));
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);
await poll(() => t.hub.computerOf(deviceId) !== null || !computer);
@@ -122,6 +123,30 @@ const { boot, check, done } = require('./helpers/app');
@@ -122,6 +123,30 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await t.db.maybe('SELECT agent_computer FROM devices WHERE id = $1', [mine.deviceId])).agent_computer, false);
assert.strictEqual((await t.db.maybe('SELECT agent_computer FROM devices WHERE id = $1', [mine.deviceId])).agent_computer, false);
});
});
+ await check('Watch sends only probes, only where the owner let their watches use the computer', async () => {
+ const watch = t.network.signService({ sub: 'svc:watch', cap: ['bot.computer.control'] });
+ const asWatch = { token: watch, headers: { 'X-OV-Subject': alex.subject } };
+ const probe = { kind: 'probe', value: { target: 'http://192.168.1.20/status' } };
+ const before = await cmd(mine.deviceId, probe, asWatch);
+ assert.deepStrictEqual([before.status, before.json.code], [403, 'bot.agent_not_allowed']);
+ assert.match(before.json.detail || before.text, /watches/);
+ assert.deepStrictEqual((await t.call('GET', '/api/v1/machines', asWatch)).json.machines, []);
+ assert.strictEqual((await t.call('POST', `/api/v1/machines/${mine.deviceId}/watch-probe`, { ...asWatch, body: { on: true } })).status, 403, 'Watch never turns itself on');
+ const on = await t.call('POST', `/api/v1/machines/${mine.deviceId}/watch-probe`, { user: alex, body: { on: true } });
+ assert.deepStrictEqual([on.status, on.json.machine.watch_probe, !!on.json.machine.computer.probe], [200, true, true]);
+ const ok = await cmd(mine.deviceId, probe, asWatch);
+ assert.deepStrictEqual([ok.status, ok.json.result, ok.json.data.status], [200, 'ack', 200]);
+ const exec = await cmd(mine.deviceId, { kind: 'exec', value: { argv: ['id'] } }, asWatch);
+ assert.deepStrictEqual([exec.status, exec.json.code], [403, 'bot.forbidden'], 'Watch may send probes only');
+ assert.deepStrictEqual((await t.call('GET', '/api/v1/machines', asWatch)).json.machines.map((m) => m.id), [mine.deviceId]);
+ const audit = await t.db.maybe("SELECT value FROM command_audit WHERE robot_id = $1 AND kind = 'probe' ORDER BY id DESC LIMIT 1", [mine.deviceId]);
+ assert.deepStrictEqual(audit.value, { scheme: 'http', host: '192.168.1.20' }, 'the audit keeps the host, not the path');
+ const actorToo = await cmd(mine.deviceId, probe, { token: t.network.signService({ sub: 'svc:actor', cap: ['bot.computer.control'] }), headers: { 'X-OV-Subject': alex.subject } });
+ assert.strictEqual(actorToo.status, 403, 'the watches switch does not open the computer to the agent');
+ await t.call('POST', `/api/v1/machines/${mine.deviceId}/watch-probe`, { user: alex, body: { on: false } });
+ assert.strictEqual((await cmd(mine.deviceId, probe, asWatch)).status, 403, 'off again');
+ });
+
await check('a robot\'s operators never send computer kinds', async () => {
await check('a robot\'s operators never send computer kinds', async () => {
const { robot } = await t.robot(alex);
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'] } } });
const r = await t.call('POST', `/api/v1/robots/${robot.id}/commands`, { user: alex, body: { kind: 'exec', value: { argv: ['id'] } } });