openvibers/openvibe.bot Commits
Desk leases for Run's sessions (contracts 0.223.0, bot.desk.session, Desks D6): desk hosts for a person, lease/commands/end of one browser desk per session, shared sandboxed hosts offered by their owner, leased desks kept from the machine's own routes, browser on a desk checked against its desks; contracts v0.223.0 (#208)
Changes
@@@ -43,6 +43,15 @@ Sitemap entries use the release commit time for these code-backed pages.- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe.Node `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe.Node `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.- **The one command on the robot** (`server/claims.js`, migration 0019): `curl -fsSL <BOT_INSTALLER_URL> | sh` with no robot and no code. The installer finds the kit on the machine (OpenVibe.Node `docs/install.md`) and starts a claim, `POST /api/v1/claims` (anyone, counted by address: a code like `KQ7M-2XRD`, a poll secret, 15 minutes). The owner enters the code at `/link` (signed in; the QR opens `/link?code=…`), sees what the machine found (shown escaped, it only pre-selects the model) and approves it with a name, a model the installer has a driver for (or the generic relay), who may drive, and **Show its camera on my OpenVibe.Live channel**, or denies it. Approving makes the robot exactly as Add robot does and keeps its pairing on the claim; the machine reads it with `GET /api/v1/claims/:id` and its `X-OV-Claim-Secret`, then pairs the usual way. Once it has paired (`domain.pairing.onPaired`) the pairing is dropped and the Live mirror is turned on if the owner asked. The approval page links to the owner's channel with `?bind_robot=rob_…`, where Live asks them to let the channel's chat drive it.- **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`).++- **Desks for OpenVibe.Run's sessions** (contracts 0.223.0, `bot.desk.session`, the Desks plan's D6; migration 0026). Run, with a service token naming the session's person in `X-OV-Subject`, leases one named browser desk per session (`bot.desk-lease@1`).++ - `GET /api/v1/desk-hosts` lists where a desk may go for that person:++ - their own machines where "Let my agent use this computer" is on (trust `user-owned`);++ - desk hosts whose owner offers them to other people (`devices.shared_desks`, the "Offer my desks to other people" button on `/machines`), only where the Node sandboxes every desk (`desk.isolation: sandbox`, OpenVibe.Node v0.32.0, `install.sh --desk-host --shared`); these are `community`, or `first-party` for the owners in `BOT_FIRST_PARTY_DESK_OWNERS`.++ - Each host comes with its room: a browser desk counts as half of `max_desks`.++ - `POST /api/v1/machines/:id/desk-leases` starts the desk. Then `POST …/desk-leases/:desk/commands` carries `screen`, `input` and `browser` (`bot.command@1` without `desk`) to that desk alone, and `DELETE …/desk-leases/:desk` stops it.++ - A leased desk is the session's alone: the machine's own `POST /machines/:id/commands` answers it `409 bot.desk_leased`, its owner's included.++ - A full machine answers `409 bot.machine_busy`; anyone else gets 404.++ - Shared hosts are also reported to the Network as `worker:browser` offers (`desk:<id>`).- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe.Node's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-node approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).- **The live screen** (plan T14, contracts 0.182.0, OpenVibe.Node v0.12.0): the machine's owner, signed in as themselves, watches its screen at `/machines/:id/screen` or through `POST|GET|DELETE /api/v1/machines/:id/screen` (`server/machine-screen.js`).- **The stream.** Each start makes or rotates the machine's own OpenRestream stream (external ref `bot:machine:<id>`, playback private, no recording, never mirrored to Live; `devices.screen_stream_id`, migration 0017). Rotating ends any session still open.@@@ -157,6 +166,7 @@ per export and deletion in `account_data_events` (migration 0008).|---|---|| `bot.robot.read` | `GET /robots`, `GET /robots/:id`, operators, devices, the audit and the streaming toggles, for a service acting for an owner || `bot.robot.manage` | create, patch and delete a robot, pairing codes, operators, and the streaming toggles (`POST /robots/:id/streaming`) |++| `bot.desk.session` | OpenVibe.Run's sessions (contracts 0.223.0): `GET /desk-hosts`, then lease, use and end one browser desk per session (`/machines/:id/desk-leases…`) on the person's own machine or a sandboxed desk host shared with other people || `bot.computer.control` | an agent service (OpenVibe.Actor) uses a person's own machine: `GET /machines` (the person's machines whose owner allowed it), `GET /machines/:id/computer`, `POST /machines/:id/commands` (exec, fs, screen) acting as that owner, only where the owner turned "Let my agent use this computer" on || `bot.robot.control` | the e-stop set and clear (clear is owner-only), the control gate and `POST /robots/:id/commands` (so a bound channel's chat can forward a command over HTTP instead of a hardware socket) || `bot.device.connect` | rotate and revoke a device credential |@@@ -216,7 +226,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras BotOpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.<!-- versions:start -->--- openvibe-contracts: v0.222.0++- openvibe-contracts: v0.223.0- openvibe-sdk: v0.67.0- openvibe-shared: v4.19.0<!-- versions:end -->@@@ -5,11 +5,11 @@"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.222.0",++ "contracts": "openvibe-contracts v0.223.0","sdk": "openvibe-sdk v0.67.0","shared": "openvibe-shared v4.19.0","packages": [-- "openvibe-contracts v0.222.0",++ "openvibe-contracts v0.223.0","openvibe-sdk v0.67.0","openvibe-shared v4.19.0"],@@@ -19,6 +19,7 @@"bot.robot.control","bot.computer.control","bot.machine.ai",++ "bot.desk.session","bot.device.connect","bot.job.dispatch","bot.resource.read",nnew file mode 100644@@@ -0,0 +1,20 @@++-- phase: expand++-- Run sessions (contracts 0.223.0, the Desks plan's D6). A machine's owner may offer its desks to other people++-- (shared_desks, off until they turn it on on openvibe.bot/machines; only where its OpenVibe Node runs every desk in its++-- own sandbox, desk_isolation sandbox, Node v0.32.0). OpenVibe.Run leases one named desk per session there, or on the++-- person's own machine (bot.desk-lease@1): only that session's commands reach the desk, and the lease ends with it.++ALTER TABLE devices ADD COLUMN IF NOT EXISTS shared_desks boolean NOT NULL DEFAULT false;++++CREATE TABLE IF NOT EXISTS desk_leases (++ device_id text NOT NULL REFERENCES devices(id) ON DELETE CASCADE,++ desk text NOT NULL CHECK (desk ~ '^desk-[a-z0-9][a-z0-9-]{0,23}$'),++ session_id text NOT NULL, -- ses_<ULID>, the OpenVibe.Run session++ owner_subject text NOT NULL, -- user:usr_…, the only one whose commands reach the desk++ kind text NOT NULL DEFAULT 'browser' CHECK (kind IN ('browser')),++ width integer NOT NULL,++ height integer NOT NULL,++ shared boolean NOT NULL, -- a desk host serving other people (true) or the owner's machine++ created_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (device_id, desk)++);++CREATE UNIQUE INDEX IF NOT EXISTS desk_leases_session_idx ON desk_leases (session_id);@@@ -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.222.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.223.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0","pg": "^8.23.0",@@@ -845,9 +845,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.222.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",-- "integrity": "sha512-wdUQCilq9glRMwfxEswbIj3l0QQqKDXZAi2+CR5Mlh34rZjI1Q+7GlAoR0UtNwZAgtLwXhPxFyJ/aPk8lcDG1Q==",++ "version": "0.223.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.223.0",++ "integrity": "sha512-FWcPL1951IeYcelp3dUiDRJEiFkTbsLMLth9RVUFfuLTuHGQKoQXA5HIu6H2e2BupPTyBH+AZD00LqmA6jDmdg==","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.222.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.223.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0","pg": "^8.23.0",@@@ -216,7 +216,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.robot-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }/* A machine's computer plugin (Node docs/computer.md): its level, what waits on it, and the agent switch, full width. */.machine-computer { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--line); }--.machine-ai { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; flex-basis: 100%; }++.machine-ai, .machine-share { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; flex-basis: 100%; }/* A desk on the owner's Live channel: the second step, under the desk's own buttons. */.desk-live { flex-basis: 100%; }.desk-live > summary { list-style: none; display: inline-flex; }nnew file mode 100644@@@ -0,0 +1,186 @@++'use strict';++/**++ * Desk leases for OpenVibe.Run's sessions (contracts 0.223.0, capability bot.desk.session; the Desks plan's D6). Run,++ * with a service token naming the session's person in X-OV-Subject, leases one named browser desk on a machine:++ *++ * GET /api/v1/desk-hosts where a desk may go for this person: their own machines++ * (where they let agents use them) and desk hosts that serve++ * other people, each with its trust class and its room++ * POST /api/v1/machines/:id/desk-leases bot.desk-lease-create-request@1 → 201 bot.desk-lease@1: the++ * desk is started (kind browser, the lease's size)++ * GET /api/v1/machines/:id/desk-leases/:desk the lease, to its person only++ * POST /api/v1/machines/:id/desk-leases/:desk/commands screen, input or browser on that desk alone (bot.command@1++ * without desk) → bot.command-result@1's fields++ * DELETE /api/v1/machines/:id/desk-leases/:desk the desk is stopped and the lease ends++ *++ * A machine takes a lease when it is the person's own and its owner turned on "Let my agent use this computer", or when++ * its owner offers its desks to other people (shared_desks) and its Node runs every desk in its own sandbox (describe++ * desk.isolation "sandbox", Node v0.32.0). Anything else is 404, never confirmed to exist. A leased desk is the++ * session's alone: the machine's own routes refuse it (api/v1.js), and on a shared host nothing but browser desks runs.++ */++const express = require('express');++const { validate } = require('openvibe-contracts');++const { fail, userSubject } = require('../util');++++const CAPABILITY = 'bot.desk.session';++const RUN = 'svc:run';++const MACHINE_RE = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;++const DESK_RE = /^desk-[a-z0-9][a-z0-9-]{0,23}$/;++const ACTS = new Set(['screen', 'input', 'browser']);++const MAX_DESKS_CAP = 10; // the computer plugin's own cap on max_desks; a browser desk counts as half++++/** The refusal codes a command can come back with, as HTTP statuses (as api/v1.js commandRefusalStatus). */++function refusalStatus(code) {++ if (code === 'bot.forbidden' || code === 'bot.not_an_operator') return 403;++ if (code === 'bot.device_offline' || code === 'bot.command_pending' || code === 'bot.computer_off' || code === 'bot.person_driving') return 409;++ return 422;++}++++function router({ domain, apiAuth, hub, config = {}, limit = (req, res, next) => next() }) {++ const r = express.Router();++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);++ // The owners whose shared desk hosts are OpenVibe's own (first-party); every other shared host is community.++ const firstParty = new Set((config.deskHosts && config.deskHosts.firstPartyOwners) || []);++++ /** Only Run, with bot.desk.session, acting for a person (X-OV-Subject). → that person's subject */++ function runFor(req) {++ if (req.principal.kind === 'anonymous') fail(401, 'bot.sign_in', 'sign in to do that');++ if (req.principal.kind !== 'service' || req.principal.sub !== RUN) fail(403, 'bot.forbidden', 'desk leases are OpenVibe.Run\'s, for its sessions');++ if (!apiAuth.granted(req.principal, CAPABILITY)) fail(403, 'capability.denied', `${CAPABILITY} not granted`);++ const s = req.headers['x-ov-subject'];++ if (!s) fail(422, 'bot.invalid_input', 'a session acts for a person (X-OV-Subject)');++ return userSubject(s, 'subject');++ }++++ const sensors = (deviceId) => {++ const st = hub.deviceState(deviceId) || {};++ return st.telemetry && st.telemetry.sensors && typeof st.telemetry.sensors === 'object' ? st.telemetry.sensors : {};++ };++ const browserDesks = (offered) => !!(offered && offered.desk && Array.isArray(offered.desk.kinds) && offered.desk.kinds.includes('browser') && offered.desk.named === true);++ const sandboxed = (offered) => !!(offered && offered.desk && offered.desk.isolation === 'sandbox');++++ /**++ * How a person may use a machine for a session, or null: { device, shared, trust }. Their own machine where agents++ * may use it; another's only when its owner shares its desks and its Node sandboxes every one.++ */++ async function usable(device, subject) {++ if (!device || device.revoked_at) return null;++ const offered = hub.computerOf(device.id);++ if (!browserDesks(offered)) return null;++ const owner = await domain.devices.ownerOf(device);++ if (owner === subject && device.agent_computer) return { device, shared: false, trust: 'user-owned', offered };++ if (device.shared_desks && sandboxed(offered)) return { device, shared: true, trust: firstParty.has(owner) ? 'first-party' : 'community', offered };++ return null;++ }++ /** Room for one more browser desk: the plugin's max_desks counts a browser desk as half. */++ async function room(device, offered) {++ const max = Math.min(MAX_DESKS_CAP, Number(offered.desk.max_desks) || 1) * 2;++ const running = Number.isInteger(sensors(device.id).computer_desks) ? sensors(device.id).computer_desks : 0;++ const leased = await domain.deskLeases.count(device.id);++ return { max, used: Math.max(running, leased), free: Math.max(0, max - Math.max(running, leased)) };++ }++++ r.get('/desk-hosts', limit, wrap(async (req, res) => {++ const subject = runFor(req);++ const own = await domain.machines.list(subject);++ const shared = await domain.machines.listSharedDesks();++ const seen = new Set();++ const hosts = [];++ for (const d of [...own, ...shared]) {++ if (seen.has(d.id)) continue;++ seen.add(d.id);++ const u = await usable(d, subject);++ if (!u) continue;++ const online = !!(hub.isOnline && hub.isOnline(d.id));++ const rm = await room(d, u.offered);++ hosts.push({ machine_id: d.id, trust: u.trust, shared: u.shared, online, region: null, max_browser_desks: rm.max, used: rm.used, free: online ? rm.free : 0,++ ...(u.shared ? { limits: u.offered.desk.limits || null } : { name: d.name || null }) });++ }++ res.set('Cache-Control', 'no-store').json({ hosts });++ }));++++ async function machineFor(req, subject) {++ const id = String(req.params.id || '');++ if (!MACHINE_RE.test(id)) fail(404, 'bot.device_not_found', 'no such machine for you');++ const u = await usable(await domain.devices.get(id), subject);++ if (!u) fail(404, 'bot.device_not_found', 'no such machine for you');++ return u;++ }++ async function leaseFor(req, subject) {++ const u = await machineFor(req, subject);++ const desk = String(req.params.desk || '');++ const lease = DESK_RE.test(desk) ? await domain.deskLeases.get(u.device.id, desk) : null;++ if (!lease || lease.owner !== `user:${subject}`) fail(404, 'bot.lease_not_found', 'no such desk lease for you');++ return { ...u, lease };++ }++ const principalFor = (req, subject) => ({ ...req.principal, subject });++++ r.post('/machines/:id/desk-leases', limit, express.json({ limit: '8kb' }), wrap(async (req, res) => {++ const subject = runFor(req);++ const body = req.body && typeof req.body === 'object' ? req.body : {};++ const check = validate('bot.desk-lease-create-request@1', body);++ if (!check.valid) fail(422, 'bot.invalid_input', `invalid lease: ${(check.errors && check.errors[0] && check.errors[0].message) || 'bad value'}`.slice(0, 300));++ const u = await machineFor(req, subject);++ const owner = `user:${subject}`;++ const mine = await domain.deskLeases.forSession(body.session);++ if (mine) {++ if (mine.machine_id === u.device.id && mine.desk === body.desk && mine.owner === owner) return res.status(200).json(mine);++ fail(409, 'bot.lease_conflict', 'that session holds another desk');++ }++ if (!(hub.isOnline && hub.isOnline(u.device.id))) fail(409, 'bot.device_offline', 'the machine is offline');++ if ((await room(u.device, u.offered)).free < 1) fail(409, 'bot.machine_busy', 'the machine has no room for another desk right now');++ const width = Number.isInteger(body.width) ? body.width : 1280;++ const height = Number.isInteger(body.height) ? body.height : 800;++ const lease = await domain.deskLeases.create({ deviceId: u.device.id, desk: body.desk, session: body.session, owner, width, height, shared: u.shared });++ if (!lease) fail(409, 'bot.desk_busy', 'that desk is in use');++ // The desk starts now; a machine that refuses it gives the lease back at once.++ let result;++ try {++ result = await hub.machineCommand(u.device.id, principalFor(req, subject), subject, {++ id: `lease-${body.session}`.slice(0, 64), kind: 'desk', ms: 30000,++ value: { action: 'start', desk: body.desk, kind: 'browser', width, height },++ });++ } catch (e) { result = { result: 'refused', code: 'bot.device_offline', reason: e.message }; }++ if (!result || result.result !== 'ack') {++ await domain.deskLeases.remove(u.device.id, body.desk);++ const code = result && result.code ? result.code : result && result.result === 'nack' ? 'bot.desk_failed' : 'bot.device_offline';++ fail(code === 'bot.desk_failed' ? 502 : refusalStatus(code), code, (result && result.reason) || 'the machine did not start the desk');++ }++ res.status(201).json(lease);++ }));++++ r.get('/machines/:id/desk-leases/:desk', limit, wrap(async (req, res) => {++ const subject = runFor(req);++ const { lease } = await leaseFor(req, subject);++ res.set('Cache-Control', 'no-store').json(lease);++ }));++++ r.post('/machines/:id/desk-leases/:desk/commands', limit, express.json({ limit: '64kb' }), wrap(async (req, res) => {++ const subject = runFor(req);++ const { device, lease } = await leaseFor(req, subject);++ const kind = String((req.body && req.body.kind) || '');++ const value = req.body && req.body.value && typeof req.body.value === 'object' && !Array.isArray(req.body.value) ? req.body.value : null;++ if (!ACTS.has(kind) || !value) fail(422, 'bot.invalid_input', 'kind is screen, input or browser, with a value');++ if ('desk' in value) fail(422, 'bot.invalid_input', 'a session acts on its own desk only: leave out desk');++ const result = await hub.machineCommand(device.id, principalFor(req, subject), subject, {++ id: req.body.id, kind, value: { ...value, desk: lease.desk }, ms: req.body.ms,++ });++ res.json({ machine_id: device.id, desk: lease.desk, ...result });++ }));++++ r.delete('/machines/:id/desk-leases/:desk', limit, wrap(async (req, res) => {++ const subject = runFor(req);++ const { device, lease } = await leaseFor(req, subject);++ let stopped = false;++ try {++ const result = await hub.machineCommand(device.id, principalFor(req, subject), subject, { kind: 'desk', value: { action: 'stop', desk: lease.desk }, ms: 20000 });++ stopped = !!(result && result.result === 'ack');++ } catch { /* an offline machine stops its desks when it comes back without the lease (the plugin's idle stop) */ }++ await domain.deskLeases.remove(device.id, lease.desk);++ res.json({ machine_id: device.id, desk: lease.desk, ended: true, stopped });++ }));++++ return r;++}++++module.exports = { router, CAPABILITY, refusalStatus };@@@ -47,6 +47,7 @@ const { getProfile, listProfiles, checkProfile } = require('../profiles');const kits = require('../kits');const resourceIndex = require('./resource-index');const resourceControl = require('./resource-control');++const deskLeases = require('./desk-leases');const CAP = {read: 'bot.robot.read',@@@ -79,6 +80,8 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm// Control first (bot.resource.control), then the index (bot.resource.read) for everything else under /resources.r.use('/resources/control', limits('bot.control', { minute: 120, hour: 1200 }), resourceControl.router({ domain, apiAuth, hub }));r.use('/resources', resourceIndex.router({ db: domain.db, apiAuth, hub, baseUrl: config.baseUrl }));++ // Run's sessions lease one desk each (contracts 0.223.0, bot.desk.session): desk hosts, leases and their commands.++ r.use(deskLeases.router({ domain, apiAuth, hub, config, limit: limits('bot.desk_session', { minute: 600, hour: 6000 }) }));const manage = limits('bot.robot.manage', { minute: 30, hour: 300 });const control = limits('bot.control', { minute: 120, hour: 1200 });const machineScreen = createMachineScreen({ domain, hub });@@@ -431,6 +434,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submconst pick = (cap) => (offered && offered[cap] && typeof offered[cap] === 'object' ? offered[cap] : null);return {id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer, ai_models: !!device.ai_models, watch_probe: !!device.watch_probe,++ shared_desks: !!device.shared_desks,voice: { on: !!device.voice_on, call_word: device.call_word || DEFAULT_CALL_WORD },computer: offered ? {level: typeof sensors.computer_level === 'string' ? sensors.computer_level : ((pick('files') || pick('terminal') || pick('screen') || {}).level || null),@@@ -467,6 +471,10 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm// id answers its first result without data; an `ask`-level machine answers once its owner approves it there.r.post('/machines/:id/commands', commandControl, wrap(async (req, res) => {const { device, subject } = await computerMachine(req, { kind: req.body?.kind });++ // A desk leased to a Run session is the session's alone (desk-leases.js): its machine's own routes never reach it.++ const v = req.body && req.body.value && typeof req.body.value === 'object' ? req.body.value : {};++ const named = req.body?.kind === 'desk' ? (typeof v.desk === 'string' ? v.desk : 'private') : (typeof v.desk === 'string' ? v.desk : 'main');++ if (/^desk-/.test(named) && await domain.deskLeases.get(device.id, named)) fail(409, 'bot.desk_leased', 'that desk is held by an OpenVibe.Run session');const principal = req.principal.kind === 'service' ? { ...req.principal, subject } : req.principal;const result = await hub.machineCommand(device.id, principal, subject, { id: req.body?.id, kind: req.body?.kind, value: req.body?.value, ms: req.body?.ms });if (result.result === 'refused') fail(commandRefusalStatus(result.code), result.code, result.reason || null);@@@ -102,7 +102,7 @@ function createApp(opts = {}) {// 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 });++ const offers = createOfferPublisher({ db, hub, reporter: offerReporter, log, now, firstPartyOwners: (config.deskHosts && config.deskHosts.firstPartyOwners) || [] });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@@@ -192,6 +192,12 @@ function loadConfig(env = process.env) {cooldownMs: int(env.BOT_CONTROL_COOLDOWN_MS, 0),},++ // Desk hosts that serve other people (server/api/desk-leases.js): the owners (user:usr_…) whose shared hosts are++ // OpenVibe's own, so Run places on them as first-party; every other shared host is community.++ deskHosts: {++ firstPartyOwners: list(env.BOT_FIRST_PARTY_DESK_OWNERS).map((s) => s.replace(/^user:/, '')),++ },++// Per-actor limits on /api/v1 reads (server/api/actor-limits.js).actorLimits: {minute: Math.max(1, int(env.BOT_LIMITS_MINUTE, 120)),@@@ -790,6 +790,35 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =async function setAiModels(deviceId, value) {return db.maybe('UPDATE devices SET ai_models = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);}++ /** Offer this machine's desks to other people, or stop (the owner check and the sandbox check are the caller's). */++ async function setSharedDesks(deviceId, value) {++ return db.maybe('UPDATE devices SET shared_desks = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);++ }++ /** Every live machine whose owner offers its desks to other people (Run's placement; the live checks are the caller's). */++ async function listSharedDesks() {++ return db.many('SELECT * FROM devices WHERE revoked_at IS NULL AND shared_desks = true ORDER BY created_at');++ }++++ // ── Desk leases (contracts 0.223.0 bot.desk-lease@1, the Desks plan's D6) ──────────────────────────────────────++ const leaseOut = (r) => (r ? {++ machine_id: r.device_id, desk: r.desk, session: r.session_id, owner: r.owner_subject, kind: r.kind,++ width: Number(r.width), height: Number(r.height), shared: !!r.shared, created_at: new Date(r.created_at).toISOString(),++ } : null);++ const deskLeases = {++ async get(deviceId, desk) { return leaseOut(await db.maybe('SELECT * FROM desk_leases WHERE device_id = $1 AND desk = $2', [deviceId, desk])); },++ async forSession(sessionId) { return leaseOut(await db.maybe('SELECT * FROM desk_leases WHERE session_id = $1', [sessionId])); },++ async count(deviceId) { return Number((await db.one('SELECT count(*)::int AS n FROM desk_leases WHERE device_id = $1', [deviceId])).n); },++ async list(deviceId) { return (await db.many('SELECT * FROM desk_leases WHERE device_id = $1 ORDER BY created_at', [deviceId])).map(leaseOut); },++ /** The lease, or null when the desk is someone else's already (the caller answers 409). */++ async create({ deviceId, desk, session, owner, width, height, shared }) {++ const r = await db.maybe(++ `INSERT INTO desk_leases (device_id, desk, session_id, owner_subject, kind, width, height, shared, created_at)++ VALUES ($1, $2, $3, $4, 'browser', $5, $6, $7, $8) ON CONFLICT DO NOTHING RETURNING *`,++ [deviceId, desk, session, owner, width, height, !!shared, iso(now())]);++ return leaseOut(r);++ },++ async remove(deviceId, desk) { return leaseOut(await db.maybe('DELETE FROM desk_leases WHERE device_id = $1 AND desk = $2 RETURNING *', [deviceId, desk])); },++ };/** Let the owner's agent use this machine's computer plugin, or stop it (the owner check is the caller's). */async function setAgentComputer(deviceId, value) {return db.maybe('UPDATE devices SET agent_computer = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);@@@ -1287,8 +1316,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },-- machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice,++ machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice, setSharedDesks, listSharedDesks,screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },++ deskLeases,streaming: { get: streaming, set: setStreaming },video: { live: liveVideo },estop: { set: setEstop, clear: clearEstop },@@@ -18,7 +18,8 @@ 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 }) {++function createOfferPublisher({ db, hub, reporter = null, log = console, now = () => Date.now(), intervalMs = 60_000, settleMs = 2_000, firstPartyOwners = [] }) {++ const firstParty = new Set(firstPartyOwners);let timer = null;let soon = null;let running = null;@@@ -53,6 +54,29 @@ function createOfferPublisher({ db, hub, reporter = null, log = console, now = (}offers.push(offer);}++ // Desk hosts that serve other people (contracts 0.223.0, Desks plan D6): a browser desk per session for anyone,++ // each in its own sandbox there, placed by OpenVibe.Run and leased through Bot (api/desk-leases.js). Community,++ // or first-party for the owners configured as OpenVibe's own (BOT_FIRST_PARTY_DESK_OWNERS).++ const shared = 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.shared_desks = true ORDER BY d.id`);++ for (const row of shared) {++ const desk = ((json(row.capabilities, {}).computer || {}).desk) || {};++ if (desk.isolation !== 'sandbox' || !Array.isArray(desk.kinds) || !desk.kinds.includes('browser')) continue;++ const owner = row.owner_subject || row.robot_owner;++ const offer = {++ offer_id: `desk:${row.id}`, kind: 'node', node_id: row.id, provider: 'self', region: 'home',++ trust: firstParty.has(owner) ? 'first-party' : 'community',++ capabilities: ['worker:browser'],++ health: { status: hub && hub.isOnline(row.id) ? 'up' : 'down', checked_at: at },++ pricing: { model: 'per-second', marginal_usd_per_unit: 0, unit: 'desk-second' },++ updated_at: at,++ };++ const v = validate(CONTRACT, offer);++ if (!v.valid) { log.warn(`[Bot] offers: desk host ${row.id} does not match ${CONTRACT}, left out`); continue; }++ offers.push(offer);++ }return offers;}@@@ -56,6 +56,9 @@ const deskOf = (value) => (typeof value.desk === 'string' && value.desk ? value.// The screen and the mouse and keyboard of a desk are the desk's own (Node's desk.screen, desk.input): a desk host with// no screen of its own (no one signed in, Node v0.20.0 --desk-host) offers them on its desks only.const onDeskOnly = (kind, value) => (kind === 'screen' || kind === 'input') && deskOf(value) !== 'main';++// A browser command on a desk drives that desk's own browser: any machine with desks takes it, whether or not it offers a++// browser of its own (a desk host for other people offers none, Node v0.32.0; its core routes it to the desks, v0.32.1).++const onDeskBrowser = (kind, value, offered) => kind === 'browser' && deskOf(value) !== 'main' && !!(offered && offered.desk);/** A command that acts on a screen (the mouse and keyboard, or the browser doing more than reading it). */const actsOnScreen = (kind, value) => kind === 'input' || (kind === 'browser' && !['read', 'close'].includes(value.action));const COMPUTER_KINDS = Object.keys(COMPUTER_CAP);@@@ -777,7 +780,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datif (value.action === 'start' && deskOf(value) === 'main' && !(offered && offered.screen && offered.screen.stream === true)) {return refuse('bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your graphical session', summary);}-- } else if (!offered || !(onDeskOnly(kind, value) ? (offered.desk && offered.desk[kind] === true) : offered[COMPUTER_CAP[kind]])) {++ } else if (!offered || !(onDeskBrowser(kind, value, offered) || (onDeskOnly(kind, value) ? (offered.desk && offered.desk[kind] === true) : offered[COMPUTER_CAP[kind]]))) {return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : kind === 'ai' ? 'this machine offers no models: it needs OpenVibe Node v0.27.0 or newer with the ai plugin (install.sh --ai) and a model server (Ollama, LM Studio or llama.cpp)' : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);}// A desk other than the person's own screen needs a machine that has desks; one other than private (a named desk,@@@ -729,7 +729,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {return { on: true, level: LEVELS[level] ? level : null, terminal: !!c.terminal, files: !!c.files, screen: !!c.screen, probe: !!c.probe, input: !!c.input,screenStream: !!(c.screen && c.screen.stream === true),// Desks for its agents (Desks plan): how many at once, which kinds, and whether sign-ins carry over.-- desks: d ? { max: Number.isInteger(d.max_desks) ? d.max_desks : 1, kinds: Array.isArray(d.kinds) ? d.kinds : ['desktop'], keeps: d.profiles === true } : null,++ desks: d ? { max: Number.isInteger(d.max_desks) ? d.max_desks : 1, kinds: Array.isArray(d.kinds) ? d.kinds : ['desktop'], keeps: d.profiles === true,++ // Each desk in its own sandbox (Node v0.32.0, --desk-host --shared): it may serve other people's sessions.++ sandboxed: d.isolation === 'sandbox' } : null,ai };};// Vibe by voice: shown for a machine whose Node runs the voice plugin (capabilities.voice).@@@ -752,6 +754,16 @@ function renderMachinesPage({ machines = [], pairing = null }) {};// The person's machines whose models their AI may use: with two or more, Chat's link chooses any of them.const modelMachines = machines.filter((x) => { const c = x.ai_models && computerOf(x); return !!(c && c.ai && c.ai.models.length); }).length;++ // A desk host that sandboxes every desk (Desks plan D6): its owner may offer its desks to other people's sessions on++ // OpenVibe.Run, and sees how many are in use. Nothing of the machine itself is reachable from a desk there.++ const shareRow = (m, c) => {++ if (!c.desks || !c.desks.sandboxed) return '';++ const inUse = Number.isInteger(m.desk_leases) && m.desk_leases > 0 ? ` <span class="pill">${m.desk_leases === 1 ? '1 desk in use' : `${m.desk_leases} desks in use`}</span>` : '';++ return `<div class="machine-share"><span class="pill">${m.shared_desks ? 'Serving other people' : 'Desks: yours only'}</span>${inUse}`++ + '<span class="muted">Each desk runs in its own sandbox here (its own memory, CPU and network, the public internet only): nobody reaches this machine\'s files, screen or network from one.</span>'++ + `<form method="post" action="/machines/${esc(m.id)}/shared-desks"><input type="hidden" name="on" value="${m.shared_desks ? '0' : '1'}">`++ + `<button type="submit" class="button quiet" aria-pressed="${m.shared_desks ? 'true' : 'false'}">${m.shared_desks ? 'Stop offering my desks' : 'Offer my desks to other people'}</button></form></div>`;++ };const computerRow = (m) => {const c = computerOf(m);if (!c) return '';@@@ -778,6 +790,7 @@ function renderMachinesPage({ machines = [], pairing = null }) {return `<div class="machine-computer"><span class="pill">Computer: ${esc(c.level ? LEVELS[c.level] : 'on')}</span><span class="muted">${esc(can)}</span>${desks}${running}${waiting}${deskLink}`+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`++ + shareRow(m, c)+ (c.screenStream && m.online ? `<form method="post" action="/machines/${esc(m.id)}/screen"><button type="submit" class="button quiet">${m.computer_screen_stream === 'live' ? 'Watch my screen' : 'Show my screen'}</button></form>` : '')+ aiRow+ (c.probe ? `<form method="post" action="/machines/${esc(m.id)}/watch-probe"><input type="hidden" name="on" value="${m.watch_probe ? '0' : '1'}">`@@@ -580,6 +580,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limicomputer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,computer_screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',computer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,++ desk_leases: d.shared_desks ? await domain.deskLeases.count(d.id) : 0,ai_models: !!d.ai_models,// How fast its last model answered there (Node v0.29.0's ai plugin), and whether one is answering now.ai_speed: typeof sensors.ai_tokens_per_s === 'number' && sensors.ai_tokens_per_s > 0 && typeof sensors.ai_model === 'string'@@@ -796,6 +797,21 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiawait domain.machines.setAgentComputer(mine.id, req.body && req.body.on === '1');res.redirect(303, '/machines');}));++ // The owner offers this desk host's desks to other people's sessions (OpenVibe.Run, contracts 0.223.0), or stops: only++ // where its Node sandboxes every desk (Node v0.32.0, install.sh --desk-host --shared).++ r.post('/machines/:id/shared-desks', ...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');++ const on = req.body && req.body.on === '1';++ const desk = ((hub && hub.computerOf(mine.id)) || {}).desk;++ if (on && !(desk && desk.isolation === 'sandbox')) {++ throw new BotError(409, 'bot.computer_off', 'this machine does not sandbox its desks: install it with --desk-host --shared (OpenVibe Node v0.32.0 or newer), then try again');++ }++ await domain.machines.setSharedDesks(mine.id, on);++ res.redirect(303, '/machines');++ }));// The owner lets Vibe answer their voice on this machine (OpenVibe Node's voice plugin), with a call word, or stops it.r.post('/machines/:id/voice', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {const me = requireUser(req);nnew file mode 100644@@@ -0,0 +1,161 @@++'use strict';++// Desk leases for OpenVibe.Run's sessions (contracts 0.223.0, bot.desk.session; the Desks plan's D6). Only Run, with the++// capability, acting for a person: GET /api/v1/desk-hosts lists that person's own machines (agents allowed) and desk++// hosts whose owner offers them to other people and whose Node sandboxes every desk; a lease starts a browser desk,++// takes commands for that desk alone and ends with DELETE; the machine's own routes never reach a leased desk; a full++// machine is busy; anyone else gets 404.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++++(async () => {++ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0' } });++ const alex = t.network.newUser('alex'); // runs a desk host for other people++ const bob = t.network.newUser('bob'); // uses it, and has a machine of his own++ const carol = t.network.newUser('carol');++ let seq = 0;++ const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });++ const poll = async (fn, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(30); } return null; };++ const seen = [];++ const DESK = (isolation, max = 1) => ({ level: 'trusted', window_manager: null, input: true, screen: true, named: true, max_desks: max, kinds: ['browser'], profiles: false, ...(isolation ? { isolation } : {}) });++++ /** A machine of `owner` whose fake Node answers desk and browser commands like the computer plugin would. */++ async function machine(owner, computer) {++ const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desks' });++ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });++ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;++ const config = await c.waitFor((m) => m.type === 'config');++ c.send(frame('status', { capabilities: { computer }, drivers: [], faults: [] }));++ c.send(frame('telemetry', { sensors: { computer_level: 'trusted', computer_desks: 0 } }));++ c.ws.on('message', (raw) => {++ let m; try { m = JSON.parse(raw.toString()); } catch { return; }++ if (m.type !== 'command') return;++ seen.push({ deviceId, ...m });++ if (m.kind === 'desk') c.send(frame('ack', { id: m.id, data: { running: m.value.action !== 'stop', kind: 'browser', width: 1280, height: 800 } }));++ else if (m.kind === 'browser') c.send(frame('ack', { id: m.id, data: { url: m.value.url || 'https://example.org/', title: 'Example', text: 'Example page', truncated: false, links: [] } }));++ else if (m.kind === 'screen') c.send(frame('ack', { id: m.id, data: { format: 'jpeg', width: 1280, height: 800, content_b64: '/9j/' } }));++ else c.send(frame('nack', { id: m.id, fault_code: 'not_allowed', message: 'this machine is a desk host for other people' }));++ });++ const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, Math.max(100, Math.floor(config.heartbeat_ms / 2)));++ c.ws.on('close', () => clearInterval(beat));++ await poll(() => t.hub.computerOf(deviceId) !== null);++ return { c, deviceId };++ }++ const run = (cap = ['bot.desk.session']) => t.network.signService({ sub: 'svc:run', cap });++ const asRun = (subject, token = run()) => ({ token, headers: { 'X-OV-Subject': subject } });++ const SES = (n) => `ses_01JD2C3D4E5F6G7H8J9K0MNP${String(n).padStart(2, '0')}`;++++ const host = await machine(alex, { desk: DESK('sandbox', 1) }); // two browser desks at once++ const plain = await machine(carol, { desk: DESK(null, 2) }); // desks, not sandboxed: never shared++ const own = await machine(bob, { desk: DESK(null, 2) });++++ await check('only Run, with bot.desk.session, acting for a person', async () => {++ assert.strictEqual((await t.call('GET', '/api/v1/desk-hosts', { user: bob })).status, 403, 'not a person\'s own call');++ assert.strictEqual((await t.call('GET', '/api/v1/desk-hosts', asRun(bob.subject, t.network.signService({ sub: 'svc:actor', cap: ['bot.desk.session'] })))).status, 403, 'Run only');++ assert.strictEqual((await t.call('GET', '/api/v1/desk-hosts', asRun(bob.subject, run(['bot.computer.control'])))).status, 403, 'needs bot.desk.session');++ assert.strictEqual((await t.call('GET', '/api/v1/desk-hosts', { token: run() })).status, 422, 'for a person');++ });++++ await check('desk hosts: the person\'s own machines where agents may work, and shared sandboxed hosts', async () => {++ let hosts = (await t.call('GET', '/api/v1/desk-hosts', asRun(bob.subject))).json.hosts;++ assert.deepStrictEqual(hosts, [], 'nothing before agents are allowed and nothing is shared');++ await t.domain.machines.setAgentComputer(own.deviceId, true);++ await t.domain.machines.setSharedDesks(host.deviceId, true);++ await t.domain.machines.setSharedDesks(plain.deviceId, true); // set behind the web check: still never offered++ hosts = (await t.call('GET', '/api/v1/desk-hosts', asRun(bob.subject))).json.hosts;++ const by = Object.fromEntries(hosts.map((h) => [h.machine_id, h]));++ assert.deepStrictEqual(Object.keys(by).sort(), [host.deviceId, own.deviceId].sort(), 'never a host that does not sandbox its desks');++ assert.deepStrictEqual([by[own.deviceId].trust, by[own.deviceId].shared, by[own.deviceId].free], ['user-owned', false, 4]);++ assert.deepStrictEqual([by[host.deviceId].trust, by[host.deviceId].shared, by[host.deviceId].free, by[host.deviceId].online], ['community', true, 2, true]);++ assert.ok(!('name' in by[host.deviceId]), 'another person\'s machine name is not shown');++ const carols = (await t.call('GET', '/api/v1/desk-hosts', asRun(carol.subject))).json.hosts.map((h) => h.machine_id);++ assert.deepStrictEqual(carols, [host.deviceId], 'carol sees the shared host, not bob\'s own machine');++ });++++ let lease;++ await check('a lease starts a browser desk on the shared host, once per session', async () => {++ const body = { session: SES(1), desk: 'desk-s01', kind: 'browser', width: 1024, height: 700 };++ const r = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body });++ assert.strictEqual(r.status, 201, r.text);++ lease = r.json;++ assert.deepStrictEqual([lease.machine_id, lease.desk, lease.session, lease.owner, lease.shared, lease.width], [host.deviceId, 'desk-s01', SES(1), `user:${bob.subject}`, true, 1024]);++ const start = seen.filter((m) => m.deviceId === host.deviceId && m.kind === 'desk').at(-1);++ assert.deepStrictEqual(start.value, { action: 'start', desk: 'desk-s01', kind: 'browser', width: 1024, height: 700 });++ assert.strictEqual((await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body })).status, 200, 'a repeat answers the lease');++ const other = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(carol.subject), body: { ...body, session: SES(2) } });++ assert.deepStrictEqual([other.status, other.json.code], [409, 'bot.desk_busy']);++ const bad = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { ...body, session: SES(3), desk: 'main' } });++ assert.strictEqual(bad.status, 422, 'never main or private');++ const notShared = await t.call('POST', `/api/v1/machines/${plain.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { ...body, session: SES(4) } });++ assert.strictEqual(notShared.status, 404, 'a host that does not sandbox its desks is never leased to someone else');++ });++++ await check('commands reach the leased desk alone, for its person alone', async () => {++ const url = `/api/v1/machines/${host.deviceId}/desk-leases/desk-s01/commands`;++ const r = await t.call('POST', url, { ...asRun(bob.subject), body: { id: 'c1', kind: 'browser', value: { action: 'open', url: 'https://example.org/' } } });++ assert.deepStrictEqual([r.status, r.json.result, r.json.desk], [200, 'ack', 'desk-s01'], r.text);++ assert.strictEqual(seen.at(-1).value.desk, 'desk-s01', 'Run never names the desk: Bot does');++ assert.strictEqual((await t.call('POST', url, { ...asRun(bob.subject), body: { kind: 'browser', value: { action: 'read', desk: 'desk-other' } } })).status, 422, 'a value naming a desk');++ assert.strictEqual((await t.call('POST', url, { ...asRun(bob.subject), body: { kind: 'exec', value: { argv: ['id'] } } })).status, 422, 'never the machine itself');++ assert.strictEqual((await t.call('POST', url, { ...asRun(carol.subject), body: { kind: 'screen', value: {} } })).status, 404, 'another person');++ assert.strictEqual((await t.call('GET', `/api/v1/machines/${host.deviceId}/desk-leases/desk-s01`, asRun(carol.subject))).status, 404);++ // The host's owner, with their own machine's routes, never reaches the session's desk.++ await t.domain.machines.setAgentComputer(host.deviceId, true);++ const owner = await t.call('POST', `/api/v1/machines/${host.deviceId}/commands`, { user: alex, body: { kind: 'screen', value: { desk: 'desk-s01' } } });++ assert.deepStrictEqual([owner.status, owner.json.code], [409, 'bot.desk_leased']);++ });++++ await check('a full machine is busy; the end stops the desk and frees the room', async () => {++ const second = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(carol.subject), body: { session: SES(5), desk: 'desk-s05', kind: 'browser' } });++ assert.strictEqual(second.status, 201, second.text);++ const third = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { session: SES(6), desk: 'desk-s06', kind: 'browser' } });++ assert.deepStrictEqual([third.status, third.json.code], [409, 'bot.machine_busy'], 'max_desks 1: two browser desks');++ const end = await t.call('DELETE', `/api/v1/machines/${host.deviceId}/desk-leases/desk-s01`, asRun(bob.subject));++ assert.deepStrictEqual([end.status, end.json.ended, end.json.stopped], [200, true, true]);++ assert.deepStrictEqual(seen.filter((m) => m.kind === 'desk').at(-1).value, { action: 'stop', desk: 'desk-s01' });++ assert.strictEqual((await t.call('GET', `/api/v1/machines/${host.deviceId}/desk-leases/desk-s01`, asRun(bob.subject))).status, 404, 'gone');++ const again = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { session: SES(6), desk: 'desk-s06', kind: 'browser' } });++ assert.strictEqual(again.status, 201, 'room again');++ });++++ await check('a shared host is offered to the Network as a browser worker, community unless it is OpenVibe\'s', async () => {++ const offers = await t.app.locals.offers.build();++ const desk = offers.find((o) => o.offer_id === `desk:${host.deviceId}`);++ assert.ok(desk, 'the shared host is offered');++ assert.deepStrictEqual([desk.trust, desk.capabilities, desk.health.status], ['community', ['worker:browser'], 'up']);++ assert.ok(!offers.some((o) => o.offer_id === `desk:${plain.deviceId}`), 'never a host that does not sandbox its desks');++ assert.ok(!offers.some((o) => o.offer_id === `desk:${own.deviceId}`), 'a person\'s own machine is not offered to others');++ });++++ await check('the machines page: a sandboxed host offers its desks with one button; another cannot', async () => {++ const cookie = { Cookie: `ov_token=${t.network.signUser(alex)}` };++ const form = (id, on) => fetch(`${t.base}/machines/${id}/shared-desks`, { method: 'POST', redirect: 'manual', body: `on=${on}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: t.base, ...cookie } });++ let html = '';++ let tries = 0;++ while (tries < 20 && !html.includes('Serving other people')) {++ if (tries) await t.wait(100);++ html = await (await fetch(`${t.base}/machines`, { headers: cookie })).text();++ tries += 1;++ }++ assert.ok(html.includes('Serving other people') && html.includes('desks in use'), 'the host says it serves other people, and how many desks are in use');++ assert.ok(html.includes('Stop offering my desks'));++ assert.strictEqual((await form(host.deviceId, 0)).status, 303);++ assert.strictEqual((await t.db.maybe('SELECT shared_desks FROM devices WHERE id = $1', [host.deviceId])).shared_desks, false);++ assert.strictEqual((await form(host.deviceId, 1)).status, 303);++ assert.strictEqual((await t.db.maybe('SELECT shared_desks FROM devices WHERE id = $1', [host.deviceId])).shared_desks, true);++ const carolsCookie = { Cookie: `ov_token=${t.network.signUser(carol)}` };++ const notSandboxed = await fetch(`${t.base}/machines/${plain.deviceId}/shared-desks`, { method: 'POST', redirect: 'manual', body: 'on=1', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: t.base, ...carolsCookie } });++ assert.strictEqual(notSandboxed.status, 409, 'a host that does not sandbox its desks is never offered');++ });++++ await check('the owner stops offering: no new lease there', async () => {++ await t.domain.machines.setSharedDesks(host.deviceId, false);++ const r = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { session: SES(7), desk: 'desk-s07', kind: 'browser' } });++ assert.strictEqual(r.status, 404);++ const own2 = await t.call('POST', `/api/v1/machines/${own.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { session: SES(8), desk: 'desk-s08', kind: 'browser' } });++ assert.deepStrictEqual([own2.status, own2.json.shared], [201, false], 'a person\'s own machine, where agents may work');++ });++++ for (const m of [host, plain, own]) m.c.close();++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -43,6 +43,15 @@ Sitemap entries use the release commit time for these code-backed pages.
@@ -43,6 +43,15 @@ Sitemap entries use the release commit time for these code-backed pages.
- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe.Node `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe.Node `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe.Node `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe.Node `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
- **The one command on the robot** (`server/claims.js`, migration 0019): `curl -fsSL <BOT_INSTALLER_URL> | sh` with no robot and no code. The installer finds the kit on the machine (OpenVibe.Node `docs/install.md`) and starts a claim, `POST /api/v1/claims` (anyone, counted by address: a code like `KQ7M-2XRD`, a poll secret, 15 minutes). The owner enters the code at `/link` (signed in; the QR opens `/link?code=…`), sees what the machine found (shown escaped, it only pre-selects the model) and approves it with a name, a model the installer has a driver for (or the generic relay), who may drive, and **Show its camera on my OpenVibe.Live channel**, or denies it. Approving makes the robot exactly as Add robot does and keeps its pairing on the claim; the machine reads it with `GET /api/v1/claims/:id` and its `X-OV-Claim-Secret`, then pairs the usual way. Once it has paired (`domain.pairing.onPaired`) the pairing is dropped and the Live mirror is turned on if the owner asked. The approval page links to the owner's channel with `?bind_robot=rob_…`, where Live asks them to let the channel's chat drive it.
- **The one command on the robot** (`server/claims.js`, migration 0019): `curl -fsSL <BOT_INSTALLER_URL> | sh` with no robot and no code. The installer finds the kit on the machine (OpenVibe.Node `docs/install.md`) and starts a claim, `POST /api/v1/claims` (anyone, counted by address: a code like `KQ7M-2XRD`, a poll secret, 15 minutes). The owner enters the code at `/link` (signed in; the QR opens `/link?code=…`), sees what the machine found (shown escaped, it only pre-selects the model) and approves it with a name, a model the installer has a driver for (or the generic relay), who may drive, and **Show its camera on my OpenVibe.Live channel**, or denies it. Approving makes the robot exactly as Add robot does and keeps its pairing on the claim; the machine reads it with `GET /api/v1/claims/:id` and its `X-OV-Claim-Secret`, then pairs the usual way. Once it has paired (`domain.pairing.onPaired`) the pairing is dropped and the Live mirror is turned on if the owner asked. The approval page links to the owner's channel with `?bind_robot=rob_…`, where Live asks them to let the channel's chat drive it.
- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.bot/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).
- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.bot/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).
+- **Desks for OpenVibe.Run's sessions** (contracts 0.223.0, `bot.desk.session`, the Desks plan's D6; migration 0026). Run, with a service token naming the session's person in `X-OV-Subject`, leases one named browser desk per session (`bot.desk-lease@1`).
+ - `GET /api/v1/desk-hosts` lists where a desk may go for that person:
+ - their own machines where "Let my agent use this computer" is on (trust `user-owned`);
+ - desk hosts whose owner offers them to other people (`devices.shared_desks`, the "Offer my desks to other people" button on `/machines`), only where the Node sandboxes every desk (`desk.isolation: sandbox`, OpenVibe.Node v0.32.0, `install.sh --desk-host --shared`); these are `community`, or `first-party` for the owners in `BOT_FIRST_PARTY_DESK_OWNERS`.
+ - Each host comes with its room: a browser desk counts as half of `max_desks`.
+ - `POST /api/v1/machines/:id/desk-leases` starts the desk. Then `POST …/desk-leases/:desk/commands` carries `screen`, `input` and `browser` (`bot.command@1` without `desk`) to that desk alone, and `DELETE …/desk-leases/:desk` stops it.
+ - A leased desk is the session's alone: the machine's own `POST /machines/:id/commands` answers it `409 bot.desk_leased`, its owner's included.
+ - A full machine answers `409 bot.machine_busy`; anyone else gets 404.
+ - Shared hosts are also reported to the Network as `worker:browser` offers (`desk:<id>`).
- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe.Node's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-node approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).
- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe.Node's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-node approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).
- **The live screen** (plan T14, contracts 0.182.0, OpenVibe.Node v0.12.0): the machine's owner, signed in as themselves, watches its screen at `/machines/:id/screen` or through `POST|GET|DELETE /api/v1/machines/:id/screen` (`server/machine-screen.js`).
- **The live screen** (plan T14, contracts 0.182.0, OpenVibe.Node v0.12.0): the machine's owner, signed in as themselves, watches its screen at `/machines/:id/screen` or through `POST|GET|DELETE /api/v1/machines/:id/screen` (`server/machine-screen.js`).
- **The stream.** Each start makes or rotates the machine's own OpenRestream stream (external ref `bot:machine:<id>`, playback private, no recording, never mirrored to Live; `devices.screen_stream_id`, migration 0017). Rotating ends any session still open.
- **The stream.** Each start makes or rotates the machine's own OpenRestream stream (external ref `bot:machine:<id>`, playback private, no recording, never mirrored to Live; `devices.screen_stream_id`, migration 0017). Rotating ends any session still open.
@@ -157,6 +166,7 @@ per export and deletion in `account_data_events` (migration 0008).
@@ -157,6 +166,7 @@ per export and deletion in `account_data_events` (migration 0008).
|---|---|
|---|---|
| `bot.robot.read` | `GET /robots`, `GET /robots/:id`, operators, devices, the audit and the streaming toggles, for a service acting for an owner |
| `bot.robot.read` | `GET /robots`, `GET /robots/:id`, operators, devices, the audit and the streaming toggles, for a service acting for an owner |
| `bot.robot.manage` | create, patch and delete a robot, pairing codes, operators, and the streaming toggles (`POST /robots/:id/streaming`) |
| `bot.robot.manage` | create, patch and delete a robot, pairing codes, operators, and the streaming toggles (`POST /robots/:id/streaming`) |
+| `bot.desk.session` | OpenVibe.Run's sessions (contracts 0.223.0): `GET /desk-hosts`, then lease, use and end one browser desk per session (`/machines/:id/desk-leases…`) on the person's own machine or a sandboxed desk host shared with other people |
| `bot.computer.control` | an agent service (OpenVibe.Actor) uses a person's own machine: `GET /machines` (the person's machines whose owner allowed it), `GET /machines/:id/computer`, `POST /machines/:id/commands` (exec, fs, screen) acting as that owner, only where the owner turned "Let my agent use this computer" on |
| `bot.computer.control` | an agent service (OpenVibe.Actor) uses a person's own machine: `GET /machines` (the person's machines whose owner allowed it), `GET /machines/:id/computer`, `POST /machines/:id/commands` (exec, fs, screen) acting as that owner, only where the owner turned "Let my agent use this computer" on |
| `bot.robot.control` | the e-stop set and clear (clear is owner-only), the control gate and `POST /robots/:id/commands` (so a bound channel's chat can forward a command over HTTP instead of a hardware socket) |
| `bot.robot.control` | the e-stop set and clear (clear is owner-only), the control gate and `POST /robots/:id/commands` (so a bound channel's chat can forward a command over HTTP instead of a hardware socket) |
| `bot.device.connect` | rotate and revoke a device credential |
| `bot.device.connect` | rotate and revoke a device credential |
@@ -216,7 +226,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
@@ -216,7 +226,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.
OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.222.0
+- openvibe-contracts: v0.223.0
- openvibe-sdk: v0.67.0
- openvibe-sdk: v0.67.0
- openvibe-shared: v4.19.0
- openvibe-shared: v4.19.0
<!-- versions:end -->
<!-- versions:end -->
@@ -5,11 +5,11 @@
@@ -5,11 +5,11 @@
"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.222.0",
+ "contracts": "openvibe-contracts v0.223.0",
"sdk": "openvibe-sdk v0.67.0",
"sdk": "openvibe-sdk v0.67.0",
"shared": "openvibe-shared v4.19.0",
"shared": "openvibe-shared v4.19.0",
"packages": [
"packages": [
- "openvibe-contracts v0.222.0",
+ "openvibe-contracts v0.223.0",
"openvibe-sdk v0.67.0",
"openvibe-sdk v0.67.0",
"openvibe-shared v4.19.0"
"openvibe-shared v4.19.0"
],
],
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
"bot.robot.control",
"bot.robot.control",
"bot.computer.control",
"bot.computer.control",
"bot.machine.ai",
"bot.machine.ai",
+ "bot.desk.session",
"bot.device.connect",
"bot.device.connect",
"bot.job.dispatch",
"bot.job.dispatch",
"bot.resource.read",
"bot.resource.read",
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+-- phase: expand
+-- Run sessions (contracts 0.223.0, the Desks plan's D6). A machine's owner may offer its desks to other people
+-- (shared_desks, off until they turn it on on openvibe.bot/machines; only where its OpenVibe Node runs every desk in its
+-- own sandbox, desk_isolation sandbox, Node v0.32.0). OpenVibe.Run leases one named desk per session there, or on the
+-- person's own machine (bot.desk-lease@1): only that session's commands reach the desk, and the lease ends with it.
+ALTER TABLE devices ADD COLUMN IF NOT EXISTS shared_desks boolean NOT NULL DEFAULT false;
+
+CREATE TABLE IF NOT EXISTS desk_leases (
+ device_id text NOT NULL REFERENCES devices(id) ON DELETE CASCADE,
+ desk text NOT NULL CHECK (desk ~ '^desk-[a-z0-9][a-z0-9-]{0,23}$'),
+ session_id text NOT NULL, -- ses_<ULID>, the OpenVibe.Run session
+ owner_subject text NOT NULL, -- user:usr_…, the only one whose commands reach the desk
+ kind text NOT NULL DEFAULT 'browser' CHECK (kind IN ('browser')),
+ width integer NOT NULL,
+ height integer NOT NULL,
+ shared boolean NOT NULL, -- a desk host serving other people (true) or the owner's machine
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (device_id, desk)
+);
+CREATE UNIQUE INDEX IF NOT EXISTS desk_leases_session_idx ON desk_leases (session_id);
@@ -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.222.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.223.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",
"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.222.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
- "integrity": "sha512-wdUQCilq9glRMwfxEswbIj3l0QQqKDXZAi2+CR5Mlh34rZjI1Q+7GlAoR0UtNwZAgtLwXhPxFyJ/aPk8lcDG1Q==",
+ "version": "0.223.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.223.0",
+ "integrity": "sha512-FWcPL1951IeYcelp3dUiDRJEiFkTbsLMLth9RVUFfuLTuHGQKoQXA5HIu6H2e2BupPTyBH+AZD00LqmA6jDmdg==",
"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.222.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.223.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -216,7 +216,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -216,7 +216,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.robot-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.robot-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
/* A machine's computer plugin (Node docs/computer.md): its level, what waits on it, and the agent switch, full width. */
/* A machine's computer plugin (Node docs/computer.md): its level, what waits on it, and the agent switch, full width. */
.machine-computer { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--line); }
.machine-computer { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--line); }
-.machine-ai { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; flex-basis: 100%; }
+.machine-ai, .machine-share { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; flex-basis: 100%; }
/* A desk on the owner's Live channel: the second step, under the desk's own buttons. */
/* A desk on the owner's Live channel: the second step, under the desk's own buttons. */
.desk-live { flex-basis: 100%; }
.desk-live { flex-basis: 100%; }
.desk-live > summary { list-style: none; display: inline-flex; }
.desk-live > summary { list-style: none; display: inline-flex; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -0,0 +1,186 @@
+'use strict';
+/**
+ * Desk leases for OpenVibe.Run's sessions (contracts 0.223.0, capability bot.desk.session; the Desks plan's D6). Run,
+ * with a service token naming the session's person in X-OV-Subject, leases one named browser desk on a machine:
+ *
+ * GET /api/v1/desk-hosts where a desk may go for this person: their own machines
+ * (where they let agents use them) and desk hosts that serve
+ * other people, each with its trust class and its room
+ * POST /api/v1/machines/:id/desk-leases bot.desk-lease-create-request@1 → 201 bot.desk-lease@1: the
+ * desk is started (kind browser, the lease's size)
+ * GET /api/v1/machines/:id/desk-leases/:desk the lease, to its person only
+ * POST /api/v1/machines/:id/desk-leases/:desk/commands screen, input or browser on that desk alone (bot.command@1
+ * without desk) → bot.command-result@1's fields
+ * DELETE /api/v1/machines/:id/desk-leases/:desk the desk is stopped and the lease ends
+ *
+ * A machine takes a lease when it is the person's own and its owner turned on "Let my agent use this computer", or when
+ * its owner offers its desks to other people (shared_desks) and its Node runs every desk in its own sandbox (describe
+ * desk.isolation "sandbox", Node v0.32.0). Anything else is 404, never confirmed to exist. A leased desk is the
+ * session's alone: the machine's own routes refuse it (api/v1.js), and on a shared host nothing but browser desks runs.
+ */
+const express = require('express');
+const { validate } = require('openvibe-contracts');
+const { fail, userSubject } = require('../util');
+
+const CAPABILITY = 'bot.desk.session';
+const RUN = 'svc:run';
+const MACHINE_RE = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;
+const DESK_RE = /^desk-[a-z0-9][a-z0-9-]{0,23}$/;
+const ACTS = new Set(['screen', 'input', 'browser']);
+const MAX_DESKS_CAP = 10; // the computer plugin's own cap on max_desks; a browser desk counts as half
+
+/** The refusal codes a command can come back with, as HTTP statuses (as api/v1.js commandRefusalStatus). */
+function refusalStatus(code) {
+ if (code === 'bot.forbidden' || code === 'bot.not_an_operator') return 403;
+ if (code === 'bot.device_offline' || code === 'bot.command_pending' || code === 'bot.computer_off' || code === 'bot.person_driving') return 409;
+ return 422;
+}
+
+function router({ domain, apiAuth, hub, config = {}, limit = (req, res, next) => next() }) {
+ const r = express.Router();
+ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
+ // The owners whose shared desk hosts are OpenVibe's own (first-party); every other shared host is community.
+ const firstParty = new Set((config.deskHosts && config.deskHosts.firstPartyOwners) || []);
+
+ /** Only Run, with bot.desk.session, acting for a person (X-OV-Subject). → that person's subject */
+ function runFor(req) {
+ if (req.principal.kind === 'anonymous') fail(401, 'bot.sign_in', 'sign in to do that');
+ if (req.principal.kind !== 'service' || req.principal.sub !== RUN) fail(403, 'bot.forbidden', 'desk leases are OpenVibe.Run\'s, for its sessions');
+ if (!apiAuth.granted(req.principal, CAPABILITY)) fail(403, 'capability.denied', `${CAPABILITY} not granted`);
+ const s = req.headers['x-ov-subject'];
+ if (!s) fail(422, 'bot.invalid_input', 'a session acts for a person (X-OV-Subject)');
+ return userSubject(s, 'subject');
+ }
+
+ const sensors = (deviceId) => {
+ const st = hub.deviceState(deviceId) || {};
+ return st.telemetry && st.telemetry.sensors && typeof st.telemetry.sensors === 'object' ? st.telemetry.sensors : {};
+ };
+ const browserDesks = (offered) => !!(offered && offered.desk && Array.isArray(offered.desk.kinds) && offered.desk.kinds.includes('browser') && offered.desk.named === true);
+ const sandboxed = (offered) => !!(offered && offered.desk && offered.desk.isolation === 'sandbox');
+
+ /**
+ * How a person may use a machine for a session, or null: { device, shared, trust }. Their own machine where agents
+ * may use it; another's only when its owner shares its desks and its Node sandboxes every one.
+ */
+ async function usable(device, subject) {
+ if (!device || device.revoked_at) return null;
+ const offered = hub.computerOf(device.id);
+ if (!browserDesks(offered)) return null;
+ const owner = await domain.devices.ownerOf(device);
+ if (owner === subject && device.agent_computer) return { device, shared: false, trust: 'user-owned', offered };
+ if (device.shared_desks && sandboxed(offered)) return { device, shared: true, trust: firstParty.has(owner) ? 'first-party' : 'community', offered };
+ return null;
+ }
+ /** Room for one more browser desk: the plugin's max_desks counts a browser desk as half. */
+ async function room(device, offered) {
+ const max = Math.min(MAX_DESKS_CAP, Number(offered.desk.max_desks) || 1) * 2;
+ const running = Number.isInteger(sensors(device.id).computer_desks) ? sensors(device.id).computer_desks : 0;
+ const leased = await domain.deskLeases.count(device.id);
+ return { max, used: Math.max(running, leased), free: Math.max(0, max - Math.max(running, leased)) };
+ }
+
+ r.get('/desk-hosts', limit, wrap(async (req, res) => {
+ const subject = runFor(req);
+ const own = await domain.machines.list(subject);
+ const shared = await domain.machines.listSharedDesks();
+ const seen = new Set();
+ const hosts = [];
+ for (const d of [...own, ...shared]) {
+ if (seen.has(d.id)) continue;
+ seen.add(d.id);
+ const u = await usable(d, subject);
+ if (!u) continue;
+ const online = !!(hub.isOnline && hub.isOnline(d.id));
+ const rm = await room(d, u.offered);
+ hosts.push({ machine_id: d.id, trust: u.trust, shared: u.shared, online, region: null, max_browser_desks: rm.max, used: rm.used, free: online ? rm.free : 0,
+ ...(u.shared ? { limits: u.offered.desk.limits || null } : { name: d.name || null }) });
+ }
+ res.set('Cache-Control', 'no-store').json({ hosts });
+ }));
+
+ async function machineFor(req, subject) {
+ const id = String(req.params.id || '');
+ if (!MACHINE_RE.test(id)) fail(404, 'bot.device_not_found', 'no such machine for you');
+ const u = await usable(await domain.devices.get(id), subject);
+ if (!u) fail(404, 'bot.device_not_found', 'no such machine for you');
+ return u;
+ }
+ async function leaseFor(req, subject) {
+ const u = await machineFor(req, subject);
+ const desk = String(req.params.desk || '');
+ const lease = DESK_RE.test(desk) ? await domain.deskLeases.get(u.device.id, desk) : null;
+ if (!lease || lease.owner !== `user:${subject}`) fail(404, 'bot.lease_not_found', 'no such desk lease for you');
+ return { ...u, lease };
+ }
+ const principalFor = (req, subject) => ({ ...req.principal, subject });
+
+ r.post('/machines/:id/desk-leases', limit, express.json({ limit: '8kb' }), wrap(async (req, res) => {
+ const subject = runFor(req);
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
+ const check = validate('bot.desk-lease-create-request@1', body);
+ if (!check.valid) fail(422, 'bot.invalid_input', `invalid lease: ${(check.errors && check.errors[0] && check.errors[0].message) || 'bad value'}`.slice(0, 300));
+ const u = await machineFor(req, subject);
+ const owner = `user:${subject}`;
+ const mine = await domain.deskLeases.forSession(body.session);
+ if (mine) {
+ if (mine.machine_id === u.device.id && mine.desk === body.desk && mine.owner === owner) return res.status(200).json(mine);
+ fail(409, 'bot.lease_conflict', 'that session holds another desk');
+ }
+ if (!(hub.isOnline && hub.isOnline(u.device.id))) fail(409, 'bot.device_offline', 'the machine is offline');
+ if ((await room(u.device, u.offered)).free < 1) fail(409, 'bot.machine_busy', 'the machine has no room for another desk right now');
+ const width = Number.isInteger(body.width) ? body.width : 1280;
+ const height = Number.isInteger(body.height) ? body.height : 800;
+ const lease = await domain.deskLeases.create({ deviceId: u.device.id, desk: body.desk, session: body.session, owner, width, height, shared: u.shared });
+ if (!lease) fail(409, 'bot.desk_busy', 'that desk is in use');
+ // The desk starts now; a machine that refuses it gives the lease back at once.
+ let result;
+ try {
+ result = await hub.machineCommand(u.device.id, principalFor(req, subject), subject, {
+ id: `lease-${body.session}`.slice(0, 64), kind: 'desk', ms: 30000,
+ value: { action: 'start', desk: body.desk, kind: 'browser', width, height },
+ });
+ } catch (e) { result = { result: 'refused', code: 'bot.device_offline', reason: e.message }; }
+ if (!result || result.result !== 'ack') {
+ await domain.deskLeases.remove(u.device.id, body.desk);
+ const code = result && result.code ? result.code : result && result.result === 'nack' ? 'bot.desk_failed' : 'bot.device_offline';
+ fail(code === 'bot.desk_failed' ? 502 : refusalStatus(code), code, (result && result.reason) || 'the machine did not start the desk');
+ }
+ res.status(201).json(lease);
+ }));
+
+ r.get('/machines/:id/desk-leases/:desk', limit, wrap(async (req, res) => {
+ const subject = runFor(req);
+ const { lease } = await leaseFor(req, subject);
+ res.set('Cache-Control', 'no-store').json(lease);
+ }));
+
+ r.post('/machines/:id/desk-leases/:desk/commands', limit, express.json({ limit: '64kb' }), wrap(async (req, res) => {
+ const subject = runFor(req);
+ const { device, lease } = await leaseFor(req, subject);
+ const kind = String((req.body && req.body.kind) || '');
+ const value = req.body && req.body.value && typeof req.body.value === 'object' && !Array.isArray(req.body.value) ? req.body.value : null;
+ if (!ACTS.has(kind) || !value) fail(422, 'bot.invalid_input', 'kind is screen, input or browser, with a value');
+ if ('desk' in value) fail(422, 'bot.invalid_input', 'a session acts on its own desk only: leave out desk');
+ const result = await hub.machineCommand(device.id, principalFor(req, subject), subject, {
+ id: req.body.id, kind, value: { ...value, desk: lease.desk }, ms: req.body.ms,
+ });
+ res.json({ machine_id: device.id, desk: lease.desk, ...result });
+ }));
+
+ r.delete('/machines/:id/desk-leases/:desk', limit, wrap(async (req, res) => {
+ const subject = runFor(req);
+ const { device, lease } = await leaseFor(req, subject);
+ let stopped = false;
+ try {
+ const result = await hub.machineCommand(device.id, principalFor(req, subject), subject, { kind: 'desk', value: { action: 'stop', desk: lease.desk }, ms: 20000 });
+ stopped = !!(result && result.result === 'ack');
+ } catch { /* an offline machine stops its desks when it comes back without the lease (the plugin's idle stop) */ }
+ await domain.deskLeases.remove(device.id, lease.desk);
+ res.json({ machine_id: device.id, desk: lease.desk, ended: true, stopped });
+ }));
+
+ return r;
+}
+
+module.exports = { router, CAPABILITY, refusalStatus };
@@ -47,6 +47,7 @@ const { getProfile, listProfiles, checkProfile } = require('../profiles');
@@ -47,6 +47,7 @@ const { getProfile, listProfiles, checkProfile } = require('../profiles');
const kits = require('../kits');
const kits = require('../kits');
const resourceIndex = require('./resource-index');
const resourceIndex = require('./resource-index');
const resourceControl = require('./resource-control');
const resourceControl = require('./resource-control');
+const deskLeases = require('./desk-leases');
const CAP = {
const CAP = {
read: 'bot.robot.read',
read: 'bot.robot.read',
@@ -79,6 +80,8 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -79,6 +80,8 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
// Control first (bot.resource.control), then the index (bot.resource.read) for everything else under /resources.
// Control first (bot.resource.control), then the index (bot.resource.read) for everything else under /resources.
r.use('/resources/control', limits('bot.control', { minute: 120, hour: 1200 }), resourceControl.router({ domain, apiAuth, hub }));
r.use('/resources/control', limits('bot.control', { minute: 120, hour: 1200 }), resourceControl.router({ domain, apiAuth, hub }));
r.use('/resources', resourceIndex.router({ db: domain.db, apiAuth, hub, baseUrl: config.baseUrl }));
r.use('/resources', resourceIndex.router({ db: domain.db, apiAuth, hub, baseUrl: config.baseUrl }));
+ // Run's sessions lease one desk each (contracts 0.223.0, bot.desk.session): desk hosts, leases and their commands.
+ r.use(deskLeases.router({ domain, apiAuth, hub, config, limit: limits('bot.desk_session', { minute: 600, hour: 6000 }) }));
const manage = limits('bot.robot.manage', { minute: 30, hour: 300 });
const manage = limits('bot.robot.manage', { minute: 30, hour: 300 });
const control = limits('bot.control', { minute: 120, hour: 1200 });
const control = limits('bot.control', { minute: 120, hour: 1200 });
const machineScreen = createMachineScreen({ domain, hub });
const machineScreen = createMachineScreen({ domain, hub });
@@ -431,6 +434,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -431,6 +434,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
const pick = (cap) => (offered && offered[cap] && typeof offered[cap] === 'object' ? offered[cap] : null);
const pick = (cap) => (offered && offered[cap] && typeof offered[cap] === 'object' ? offered[cap] : null);
return {
return {
id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer, ai_models: !!device.ai_models, watch_probe: !!device.watch_probe,
id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer, ai_models: !!device.ai_models, watch_probe: !!device.watch_probe,
+ shared_desks: !!device.shared_desks,
voice: { on: !!device.voice_on, call_word: device.call_word || DEFAULT_CALL_WORD },
voice: { on: !!device.voice_on, call_word: device.call_word || DEFAULT_CALL_WORD },
computer: offered ? {
computer: offered ? {
level: typeof sensors.computer_level === 'string' ? sensors.computer_level : ((pick('files') || pick('terminal') || pick('screen') || {}).level || null),
level: typeof sensors.computer_level === 'string' ? sensors.computer_level : ((pick('files') || pick('terminal') || pick('screen') || {}).level || null),
@@ -467,6 +471,10 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -467,6 +471,10 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
// id answers its first result without data; an `ask`-level machine answers once its owner approves it there.
// id answers its first result without data; an `ask`-level machine answers once its owner approves it there.
r.post('/machines/:id/commands', commandControl, wrap(async (req, res) => {
r.post('/machines/:id/commands', commandControl, wrap(async (req, res) => {
const { device, subject } = await computerMachine(req, { kind: req.body?.kind });
const { device, subject } = await computerMachine(req, { kind: req.body?.kind });
+ // A desk leased to a Run session is the session's alone (desk-leases.js): its machine's own routes never reach it.
+ const v = req.body && req.body.value && typeof req.body.value === 'object' ? req.body.value : {};
+ const named = req.body?.kind === 'desk' ? (typeof v.desk === 'string' ? v.desk : 'private') : (typeof v.desk === 'string' ? v.desk : 'main');
+ if (/^desk-/.test(named) && await domain.deskLeases.get(device.id, named)) fail(409, 'bot.desk_leased', 'that desk is held by an OpenVibe.Run session');
const principal = req.principal.kind === 'service' ? { ...req.principal, subject } : req.principal;
const principal = req.principal.kind === 'service' ? { ...req.principal, subject } : req.principal;
const result = await hub.machineCommand(device.id, principal, subject, { id: req.body?.id, kind: req.body?.kind, value: req.body?.value, ms: req.body?.ms });
const result = await hub.machineCommand(device.id, principal, subject, { id: req.body?.id, kind: req.body?.kind, value: req.body?.value, ms: req.body?.ms });
if (result.result === 'refused') fail(commandRefusalStatus(result.code), result.code, result.reason || null);
if (result.result === 'refused') fail(commandRefusalStatus(result.code), result.code, result.reason || null);
@@ -102,7 +102,7 @@ function createApp(opts = {}) {
@@ -102,7 +102,7 @@ function createApp(opts = {}) {
// started by server/index.js with the other jobs. Without Network credentials nothing is reported.
// started by server/index.js with the other jobs. Without Network credentials nothing is reported.
const offerReporter = opts.offerReporter !== undefined ? opts.offerReporter
const offerReporter = opts.offerReporter !== undefined ? opts.offerReporter
: (config.oauth.clientSecret ? createOfferReporter(config, { fetchImpl }) : null);
: (config.oauth.clientSecret ? createOfferReporter(config, { fetchImpl }) : null);
- const offers = createOfferPublisher({ db, hub, reporter: offerReporter, log, now });
+ const offers = createOfferPublisher({ db, hub, reporter: offerReporter, log, now, firstPartyOwners: (config.deskHosts && config.deskHosts.firstPartyOwners) || [] });
domain.devices.onChange(() => offers.poke());
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
@@ -192,6 +192,12 @@ function loadConfig(env = process.env) {
@@ -192,6 +192,12 @@ function loadConfig(env = process.env) {
cooldownMs: int(env.BOT_CONTROL_COOLDOWN_MS, 0),
cooldownMs: int(env.BOT_CONTROL_COOLDOWN_MS, 0),
},
},
+ // Desk hosts that serve other people (server/api/desk-leases.js): the owners (user:usr_…) whose shared hosts are
+ // OpenVibe's own, so Run places on them as first-party; every other shared host is community.
+ deskHosts: {
+ firstPartyOwners: list(env.BOT_FIRST_PARTY_DESK_OWNERS).map((s) => s.replace(/^user:/, '')),
+ },
+
// Per-actor limits on /api/v1 reads (server/api/actor-limits.js).
// Per-actor limits on /api/v1 reads (server/api/actor-limits.js).
actorLimits: {
actorLimits: {
minute: Math.max(1, int(env.BOT_LIMITS_MINUTE, 120)),
minute: Math.max(1, int(env.BOT_LIMITS_MINUTE, 120)),
@@ -790,6 +790,35 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -790,6 +790,35 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
async function setAiModels(deviceId, value) {
async function setAiModels(deviceId, value) {
return db.maybe('UPDATE devices SET ai_models = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
return db.maybe('UPDATE devices SET ai_models = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
}
}
+ /** Offer this machine's desks to other people, or stop (the owner check and the sandbox check are the caller's). */
+ async function setSharedDesks(deviceId, value) {
+ return db.maybe('UPDATE devices SET shared_desks = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
+ }
+ /** Every live machine whose owner offers its desks to other people (Run's placement; the live checks are the caller's). */
+ async function listSharedDesks() {
+ return db.many('SELECT * FROM devices WHERE revoked_at IS NULL AND shared_desks = true ORDER BY created_at');
+ }
+
+ // ── Desk leases (contracts 0.223.0 bot.desk-lease@1, the Desks plan's D6) ──────────────────────────────────────
+ const leaseOut = (r) => (r ? {
+ machine_id: r.device_id, desk: r.desk, session: r.session_id, owner: r.owner_subject, kind: r.kind,
+ width: Number(r.width), height: Number(r.height), shared: !!r.shared, created_at: new Date(r.created_at).toISOString(),
+ } : null);
+ const deskLeases = {
+ async get(deviceId, desk) { return leaseOut(await db.maybe('SELECT * FROM desk_leases WHERE device_id = $1 AND desk = $2', [deviceId, desk])); },
+ async forSession(sessionId) { return leaseOut(await db.maybe('SELECT * FROM desk_leases WHERE session_id = $1', [sessionId])); },
+ async count(deviceId) { return Number((await db.one('SELECT count(*)::int AS n FROM desk_leases WHERE device_id = $1', [deviceId])).n); },
+ async list(deviceId) { return (await db.many('SELECT * FROM desk_leases WHERE device_id = $1 ORDER BY created_at', [deviceId])).map(leaseOut); },
+ /** The lease, or null when the desk is someone else's already (the caller answers 409). */
+ async create({ deviceId, desk, session, owner, width, height, shared }) {
+ const r = await db.maybe(
+ `INSERT INTO desk_leases (device_id, desk, session_id, owner_subject, kind, width, height, shared, created_at)
+ VALUES ($1, $2, $3, $4, 'browser', $5, $6, $7, $8) ON CONFLICT DO NOTHING RETURNING *`,
+ [deviceId, desk, session, owner, width, height, !!shared, iso(now())]);
+ return leaseOut(r);
+ },
+ async remove(deviceId, desk) { return leaseOut(await db.maybe('DELETE FROM desk_leases WHERE device_id = $1 AND desk = $2 RETURNING *', [deviceId, desk])); },
+ };
/** Let the owner's agent use this machine's computer plugin, or stop it (the owner check is the caller's). */
/** Let the owner's agent use this machine's computer plugin, or stop it (the owner check is the caller's). */
async function setAgentComputer(deviceId, value) {
async function setAgentComputer(deviceId, value) {
return db.maybe('UPDATE devices SET agent_computer = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
return db.maybe('UPDATE devices SET agent_computer = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
@@ -1287,8 +1316,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1287,8 +1316,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },
- machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice,
+ machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice, setSharedDesks, listSharedDesks,
screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
+ deskLeases,
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 },
@@ -18,7 +18,8 @@ const { json } = require('../util');
@@ -18,7 +18,8 @@ const { json } = require('../util');
const CONTRACT = 'platform.resource-offer@1';
const CONTRACT = 'platform.resource-offer@1';
const CLASSES = new Set(['function', 'code', 'browser', 'linux', 'desktop', 'gpu']);
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 }) {
+function createOfferPublisher({ db, hub, reporter = null, log = console, now = () => Date.now(), intervalMs = 60_000, settleMs = 2_000, firstPartyOwners = [] }) {
+ const firstParty = new Set(firstPartyOwners);
let timer = null;
let timer = null;
let soon = null;
let soon = null;
let running = null;
let running = null;
@@ -53,6 +54,29 @@ function createOfferPublisher({ db, hub, reporter = null, log = console, now = (
@@ -53,6 +54,29 @@ function createOfferPublisher({ db, hub, reporter = null, log = console, now = (
}
}
offers.push(offer);
offers.push(offer);
}
}
+ // Desk hosts that serve other people (contracts 0.223.0, Desks plan D6): a browser desk per session for anyone,
+ // each in its own sandbox there, placed by OpenVibe.Run and leased through Bot (api/desk-leases.js). Community,
+ // or first-party for the owners configured as OpenVibe's own (BOT_FIRST_PARTY_DESK_OWNERS).
+ const shared = 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.shared_desks = true ORDER BY d.id`);
+ for (const row of shared) {
+ const desk = ((json(row.capabilities, {}).computer || {}).desk) || {};
+ if (desk.isolation !== 'sandbox' || !Array.isArray(desk.kinds) || !desk.kinds.includes('browser')) continue;
+ const owner = row.owner_subject || row.robot_owner;
+ const offer = {
+ offer_id: `desk:${row.id}`, kind: 'node', node_id: row.id, provider: 'self', region: 'home',
+ trust: firstParty.has(owner) ? 'first-party' : 'community',
+ capabilities: ['worker:browser'],
+ health: { status: hub && hub.isOnline(row.id) ? 'up' : 'down', checked_at: at },
+ pricing: { model: 'per-second', marginal_usd_per_unit: 0, unit: 'desk-second' },
+ updated_at: at,
+ };
+ const v = validate(CONTRACT, offer);
+ if (!v.valid) { log.warn(`[Bot] offers: desk host ${row.id} does not match ${CONTRACT}, left out`); continue; }
+ offers.push(offer);
+ }
return offers;
return offers;
}
}
@@ -56,6 +56,9 @@ const deskOf = (value) => (typeof value.desk === 'string' && value.desk ? value.
@@ -56,6 +56,9 @@ const deskOf = (value) => (typeof value.desk === 'string' && value.desk ? value.
// The screen and the mouse and keyboard of a desk are the desk's own (Node's desk.screen, desk.input): a desk host with
// The screen and the mouse and keyboard of a desk are the desk's own (Node's desk.screen, desk.input): a desk host with
// no screen of its own (no one signed in, Node v0.20.0 --desk-host) offers them on its desks only.
// no screen of its own (no one signed in, Node v0.20.0 --desk-host) offers them on its desks only.
const onDeskOnly = (kind, value) => (kind === 'screen' || kind === 'input') && deskOf(value) !== 'main';
const onDeskOnly = (kind, value) => (kind === 'screen' || kind === 'input') && deskOf(value) !== 'main';
+// A browser command on a desk drives that desk's own browser: any machine with desks takes it, whether or not it offers a
+// browser of its own (a desk host for other people offers none, Node v0.32.0; its core routes it to the desks, v0.32.1).
+const onDeskBrowser = (kind, value, offered) => kind === 'browser' && deskOf(value) !== 'main' && !!(offered && offered.desk);
/** A command that acts on a screen (the mouse and keyboard, or the browser doing more than reading it). */
/** A command that acts on a screen (the mouse and keyboard, or the browser doing more than reading it). */
const actsOnScreen = (kind, value) => kind === 'input' || (kind === 'browser' && !['read', 'close'].includes(value.action));
const actsOnScreen = (kind, value) => kind === 'input' || (kind === 'browser' && !['read', 'close'].includes(value.action));
const COMPUTER_KINDS = Object.keys(COMPUTER_CAP);
const COMPUTER_KINDS = Object.keys(COMPUTER_CAP);
@@ -777,7 +780,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -777,7 +780,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
if (value.action === 'start' && deskOf(value) === 'main' && !(offered && offered.screen && offered.screen.stream === true)) {
if (value.action === 'start' && deskOf(value) === 'main' && !(offered && offered.screen && offered.screen.stream === true)) {
return refuse('bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your graphical session', summary);
return refuse('bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your graphical session', summary);
}
}
- } else if (!offered || !(onDeskOnly(kind, value) ? (offered.desk && offered.desk[kind] === true) : offered[COMPUTER_CAP[kind]])) {
+ } else if (!offered || !(onDeskBrowser(kind, value, offered) || (onDeskOnly(kind, value) ? (offered.desk && offered.desk[kind] === true) : offered[COMPUTER_CAP[kind]]))) {
return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : kind === 'ai' ? 'this machine offers no models: it needs OpenVibe Node v0.27.0 or newer with the ai plugin (install.sh --ai) and a model server (Ollama, LM Studio or llama.cpp)' : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);
return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : kind === 'ai' ? 'this machine offers no models: it needs OpenVibe Node v0.27.0 or newer with the ai plugin (install.sh --ai) and a model server (Ollama, LM Studio or llama.cpp)' : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);
}
}
// A desk other than the person's own screen needs a machine that has desks; one other than private (a named desk,
// A desk other than the person's own screen needs a machine that has desks; one other than private (a named desk,
@@ -729,7 +729,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -729,7 +729,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {
return { on: true, level: LEVELS[level] ? level : null, terminal: !!c.terminal, files: !!c.files, screen: !!c.screen, probe: !!c.probe, input: !!c.input,
return { on: true, level: LEVELS[level] ? level : null, terminal: !!c.terminal, files: !!c.files, screen: !!c.screen, probe: !!c.probe, input: !!c.input,
screenStream: !!(c.screen && c.screen.stream === true),
screenStream: !!(c.screen && c.screen.stream === true),
// Desks for its agents (Desks plan): how many at once, which kinds, and whether sign-ins carry over.
// Desks for its agents (Desks plan): how many at once, which kinds, and whether sign-ins carry over.
- desks: d ? { max: Number.isInteger(d.max_desks) ? d.max_desks : 1, kinds: Array.isArray(d.kinds) ? d.kinds : ['desktop'], keeps: d.profiles === true } : null,
+ desks: d ? { max: Number.isInteger(d.max_desks) ? d.max_desks : 1, kinds: Array.isArray(d.kinds) ? d.kinds : ['desktop'], keeps: d.profiles === true,
+ // Each desk in its own sandbox (Node v0.32.0, --desk-host --shared): it may serve other people's sessions.
+ sandboxed: d.isolation === 'sandbox' } : null,
ai };
ai };
};
};
// Vibe by voice: shown for a machine whose Node runs the voice plugin (capabilities.voice).
// Vibe by voice: shown for a machine whose Node runs the voice plugin (capabilities.voice).
@@ -752,6 +754,16 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -752,6 +754,16 @@ function renderMachinesPage({ machines = [], pairing = null }) {
};
};
// The person's machines whose models their AI may use: with two or more, Chat's link chooses any of them.
// The person's machines whose models their AI may use: with two or more, Chat's link chooses any of them.
const modelMachines = machines.filter((x) => { const c = x.ai_models && computerOf(x); return !!(c && c.ai && c.ai.models.length); }).length;
const modelMachines = machines.filter((x) => { const c = x.ai_models && computerOf(x); return !!(c && c.ai && c.ai.models.length); }).length;
+ // A desk host that sandboxes every desk (Desks plan D6): its owner may offer its desks to other people's sessions on
+ // OpenVibe.Run, and sees how many are in use. Nothing of the machine itself is reachable from a desk there.
+ const shareRow = (m, c) => {
+ if (!c.desks || !c.desks.sandboxed) return '';
+ const inUse = Number.isInteger(m.desk_leases) && m.desk_leases > 0 ? ` <span class="pill">${m.desk_leases === 1 ? '1 desk in use' : `${m.desk_leases} desks in use`}</span>` : '';
+ return `<div class="machine-share"><span class="pill">${m.shared_desks ? 'Serving other people' : 'Desks: yours only'}</span>${inUse}`
+ + '<span class="muted">Each desk runs in its own sandbox here (its own memory, CPU and network, the public internet only): nobody reaches this machine\'s files, screen or network from one.</span>'
+ + `<form method="post" action="/machines/${esc(m.id)}/shared-desks"><input type="hidden" name="on" value="${m.shared_desks ? '0' : '1'}">`
+ + `<button type="submit" class="button quiet" aria-pressed="${m.shared_desks ? 'true' : 'false'}">${m.shared_desks ? 'Stop offering my desks' : 'Offer my desks to other people'}</button></form></div>`;
+ };
const computerRow = (m) => {
const computerRow = (m) => {
const c = computerOf(m);
const c = computerOf(m);
if (!c) return '';
if (!c) return '';
@@ -778,6 +790,7 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -778,6 +790,7 @@ function renderMachinesPage({ machines = [], pairing = null }) {
return `<div class="machine-computer"><span class="pill">Computer: ${esc(c.level ? LEVELS[c.level] : 'on')}</span><span class="muted">${esc(can)}</span>${desks}${running}${waiting}${deskLink}`
return `<div class="machine-computer"><span class="pill">Computer: ${esc(c.level ? LEVELS[c.level] : 'on')}</span><span class="muted">${esc(can)}</span>${desks}${running}${waiting}${deskLink}`
+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`
+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`
+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`
+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`
+ + shareRow(m, c)
+ (c.screenStream && m.online ? `<form method="post" action="/machines/${esc(m.id)}/screen"><button type="submit" class="button quiet">${m.computer_screen_stream === 'live' ? 'Watch my screen' : 'Show my screen'}</button></form>` : '')
+ (c.screenStream && m.online ? `<form method="post" action="/machines/${esc(m.id)}/screen"><button type="submit" class="button quiet">${m.computer_screen_stream === 'live' ? 'Watch my screen' : 'Show my screen'}</button></form>` : '')
+ aiRow
+ aiRow
+ (c.probe ? `<form method="post" action="/machines/${esc(m.id)}/watch-probe"><input type="hidden" name="on" value="${m.watch_probe ? '0' : '1'}">`
+ (c.probe ? `<form method="post" action="/machines/${esc(m.id)}/watch-probe"><input type="hidden" name="on" value="${m.watch_probe ? '0' : '1'}">`
@@ -580,6 +580,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -580,6 +580,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
computer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
computer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
computer_screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',
computer_screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',
computer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
computer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
+ desk_leases: d.shared_desks ? await domain.deskLeases.count(d.id) : 0,
ai_models: !!d.ai_models,
ai_models: !!d.ai_models,
// How fast its last model answered there (Node v0.29.0's ai plugin), and whether one is answering now.
// How fast its last model answered there (Node v0.29.0's ai plugin), and whether one is answering now.
ai_speed: typeof sensors.ai_tokens_per_s === 'number' && sensors.ai_tokens_per_s > 0 && typeof sensors.ai_model === 'string'
ai_speed: typeof sensors.ai_tokens_per_s === 'number' && sensors.ai_tokens_per_s > 0 && typeof sensors.ai_model === 'string'
@@ -796,6 +797,21 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -796,6 +797,21 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
await domain.machines.setAgentComputer(mine.id, req.body && req.body.on === '1');
await domain.machines.setAgentComputer(mine.id, req.body && req.body.on === '1');
res.redirect(303, '/machines');
res.redirect(303, '/machines');
}));
}));
+ // The owner offers this desk host's desks to other people's sessions (OpenVibe.Run, contracts 0.223.0), or stops: only
+ // where its Node sandboxes every desk (Node v0.32.0, install.sh --desk-host --shared).
+ r.post('/machines/:id/shared-desks', ...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');
+ const on = req.body && req.body.on === '1';
+ const desk = ((hub && hub.computerOf(mine.id)) || {}).desk;
+ if (on && !(desk && desk.isolation === 'sandbox')) {
+ throw new BotError(409, 'bot.computer_off', 'this machine does not sandbox its desks: install it with --desk-host --shared (OpenVibe Node v0.32.0 or newer), then try again');
+ }
+ await domain.machines.setSharedDesks(mine.id, on);
+ res.redirect(303, '/machines');
+ }));
// The owner lets Vibe answer their voice on this machine (OpenVibe Node's voice plugin), with a call word, or stops it.
// The owner lets Vibe answer their voice on this machine (OpenVibe Node's voice plugin), with a call word, or stops it.
r.post('/machines/:id/voice', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
r.post('/machines/:id/voice', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
new file mode 100644
new file mode 100644
@@ -0,0 +1,161 @@
@@ -0,0 +1,161 @@
+'use strict';
+// Desk leases for OpenVibe.Run's sessions (contracts 0.223.0, bot.desk.session; the Desks plan's D6). Only Run, with the
+// capability, acting for a person: GET /api/v1/desk-hosts lists that person's own machines (agents allowed) and desk
+// hosts whose owner offers them to other people and whose Node sandboxes every desk; a lease starts a browser desk,
+// takes commands for that desk alone and ends with DELETE; the machine's own routes never reach a leased desk; a full
+// machine is busy; anyone else gets 404.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+
+(async () => {
+ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0' } });
+ const alex = t.network.newUser('alex'); // runs a desk host for other people
+ const bob = t.network.newUser('bob'); // uses it, and has a machine of his own
+ const carol = t.network.newUser('carol');
+ let seq = 0;
+ const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });
+ const poll = async (fn, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(30); } return null; };
+ const seen = [];
+ const DESK = (isolation, max = 1) => ({ level: 'trusted', window_manager: null, input: true, screen: true, named: true, max_desks: max, kinds: ['browser'], profiles: false, ...(isolation ? { isolation } : {}) });
+
+ /** A machine of `owner` whose fake Node answers desk and browser commands like the computer plugin would. */
+ async function machine(owner, computer) {
+ const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desks' });
+ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
+ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;
+ const config = await c.waitFor((m) => m.type === 'config');
+ c.send(frame('status', { capabilities: { computer }, drivers: [], faults: [] }));
+ c.send(frame('telemetry', { sensors: { computer_level: 'trusted', computer_desks: 0 } }));
+ c.ws.on('message', (raw) => {
+ let m; try { m = JSON.parse(raw.toString()); } catch { return; }
+ if (m.type !== 'command') return;
+ seen.push({ deviceId, ...m });
+ if (m.kind === 'desk') c.send(frame('ack', { id: m.id, data: { running: m.value.action !== 'stop', kind: 'browser', width: 1280, height: 800 } }));
+ else if (m.kind === 'browser') c.send(frame('ack', { id: m.id, data: { url: m.value.url || 'https://example.org/', title: 'Example', text: 'Example page', truncated: false, links: [] } }));
+ else if (m.kind === 'screen') c.send(frame('ack', { id: m.id, data: { format: 'jpeg', width: 1280, height: 800, content_b64: '/9j/' } }));
+ else c.send(frame('nack', { id: m.id, fault_code: 'not_allowed', message: 'this machine is a desk host for other people' }));
+ });
+ const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, Math.max(100, Math.floor(config.heartbeat_ms / 2)));
+ c.ws.on('close', () => clearInterval(beat));
+ await poll(() => t.hub.computerOf(deviceId) !== null);
+ return { c, deviceId };
+ }
+ const run = (cap = ['bot.desk.session']) => t.network.signService({ sub: 'svc:run', cap });
+ const asRun = (subject, token = run()) => ({ token, headers: { 'X-OV-Subject': subject } });
+ const SES = (n) => `ses_01JD2C3D4E5F6G7H8J9K0MNP${String(n).padStart(2, '0')}`;
+
+ const host = await machine(alex, { desk: DESK('sandbox', 1) }); // two browser desks at once
+ const plain = await machine(carol, { desk: DESK(null, 2) }); // desks, not sandboxed: never shared
+ const own = await machine(bob, { desk: DESK(null, 2) });
+
+ await check('only Run, with bot.desk.session, acting for a person', async () => {
+ assert.strictEqual((await t.call('GET', '/api/v1/desk-hosts', { user: bob })).status, 403, 'not a person\'s own call');
+ assert.strictEqual((await t.call('GET', '/api/v1/desk-hosts', asRun(bob.subject, t.network.signService({ sub: 'svc:actor', cap: ['bot.desk.session'] })))).status, 403, 'Run only');
+ assert.strictEqual((await t.call('GET', '/api/v1/desk-hosts', asRun(bob.subject, run(['bot.computer.control'])))).status, 403, 'needs bot.desk.session');
+ assert.strictEqual((await t.call('GET', '/api/v1/desk-hosts', { token: run() })).status, 422, 'for a person');
+ });
+
+ await check('desk hosts: the person\'s own machines where agents may work, and shared sandboxed hosts', async () => {
+ let hosts = (await t.call('GET', '/api/v1/desk-hosts', asRun(bob.subject))).json.hosts;
+ assert.deepStrictEqual(hosts, [], 'nothing before agents are allowed and nothing is shared');
+ await t.domain.machines.setAgentComputer(own.deviceId, true);
+ await t.domain.machines.setSharedDesks(host.deviceId, true);
+ await t.domain.machines.setSharedDesks(plain.deviceId, true); // set behind the web check: still never offered
+ hosts = (await t.call('GET', '/api/v1/desk-hosts', asRun(bob.subject))).json.hosts;
+ const by = Object.fromEntries(hosts.map((h) => [h.machine_id, h]));
+ assert.deepStrictEqual(Object.keys(by).sort(), [host.deviceId, own.deviceId].sort(), 'never a host that does not sandbox its desks');
+ assert.deepStrictEqual([by[own.deviceId].trust, by[own.deviceId].shared, by[own.deviceId].free], ['user-owned', false, 4]);
+ assert.deepStrictEqual([by[host.deviceId].trust, by[host.deviceId].shared, by[host.deviceId].free, by[host.deviceId].online], ['community', true, 2, true]);
+ assert.ok(!('name' in by[host.deviceId]), 'another person\'s machine name is not shown');
+ const carols = (await t.call('GET', '/api/v1/desk-hosts', asRun(carol.subject))).json.hosts.map((h) => h.machine_id);
+ assert.deepStrictEqual(carols, [host.deviceId], 'carol sees the shared host, not bob\'s own machine');
+ });
+
+ let lease;
+ await check('a lease starts a browser desk on the shared host, once per session', async () => {
+ const body = { session: SES(1), desk: 'desk-s01', kind: 'browser', width: 1024, height: 700 };
+ const r = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body });
+ assert.strictEqual(r.status, 201, r.text);
+ lease = r.json;
+ assert.deepStrictEqual([lease.machine_id, lease.desk, lease.session, lease.owner, lease.shared, lease.width], [host.deviceId, 'desk-s01', SES(1), `user:${bob.subject}`, true, 1024]);
+ const start = seen.filter((m) => m.deviceId === host.deviceId && m.kind === 'desk').at(-1);
+ assert.deepStrictEqual(start.value, { action: 'start', desk: 'desk-s01', kind: 'browser', width: 1024, height: 700 });
+ assert.strictEqual((await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body })).status, 200, 'a repeat answers the lease');
+ const other = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(carol.subject), body: { ...body, session: SES(2) } });
+ assert.deepStrictEqual([other.status, other.json.code], [409, 'bot.desk_busy']);
+ const bad = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { ...body, session: SES(3), desk: 'main' } });
+ assert.strictEqual(bad.status, 422, 'never main or private');
+ const notShared = await t.call('POST', `/api/v1/machines/${plain.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { ...body, session: SES(4) } });
+ assert.strictEqual(notShared.status, 404, 'a host that does not sandbox its desks is never leased to someone else');
+ });
+
+ await check('commands reach the leased desk alone, for its person alone', async () => {
+ const url = `/api/v1/machines/${host.deviceId}/desk-leases/desk-s01/commands`;
+ const r = await t.call('POST', url, { ...asRun(bob.subject), body: { id: 'c1', kind: 'browser', value: { action: 'open', url: 'https://example.org/' } } });
+ assert.deepStrictEqual([r.status, r.json.result, r.json.desk], [200, 'ack', 'desk-s01'], r.text);
+ assert.strictEqual(seen.at(-1).value.desk, 'desk-s01', 'Run never names the desk: Bot does');
+ assert.strictEqual((await t.call('POST', url, { ...asRun(bob.subject), body: { kind: 'browser', value: { action: 'read', desk: 'desk-other' } } })).status, 422, 'a value naming a desk');
+ assert.strictEqual((await t.call('POST', url, { ...asRun(bob.subject), body: { kind: 'exec', value: { argv: ['id'] } } })).status, 422, 'never the machine itself');
+ assert.strictEqual((await t.call('POST', url, { ...asRun(carol.subject), body: { kind: 'screen', value: {} } })).status, 404, 'another person');
+ assert.strictEqual((await t.call('GET', `/api/v1/machines/${host.deviceId}/desk-leases/desk-s01`, asRun(carol.subject))).status, 404);
+ // The host's owner, with their own machine's routes, never reaches the session's desk.
+ await t.domain.machines.setAgentComputer(host.deviceId, true);
+ const owner = await t.call('POST', `/api/v1/machines/${host.deviceId}/commands`, { user: alex, body: { kind: 'screen', value: { desk: 'desk-s01' } } });
+ assert.deepStrictEqual([owner.status, owner.json.code], [409, 'bot.desk_leased']);
+ });
+
+ await check('a full machine is busy; the end stops the desk and frees the room', async () => {
+ const second = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(carol.subject), body: { session: SES(5), desk: 'desk-s05', kind: 'browser' } });
+ assert.strictEqual(second.status, 201, second.text);
+ const third = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { session: SES(6), desk: 'desk-s06', kind: 'browser' } });
+ assert.deepStrictEqual([third.status, third.json.code], [409, 'bot.machine_busy'], 'max_desks 1: two browser desks');
+ const end = await t.call('DELETE', `/api/v1/machines/${host.deviceId}/desk-leases/desk-s01`, asRun(bob.subject));
+ assert.deepStrictEqual([end.status, end.json.ended, end.json.stopped], [200, true, true]);
+ assert.deepStrictEqual(seen.filter((m) => m.kind === 'desk').at(-1).value, { action: 'stop', desk: 'desk-s01' });
+ assert.strictEqual((await t.call('GET', `/api/v1/machines/${host.deviceId}/desk-leases/desk-s01`, asRun(bob.subject))).status, 404, 'gone');
+ const again = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { session: SES(6), desk: 'desk-s06', kind: 'browser' } });
+ assert.strictEqual(again.status, 201, 'room again');
+ });
+
+ await check('a shared host is offered to the Network as a browser worker, community unless it is OpenVibe\'s', async () => {
+ const offers = await t.app.locals.offers.build();
+ const desk = offers.find((o) => o.offer_id === `desk:${host.deviceId}`);
+ assert.ok(desk, 'the shared host is offered');
+ assert.deepStrictEqual([desk.trust, desk.capabilities, desk.health.status], ['community', ['worker:browser'], 'up']);
+ assert.ok(!offers.some((o) => o.offer_id === `desk:${plain.deviceId}`), 'never a host that does not sandbox its desks');
+ assert.ok(!offers.some((o) => o.offer_id === `desk:${own.deviceId}`), 'a person\'s own machine is not offered to others');
+ });
+
+ await check('the machines page: a sandboxed host offers its desks with one button; another cannot', async () => {
+ const cookie = { Cookie: `ov_token=${t.network.signUser(alex)}` };
+ const form = (id, on) => fetch(`${t.base}/machines/${id}/shared-desks`, { method: 'POST', redirect: 'manual', body: `on=${on}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: t.base, ...cookie } });
+ let html = '';
+ let tries = 0;
+ while (tries < 20 && !html.includes('Serving other people')) {
+ if (tries) await t.wait(100);
+ html = await (await fetch(`${t.base}/machines`, { headers: cookie })).text();
+ tries += 1;
+ }
+ assert.ok(html.includes('Serving other people') && html.includes('desks in use'), 'the host says it serves other people, and how many desks are in use');
+ assert.ok(html.includes('Stop offering my desks'));
+ assert.strictEqual((await form(host.deviceId, 0)).status, 303);
+ assert.strictEqual((await t.db.maybe('SELECT shared_desks FROM devices WHERE id = $1', [host.deviceId])).shared_desks, false);
+ assert.strictEqual((await form(host.deviceId, 1)).status, 303);
+ assert.strictEqual((await t.db.maybe('SELECT shared_desks FROM devices WHERE id = $1', [host.deviceId])).shared_desks, true);
+ const carolsCookie = { Cookie: `ov_token=${t.network.signUser(carol)}` };
+ const notSandboxed = await fetch(`${t.base}/machines/${plain.deviceId}/shared-desks`, { method: 'POST', redirect: 'manual', body: 'on=1', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: t.base, ...carolsCookie } });
+ assert.strictEqual(notSandboxed.status, 409, 'a host that does not sandbox its desks is never offered');
+ });
+
+ await check('the owner stops offering: no new lease there', async () => {
+ await t.domain.machines.setSharedDesks(host.deviceId, false);
+ const r = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { session: SES(7), desk: 'desk-s07', kind: 'browser' } });
+ assert.strictEqual(r.status, 404);
+ const own2 = await t.call('POST', `/api/v1/machines/${own.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { session: SES(8), desk: 'desk-s08', kind: 'browser' } });
+ assert.deepStrictEqual([own2.status, own2.json.shared], [201, false], 'a person\'s own machine, where agents may work');
+ });
+
+ for (const m of [host, plain, own]) m.c.close();
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });