openvibers/openvibe.bot Commits
Bot: a person's own machines that run their jobs (/machines, worker offers to Network, only the owner's jobs reach them) (#98)
Changes
@@@ -41,6 +41,7 @@ Sitemap entries use the release commit time for these code-backed pages.## Devices- 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 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.@@@ -98,6 +99,7 @@ A person acts on their own robots with a Network user token; a service acts with- A device credential is a secret: sent only in the `Authorization` header (never a query string), stored hashed, returned once by `pair` or a rotation, and revoking it closes the socket at once.- A person acts only as themself; naming anyone else is refused, never ignored, and a node token is refused on the person and service routes.++- A person's machine runs only that person's jobs: the job dispatcher compares the job's requester (`user:usr_…`, as Run names it) with the machine's owner (a worker machine's own owner, else its robot's owner) and refuses anyone else's, a service's or nobody's job with `bot.not_owner`, whatever the placer chose. Its offer to Network names the owner and Network keeps it off the public offers list.- Pairing codes are one-time and short-lived; the e-stop is latched and only the owner clears it, while `halt` always passes the gate.- Every command, allowed or refused, is audited; the outbox carries ids, kinds and results — never a credential, a pairing code, a publish key or a WHIP URL.- A server-side ONVIF camera is reached only at the one URL configured in `BOT_ONVIF_CAMERAS` (http(s), one host, no embedded credentials), with a timeout and no redirects, so a camera cannot steer Bot elsewhere. Its ONVIF user and password are named by secret reference (`username_ref`/`password_ref`), read only when a request is built, sent as a WS-Security digest and never stored, answered or logged.@@@ -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.160.0",++ "contracts": "openvibe-contracts v0.161.0","sdk": "openvibe-sdk v0.44.0","shared": "openvibe-shared v4.1.3","packages": [@@@ -56,7 +56,8 @@"embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)","embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot","streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once",-- "server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects"++ "server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects",++ "a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)"],"updated": "2026-10-14"}nnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- Plan T14/T16: a person's own machine that runs jobs for them (a worker) and serves no robot. Its owner is stored on++-- the device; a robot's device keeps NULL here and is owned by its robot's owner.++ALTER TABLE devices ADD COLUMN owner_subject text;++CREATE INDEX devices_owner ON devices (owner_subject) WHERE owner_subject IS NOT NULL AND revoked_at IS NULL;@@@ -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.160.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.161.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.160.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.160.0",-- "integrity": "sha512-1hhbfO3js3IRrCH2XDTy6TVo9aPaSqSangyBRq1U2WdMvoToeujiucERZnXQCW8kIYWM6R+Ow5M7jNGi7ySnwQ==",++ "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==","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.160.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.161.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",@@@ -24,7 +24,8 @@ const { http } = require('openvibe-contracts');const { isLoopbackDirect } = require('openvibe-shared/metrics');const { loadConfig } = require('./config');const { openDb } = require('./db');--const { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity } = require('./network');++const { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity, createOfferReporter } = require('./network');++const { createOfferPublisher } = require('./jobs/offers');const { createOpenRe } = require('./openre/client');const { createBotOutbox } = require('./events/outbox');const { createUsageRelay } = require('./jobs/metering');@@@ -89,6 +90,12 @@ function createApp(opts = {}) {// One jobs service for the device socket and the jobs API, so both see the same stdout rings.const jobs = createJobs({ db: () => domain.db, hub, usage, log, now });hub.bindJobs(jobs);++ // The worker offers of the machines connected here, reported to Network for Run's placement (server/jobs/offers.js);++ // started by server/index.js with the other jobs. Without Network credentials nothing is reported.++ const offerReporter = opts.offerReporter !== undefined ? opts.offerReporter++ : (config.oauth.clientSecret ? createOfferReporter(config, { fetchImpl }) : null);++ const offers = createOfferPublisher({ db, hub, reporter: offerReporter, log, now });++ domain.devices.onChange(() => offers.poke());const sim = opts.sim || createSimulator({ config, domain, hub, now, log });// Server-side ONVIF cameras (server/onvif): the same in-process attach as the simulator, but with a real// kind 'server' device row and an outbound ONVIF request per ptz command.@@@ -165,7 +172,7 @@ function createApp(opts = {}) {return res.status(500).type('text/plain').send('Something went wrong\n');});-- Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, sim, onvif, userAuth, metrics, submissions, routines, webhooks });++ Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, offers, sim, onvif, userAuth, metrics, submissions, routines, webhooks });return app;}@@@ -19,6 +19,8 @@ const {} = require('../util');const { getProfile, validateProfile, reservedKeyReason } = require('../profiles');const { ENVELOPE } = require('../events/outbox');++/** A person's subject (the owner a worker machine's pairing names). */++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const DEFAULT_ALLOW = {owner: ['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'halt'],@@@ -69,6 +71,12 @@ function noteToHz(note) {function createDomain({ db, config, outbox, link = null, nodes = null, openre = null, webhooks = null, onEvent = null, now = () => Date.now(), log = console }) {++ // Listeners told when a device appears, goes on or offline, changes what it advertises or is revoked (the worker++ // offers Bot reports to Network follow these, server/jobs/offers.js).++ const changeListeners = new Set();++ const onChange = (fn) => { changeListeners.add(fn); return () => changeListeners.delete(fn); };++ const changed = () => { for (const fn of changeListeners) { try { fn(); } catch { /* a listener never breaks the caller */ } } };++ const workerRef = (subject) => `worker:${subject}`;// ── Presenters ────────────────────────────────────────────────────────────────────────────────const presentRobot = (r) => (r ? {id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,@@@ -589,6 +597,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =if (!d) fail(404, 'bot.device_not_found', 'no such device');const at = iso(now());const updated = await db.maybe('UPDATE devices SET revoked_at = $2, credential_prev_hash = NULL, prev_valid_until = NULL, updated_at = $2 WHERE id = $1 RETURNING *', [id, at]);++ changed();return updated;}@@@ -612,18 +621,56 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =if (!p || p.principal !== principalId) refuse('Network knows no such machine paired for Bot');if (p.status !== 'active') refuse(`the machine is ${p.status} on Network`);if (!p.paired_for || p.paired_for.service !== 'bot') refuse('the machine was not paired for Bot');-- const robot = isRobotId(p.paired_for.ref) ? await getRobot(p.paired_for.ref) : null;-- if (!robot || !p.owner || p.owner.kind !== 'user' || p.owner.subject !== robot.owner_subject) refuse("the machine was not paired for one of its owner's robots");++ // A worker machine (plan T14/T16) runs its owner's own jobs and serves no robot: its pairing names the owner++ // (ref `worker:<owner>`), and the device keeps that owner.++ const worker = !!(p.owner && p.owner.kind === 'user' && USER_SUBJECT.test(String(p.owner.subject)) && p.paired_for.ref === workerRef(p.owner.subject));++ const robot = !worker && isRobotId(p.paired_for.ref) ? await getRobot(p.paired_for.ref) : null;++ if (!worker && (!robot || !p.owner || p.owner.kind !== 'user' || p.owner.subject !== robot.owner_subject)) refuse("the machine was not paired for one of its owner's robots");const at = iso(now());const name = typeof p.name === 'string' ? storable(p.name).trim().slice(0, 80) || null : null;// Two first connections at once: the unique index lets one insert win, and the other reads it.const inserted = await db.maybe(-- `INSERT INTO devices (id, robot_ids, name, kind, agent_version, drivers, capabilities, credential_hash, node_principal, created_at, updated_at)-- VALUES ($1, $2::jsonb, $3, 'onboard', NULL, '[]'::jsonb, '{}'::jsonb, NULL, $4, $5, $5)++ `INSERT INTO devices (id, robot_ids, name, kind, agent_version, drivers, capabilities, credential_hash, node_principal, owner_subject, created_at, updated_at)++ VALUES ($1, $2::jsonb, $3, $4, NULL, '[]'::jsonb, '{}'::jsonb, NULL, $5, $6, $7, $7)ON CONFLICT (node_principal) WHERE node_principal IS NOT NULL AND revoked_at IS NULL DO NOTHING RETURNING *`,-- [prefixedId('dev', now()), JSON.stringify([robot.id]), name, principalId, at]);++ [prefixedId('dev', now()), JSON.stringify(worker ? [] : [robot.id]), name, worker ? 'server' : 'onboard', principalId,++ worker ? p.owner.subject : null, at]);++ changed();return inserted || db.maybe('SELECT * FROM devices WHERE node_principal = $1 AND revoked_at IS NULL', [principalId]);}++++ // ── Machines that run their owner's jobs (plan T14/T16) ────────────────────────────────────────++ /**++ * A one-time code for `ownerSubject` to add a machine that runs their own jobs (Network pairing, ref++ * `worker:<owner>`; Network keeps one live code per ref, so a new code replaces an unused one). The installer++ * command turns the Node's job worker on (`--worker`). → { code, expires_at, pairing_id, installer }++ */++ async function mintMachine(ownerSubject, installerUrl) {++ if (!nodes) fail(503, 'bot.network_unavailable', 'Network pairing is not configured here');++ const p = await nodes.pair({ subject: ownerSubject, ref: workerRef(ownerSubject) });++ if (!p || typeof p.pairing_id !== 'string' || typeof p.code !== 'string') fail(503, 'bot.network_unavailable', 'Network answered no pairing code');++ const url = installerUrl || config.installer.scriptUrl;++ return {++ code: p.code, expires_at: p.expires_at, pairing_id: p.pairing_id,++ installer: `curl -fsSL ${url} | sh -s -- --network ${config.network.url} --pairing ${p.pairing_id} --code ${p.code} --worker`,++ };++ }++ /** The person's live machines that run jobs: their worker machines, and their robots' machines that have a job worker. */++ async function listMachines(subject) {++ return db.many(++ `SELECT d.* FROM devices d++ WHERE d.revoked_at IS NULL AND (d.owner_subject = $1 OR (d.capabilities -> 'worker' IS NOT NULL AND EXISTS (++ 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]);++ }++ /** 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;++ if (device.owner_subject) return device.owner_subject;++ const robotId = json(device.robot_ids, [])[0];++ const robot = robotId ? await getRobot(robotId) : null;++ return robot ? robot.owner_subject : null;++ }/*** Issue (or re-issue) a device's WHIP publish key, a new ingest key of its robot's OpenRestream stream: the new* key replaces the old one, which stops working. → { device, publish_key, whip_url?, profile }, the key@@@ -647,6 +694,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =}/** Persist what a Network-paired device declares (kind, drivers, capabilities, agent_version): only the given fields. */async function updateDeclared(id, { kind, drivers, capabilities, agent_version: agentVersion }) {++ if (capabilities) changed();return db.maybe(`UPDATE devices SET kind = COALESCE($2, kind), drivers = COALESCE($3::jsonb, drivers), capabilities = COALESCE($4::jsonb, capabilities),agent_version = CASE WHEN $5 THEN $6 ELSE agent_version END, updated_at = $7@@@ -663,6 +711,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =async function touchSeen(id) { await db.query('UPDATE devices SET last_seen = $2 WHERE id = $1', [id, iso(now())]); }async function setOnline(device, online) {++ changed();const robotIds = json(device.robot_ids, []);const at = iso(now());await db.tx(async (t) => {@@@ -1030,7 +1079,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =localProfile, saveLocalProfile, useCatalogueProfile },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 },++ 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 },streaming: { get: streaming, set: setStreaming },video: { live: liveVideo },estop: { set: setEstop, clear: clearEstop },@@@ -33,7 +33,7 @@ async function main() {const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });const app = createApp({ config, db, valkey, registry });-- const { domain, keys, outbox, usage, hub, sim, onvif, routines, webhooks } = app.locals;++ const { domain, keys, outbox, usage, hub, offers, sim, onvif, routines, webhooks } = app.locals;keys.start().catch(() => { /* logged by the JWKS client */ });const timers = [];@@@ -46,6 +46,7 @@ async function main() {usage.start();routines.start(); // scheduled routines (server/routines.js)webhooks.start(); // robot webhook deliveries (server/webhooks.js)++ offers.start(); // the worker offers of the machines connected here, to Network (server/jobs/offers.js)}const server = app.listen(config.port, config.host, () => {@@@ -82,6 +83,7 @@ async function main() {() => timers.forEach(clearInterval),() => routines.stop(),() => webhooks.stop(),++ () => offers.stop(),() => keys.stop(),() => sim.stopAll(),() => onvif.stopAll(),@@@ -37,7 +37,14 @@ function advertisedClasses(capabilities) {return worker && Array.isArray(worker.runtime_classes) ? worker.runtime_classes.filter((c) => typeof c === 'string') : [];}const canonical = (v) => JSON.stringify(v, (k, x) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.keys(x).sort().map((key) => [key, x[key]])) : x));--const liveDevice = (db, id) => db.maybe('SELECT id, capabilities FROM devices WHERE id = $1 AND revoked_at IS NULL', [id]);++const liveDevice = (db, id) => db.maybe('SELECT id, capabilities, robot_ids, owner_subject FROM devices WHERE id = $1 AND revoked_at IS NULL', [id]);++/** Whose machine it is: a worker machine's owner, else its robot's owner (every device here is a person's own). */++async function ownerOfDevice(db, device) {++ if (device.owner_subject) return device.owner_subject;++ const robotId = json(device.robot_ids, [])[0];++ const robot = robotId ? await db.maybe('SELECT owner_subject FROM robots WHERE id = $1', [robotId]) : null;++ return robot ? robot.owner_subject : null;++}/*** Validate `job` against platform.job@1, store it for device `nodeId` and send it if the device is connected@@@ -53,6 +60,12 @@ async function dispatch(db, nodeId, job, { link, project = null, subject = null,const device = await liveDevice(db, nodeId);if (!device) fail(404, 'bot.device_not_found', 'no such device, or it is revoked');if (!advertisedClasses(device.capabilities).includes(job.class)) fail(409, 'bot.class_unadvertised', `the device does not advertise the runtime class ${job.class}`);++ // A person's machine runs only that person's own jobs (contracts 0.161.0, ADR-046): Run names the job's requester++ // as `subject`, and anyone else's job, or a job for nobody, is refused here whatever the placer chose.++ // `subject` is a subject reference (`user:usr_…`, how Run formats a person); a service's or nobody's job never runs.++ const owner = await ownerOfDevice(db, device);++ const person = typeof subject === 'string' && /^user:usr_/.test(subject) ? subject.slice('user:'.length) : null;++ if (!owner || person !== owner) fail(403, 'bot.not_owner', "a person's machine runs only that person's own jobs");const { row, created } = await store.put(db, { job, nodeId, project, subject, provider, at: now() });if (!created && (row.node_id !== nodeId || canonical(row.job) !== canonical(job)|| row.project_id !== project || row.subject !== subject || row.provider !== provider)) {nnew file mode 100644@@@ -0,0 +1,99 @@++'use strict';++/**++ * The worker offers Bot reports to Network (plan T14/T16, contracts 0.161.0, ADR-046). Every live device that runs a++ * job worker (OpenVibe.Node status.capabilities.worker.runtime_classes) is a person's own machine: one++ * platform.resource-offer@1 per device, kind `node`, trust `user-owned`, naming its owner, advertising++ * `worker:<class>` for each class, `up` while the device is connected and `down` otherwise. The set is reported whole++ * as source `bot` (Network marks an offer missing from a later report down), once a minute and a moment after a device++ * comes or goes or changes what it advertises.++ *++ * createOfferPublisher({ db, hub, reporter, log, now, intervalMs, settleMs }) → { build, publish, start, stop, poke }++ *++ * An offer that does not match the contract is left out (Network would refuse the whole report for one bad offer).++ * Without a reporter (no Network credentials) nothing is reported.++ */++const { validate } = require('openvibe-contracts');++const { json } = require('../util');++++const CONTRACT = 'platform.resource-offer@1';++const CLASSES = new Set(['function', 'code', 'browser', 'linux', 'desktop', 'gpu']);++++function createOfferPublisher({ db, hub, reporter = null, log = console, now = () => Date.now(), intervalMs = 60_000, settleMs = 2_000 }) {++ let timer = null;++ let soon = null;++ let running = null;++ let again = false;++++ /** The offers of every live worker device, owner and connection included. */++ async function build() {++ const rows = await db.many(++ `SELECT d.id, d.capabilities, d.owner_subject, r.owner_subject AS robot_owner++ FROM devices d LEFT JOIN robots r ON r.id = d.robot_ids ->> 0++ WHERE d.revoked_at IS NULL AND d.capabilities -> 'worker' IS NOT NULL++ ORDER BY d.id`);++ const at = new Date(now()).toISOString();++ const offers = [];++ for (const row of rows) {++ const worker = json(row.capabilities, {}).worker || {};++ const classes = (Array.isArray(worker.runtime_classes) ? worker.runtime_classes : []).filter((c) => CLASSES.has(c));++ const owner = row.owner_subject || row.robot_owner;++ if (!classes.length || !owner) continue;++ const offer = {++ offer_id: `node:${row.id}`, kind: 'node', node_id: row.id, provider: 'self', region: 'home',++ trust: 'user-owned', owner,++ capabilities: classes.map((c) => `worker:${c}`),++ health: { status: hub && hub.isOnline(row.id) ? 'up' : 'down', checked_at: at },++ pricing: { model: 'free-allowance', marginal_usd_per_unit: 0 },++ updated_at: at,++ };++ const v = validate(CONTRACT, offer);++ if (!v.valid) {++ log.warn(`[Bot] offers: ${row.id} does not match ${CONTRACT}, left out`);++ continue;++ }++ offers.push(offer);++ }++ return offers;++ }++++ /** Report the whole set now (one report at a time; a change during one reports again after it). */++ async function publish() {++ if (!reporter) return null;++ if (running) { again = true; return running; }++ running = (async () => {++ try {++ return await reporter.report(await build());++ } catch (e) {++ log.warn(`[Bot] offers report: ${e.message}`);++ return null;++ } finally {++ running = null;++ if (again) { again = false; poke(); }++ }++ })();++ return running;++ }++++ /** A device changed: report a moment later (changes in a burst are reported once). Only once started. */++ function poke() {++ if (!reporter || !timer || soon) return;++ soon = setTimeout(() => { soon = null; void publish(); }, settleMs);++ if (soon.unref) soon.unref();++ }++++ function start() {++ if (!reporter || timer) return;++ timer = setInterval(() => { void publish(); }, intervalMs);++ if (timer.unref) timer.unref();++ poke();++ }++ function stop() {++ if (timer) clearInterval(timer);++ if (soon) clearTimeout(soon);++ timer = null;++ soon = null;++ }++ return { build, publish, start, stop, poke };++}++++module.exports = { createOfferPublisher };@@@ -87,6 +87,36 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {};}++/**++ * Network's resource registry (plan T14, contracts 0.161.0): Bot reports the worker offers of the machines connected++ * to it — each a user-owned offer naming its owner — as the complete set for source `bot`++ * (POST /internal/resources/report, network.resource.report); an offer missing from a later report is marked down.++ * report(offers) → Network's answer { source, offers, marked_down }++ * A failure throws 503 bot.network_unavailable.++ */++function createOfferReporter(config, { fetchImpl = globalThis.fetch } = {}) {++ const tokens = serviceAuth.createTokenClient({++ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network', scope: 'network.resource.report', fetchImpl,++ });++ async function report(offers) {++ let res;++ try {++ res = await fetchImpl(`${config.network.internalUrl}/internal/resources/report`, {++ method: 'POST', headers: { ...(await tokens.authHeaders()), Accept: 'application/json', 'Content-Type': 'application/json' },++ body: JSON.stringify({ source: 'bot', offers }), signal: AbortSignal.timeout(8000),++ });++ } catch (e) {++ throw new BotError(503, 'bot.network_unavailable', `Network did not answer the offers report: ${e.message}`);++ }++ if (res.status === 401) tokens.invalidate();++ const out = await res.json().catch(() => null);++ if (!res.ok || !out) throw new BotError(503, 'bot.network_unavailable', `Network answered ${res.status} for the offers report${out && out.error ? `: ${out.error}` : ''}`);++ return out;++ }++ return { report };++}++/*** Network's identity resolution for people (usernames ↔ subjects), with Bot's own svc:bot token* (audience openvibe.network, identity.subject.resolve — Network grants Bot this).@@@ -157,4 +187,4 @@ function createIdentity(config, { fetchImpl = globalThis.fetch, now = () => Datereturn { byUsername, names };}--module.exports = { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity };++module.exports = { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity, createOfferReporter };@@@ -143,7 +143,7 @@ function renderDirectory({ config, listed = [], mine = null, models = [], signedconst yours = !signedIn ? `<section class="dir-sec dir-cta"><h2>Have a robot?</h2><p class="muted">Sign in to add it and pair it with one pasted command, or drive a simulated rover first, no account needed.</p><p class="dir-actions">${link('/try', 'Try the panel', 'dir-btn dir-primary')} ${link('/auth/login?next=%2Frobots%2Fnew', 'Sign in and add a robot', 'dir-btn')}</p></section>`-- : `<section class="dir-sec" aria-labelledby="yours-h"><div class="dir-sec-head"><h2 id="yours-h">Your robots</h2>${link('/robots/new', 'Add a robot', 'dir-btn dir-primary')}</div>++ : `<section class="dir-sec" aria-labelledby="yours-h"><div class="dir-sec-head"><h2 id="yours-h">Your robots</h2>${link('/machines', 'Your machines', 'dir-btn')}${link('/robots/new', 'Add a robot', 'dir-btn dir-primary')}</div>${mine && mine.length ? `<ul class="dir-list">${mine.map((r) => `<li class="dir-row"><a href="/panel/${esc(r.id)}"><b>${esc(r.name)}</b></a><span class="muted">${esc(r.model_name)}</span>`+ `${r.listed ? `<a class="dir-pill" href="/r/${esc(r.id)}">Listed</a>` : '<span class="dir-pill dir-quiet">Not listed</span>'}`+ `<span class="dir-row-actions">${link(`/panel/${r.id}`, 'Panel')} · ${link(`/pair/${r.id}`, 'Pair a device')}</span></li>`).join('')}</ul>`@@@ -579,6 +579,41 @@ ${first}return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });}++/**++ * /machines (plan T14/T16): a person's own machines that run jobs for them, and the command that adds one. A machine++ * here runs its owner's jobs only (Bot refuses anyone else's), connects out, and keeps its local limits and kill switch.++ */++function renderMachinesPage({ machines = [], pairing = null }) {++ const classesOf = (m) => {++ const w = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).worker || {};++ return Array.isArray(w.runtime_classes) ? w.runtime_classes : [];++ };++ 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>`++ + `<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++ ? `<ol class="steps"><li class="card"><h2>Run this on the machine</h2>++<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 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>`++ : `<form class="add-robot card" method="post" action="/machines"><h2>Add a machine</h2>++<p class="muted">You get a one-line install command with a one-time code.</p><button type="submit" class="primary">Add a machine</button></form>`;++ const body = `${topbar('<span class="crumb">Machines</span>')}++<main class="page">++<h1>Your machines</h1>++<p class="muted">A machine here runs jobs for you and only you. It connects out to OpenVibe and needs no open ports. The limits in its own config and its local kill switch (<code>openvibe-node stop</code>) always win.</p>++${list}++${add}++</main>`;++ return page('Your machines', body, { scripts: pairing ? ['/panel/panel.js'] : [], description: 'Your own machines that run your jobs on OpenVibe.', canonical: '/machines' });++}++/*** /device/phone: the page that makes a phone the robot's device (public/phone-device.js). Its setup form pairs it (the* QR code fills in the robot and code); once running, the screen is the robot's face, with the link and camera state@@@ -613,4 +648,4 @@ function renderPhonePage() {return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });}--module.exports = { page, renderPanel, renderPhonePage, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };++module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };@@@ -36,7 +36,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');const { frameAncestors } = require('../config');const { BotError } = require('../util');const kits = require('../kits');--const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage } = require('./render');++const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage } = require('./render');const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage } = require('./docs');const { checkProfile } = require('../profiles');const { renderHome, HOME_CSP } = require('./home');@@@ -423,6 +423,34 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst purpose = String(req.headers['sec-purpose'] || req.headers.purpose || req.headers['x-moz'] || '');return (site && site !== 'same-origin' && site !== 'none') || /prefetch|prerender/i.test(purpose);};++ // ── The person's own machines that run their jobs (plan T14/T16) ──++ const machineRows = async (subject) => Promise.all((await domain.machines.list(subject)).map(async (d) => {++ const robotId = Array.isArray(d.robot_ids) ? d.robot_ids[0] : null;++ const robot = robotId ? await domain.robots.get(robotId) : null;++ return { ...d, online: !!(hub && hub.isOnline(d.id)), robot: robot ? robot.name : null };++ }));++ r.get('/machines', frameCsp, page(async (req, res) => {++ const me = requireUser(req);++ res.setHeader('Vary', 'Cookie');++ res.type('html').send(renderMachinesPage({ machines: await machineRows(me.subject) }));++ }));++ r.post('/machines', ...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 pairing = await domain.machines.mint(me.subject);++ res.status(201).type('html').send(renderMachinesPage({ machines: await machineRows(me.subject), pairing }));++ }));++ 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');++ 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.devices.revoke(mine.id);++ if (mine.node_principal) await domain.devices.revokeNode(mine.node_principal).catch((e) => log.warn(`[Bot] machine ${mine.id} on Network: ${e.message}`));++ if (hub) hub.closeDevice(mine.id, 'revoked');++ res.redirect(303, '/machines');++ }));++r.get('/pair/:id', ...manage, page(async (req, res) => {const me = requireUser(req);if (crossSiteOrPrefetch(req)) return res.redirect(303, '/robots');nnew file mode 100644@@@ -0,0 +1,104 @@++'use strict';++// A person's own machine that runs their jobs (plan T14/T16, contracts 0.161.0): a Network pairing with ref++// `worker:<owner>` binds a machine that serves no robot and keeps its owner; Bot reports every machine with a job++// worker to Network as a user-owned offer naming its owner (up while connected, down once it goes); a job reaches a++// machine only for its owner; /machines lists the person's machines, adds one with a one-time `--worker` command and++// removes one.++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const reports = [];++ const reporter = { report: async (offers) => { reports.push(offers); return { source: 'bot', offers: offers.length, marked_down: 0 }; } };++ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0' }, appOpts: { offerReporter: reporter } });++ const alex = t.network.newUser('alex');++ const bob = t.network.newUser('bob');++ const { dispatch } = require('../server/jobs/dispatch');++ 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(50); } return null; };++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });++ const form = (p, user, headers = {}) => fetch(t.base + p, { method: 'POST', redirect: 'manual', body: '', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers } });++ let seq = 0;++ const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });++ const offers = t.app.locals.offers;++++ /** A Network-paired machine for `ref`, connected, advertising `classes` (none: no job worker). */++ async function machine(owner, ref, classes = ['code']) {++ const principal = t.network.addNode({ owner: owner.subject, ref, 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;++ if (classes.length) {++ c.send(frame('status', { capabilities: { worker: { capabilities: classes.map((x) => `worker:${x}`), runtime_classes: classes } } }));++ await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [deviceId]); return d && d.capabilities.worker; });++ }++ return { principal, c, deviceId };++ }++++ let mine;++ await check('a worker pairing binds a machine with no robot, owned by whoever paired it', async () => {++ mine = await machine(alex, `worker:${alex.subject}`);++ const row = await t.db.maybe('SELECT * FROM devices WHERE id = $1', [mine.deviceId]);++ assert.deepStrictEqual([row.robot_ids, row.owner_subject, row.kind], [[], alex.subject, 'server']);++ assert.strictEqual(await t.domain.devices.ownerOf(row), alex.subject);++ // Someone else's worker ref with this owner is refused: the ref must name the pairing's own owner.++ const stray = t.network.addNode({ owner: alex.subject, ref: `worker:${bob.subject}` });++ const bound = await t.call('POST', '/api/v1/devices/bind', { token: t.network.signNode(stray), body: {} });++ assert.strictEqual(bound.status, 403, bound.text);++ assert.strictEqual(bound.json.code, 'bot.node_not_bound');++ });++++ await check('its worker offer is user-owned and names its owner: up while connected, down once it goes', async () => {++ const built = await offers.build();++ const offer = built.find((o) => o.node_id === mine.deviceId);++ assert.ok(offer, JSON.stringify(built));++ assert.deepStrictEqual([offer.kind, offer.trust, offer.owner, offer.capabilities, offer.health.status],++ ['node', 'user-owned', alex.subject, ['worker:code'], 'up']);++ // A robot's machine with a job worker is its robot owner's; a machine with no worker offers nothing.++ const { robot } = await t.robot(bob);++ const robots = await machine(bob, robot.id, ['function']);++ const quiet = await machine(bob, `worker:${bob.subject}`, []);++ const again = await offers.build();++ assert.strictEqual(again.find((o) => o.node_id === robots.deviceId).owner, bob.subject);++ assert.strictEqual(again.find((o) => o.node_id === quiet.deviceId), undefined);++ quiet.c.close();++ robots.c.close();++ // Reported whole, as Network's resource registry takes it.++ await offers.publish();++ assert.deepStrictEqual(reports.at(-1).map((o) => o.offer_id).sort(), again.map((o) => o.offer_id).sort());++ });++++ await check('a machine runs only its owner’s jobs', async () => {++ const job = (over = {}) => ({ id: `job_${ids.ulid()}`, class: 'code', artifact: { name: 'openvibe-codes', version: '0.3.0' }, args: {}, ttl_ms: 60000, limits: { wall_ms: 30000, cpu_ms: 30000, mem_bytes: 268435456 }, ...over });++ const project = `prj_${ids.ulid()}`;++ const send = (subject) => dispatch(t.db, mine.deviceId, job(), { link: t.hub, project, subject });++ const ok = await send(`user:${alex.subject}`);++ assert.strictEqual(ok.sent, true);++ for (const subject of [`user:${bob.subject}`, null, 'service:watch', alex.subject]) {++ await assert.rejects(send(subject), (e) => e.status === 403 && e.code === 'bot.not_owner', String(subject));++ }++ });++++ await check('/machines lists them, adds one with a one-time --worker command, and removes one', async () => {++ const anon = await fetch(`${t.base}/machines`, { redirect: 'manual' });++ assert.strictEqual(anon.status, 302, 'signed out: to the sign-in');++ const page = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();++ assert.ok(page.includes('Your machines') && page.includes('desk-pc') && page.includes('Online') && page.includes('code jobs'), page);++ assert.ok(!page.includes(bob.subject));++ const added = await form('/machines', alex);++ assert.strictEqual(added.status, 201);++ const html = await added.text();++ const pairing = t.network.pairings.at(-1);++ assert.strictEqual(pairing.body.ref, `worker:${alex.subject}`);++ assert.ok(html.includes('--worker') && html.includes('--pairing pair_'), html);++ assert.strictEqual((await form('/machines', alex, { Origin: 'https://evil.test' })).status, 403, 'no cross-site post');++ assert.strictEqual((await form(`/machines/${mine.deviceId}/remove`, bob)).status, 404, 'not bob’s');++ const removed = await form(`/machines/${mine.deviceId}/remove`, alex);++ assert.strictEqual(removed.status, 303);++ assert.ok((await t.db.maybe('SELECT revoked_at FROM devices WHERE id = $1', [mine.deviceId])).revoked_at);++ assert.ok(t.network.revokes.includes(mine.principal));++ assert.strictEqual((await offers.build()).find((o) => o.node_id === mine.deviceId), undefined, 'a removed machine offers nothing');++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -41,6 +41,7 @@ Sitemap entries use the release commit time for these code-backed pages.
@@ -41,6 +41,7 @@ Sitemap entries use the release commit time for these code-backed pages.
## Devices
## Devices
- 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 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.
@@ -98,6 +99,7 @@ A person acts on their own robots with a Network user token; a service acts with
@@ -98,6 +99,7 @@ A person acts on their own robots with a Network user token; a service acts with
- A device credential is a secret: sent only in the `Authorization` header (never a query string), stored hashed, returned once by `pair` or a rotation, and revoking it closes the socket at once.
- A device credential is a secret: sent only in the `Authorization` header (never a query string), stored hashed, returned once by `pair` or a rotation, and revoking it closes the socket at once.
- A person acts only as themself; naming anyone else is refused, never ignored, and a node token is refused on the person and service routes.
- A person acts only as themself; naming anyone else is refused, never ignored, and a node token is refused on the person and service routes.
+- A person's machine runs only that person's jobs: the job dispatcher compares the job's requester (`user:usr_…`, as Run names it) with the machine's owner (a worker machine's own owner, else its robot's owner) and refuses anyone else's, a service's or nobody's job with `bot.not_owner`, whatever the placer chose. Its offer to Network names the owner and Network keeps it off the public offers list.
- Pairing codes are one-time and short-lived; the e-stop is latched and only the owner clears it, while `halt` always passes the gate.
- Pairing codes are one-time and short-lived; the e-stop is latched and only the owner clears it, while `halt` always passes the gate.
- Every command, allowed or refused, is audited; the outbox carries ids, kinds and results — never a credential, a pairing code, a publish key or a WHIP URL.
- Every command, allowed or refused, is audited; the outbox carries ids, kinds and results — never a credential, a pairing code, a publish key or a WHIP URL.
- A server-side ONVIF camera is reached only at the one URL configured in `BOT_ONVIF_CAMERAS` (http(s), one host, no embedded credentials), with a timeout and no redirects, so a camera cannot steer Bot elsewhere. Its ONVIF user and password are named by secret reference (`username_ref`/`password_ref`), read only when a request is built, sent as a WS-Security digest and never stored, answered or logged.
- A server-side ONVIF camera is reached only at the one URL configured in `BOT_ONVIF_CAMERAS` (http(s), one host, no embedded credentials), with a timeout and no redirects, so a camera cannot steer Bot elsewhere. Its ONVIF user and password are named by secret reference (`username_ref`/`password_ref`), read only when a request is built, sent as a WS-Security digest and never stored, answered or logged.
@@ -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.160.0",
+ "contracts": "openvibe-contracts v0.161.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": [
@@ -56,7 +56,8 @@
@@ -56,7 +56,8 @@
"embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",
"embed opt-in: robots.embed_public (migration 0005), set only by the owner with POST /robots/:id/embed, web-only (not in /api/v1); BOT_EMBED_ORIGINS is the validated frame-ancestors allow-list for the embeddable panel (default OpenVibe.Live)",
"embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot",
"embeddable panel: GET /panel/:id/embed framed only by BOT_EMBED_ORIGINS (no cookie, never a sign-in redirect; a member keeps their role, anyone else watches an embed_public robot) and the anonymous read-only /watch socket carrying the public state (online, e-stop, latency, battery, the profile's readouts), capped per address and per robot",
"streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once",
"streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once",
- "server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects"
+ "server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects",
+ "a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)"
],
],
"updated": "2026-10-14"
"updated": "2026-10-14"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- Plan T14/T16: a person's own machine that runs jobs for them (a worker) and serves no robot. Its owner is stored on
+-- the device; a robot's device keeps NULL here and is owned by its robot's owner.
+ALTER TABLE devices ADD COLUMN owner_subject text;
+CREATE INDEX devices_owner ON devices (owner_subject) WHERE owner_subject IS NOT NULL AND revoked_at IS NULL;
@@ -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.160.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.161.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.160.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.160.0",
- "integrity": "sha512-1hhbfO3js3IRrCH2XDTy6TVo9aPaSqSangyBRq1U2WdMvoToeujiucERZnXQCW8kIYWM6R+Ow5M7jNGi7ySnwQ==",
+ "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==",
"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.160.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.161.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",
@@ -24,7 +24,8 @@ const { http } = require('openvibe-contracts');
@@ -24,7 +24,8 @@ const { http } = require('openvibe-contracts');
const { isLoopbackDirect } = require('openvibe-shared/metrics');
const { isLoopbackDirect } = require('openvibe-shared/metrics');
const { loadConfig } = require('./config');
const { loadConfig } = require('./config');
const { openDb } = require('./db');
const { openDb } = require('./db');
-const { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity } = require('./network');
+const { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity, createOfferReporter } = require('./network');
+const { createOfferPublisher } = require('./jobs/offers');
const { createOpenRe } = require('./openre/client');
const { createOpenRe } = require('./openre/client');
const { createBotOutbox } = require('./events/outbox');
const { createBotOutbox } = require('./events/outbox');
const { createUsageRelay } = require('./jobs/metering');
const { createUsageRelay } = require('./jobs/metering');
@@ -89,6 +90,12 @@ function createApp(opts = {}) {
@@ -89,6 +90,12 @@ function createApp(opts = {}) {
// One jobs service for the device socket and the jobs API, so both see the same stdout rings.
// One jobs service for the device socket and the jobs API, so both see the same stdout rings.
const jobs = createJobs({ db: () => domain.db, hub, usage, log, now });
const jobs = createJobs({ db: () => domain.db, hub, usage, log, now });
hub.bindJobs(jobs);
hub.bindJobs(jobs);
+ // The worker offers of the machines connected here, reported to Network for Run's placement (server/jobs/offers.js);
+ // started by server/index.js with the other jobs. Without Network credentials nothing is reported.
+ const offerReporter = opts.offerReporter !== undefined ? opts.offerReporter
+ : (config.oauth.clientSecret ? createOfferReporter(config, { fetchImpl }) : null);
+ const offers = createOfferPublisher({ db, hub, reporter: offerReporter, log, now });
+ domain.devices.onChange(() => offers.poke());
const sim = opts.sim || createSimulator({ config, domain, hub, now, log });
const sim = opts.sim || createSimulator({ config, domain, hub, now, log });
// Server-side ONVIF cameras (server/onvif): the same in-process attach as the simulator, but with a real
// Server-side ONVIF cameras (server/onvif): the same in-process attach as the simulator, but with a real
// kind 'server' device row and an outbound ONVIF request per ptz command.
// kind 'server' device row and an outbound ONVIF request per ptz command.
@@ -165,7 +172,7 @@ function createApp(opts = {}) {
@@ -165,7 +172,7 @@ function createApp(opts = {}) {
return res.status(500).type('text/plain').send('Something went wrong\n');
return res.status(500).type('text/plain').send('Something went wrong\n');
});
});
- Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, sim, onvif, userAuth, metrics, submissions, routines, webhooks });
+ Object.assign(app.locals, { config, db, valkey, domain, keys, outbox, usage, hub, jobs, offers, sim, onvif, userAuth, metrics, submissions, routines, webhooks });
return app;
return app;
}
}
@@ -19,6 +19,8 @@ const {
@@ -19,6 +19,8 @@ const {
} = require('../util');
} = require('../util');
const { getProfile, validateProfile, reservedKeyReason } = require('../profiles');
const { getProfile, validateProfile, reservedKeyReason } = require('../profiles');
const { ENVELOPE } = require('../events/outbox');
const { ENVELOPE } = require('../events/outbox');
+/** A person's subject (the owner a worker machine's pairing names). */
+const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const DEFAULT_ALLOW = {
const DEFAULT_ALLOW = {
owner: ['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'halt'],
owner: ['drive', 'actuator', 'ptz', 'say', 'display', 'button', 'point', 'halt'],
@@ -69,6 +71,12 @@ function noteToHz(note) {
@@ -69,6 +71,12 @@ function noteToHz(note) {
function createDomain({ db, config, outbox, link = null, nodes = null, openre = null, webhooks = null, onEvent = null, now = () => Date.now(), log = console }) {
function createDomain({ db, config, outbox, link = null, nodes = null, openre = null, webhooks = null, onEvent = null, now = () => Date.now(), log = console }) {
+ // Listeners told when a device appears, goes on or offline, changes what it advertises or is revoked (the worker
+ // offers Bot reports to Network follow these, server/jobs/offers.js).
+ const changeListeners = new Set();
+ const onChange = (fn) => { changeListeners.add(fn); return () => changeListeners.delete(fn); };
+ const changed = () => { for (const fn of changeListeners) { try { fn(); } catch { /* a listener never breaks the caller */ } } };
+ const workerRef = (subject) => `worker:${subject}`;
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
const presentRobot = (r) => (r ? {
const presentRobot = (r) => (r ? {
id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
@@ -589,6 +597,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -589,6 +597,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
if (!d) fail(404, 'bot.device_not_found', 'no such device');
if (!d) fail(404, 'bot.device_not_found', 'no such device');
const at = iso(now());
const at = iso(now());
const updated = await db.maybe('UPDATE devices SET revoked_at = $2, credential_prev_hash = NULL, prev_valid_until = NULL, updated_at = $2 WHERE id = $1 RETURNING *', [id, at]);
const updated = await db.maybe('UPDATE devices SET revoked_at = $2, credential_prev_hash = NULL, prev_valid_until = NULL, updated_at = $2 WHERE id = $1 RETURNING *', [id, at]);
+ changed();
return updated;
return updated;
}
}
@@ -612,18 +621,56 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -612,18 +621,56 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
if (!p || p.principal !== principalId) refuse('Network knows no such machine paired for Bot');
if (!p || p.principal !== principalId) refuse('Network knows no such machine paired for Bot');
if (p.status !== 'active') refuse(`the machine is ${p.status} on Network`);
if (p.status !== 'active') refuse(`the machine is ${p.status} on Network`);
if (!p.paired_for || p.paired_for.service !== 'bot') refuse('the machine was not paired for Bot');
if (!p.paired_for || p.paired_for.service !== 'bot') refuse('the machine was not paired for Bot');
- const robot = isRobotId(p.paired_for.ref) ? await getRobot(p.paired_for.ref) : null;
- if (!robot || !p.owner || p.owner.kind !== 'user' || p.owner.subject !== robot.owner_subject) refuse("the machine was not paired for one of its owner's robots");
+ // A worker machine (plan T14/T16) runs its owner's own jobs and serves no robot: its pairing names the owner
+ // (ref `worker:<owner>`), and the device keeps that owner.
+ const worker = !!(p.owner && p.owner.kind === 'user' && USER_SUBJECT.test(String(p.owner.subject)) && p.paired_for.ref === workerRef(p.owner.subject));
+ const robot = !worker && isRobotId(p.paired_for.ref) ? await getRobot(p.paired_for.ref) : null;
+ if (!worker && (!robot || !p.owner || p.owner.kind !== 'user' || p.owner.subject !== robot.owner_subject)) refuse("the machine was not paired for one of its owner's robots");
const at = iso(now());
const at = iso(now());
const name = typeof p.name === 'string' ? storable(p.name).trim().slice(0, 80) || null : null;
const name = typeof p.name === 'string' ? storable(p.name).trim().slice(0, 80) || null : null;
// Two first connections at once: the unique index lets one insert win, and the other reads it.
// Two first connections at once: the unique index lets one insert win, and the other reads it.
const inserted = await db.maybe(
const inserted = await db.maybe(
- `INSERT INTO devices (id, robot_ids, name, kind, agent_version, drivers, capabilities, credential_hash, node_principal, created_at, updated_at)
- VALUES ($1, $2::jsonb, $3, 'onboard', NULL, '[]'::jsonb, '{}'::jsonb, NULL, $4, $5, $5)
+ `INSERT INTO devices (id, robot_ids, name, kind, agent_version, drivers, capabilities, credential_hash, node_principal, owner_subject, created_at, updated_at)
+ VALUES ($1, $2::jsonb, $3, $4, NULL, '[]'::jsonb, '{}'::jsonb, NULL, $5, $6, $7, $7)
ON CONFLICT (node_principal) WHERE node_principal IS NOT NULL AND revoked_at IS NULL DO NOTHING RETURNING *`,
ON CONFLICT (node_principal) WHERE node_principal IS NOT NULL AND revoked_at IS NULL DO NOTHING RETURNING *`,
- [prefixedId('dev', now()), JSON.stringify([robot.id]), name, principalId, at]);
+ [prefixedId('dev', now()), JSON.stringify(worker ? [] : [robot.id]), name, worker ? 'server' : 'onboard', principalId,
+ worker ? p.owner.subject : null, at]);
+ changed();
return inserted || db.maybe('SELECT * FROM devices WHERE node_principal = $1 AND revoked_at IS NULL', [principalId]);
return inserted || db.maybe('SELECT * FROM devices WHERE node_principal = $1 AND revoked_at IS NULL', [principalId]);
}
}
+
+ // ── Machines that run their owner's jobs (plan T14/T16) ────────────────────────────────────────
+ /**
+ * A one-time code for `ownerSubject` to add a machine that runs their own jobs (Network pairing, ref
+ * `worker:<owner>`; Network keeps one live code per ref, so a new code replaces an unused one). The installer
+ * command turns the Node's job worker on (`--worker`). → { code, expires_at, pairing_id, installer }
+ */
+ async function mintMachine(ownerSubject, installerUrl) {
+ if (!nodes) fail(503, 'bot.network_unavailable', 'Network pairing is not configured here');
+ const p = await nodes.pair({ subject: ownerSubject, ref: workerRef(ownerSubject) });
+ if (!p || typeof p.pairing_id !== 'string' || typeof p.code !== 'string') fail(503, 'bot.network_unavailable', 'Network answered no pairing code');
+ const url = installerUrl || config.installer.scriptUrl;
+ return {
+ code: p.code, expires_at: p.expires_at, pairing_id: p.pairing_id,
+ installer: `curl -fsSL ${url} | sh -s -- --network ${config.network.url} --pairing ${p.pairing_id} --code ${p.code} --worker`,
+ };
+ }
+ /** The person's live machines that run jobs: their worker machines, and their robots' machines that have a job worker. */
+ async function listMachines(subject) {
+ return db.many(
+ `SELECT d.* FROM devices d
+ WHERE d.revoked_at IS NULL AND (d.owner_subject = $1 OR (d.capabilities -> 'worker' IS NOT NULL AND EXISTS (
+ 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]);
+ }
+ /** 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;
+ if (device.owner_subject) return device.owner_subject;
+ const robotId = json(device.robot_ids, [])[0];
+ const robot = robotId ? await getRobot(robotId) : null;
+ return robot ? robot.owner_subject : null;
+ }
/**
/**
* Issue (or re-issue) a device's WHIP publish key, a new ingest key of its robot's OpenRestream stream: the new
* Issue (or re-issue) a device's WHIP publish key, a new ingest key of its robot's OpenRestream stream: the new
* key replaces the old one, which stops working. → { device, publish_key, whip_url?, profile }, the key
* key replaces the old one, which stops working. → { device, publish_key, whip_url?, profile }, the key
@@ -647,6 +694,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -647,6 +694,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
}
}
/** Persist what a Network-paired device declares (kind, drivers, capabilities, agent_version): only the given fields. */
/** Persist what a Network-paired device declares (kind, drivers, capabilities, agent_version): only the given fields. */
async function updateDeclared(id, { kind, drivers, capabilities, agent_version: agentVersion }) {
async function updateDeclared(id, { kind, drivers, capabilities, agent_version: agentVersion }) {
+ if (capabilities) changed();
return db.maybe(
return db.maybe(
`UPDATE devices SET kind = COALESCE($2, kind), drivers = COALESCE($3::jsonb, drivers), capabilities = COALESCE($4::jsonb, capabilities),
`UPDATE devices SET kind = COALESCE($2, kind), drivers = COALESCE($3::jsonb, drivers), capabilities = COALESCE($4::jsonb, capabilities),
agent_version = CASE WHEN $5 THEN $6 ELSE agent_version END, updated_at = $7
agent_version = CASE WHEN $5 THEN $6 ELSE agent_version END, updated_at = $7
@@ -663,6 +711,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -663,6 +711,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
async function touchSeen(id) { await db.query('UPDATE devices SET last_seen = $2 WHERE id = $1', [id, iso(now())]); }
async function touchSeen(id) { await db.query('UPDATE devices SET last_seen = $2 WHERE id = $1', [id, iso(now())]); }
async function setOnline(device, online) {
async function setOnline(device, online) {
+ changed();
const robotIds = json(device.robot_ids, []);
const robotIds = json(device.robot_ids, []);
const at = iso(now());
const at = iso(now());
await db.tx(async (t) => {
await db.tx(async (t) => {
@@ -1030,7 +1079,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1030,7 +1079,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
localProfile, saveLocalProfile, useCatalogueProfile },
localProfile, saveLocalProfile, useCatalogueProfile },
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 },
+ 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 },
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 },
@@ -33,7 +33,7 @@ async function main() {
@@ -33,7 +33,7 @@ async function main() {
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const app = createApp({ config, db, valkey, registry });
const app = createApp({ config, db, valkey, registry });
- const { domain, keys, outbox, usage, hub, sim, onvif, routines, webhooks } = app.locals;
+ const { domain, keys, outbox, usage, hub, offers, sim, onvif, routines, webhooks } = app.locals;
keys.start().catch(() => { /* logged by the JWKS client */ });
keys.start().catch(() => { /* logged by the JWKS client */ });
const timers = [];
const timers = [];
@@ -46,6 +46,7 @@ async function main() {
@@ -46,6 +46,7 @@ async function main() {
usage.start();
usage.start();
routines.start(); // scheduled routines (server/routines.js)
routines.start(); // scheduled routines (server/routines.js)
webhooks.start(); // robot webhook deliveries (server/webhooks.js)
webhooks.start(); // robot webhook deliveries (server/webhooks.js)
+ offers.start(); // the worker offers of the machines connected here, to Network (server/jobs/offers.js)
}
}
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
@@ -82,6 +83,7 @@ async function main() {
@@ -82,6 +83,7 @@ async function main() {
() => timers.forEach(clearInterval),
() => timers.forEach(clearInterval),
() => routines.stop(),
() => routines.stop(),
() => webhooks.stop(),
() => webhooks.stop(),
+ () => offers.stop(),
() => keys.stop(),
() => keys.stop(),
() => sim.stopAll(),
() => sim.stopAll(),
() => onvif.stopAll(),
() => onvif.stopAll(),
@@ -37,7 +37,14 @@ function advertisedClasses(capabilities) {
@@ -37,7 +37,14 @@ function advertisedClasses(capabilities) {
return worker && Array.isArray(worker.runtime_classes) ? worker.runtime_classes.filter((c) => typeof c === 'string') : [];
return worker && Array.isArray(worker.runtime_classes) ? worker.runtime_classes.filter((c) => typeof c === 'string') : [];
}
}
const canonical = (v) => JSON.stringify(v, (k, x) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.keys(x).sort().map((key) => [key, x[key]])) : x));
const canonical = (v) => JSON.stringify(v, (k, x) => (x && typeof x === 'object' && !Array.isArray(x) ? Object.fromEntries(Object.keys(x).sort().map((key) => [key, x[key]])) : x));
-const liveDevice = (db, id) => db.maybe('SELECT id, capabilities FROM devices WHERE id = $1 AND revoked_at IS NULL', [id]);
+const liveDevice = (db, id) => db.maybe('SELECT id, capabilities, robot_ids, owner_subject FROM devices WHERE id = $1 AND revoked_at IS NULL', [id]);
+/** Whose machine it is: a worker machine's owner, else its robot's owner (every device here is a person's own). */
+async function ownerOfDevice(db, device) {
+ if (device.owner_subject) return device.owner_subject;
+ const robotId = json(device.robot_ids, [])[0];
+ const robot = robotId ? await db.maybe('SELECT owner_subject FROM robots WHERE id = $1', [robotId]) : null;
+ return robot ? robot.owner_subject : null;
+}
/**
/**
* Validate `job` against platform.job@1, store it for device `nodeId` and send it if the device is connected
* Validate `job` against platform.job@1, store it for device `nodeId` and send it if the device is connected
@@ -53,6 +60,12 @@ async function dispatch(db, nodeId, job, { link, project = null, subject = null,
@@ -53,6 +60,12 @@ async function dispatch(db, nodeId, job, { link, project = null, subject = null,
const device = await liveDevice(db, nodeId);
const device = await liveDevice(db, nodeId);
if (!device) fail(404, 'bot.device_not_found', 'no such device, or it is revoked');
if (!device) fail(404, 'bot.device_not_found', 'no such device, or it is revoked');
if (!advertisedClasses(device.capabilities).includes(job.class)) fail(409, 'bot.class_unadvertised', `the device does not advertise the runtime class ${job.class}`);
if (!advertisedClasses(device.capabilities).includes(job.class)) fail(409, 'bot.class_unadvertised', `the device does not advertise the runtime class ${job.class}`);
+ // A person's machine runs only that person's own jobs (contracts 0.161.0, ADR-046): Run names the job's requester
+ // as `subject`, and anyone else's job, or a job for nobody, is refused here whatever the placer chose.
+ // `subject` is a subject reference (`user:usr_…`, how Run formats a person); a service's or nobody's job never runs.
+ const owner = await ownerOfDevice(db, device);
+ const person = typeof subject === 'string' && /^user:usr_/.test(subject) ? subject.slice('user:'.length) : null;
+ if (!owner || person !== owner) fail(403, 'bot.not_owner', "a person's machine runs only that person's own jobs");
const { row, created } = await store.put(db, { job, nodeId, project, subject, provider, at: now() });
const { row, created } = await store.put(db, { job, nodeId, project, subject, provider, at: now() });
if (!created && (row.node_id !== nodeId || canonical(row.job) !== canonical(job)
if (!created && (row.node_id !== nodeId || canonical(row.job) !== canonical(job)
|| row.project_id !== project || row.subject !== subject || row.provider !== provider)) {
|| row.project_id !== project || row.subject !== subject || row.provider !== provider)) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,99 @@
@@ -0,0 +1,99 @@
+'use strict';
+/**
+ * The worker offers Bot reports to Network (plan T14/T16, contracts 0.161.0, ADR-046). Every live device that runs a
+ * job worker (OpenVibe.Node status.capabilities.worker.runtime_classes) is a person's own machine: one
+ * platform.resource-offer@1 per device, kind `node`, trust `user-owned`, naming its owner, advertising
+ * `worker:<class>` for each class, `up` while the device is connected and `down` otherwise. The set is reported whole
+ * as source `bot` (Network marks an offer missing from a later report down), once a minute and a moment after a device
+ * comes or goes or changes what it advertises.
+ *
+ * createOfferPublisher({ db, hub, reporter, log, now, intervalMs, settleMs }) → { build, publish, start, stop, poke }
+ *
+ * An offer that does not match the contract is left out (Network would refuse the whole report for one bad offer).
+ * Without a reporter (no Network credentials) nothing is reported.
+ */
+const { validate } = require('openvibe-contracts');
+const { json } = require('../util');
+
+const CONTRACT = 'platform.resource-offer@1';
+const CLASSES = new Set(['function', 'code', 'browser', 'linux', 'desktop', 'gpu']);
+
+function createOfferPublisher({ db, hub, reporter = null, log = console, now = () => Date.now(), intervalMs = 60_000, settleMs = 2_000 }) {
+ let timer = null;
+ let soon = null;
+ let running = null;
+ let again = false;
+
+ /** The offers of every live worker device, owner and connection included. */
+ async function build() {
+ const rows = await db.many(
+ `SELECT d.id, d.capabilities, d.owner_subject, r.owner_subject AS robot_owner
+ FROM devices d LEFT JOIN robots r ON r.id = d.robot_ids ->> 0
+ WHERE d.revoked_at IS NULL AND d.capabilities -> 'worker' IS NOT NULL
+ ORDER BY d.id`);
+ const at = new Date(now()).toISOString();
+ const offers = [];
+ for (const row of rows) {
+ const worker = json(row.capabilities, {}).worker || {};
+ const classes = (Array.isArray(worker.runtime_classes) ? worker.runtime_classes : []).filter((c) => CLASSES.has(c));
+ const owner = row.owner_subject || row.robot_owner;
+ if (!classes.length || !owner) continue;
+ const offer = {
+ offer_id: `node:${row.id}`, kind: 'node', node_id: row.id, provider: 'self', region: 'home',
+ trust: 'user-owned', owner,
+ capabilities: classes.map((c) => `worker:${c}`),
+ health: { status: hub && hub.isOnline(row.id) ? 'up' : 'down', checked_at: at },
+ pricing: { model: 'free-allowance', marginal_usd_per_unit: 0 },
+ updated_at: at,
+ };
+ const v = validate(CONTRACT, offer);
+ if (!v.valid) {
+ log.warn(`[Bot] offers: ${row.id} does not match ${CONTRACT}, left out`);
+ continue;
+ }
+ offers.push(offer);
+ }
+ return offers;
+ }
+
+ /** Report the whole set now (one report at a time; a change during one reports again after it). */
+ async function publish() {
+ if (!reporter) return null;
+ if (running) { again = true; return running; }
+ running = (async () => {
+ try {
+ return await reporter.report(await build());
+ } catch (e) {
+ log.warn(`[Bot] offers report: ${e.message}`);
+ return null;
+ } finally {
+ running = null;
+ if (again) { again = false; poke(); }
+ }
+ })();
+ return running;
+ }
+
+ /** A device changed: report a moment later (changes in a burst are reported once). Only once started. */
+ function poke() {
+ if (!reporter || !timer || soon) return;
+ soon = setTimeout(() => { soon = null; void publish(); }, settleMs);
+ if (soon.unref) soon.unref();
+ }
+
+ function start() {
+ if (!reporter || timer) return;
+ timer = setInterval(() => { void publish(); }, intervalMs);
+ if (timer.unref) timer.unref();
+ poke();
+ }
+ function stop() {
+ if (timer) clearInterval(timer);
+ if (soon) clearTimeout(soon);
+ timer = null;
+ soon = null;
+ }
+ return { build, publish, start, stop, poke };
+}
+
+module.exports = { createOfferPublisher };
@@ -87,6 +87,36 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
@@ -87,6 +87,36 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
};
};
}
}
+/**
+ * Network's resource registry (plan T14, contracts 0.161.0): Bot reports the worker offers of the machines connected
+ * to it — each a user-owned offer naming its owner — as the complete set for source `bot`
+ * (POST /internal/resources/report, network.resource.report); an offer missing from a later report is marked down.
+ * report(offers) → Network's answer { source, offers, marked_down }
+ * A failure throws 503 bot.network_unavailable.
+ */
+function createOfferReporter(config, { fetchImpl = globalThis.fetch } = {}) {
+ const tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network', scope: 'network.resource.report', fetchImpl,
+ });
+ async function report(offers) {
+ let res;
+ try {
+ res = await fetchImpl(`${config.network.internalUrl}/internal/resources/report`, {
+ method: 'POST', headers: { ...(await tokens.authHeaders()), Accept: 'application/json', 'Content-Type': 'application/json' },
+ body: JSON.stringify({ source: 'bot', offers }), signal: AbortSignal.timeout(8000),
+ });
+ } catch (e) {
+ throw new BotError(503, 'bot.network_unavailable', `Network did not answer the offers report: ${e.message}`);
+ }
+ if (res.status === 401) tokens.invalidate();
+ const out = await res.json().catch(() => null);
+ if (!res.ok || !out) throw new BotError(503, 'bot.network_unavailable', `Network answered ${res.status} for the offers report${out && out.error ? `: ${out.error}` : ''}`);
+ return out;
+ }
+ return { report };
+}
+
/**
/**
* Network's identity resolution for people (usernames ↔ subjects), with Bot's own svc:bot token
* Network's identity resolution for people (usernames ↔ subjects), with Bot's own svc:bot token
* (audience openvibe.network, identity.subject.resolve — Network grants Bot this).
* (audience openvibe.network, identity.subject.resolve — Network grants Bot this).
@@ -157,4 +187,4 @@ function createIdentity(config, { fetchImpl = globalThis.fetch, now = () => Date
@@ -157,4 +187,4 @@ function createIdentity(config, { fetchImpl = globalThis.fetch, now = () => Date
return { byUsername, names };
return { byUsername, names };
}
}
-module.exports = { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity };
+module.exports = { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity, createOfferReporter };
@@ -143,7 +143,7 @@ function renderDirectory({ config, listed = [], mine = null, models = [], signed
@@ -143,7 +143,7 @@ function renderDirectory({ config, listed = [], mine = null, models = [], signed
const yours = !signedIn ? `<section class="dir-sec dir-cta"><h2>Have a robot?</h2>
const yours = !signedIn ? `<section class="dir-sec dir-cta"><h2>Have a robot?</h2>
<p class="muted">Sign in to add it and pair it with one pasted command, or drive a simulated rover first, no account needed.</p>
<p class="muted">Sign in to add it and pair it with one pasted command, or drive a simulated rover first, no account needed.</p>
<p class="dir-actions">${link('/try', 'Try the panel', 'dir-btn dir-primary')} ${link('/auth/login?next=%2Frobots%2Fnew', 'Sign in and add a robot', 'dir-btn')}</p></section>`
<p class="dir-actions">${link('/try', 'Try the panel', 'dir-btn dir-primary')} ${link('/auth/login?next=%2Frobots%2Fnew', 'Sign in and add a robot', 'dir-btn')}</p></section>`
- : `<section class="dir-sec" aria-labelledby="yours-h"><div class="dir-sec-head"><h2 id="yours-h">Your robots</h2>${link('/robots/new', 'Add a robot', 'dir-btn dir-primary')}</div>
+ : `<section class="dir-sec" aria-labelledby="yours-h"><div class="dir-sec-head"><h2 id="yours-h">Your robots</h2>${link('/machines', 'Your machines', 'dir-btn')}${link('/robots/new', 'Add a robot', 'dir-btn dir-primary')}</div>
${mine && mine.length ? `<ul class="dir-list">${mine.map((r) => `<li class="dir-row"><a href="/panel/${esc(r.id)}"><b>${esc(r.name)}</b></a><span class="muted">${esc(r.model_name)}</span>`
${mine && mine.length ? `<ul class="dir-list">${mine.map((r) => `<li class="dir-row"><a href="/panel/${esc(r.id)}"><b>${esc(r.name)}</b></a><span class="muted">${esc(r.model_name)}</span>`
+ `${r.listed ? `<a class="dir-pill" href="/r/${esc(r.id)}">Listed</a>` : '<span class="dir-pill dir-quiet">Not listed</span>'}`
+ `${r.listed ? `<a class="dir-pill" href="/r/${esc(r.id)}">Listed</a>` : '<span class="dir-pill dir-quiet">Not listed</span>'}`
+ `<span class="dir-row-actions">${link(`/panel/${r.id}`, 'Panel')} · ${link(`/pair/${r.id}`, 'Pair a device')}</span></li>`).join('')}</ul>`
+ `<span class="dir-row-actions">${link(`/panel/${r.id}`, 'Panel')} · ${link(`/pair/${r.id}`, 'Pair a device')}</span></li>`).join('')}</ul>`
@@ -579,6 +579,41 @@ ${first}
@@ -579,6 +579,41 @@ ${first}
return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
return page(`Pair ${robot.name}`, body, { scripts: ['/panel/panel.js'] });
}
}
+/**
+ * /machines (plan T14/T16): a person's own machines that run jobs for them, and the command that adds one. A machine
+ * here runs its owner's jobs only (Bot refuses anyone else's), connects out, and keeps its local limits and kill switch.
+ */
+function renderMachinesPage({ machines = [], pairing = null }) {
+ const classesOf = (m) => {
+ const w = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).worker || {};
+ return Array.isArray(w.runtime_classes) ? w.runtime_classes : [];
+ };
+ 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>`
+ + `<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
+ ? `<ol class="steps"><li class="card"><h2>Run this on the machine</h2>
+<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 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>`
+ : `<form class="add-robot card" method="post" action="/machines"><h2>Add a machine</h2>
+<p class="muted">You get a one-line install command with a one-time code.</p><button type="submit" class="primary">Add a machine</button></form>`;
+ const body = `${topbar('<span class="crumb">Machines</span>')}
+<main class="page">
+<h1>Your machines</h1>
+<p class="muted">A machine here runs jobs for you and only you. It connects out to OpenVibe and needs no open ports. The limits in its own config and its local kill switch (<code>openvibe-node stop</code>) always win.</p>
+${list}
+${add}
+</main>`;
+ return page('Your machines', body, { scripts: pairing ? ['/panel/panel.js'] : [], description: 'Your own machines that run your jobs on OpenVibe.', canonical: '/machines' });
+}
+
/**
/**
* /device/phone: the page that makes a phone the robot's device (public/phone-device.js). Its setup form pairs it (the
* /device/phone: the page that makes a phone the robot's device (public/phone-device.js). Its setup form pairs it (the
* QR code fills in the robot and code); once running, the screen is the robot's face, with the link and camera state
* QR code fills in the robot and code); once running, the screen is the robot's face, with the link and camera state
@@ -613,4 +648,4 @@ function renderPhonePage() {
@@ -613,4 +648,4 @@ function renderPhonePage() {
return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });
return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });
}
}
-module.exports = { page, renderPanel, renderPhonePage, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
+module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
@@ -36,7 +36,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
@@ -36,7 +36,7 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const { BotError } = require('../util');
const kits = require('../kits');
const kits = require('../kits');
-const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage } = require('./render');
+const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage } = require('./render');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage } = require('./docs');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage } = require('./docs');
const { checkProfile } = require('../profiles');
const { checkProfile } = require('../profiles');
const { renderHome, HOME_CSP } = require('./home');
const { renderHome, HOME_CSP } = require('./home');
@@ -423,6 +423,34 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -423,6 +423,34 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const purpose = String(req.headers['sec-purpose'] || req.headers.purpose || req.headers['x-moz'] || '');
const purpose = String(req.headers['sec-purpose'] || req.headers.purpose || req.headers['x-moz'] || '');
return (site && site !== 'same-origin' && site !== 'none') || /prefetch|prerender/i.test(purpose);
return (site && site !== 'same-origin' && site !== 'none') || /prefetch|prerender/i.test(purpose);
};
};
+ // ── The person's own machines that run their jobs (plan T14/T16) ──
+ const machineRows = async (subject) => Promise.all((await domain.machines.list(subject)).map(async (d) => {
+ const robotId = Array.isArray(d.robot_ids) ? d.robot_ids[0] : null;
+ const robot = robotId ? await domain.robots.get(robotId) : null;
+ return { ...d, online: !!(hub && hub.isOnline(d.id)), robot: robot ? robot.name : null };
+ }));
+ r.get('/machines', frameCsp, page(async (req, res) => {
+ const me = requireUser(req);
+ res.setHeader('Vary', 'Cookie');
+ res.type('html').send(renderMachinesPage({ machines: await machineRows(me.subject) }));
+ }));
+ r.post('/machines', ...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 pairing = await domain.machines.mint(me.subject);
+ res.status(201).type('html').send(renderMachinesPage({ machines: await machineRows(me.subject), pairing }));
+ }));
+ 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');
+ 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.devices.revoke(mine.id);
+ if (mine.node_principal) await domain.devices.revokeNode(mine.node_principal).catch((e) => log.warn(`[Bot] machine ${mine.id} on Network: ${e.message}`));
+ if (hub) hub.closeDevice(mine.id, 'revoked');
+ res.redirect(303, '/machines');
+ }));
+
r.get('/pair/:id', ...manage, page(async (req, res) => {
r.get('/pair/:id', ...manage, page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
if (crossSiteOrPrefetch(req)) return res.redirect(303, '/robots');
if (crossSiteOrPrefetch(req)) return res.redirect(303, '/robots');
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'use strict';
+// A person's own machine that runs their jobs (plan T14/T16, contracts 0.161.0): a Network pairing with ref
+// `worker:<owner>` binds a machine that serves no robot and keeps its owner; Bot reports every machine with a job
+// worker to Network as a user-owned offer naming its owner (up while connected, down once it goes); a job reaches a
+// machine only for its owner; /machines lists the person's machines, adds one with a one-time `--worker` command and
+// removes one.
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const reports = [];
+ const reporter = { report: async (offers) => { reports.push(offers); return { source: 'bot', offers: offers.length, marked_down: 0 }; } };
+ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0' }, appOpts: { offerReporter: reporter } });
+ const alex = t.network.newUser('alex');
+ const bob = t.network.newUser('bob');
+ const { dispatch } = require('../server/jobs/dispatch');
+ 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(50); } return null; };
+ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
+ const form = (p, user, headers = {}) => fetch(t.base + p, { method: 'POST', redirect: 'manual', body: '', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers } });
+ let seq = 0;
+ const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });
+ const offers = t.app.locals.offers;
+
+ /** A Network-paired machine for `ref`, connected, advertising `classes` (none: no job worker). */
+ async function machine(owner, ref, classes = ['code']) {
+ const principal = t.network.addNode({ owner: owner.subject, ref, 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;
+ if (classes.length) {
+ c.send(frame('status', { capabilities: { worker: { capabilities: classes.map((x) => `worker:${x}`), runtime_classes: classes } } }));
+ await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [deviceId]); return d && d.capabilities.worker; });
+ }
+ return { principal, c, deviceId };
+ }
+
+ let mine;
+ await check('a worker pairing binds a machine with no robot, owned by whoever paired it', async () => {
+ mine = await machine(alex, `worker:${alex.subject}`);
+ const row = await t.db.maybe('SELECT * FROM devices WHERE id = $1', [mine.deviceId]);
+ assert.deepStrictEqual([row.robot_ids, row.owner_subject, row.kind], [[], alex.subject, 'server']);
+ assert.strictEqual(await t.domain.devices.ownerOf(row), alex.subject);
+ // Someone else's worker ref with this owner is refused: the ref must name the pairing's own owner.
+ const stray = t.network.addNode({ owner: alex.subject, ref: `worker:${bob.subject}` });
+ const bound = await t.call('POST', '/api/v1/devices/bind', { token: t.network.signNode(stray), body: {} });
+ assert.strictEqual(bound.status, 403, bound.text);
+ assert.strictEqual(bound.json.code, 'bot.node_not_bound');
+ });
+
+ await check('its worker offer is user-owned and names its owner: up while connected, down once it goes', async () => {
+ const built = await offers.build();
+ const offer = built.find((o) => o.node_id === mine.deviceId);
+ assert.ok(offer, JSON.stringify(built));
+ assert.deepStrictEqual([offer.kind, offer.trust, offer.owner, offer.capabilities, offer.health.status],
+ ['node', 'user-owned', alex.subject, ['worker:code'], 'up']);
+ // A robot's machine with a job worker is its robot owner's; a machine with no worker offers nothing.
+ const { robot } = await t.robot(bob);
+ const robots = await machine(bob, robot.id, ['function']);
+ const quiet = await machine(bob, `worker:${bob.subject}`, []);
+ const again = await offers.build();
+ assert.strictEqual(again.find((o) => o.node_id === robots.deviceId).owner, bob.subject);
+ assert.strictEqual(again.find((o) => o.node_id === quiet.deviceId), undefined);
+ quiet.c.close();
+ robots.c.close();
+ // Reported whole, as Network's resource registry takes it.
+ await offers.publish();
+ assert.deepStrictEqual(reports.at(-1).map((o) => o.offer_id).sort(), again.map((o) => o.offer_id).sort());
+ });
+
+ await check('a machine runs only its owner’s jobs', async () => {
+ const job = (over = {}) => ({ id: `job_${ids.ulid()}`, class: 'code', artifact: { name: 'openvibe-codes', version: '0.3.0' }, args: {}, ttl_ms: 60000, limits: { wall_ms: 30000, cpu_ms: 30000, mem_bytes: 268435456 }, ...over });
+ const project = `prj_${ids.ulid()}`;
+ const send = (subject) => dispatch(t.db, mine.deviceId, job(), { link: t.hub, project, subject });
+ const ok = await send(`user:${alex.subject}`);
+ assert.strictEqual(ok.sent, true);
+ for (const subject of [`user:${bob.subject}`, null, 'service:watch', alex.subject]) {
+ await assert.rejects(send(subject), (e) => e.status === 403 && e.code === 'bot.not_owner', String(subject));
+ }
+ });
+
+ await check('/machines lists them, adds one with a one-time --worker command, and removes one', async () => {
+ const anon = await fetch(`${t.base}/machines`, { redirect: 'manual' });
+ assert.strictEqual(anon.status, 302, 'signed out: to the sign-in');
+ const page = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
+ assert.ok(page.includes('Your machines') && page.includes('desk-pc') && page.includes('Online') && page.includes('code jobs'), page);
+ assert.ok(!page.includes(bob.subject));
+ const added = await form('/machines', alex);
+ assert.strictEqual(added.status, 201);
+ const html = await added.text();
+ const pairing = t.network.pairings.at(-1);
+ assert.strictEqual(pairing.body.ref, `worker:${alex.subject}`);
+ assert.ok(html.includes('--worker') && html.includes('--pairing pair_'), html);
+ assert.strictEqual((await form('/machines', alex, { Origin: 'https://evil.test' })).status, 403, 'no cross-site post');
+ assert.strictEqual((await form(`/machines/${mine.deviceId}/remove`, bob)).status, 404, 'not bob’s');
+ const removed = await form(`/machines/${mine.deviceId}/remove`, alex);
+ assert.strictEqual(removed.status, 303);
+ assert.ok((await t.db.maybe('SELECT revoked_at FROM devices WHERE id = $1', [mine.deviceId])).revoked_at);
+ assert.ok(t.network.revokes.includes(mine.principal));
+ assert.strictEqual((await offers.build()).find((o) => o.node_id === mine.deviceId), undefined, 'a removed machine offers nothing');
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });