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)

OP OpenVibers committed 12 days ago · 4dc27ba500 · 9 files, +274 −25

* A watch link to a machine's desk (plan L55): the owner's agent shows the desk it works on, inside a chat

The chat shows the last screenshot and links to the machine's screen page, which only frames itself and needs a
Bot sign-in, so it cannot be embedded. A watch link is the owner's own link: the page needs no sign-in (the token
is the credential), may sit in a frame, and lives for minutes rather than days.

- create() takes any desk the machine has (main, private or desk-<name>) and an expiry in minutes (5-240) besides
  days; a link carries the mouse and keyboard only when it is asked for
- POST /api/v1/machines/:id/watch-links makes one for the owner's own agent (svc:actor, bot.machine.watch_link):
  the machine is the one the named person owns (404, never 403), the call is counted against that person and
  against the agent's own token, and making it is audited
- the share page may sit in a frame on openvibe.actor as well as on openvibe.chat and openvibe.network
- migration 0029 widens the desk check on the share links
- tests: test/watch-links.test.js, and a named-desk case in test/screen-shares.test.js

* Watch links: an agent links its own desk, never the owner's screen

Review of the watch-link route: a service asking with no desk got the owner's own screen (main), with control when
asked. An agent now names the private desk or its desk-<name>; main or no desk is refused (bot.desk_not_agents). The
owner, signed in, still links any of their desks.

Changes

ddiff --git a/README.md b/README.md
iindex 2061e1e..84fbaeb 100644
---- a/README.md
++++ b/README.md
@@@ -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
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.
-- - **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, typed
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).
| `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 |
ddiff --git a/STATUS.json b/STATUS.json
iindex c7aaa9e..28333da 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
}
ddiff --git a/migrations/0029_screen_share_desk_names.sql b/migrations/0029_screen_share_desk_names.sql
nnew file mode 100644
iindex 0000000..e09e637
---- /dev/null
++++ b/migrations/0029_screen_share_desk_names.sql
@@@ -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}$');
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 2d9c249..89466c4 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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('../routi
const { 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, subm
const manage = limits('bot.robot.manage', { minute: 30, hour: 300 });
const control = limits('bot.control', { minute: 120, hour: 1200 });
const machineScreen = createMachineScreen({ domain, hub });
++ // The 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, subm
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.
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, subm
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 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
ddiff --git a/server/screen-shares.js b/server/screen-shares.js
iindex 299c085..7af4d42 100644
---- a/server/screen-shares.js
++++ b/server/screen-shares.js
@@@ -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 more
function 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.n
async 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.n
const 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 screen
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');
recent.push(now());
@@@ -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));
}
-- 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 };
ddiff --git a/server/web/render.js b/server/web/render.js
iindex c3bf498..9899f4f 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -861,7 +861,7 @@ function sharesBlock(machine, shares, madeLink, madeShare, hasDesk = false, hasS
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>'
: 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>
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex f53b04b..a93c510 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -728,12 +728,12 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
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.
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, limi
res.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)));
ddiff --git a/test/screen-shares.test.js b/test/screen-shares.test.js
iindex 06881d0..5c56912 100644
---- a/test/screen-shares.test.js
++++ b/test/screen-shares.test.js
@@@ -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++; }
ddiff --git a/test/watch-links.test.js b/test/watch-links.test.js
nnew file mode 100644
iindex 0000000..cac8ecd
---- /dev/null
++++ b/test/watch-links.test.js
@@@ -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); });