openvibers/openvibe.bot Commits
A watch link to a machine's desk (plan L55): the owner's agent shows the desk it works on, inside a chat (#251)
Changes
@@@ -87,12 +87,18 @@ Sitemap entries use the release commit time for these code-backed pages.(also start or stop the screen, and the mouse and keyboard). It goes where the group works, such as a workspace room'sScreen on OpenVibe.Chat. A link shows the owner's own screen or, on a machine with one, the **private desk**(migration `0023_screen_share_desk.sql`): a room watches the agent work there, and a use link's clicks go to the desk.-- - **The link** is shown once; Bot keeps its sha256. It lasts 1-365 days (30 by default), and stops at once when revoked-- or when the machine is removed. The owner's screen page lists the links (where each went, its kind, end date, last-- use) with a Stop button.++ - **The link** is shown once; Bot keeps its sha256. It lasts 1-365 days (30 by default), or 5-240 minutes when the++ caller asks for minutes (an agent's desk, watched for one turn), and stops at once when revoked or when the machine++ is removed. The owner's screen page lists the links (where each went, its kind, end date, last use) with a Stop++ button.++ - **The desk** is any desk the machine has: its own screen (`main`), the **private desk** (migration++ `0023_screen_share_desk.sql`) or an agent task's own desk (`desk-<name>`, migration++ `0029_screen_share_desk_names.sql`): a room watches the agent work there, and a use link's clicks go to the desk.- **The page** `/screen/:token` plays the screen like the owner's page. Its CSP lets it sit in a frame on-- `openvibe.chat`, `openvibe.network` and `BOT_EMBED_ORIGINS`. It sends `Referrer-Policy: no-referrer` and is noindex.-- `GET /screen/:token/state.json` is the state.++ `openvibe.chat`, `openvibe.network`, `openvibe.actor` and `BOT_EMBED_ORIGINS`. It sends++ `Referrer-Policy: no-referrer` and is noindex. `GET /screen/:token/state.json` is the state.++ - **The agent's way in**: `POST /api/v1/machines/:id/watch-links` (`bot.machine.watch_link`) gives the owner's own++ agent a link to a desk, live, inside a chat — see the capabilities table below.- **Using it** (use links only, `public/screen-share.js`): `POST /screen/:token/start|stop`, and `POST /screen/:token/input` with a`bot.command@1` input value (click, double click, right click, move, scroll at a position in the picture, typedtext, or a key or chord by name). Each is an `input` command to the machine **as its owner**, judged by the level@@@ -173,6 +179,7 @@ per export and deletion in `account_data_events` (migration 0008).| `bot.device.connect` | rotate and revoke a device credential || `bot.machine.ai` | OpenVibe.AI asks a person's own machine's model for them (kind `ai` only), and lists their machines whose owner turned on "Let my AI use this computer's models" || `bot.machine.manage` | the machines hub (OpenVibe.Network) acts for a person: `GET /machines`, `GET /machines/:id`, `POST /machines/pairings`, `PATCH /machines/:id`, `PUT /machines/:id/roles` and `POST /machines/:id/revoke`, naming the person in `X-OV-Subject`. Only the network's own service (`svc:network`) may: any other service with the capability is refused (`403 bot.forbidden`) |++| `bot.machine.watch_link` | the owner's own agent (OpenVibe.Actor) asks for a link that shows the desk it works on, live, inside a chat: `POST /machines/:id/watch-links` with `{ desk, control, minutes }`, naming the person in `X-OV-Subject`. The machine must be that person's own (404, never 403), only `svc:actor` may (any other service is refused), the desk is the private desk or the agent's own `desk-<name>` (never the owner's screen, `main`), the link lives 5-240 minutes and carries the mouse and keyboard only when `control` is asked for || `bot.job.dispatch` | the internal Run → Bot jobs API (`POST /jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`); services only || `bot.resource.read` | the internal authority index (`GET /api/v1/resources`, `GET /api/v1/resources/:ovrn`); services only |@@@ -20,6 +20,7 @@"bot.computer.control","bot.machine.ai","bot.machine.manage",++ "bot.machine.watch_link","bot.desk.session","bot.device.connect","bot.job.dispatch",@@@ -63,7 +64,8 @@"streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once","server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects","a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)",-- "machines hub (plan L34): Bot stays the registry; the hub's routes (GET /api/v1/machines, GET /api/v1/machines/:id, POST /api/v1/machines/pairings, PATCH /api/v1/machines/:id, PUT /api/v1/machines/:id/roles, POST /api/v1/machines/:id/revoke) serve the person and the network's own service (svc:network, and only that one) acting for them (bot.machine.manage, X-OV-Subject), with the machine's roles (migration 0028) in the device's config frame and its online state reported to Network once per change"++ "machines hub (plan L34): Bot stays the registry; the hub's routes (GET /api/v1/machines, GET /api/v1/machines/:id, POST /api/v1/machines/pairings, PATCH /api/v1/machines/:id, PUT /api/v1/machines/:id/roles, POST /api/v1/machines/:id/revoke) serve the person and the network's own service (svc:network, and only that one) acting for them (bot.machine.manage, X-OV-Subject), with the machine's roles (migration 0028) in the device's config frame and its online state reported to Network once per change",++ "watch links to a desk (plan L55): POST /api/v1/machines/:id/watch-links gives the owner's agent (svc:actor, bot.machine.watch_link) a link that shows the desk the agent works on, live, inside a chat (https://openvibe.bot/screen/<token>, no sign-in, framable on openvibe.chat and openvibe.actor); the machine is the one the named person owns, the link lives 5 to 240 minutes, and it carries the mouse and keyboard only when asked for"],"updated": "2026-11-01"}nnew file mode 100644@@@ -0,0 +1,6 @@++-- phase: expand++-- A screen share link may show any desk the machine has (plan L55): main, private, or desk-<name> — the desk an++-- agent task works on, watched in a chat turn. The check grows; the links made before keep the desk they had.++ALTER TABLE machine_screen_shares DROP CONSTRAINT IF EXISTS machine_screen_shares_desk_check;++ALTER TABLE machine_screen_shares ADD CONSTRAINT machine_screen_shares_desk_check++ CHECK (desk IN ('main', 'private') OR desk ~ '^desk-[a-z0-9][a-z0-9-]{0,23}$');@@@ -33,6 +33,7 @@* | POST /jobs, /jobs/:id/cancel, GET /jobs/:id | bot.job.dispatch | — (services only) |* | POST /pair | — (one-time code) | the agent (code is the credential) |* | POST /claims | — (anyone) | a device asking to be added: a code |++ * | POST /machines/:id/watch-links | bot.machine.watch_link | the owner, or their agent for them |* | GET /claims/:id | — (X-OV-Claim-Secret) | that device: pending, or its pairing|** With BOT_PAIRING_AUTHORITY=network the pairing codes are Network's and POST /pair answers 410 bot.pairing_moved.@@@ -43,6 +44,7 @@ const { parseSteps, stepHelp, AGENT_MAX_STEPS, AGENT_MAX_S } = require('../routiconst { http } = require('openvibe-contracts');const { fail, userSubject, isRobotId, json } = require('../util');const { createMachineScreen } = require('../machine-screen');++const { createScreenShares } = require('../screen-shares');const { getProfile, listProfiles, checkProfile } = require('../profiles');const machineRoles = require('../machine-roles');const kits = require('../kits');@@@ -60,6 +62,8 @@ const CAP = {ai: 'bot.machine.ai',// The network's machines hub (plan L34): a service acts for a person on their own machines.machine: 'bot.machine.manage',++ // A watch link to a machine's desk (plan L55): the owner's agent shows the desk it works on, inside a chat.++ watchLink: 'bot.machine.watch_link',};/** Services that act as a person's agent (plan T15/T17): they drive a robot only where its owner turned agent_control on. */@@@ -80,6 +84,12 @@ const MACHINE_SERVICES = {const HUB_SERVICE = 'svc:network';// A model on a person's machine is OpenVibe.AI's and the owner's only: no other service sends kind ai.const AI_KIND = 'ai';++/**++ * The services that may make a watch link to a person's machine desk (plan L55): the owner's own agent, which shows++ * the desk it works on in a chat. The capability alone is not enough — a service holding it could name any person in++ * X-OV-Subject — so the service is this one, and the machine is the named person's own.++ */++const WATCH_LINK_SERVICES = new Set(['svc:actor']);function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null, claims = null }) {const r = express.Router();@@@ -92,6 +102,9 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submconst manage = limits('bot.robot.manage', { minute: 30, hour: 300 });const control = limits('bot.control', { minute: 120, hour: 1200 });const machineScreen = createMachineScreen({ domain, hub });++ // The share links themselves (server/screen-shares.js): the web page makes them for the owner, this route for the++ // owner's agent, both on the same table.++ const shares = createScreenShares({ db: domain.db, domain, hub, machineScreen });// 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@@@ -102,6 +115,8 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submconst pair = limits('bot.pair', { minute: 20, hour: 200 });// The hub's routes: a service naming a person is counted against that person, as their own token is.const machineManage = limits.actedFor('bot.machine.manage', { minute: 30, hour: 300 });++ // A watch link (plan L55): counted against the person whose desk it is, and against the agent's own token.++ const watchLink = limits.actedFor('bot.machine.watch_link', { minute: 30, hour: 300 }, { minute: 60, hour: 600 });const jobs = limits('bot.jobs', { minute: 120, hour: 1200 });const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);@@@ -596,6 +611,43 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submres.json({ machine: machineOut(updated) });}));++ // ── A watch link to a machine's desk (plan L55) ─────────────────────────────────────────────────++ // The link shows the desk the agent works on, live, inside a chat turn: https://openvibe.bot/screen/<token>.++ // The page needs no sign-in (the token is the credential) and may sit in a frame on openvibe.chat or++ // openvibe.actor. It ends in minutes, and it is the owner's own link: the machine is the one the subject owns.++ r.post('/machines/:id/watch-links', watchLink, wrap(async (req, res) => {++ const subject = actedFor(req);++ if (req.principal.kind === 'service') {++ if (!WATCH_LINK_SERVICES.has(req.principal.sub)) fail(403, 'bot.forbidden', 'watch links are for the owner\'s agent');++ if (!apiAuth.granted(req.principal, CAP.watchLink)) fail(403, 'capability.denied', `${CAP.watchLink} not granted`);++ }++ const device = await domain.devices.get(String(req.params.id || '').slice(0, 64));++ const ownerSubject = device && !device.revoked_at ? await domain.devices.ownerOf(device) : null;++ // 404, never 403: nobody may learn whose machine an id is.++ if (!ownerSubject || ownerSubject !== subject) fail(404, 'bot.device_not_found', 'no such machine of yours');++ // The agent reaches a machine only where its owner turned "Let my agent use this computer" on.++ if (req.principal.kind === 'service' && !device.agent_computer) fail(403, 'bot.agent_not_allowed', 'the owner has not let their agent use this computer');++ const desk = req.body?.desk == null || req.body.desk === '' ? (req.principal.kind === 'service' ? '' : 'main') : String(req.body.desk);++ // The agent shows the desk it works on: the private desk or its own desk-<name>, never the owner's own screen++ // (main). The owner, signed in, may link any of their desks.++ // (A malformed name is create()'s to refuse, as bot.invalid_input.)++ if (req.principal.kind === 'service' && (desk === '' || desk === 'main')) fail(422, 'bot.desk_not_agents', 'an agent links the private desk or its own desk-<name>, never the owner\'s screen');++ const made = await shares.create(device, subject, {++ label: req.body?.label || null,++ control: req.body?.control === true, // the mouse and keyboard only when asked for++ minutes: req.body?.minutes == null || req.body.minutes === '' ? 60 : req.body.minutes,++ desk,++ });++ await domain.audit.record({++ robotId: device.id, deviceId: device.id, subject, operatorKind: req.principal.kind, role: 'owner',++ kind: 'screen.share', value: { desk: made.share.desk, control: made.share.control, expires_at: made.share.expires_at }, result: 'ack',++ });++ res.status(201).json({++ url: `${config.baseUrl}/screen/${made.token}`, expires_at: made.share.expires_at,++ desk: made.share.desk, control: made.share.control, share_id: made.share.id,++ });++ }));++// ── The machines hub (plan L34, step M2) ───────────────────────────────────────────────────────// The machine's owner, or the network's hub (OpenVibe.Network) acting for them: a service holding// bot.machine.manage and naming the person in X-OV-Subject. Bot stays the registry underneath, so the@@@ -13,9 +13,10 @@* the machine is removed. Nothing here lets a holder run programs, read files or point the screen elsewhere: only the* screen's start and stop, and the mouse and keyboard.*-- * create(device, owner, { label, control, days, desk }) → { share, token }; desk `private`: the link shows and-- * drives the machine's private desk (where an agent works), never the-- * owner's own screen++ * create(device, owner, { label, control, days, minutes, desk }) → { share, token }; the desk is any desk the++ * machine has (main, private or desk-<name>: an agent task's own++ * desk, plan L55), and the link ends in days or, when `minutes` is++ * given, in 5 to 240 minutes* list(device, owner) / revoke(device, owner, id)* open(token) → { share, device } or null* state(token) / start(token) / stop(token) the screen, through the link@@@ -23,32 +24,44 @@* move, scroll, type, key) → the machine's answer*/const { BotError, prefixedId, sha256, token: newToken } = require('./util');--const { inputValue, ACTIONS } = require('./machine-screen');++const { inputValue, ACTIONS, DESK } = require('./machine-screen');const DAYS = { min: 1, max: 365, default: 30 };++// A link made in minutes (an agent's desk, watched for one turn) ends sooner than one made in days (plan L55).++const MINUTES = { min: 5, max: 240, default: 60 };const PER_SECOND = 8; // a person clicks and types a few times a second, not morefunction createScreenShares({ db, domain, hub, machineScreen, now = () => Date.now() }) {const rate = new Map(); // share id → recent input timestamps (this process)const shape = (r) => ({-- id: r.id, device_id: r.device_id, label: r.label || null, control: !!r.control, desk: r.desk === 'private' ? 'private' : 'main',++ id: r.id, device_id: r.device_id, label: r.label || null, control: !!r.control, desk: r.desk || 'main',created_at: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,expires_at: r.expires_at ? (r.expires_at instanceof Date ? r.expires_at.toISOString() : r.expires_at) : null,last_used_at: r.last_used_at ? (r.last_used_at instanceof Date ? r.last_used_at.toISOString() : r.last_used_at) : null,});-- async function create(device, owner, { label = null, control = false, days = DAYS.default, desk = 'main' } = {}) {++ async function create(device, owner, { label = null, control = false, days = DAYS.default, minutes = null, desk = 'main' } = {}) {if (!device || device.owner_subject !== owner) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');-- const d = Number(days);-- if (!Number.isInteger(d) || d < DAYS.min || d > DAYS.max) throw new BotError(422, 'bot.invalid_input', `days is a whole number from ${DAYS.min} to ${DAYS.max}`);++ // Any desk the machine has: its own screen (main), the private desk, or an agent task's own desk (desk-<name>).const where = desk == null || desk === '' ? 'main' : String(desk);-- if (where !== 'main' && where !== 'private') throw new BotError(422, 'bot.invalid_input', 'desk is main or private');++ if (!DESK.test(where)) throw new BotError(422, 'bot.invalid_input', 'desk is main, private or desk-<name>');++ // A link may end in minutes (an agent's desk, watched for one turn) or in days (the owner's own link).++ let ends;++ if (minutes != null && minutes !== '') {++ const m = Number(minutes);++ if (!Number.isInteger(m) || m < MINUTES.min || m > MINUTES.max) throw new BotError(422, 'bot.invalid_input', `minutes is a whole number from ${MINUTES.min} to ${MINUTES.max}`);++ ends = now() + m * 60000;++ } else {++ const d = Number(days);++ if (!Number.isInteger(d) || d < DAYS.min || d > DAYS.max) throw new BotError(422, 'bot.invalid_input', `days is a whole number from ${DAYS.min} to ${DAYS.max}`);++ ends = now() + d * 86400000;++ }const id = prefixedId('shr', now());const t = newToken(32);const l = label == null ? null : String(label).replace(/\s+/g, ' ').trim().slice(0, 80) || null;await db.query(`INSERT INTO machine_screen_shares (id, device_id, owner_subject, token_hash, label, control, created_at, expires_at, desk)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [id, device.id, owner, sha256(t), l, !!control, new Date(now()), new Date(now() + d * 86400000), where]);++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [id, device.id, owner, sha256(t), l, !!control, new Date(now()), new Date(ends), where]);const row = await db.maybe('SELECT * FROM machine_screen_shares WHERE id = $1', [id]);return { share: shape(row), token: t };}@@@ -89,12 +102,12 @@ function createScreenShares({ db, domain, hub, machineScreen, now = () => Date.nasync function state(t) {const s = await must(t);const st = await machineScreen.state(s.device, s.share.owner_subject);-- return { ...st, name: s.device.name || 'A computer', control: !!s.share.control, label: s.share.label || null, desk: s.share.desk === 'private' ? 'private' : 'main' };++ return { ...st, name: s.device.name || 'A computer', control: !!s.share.control, label: s.share.label || null, desk: s.share.desk || 'main' };}async function start(t) {const s = await must(t, { control: true });-- return await machineScreen.start(s.device, s.share.owner_subject, asShare(s), { seconds: 1800, fps: 4, desk: s.share.desk === 'private' ? 'private' : 'main' });++ return await machineScreen.start(s.device, s.share.owner_subject, asShare(s), { seconds: 1800, fps: 4, desk: s.share.desk || 'main' });}async function stop(t) {@@@ -107,7 +120,7 @@ function createScreenShares({ db, domain, hub, machineScreen, now = () => Date.nconst s = await must(t, { control: true });if (!hub) throw new BotError(503, 'bot.unavailable', 'machines are not connected here');const out = inputValue(value);-- if (s.share.desk === 'private') out.desk = 'private'; // a desk link drives the desk, never the owner's screen++ if (s.share.desk && s.share.desk !== 'main') out.desk = s.share.desk; // a desk link drives the desk, never the owner's screenconst recent = (rate.get(s.share.id) || []).filter((x) => now() - x < 1000);if (recent.length >= PER_SECOND) throw new BotError(429, 'bot.rate_limited', 'slow down a little');recent.push(now());@@@ -118,7 +131,7 @@ function createScreenShares({ db, domain, hub, machineScreen, now = () => Date.nthrow new BotError(r && r.result === 'expired' ? 504 : 409, code.startsWith('bot.') ? code : 'bot.device_refused', `the computer did not do it: ${(r && (r.reason || r.result)) || 'no answer'}`.slice(0, 300));}-- return { create, list, revoke, open, state, start, stop, input, DAYS };++ return { create, list, revoke, open, state, start, stop, input, DAYS, MINUTES };}module.exports = { createScreenShares, ACTIONS };@@@ -861,7 +861,7 @@ function sharesBlock(machine, shares, madeLink, madeShare, hasDesk = false, hasSconst which = !hasScreen? '<input type="hidden" name="desk" value="private"><p class="muted small">A link here shares the private desk: this computer has no screen of its own.</p>': hasDesk ? '<label class="field"><span class="field-label">Which screen</span><select name="desk"><option value="main">Your own screen</option><option value="private">The private desk, where your agent works</option></select></label>' : '';-- const rows = (shares || []).map((x) => `<li><strong>${esc(x.label || 'A link')}</strong> · ${x.desk === 'private' ? 'the private desk · ' : ''}${x.control ? 'watch and use' : 'watch only'} · until <time datetime="${esc(x.expires_at || '')}" data-local-time>${esc(String(x.expires_at || '').slice(0, 10))}</time>${x.last_used_at ? ` · last opened <time datetime="${esc(x.last_used_at)}" data-local-time>${esc(String(x.last_used_at).slice(0, 16).replace('T', ' '))}</time>` : ''}++ const rows = (shares || []).map((x) => `<li><strong>${esc(x.label || 'A link')}</strong> · ${x.desk && x.desk !== 'main' ? `the ${esc(x.desk)} desk · ` : ''}${x.control ? 'watch and use' : 'watch only'} · until <time datetime="${esc(x.expires_at || '')}" data-local-time>${esc(String(x.expires_at || '').slice(0, 10))}</time>${x.last_used_at ? ` · last opened <time datetime="${esc(x.last_used_at)}" data-local-time>${esc(String(x.last_used_at).slice(0, 16).replace('T', ' '))}</time>` : ''}<form method="post" action="/machines/${esc(machine.id)}/shares/${esc(x.id)}/revoke" class="inline-form"><button type="submit" class="button quiet">Stop this link</button></form></li>`).join('');return `<section class="machine-shares" aria-labelledby="shares-h"><h2 id="shares-h">Share this screen</h2><p class="muted">Make a link for a group: put it in a workspace room on OpenVibe.Chat (the staff room's Screen) or anywhere people work together. <strong>Watch</strong> shows the screen live. <strong>Watch and use</strong> also lets them start it and use the mouse and keyboard, at the level you set on this computer: at <code>ask</code> each action waits for your approval there, and at <code>observe</code> nothing happens. Nobody can run programs or read files through a link.</p>@@@ -885,7 +885,7 @@ function renderSharePage({ token, name, control, label, desk = 'main' }) {const base = `/screen/${encodeURIComponent(token)}`;const body = `<main class="page share-page" data-share="${esc(base)}" data-control="${control ? '1' : '0'}"><header class="share-head"><h1>${esc(name)}${label ? ` <span class="muted">· ${esc(label)}</span>` : ''}</h1>--<p class="muted share-mode">${control ? 'Shared to watch and use' : 'Shared to watch'}${desk === 'private' ? ': the private desk, where an agent works (never the owner\'s own screen)' : ''}</p></header>++<p class="muted share-mode">${control ? 'Shared to watch and use' : 'Shared to watch'}${desk && desk !== 'main' ? `: the ${esc(desk)} desk, where an agent works (never the owner\'s own screen)` : ''}</p></header><figure class="camera machine-screen share-screen" data-screen-state="${esc(base)}/state.json" tabindex="${control ? '0' : '-1'}" aria-label="The shared screen${control ? ': turn on Use to click and type on it' : ''}"><div class="camera-screen" data-camera-screen>${icon('plug', 'icon camera-icon')}<p class="camera-title" data-screen-status role="status">Looking for the screen…</p><p class="camera-sub">The picture comes from the computer itself, a few frames a second.</p></div>@@@ -728,12 +728,12 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst shares = createScreenShares({ db: domain && domain.db, domain, hub, machineScreen });// A share page may sit in OpenVibe's own workspaces (a room on OpenVibe.Chat, the admin panel) and the configured embeds.const shareCsp = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,-- `frame-ancestors ${frameAncestors(config)} https://openvibe.chat https://openvibe.network`, "object-src 'none'", "base-uri 'self'"].join('; ');++ `frame-ancestors ${frameAncestors(config)} https://openvibe.chat https://openvibe.network https://openvibe.actor`, "object-src 'none'", "base-uri 'self'"].join('; ');r.post('/machines/:id/shares', ...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);-- const made = await shares.create(mine, me.subject, { label: req.body && req.body.label, control: req.body && req.body.control === '1', days: (req.body && req.body.days) || 30, desk: req.body && req.body.desk === 'private' ? 'private' : 'main' });++ const made = await shares.create(mine, me.subject, { label: req.body && req.body.label, control: req.body && req.body.control === '1', days: (req.body && req.body.days) || 30, desk: req.body && req.body.desk });res.setHeader('Content-Security-Policy', PANEL_CSP);res.type('html').send(renderMachineScreenPage({ machine: mine, idle: true, shares: await shares.list(mine, me.subject), madeLink: `${config.baseUrl}/screen/${made.token}`, madeShare: made.share, hasDesk: hasDesk(mine), hasScreen: hasScreen(mine) }));}));@@@ -759,7 +759,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limires.setHeader('Content-Security-Policy', shareCsp);const s = await shares.open(req.params.token).catch(() => null);if (!s) return res.status(404).type('html').send(renderShareGone());-- res.type('html').send(renderSharePage({ token: req.params.token, name: s.device.name || 'A computer', control: !!s.share.control, label: s.share.label, desk: s.share.desk === 'private' ? 'private' : 'main' }));++ res.type('html').send(renderSharePage({ token: req.params.token, name: s.device.name || 'A computer', control: !!s.share.control, label: s.share.label, desk: s.share.desk || 'main' }));});r.get('/screen/:token/state.json', shareJson(async (req) => await shares.state(req.params.token)));r.post('/screen/:token/start', ...shareLimit, shareJson(async (req) => await shares.start(req.params.token)));@@@ -62,6 +62,7 @@ const { boot, check, done } = require('./helpers/app');let r = await fetch(`${t.base}/screen/${watch}`);assert.strictEqual(r.status, 200);assert.match(r.headers.get('content-security-policy'), /frame-ancestors [^;]*https:\/\/openvibe\.chat/);++ assert.match(r.headers.get('content-security-policy'), /frame-ancestors [^;]*https:\/\/openvibe\.actor/);assert.strictEqual(r.headers.get('referrer-policy'), 'no-referrer');let html = await r.text();assert.match(html, /staff-desk/);@@@ -124,6 +125,20 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual((await t.db.maybe("SELECT desk FROM machine_screen_shares WHERE label = 'Agent desk'")).desk, 'private');});++ await check('a link may show any desk the machine has, not only the private one (plan L55)', async () => {++ let r = await form(`/machines/${deviceId}/shares`, alex, 'label=Task+desk&days=1&control=1&desk=desk-task-a');++ const html = await r.text();++ const task = linkOf(html);++ assert.ok(task, 'a link to a named desk');++ assert.match(html, /the desk-task-a desk/);++ assert.match(await (await fetch(`${t.base}/screen/${task}`)).text(), /the desk-task-a desk/);++ assert.strictEqual((await json('GET', `/screen/${task}/state.json`)).json.desk, 'desk-task-a');++ assert.strictEqual((await t.db.maybe("SELECT desk FROM machine_screen_shares WHERE label = 'Task desk'")).desk, 'desk-task-a');++ // A desk the machine does not have is refused by the contract, not stored.++ r = await form(`/machines/${deviceId}/shares`, alex, 'label=Bad&days=1&desk=desk-../x');++ assert.ok(r.status >= 400, 'not a desk name');++ });++await check('a burst is slowed down; a revoked link stops at once', async () => {let limited = 0;for (let i = 0; i < 12; i++) { if ((await json('POST', `/screen/${use}/input`, { action: 'move', x: i, y: i, width: 100, height: 100 })).status === 429) limited++; }nnew file mode 100644@@@ -0,0 +1,154 @@++'use strict';++// A watch link to a machine's desk (plan L55): the owner's agent (svc:actor with bot.machine.watch_link) asks Bot for a++// link that shows the desk it works on, live, inside a chat turn. The link is the owner's own (the machine is the one++// the named subject owns), it needs no sign-in, it may sit in a frame on openvibe.chat or openvibe.actor, and it ends++// in minutes. Another person's machine is not there at all (404), another service may not make one, and the mouse and++// keyboard come with it only when asked for.++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 COMPUTER = { screen: { level: 'trusted', tool: 'grim', stream: true }, input: { level: 'trusted' }, files: { level: 'trusted', roots: ['/home/alex'] }, terminal: { level: 'trusted' },++ desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } };++ const seen = [];++++ /** A Network-paired machine of `owner` whose fake Node answers screen and input like the plugin would. */++ async function machine(owner, computer = COMPUTER) {++ const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });++ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });++ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;++ const config = await c.waitFor((m) => m.type === 'config');++ c.send(frame('status', { capabilities: { computer }, drivers: [], faults: [] }));++ c.send(frame('telemetry', { sensors: { computer_level: 'trusted', 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, seconds: m.value.seconds, fps: m.value.fps, until_ms: Date.now() + m.value.seconds * 1000 } }));++ else c.send(frame('ack', { id: m.id, data: { done: true } }));++ });++ const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, Math.max(100, Math.floor(config.heartbeat_ms / 2)));++ c.ws.on('close', () => clearInterval(beat));++ await poll(() => t.hub.computerOf(deviceId) !== null);++ return { c, deviceId };++ }++ const actor = (cap = ['bot.machine.watch_link']) => t.network.signService({ sub: 'svc:actor', cap });++ const asActor = (token, subject) => ({ token, headers: { 'X-OV-Subject': subject } });++ const link = (deviceId, body, opts) => t.call('POST', `/api/v1/machines/${deviceId}/watch-links`, { body, ...opts });++++ let mine, theirs;++ await check('the agent asks for a link to the desk it works on; the link is the owner\'s own and shows it', async () => {++ mine = await machine(alex);++ theirs = await machine(bob);++ await t.call('POST', `/api/v1/machines/${mine.deviceId}/agent-computer`, { user: alex, body: { on: true } });++ const r = await link(mine.deviceId, { desk: 'desk-task-a', minutes: 30 }, asActor(actor(), alex.subject));++ assert.strictEqual(r.status, 201, r.text);++ assert.match(r.json.url, /^https?:\/\/[^/]+\/screen\/[A-Za-z0-9_-]{40,60}$/, 'the link is a page on openvibe.bot');++ assert.ok(r.json.expires_at, 'it says when it ends');++ const left = new Date(r.json.expires_at).getTime() - Date.now();++ assert.ok(left > 29 * 60000 && left <= 30 * 60000, 'thirty minutes, more or less');++ assert.deepStrictEqual([r.json.desk, r.json.control], ['desk-task-a', false], 'watching only, unless asked for');++ // The page itself: no sign-in, and it may sit in a frame on OpenVibe\'s own services.++ const token = r.json.url.split('/').pop();++ const page = await fetch(t.base + `/screen/${token}`);++ assert.strictEqual(page.status, 200);++ assert.match(page.headers.get('content-security-policy'), /frame-ancestors [^;]*https:\/\/openvibe\.actor/);++ assert.match(page.headers.get('content-security-policy'), /frame-ancestors [^;]*https:\/\/openvibe\.chat/);++ assert.match(await page.text(), /desk-task-a desk/);++ assert.strictEqual((await fetch(t.base + `/screen/${token}/state.json`)).status, 200);++ });++++ await check('another person\'s machine is not there at all, and another service may not make a link', async () => {++ const other = await link(mine.deviceId, { desk: 'main' }, asActor(actor(), bob.subject));++ assert.strictEqual(other.status, 404, 'never 403: nobody may learn whose machine an id is');++ assert.strictEqual(other.json.code, 'bot.device_not_found');++ // The agent may not make a link for a machine the owner never opened to it.++ await t.call('POST', `/api/v1/machines/${theirs.deviceId}/agent-computer`, { user: bob, body: { on: false } });++ const notOpen = await link(theirs.deviceId, { desk: 'main' }, asActor(actor(), bob.subject));++ assert.strictEqual(notOpen.status, 403);++ assert.strictEqual(notOpen.json.code, 'bot.agent_not_allowed');++ // Another service, even holding the capability, is not the owner\'s agent.++ const other1 = await link(mine.deviceId, { desk: 'main' }, { token: t.network.signService({ sub: 'svc:live', cap: ['bot.machine.watch_link'] }), headers: { 'X-OV-Subject': alex.subject } });++ assert.strictEqual(other1.status, 403);++ assert.strictEqual(other1.json.code, 'bot.forbidden');++ // The agent shows its own desk, never the owner's screen, and names the desk it means.++ const ownScreen = await link(mine.deviceId, { desk: 'main', minutes: 30 }, asActor(actor(), alex.subject));++ assert.strictEqual(ownScreen.status, 422);++ assert.strictEqual(ownScreen.json.code, 'bot.desk_not_agents');++ const unnamed = await link(mine.deviceId, { minutes: 30 }, asActor(actor(), alex.subject));++ assert.strictEqual(unnamed.status, 422, 'no desk named is not the owner\'s screen either');++ assert.strictEqual((await link(mine.deviceId, { desk: 'private', minutes: 30 }, asActor(actor(), alex.subject))).status, 201, 'the private desk is an agent\'s');++ // The agent without the capability has no route at all.++ const noCap = await link(mine.deviceId, { desk: 'main' }, asActor(actor(['bot.computer.control']), alex.subject));++ assert.strictEqual(noCap.status, 403);++ assert.strictEqual(noCap.json.code, 'capability.denied');++ });++++ await check('the link ends in minutes, and the desk is any agent desk the machine has', async () => {++ const soon = await link(mine.deviceId, { desk: 'desk-task-a', minutes: 5 }, asActor(actor(), alex.subject));++ assert.strictEqual(soon.status, 201, soon.text);++ const five = new Date(soon.json.expires_at).getTime() - Date.now();++ assert.ok(five > 4 * 60000 && five <= 5 * 60000, 'five minutes');++ for (const minutes of [4, 241, 0, 'soon', 1.5]) {++ const bad = await link(mine.deviceId, { desk: 'desk-task-a', minutes }, asActor(actor(), alex.subject));++ assert.strictEqual(bad.status, 422, `minutes ${minutes}`);++ assert.strictEqual(bad.json.code, 'bot.invalid_input');++ }++ for (const desk of ['private', 'desk-task-a', 'desk-a-b-9']) {++ const ok = await link(mine.deviceId, { desk, minutes: 60 }, asActor(actor(), alex.subject));++ assert.strictEqual(ok.status, 201, `desk ${desk}`);++ assert.strictEqual(ok.json.desk, desk);++ }++ for (const desk of ['desk-../x', 'desk-', 'DESK-a', 'other', 'desk-' + 'x'.repeat(30)]) {++ const bad = await link(mine.deviceId, { desk, minutes: 60 }, asActor(actor(), alex.subject));++ assert.strictEqual(bad.status, 422, `desk ${desk}`);++ assert.strictEqual(bad.json.code, 'bot.invalid_input');++ }++ });++++ await check('the mouse and the keyboard come with the link only when asked for', async () => {++ const plain = await link(mine.deviceId, { desk: 'desk-task-a', minutes: 30 }, asActor(actor(), alex.subject));++ const token = plain.json.url.split('/').pop();++ const html = await (await fetch(t.base + `/screen/${token}`)).text();++ assert.ok(!html.includes('screen-share.js'), 'a watch link has no controls');++ assert.strictEqual((await fetch(t.base + `/screen/${token}/start`, { method: 'POST' })).status, 403);++ const use = await link(mine.deviceId, { desk: 'desk-task-a', control: true, minutes: 30 }, asActor(actor(), alex.subject));++ assert.strictEqual(use.json.control, true);++ const useToken = use.json.url.split('/').pop();++ const useHtml = await (await fetch(t.base + `/screen/${useToken}`)).text();++ assert.match(useHtml, /screen-share\.js/);++ assert.strictEqual((await fetch(t.base + `/screen/${useToken}/start`, { method: 'POST' })).status, 200);++ const start = seen.filter((m) => m.kind === 'screen_stream').at(-1);++ assert.strictEqual(start.value.desk, 'desk-task-a', 'the live screen of that desk');++ });++++ await check('the owner may make one themselves; the making of it is audited', async () => {++ const own = await link(mine.deviceId, { desk: 'desk-task-a', minutes: 30 }, { user: alex });++ assert.strictEqual(own.status, 201, own.text);++ assert.ok(own.json.url.includes('/screen/'));++ const audit = await t.db.many("SELECT operator_subject, operator_kind, kind, value, result FROM command_audit WHERE robot_id = $1 AND kind = 'screen.share' ORDER BY id", [mine.deviceId]);++ assert.ok(audit.length >= 1, 'the link is in the audit');++ assert.deepStrictEqual(audit.at(-1).value.desk, 'desk-task-a');++ assert.strictEqual(audit.at(-1).result, 'ack');++ assert.strictEqual(audit.at(-1).operator_subject, alex.subject);++ });++++ await check('too many links for one person are refused', async () => {++ let refused = 0;++ for (let i = 0; i < 80; i++) {++ const r = await link(mine.deviceId, { desk: 'main', minutes: 5 }, asActor(actor(), alex.subject));++ if (r.status === 429) { refused++; break; }++ }++ assert.strictEqual(refused, 1, 'rate-limited');++ });++++ mine.c.close();++ theirs.c.close();++ await done(t);++})().catch((err) => { console.error(err); process.exit(1); });
@@ -87,12 +87,18 @@ Sitemap entries use the release commit time for these code-backed pages.
@@ -87,12 +87,18 @@ Sitemap entries use the release commit time for these code-backed pages.
(also start or stop the screen, and the mouse and keyboard). It goes where the group works, such as a workspace room's
(also start or stop the screen, and the mouse and keyboard). It goes where the group works, such as a workspace room's
Screen on OpenVibe.Chat. A link shows the owner's own screen or, on a machine with one, the **private desk**
Screen on OpenVibe.Chat. A link shows the owner's own screen or, on a machine with one, the **private desk**
(migration `0023_screen_share_desk.sql`): a room watches the agent work there, and a use link's clicks go to the desk.
(migration `0023_screen_share_desk.sql`): a room watches the agent work there, and a use link's clicks go to the desk.
- - **The link** is shown once; Bot keeps its sha256. It lasts 1-365 days (30 by default), and stops at once when revoked
- or when the machine is removed. The owner's screen page lists the links (where each went, its kind, end date, last
- use) with a Stop button.
+ - **The link** is shown once; Bot keeps its sha256. It lasts 1-365 days (30 by default), or 5-240 minutes when the
+ caller asks for minutes (an agent's desk, watched for one turn), and stops at once when revoked or when the machine
+ is removed. The owner's screen page lists the links (where each went, its kind, end date, last use) with a Stop
+ button.
+ - **The desk** is any desk the machine has: its own screen (`main`), the **private desk** (migration
+ `0023_screen_share_desk.sql`) or an agent task's own desk (`desk-<name>`, migration
+ `0029_screen_share_desk_names.sql`): a room watches the agent work there, and a use link's clicks go to the desk.
- **The page** `/screen/:token` plays the screen like the owner's page. Its CSP lets it sit in a frame on
- **The page** `/screen/:token` plays the screen like the owner's page. Its CSP lets it sit in a frame on
- `openvibe.chat`, `openvibe.network` and `BOT_EMBED_ORIGINS`. It sends `Referrer-Policy: no-referrer` and is noindex.
- `GET /screen/:token/state.json` is the state.
+ `openvibe.chat`, `openvibe.network`, `openvibe.actor` and `BOT_EMBED_ORIGINS`. It sends
+ `Referrer-Policy: no-referrer` and is noindex. `GET /screen/:token/state.json` is the state.
+ - **The agent's way in**: `POST /api/v1/machines/:id/watch-links` (`bot.machine.watch_link`) gives the owner's own
+ agent a link to a desk, live, inside a chat — see the capabilities table below.
- **Using it** (use links only, `public/screen-share.js`): `POST /screen/:token/start|stop`, and `POST /screen/:token/input` with a
- **Using it** (use links only, `public/screen-share.js`): `POST /screen/:token/start|stop`, and `POST /screen/:token/input` with a
`bot.command@1` input value (click, double click, right click, move, scroll at a position in the picture, typed
`bot.command@1` input value (click, double click, right click, move, scroll at a position in the picture, typed
text, or a key or chord by name). Each is an `input` command to the machine **as its owner**, judged by the level
text, or a key or chord by name). Each is an `input` command to the machine **as its owner**, judged by the level
@@ -173,6 +179,7 @@ per export and deletion in `account_data_events` (migration 0008).
@@ -173,6 +179,7 @@ per export and deletion in `account_data_events` (migration 0008).
| `bot.device.connect` | rotate and revoke a device credential |
| `bot.device.connect` | rotate and revoke a device credential |
| `bot.machine.ai` | OpenVibe.AI asks a person's own machine's model for them (kind `ai` only), and lists their machines whose owner turned on "Let my AI use this computer's models" |
| `bot.machine.ai` | OpenVibe.AI asks a person's own machine's model for them (kind `ai` only), and lists their machines whose owner turned on "Let my AI use this computer's models" |
| `bot.machine.manage` | the machines hub (OpenVibe.Network) acts for a person: `GET /machines`, `GET /machines/:id`, `POST /machines/pairings`, `PATCH /machines/:id`, `PUT /machines/:id/roles` and `POST /machines/:id/revoke`, naming the person in `X-OV-Subject`. Only the network's own service (`svc:network`) may: any other service with the capability is refused (`403 bot.forbidden`) |
| `bot.machine.manage` | the machines hub (OpenVibe.Network) acts for a person: `GET /machines`, `GET /machines/:id`, `POST /machines/pairings`, `PATCH /machines/:id`, `PUT /machines/:id/roles` and `POST /machines/:id/revoke`, naming the person in `X-OV-Subject`. Only the network's own service (`svc:network`) may: any other service with the capability is refused (`403 bot.forbidden`) |
+| `bot.machine.watch_link` | the owner's own agent (OpenVibe.Actor) asks for a link that shows the desk it works on, live, inside a chat: `POST /machines/:id/watch-links` with `{ desk, control, minutes }`, naming the person in `X-OV-Subject`. The machine must be that person's own (404, never 403), only `svc:actor` may (any other service is refused), the desk is the private desk or the agent's own `desk-<name>` (never the owner's screen, `main`), the link lives 5-240 minutes and carries the mouse and keyboard only when `control` is asked for |
| `bot.job.dispatch` | the internal Run → Bot jobs API (`POST /jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`); services only |
| `bot.job.dispatch` | the internal Run → Bot jobs API (`POST /jobs`, `POST /jobs/:id/cancel`, `GET /jobs/:id`); services only |
| `bot.resource.read` | the internal authority index (`GET /api/v1/resources`, `GET /api/v1/resources/:ovrn`); services only |
| `bot.resource.read` | the internal authority index (`GET /api/v1/resources`, `GET /api/v1/resources/:ovrn`); services only |
@@ -20,6 +20,7 @@
@@ -20,6 +20,7 @@
"bot.computer.control",
"bot.computer.control",
"bot.machine.ai",
"bot.machine.ai",
"bot.machine.manage",
"bot.machine.manage",
+ "bot.machine.watch_link",
"bot.desk.session",
"bot.desk.session",
"bot.device.connect",
"bot.device.connect",
"bot.job.dispatch",
"bot.job.dispatch",
@@ -63,7 +64,8 @@
@@ -63,7 +64,8 @@
"streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once",
"streaming and recording toggles per robot, off by default and owner-only: media records live sessions to OpenVibe.Media (the OpenRestream stream's recording_mode vod/none) and live shows them on the owner's OpenVibe.Live channel (mirror_to_live), read at GET /api/v1/robots/:id/streaming and set at POST /api/v1/robots/:id/streaming; Bot stores no copy, each real change audits once",
"server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects",
"server-side ONVIF cameras (profile camera.onvif, driver onvif, server/onvif): an in-process device beside the simulator attaches a camera configured with BOT_ONVIF_CAMERAS (its URL plus username_ref/password_ref secret references, never in the profile or in clear), writes a real kind server device row, translates ptz commands to ONVIF ContinuousMove/Stop with the profile deadline as a deadman, and carries the camera's GetStatus readout in telemetry; every request goes only to the configured host, with a timeout and no redirects",
"a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)",
"a person's own machines that run their jobs (/machines: add with a --worker command, list, remove); their worker offers reported to Network as user-owned offers naming the owner; jobs reach a machine only for its owner (plan T14/T16)",
- "machines hub (plan L34): Bot stays the registry; the hub's routes (GET /api/v1/machines, GET /api/v1/machines/:id, POST /api/v1/machines/pairings, PATCH /api/v1/machines/:id, PUT /api/v1/machines/:id/roles, POST /api/v1/machines/:id/revoke) serve the person and the network's own service (svc:network, and only that one) acting for them (bot.machine.manage, X-OV-Subject), with the machine's roles (migration 0028) in the device's config frame and its online state reported to Network once per change"
+ "machines hub (plan L34): Bot stays the registry; the hub's routes (GET /api/v1/machines, GET /api/v1/machines/:id, POST /api/v1/machines/pairings, PATCH /api/v1/machines/:id, PUT /api/v1/machines/:id/roles, POST /api/v1/machines/:id/revoke) serve the person and the network's own service (svc:network, and only that one) acting for them (bot.machine.manage, X-OV-Subject), with the machine's roles (migration 0028) in the device's config frame and its online state reported to Network once per change",
+ "watch links to a desk (plan L55): POST /api/v1/machines/:id/watch-links gives the owner's agent (svc:actor, bot.machine.watch_link) a link that shows the desk the agent works on, live, inside a chat (https://openvibe.bot/screen/<token>, no sign-in, framable on openvibe.chat and openvibe.actor); the machine is the one the named person owns, the link lives 5 to 240 minutes, and it carries the mouse and keyboard only when asked for"
],
],
"updated": "2026-11-01"
"updated": "2026-11-01"
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,6 @@
@@ -0,0 +1,6 @@
+-- phase: expand
+-- A screen share link may show any desk the machine has (plan L55): main, private, or desk-<name> — the desk an
+-- agent task works on, watched in a chat turn. The check grows; the links made before keep the desk they had.
+ALTER TABLE machine_screen_shares DROP CONSTRAINT IF EXISTS machine_screen_shares_desk_check;
+ALTER TABLE machine_screen_shares ADD CONSTRAINT machine_screen_shares_desk_check
+ CHECK (desk IN ('main', 'private') OR desk ~ '^desk-[a-z0-9][a-z0-9-]{0,23}$');
@@ -33,6 +33,7 @@
@@ -33,6 +33,7 @@
* | POST /jobs, /jobs/:id/cancel, GET /jobs/:id | bot.job.dispatch | — (services only) |
* | POST /jobs, /jobs/:id/cancel, GET /jobs/:id | bot.job.dispatch | — (services only) |
* | POST /pair | — (one-time code) | the agent (code is the credential) |
* | POST /pair | — (one-time code) | the agent (code is the credential) |
* | POST /claims | — (anyone) | a device asking to be added: a code |
* | POST /claims | — (anyone) | a device asking to be added: a code |
+ * | POST /machines/:id/watch-links | bot.machine.watch_link | the owner, or their agent for them |
* | GET /claims/:id | — (X-OV-Claim-Secret) | that device: pending, or its pairing|
* | GET /claims/:id | — (X-OV-Claim-Secret) | that device: pending, or its pairing|
*
*
* With BOT_PAIRING_AUTHORITY=network the pairing codes are Network's and POST /pair answers 410 bot.pairing_moved.
* With BOT_PAIRING_AUTHORITY=network the pairing codes are Network's and POST /pair answers 410 bot.pairing_moved.
@@ -43,6 +44,7 @@ const { parseSteps, stepHelp, AGENT_MAX_STEPS, AGENT_MAX_S } = require('../routi
@@ -43,6 +44,7 @@ const { parseSteps, stepHelp, AGENT_MAX_STEPS, AGENT_MAX_S } = require('../routi
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 { createMachineScreen } = require('../machine-screen');
+const { createScreenShares } = require('../screen-shares');
const { getProfile, listProfiles, checkProfile } = require('../profiles');
const { getProfile, listProfiles, checkProfile } = require('../profiles');
const machineRoles = require('../machine-roles');
const machineRoles = require('../machine-roles');
const kits = require('../kits');
const kits = require('../kits');
@@ -60,6 +62,8 @@ const CAP = {
@@ -60,6 +62,8 @@ const CAP = {
ai: 'bot.machine.ai',
ai: 'bot.machine.ai',
// The network's machines hub (plan L34): a service acts for a person on their own machines.
// The network's machines hub (plan L34): a service acts for a person on their own machines.
machine: 'bot.machine.manage',
machine: 'bot.machine.manage',
+ // A watch link to a machine's desk (plan L55): the owner's agent shows the desk it works on, inside a chat.
+ watchLink: 'bot.machine.watch_link',
};
};
/** Services that act as a person's agent (plan T15/T17): they drive a robot only where its owner turned agent_control on. */
/** Services that act as a person's agent (plan T15/T17): they drive a robot only where its owner turned agent_control on. */
@@ -80,6 +84,12 @@ const MACHINE_SERVICES = {
@@ -80,6 +84,12 @@ const MACHINE_SERVICES = {
const HUB_SERVICE = 'svc:network';
const HUB_SERVICE = 'svc:network';
// A model on a person's machine is OpenVibe.AI's and the owner's only: no other service sends kind ai.
// A model on a person's machine is OpenVibe.AI's and the owner's only: no other service sends kind ai.
const AI_KIND = 'ai';
const AI_KIND = 'ai';
+/**
+ * The services that may make a watch link to a person's machine desk (plan L55): the owner's own agent, which shows
+ * the desk it works on in a chat. The capability alone is not enough — a service holding it could name any person in
+ * X-OV-Subject — so the service is this one, and the machine is the named person's own.
+ */
+const WATCH_LINK_SERVICES = new Set(['svc:actor']);
function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null, claims = null }) {
function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null, claims = null }) {
const r = express.Router();
const r = express.Router();
@@ -92,6 +102,9 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -92,6 +102,9 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
const manage = limits('bot.robot.manage', { minute: 30, hour: 300 });
const manage = limits('bot.robot.manage', { minute: 30, hour: 300 });
const control = limits('bot.control', { minute: 120, hour: 1200 });
const control = limits('bot.control', { minute: 120, hour: 1200 });
const machineScreen = createMachineScreen({ domain, hub });
const machineScreen = createMachineScreen({ domain, hub });
+ // The share links themselves (server/screen-shares.js): the web page makes them for the owner, this route for the
+ // owner's agent, both on the same table.
+ const shares = createScreenShares({ db: domain.db, domain, hub, machineScreen });
// 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
@@ -102,6 +115,8 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -102,6 +115,8 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
const pair = limits('bot.pair', { minute: 20, hour: 200 });
const pair = limits('bot.pair', { minute: 20, hour: 200 });
// The hub's routes: a service naming a person is counted against that person, as their own token is.
// The hub's routes: a service naming a person is counted against that person, as their own token is.
const machineManage = limits.actedFor('bot.machine.manage', { minute: 30, hour: 300 });
const machineManage = limits.actedFor('bot.machine.manage', { minute: 30, hour: 300 });
+ // A watch link (plan L55): counted against the person whose desk it is, and against the agent's own token.
+ const watchLink = limits.actedFor('bot.machine.watch_link', { minute: 30, hour: 300 }, { minute: 60, hour: 600 });
const jobs = limits('bot.jobs', { minute: 120, hour: 1200 });
const jobs = limits('bot.jobs', { minute: 120, hour: 1200 });
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
@@ -596,6 +611,43 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -596,6 +611,43 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.json({ machine: machineOut(updated) });
res.json({ machine: machineOut(updated) });
}));
}));
+ // ── A watch link to a machine's desk (plan L55) ─────────────────────────────────────────────────
+ // The link shows the desk the agent works on, live, inside a chat turn: https://openvibe.bot/screen/<token>.
+ // The page needs no sign-in (the token is the credential) and may sit in a frame on openvibe.chat or
+ // openvibe.actor. It ends in minutes, and it is the owner's own link: the machine is the one the subject owns.
+ r.post('/machines/:id/watch-links', watchLink, wrap(async (req, res) => {
+ const subject = actedFor(req);
+ if (req.principal.kind === 'service') {
+ if (!WATCH_LINK_SERVICES.has(req.principal.sub)) fail(403, 'bot.forbidden', 'watch links are for the owner\'s agent');
+ if (!apiAuth.granted(req.principal, CAP.watchLink)) fail(403, 'capability.denied', `${CAP.watchLink} not granted`);
+ }
+ const device = await domain.devices.get(String(req.params.id || '').slice(0, 64));
+ const ownerSubject = device && !device.revoked_at ? await domain.devices.ownerOf(device) : null;
+ // 404, never 403: nobody may learn whose machine an id is.
+ if (!ownerSubject || ownerSubject !== subject) fail(404, 'bot.device_not_found', 'no such machine of yours');
+ // The agent reaches a machine only where its owner turned "Let my agent use this computer" on.
+ if (req.principal.kind === 'service' && !device.agent_computer) fail(403, 'bot.agent_not_allowed', 'the owner has not let their agent use this computer');
+ const desk = req.body?.desk == null || req.body.desk === '' ? (req.principal.kind === 'service' ? '' : 'main') : String(req.body.desk);
+ // The agent shows the desk it works on: the private desk or its own desk-<name>, never the owner's own screen
+ // (main). The owner, signed in, may link any of their desks.
+ // (A malformed name is create()'s to refuse, as bot.invalid_input.)
+ if (req.principal.kind === 'service' && (desk === '' || desk === 'main')) fail(422, 'bot.desk_not_agents', 'an agent links the private desk or its own desk-<name>, never the owner\'s screen');
+ const made = await shares.create(device, subject, {
+ label: req.body?.label || null,
+ control: req.body?.control === true, // the mouse and keyboard only when asked for
+ minutes: req.body?.minutes == null || req.body.minutes === '' ? 60 : req.body.minutes,
+ desk,
+ });
+ await domain.audit.record({
+ robotId: device.id, deviceId: device.id, subject, operatorKind: req.principal.kind, role: 'owner',
+ kind: 'screen.share', value: { desk: made.share.desk, control: made.share.control, expires_at: made.share.expires_at }, result: 'ack',
+ });
+ res.status(201).json({
+ url: `${config.baseUrl}/screen/${made.token}`, expires_at: made.share.expires_at,
+ desk: made.share.desk, control: made.share.control, share_id: made.share.id,
+ });
+ }));
+
// ── The machines hub (plan L34, step M2) ───────────────────────────────────────────────────────
// ── The machines hub (plan L34, step M2) ───────────────────────────────────────────────────────
// The machine's owner, or the network's hub (OpenVibe.Network) acting for them: a service holding
// The machine's owner, or the network's hub (OpenVibe.Network) acting for them: a service holding
// bot.machine.manage and naming the person in X-OV-Subject. Bot stays the registry underneath, so the
// bot.machine.manage and naming the person in X-OV-Subject. Bot stays the registry underneath, so the
@@ -13,9 +13,10 @@
@@ -13,9 +13,10 @@
* the machine is removed. Nothing here lets a holder run programs, read files or point the screen elsewhere: only the
* the machine is removed. Nothing here lets a holder run programs, read files or point the screen elsewhere: only the
* screen's start and stop, and the mouse and keyboard.
* screen's start and stop, and the mouse and keyboard.
*
*
- * create(device, owner, { label, control, days, desk }) → { share, token }; desk `private`: the link shows and
- * drives the machine's private desk (where an agent works), never the
- * owner's own screen
+ * create(device, owner, { label, control, days, minutes, desk }) → { share, token }; the desk is any desk the
+ * machine has (main, private or desk-<name>: an agent task's own
+ * desk, plan L55), and the link ends in days or, when `minutes` is
+ * given, in 5 to 240 minutes
* list(device, owner) / revoke(device, owner, id)
* list(device, owner) / revoke(device, owner, id)
* open(token) → { share, device } or null
* open(token) → { share, device } or null
* state(token) / start(token) / stop(token) the screen, through the link
* state(token) / start(token) / stop(token) the screen, through the link
@@ -23,32 +24,44 @@
@@ -23,32 +24,44 @@
* move, scroll, type, key) → the machine's answer
* move, scroll, type, key) → the machine's answer
*/
*/
const { BotError, prefixedId, sha256, token: newToken } = require('./util');
const { BotError, prefixedId, sha256, token: newToken } = require('./util');
-const { inputValue, ACTIONS } = require('./machine-screen');
+const { inputValue, ACTIONS, DESK } = require('./machine-screen');
const DAYS = { min: 1, max: 365, default: 30 };
const DAYS = { min: 1, max: 365, default: 30 };
+// A link made in minutes (an agent's desk, watched for one turn) ends sooner than one made in days (plan L55).
+const MINUTES = { min: 5, max: 240, default: 60 };
const PER_SECOND = 8; // a person clicks and types a few times a second, not more
const PER_SECOND = 8; // a person clicks and types a few times a second, not more
function createScreenShares({ db, domain, hub, machineScreen, now = () => Date.now() }) {
function createScreenShares({ db, domain, hub, machineScreen, now = () => Date.now() }) {
const rate = new Map(); // share id → recent input timestamps (this process)
const rate = new Map(); // share id → recent input timestamps (this process)
const shape = (r) => ({
const shape = (r) => ({
- id: r.id, device_id: r.device_id, label: r.label || null, control: !!r.control, desk: r.desk === 'private' ? 'private' : 'main',
+ id: r.id, device_id: r.device_id, label: r.label || null, control: !!r.control, desk: r.desk || 'main',
created_at: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
created_at: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
expires_at: r.expires_at ? (r.expires_at instanceof Date ? r.expires_at.toISOString() : r.expires_at) : null,
expires_at: r.expires_at ? (r.expires_at instanceof Date ? r.expires_at.toISOString() : r.expires_at) : null,
last_used_at: r.last_used_at ? (r.last_used_at instanceof Date ? r.last_used_at.toISOString() : r.last_used_at) : null,
last_used_at: r.last_used_at ? (r.last_used_at instanceof Date ? r.last_used_at.toISOString() : r.last_used_at) : null,
});
});
- async function create(device, owner, { label = null, control = false, days = DAYS.default, desk = 'main' } = {}) {
+ async function create(device, owner, { label = null, control = false, days = DAYS.default, minutes = null, desk = 'main' } = {}) {
if (!device || device.owner_subject !== owner) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');
if (!device || device.owner_subject !== owner) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');
- const d = Number(days);
- if (!Number.isInteger(d) || d < DAYS.min || d > DAYS.max) throw new BotError(422, 'bot.invalid_input', `days is a whole number from ${DAYS.min} to ${DAYS.max}`);
+ // Any desk the machine has: its own screen (main), the private desk, or an agent task's own desk (desk-<name>).
const where = desk == null || desk === '' ? 'main' : String(desk);
const where = desk == null || desk === '' ? 'main' : String(desk);
- if (where !== 'main' && where !== 'private') throw new BotError(422, 'bot.invalid_input', 'desk is main or private');
+ if (!DESK.test(where)) throw new BotError(422, 'bot.invalid_input', 'desk is main, private or desk-<name>');
+ // A link may end in minutes (an agent's desk, watched for one turn) or in days (the owner's own link).
+ let ends;
+ if (minutes != null && minutes !== '') {
+ const m = Number(minutes);
+ if (!Number.isInteger(m) || m < MINUTES.min || m > MINUTES.max) throw new BotError(422, 'bot.invalid_input', `minutes is a whole number from ${MINUTES.min} to ${MINUTES.max}`);
+ ends = now() + m * 60000;
+ } else {
+ const d = Number(days);
+ if (!Number.isInteger(d) || d < DAYS.min || d > DAYS.max) throw new BotError(422, 'bot.invalid_input', `days is a whole number from ${DAYS.min} to ${DAYS.max}`);
+ ends = now() + d * 86400000;
+ }
const id = prefixedId('shr', now());
const id = prefixedId('shr', now());
const t = newToken(32);
const t = newToken(32);
const l = label == null ? null : String(label).replace(/\s+/g, ' ').trim().slice(0, 80) || null;
const l = label == null ? null : String(label).replace(/\s+/g, ' ').trim().slice(0, 80) || null;
await db.query(`INSERT INTO machine_screen_shares (id, device_id, owner_subject, token_hash, label, control, created_at, expires_at, desk)
await db.query(`INSERT INTO machine_screen_shares (id, device_id, owner_subject, token_hash, label, control, created_at, expires_at, desk)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [id, device.id, owner, sha256(t), l, !!control, new Date(now()), new Date(now() + d * 86400000), where]);
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [id, device.id, owner, sha256(t), l, !!control, new Date(now()), new Date(ends), where]);
const row = await db.maybe('SELECT * FROM machine_screen_shares WHERE id = $1', [id]);
const row = await db.maybe('SELECT * FROM machine_screen_shares WHERE id = $1', [id]);
return { share: shape(row), token: t };
return { share: shape(row), token: t };
}
}
@@ -89,12 +102,12 @@ function createScreenShares({ db, domain, hub, machineScreen, now = () => Date.n
@@ -89,12 +102,12 @@ function createScreenShares({ db, domain, hub, machineScreen, now = () => Date.n
async function state(t) {
async function state(t) {
const s = await must(t);
const s = await must(t);
const st = await machineScreen.state(s.device, s.share.owner_subject);
const st = await machineScreen.state(s.device, s.share.owner_subject);
- return { ...st, name: s.device.name || 'A computer', control: !!s.share.control, label: s.share.label || null, desk: s.share.desk === 'private' ? 'private' : 'main' };
+ return { ...st, name: s.device.name || 'A computer', control: !!s.share.control, label: s.share.label || null, desk: s.share.desk || 'main' };
}
}
async function start(t) {
async function start(t) {
const s = await must(t, { control: true });
const s = await must(t, { control: true });
- return await machineScreen.start(s.device, s.share.owner_subject, asShare(s), { seconds: 1800, fps: 4, desk: s.share.desk === 'private' ? 'private' : 'main' });
+ return await machineScreen.start(s.device, s.share.owner_subject, asShare(s), { seconds: 1800, fps: 4, desk: s.share.desk || 'main' });
}
}
async function stop(t) {
async function stop(t) {
@@ -107,7 +120,7 @@ function createScreenShares({ db, domain, hub, machineScreen, now = () => Date.n
@@ -107,7 +120,7 @@ function createScreenShares({ db, domain, hub, machineScreen, now = () => Date.n
const s = await must(t, { control: true });
const s = await must(t, { control: true });
if (!hub) throw new BotError(503, 'bot.unavailable', 'machines are not connected here');
if (!hub) throw new BotError(503, 'bot.unavailable', 'machines are not connected here');
const out = inputValue(value);
const out = inputValue(value);
- if (s.share.desk === 'private') out.desk = 'private'; // a desk link drives the desk, never the owner's screen
+ if (s.share.desk && s.share.desk !== 'main') out.desk = s.share.desk; // a desk link drives the desk, never the owner's screen
const recent = (rate.get(s.share.id) || []).filter((x) => now() - x < 1000);
const recent = (rate.get(s.share.id) || []).filter((x) => now() - x < 1000);
if (recent.length >= PER_SECOND) throw new BotError(429, 'bot.rate_limited', 'slow down a little');
if (recent.length >= PER_SECOND) throw new BotError(429, 'bot.rate_limited', 'slow down a little');
recent.push(now());
recent.push(now());
@@ -118,7 +131,7 @@ function createScreenShares({ db, domain, hub, machineScreen, now = () => Date.n
@@ -118,7 +131,7 @@ function createScreenShares({ db, domain, hub, machineScreen, now = () => Date.n
throw new BotError(r && r.result === 'expired' ? 504 : 409, code.startsWith('bot.') ? code : 'bot.device_refused', `the computer did not do it: ${(r && (r.reason || r.result)) || 'no answer'}`.slice(0, 300));
throw new BotError(r && r.result === 'expired' ? 504 : 409, code.startsWith('bot.') ? code : 'bot.device_refused', `the computer did not do it: ${(r && (r.reason || r.result)) || 'no answer'}`.slice(0, 300));
}
}
- return { create, list, revoke, open, state, start, stop, input, DAYS };
+ return { create, list, revoke, open, state, start, stop, input, DAYS, MINUTES };
}
}
module.exports = { createScreenShares, ACTIONS };
module.exports = { createScreenShares, ACTIONS };
@@ -861,7 +861,7 @@ function sharesBlock(machine, shares, madeLink, madeShare, hasDesk = false, hasS
@@ -861,7 +861,7 @@ function sharesBlock(machine, shares, madeLink, madeShare, hasDesk = false, hasS
const which = !hasScreen
const which = !hasScreen
? '<input type="hidden" name="desk" value="private"><p class="muted small">A link here shares the private desk: this computer has no screen of its own.</p>'
? '<input type="hidden" name="desk" value="private"><p class="muted small">A link here shares the private desk: this computer has no screen of its own.</p>'
: hasDesk ? '<label class="field"><span class="field-label">Which screen</span><select name="desk"><option value="main">Your own screen</option><option value="private">The private desk, where your agent works</option></select></label>' : '';
: hasDesk ? '<label class="field"><span class="field-label">Which screen</span><select name="desk"><option value="main">Your own screen</option><option value="private">The private desk, where your agent works</option></select></label>' : '';
- const rows = (shares || []).map((x) => `<li><strong>${esc(x.label || 'A link')}</strong> · ${x.desk === 'private' ? 'the private desk · ' : ''}${x.control ? 'watch and use' : 'watch only'} · until <time datetime="${esc(x.expires_at || '')}" data-local-time>${esc(String(x.expires_at || '').slice(0, 10))}</time>${x.last_used_at ? ` · last opened <time datetime="${esc(x.last_used_at)}" data-local-time>${esc(String(x.last_used_at).slice(0, 16).replace('T', ' '))}</time>` : ''}
+ const rows = (shares || []).map((x) => `<li><strong>${esc(x.label || 'A link')}</strong> · ${x.desk && x.desk !== 'main' ? `the ${esc(x.desk)} desk · ` : ''}${x.control ? 'watch and use' : 'watch only'} · until <time datetime="${esc(x.expires_at || '')}" data-local-time>${esc(String(x.expires_at || '').slice(0, 10))}</time>${x.last_used_at ? ` · last opened <time datetime="${esc(x.last_used_at)}" data-local-time>${esc(String(x.last_used_at).slice(0, 16).replace('T', ' '))}</time>` : ''}
<form method="post" action="/machines/${esc(machine.id)}/shares/${esc(x.id)}/revoke" class="inline-form"><button type="submit" class="button quiet">Stop this link</button></form></li>`).join('');
<form method="post" action="/machines/${esc(machine.id)}/shares/${esc(x.id)}/revoke" class="inline-form"><button type="submit" class="button quiet">Stop this link</button></form></li>`).join('');
return `<section class="machine-shares" aria-labelledby="shares-h"><h2 id="shares-h">Share this screen</h2>
return `<section class="machine-shares" aria-labelledby="shares-h"><h2 id="shares-h">Share this screen</h2>
<p class="muted">Make a link for a group: put it in a workspace room on OpenVibe.Chat (the staff room's Screen) or anywhere people work together. <strong>Watch</strong> shows the screen live. <strong>Watch and use</strong> also lets them start it and use the mouse and keyboard, at the level you set on this computer: at <code>ask</code> each action waits for your approval there, and at <code>observe</code> nothing happens. Nobody can run programs or read files through a link.</p>
<p class="muted">Make a link for a group: put it in a workspace room on OpenVibe.Chat (the staff room's Screen) or anywhere people work together. <strong>Watch</strong> shows the screen live. <strong>Watch and use</strong> also lets them start it and use the mouse and keyboard, at the level you set on this computer: at <code>ask</code> each action waits for your approval there, and at <code>observe</code> nothing happens. Nobody can run programs or read files through a link.</p>
@@ -885,7 +885,7 @@ function renderSharePage({ token, name, control, label, desk = 'main' }) {
@@ -885,7 +885,7 @@ function renderSharePage({ token, name, control, label, desk = 'main' }) {
const base = `/screen/${encodeURIComponent(token)}`;
const base = `/screen/${encodeURIComponent(token)}`;
const body = `<main class="page share-page" data-share="${esc(base)}" data-control="${control ? '1' : '0'}">
const body = `<main class="page share-page" data-share="${esc(base)}" data-control="${control ? '1' : '0'}">
<header class="share-head"><h1>${esc(name)}${label ? ` <span class="muted">· ${esc(label)}</span>` : ''}</h1>
<header class="share-head"><h1>${esc(name)}${label ? ` <span class="muted">· ${esc(label)}</span>` : ''}</h1>
-<p class="muted share-mode">${control ? 'Shared to watch and use' : 'Shared to watch'}${desk === 'private' ? ': the private desk, where an agent works (never the owner\'s own screen)' : ''}</p></header>
+<p class="muted share-mode">${control ? 'Shared to watch and use' : 'Shared to watch'}${desk && desk !== 'main' ? `: the ${esc(desk)} desk, where an agent works (never the owner\'s own screen)` : ''}</p></header>
<figure class="camera machine-screen share-screen" data-screen-state="${esc(base)}/state.json" tabindex="${control ? '0' : '-1'}" aria-label="The shared screen${control ? ': turn on Use to click and type on it' : ''}">
<figure class="camera machine-screen share-screen" data-screen-state="${esc(base)}/state.json" tabindex="${control ? '0' : '-1'}" aria-label="The shared screen${control ? ': turn on Use to click and type on it' : ''}">
<div class="camera-screen" data-camera-screen>${icon('plug', 'icon camera-icon')}<p class="camera-title" data-screen-status role="status">Looking for the screen…</p>
<div class="camera-screen" data-camera-screen>${icon('plug', 'icon camera-icon')}<p class="camera-title" data-screen-status role="status">Looking for the screen…</p>
<p class="camera-sub">The picture comes from the computer itself, a few frames a second.</p></div>
<p class="camera-sub">The picture comes from the computer itself, a few frames a second.</p></div>
@@ -728,12 +728,12 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -728,12 +728,12 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const shares = createScreenShares({ db: domain && domain.db, domain, hub, machineScreen });
const shares = createScreenShares({ db: domain && domain.db, domain, hub, machineScreen });
// A share page may sit in OpenVibe's own workspaces (a room on OpenVibe.Chat, the admin panel) and the configured embeds.
// A share page may sit in OpenVibe's own workspaces (a room on OpenVibe.Chat, the admin panel) and the configured embeds.
const shareCsp = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,
const shareCsp = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,
- `frame-ancestors ${frameAncestors(config)} https://openvibe.chat https://openvibe.network`, "object-src 'none'", "base-uri 'self'"].join('; ');
+ `frame-ancestors ${frameAncestors(config)} https://openvibe.chat https://openvibe.network https://openvibe.actor`, "object-src 'none'", "base-uri 'self'"].join('; ');
r.post('/machines/:id/shares', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
r.post('/machines/:id/shares', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
const mine = await myMachine(req, me);
const mine = await myMachine(req, me);
- const made = await shares.create(mine, me.subject, { label: req.body && req.body.label, control: req.body && req.body.control === '1', days: (req.body && req.body.days) || 30, desk: req.body && req.body.desk === 'private' ? 'private' : 'main' });
+ const made = await shares.create(mine, me.subject, { label: req.body && req.body.label, control: req.body && req.body.control === '1', days: (req.body && req.body.days) || 30, desk: req.body && req.body.desk });
res.setHeader('Content-Security-Policy', PANEL_CSP);
res.setHeader('Content-Security-Policy', PANEL_CSP);
res.type('html').send(renderMachineScreenPage({ machine: mine, idle: true, shares: await shares.list(mine, me.subject), madeLink: `${config.baseUrl}/screen/${made.token}`, madeShare: made.share, hasDesk: hasDesk(mine), hasScreen: hasScreen(mine) }));
res.type('html').send(renderMachineScreenPage({ machine: mine, idle: true, shares: await shares.list(mine, me.subject), madeLink: `${config.baseUrl}/screen/${made.token}`, madeShare: made.share, hasDesk: hasDesk(mine), hasScreen: hasScreen(mine) }));
}));
}));
@@ -759,7 +759,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -759,7 +759,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
res.setHeader('Content-Security-Policy', shareCsp);
res.setHeader('Content-Security-Policy', shareCsp);
const s = await shares.open(req.params.token).catch(() => null);
const s = await shares.open(req.params.token).catch(() => null);
if (!s) return res.status(404).type('html').send(renderShareGone());
if (!s) return res.status(404).type('html').send(renderShareGone());
- res.type('html').send(renderSharePage({ token: req.params.token, name: s.device.name || 'A computer', control: !!s.share.control, label: s.share.label, desk: s.share.desk === 'private' ? 'private' : 'main' }));
+ res.type('html').send(renderSharePage({ token: req.params.token, name: s.device.name || 'A computer', control: !!s.share.control, label: s.share.label, desk: s.share.desk || 'main' }));
});
});
r.get('/screen/:token/state.json', shareJson(async (req) => await shares.state(req.params.token)));
r.get('/screen/:token/state.json', shareJson(async (req) => await shares.state(req.params.token)));
r.post('/screen/:token/start', ...shareLimit, shareJson(async (req) => await shares.start(req.params.token)));
r.post('/screen/:token/start', ...shareLimit, shareJson(async (req) => await shares.start(req.params.token)));
@@ -62,6 +62,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -62,6 +62,7 @@ const { boot, check, done } = require('./helpers/app');
let r = await fetch(`${t.base}/screen/${watch}`);
let r = await fetch(`${t.base}/screen/${watch}`);
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
assert.match(r.headers.get('content-security-policy'), /frame-ancestors [^;]*https:\/\/openvibe\.chat/);
assert.match(r.headers.get('content-security-policy'), /frame-ancestors [^;]*https:\/\/openvibe\.chat/);
+ assert.match(r.headers.get('content-security-policy'), /frame-ancestors [^;]*https:\/\/openvibe\.actor/);
assert.strictEqual(r.headers.get('referrer-policy'), 'no-referrer');
assert.strictEqual(r.headers.get('referrer-policy'), 'no-referrer');
let html = await r.text();
let html = await r.text();
assert.match(html, /staff-desk/);
assert.match(html, /staff-desk/);
@@ -124,6 +125,20 @@ const { boot, check, done } = require('./helpers/app');
@@ -124,6 +125,20 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await t.db.maybe("SELECT desk FROM machine_screen_shares WHERE label = 'Agent desk'")).desk, 'private');
assert.strictEqual((await t.db.maybe("SELECT desk FROM machine_screen_shares WHERE label = 'Agent desk'")).desk, 'private');
});
});
+ await check('a link may show any desk the machine has, not only the private one (plan L55)', async () => {
+ let r = await form(`/machines/${deviceId}/shares`, alex, 'label=Task+desk&days=1&control=1&desk=desk-task-a');
+ const html = await r.text();
+ const task = linkOf(html);
+ assert.ok(task, 'a link to a named desk');
+ assert.match(html, /the desk-task-a desk/);
+ assert.match(await (await fetch(`${t.base}/screen/${task}`)).text(), /the desk-task-a desk/);
+ assert.strictEqual((await json('GET', `/screen/${task}/state.json`)).json.desk, 'desk-task-a');
+ assert.strictEqual((await t.db.maybe("SELECT desk FROM machine_screen_shares WHERE label = 'Task desk'")).desk, 'desk-task-a');
+ // A desk the machine does not have is refused by the contract, not stored.
+ r = await form(`/machines/${deviceId}/shares`, alex, 'label=Bad&days=1&desk=desk-../x');
+ assert.ok(r.status >= 400, 'not a desk name');
+ });
+
await check('a burst is slowed down; a revoked link stops at once', async () => {
await check('a burst is slowed down; a revoked link stops at once', async () => {
let limited = 0;
let limited = 0;
for (let i = 0; i < 12; i++) { if ((await json('POST', `/screen/${use}/input`, { action: 'move', x: i, y: i, width: 100, height: 100 })).status === 429) limited++; }
for (let i = 0; i < 12; i++) { if ((await json('POST', `/screen/${use}/input`, { action: 'move', x: i, y: i, width: 100, height: 100 })).status === 429) limited++; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
+'use strict';
+// A watch link to a machine's desk (plan L55): the owner's agent (svc:actor with bot.machine.watch_link) asks Bot for a
+// link that shows the desk it works on, live, inside a chat turn. The link is the owner's own (the machine is the one
+// the named subject owns), it needs no sign-in, it may sit in a frame on openvibe.chat or openvibe.actor, and it ends
+// in minutes. Another person's machine is not there at all (404), another service may not make one, and the mouse and
+// keyboard come with it only when asked for.
+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 COMPUTER = { screen: { level: 'trusted', tool: 'grim', stream: true }, input: { level: 'trusted' }, files: { level: 'trusted', roots: ['/home/alex'] }, terminal: { level: 'trusted' },
+ desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } };
+ const seen = [];
+
+ /** A Network-paired machine of `owner` whose fake Node answers screen and input like the plugin would. */
+ async function machine(owner, computer = COMPUTER) {
+ const principal = t.network.addNode({ owner: owner.subject, ref: `worker:${owner.subject}`, name: 'desk-pc' });
+ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
+ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;
+ const config = await c.waitFor((m) => m.type === 'config');
+ c.send(frame('status', { capabilities: { computer }, drivers: [], faults: [] }));
+ c.send(frame('telemetry', { sensors: { computer_level: 'trusted', 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, seconds: m.value.seconds, fps: m.value.fps, until_ms: Date.now() + m.value.seconds * 1000 } }));
+ else c.send(frame('ack', { id: m.id, data: { done: true } }));
+ });
+ const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, Math.max(100, Math.floor(config.heartbeat_ms / 2)));
+ c.ws.on('close', () => clearInterval(beat));
+ await poll(() => t.hub.computerOf(deviceId) !== null);
+ return { c, deviceId };
+ }
+ const actor = (cap = ['bot.machine.watch_link']) => t.network.signService({ sub: 'svc:actor', cap });
+ const asActor = (token, subject) => ({ token, headers: { 'X-OV-Subject': subject } });
+ const link = (deviceId, body, opts) => t.call('POST', `/api/v1/machines/${deviceId}/watch-links`, { body, ...opts });
+
+ let mine, theirs;
+ await check('the agent asks for a link to the desk it works on; the link is the owner\'s own and shows it', async () => {
+ mine = await machine(alex);
+ theirs = await machine(bob);
+ await t.call('POST', `/api/v1/machines/${mine.deviceId}/agent-computer`, { user: alex, body: { on: true } });
+ const r = await link(mine.deviceId, { desk: 'desk-task-a', minutes: 30 }, asActor(actor(), alex.subject));
+ assert.strictEqual(r.status, 201, r.text);
+ assert.match(r.json.url, /^https?:\/\/[^/]+\/screen\/[A-Za-z0-9_-]{40,60}$/, 'the link is a page on openvibe.bot');
+ assert.ok(r.json.expires_at, 'it says when it ends');
+ const left = new Date(r.json.expires_at).getTime() - Date.now();
+ assert.ok(left > 29 * 60000 && left <= 30 * 60000, 'thirty minutes, more or less');
+ assert.deepStrictEqual([r.json.desk, r.json.control], ['desk-task-a', false], 'watching only, unless asked for');
+ // The page itself: no sign-in, and it may sit in a frame on OpenVibe\'s own services.
+ const token = r.json.url.split('/').pop();
+ const page = await fetch(t.base + `/screen/${token}`);
+ assert.strictEqual(page.status, 200);
+ assert.match(page.headers.get('content-security-policy'), /frame-ancestors [^;]*https:\/\/openvibe\.actor/);
+ assert.match(page.headers.get('content-security-policy'), /frame-ancestors [^;]*https:\/\/openvibe\.chat/);
+ assert.match(await page.text(), /desk-task-a desk/);
+ assert.strictEqual((await fetch(t.base + `/screen/${token}/state.json`)).status, 200);
+ });
+
+ await check('another person\'s machine is not there at all, and another service may not make a link', async () => {
+ const other = await link(mine.deviceId, { desk: 'main' }, asActor(actor(), bob.subject));
+ assert.strictEqual(other.status, 404, 'never 403: nobody may learn whose machine an id is');
+ assert.strictEqual(other.json.code, 'bot.device_not_found');
+ // The agent may not make a link for a machine the owner never opened to it.
+ await t.call('POST', `/api/v1/machines/${theirs.deviceId}/agent-computer`, { user: bob, body: { on: false } });
+ const notOpen = await link(theirs.deviceId, { desk: 'main' }, asActor(actor(), bob.subject));
+ assert.strictEqual(notOpen.status, 403);
+ assert.strictEqual(notOpen.json.code, 'bot.agent_not_allowed');
+ // Another service, even holding the capability, is not the owner\'s agent.
+ const other1 = await link(mine.deviceId, { desk: 'main' }, { token: t.network.signService({ sub: 'svc:live', cap: ['bot.machine.watch_link'] }), headers: { 'X-OV-Subject': alex.subject } });
+ assert.strictEqual(other1.status, 403);
+ assert.strictEqual(other1.json.code, 'bot.forbidden');
+ // The agent shows its own desk, never the owner's screen, and names the desk it means.
+ const ownScreen = await link(mine.deviceId, { desk: 'main', minutes: 30 }, asActor(actor(), alex.subject));
+ assert.strictEqual(ownScreen.status, 422);
+ assert.strictEqual(ownScreen.json.code, 'bot.desk_not_agents');
+ const unnamed = await link(mine.deviceId, { minutes: 30 }, asActor(actor(), alex.subject));
+ assert.strictEqual(unnamed.status, 422, 'no desk named is not the owner\'s screen either');
+ assert.strictEqual((await link(mine.deviceId, { desk: 'private', minutes: 30 }, asActor(actor(), alex.subject))).status, 201, 'the private desk is an agent\'s');
+ // The agent without the capability has no route at all.
+ const noCap = await link(mine.deviceId, { desk: 'main' }, asActor(actor(['bot.computer.control']), alex.subject));
+ assert.strictEqual(noCap.status, 403);
+ assert.strictEqual(noCap.json.code, 'capability.denied');
+ });
+
+ await check('the link ends in minutes, and the desk is any agent desk the machine has', async () => {
+ const soon = await link(mine.deviceId, { desk: 'desk-task-a', minutes: 5 }, asActor(actor(), alex.subject));
+ assert.strictEqual(soon.status, 201, soon.text);
+ const five = new Date(soon.json.expires_at).getTime() - Date.now();
+ assert.ok(five > 4 * 60000 && five <= 5 * 60000, 'five minutes');
+ for (const minutes of [4, 241, 0, 'soon', 1.5]) {
+ const bad = await link(mine.deviceId, { desk: 'desk-task-a', minutes }, asActor(actor(), alex.subject));
+ assert.strictEqual(bad.status, 422, `minutes ${minutes}`);
+ assert.strictEqual(bad.json.code, 'bot.invalid_input');
+ }
+ for (const desk of ['private', 'desk-task-a', 'desk-a-b-9']) {
+ const ok = await link(mine.deviceId, { desk, minutes: 60 }, asActor(actor(), alex.subject));
+ assert.strictEqual(ok.status, 201, `desk ${desk}`);
+ assert.strictEqual(ok.json.desk, desk);
+ }
+ for (const desk of ['desk-../x', 'desk-', 'DESK-a', 'other', 'desk-' + 'x'.repeat(30)]) {
+ const bad = await link(mine.deviceId, { desk, minutes: 60 }, asActor(actor(), alex.subject));
+ assert.strictEqual(bad.status, 422, `desk ${desk}`);
+ assert.strictEqual(bad.json.code, 'bot.invalid_input');
+ }
+ });
+
+ await check('the mouse and the keyboard come with the link only when asked for', async () => {
+ const plain = await link(mine.deviceId, { desk: 'desk-task-a', minutes: 30 }, asActor(actor(), alex.subject));
+ const token = plain.json.url.split('/').pop();
+ const html = await (await fetch(t.base + `/screen/${token}`)).text();
+ assert.ok(!html.includes('screen-share.js'), 'a watch link has no controls');
+ assert.strictEqual((await fetch(t.base + `/screen/${token}/start`, { method: 'POST' })).status, 403);
+ const use = await link(mine.deviceId, { desk: 'desk-task-a', control: true, minutes: 30 }, asActor(actor(), alex.subject));
+ assert.strictEqual(use.json.control, true);
+ const useToken = use.json.url.split('/').pop();
+ const useHtml = await (await fetch(t.base + `/screen/${useToken}`)).text();
+ assert.match(useHtml, /screen-share\.js/);
+ assert.strictEqual((await fetch(t.base + `/screen/${useToken}/start`, { method: 'POST' })).status, 200);
+ const start = seen.filter((m) => m.kind === 'screen_stream').at(-1);
+ assert.strictEqual(start.value.desk, 'desk-task-a', 'the live screen of that desk');
+ });
+
+ await check('the owner may make one themselves; the making of it is audited', async () => {
+ const own = await link(mine.deviceId, { desk: 'desk-task-a', minutes: 30 }, { user: alex });
+ assert.strictEqual(own.status, 201, own.text);
+ assert.ok(own.json.url.includes('/screen/'));
+ const audit = await t.db.many("SELECT operator_subject, operator_kind, kind, value, result FROM command_audit WHERE robot_id = $1 AND kind = 'screen.share' ORDER BY id", [mine.deviceId]);
+ assert.ok(audit.length >= 1, 'the link is in the audit');
+ assert.deepStrictEqual(audit.at(-1).value.desk, 'desk-task-a');
+ assert.strictEqual(audit.at(-1).result, 'ack');
+ assert.strictEqual(audit.at(-1).operator_subject, alex.subject);
+ });
+
+ await check('too many links for one person are refused', async () => {
+ let refused = 0;
+ for (let i = 0; i < 80; i++) {
+ const r = await link(mine.deviceId, { desk: 'main', minutes: 5 }, asActor(actor(), alex.subject));
+ if (r.status === 429) { refused++; break; }
+ }
+ assert.strictEqual(refused, 1, 'rate-limited');
+ });
+
+ mine.c.close();
+ theirs.c.close();
+ await done(t);
+})().catch((err) => { console.error(err); process.exit(1); });