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