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)

OP OpenVibers committed 30 days ago · 4ff7585134 · 12 files, +90 −22

Plan T18 step 7 (Contracts 0.167.0, Node#43): Bot's side of Watch's `node` carrier.

- **What Watch may send**: `svc:watch` with `bot.computer.control` may send `probe` to a person's machine, acting as that person, and nothing else (any other kind: 403). Only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`). `MACHINE_SERVICES` holds each service's kinds and switch.
- **Switches are separate**: the agent's switch does not open probes to Watch, and the watches switch does not open the computer to the agent.
- **Owner controls**: `POST /api/v1/machines/:id/watch-probe` and a form on /machines, shown when the machine offers probe. `GET /machines` lists, for Watch, only the machines it may probe.
- **Audit**: keeps the probe's scheme and host, never its path.
- **Device config**: lists `probe`.
- **Docs**: /docs/computers and the README describe the probe and the switch.
- **Pins**: contracts v0.167.0.

Tests: `test/machine-computer.test.js` adds Watch's case: the switch, probe only, the listing, the audit, the switches kept apart, and off again. The full suite passes; `panel.test.js` flaked once under load and passes on its own.

Changes

ddiff --git a/README.md b/README.md
iindex c76934a..177b7d3 100644
---- a/README.md
++++ b/README.md
@@@ -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.
ddiff --git a/STATUS.json b/STATUS.json
iindex 2a9179e..ef1622b 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.166.0",
++ "contracts": "openvibe-contracts v0.167.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.1.3",
"packages": [
ddiff --git a/migrations/0015_machine_watch_probe.sql b/migrations/0015_machine_watch_probe.sql
nnew file mode 100644
iindex 0000000..d582dd4
---- /dev/null
++++ b/migrations/0015_machine_watch_probe.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex f2c26c7..5820ff3 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.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",
ddiff --git a/package.json b/package.json
iindex e4edd30..fc52911 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.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",
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex aa1462f..4786101 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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, subm
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,
++ 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 };
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex d1b2605..550f7e2 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 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 },
ddiff --git a/server/realtime.js b/server/realtime.js
iindex 47e3bd8..29e0637 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -39,7 +39,7 @@ const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leave
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_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 };
}
ddiff --git a/server/web/docs.js b/server/web/docs.js
iindex e681c86..5d57322 100644
---- a/server/web/docs.js
++++ b/server/web/docs.js
@@@ -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>
`;
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 4fdcf49..91f3724 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -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>`
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex cf430e9..955a104 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -451,6 +451,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 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);
ddiff --git a/test/machine-computer.test.js b/test/machine-computer.test.js
iindex ce4894c..4015b1e 100644
---- a/test/machine-computer.test.js
++++ b/test/machine-computer.test.js
@@@ -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'] } } });