openvibers/openvibe.bot Commits
Bot: the live screen — the owner watches their machine's screen on its own private stream (#115)
Changes
@@@ -43,6 +43,13 @@ Sitemap entries use the release commit time for these code-backed pages.- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe.Node `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe.Node `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.bot/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe.Node's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-node approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. 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.++ - **The command.** `screen_stream` start goes to the machine with that stream's WHIP URL and key. It is not a `bot.command@1` kind, so the commands route, agents, watches and operators can never send it or choose where the screen goes.++ - **The audit** keeps the action, seconds and fps, never the URL or key.++ - **The viewer.** `GET` answers the session's viewer `signaling_url` once the machine publishes, and the page plays it with the panel's WebRTC viewer.++ - **Stop.** `DELETE` (or Stop) tells the machine to stop and retires the key. Removing a machine archives its stream.++ - **Refused** with `bot.computer_off` when the machine does not offer it (`screen.stream`), and no key is made.- A server-side ONVIF camera (`camera.onvif`) is not installed on a machine: it gets no `--driver` and Bot attaches it itself from `BOT_ONVIF_CAMERAS` (see Deploy), so its panel works as soon as the camera answers.- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe.Node's `docs/hardware-adeept.md` (T15 step 1).- A robot can use its own `local.rob_…` profile, stored with that robot and validated against `bot.robot-profile@1` and Bot's capability, driver and widget registries. On the owner's robot page, the Buttons form adds or removes rows and saves each button's name, label, optional keyboard key, cooldown in milliseconds and hold setting. The Video click checkbox enables a separate point cooldown. Only the owner can save or return to the robot's catalogue profile; each change is audited.nnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- Plan T14 (contracts 0.182.0): the machine's own private OpenRestream stream that shows its screen live to its owner++-- (playback private, never recorded or mirrored to Live; external ref bot:machine:<id>). Null until the owner first++-- shows the screen; each start rotates the stream's key.++ALTER TABLE devices ADD COLUMN screen_stream_id text;@@@ -349,3 +349,8 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.days { display: flex; flex-wrap: wrap; gap: 0.3rem 0.8rem; }.secret-once { padding: 0.6rem 0.8rem; border-radius: 10px; background: color-mix(in srgb, var(--ok) 14%, transparent); overflow-wrap: anywhere; }.setting-note code { font-size: 0.8em; }++++/* A machine's live screen (/machines/:id/screen): the camera tile at the page's width, with Stop under it. */++.machine-screen { margin: 1rem 0 0.75rem; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }++.machine-screen-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin-bottom: 0.75rem; }++.machine-screen-actions form { margin: 0; }@@@ -555,6 +555,7 @@let current = null; // the session being connected: { ws, transport, consumers }let timer = null; // the pending reconnectlet attempt = 0;++ let stopped = false; // stop(): no more reconnects (a machine's screen that ended)/** The tile shows the video or its placeholder, never both. */function showLive(on) {@@@ -572,7 +573,7 @@}/** The placeholder returns, the session closes, and the next attempt is a backoff away. */function retry() {-- if (timer) return;++ if (timer || stopped) return;showLive(false);teardown();timer = setTimeout(() => { timer = null; connect(); }, Math.min(10000, 500 * 2 ** attempt++));@@@ -632,9 +633,51 @@}}connect();++ return {++ stop() {++ stopped = true;++ if (timer) { clearTimeout(timer); timer = null; }++ showLive(false);++ teardown();++ },++ };}for (const tile of $$('[data-signaling-url]')) liveCamera(tile);++ // ── A machine's live screen (/machines/:id/screen): wait until the machine publishes, then play it as a camera ──++ for (const time of $$('time[data-local-time]')) {++ const at = new Date(time.getAttribute('datetime'));++ if (!Number.isNaN(at.getTime())) time.textContent = at.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });++ }++ for (const tile of $$('[data-screen-state]')) {++ const status = $('[data-screen-status]', tile);++ let playing = null;++ let misses = 0;++ const say = (text) => { if (status) status.textContent = text; };++ const poll = async () => {++ let s = null;++ try {++ const r = await fetch(tile.dataset.screenState, { headers: { Accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store' });++ s = r.ok ? await r.json() : null;++ } catch { s = null; }++ if (s && s.signaling_url && !playing) {++ tile.dataset.signalingUrl = s.signaling_url;++ say('Connecting to the screen…');++ playing = liveCamera(tile);++ } else if (playing && s && s.state === 'off') {++ // The machine stopped (its time is up, Stop, or its kill switch): leave the last word on the page.++ playing.stop();++ say('Your screen is no longer shown.');++ return;++ } else if (!playing) {++ misses += 1;++ say(s && s.online === false ? 'The machine is offline.' : misses > 20 ? 'Still waiting for the machine. Is the Node running in your desktop session?' : 'Waiting for the machine…');++ }++ setTimeout(poll, playing ? 5000 : 1500);++ };++ if (status && !$('.error', document)) poll();++ }++// ── State ─────────────────────────────────────────────────────────────────────────────────────────function enableControls() {for (const section of $$('[data-command]')) {@@@ -38,6 +38,7 @@ const express = require('express');const { parseSteps, stepHelp, AGENT_MAX_STEPS, AGENT_MAX_S } = require('../routines');const { http } = require('openvibe-contracts');const { fail, userSubject, isRobotId, json } = require('../util');++const { createMachineScreen } = require('../machine-screen');const { getProfile, listProfiles, checkProfile } = require('../profiles');const kits = require('../kits');const resourceIndex = require('./resource-index');@@@ -71,6 +72,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submr.use('/resources', resourceIndex.router({ db: domain.db, apiAuth }));const manage = limits('bot.robot.manage', { minute: 30, hour: 300 });const control = limits('bot.control', { minute: 120, hour: 1200 });++ const machineScreen = createMachineScreen({ domain, hub });// The command route counts against the acted-for person, not the service token: a bound channel's chat// forwards many viewers' commands through one service, and each viewer gets their own bucket. A service// is also capped by its own token (bot.control.service, 10× the per-person bucket): the subject is only@@@ -432,6 +434,26 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submif (result.result === 'refused') fail(commandRefusalStatus(result.code), result.code, result.reason || null);res.json({ machine_id: device.id, ...result });}));++ // The live screen (plan T14): the machine's owner, signed in as themselves, watches it on their own private stream.++ // Never an agent, a watch or another service: screen_stream is not a bot.command@1 kind, and only this route sends it.++ const screenOwner = async (req) => {++ if (req.principal.kind !== 'user') fail(403, 'bot.forbidden', 'only the machine\'s owner, signed in, shows its screen');++ return computerMachine(req, { agentNeedsOptIn: false });++ };++ r.post('/machines/:id/screen', manage, wrap(async (req, res) => {++ const { device, subject } = await screenOwner(req);++ const out = await machineScreen.start(device, subject, req.principal, { seconds: req.body?.seconds, fps: req.body?.fps });++ res.status(201).json({ machine_id: device.id, ...out });++ }));++ r.get('/machines/:id/screen', wrap(async (req, res) => {++ const { device, subject } = await screenOwner(req);++ res.set('Cache-Control', 'no-store');++ res.json({ machine_id: device.id, ...(await machineScreen.state(device, subject)) });++ }));++ r.delete('/machines/:id/screen', manage, wrap(async (req, res) => {++ const { device, subject } = await screenOwner(req);++ res.json({ machine_id: device.id, ...(await machineScreen.stop(device, subject, req.principal)) });++ }));// The owner lets their watches probe from the machine, or stops them (never Watch itself).r.post('/machines/:id/watch-probe', manage, wrap(async (req, res) => {if (req.principal.kind !== 'user') fail(403, 'bot.forbidden', 'only the machine\'s owner turns this on or off');@@@ -674,6 +674,65 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =SELECT 1 FROM robots r WHERE r.owner_subject = $1 AND d.robot_ids @> jsonb_build_array(r.id))))ORDER BY d.created_at DESC`, [subject]);}++ // ── The live screen (plan T14, contracts 0.182.0) ──────────────────────────────────────────────────────++ /**++ * A new ingest key for the machine's own screen stream on OpenRestream, as its owner → { streamId, key, whipUrl }.++ * One private stream per machine (external ref bot:machine:<id>): playback private, no recording, never on Live.++ * Each start rotates the key and ends a session still open, so only the newest start publishes. Bot keeps the++ * stream id, never the key, which goes once to the machine in its start command. Throws 503 without OpenRestream++ * or a WHIP ingest.++ */++ async function screenKey(device, owner) {++ if (!openre || !whipUrl('x')) fail(503, 'bot.openre_unavailable', 'OpenRestream is not configured here, so the screen cannot be shown');++ const rotated = async (streamId) => {++ const r = await openre.rotate(streamId, owner, { grace_seconds: 0, end_sessions: true });++ return r && { streamId, key: r.key };++ };++ let out = device.screen_stream_id ? await rotated(device.screen_stream_id) : null;++ if (!out) {++ const found = await openre.find(`bot:machine:${device.id}`, owner);++ if (found && found.id) out = await rotated(found.id);++ }++ if (!out) {++ const label = String(device.name || device.id).slice(0, 100);++ const created = await openre.create({++ title: `${label} screen`, protocols: ['webrtc'], recording_mode: 'none', recording_visibility: 'private',++ playback_visibility: 'private', mirror_to_live: false,++ external_refs: [{ service: 'bot', type: 'machine', id: device.id, label }],++ }, owner);++ out = created && created.stream && { streamId: created.stream.id, key: created.key };++ }++ if (!out || typeof out.streamId !== 'string' || !out.key || typeof out.key.key !== 'string' || !out.key.key) {++ fail(503, 'bot.openre_unavailable', 'OpenRestream answered no ingest key for the screen');++ }++ if (out.streamId !== device.screen_stream_id) {++ await db.query('UPDATE devices SET screen_stream_id = $2, updated_at = $3 WHERE id = $1', [device.id, out.streamId, iso(now())]);++ }++ return { streamId: out.streamId, key: out.key.key, whipUrl: whipUrl(out.key.key) };++ }++ /** The viewer signaling URL of the machine's open screen session, as its owner, or null (none is live yet). */++ async function screenLive(device, owner) {++ if (!openre || !device.screen_stream_id) return null;++ const open = await openre.sessions(device.screen_stream_id, owner, { state: 'open' });++ const session = open.find((x) => x && x.protocol === 'webrtc') || null;++ if (!session) return null;++ const descriptor = await openre.playback(session.id, owner);++ const url = descriptor && descriptor.webrtc && descriptor.webrtc.signaling_url;++ return typeof url === 'string' && /^wss?:\/\//.test(url) ? url : null;++ }++ /** Stop: retire the screen stream's key and end its session at once → whether there was a stream. */++ async function screenEnd(device, owner) {++ if (!openre || !device.screen_stream_id) return false;++ await openre.rotate(device.screen_stream_id, owner, { grace_seconds: 0, end_sessions: true });++ return true;++ }++ /** A machine removed: its screen stream goes too (archived on OpenRestream). */++ async function screenArchive(device, owner) {++ if (!openre || !device.screen_stream_id) return false;++ await openre.archive(device.screen_stream_id, owner);++ await db.query('UPDATE devices SET screen_stream_id = NULL WHERE id = $1', [device.id]);++ return true;++ }/** Let the owner's watches probe from this machine, or stop them (the owner check is the caller's). */async function setWatchProbe(deviceId, value) {return db.maybe('UPDATE devices SET watch_probe = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);@@@ -1099,7 +1158,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },-- machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setWatchProbe },++ machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setWatchProbe,++ screen: { key: screenKey, live: screenLive, end: screenEnd, archive: screenArchive } },streaming: { get: streaming, set: setStreaming },video: { live: liveVideo },estop: { set: setEstop, clear: clearEstop },nnew file mode 100644@@@ -0,0 +1,73 @@++'use strict';++/**++ * The live screen (plan T14, contracts 0.182.0): a machine's owner watches its screen on their own private OpenRestream++ * stream. Used by the API (POST/GET/DELETE /api/v1/machines/:id/screen) and the web page (/machines/:id/screen), for++ * the owner signed in as themselves only — never an agent, a watch or another service.++ *++ * start(device, subject, principal, { seconds, fps }) a new key for the machine's stream (domain.machines.screen),++ * then screen_stream start to the machine with the WHIP URL++ * and key → { streaming, seconds, fps, until }++ * state(device, subject) → { live, signaling_url, state, stream } (the session's viewer++ * URL once the machine is publishing; `state` the machine's own++ * telemetry: live or off)++ * stop(device, subject, principal) screen_stream stop to the machine (when online) and the++ * stream's key retired with its session → { stopped }++ *++ * OpenVibe.Node's computer plugin captures the frames and its core publishes them (Node v0.12.0, docs/computer.md).++ */++const { BotError } = require('./util');++++const SECONDS = { min: 30, max: 1800, default: 600 };++const FPS = { min: 1, max: 5, default: 2 };++++function whole(v, { min, max, default: dflt }, name) {++ if (v == null || v === '') return dflt;++ const n = Number(v);++ if (!Number.isInteger(n) || n < min || n > max) throw new BotError(422, 'bot.invalid_input', `${name} is a whole number from ${min} to ${max}`);++ return n;++}++++function createMachineScreen({ domain, hub, now = () => Date.now() }) {++ const unavailable = () => new BotError(503, 'bot.unavailable', 'machines are not connected here');++ return {++ limits: { SECONDS, FPS },++ async start(device, subject, principal, opts = {}) {++ if (!hub) throw unavailable();++ const seconds = whole(opts.seconds, SECONDS, 'seconds');++ const fps = whole(opts.fps, FPS, 'fps');++ if (!hub.isOnline(device.id)) throw new BotError(409, 'bot.device_offline', 'the machine is offline');++ // Before any key is made: the machine says whether it can (Node v0.12.0's computer plugin, a capture tool).++ const offered = hub.computerOf(device.id);++ if (!(offered && offered.screen && offered.screen.stream === true)) {++ throw new BotError(409, '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');++ }++ const key = await domain.machines.screen.key(device, subject);++ const r = await hub.machineScreen(device.id, principal, subject, { action: 'start', whip_url: key.whipUrl, publish_key: key.key, seconds, fps });++ if (r.result !== 'ack') {++ const code = r.code || (r.result === 'expired' ? 'bot.device_timeout' : 'bot.device_refused');++ const status = r.result === 'refused' ? (code === 'bot.computer_off' || code === 'bot.device_offline' ? 409 : 422) : r.result === 'expired' ? 504 : 409;++ throw new BotError(status, code.startsWith('bot.') ? code : 'bot.device_refused', `the machine did not start showing its screen: ${r.reason || r.result}`.slice(0, 300));++ }++ const data = r.data && typeof r.data === 'object' ? r.data : {};++ const until = Number.isFinite(data.until_ms) ? new Date(data.until_ms).toISOString() : new Date(now() + seconds * 1000).toISOString();++ return { streaming: true, seconds, fps, until };++ },++ async state(device, subject) {++ const sensors = hub ? ((hub.deviceState(device.id).telemetry || {}).sensors || {}) : {};++ const state = sensors.computer_screen_stream === 'live' ? 'live' : 'off';++ const url = state === 'live' ? await domain.machines.screen.live(device, subject) : null;++ return { live: !!url, signaling_url: url, state, online: !!(hub && hub.isOnline(device.id)) };++ },++ async stop(device, subject, principal) {++ let told = false;++ if (hub && hub.isOnline(device.id)) {++ const r = await hub.machineScreen(device.id, principal, subject, { action: 'stop' });++ told = r.result === 'ack';++ }++ const ended = await domain.machines.screen.end(device, subject);++ return { stopped: told || ended };++ },++ };++}++++module.exports = { createMachineScreen, SECONDS, FPS };@@@ -41,6 +41,9 @@ const DEVICE_MAX_PAYLOAD = 640 * 1024;// status, and the reply window (the owner's approval on the machine plus the program's own run).const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser' };const COMPUTER_KINDS = Object.keys(COMPUTER_CAP);++// The live screen (contracts 0.182.0): a device kind only Bot sends (machineScreen), with the machine's own private++// stream filled in. It is not one of bot.command@1's kinds, so no operator, agent or service can send it.++const SCREEN_KIND = 'screen_stream';const COMPUTER_DEFAULT_MS = 60000;const COMPUTER_MAX_MS = 600000;/** What an audit row keeps of a computer command: never a file's content, stdin or arguments (they may hold secrets). */@@@ -300,7 +303,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datconst declared = (conn.status && conn.status.capabilities) || json(conn.device.capabilities, {}) || {};conn.computerAllowed = !robot || !!declared.computer;const base = robot ? domain.control.allowedFor(robot, 'owner', profile) : ['halt'];-- const allowed = conn.computerAllowed ? [...new Set([...base, ...COMPUTER_KINDS])] : base;++ const allowed = conn.computerAllowed ? [...new Set([...base, ...COMPUTER_KINDS, SCREEN_KIND])] : base;sendFrame(conn, 'config', {heartbeat_ms: config.device.heartbeatMs, limits, allowed_commands: allowed,estop_latched: robot ? !!robot.estop_latched : false,@@@ -623,6 +626,17 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat* → { id, result, code?, reason?, latency_ms?, data?, cached? }*/function machineCommand(deviceId, principal, subject, msg) {++ return machineSend(deviceId, principal, subject, { id: msg.id, kind: msg.kind, value: msg.value, ms: msg.ms });++ }++ /**++ * The live screen to the machine (api/v1.js and the web routes, the owner only): `value` is screen_stream's,++ * with the stream's WHIP URL and key on a start. Audited with the action, seconds and fps only, never the URL++ * or the key. → as machineCommand++ */++ function machineScreen(deviceId, principal, subject, value) {++ return machineSend(deviceId, principal, subject, { kind: SCREEN_KIND, value, ms: 20000, screen: true });++ }++ function machineSend(deviceId, principal, subject, msg) {return new Promise((resolve, reject) => {let settled = false;const finish = (r) => { if (!settled) { settled = true; resolve(r); } };@@@ -656,17 +670,26 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Datconst kind = String(msg.kind || '');const value = msg.value && typeof msg.value === 'object' && !Array.isArray(msg.value) ? msg.value : {};const refuse = async (code, reason, summary = null) => {-- await domain.audit.record({ robotId: deviceId, deviceId, subject, operatorKind: conn.principal.kind, role: 'owner', kind: COMPUTER_CAP[kind] ? kind : 'unknown', value: summary || {}, result: 'refused', reason: code });++ await domain.audit.record({ robotId: deviceId, deviceId, subject, operatorKind: conn.principal.kind, role: 'owner', kind: COMPUTER_CAP[kind] || kind === SCREEN_KIND ? kind : 'unknown', value: summary || {}, result: 'refused', reason: code });return sendFrame(conn, 'command_result', { id, result: 'refused', code, reason });};-- if (!COMPUTER_CAP[kind]) return refuse('bot.invalid_input', `kind is ${COMPUTER_KINDS.join(', ')}`);-- const check = validate('bot.command@1', { v: 1, seq: 0, ts: 0, type: 'command', id, kind, value });-- if (!check.valid) return refuse('bot.invalid_input', `invalid ${kind}: ${(check.errors && check.errors[0] && (check.errors[0].message || JSON.stringify(check.errors[0]))) || 'bad value'}`.slice(0, 300));-- const summary = computerSummary(kind, value);++ const screen = msg.screen === true && kind === SCREEN_KIND;++ if (!screen && !COMPUTER_CAP[kind]) return refuse('bot.invalid_input', `kind is ${COMPUTER_KINDS.join(', ')}`);++ if (!screen) {++ const check = validate('bot.command@1', { v: 1, seq: 0, ts: 0, type: 'command', id, kind, value });++ if (!check.valid) return refuse('bot.invalid_input', `invalid ${kind}: ${(check.errors && check.errors[0] && (check.errors[0].message || JSON.stringify(check.errors[0]))) || 'bad value'}`.slice(0, 300));++ }++ const summary = screen++ ? { action: String(value.action), ...(Number.isInteger(value.seconds) ? { seconds: value.seconds } : {}), ...(Number.isInteger(value.fps) ? { fps: value.fps } : {}) }++ : computerSummary(kind, value);const device = deviceConns.get(deviceId);if (!device || !device.online) return refuse('bot.device_offline', 'the machine is offline', summary);const offered = computerOf(deviceId);-- if (!offered || !offered[COMPUTER_CAP[kind]]) return refuse('bot.computer_off', `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);++ if (screen) {++ if (value.action === 'start' && !(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 || !offered[COMPUTER_CAP[kind]]) return refuse('bot.computer_off', `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);const requested = Number(msg.ms);const windowMs = Number.isInteger(requested) && requested > 0 ? Math.min(Math.max(requested, 1000), COMPUTER_MAX_MS) : COMPUTER_DEFAULT_MS;const deviceCmdId = prefixedId('cmd', now());@@@ -900,7 +923,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat/** A one-time ticket for the embedded panel's Drive (POST /panel/:id/ticket), or null. */issueControlTicket(principal, robotId) { return tickets.issue(principal, robotId); },isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, dropSubject, operatorCommand,-- machineCommand, computerOf,++ machineCommand, machineScreen, computerOf,// Real devices only: a simulator is not a machine anyone runs.onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online && !c.sim) n++; return n; },devices() { return [...deviceConns.values()].filter((c) => !c.sim).map((c) => ({ device_id: c.device.id, online: c.online })); },@@@ -394,6 +394,16 @@ ${code(COMPUTER_APPROVE)}<p>On ${inLink('/machines', 'Your machines')}, <b>Let my agent use this computer</b> is off until you turn it on. With it on, a task you give ${out('https://openvibe.actor', 'OpenVibe.Actor')}, such as "what changed in ~/site today?", can list and read files, run programs and write files there, as you and at your level. The ${out('https://openvibe.actor/console', 'Console')} shows what waits for you. Turning the switch off stops it at once.</p><p><b>Let my watches check things from this computer</b> is a separate switch. It lets ${out('https://openvibe.watch', 'OpenVibe.Watch')} probe your own network from the computer for your watches, for example "tell me when my NAS stops answering". It allows only probes: one GET or one TCP connect, to private addresses only.</p></section>++<section class="card" aria-labelledby="screen-c-h"><h2 id="screen-c-h">Watch your screen live</h2>++<p>On ${inLink('/machines', 'Your machines')}, <b>Show my screen</b> shows the computer's screen live, to you only, for 10 minutes (30 seconds to 30 minutes through the API).</p>++<ul>++<li><b>Private.</b> It goes to a private stream of that computer's own: nothing is recorded, it never appears on OpenVibe Live, and only you can open it. Your agents, your watches and anyone else cannot start it.</li>++<li><b>How it is captured.</b> The computer takes a few pictures of its screen a second with the same tools as a screenshot. It needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your desktop session.</li>++<li><b>It says so.</b> The desktop shows a notice when it starts, and <code>openvibe-node status</code> shows it while it runs.</li>++<li><b>Stopping it.</b> <b>Stop showing my screen</b> ends it, and so do its time running out and <code>openvibe-node stop</code> on the computer, at once. To never offer it, set <code>allow_screen_stream: false</code> in the plugin's config.</li>++</ul>++<p>From the API, with your own token: <code>POST /api/v1/machines/:id/screen</code> (<code>seconds</code>, <code>fps</code> from 1 to 5) starts it, <code>GET</code> answers <code>signaling_url</code> once the computer is sending, and <code>DELETE</code> stops it.</p></section>++<section class="card" aria-labelledby="api-c-h"><h2 id="api-c-h">The API</h2><p>Your own token reaches your own computers. The answer comes back in <code>data</code>:</p>${code(COMPUTER_CALL)}@@@ -594,7 +594,8 @@ function renderMachinesPage({ machines = [], pairing = null }) {const c = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).computer;if (!c || typeof c !== 'object') return null;const level = m.computer_level || ((c.files || c.terminal || c.screen || {}).level) || null;-- return { level: LEVELS[level] ? level : null, terminal: !!c.terminal, files: !!c.files, screen: !!c.screen, probe: !!c.probe, input: !!c.input };++ return { 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) };};const computerRow = (m) => {const c = computerOf(m);@@@ -605,6 +606,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>${waiting}`+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`++ + (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.probe ? `<form method="post" action="/machines/${esc(m.id)}/watch-probe"><input type="hidden" name="on" value="${m.watch_probe ? '0' : '1'}">`+ `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from this computer'}</button></form>` : '')+ '</div>';@@@ -639,6 +641,41 @@ ${add}return page('Your machines', body, { scripts: pairing ? ['/panel/panel.js'] : [], description: 'Your own machines that run your jobs on OpenVibe.', canonical: '/machines' });}++/**++ * /machines/:id/screen: the machine's screen, live, for its owner only (plan T14). The tile waits for the machine's++ * session (public/panel.js polls `stateUrl`) and plays it like a robot's camera; Stop ends it at once.++ */++function renderMachineScreenPage({ machine, until = null, error = null, idle = false }) {++ const name = machine.name || machine.id;++ const stateUrl = `/machines/${encodeURIComponent(machine.id)}/screen.json`;++ // Idle (nothing is shown, as from a link): a start button instead of waiting for a stream nobody started.++ if (idle && !error) {++ const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}++<main class="page">++<h1>${esc(name)}: screen</h1>++<p class="muted">Show this computer's screen here, live, to you only, for 10 minutes. Nothing is recorded, and the computer says on its desktop that its screen is live.</p>++<form method="post" action="/machines/${esc(machine.id)}/screen"><button type="submit" class="primary">Show my screen</button></form>++</main>`;++ return page(`${name}: screen`, body, { description: 'Your machine\'s screen, live, for you only.', canonical: '/machines' });++ }++ const ends = until ? `<p class="muted">Shown until <time datetime="${esc(until)}" data-local-time>${esc(until)}</time>, to you only. Nothing is recorded.</p>` : '';++ const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}++<main class="page">++<h1>${esc(name)}: screen</h1>++${error ? `<p class="error" role="alert">${esc(error)}</p>` : ends}++<figure class="camera machine-screen" data-screen-state="${esc(stateUrl)}">++<div class="camera-screen" data-camera-screen>${icon('plug', 'icon camera-icon')}<p class="camera-title" data-screen-status role="status">${error ? 'The screen is not shown.' : 'Waiting for the machine…'}</p>++<p class="camera-sub">The picture comes from the machine itself, a few frames a second.</p></div>++</figure>++<div class="machine-screen-actions">++<form method="post" action="/machines/${esc(machine.id)}/screen/stop"><button type="submit" class="primary">Stop showing my screen</button></form>++<form method="post" action="/machines/${esc(machine.id)}/screen"><button type="submit" class="button quiet">${error ? 'Try again' : 'Start again (10 more minutes)'}</button></form>++</div>++<p class="muted small">The machine says on its desktop that its screen is live. <code>openvibe-node stop</code> on the machine ends it at once, and it ends on its own after its time.</p>++</main>`;++ return page(`${name}: screen`, body, { scripts: ['/panel/panel.js'], description: 'Your machine\'s screen, live, for you only.', canonical: '/machines' });++}++/*** /device/phone: the page that makes a phone the robot's device (public/phone-device.js). Its setup form pairs it (the* QR code fills in the robot and code); once running, the screen is the robot's face, with the link and camera state@@@ -673,4 +710,4 @@ function renderPhonePage() {return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });}--module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };++module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineScreenPage, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };@@@ -36,7 +36,8 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');const { frameAncestors } = require('../config');const { BotError } = require('../util');const kits = require('../kits');--const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage } = require('./render');++const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineScreenPage } = require('./render');++const { createMachineScreen } = require('../machine-screen');const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');const { checkProfile } = require('../profiles');const { renderHome, HOME_CSP } = require('./home');@@@ -438,8 +439,53 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi...d, online: !!(hub && hub.isOnline(d.id)), robot: robot ? robot.name : null,computer_level: typeof sensors.computer_level === 'string' ? sensors.computer_level : null,computer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,++ computer_screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',};}));++ // ── The live screen (plan T14): the owner watches their machine's screen on their own private stream ──++ const machineScreen = createMachineScreen({ domain, hub });++ const myMachine = async (req, me) => {++ 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');++ return mine;++ };++ const asUser = (me) => ({ kind: 'user', sub: me.subject });++ r.post('/machines/:id/screen', ...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 myMachine(req, me);++ try {++ const out = await machineScreen.start(mine, me.subject, asUser(me));++ res.redirect(303, `/machines/${encodeURIComponent(mine.id)}/screen?until=${encodeURIComponent(out.until)}`);++ } catch (e) {++ if (!(e instanceof BotError)) throw e;++ res.setHeader('Content-Security-Policy', PANEL_CSP);++ res.status(e.status).type('html').send(renderMachineScreenPage({ machine: mine, error: e.detail || e.message }));++ }++ }));++ r.get('/machines/:id/screen', page(async (req, res) => {++ const me = requireUser(req);++ const mine = await myMachine(req, me);++ const until = typeof req.query.until === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d{1,3})?Z$/.test(req.query.until) ? req.query.until : null;++ // Opened from a link with nothing shown: offer to start it rather than wait for a stream nobody started.++ const idle = !until && (await machineScreen.state(mine, me.subject)).state !== 'live';++ res.setHeader('Vary', 'Cookie');++ res.setHeader('Content-Security-Policy', PANEL_CSP);++ res.type('html').send(renderMachineScreenPage({ machine: mine, until, idle }));++ }));++ r.get('/machines/:id/screen.json', page(async (req, res) => {++ const me = requireUser(req);++ const mine = await myMachine(req, me);++ res.setHeader('Cache-Control', 'private, no-store');++ res.json(await machineScreen.state(mine, me.subject));++ }));++ r.post('/machines/:id/screen/stop', ...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 myMachine(req, me);++ await machineScreen.stop(mine, me.subject, asUser(me));++ res.redirect(303, '/machines');++ }));r.get('/machines', frameCsp, page(async (req, res) => {const me = requireUser(req);res.setHeader('Vary', 'Cookie');@@@ -475,6 +521,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst mine = (await domain.machines.list(me.subject)).find((d) => d.id === req.params.id);if (!mine) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');await domain.devices.revoke(mine.id);++ await domain.machines.screen.archive(mine, me.subject).catch((e) => log.warn(`[Bot] machine ${mine.id} screen stream: ${e.message}`));if (mine.node_principal) await domain.devices.revokeNode(mine.node_principal).catch((e) => log.warn(`[Bot] machine ${mine.id} on Network: ${e.message}`));if (hub) hub.closeDevice(mine.id, 'revoked');res.redirect(303, '/machines');nnew file mode 100644@@@ -0,0 +1,169 @@++'use strict';++// The live screen (plan T14, contracts 0.182.0): the machine's owner, signed in, watches its screen on their own++// private OpenRestream stream. POST /api/v1/machines/:id/screen makes or rotates the machine's stream (playback++// private, no recording, never on Live) and sends screen_stream start with the WHIP URL and key; GET answers the++// session's viewer URL once the machine publishes; DELETE stops it and retires the key. Nobody else starts it — not++// another person, an agent, a watch or a robot operator — and the generic command route never sends the kind; the++// audit keeps the action, never the URL or key. The web page (/machines/:id/screen) does the same for the owner.++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', BOT_WHIP_BASE: 'https://ingest.openre.test/whip/' } });++ const alex = t.network.newUser('alex');++ const bob = t.network.newUser('bob');++ let seq = 0;++ const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });++ const poll = async (fn, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(30); } return null; };++ const SCREEN = { screen: { level: 'observe', tool: 'grim', stream: true }, files: { level: 'observe', roots: ['/home/alex'] } };++ const seen = [];++++ /** A machine of `owner` whose fake Node answers screen_stream like Node v0.12.0. */++ async function machine(owner, computer = SCREEN) {++ const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });++ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });++ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;++ const config = await c.waitFor((m) => m.type === 'config');++ c.send(frame('status', { capabilities: { computer }, drivers: [], faults: [] }));++ c.send(frame('telemetry', { sensors: { computer_level: 'observe', computer_waiting: 0, computer_running: 0, computer_screen_stream: 'off' } }));++ c.ws.on('message', (raw) => {++ let m; try { m = JSON.parse(raw.toString()); } catch { return; }++ if (m.type !== 'command') return;++ seen.push(m);++ if (m.kind === 'screen_stream' && m.value.action === 'start') {++ c.send(frame('ack', { id: m.id, data: { streaming: true, sid: 'abc', seconds: m.value.seconds, fps: m.value.fps, until_ms: Date.now() + m.value.seconds * 1000 } }));++ } else if (m.kind === 'screen_stream') c.send(frame('ack', { id: m.id, data: { streaming: false, stopped: true } }));++ else c.send(frame('nack', { id: m.id, fault_code: 'unsupported' }));++ });++ // Heartbeats keep it online, as a real Node's do.++ 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, config };++ }++ const screen = (method, deviceId, opts = {}) => t.call(method, `/api/v1/machines/${deviceId}/screen`, opts);++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });++ const form = (p, user, headers = {}) => fetch(t.base + p, { method: 'POST', redirect: 'manual', body: '', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers } });++++ let mine;++ let streamId;++ await check('the owner starts it: a private stream of the machine\'s own, the URL and key go to the machine only', async () => {++ mine = await machine(alex);++ assert.ok(mine.config.allowed_commands.includes('screen_stream'), 'the machine is told it takes screen_stream');++ const r = await screen('POST', mine.deviceId, { user: alex, body: { seconds: 120, fps: 3 } });++ assert.strictEqual(r.status, 201, r.text);++ assert.deepStrictEqual([r.json.streaming, r.json.seconds, r.json.fps, r.json.machine_id], [true, 120, 3, mine.deviceId]);++ assert.ok(Date.parse(r.json.until) > Date.now() + 100000);++ const sent = seen.at(-1);++ assert.strictEqual(sent.kind, 'screen_stream');++ assert.deepStrictEqual([sent.value.action, sent.value.seconds, sent.value.fps], ['start', 120, 3]);++ assert.match(sent.value.publish_key, /^ork_/);++ assert.strictEqual(sent.value.whip_url, `https://ingest.openre.test/whip/${encodeURIComponent(sent.value.publish_key)}`);++ const st = [...t.openre.streams.values()].find((x) => x.owner === alex.subject && x.refs.some((ref) => ref.type === 'machine'));++ assert.ok(st, 'a stream on OpenRestream, as alex');++ streamId = st.id;++ assert.deepStrictEqual([st.playback_visibility, st.recording_mode, st.mirror_to_live, st.protocols], ['private', 'none', false, ['webrtc']]);++ assert.deepStrictEqual(st.refs.map((ref) => [ref.service, ref.type, ref.id]), [['bot', 'machine', mine.deviceId]]);++ assert.strictEqual((await t.db.maybe('SELECT screen_stream_id FROM devices WHERE id = $1', [mine.deviceId])).screen_stream_id, streamId);++ assert.ok(t.openre.admits(sent.value.publish_key), 'OpenRestream admits the key the machine got');++ });++++ await check('the audit keeps the action, never the URL or the key', async () => {++ const rows = await t.db.many("SELECT * FROM command_audit WHERE robot_id = $1 AND kind = 'screen_stream'", [mine.deviceId]);++ assert.deepStrictEqual(rows.map((x) => [x.result, x.value]), [['ack', { action: 'start', seconds: 120, fps: 3 }]]);++ assert.ok(!JSON.stringify(rows).includes('ork_') && !JSON.stringify(rows).includes('whip'));++ });++++ await check('GET answers the viewer URL once the machine publishes', async () => {++ const before = await screen('GET', mine.deviceId, { user: alex });++ assert.deepStrictEqual([before.status, before.json.live, before.json.signaling_url, before.json.state], [200, false, null, 'off']);++ await t.wait(600); // telemetry samples are kept at most twice a second++ mine.c.send(frame('telemetry', { sensors: { computer_level: 'observe', computer_screen_stream: 'live' } }));++ const session = t.openre.goLive(streamId);++ const live = await poll(async () => { const r = await screen('GET', mine.deviceId, { user: alex }); return r.json.live ? r : null; });++ assert.ok(live, 'live');++ assert.strictEqual(live.json.signaling_url, `ws://127.0.0.1:9936/w/${session.id}`);++ });++++ await check('a new start rotates the key and ends the open session; the old key stops working', async () => {++ const oldKey = seen.filter((m) => m.kind === 'screen_stream').at(-1).value.publish_key;++ const r = await screen('POST', mine.deviceId, { user: alex, body: {} });++ assert.deepStrictEqual([r.status, r.json.seconds, r.json.fps], [201, 600, 2], `ten minutes at 2 frames a second by default: ${r.text}`);++ const newKey = seen.at(-1).value.publish_key;++ assert.notStrictEqual(newKey, oldKey);++ assert.ok(!t.openre.admits(oldKey) && t.openre.admits(newKey));++ const rotate = t.openre.calls.filter((c) => c.path === `/api/v1/streams/${streamId}/keys/rotate`).at(-1);++ assert.deepStrictEqual(rotate.body, { grace_seconds: 0, end_sessions: true });++ assert.strictEqual([...t.openre.streams.values()].filter((x) => x.owner === alex.subject).length, 1, 'still one stream per machine');++ });++++ await check('nobody else starts it, and no command route sends the kind', async () => {++ assert.strictEqual((await screen('POST', mine.deviceId, { user: bob })).status, 404);++ assert.strictEqual((await screen('GET', mine.deviceId, { user: bob })).status, 404);++ const agent = await screen('POST', mine.deviceId, { sub: 'svc:actor', cap: ['bot.computer.control'], headers: { 'X-OV-Subject': alex.subject } });++ assert.strictEqual(agent.status, 403, 'not the owner\'s agent either');++ const viaCommands = await t.call('POST', `/api/v1/machines/${mine.deviceId}/commands`,++ { user: alex, body: { kind: 'screen_stream', value: { action: 'start', whip_url: 'https://evil.test/whip/x', publish_key: 'x' } } });++ assert.deepStrictEqual([viaCommands.status, viaCommands.json.code], [422, 'bot.invalid_input']);++ assert.ok(!seen.some((m) => m.value && m.value.whip_url === 'https://evil.test/whip/x'), 'it never reached the machine');++ assert.strictEqual((await screen('POST', mine.deviceId, { user: alex, body: { seconds: 5 } })).status, 422, 'seconds from 30 to 1800');++ assert.strictEqual((await screen('POST', mine.deviceId, { user: alex, body: { fps: 30 } })).status, 422, 'fps from 1 to 5');++ });++++ await check('a machine without the live screen is refused before a key is made', async () => {++ const old = await machine(bob, { screen: { level: 'observe', tool: 'gnome-screenshot', stream: false } });++ const before = t.openre.calls.length;++ const r = await screen('POST', old.deviceId, { user: bob });++ assert.deepStrictEqual([r.status, r.json.code], [409, 'bot.computer_off']);++ assert.match(r.json.detail, /v0\.12\.0/);++ const made = t.openre.calls.slice(before).filter((c) => c.method === 'POST' && c.path === '/api/v1/streams');++ assert.strictEqual(made.length, 0, 'refused before any stream or key is made');++ old.c.close();++ });++++ await check('DELETE stops it on the machine and retires the key', async () => {++ const key = seen.filter((m) => m.kind === 'screen_stream' && m.value.action === 'start').at(-1).value.publish_key;++ const r = await screen('DELETE', mine.deviceId, { user: alex });++ assert.deepStrictEqual([r.status, r.json.stopped], [200, true]);++ assert.deepStrictEqual(seen.at(-1).value, { action: 'stop' });++ assert.ok(!t.openre.admits(key));++ });++++ await check('the web page: Show my screen, the viewer page with the panel\'s CSP, its state, Stop', async () => {++ const list = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();++ assert.ok(list.includes(`action="/machines/${mine.deviceId}/screen"`) && list.includes('Watch my screen'), 'the machine offers it (Watch: its telemetry says the screen is live)');++ const started = await form(`/machines/${mine.deviceId}/screen`, alex);++ assert.strictEqual(started.status, 303);++ const where = started.headers.get('location');++ assert.match(where, new RegExp(`^/machines/${mine.deviceId}/screen\\?until=\\d{4}-`));++ const page = await fetch(t.base + where, { headers: cookie(alex) });++ assert.strictEqual(page.status, 200);++ assert.match(page.headers.get('content-security-policy'), /connect-src 'self' wss:\/\/ingest\.openre\.stream/);++ const html = await page.text();++ assert.ok(html.includes(`data-screen-state="/machines/${mine.deviceId}/screen.json"`) && html.includes('/panel/panel.js'));++ const state = await fetch(`${t.base}/machines/${mine.deviceId}/screen.json`, { headers: cookie(alex) });++ assert.strictEqual(state.status, 200);++ assert.strictEqual(state.headers.get('cache-control'), 'private, no-store');++ assert.strictEqual((await fetch(`${t.base}/machines/${mine.deviceId}/screen.json`, { headers: cookie(bob) })).status, 404);++ assert.strictEqual((await form(`/machines/${mine.deviceId}/screen`, alex, { Origin: 'https://evil.test' })).status, 403, 'no cross-site post');++ const stopped = await form(`/machines/${mine.deviceId}/screen/stop`, alex);++ await t.wait(600); // telemetry samples are kept at most twice a second++ mine.c.send(frame('telemetry', { sensors: { computer_level: 'observe', computer_screen_stream: 'off' } }));++ const idle = await poll(async () => {++ const html = await (await fetch(`${t.base}/machines/${mine.deviceId}/screen`, { headers: cookie(alex) })).text();++ return !html.includes('data-screen-state') ? html : null;++ });++ assert.ok(idle && idle.includes(`action="/machines/${mine.deviceId}/screen"`), 'opened from a link with nothing shown, it offers to start');++ assert.deepStrictEqual([stopped.status, stopped.headers.get('location')], [303, '/machines']);++ assert.deepStrictEqual(seen.at(-1).value, { action: 'stop' });++ });++++ await check('removing the machine archives its screen stream', async () => {++ const r = await form(`/machines/${mine.deviceId}/remove`, alex);++ assert.strictEqual(r.status, 303);++ assert.ok(t.openre.calls.some((c) => c.method === 'DELETE' && c.path === `/api/v1/streams/${streamId}`));++ });++++ mine.c.close();++ await done(t);++})();
@@ -43,6 +43,13 @@ Sitemap entries use the release commit time for these code-backed pages.
@@ -43,6 +43,13 @@ Sitemap entries use the release commit time for these code-backed pages.
- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe.Node `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe.Node `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
- A device (the machine that serves a robot) pairs with one pasted command, shown on the robot's pairing page: `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX [--driver …]` (the Network form in `docs/protocol.md` §1). The `--driver` follows the robot's profile: `adeept` for `adeept.adr036`, `adeept-mecanum` for `adeept.adr036.mecanum`, `cozmo` for `cozmo`, `looi` for `looi` (the community's Generic LOOI driver, a Bluetooth bridge; OpenVibe.Node `docs/hardware-looi.md`); `ros2` for `ros2.diffdrive` and `ros2-holonomic` for `ros2.holonomic` (any ROS 2 robot on `cmd_vel`, through rosbridge or rclpy; OpenVibe.Node `plugins/ros2`); any other profile gets none (`server/domain/index.js` `driverForProfile`), the installer's dry-run `none`.
- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.bot/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).
- **A person's own machine that runs their jobs** (plan T14/T16) serves no robot. `openvibe.bot/machines` lists the person's machines (online, the job classes they run, the robot they serve if any), adds one with a one-time command, `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --network <Network> --pairing pair_… --code XXXX-XXXX --worker` (a Network pairing with ref `worker:<owner>`, so the device keeps `owner_subject` and has no robot; migration 0013), and removes one (revoked here and on Network). Every live machine with a job worker, a robot's included, is reported to Network as a `user-owned` resource offer naming its owner (`server/jobs/offers.js`: one offer per device, `worker:<class>` per class, `up` while connected and `down` otherwise; the whole set as source `bot`, once a minute and two seconds after a device comes, goes or changes what it advertises). Run places a person's job only on that person's machines, and the dispatcher refuses any other (`bot.not_owner`).
- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe.Node's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-node approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. 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 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 command.** `screen_stream` start goes to the machine with that stream's WHIP URL and key. It is not a `bot.command@1` kind, so the commands route, agents, watches and operators can never send it or choose where the screen goes.
+ - **The audit** keeps the action, seconds and fps, never the URL or key.
+ - **The viewer.** `GET` answers the session's viewer `signaling_url` once the machine publishes, and the page plays it with the panel's WebRTC viewer.
+ - **Stop.** `DELETE` (or Stop) tells the machine to stop and retires the key. Removing a machine archives its stream.
+ - **Refused** with `bot.computer_off` when the machine does not offer it (`screen.stream`), and no key is made.
- A server-side ONVIF camera (`camera.onvif`) is not installed on a machine: it gets no `--driver` and Bot attaches it itself from `BOT_ONVIF_CAMERAS` (see Deploy), so its panel works as soon as the camera answers.
- A server-side ONVIF camera (`camera.onvif`) is not installed on a machine: it gets no `--driver` and Bot attaches it itself from `BOT_ONVIF_CAMERAS` (see Deploy), so its panel works as soon as the camera answers.
- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe.Node's `docs/hardware-adeept.md` (T15 step 1).
- The robot's panel is `openvibe.bot/panel/<rob_…>` (`GET /panel/:id`, rendered from the profile; `docs/protocol.md` §3). The device-side bring-up steps for the Adeept ADR036 kit live with the driver, in OpenVibe.Node's `docs/hardware-adeept.md` (T15 step 1).
- A robot can use its own `local.rob_…` profile, stored with that robot and validated against `bot.robot-profile@1` and Bot's capability, driver and widget registries. On the owner's robot page, the Buttons form adds or removes rows and saves each button's name, label, optional keyboard key, cooldown in milliseconds and hold setting. The Video click checkbox enables a separate point cooldown. Only the owner can save or return to the robot's catalogue profile; each change is audited.
- A robot can use its own `local.rob_…` profile, stored with that robot and validated against `bot.robot-profile@1` and Bot's capability, driver and widget registries. On the owner's robot page, the Buttons form adds or removes rows and saves each button's name, label, optional keyboard key, cooldown in milliseconds and hold setting. The Video click checkbox enables a separate point cooldown. Only the owner can save or return to the robot's catalogue profile; each change is audited.
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- Plan T14 (contracts 0.182.0): the machine's own private OpenRestream stream that shows its screen live to its owner
+-- (playback private, never recorded or mirrored to Live; external ref bot:machine:<id>). Null until the owner first
+-- shows the screen; each start rotates the stream's key.
+ALTER TABLE devices ADD COLUMN screen_stream_id text;
@@ -349,3 +349,8 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -349,3 +349,8 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.days { display: flex; flex-wrap: wrap; gap: 0.3rem 0.8rem; }
.days { display: flex; flex-wrap: wrap; gap: 0.3rem 0.8rem; }
.secret-once { padding: 0.6rem 0.8rem; border-radius: 10px; background: color-mix(in srgb, var(--ok) 14%, transparent); overflow-wrap: anywhere; }
.secret-once { padding: 0.6rem 0.8rem; border-radius: 10px; background: color-mix(in srgb, var(--ok) 14%, transparent); overflow-wrap: anywhere; }
.setting-note code { font-size: 0.8em; }
.setting-note code { font-size: 0.8em; }
+
+/* A machine's live screen (/machines/:id/screen): the camera tile at the page's width, with Stop under it. */
+.machine-screen { margin: 1rem 0 0.75rem; border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
+.machine-screen-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; margin-bottom: 0.75rem; }
+.machine-screen-actions form { margin: 0; }
@@ -555,6 +555,7 @@
@@ -555,6 +555,7 @@
let current = null; // the session being connected: { ws, transport, consumers }
let current = null; // the session being connected: { ws, transport, consumers }
let timer = null; // the pending reconnect
let timer = null; // the pending reconnect
let attempt = 0;
let attempt = 0;
+ let stopped = false; // stop(): no more reconnects (a machine's screen that ended)
/** The tile shows the video or its placeholder, never both. */
/** The tile shows the video or its placeholder, never both. */
function showLive(on) {
function showLive(on) {
@@ -572,7 +573,7 @@
@@ -572,7 +573,7 @@
}
}
/** The placeholder returns, the session closes, and the next attempt is a backoff away. */
/** The placeholder returns, the session closes, and the next attempt is a backoff away. */
function retry() {
function retry() {
- if (timer) return;
+ if (timer || stopped) return;
showLive(false);
showLive(false);
teardown();
teardown();
timer = setTimeout(() => { timer = null; connect(); }, Math.min(10000, 500 * 2 ** attempt++));
timer = setTimeout(() => { timer = null; connect(); }, Math.min(10000, 500 * 2 ** attempt++));
@@ -632,9 +633,51 @@
@@ -632,9 +633,51 @@
}
}
}
}
connect();
connect();
+ return {
+ stop() {
+ stopped = true;
+ if (timer) { clearTimeout(timer); timer = null; }
+ showLive(false);
+ teardown();
+ },
+ };
}
}
for (const tile of $$('[data-signaling-url]')) liveCamera(tile);
for (const tile of $$('[data-signaling-url]')) liveCamera(tile);
+ // ── A machine's live screen (/machines/:id/screen): wait until the machine publishes, then play it as a camera ──
+ for (const time of $$('time[data-local-time]')) {
+ const at = new Date(time.getAttribute('datetime'));
+ if (!Number.isNaN(at.getTime())) time.textContent = at.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
+ }
+ for (const tile of $$('[data-screen-state]')) {
+ const status = $('[data-screen-status]', tile);
+ let playing = null;
+ let misses = 0;
+ const say = (text) => { if (status) status.textContent = text; };
+ const poll = async () => {
+ let s = null;
+ try {
+ const r = await fetch(tile.dataset.screenState, { headers: { Accept: 'application/json' }, credentials: 'same-origin', cache: 'no-store' });
+ s = r.ok ? await r.json() : null;
+ } catch { s = null; }
+ if (s && s.signaling_url && !playing) {
+ tile.dataset.signalingUrl = s.signaling_url;
+ say('Connecting to the screen…');
+ playing = liveCamera(tile);
+ } else if (playing && s && s.state === 'off') {
+ // The machine stopped (its time is up, Stop, or its kill switch): leave the last word on the page.
+ playing.stop();
+ say('Your screen is no longer shown.');
+ return;
+ } else if (!playing) {
+ misses += 1;
+ say(s && s.online === false ? 'The machine is offline.' : misses > 20 ? 'Still waiting for the machine. Is the Node running in your desktop session?' : 'Waiting for the machine…');
+ }
+ setTimeout(poll, playing ? 5000 : 1500);
+ };
+ if (status && !$('.error', document)) poll();
+ }
+
// ── State ─────────────────────────────────────────────────────────────────────────────────────────
// ── State ─────────────────────────────────────────────────────────────────────────────────────────
function enableControls() {
function enableControls() {
for (const section of $$('[data-command]')) {
for (const section of $$('[data-command]')) {
@@ -38,6 +38,7 @@ const express = require('express');
@@ -38,6 +38,7 @@ const express = require('express');
const { parseSteps, stepHelp, AGENT_MAX_STEPS, AGENT_MAX_S } = require('../routines');
const { parseSteps, stepHelp, AGENT_MAX_STEPS, AGENT_MAX_S } = require('../routines');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const { fail, userSubject, isRobotId, json } = require('../util');
const { fail, userSubject, isRobotId, json } = require('../util');
+const { createMachineScreen } = require('../machine-screen');
const { getProfile, listProfiles, checkProfile } = require('../profiles');
const { getProfile, listProfiles, checkProfile } = require('../profiles');
const kits = require('../kits');
const kits = require('../kits');
const resourceIndex = require('./resource-index');
const resourceIndex = require('./resource-index');
@@ -71,6 +72,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -71,6 +72,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
r.use('/resources', resourceIndex.router({ db: domain.db, apiAuth }));
r.use('/resources', resourceIndex.router({ db: domain.db, apiAuth }));
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 });
// The command route counts against the acted-for person, not the service token: a bound channel's chat
// The command route counts against the acted-for person, not the service token: a bound channel's chat
// forwards many viewers' commands through one service, and each viewer gets their own bucket. A service
// forwards many viewers' commands through one service, and each viewer gets their own bucket. A service
// is also capped by its own token (bot.control.service, 10× the per-person bucket): the subject is only
// is also capped by its own token (bot.control.service, 10× the per-person bucket): the subject is only
@@ -432,6 +434,26 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -432,6 +434,26 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
if (result.result === 'refused') fail(commandRefusalStatus(result.code), result.code, result.reason || null);
if (result.result === 'refused') fail(commandRefusalStatus(result.code), result.code, result.reason || null);
res.json({ machine_id: device.id, ...result });
res.json({ machine_id: device.id, ...result });
}));
}));
+ // The live screen (plan T14): the machine's owner, signed in as themselves, watches it on their own private stream.
+ // Never an agent, a watch or another service: screen_stream is not a bot.command@1 kind, and only this route sends it.
+ const screenOwner = async (req) => {
+ if (req.principal.kind !== 'user') fail(403, 'bot.forbidden', 'only the machine\'s owner, signed in, shows its screen');
+ return computerMachine(req, { agentNeedsOptIn: false });
+ };
+ r.post('/machines/:id/screen', manage, wrap(async (req, res) => {
+ const { device, subject } = await screenOwner(req);
+ const out = await machineScreen.start(device, subject, req.principal, { seconds: req.body?.seconds, fps: req.body?.fps });
+ res.status(201).json({ machine_id: device.id, ...out });
+ }));
+ r.get('/machines/:id/screen', wrap(async (req, res) => {
+ const { device, subject } = await screenOwner(req);
+ res.set('Cache-Control', 'no-store');
+ res.json({ machine_id: device.id, ...(await machineScreen.state(device, subject)) });
+ }));
+ r.delete('/machines/:id/screen', manage, wrap(async (req, res) => {
+ const { device, subject } = await screenOwner(req);
+ res.json({ machine_id: device.id, ...(await machineScreen.stop(device, subject, req.principal)) });
+ }));
// The owner lets their watches probe from the machine, or stops them (never Watch itself).
// The owner lets their watches probe from the machine, or stops them (never Watch itself).
r.post('/machines/:id/watch-probe', manage, wrap(async (req, res) => {
r.post('/machines/:id/watch-probe', manage, wrap(async (req, res) => {
if (req.principal.kind !== 'user') fail(403, 'bot.forbidden', 'only the machine\'s owner turns this on or off');
if (req.principal.kind !== 'user') fail(403, 'bot.forbidden', 'only the machine\'s owner turns this on or off');
@@ -674,6 +674,65 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -674,6 +674,65 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
SELECT 1 FROM robots r WHERE r.owner_subject = $1 AND d.robot_ids @> jsonb_build_array(r.id))))
SELECT 1 FROM robots r WHERE r.owner_subject = $1 AND d.robot_ids @> jsonb_build_array(r.id))))
ORDER BY d.created_at DESC`, [subject]);
ORDER BY d.created_at DESC`, [subject]);
}
}
+ // ── The live screen (plan T14, contracts 0.182.0) ──────────────────────────────────────────────────────
+ /**
+ * A new ingest key for the machine's own screen stream on OpenRestream, as its owner → { streamId, key, whipUrl }.
+ * One private stream per machine (external ref bot:machine:<id>): playback private, no recording, never on Live.
+ * Each start rotates the key and ends a session still open, so only the newest start publishes. Bot keeps the
+ * stream id, never the key, which goes once to the machine in its start command. Throws 503 without OpenRestream
+ * or a WHIP ingest.
+ */
+ async function screenKey(device, owner) {
+ if (!openre || !whipUrl('x')) fail(503, 'bot.openre_unavailable', 'OpenRestream is not configured here, so the screen cannot be shown');
+ const rotated = async (streamId) => {
+ const r = await openre.rotate(streamId, owner, { grace_seconds: 0, end_sessions: true });
+ return r && { streamId, key: r.key };
+ };
+ let out = device.screen_stream_id ? await rotated(device.screen_stream_id) : null;
+ if (!out) {
+ const found = await openre.find(`bot:machine:${device.id}`, owner);
+ if (found && found.id) out = await rotated(found.id);
+ }
+ if (!out) {
+ const label = String(device.name || device.id).slice(0, 100);
+ const created = await openre.create({
+ title: `${label} screen`, protocols: ['webrtc'], recording_mode: 'none', recording_visibility: 'private',
+ playback_visibility: 'private', mirror_to_live: false,
+ external_refs: [{ service: 'bot', type: 'machine', id: device.id, label }],
+ }, owner);
+ out = created && created.stream && { streamId: created.stream.id, key: created.key };
+ }
+ if (!out || typeof out.streamId !== 'string' || !out.key || typeof out.key.key !== 'string' || !out.key.key) {
+ fail(503, 'bot.openre_unavailable', 'OpenRestream answered no ingest key for the screen');
+ }
+ if (out.streamId !== device.screen_stream_id) {
+ await db.query('UPDATE devices SET screen_stream_id = $2, updated_at = $3 WHERE id = $1', [device.id, out.streamId, iso(now())]);
+ }
+ return { streamId: out.streamId, key: out.key.key, whipUrl: whipUrl(out.key.key) };
+ }
+ /** The viewer signaling URL of the machine's open screen session, as its owner, or null (none is live yet). */
+ async function screenLive(device, owner) {
+ if (!openre || !device.screen_stream_id) return null;
+ const open = await openre.sessions(device.screen_stream_id, owner, { state: 'open' });
+ const session = open.find((x) => x && x.protocol === 'webrtc') || null;
+ if (!session) return null;
+ const descriptor = await openre.playback(session.id, owner);
+ const url = descriptor && descriptor.webrtc && descriptor.webrtc.signaling_url;
+ return typeof url === 'string' && /^wss?:\/\//.test(url) ? url : null;
+ }
+ /** Stop: retire the screen stream's key and end its session at once → whether there was a stream. */
+ async function screenEnd(device, owner) {
+ if (!openre || !device.screen_stream_id) return false;
+ await openre.rotate(device.screen_stream_id, owner, { grace_seconds: 0, end_sessions: true });
+ return true;
+ }
+ /** A machine removed: its screen stream goes too (archived on OpenRestream). */
+ async function screenArchive(device, owner) {
+ if (!openre || !device.screen_stream_id) return false;
+ await openre.archive(device.screen_stream_id, owner);
+ await db.query('UPDATE devices SET screen_stream_id = NULL WHERE id = $1', [device.id]);
+ return true;
+ }
/** Let the owner's watches probe from this machine, or stop them (the owner check is the caller's). */
/** Let the owner's watches probe from this machine, or stop them (the owner check is the caller's). */
async function setWatchProbe(deviceId, value) {
async function setWatchProbe(deviceId, value) {
return db.maybe('UPDATE devices SET watch_probe = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
return db.maybe('UPDATE devices SET watch_probe = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
@@ -1099,7 +1158,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1099,7 +1158,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, 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, setWatchProbe },
+ machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setWatchProbe,
+ screen: { key: screenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
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 },
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+'use strict';
+/**
+ * The live screen (plan T14, contracts 0.182.0): a machine's owner watches its screen on their own private OpenRestream
+ * stream. Used by the API (POST/GET/DELETE /api/v1/machines/:id/screen) and the web page (/machines/:id/screen), for
+ * the owner signed in as themselves only — never an agent, a watch or another service.
+ *
+ * start(device, subject, principal, { seconds, fps }) a new key for the machine's stream (domain.machines.screen),
+ * then screen_stream start to the machine with the WHIP URL
+ * and key → { streaming, seconds, fps, until }
+ * state(device, subject) → { live, signaling_url, state, stream } (the session's viewer
+ * URL once the machine is publishing; `state` the machine's own
+ * telemetry: live or off)
+ * stop(device, subject, principal) screen_stream stop to the machine (when online) and the
+ * stream's key retired with its session → { stopped }
+ *
+ * OpenVibe.Node's computer plugin captures the frames and its core publishes them (Node v0.12.0, docs/computer.md).
+ */
+const { BotError } = require('./util');
+
+const SECONDS = { min: 30, max: 1800, default: 600 };
+const FPS = { min: 1, max: 5, default: 2 };
+
+function whole(v, { min, max, default: dflt }, name) {
+ if (v == null || v === '') return dflt;
+ const n = Number(v);
+ if (!Number.isInteger(n) || n < min || n > max) throw new BotError(422, 'bot.invalid_input', `${name} is a whole number from ${min} to ${max}`);
+ return n;
+}
+
+function createMachineScreen({ domain, hub, now = () => Date.now() }) {
+ const unavailable = () => new BotError(503, 'bot.unavailable', 'machines are not connected here');
+ return {
+ limits: { SECONDS, FPS },
+ async start(device, subject, principal, opts = {}) {
+ if (!hub) throw unavailable();
+ const seconds = whole(opts.seconds, SECONDS, 'seconds');
+ const fps = whole(opts.fps, FPS, 'fps');
+ if (!hub.isOnline(device.id)) throw new BotError(409, 'bot.device_offline', 'the machine is offline');
+ // Before any key is made: the machine says whether it can (Node v0.12.0's computer plugin, a capture tool).
+ const offered = hub.computerOf(device.id);
+ if (!(offered && offered.screen && offered.screen.stream === true)) {
+ throw new BotError(409, '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');
+ }
+ const key = await domain.machines.screen.key(device, subject);
+ const r = await hub.machineScreen(device.id, principal, subject, { action: 'start', whip_url: key.whipUrl, publish_key: key.key, seconds, fps });
+ if (r.result !== 'ack') {
+ const code = r.code || (r.result === 'expired' ? 'bot.device_timeout' : 'bot.device_refused');
+ const status = r.result === 'refused' ? (code === 'bot.computer_off' || code === 'bot.device_offline' ? 409 : 422) : r.result === 'expired' ? 504 : 409;
+ throw new BotError(status, code.startsWith('bot.') ? code : 'bot.device_refused', `the machine did not start showing its screen: ${r.reason || r.result}`.slice(0, 300));
+ }
+ const data = r.data && typeof r.data === 'object' ? r.data : {};
+ const until = Number.isFinite(data.until_ms) ? new Date(data.until_ms).toISOString() : new Date(now() + seconds * 1000).toISOString();
+ return { streaming: true, seconds, fps, until };
+ },
+ async state(device, subject) {
+ const sensors = hub ? ((hub.deviceState(device.id).telemetry || {}).sensors || {}) : {};
+ const state = sensors.computer_screen_stream === 'live' ? 'live' : 'off';
+ const url = state === 'live' ? await domain.machines.screen.live(device, subject) : null;
+ return { live: !!url, signaling_url: url, state, online: !!(hub && hub.isOnline(device.id)) };
+ },
+ async stop(device, subject, principal) {
+ let told = false;
+ if (hub && hub.isOnline(device.id)) {
+ const r = await hub.machineScreen(device.id, principal, subject, { action: 'stop' });
+ told = r.result === 'ack';
+ }
+ const ended = await domain.machines.screen.end(device, subject);
+ return { stopped: told || ended };
+ },
+ };
+}
+
+module.exports = { createMachineScreen, SECONDS, FPS };
@@ -41,6 +41,9 @@ const DEVICE_MAX_PAYLOAD = 640 * 1024;
@@ -41,6 +41,9 @@ const DEVICE_MAX_PAYLOAD = 640 * 1024;
// status, and the reply window (the owner's approval on the machine plus the program's own run).
// status, and the reply window (the owner's approval on the machine plus the program's own run).
const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser' };
const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser' };
const COMPUTER_KINDS = Object.keys(COMPUTER_CAP);
const COMPUTER_KINDS = Object.keys(COMPUTER_CAP);
+// The live screen (contracts 0.182.0): a device kind only Bot sends (machineScreen), with the machine's own private
+// stream filled in. It is not one of bot.command@1's kinds, so no operator, agent or service can send it.
+const SCREEN_KIND = 'screen_stream';
const COMPUTER_DEFAULT_MS = 60000;
const COMPUTER_DEFAULT_MS = 60000;
const COMPUTER_MAX_MS = 600000;
const COMPUTER_MAX_MS = 600000;
/** What an audit row keeps of a computer command: never a file's content, stdin or arguments (they may hold secrets). */
/** What an audit row keeps of a computer command: never a file's content, stdin or arguments (they may hold secrets). */
@@ -300,7 +303,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -300,7 +303,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
const declared = (conn.status && conn.status.capabilities) || json(conn.device.capabilities, {}) || {};
const declared = (conn.status && conn.status.capabilities) || json(conn.device.capabilities, {}) || {};
conn.computerAllowed = !robot || !!declared.computer;
conn.computerAllowed = !robot || !!declared.computer;
const base = robot ? domain.control.allowedFor(robot, 'owner', profile) : ['halt'];
const base = robot ? domain.control.allowedFor(robot, 'owner', profile) : ['halt'];
- const allowed = conn.computerAllowed ? [...new Set([...base, ...COMPUTER_KINDS])] : base;
+ const allowed = conn.computerAllowed ? [...new Set([...base, ...COMPUTER_KINDS, SCREEN_KIND])] : base;
sendFrame(conn, 'config', {
sendFrame(conn, 'config', {
heartbeat_ms: config.device.heartbeatMs, limits, allowed_commands: allowed,
heartbeat_ms: config.device.heartbeatMs, limits, allowed_commands: allowed,
estop_latched: robot ? !!robot.estop_latched : false,
estop_latched: robot ? !!robot.estop_latched : false,
@@ -623,6 +626,17 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -623,6 +626,17 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
* → { id, result, code?, reason?, latency_ms?, data?, cached? }
* → { id, result, code?, reason?, latency_ms?, data?, cached? }
*/
*/
function machineCommand(deviceId, principal, subject, msg) {
function machineCommand(deviceId, principal, subject, msg) {
+ return machineSend(deviceId, principal, subject, { id: msg.id, kind: msg.kind, value: msg.value, ms: msg.ms });
+ }
+ /**
+ * The live screen to the machine (api/v1.js and the web routes, the owner only): `value` is screen_stream's,
+ * with the stream's WHIP URL and key on a start. Audited with the action, seconds and fps only, never the URL
+ * or the key. → as machineCommand
+ */
+ function machineScreen(deviceId, principal, subject, value) {
+ return machineSend(deviceId, principal, subject, { kind: SCREEN_KIND, value, ms: 20000, screen: true });
+ }
+ function machineSend(deviceId, principal, subject, msg) {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
let settled = false;
let settled = false;
const finish = (r) => { if (!settled) { settled = true; resolve(r); } };
const finish = (r) => { if (!settled) { settled = true; resolve(r); } };
@@ -656,17 +670,26 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -656,17 +670,26 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
const kind = String(msg.kind || '');
const kind = String(msg.kind || '');
const value = msg.value && typeof msg.value === 'object' && !Array.isArray(msg.value) ? msg.value : {};
const value = msg.value && typeof msg.value === 'object' && !Array.isArray(msg.value) ? msg.value : {};
const refuse = async (code, reason, summary = null) => {
const refuse = async (code, reason, summary = null) => {
- await domain.audit.record({ robotId: deviceId, deviceId, subject, operatorKind: conn.principal.kind, role: 'owner', kind: COMPUTER_CAP[kind] ? kind : 'unknown', value: summary || {}, result: 'refused', reason: code });
+ await domain.audit.record({ robotId: deviceId, deviceId, subject, operatorKind: conn.principal.kind, role: 'owner', kind: COMPUTER_CAP[kind] || kind === SCREEN_KIND ? kind : 'unknown', value: summary || {}, result: 'refused', reason: code });
return sendFrame(conn, 'command_result', { id, result: 'refused', code, reason });
return sendFrame(conn, 'command_result', { id, result: 'refused', code, reason });
};
};
- if (!COMPUTER_CAP[kind]) return refuse('bot.invalid_input', `kind is ${COMPUTER_KINDS.join(', ')}`);
- const check = validate('bot.command@1', { v: 1, seq: 0, ts: 0, type: 'command', id, kind, value });
- if (!check.valid) return refuse('bot.invalid_input', `invalid ${kind}: ${(check.errors && check.errors[0] && (check.errors[0].message || JSON.stringify(check.errors[0]))) || 'bad value'}`.slice(0, 300));
- const summary = computerSummary(kind, value);
+ const screen = msg.screen === true && kind === SCREEN_KIND;
+ if (!screen && !COMPUTER_CAP[kind]) return refuse('bot.invalid_input', `kind is ${COMPUTER_KINDS.join(', ')}`);
+ if (!screen) {
+ const check = validate('bot.command@1', { v: 1, seq: 0, ts: 0, type: 'command', id, kind, value });
+ if (!check.valid) return refuse('bot.invalid_input', `invalid ${kind}: ${(check.errors && check.errors[0] && (check.errors[0].message || JSON.stringify(check.errors[0]))) || 'bad value'}`.slice(0, 300));
+ }
+ const summary = screen
+ ? { action: String(value.action), ...(Number.isInteger(value.seconds) ? { seconds: value.seconds } : {}), ...(Number.isInteger(value.fps) ? { fps: value.fps } : {}) }
+ : computerSummary(kind, value);
const device = deviceConns.get(deviceId);
const device = deviceConns.get(deviceId);
if (!device || !device.online) return refuse('bot.device_offline', 'the machine is offline', summary);
if (!device || !device.online) return refuse('bot.device_offline', 'the machine is offline', summary);
const offered = computerOf(deviceId);
const offered = computerOf(deviceId);
- if (!offered || !offered[COMPUTER_CAP[kind]]) return refuse('bot.computer_off', `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);
+ if (screen) {
+ if (value.action === 'start' && !(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 || !offered[COMPUTER_CAP[kind]]) return refuse('bot.computer_off', `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);
const requested = Number(msg.ms);
const requested = Number(msg.ms);
const windowMs = Number.isInteger(requested) && requested > 0 ? Math.min(Math.max(requested, 1000), COMPUTER_MAX_MS) : COMPUTER_DEFAULT_MS;
const windowMs = Number.isInteger(requested) && requested > 0 ? Math.min(Math.max(requested, 1000), COMPUTER_MAX_MS) : COMPUTER_DEFAULT_MS;
const deviceCmdId = prefixedId('cmd', now());
const deviceCmdId = prefixedId('cmd', now());
@@ -900,7 +923,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
@@ -900,7 +923,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
/** A one-time ticket for the embedded panel's Drive (POST /panel/:id/ticket), or null. */
/** A one-time ticket for the embedded panel's Drive (POST /panel/:id/ticket), or null. */
issueControlTicket(principal, robotId) { return tickets.issue(principal, robotId); },
issueControlTicket(principal, robotId) { return tickets.issue(principal, robotId); },
isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, dropSubject, operatorCommand,
isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, dropSubject, operatorCommand,
- machineCommand, computerOf,
+ machineCommand, machineScreen, computerOf,
// Real devices only: a simulator is not a machine anyone runs.
// Real devices only: a simulator is not a machine anyone runs.
onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online && !c.sim) n++; return n; },
onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online && !c.sim) n++; return n; },
devices() { return [...deviceConns.values()].filter((c) => !c.sim).map((c) => ({ device_id: c.device.id, online: c.online })); },
devices() { return [...deviceConns.values()].filter((c) => !c.sim).map((c) => ({ device_id: c.device.id, online: c.online })); },
@@ -394,6 +394,16 @@ ${code(COMPUTER_APPROVE)}
@@ -394,6 +394,16 @@ ${code(COMPUTER_APPROVE)}
<p>On ${inLink('/machines', 'Your machines')}, <b>Let my agent use this computer</b> is off until you turn it on. With it on, a task you give ${out('https://openvibe.actor', 'OpenVibe.Actor')}, such as "what changed in ~/site today?", can list and read files, run programs and write files there, as you and at your level. The ${out('https://openvibe.actor/console', 'Console')} shows what waits for you. Turning the switch off stops it at once.</p>
<p>On ${inLink('/machines', 'Your machines')}, <b>Let my agent use this computer</b> is off until you turn it on. With it on, a task you give ${out('https://openvibe.actor', 'OpenVibe.Actor')}, such as "what changed in ~/site today?", can list and read files, run programs and write files there, as you and at your level. The ${out('https://openvibe.actor/console', 'Console')} shows what waits for you. Turning the switch off stops it at once.</p>
<p><b>Let my watches check things from this computer</b> is a separate switch. It lets ${out('https://openvibe.watch', 'OpenVibe.Watch')} probe your own network from the computer for your watches, for example "tell me when my NAS stops answering". It allows only probes: one GET or one TCP connect, to private addresses only.</p></section>
<p><b>Let my watches check things from this computer</b> is a separate switch. It lets ${out('https://openvibe.watch', 'OpenVibe.Watch')} probe your own network from the computer for your watches, for example "tell me when my NAS stops answering". It allows only probes: one GET or one TCP connect, to private addresses only.</p></section>
+<section class="card" aria-labelledby="screen-c-h"><h2 id="screen-c-h">Watch your screen live</h2>
+<p>On ${inLink('/machines', 'Your machines')}, <b>Show my screen</b> shows the computer's screen live, to you only, for 10 minutes (30 seconds to 30 minutes through the API).</p>
+<ul>
+<li><b>Private.</b> It goes to a private stream of that computer's own: nothing is recorded, it never appears on OpenVibe Live, and only you can open it. Your agents, your watches and anyone else cannot start it.</li>
+<li><b>How it is captured.</b> The computer takes a few pictures of its screen a second with the same tools as a screenshot. It needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your desktop session.</li>
+<li><b>It says so.</b> The desktop shows a notice when it starts, and <code>openvibe-node status</code> shows it while it runs.</li>
+<li><b>Stopping it.</b> <b>Stop showing my screen</b> ends it, and so do its time running out and <code>openvibe-node stop</code> on the computer, at once. To never offer it, set <code>allow_screen_stream: false</code> in the plugin's config.</li>
+</ul>
+<p>From the API, with your own token: <code>POST /api/v1/machines/:id/screen</code> (<code>seconds</code>, <code>fps</code> from 1 to 5) starts it, <code>GET</code> answers <code>signaling_url</code> once the computer is sending, and <code>DELETE</code> stops it.</p></section>
+
<section class="card" aria-labelledby="api-c-h"><h2 id="api-c-h">The API</h2>
<section class="card" aria-labelledby="api-c-h"><h2 id="api-c-h">The API</h2>
<p>Your own token reaches your own computers. The answer comes back in <code>data</code>:</p>
<p>Your own token reaches your own computers. The answer comes back in <code>data</code>:</p>
${code(COMPUTER_CALL)}
${code(COMPUTER_CALL)}
@@ -594,7 +594,8 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -594,7 +594,8 @@ function renderMachinesPage({ machines = [], pairing = null }) {
const c = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).computer;
const c = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).computer;
if (!c || typeof c !== 'object') return null;
if (!c || typeof c !== 'object') return null;
const level = m.computer_level || ((c.files || c.terminal || c.screen || {}).level) || null;
const level = m.computer_level || ((c.files || c.terminal || c.screen || {}).level) || null;
- return { level: LEVELS[level] ? level : null, terminal: !!c.terminal, files: !!c.files, screen: !!c.screen, probe: !!c.probe, input: !!c.input };
+ return { 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) };
};
};
const computerRow = (m) => {
const computerRow = (m) => {
const c = computerOf(m);
const c = computerOf(m);
@@ -605,6 +606,7 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -605,6 +606,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>${waiting}`
return `<div class="machine-computer"><span class="pill">Computer: ${esc(c.level ? LEVELS[c.level] : 'on')}</span><span class="muted">${esc(can)}</span>${waiting}`
+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`
+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`
+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`
+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`
+ + (c.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.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'}">`
+ `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from this computer'}</button></form>` : '')
+ `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from this computer'}</button></form>` : '')
+ '</div>';
+ '</div>';
@@ -639,6 +641,41 @@ ${add}
@@ -639,6 +641,41 @@ ${add}
return page('Your machines', body, { scripts: pairing ? ['/panel/panel.js'] : [], description: 'Your own machines that run your jobs on OpenVibe.', canonical: '/machines' });
return page('Your machines', body, { scripts: pairing ? ['/panel/panel.js'] : [], description: 'Your own machines that run your jobs on OpenVibe.', canonical: '/machines' });
}
}
+/**
+ * /machines/:id/screen: the machine's screen, live, for its owner only (plan T14). The tile waits for the machine's
+ * session (public/panel.js polls `stateUrl`) and plays it like a robot's camera; Stop ends it at once.
+ */
+function renderMachineScreenPage({ machine, until = null, error = null, idle = false }) {
+ const name = machine.name || machine.id;
+ const stateUrl = `/machines/${encodeURIComponent(machine.id)}/screen.json`;
+ // Idle (nothing is shown, as from a link): a start button instead of waiting for a stream nobody started.
+ if (idle && !error) {
+ const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}
+<main class="page">
+<h1>${esc(name)}: screen</h1>
+<p class="muted">Show this computer's screen here, live, to you only, for 10 minutes. Nothing is recorded, and the computer says on its desktop that its screen is live.</p>
+<form method="post" action="/machines/${esc(machine.id)}/screen"><button type="submit" class="primary">Show my screen</button></form>
+</main>`;
+ return page(`${name}: screen`, body, { description: 'Your machine\'s screen, live, for you only.', canonical: '/machines' });
+ }
+ const ends = until ? `<p class="muted">Shown until <time datetime="${esc(until)}" data-local-time>${esc(until)}</time>, to you only. Nothing is recorded.</p>` : '';
+ const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}
+<main class="page">
+<h1>${esc(name)}: screen</h1>
+${error ? `<p class="error" role="alert">${esc(error)}</p>` : ends}
+<figure class="camera machine-screen" data-screen-state="${esc(stateUrl)}">
+<div class="camera-screen" data-camera-screen>${icon('plug', 'icon camera-icon')}<p class="camera-title" data-screen-status role="status">${error ? 'The screen is not shown.' : 'Waiting for the machine…'}</p>
+<p class="camera-sub">The picture comes from the machine itself, a few frames a second.</p></div>
+</figure>
+<div class="machine-screen-actions">
+<form method="post" action="/machines/${esc(machine.id)}/screen/stop"><button type="submit" class="primary">Stop showing my screen</button></form>
+<form method="post" action="/machines/${esc(machine.id)}/screen"><button type="submit" class="button quiet">${error ? 'Try again' : 'Start again (10 more minutes)'}</button></form>
+</div>
+<p class="muted small">The machine says on its desktop that its screen is live. <code>openvibe-node stop</code> on the machine ends it at once, and it ends on its own after its time.</p>
+</main>`;
+ return page(`${name}: screen`, body, { scripts: ['/panel/panel.js'], description: 'Your machine\'s screen, live, for you only.', canonical: '/machines' });
+}
+
/**
/**
* /device/phone: the page that makes a phone the robot's device (public/phone-device.js). Its setup form pairs it (the
* /device/phone: the page that makes a phone the robot's device (public/phone-device.js). Its setup form pairs it (the
* QR code fills in the robot and code); once running, the screen is the robot's face, with the link and camera state
* QR code fills in the robot and code); once running, the screen is the robot's face, with the link and camera state
@@ -673,4 +710,4 @@ function renderPhonePage() {
@@ -673,4 +710,4 @@ function renderPhonePage() {
return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });
return page('This phone is a robot', body, { scripts: ['/panel/phone-device.js'], bodyClass: 'phone-page', description: 'Turn a phone into a robot on OpenVibe.Bot from its browser: its camera, voice and face, with nothing to install.', canonical: '/device/phone' });
}
}
-module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
+module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineScreenPage, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, camerasOf, esc };
@@ -36,7 +36,8 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
@@ -36,7 +36,8 @@ const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { frameAncestors } = require('../config');
const { BotError } = require('../util');
const { BotError } = require('../util');
const kits = require('../kits');
const kits = require('../kits');
-const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage } = require('./render');
+const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineScreenPage } = require('./render');
+const { createMachineScreen } = require('../machine-screen');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
const { checkProfile } = require('../profiles');
const { checkProfile } = require('../profiles');
const { renderHome, HOME_CSP } = require('./home');
const { renderHome, HOME_CSP } = require('./home');
@@ -438,8 +439,53 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -438,8 +439,53 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
...d, online: !!(hub && hub.isOnline(d.id)), robot: robot ? robot.name : null,
...d, online: !!(hub && hub.isOnline(d.id)), robot: robot ? robot.name : null,
computer_level: typeof sensors.computer_level === 'string' ? sensors.computer_level : null,
computer_level: typeof sensors.computer_level === 'string' ? sensors.computer_level : null,
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',
};
};
}));
}));
+ // ── The live screen (plan T14): the owner watches their machine's screen on their own private stream ──
+ const machineScreen = createMachineScreen({ domain, hub });
+ const myMachine = async (req, me) => {
+ 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');
+ return mine;
+ };
+ const asUser = (me) => ({ kind: 'user', sub: me.subject });
+ r.post('/machines/:id/screen', ...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 myMachine(req, me);
+ try {
+ const out = await machineScreen.start(mine, me.subject, asUser(me));
+ res.redirect(303, `/machines/${encodeURIComponent(mine.id)}/screen?until=${encodeURIComponent(out.until)}`);
+ } catch (e) {
+ if (!(e instanceof BotError)) throw e;
+ res.setHeader('Content-Security-Policy', PANEL_CSP);
+ res.status(e.status).type('html').send(renderMachineScreenPage({ machine: mine, error: e.detail || e.message }));
+ }
+ }));
+ r.get('/machines/:id/screen', page(async (req, res) => {
+ const me = requireUser(req);
+ const mine = await myMachine(req, me);
+ const until = typeof req.query.until === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d{1,3})?Z$/.test(req.query.until) ? req.query.until : null;
+ // Opened from a link with nothing shown: offer to start it rather than wait for a stream nobody started.
+ const idle = !until && (await machineScreen.state(mine, me.subject)).state !== 'live';
+ res.setHeader('Vary', 'Cookie');
+ res.setHeader('Content-Security-Policy', PANEL_CSP);
+ res.type('html').send(renderMachineScreenPage({ machine: mine, until, idle }));
+ }));
+ r.get('/machines/:id/screen.json', page(async (req, res) => {
+ const me = requireUser(req);
+ const mine = await myMachine(req, me);
+ res.setHeader('Cache-Control', 'private, no-store');
+ res.json(await machineScreen.state(mine, me.subject));
+ }));
+ r.post('/machines/:id/screen/stop', ...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 myMachine(req, me);
+ await machineScreen.stop(mine, me.subject, asUser(me));
+ res.redirect(303, '/machines');
+ }));
r.get('/machines', frameCsp, page(async (req, res) => {
r.get('/machines', frameCsp, page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
res.setHeader('Vary', 'Cookie');
res.setHeader('Vary', 'Cookie');
@@ -475,6 +521,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -475,6 +521,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const mine = (await domain.machines.list(me.subject)).find((d) => d.id === req.params.id);
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');
if (!mine) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');
await domain.devices.revoke(mine.id);
await domain.devices.revoke(mine.id);
+ await domain.machines.screen.archive(mine, me.subject).catch((e) => log.warn(`[Bot] machine ${mine.id} screen stream: ${e.message}`));
if (mine.node_principal) await domain.devices.revokeNode(mine.node_principal).catch((e) => log.warn(`[Bot] machine ${mine.id} on Network: ${e.message}`));
if (mine.node_principal) await domain.devices.revokeNode(mine.node_principal).catch((e) => log.warn(`[Bot] machine ${mine.id} on Network: ${e.message}`));
if (hub) hub.closeDevice(mine.id, 'revoked');
if (hub) hub.closeDevice(mine.id, 'revoked');
res.redirect(303, '/machines');
res.redirect(303, '/machines');
new file mode 100644
new file mode 100644
@@ -0,0 +1,169 @@
@@ -0,0 +1,169 @@
+'use strict';
+// The live screen (plan T14, contracts 0.182.0): the machine's owner, signed in, watches its screen on their own
+// private OpenRestream stream. POST /api/v1/machines/:id/screen makes or rotates the machine's stream (playback
+// private, no recording, never on Live) and sends screen_stream start with the WHIP URL and key; GET answers the
+// session's viewer URL once the machine publishes; DELETE stops it and retires the key. Nobody else starts it — not
+// another person, an agent, a watch or a robot operator — and the generic command route never sends the kind; the
+// audit keeps the action, never the URL or key. The web page (/machines/:id/screen) does the same for the owner.
+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', BOT_WHIP_BASE: 'https://ingest.openre.test/whip/' } });
+ const alex = t.network.newUser('alex');
+ const bob = t.network.newUser('bob');
+ let seq = 0;
+ const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });
+ const poll = async (fn, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(30); } return null; };
+ const SCREEN = { screen: { level: 'observe', tool: 'grim', stream: true }, files: { level: 'observe', roots: ['/home/alex'] } };
+ const seen = [];
+
+ /** A machine of `owner` whose fake Node answers screen_stream like Node v0.12.0. */
+ async function machine(owner, computer = SCREEN) {
+ const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });
+ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
+ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;
+ const config = await c.waitFor((m) => m.type === 'config');
+ c.send(frame('status', { capabilities: { computer }, drivers: [], faults: [] }));
+ c.send(frame('telemetry', { sensors: { computer_level: 'observe', computer_waiting: 0, computer_running: 0, computer_screen_stream: 'off' } }));
+ c.ws.on('message', (raw) => {
+ let m; try { m = JSON.parse(raw.toString()); } catch { return; }
+ if (m.type !== 'command') return;
+ seen.push(m);
+ if (m.kind === 'screen_stream' && m.value.action === 'start') {
+ c.send(frame('ack', { id: m.id, data: { streaming: true, sid: 'abc', seconds: m.value.seconds, fps: m.value.fps, until_ms: Date.now() + m.value.seconds * 1000 } }));
+ } else if (m.kind === 'screen_stream') c.send(frame('ack', { id: m.id, data: { streaming: false, stopped: true } }));
+ else c.send(frame('nack', { id: m.id, fault_code: 'unsupported' }));
+ });
+ // Heartbeats keep it online, as a real Node's do.
+ 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, config };
+ }
+ const screen = (method, deviceId, opts = {}) => t.call(method, `/api/v1/machines/${deviceId}/screen`, opts);
+ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
+ const form = (p, user, headers = {}) => fetch(t.base + p, { method: 'POST', redirect: 'manual', body: '', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers } });
+
+ let mine;
+ let streamId;
+ await check('the owner starts it: a private stream of the machine\'s own, the URL and key go to the machine only', async () => {
+ mine = await machine(alex);
+ assert.ok(mine.config.allowed_commands.includes('screen_stream'), 'the machine is told it takes screen_stream');
+ const r = await screen('POST', mine.deviceId, { user: alex, body: { seconds: 120, fps: 3 } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.deepStrictEqual([r.json.streaming, r.json.seconds, r.json.fps, r.json.machine_id], [true, 120, 3, mine.deviceId]);
+ assert.ok(Date.parse(r.json.until) > Date.now() + 100000);
+ const sent = seen.at(-1);
+ assert.strictEqual(sent.kind, 'screen_stream');
+ assert.deepStrictEqual([sent.value.action, sent.value.seconds, sent.value.fps], ['start', 120, 3]);
+ assert.match(sent.value.publish_key, /^ork_/);
+ assert.strictEqual(sent.value.whip_url, `https://ingest.openre.test/whip/${encodeURIComponent(sent.value.publish_key)}`);
+ const st = [...t.openre.streams.values()].find((x) => x.owner === alex.subject && x.refs.some((ref) => ref.type === 'machine'));
+ assert.ok(st, 'a stream on OpenRestream, as alex');
+ streamId = st.id;
+ assert.deepStrictEqual([st.playback_visibility, st.recording_mode, st.mirror_to_live, st.protocols], ['private', 'none', false, ['webrtc']]);
+ assert.deepStrictEqual(st.refs.map((ref) => [ref.service, ref.type, ref.id]), [['bot', 'machine', mine.deviceId]]);
+ assert.strictEqual((await t.db.maybe('SELECT screen_stream_id FROM devices WHERE id = $1', [mine.deviceId])).screen_stream_id, streamId);
+ assert.ok(t.openre.admits(sent.value.publish_key), 'OpenRestream admits the key the machine got');
+ });
+
+ await check('the audit keeps the action, never the URL or the key', async () => {
+ const rows = await t.db.many("SELECT * FROM command_audit WHERE robot_id = $1 AND kind = 'screen_stream'", [mine.deviceId]);
+ assert.deepStrictEqual(rows.map((x) => [x.result, x.value]), [['ack', { action: 'start', seconds: 120, fps: 3 }]]);
+ assert.ok(!JSON.stringify(rows).includes('ork_') && !JSON.stringify(rows).includes('whip'));
+ });
+
+ await check('GET answers the viewer URL once the machine publishes', async () => {
+ const before = await screen('GET', mine.deviceId, { user: alex });
+ assert.deepStrictEqual([before.status, before.json.live, before.json.signaling_url, before.json.state], [200, false, null, 'off']);
+ await t.wait(600); // telemetry samples are kept at most twice a second
+ mine.c.send(frame('telemetry', { sensors: { computer_level: 'observe', computer_screen_stream: 'live' } }));
+ const session = t.openre.goLive(streamId);
+ const live = await poll(async () => { const r = await screen('GET', mine.deviceId, { user: alex }); return r.json.live ? r : null; });
+ assert.ok(live, 'live');
+ assert.strictEqual(live.json.signaling_url, `ws://127.0.0.1:9936/w/${session.id}`);
+ });
+
+ await check('a new start rotates the key and ends the open session; the old key stops working', async () => {
+ const oldKey = seen.filter((m) => m.kind === 'screen_stream').at(-1).value.publish_key;
+ const r = await screen('POST', mine.deviceId, { user: alex, body: {} });
+ assert.deepStrictEqual([r.status, r.json.seconds, r.json.fps], [201, 600, 2], `ten minutes at 2 frames a second by default: ${r.text}`);
+ const newKey = seen.at(-1).value.publish_key;
+ assert.notStrictEqual(newKey, oldKey);
+ assert.ok(!t.openre.admits(oldKey) && t.openre.admits(newKey));
+ const rotate = t.openre.calls.filter((c) => c.path === `/api/v1/streams/${streamId}/keys/rotate`).at(-1);
+ assert.deepStrictEqual(rotate.body, { grace_seconds: 0, end_sessions: true });
+ assert.strictEqual([...t.openre.streams.values()].filter((x) => x.owner === alex.subject).length, 1, 'still one stream per machine');
+ });
+
+ await check('nobody else starts it, and no command route sends the kind', async () => {
+ assert.strictEqual((await screen('POST', mine.deviceId, { user: bob })).status, 404);
+ assert.strictEqual((await screen('GET', mine.deviceId, { user: bob })).status, 404);
+ const agent = await screen('POST', mine.deviceId, { sub: 'svc:actor', cap: ['bot.computer.control'], headers: { 'X-OV-Subject': alex.subject } });
+ assert.strictEqual(agent.status, 403, 'not the owner\'s agent either');
+ const viaCommands = await t.call('POST', `/api/v1/machines/${mine.deviceId}/commands`,
+ { user: alex, body: { kind: 'screen_stream', value: { action: 'start', whip_url: 'https://evil.test/whip/x', publish_key: 'x' } } });
+ assert.deepStrictEqual([viaCommands.status, viaCommands.json.code], [422, 'bot.invalid_input']);
+ assert.ok(!seen.some((m) => m.value && m.value.whip_url === 'https://evil.test/whip/x'), 'it never reached the machine');
+ assert.strictEqual((await screen('POST', mine.deviceId, { user: alex, body: { seconds: 5 } })).status, 422, 'seconds from 30 to 1800');
+ assert.strictEqual((await screen('POST', mine.deviceId, { user: alex, body: { fps: 30 } })).status, 422, 'fps from 1 to 5');
+ });
+
+ await check('a machine without the live screen is refused before a key is made', async () => {
+ const old = await machine(bob, { screen: { level: 'observe', tool: 'gnome-screenshot', stream: false } });
+ const before = t.openre.calls.length;
+ const r = await screen('POST', old.deviceId, { user: bob });
+ assert.deepStrictEqual([r.status, r.json.code], [409, 'bot.computer_off']);
+ assert.match(r.json.detail, /v0\.12\.0/);
+ const made = t.openre.calls.slice(before).filter((c) => c.method === 'POST' && c.path === '/api/v1/streams');
+ assert.strictEqual(made.length, 0, 'refused before any stream or key is made');
+ old.c.close();
+ });
+
+ await check('DELETE stops it on the machine and retires the key', async () => {
+ const key = seen.filter((m) => m.kind === 'screen_stream' && m.value.action === 'start').at(-1).value.publish_key;
+ const r = await screen('DELETE', mine.deviceId, { user: alex });
+ assert.deepStrictEqual([r.status, r.json.stopped], [200, true]);
+ assert.deepStrictEqual(seen.at(-1).value, { action: 'stop' });
+ assert.ok(!t.openre.admits(key));
+ });
+
+ await check('the web page: Show my screen, the viewer page with the panel\'s CSP, its state, Stop', async () => {
+ const list = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
+ assert.ok(list.includes(`action="/machines/${mine.deviceId}/screen"`) && list.includes('Watch my screen'), 'the machine offers it (Watch: its telemetry says the screen is live)');
+ const started = await form(`/machines/${mine.deviceId}/screen`, alex);
+ assert.strictEqual(started.status, 303);
+ const where = started.headers.get('location');
+ assert.match(where, new RegExp(`^/machines/${mine.deviceId}/screen\\?until=\\d{4}-`));
+ const page = await fetch(t.base + where, { headers: cookie(alex) });
+ assert.strictEqual(page.status, 200);
+ assert.match(page.headers.get('content-security-policy'), /connect-src 'self' wss:\/\/ingest\.openre\.stream/);
+ const html = await page.text();
+ assert.ok(html.includes(`data-screen-state="/machines/${mine.deviceId}/screen.json"`) && html.includes('/panel/panel.js'));
+ const state = await fetch(`${t.base}/machines/${mine.deviceId}/screen.json`, { headers: cookie(alex) });
+ assert.strictEqual(state.status, 200);
+ assert.strictEqual(state.headers.get('cache-control'), 'private, no-store');
+ assert.strictEqual((await fetch(`${t.base}/machines/${mine.deviceId}/screen.json`, { headers: cookie(bob) })).status, 404);
+ assert.strictEqual((await form(`/machines/${mine.deviceId}/screen`, alex, { Origin: 'https://evil.test' })).status, 403, 'no cross-site post');
+ const stopped = await form(`/machines/${mine.deviceId}/screen/stop`, alex);
+ await t.wait(600); // telemetry samples are kept at most twice a second
+ mine.c.send(frame('telemetry', { sensors: { computer_level: 'observe', computer_screen_stream: 'off' } }));
+ const idle = await poll(async () => {
+ const html = await (await fetch(`${t.base}/machines/${mine.deviceId}/screen`, { headers: cookie(alex) })).text();
+ return !html.includes('data-screen-state') ? html : null;
+ });
+ assert.ok(idle && idle.includes(`action="/machines/${mine.deviceId}/screen"`), 'opened from a link with nothing shown, it offers to start');
+ assert.deepStrictEqual([stopped.status, stopped.headers.get('location')], [303, '/machines']);
+ assert.deepStrictEqual(seen.at(-1).value, { action: 'stop' });
+ });
+
+ await check('removing the machine archives its screen stream', async () => {
+ const r = await form(`/machines/${mine.deviceId}/remove`, alex);
+ assert.strictEqual(r.status, 303);
+ assert.ok(t.openre.calls.some((c) => c.method === 'DELETE' && c.path === `/api/v1/streams/${streamId}`));
+ });
+
+ mine.c.close();
+ await done(t);
+})();