openvibers/openvibe.network Commits
Link hub: the owner's machine switches, live screen, screen links and desks (#769)
Changes
@@@ -202,6 +202,18 @@ server {proxy_set_header X-Forwarded-Proto $scheme;add_header Cache-Control "no-store" always;}++ # The machine hub on openvibe.link/machines is Network's page, so its API is Network's too, on the host-only++ # session this host signs in with: the machines list, the owner's controls and the live screen, plus machine pools.++ location ~ ^/api/v1/(machines|machine-pools)(/|$) {++ proxy_pass http://127.0.0.1:4000;++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++ add_header Cache-Control "no-store" always;++ }location ~ ^/(|install|add-computer(/complete)?|machines(/.*)?|pair|index\.md|send\.js|egress-settings\.js|locker\.mjs|llms\.txt|llms-full\.txt|robots\.txt|sitemap\.xml|\.well-known/agent\.json)$ {limit_except GET HEAD { deny all; }proxy_pass http://127.0.0.1:4000;@@@ -698,3 +698,18 @@ html[data-ov-motion=off] .me-act:hover{transform: none}.my-email-row { display: flex; gap: 8px; align-items: stretch; }.my-email-row input { flex: 1; min-width: 0; }#account-emails-message:empty { display: none; }++++/* The machine card's owner controls (my/machines.js, the Link hub): the two switches share .mc-role, the live++ screen, its links and the machine's desks. Plain: the picture fills the card, a row wraps on a phone. */++.mc-screen-block, .mc-shares, .mc-desks { display: grid; gap: 8px; min-width: 0; }++.mc-screen { margin: 0; display: grid; gap: 8px; min-width: 0; border-radius: 10px; }++.mc-screen:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }++.mc-screen video { width: 100%; max-height: 70vh; border-radius: 10px; background: #000; object-fit: contain; }++.mc-screen video[hidden] { display: none; }++.mc-shares-list, .mc-desks-list { display: grid; gap: 6px; }++.mc-share-row, .mc-desk-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-card); }++.mc-share-row small, .mc-desk-row small { display: block; color: var(--text-muted); font-size: 12px; overflow-wrap: anywhere; }++.mc-desk-live { flex-basis: 100%; }++.mc-desk-live summary { display: inline-block; cursor: pointer; }++.mc-desk-live[open] { display: grid; gap: 8px; }++.mc-takeover { margin-top: 4px; }@@@ -51,7 +51,7 @@ function mcCard(m) {}// The roles: a switch each; a change saves the whole set at once.-- const roles = mcEl('fieldset', 'mc-roles', [mcEl('legend', '', ['What it does for you'])]);++ const roles = mcEl('fieldset', 'mc-roles mc-role-set', [mcEl('legend', '', ['What it does for you'])]);(mcRoles || []).forEach((role) => {const id = `mc-${m.id}-${role.id.replace(/\W/g, '-')}`;const box = mcEl('input', '', [], { type: 'checkbox', id, value: role.id });@@@ -61,6 +61,15 @@ function mcCard(m) {});card.append(roles);++ const own = mcOwnerSwitches(m);++ if (own) card.append(own);++ const screen = mcScreenBlock(card, m);++ if (screen) card.append(screen);++ const links = mcSharesBlock(m);++ if (links) card.append(links);++ const desks = mcDesksBlock(card, m);++ if (desks) card.append(desks);++const acts = mcEl('div', 'mc-acts');const rename = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-pen'), ' Rename'], { type: 'button' });rename.addEventListener('click', () => mcRename(card, m, name));@@@ -71,18 +80,389 @@ function mcCard(m) {return card;}++/* ── The owner's switches, the live screen, its links, and the machine's desks ───────────────────────────────++ Everything here answers from what Bot reports (the machine's controls) and calls the hub's routes, which call++ Bot as the person. A switch is the person's own click; an app's token cannot reach these routes (Network refuses++ it), so nothing turns on what an agent may do by itself. */++++/** A labelled switch row: a checkbox, an icon, a bold label and the sentence that says what it allows. */++function mcSwitchRow(id, label, note, checked, onChange) {++ const box = mcEl('input', '', [], { type: 'checkbox', id });++ box.checked = checked === true;++ box.addEventListener('change', () => onChange(box));++ return mcEl('label', 'mc-role', [box, mcEl('span', 'mc-role-ic', [mcIcon(id.endsWith('-agent') ? 'fa-robot' : 'fa-tower-broadcast')]),++ mcEl('span', 'mc-role-t', [mcEl('b', '', [label]), mcEl('small', '', [note])])], { for: id });++}++++/** The two switches the removed page had for the person's agent and watches, off unless Bot says they are on. */++function mcOwnerSwitches(m) {++ const c = m.controls || null;++ if (!c || !c.computer) return null;++ const field = mcEl('fieldset', 'mc-roles mc-switches', [mcEl('legend', '', ['For your agent and your watches'])]);++ field.append(mcSwitchRow(`mc-${m.id}-agent`, 'Let my agent use this computer',++ 'Your OpenVibe.Actor agent may use its terminal, files and screen as you, at the level set on the computer. Off until you turn it on.',++ c.agent_computer, (box) => mcSaveSwitch(m, box, 'agent-computer', `Saved. ${m.name || 'The machine'} ${box.checked ? 'may be used by your agent.' : 'is off limits to your agent now.'}`)));++ if (c.probe) {++ field.append(mcSwitchRow(`mc-${m.id}-watch`, 'Let my watches check things from this computer',++ 'OpenVibe.Watch may probe your own network from here for your watches: one GET or one TCP connection, to private addresses only.',++ c.watch_probe, (box) => mcSaveSwitch(m, box, 'watch-probe', `Saved. Your watches ${box.checked ? 'may check from this computer.' : 'no longer check from here.'}`)));++ }++ return field;++}++++async function mcSaveSwitch(m, box, route, done) {++ const before = !box.checked;++ box.disabled = true;++ try {++ const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/${route}`, { method: 'POST', body: JSON.stringify({ on: box.checked }) });++ if (out && out.machine && out.machine.controls) m.controls = out.machine.controls;++ mcSay(done);++ } catch (e) {++ box.checked = before;++ mcSay(e.message || 'The switch could not be saved.', true);++ } finally { box.disabled = false; }++}++++/** The live screen: Show/Stop, ten minutes by Bot's default, the picture, and Take over. */++function mcScreenBlock(card, m) {++ const c = m.controls || null;++ const screen = c && c.screen;++ if (!screen || !screen.available) return null;++ // Offline with nothing showing: there is no picture to start; the links below can still be revoked.++ if (!m.online && !screen.live) return null;++ const block = mcEl('div', 'mc-screen-block', [], { 'data-screen-block': m.id });++ const acts = mcEl('div', 'mc-acts');++ const show = mcEl('button', 'btn btn-primary btn-small', [mcIcon('fa-display'), screen.live ? ' Watch my screen' : ' Show my screen'], { type: 'button' });++ show.addEventListener('click', async () => {++ show.disabled = true;++ try {++ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen`, { method: 'POST', body: JSON.stringify({ desk: 'main' }) });++ mcSay('Showing the screen: the picture appears in a few seconds.');++ mcMountPlayer(card, block, m, 'main', true);++ show.hidden = true;++ } catch (e) { mcSay(e.message || 'The screen could not be shown.', true); }++ finally { show.disabled = false; }++ });++ const stop = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-stop'), ' Stop showing my screen'], { type: 'button' });++ stop.addEventListener('click', async () => {++ stop.disabled = true;++ try {++ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen`, { method: 'DELETE' });++ mcUnmountPlayer(card);++ mcSay('Your screen is no longer shown.');++ show.hidden = false;++ } catch (e) { mcSay(e.message || 'The screen could not be stopped.', true); }++ finally { stop.disabled = false; }++ });++ acts.append(show, stop);++ block.append(acts, mcEl('p', 'mc-confirm', ['Live to you only, for 10 minutes. Nothing is recorded, and the computer says on its desktop that its screen is live.']));++ // A screen already showing when the card was drawn: connect without starting a second one.++ if (screen.live && m.online) mcMountPlayer(card, block, m, 'main', false);++ return block;++}++++/** The links made for this machine's screen: list them, and revoke one for good. */++function mcSharesBlock(m) {++ const c = m.controls || {};++ if (!c.computer || !((c.screen && c.screen.available) || (c.desk && c.desk.available))) return null;++ const block = mcEl('div', 'mc-shares');++ const button = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-link'), ' Screen links'], { type: 'button' });++ const list = mcEl('div', 'mc-shares-list');++ list.hidden = true;++ const draw = (shares) => {++ if (!shares.length) {++ list.replaceChildren(mcEl('p', 'mc-confirm', ['No screen links. Your agent makes one when it shows you a desk.']));++ return;++ }++ list.replaceChildren(...shares.map((s) => {++ const meta = ` · ${s.control ? 'watch and use' : 'watch only'}${s.desk && s.desk !== 'main' ? ` · ${s.desk}` : ''}${s.expires_at ? ` · until ${new Date(s.expires_at).toLocaleString()}` : ''}`;++ const row = mcEl('div', 'mc-share-row', [mcEl('span', '', [mcEl('b', '', [s.label || 'A screen link']), mcEl('small', '', [meta])])]);++ const revoke = mcEl('button', 'btn btn-small mc-revoke', ['Revoke'], { type: 'button' });++ revoke.addEventListener('click', async () => {++ revoke.disabled = true;++ try {++ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/shares/${encodeURIComponent(s.id)}`, { method: 'DELETE' });++ mcSay('The screen link is revoked.');++ row.remove();++ } catch (e) { mcSay(e.message || 'The link could not be revoked.', true); revoke.disabled = false; }++ });++ row.append(revoke);++ return row;++ }));++ };++ button.addEventListener('click', async () => {++ button.disabled = true;++ try {++ const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/shares`);++ draw((out && out.shares) || []);++ list.hidden = false;++ } catch (e) { mcSay(e.message || 'The screen links could not be read.', true); }++ finally { button.disabled = false; }++ });++ block.append(button, list);++ return block;++}++++/** The desks running on this machine: watched/taken over, stopped, or put on the owner's Live channel. */++function mcDesksBlock(card, m) {++ const d = m.controls && m.controls.desk;++ if (!d || !d.available || !m.online) return null;++ const block = mcEl('div', 'mc-desks');++ const button = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-display'), ' See its desks'], { type: 'button' });++ const list = mcEl('div', 'mc-desks-list');++ list.hidden = true;++ const draw = (desks, maxDesks) => {++ if (!desks.length) {++ list.replaceChildren(mcEl('p', 'mc-confirm', ['No desks are running. An agent\'s task starts a desk of its own here, and stops it when it is done.']));++ return;++ }++ list.replaceChildren(...desks.map((desk) => {++ const meta = ` · ${desk.kind || 'desk'}${Number.isInteger(desk.width) ? ` · ${desk.width}×${desk.height}` : ''}`;++ const row = mcEl('div', 'mc-desk-row', [mcEl('span', '', [mcEl('b', '', [desk.desk]), mcEl('small', '', [meta])])]);++ const acts = mcEl('div', 'mc-acts');++ const watch = mcEl('button', 'btn btn-small', ['Watch and take over'], { type: 'button' });++ watch.addEventListener('click', async () => {++ watch.disabled = true;++ try {++ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen`, { method: 'POST', body: JSON.stringify({ desk: desk.desk }) });++ mcMountPlayer(card, block, m, desk.desk, true);++ mcSay(`Showing ${desk.desk}.`);++ } catch (e) { mcSay(e.message || 'The desk could not be shown.', true); }++ finally { watch.disabled = false; }++ });++ const stop = mcEl('button', 'btn btn-outline btn-small', ['Stop'], { type: 'button' });++ stop.addEventListener('click', async () => {++ stop.disabled = true;++ try {++ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/desks/${encodeURIComponent(desk.desk)}/stop`, { method: 'POST', body: '{}' });++ mcSay(`${desk.desk} is stopped.`);++ } catch (e) { mcSay(e.message || 'The desk could not be stopped.', true); stop.disabled = false; }++ });++ const live = mcEl('details', 'mc-desk-live', [mcEl('summary', 'btn btn-outline btn-small', ['Show live on my channel']),++ mcEl('p', 'mc-confirm', ['Everyone can watch this desk on your OpenVibe Live channel for up to 30 minutes: what your agent does and every page it opens, signed-in pages included.']),++ (() => {++ const go = mcEl('button', 'btn btn-small mc-revoke', ['Go live with this desk'], { type: 'button' });++ go.addEventListener('click', async () => {++ go.disabled = true;++ try {++ const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/desks/${encodeURIComponent(desk.desk)}/live`, { method: 'POST', body: '{}' });++ mcSay(out && out.channel_url ? `Live on ${out.channel_url} for up to 30 minutes.` : `${desk.desk} is live on your OpenVibe Live channel for up to 30 minutes.`);++ if (out && out.channel_url) list.append(mcEl('p', 'mc-confirm', [mcEl('a', '', [out.channel_url], { href: out.channel_url })]));++ } catch (e) { mcSay(e.message || 'The desk could not go live.', true); go.disabled = false; }++ });++ return go;++ })()]);++ acts.append(watch, stop, live);++ row.append(acts);++ return row;++ }).concat(maxDesks ? [mcEl('p', 'mc-confirm', [`At most ${maxDesks} whole ${maxDesks === 1 ? 'desk' : 'desks'} at once here; a browser desk counts as half.`])] : []));++ };++ button.addEventListener('click', async () => {++ button.disabled = true;++ try {++ const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/desks`);++ draw((out && out.desks) || [], out && out.max_desks);++ list.hidden = false;++ } catch (e) { mcSay(e.message || 'The machine\'s desks could not be read.', true); }++ finally { button.disabled = false; }++ });++ block.append(button, list);++ return block;++}++++/* ── The picture: OpenRestream's viewer signaling, the same mediasoup flow the Bot page used ───────────────── */++++const MC_MEDIASOUP_URL = 'https://esm.sh/[email protected]';++let mcMediasoup = null;++const mcLoadMediasoup = () => (mcMediasoup ||= import(MC_MEDIASOUP_URL).then(++ (m) => m.Device || (m.default && m.default.Device),++ (e) => { mcMediasoup = null; throw e; }));++++function mcViewerWait(ws, type, timeoutMs = 15000) {++ return new Promise((resolve, reject) => {++ if (ws.readyState !== WebSocket.OPEN) return reject(new Error('the screen signaling socket closed'));++ let timer;++ const done = (finish) => { clearTimeout(timer); ws.removeEventListener('message', onMessage); ws.removeEventListener('close', onClose); finish(); };++ const onMessage = (e) => {++ let m; try { m = JSON.parse(e.data); } catch { return; }++ if (m.type === type) done(() => resolve(m));++ else if (m.type === 'watch-queued') done(() => reject(new Error('nothing is publishing yet')));++ else if (m.type === 'sfu-error' || m.type === 'sfu-viewer-error') done(() => reject(new Error(m.error || m.type)));++ };++ const onClose = () => done(() => reject(new Error('the screen signaling socket closed')));++ timer = setTimeout(() => done(() => reject(new Error(`no ${type} from the screen`))), timeoutMs);++ ws.addEventListener('message', onMessage);++ ws.addEventListener('close', onClose);++ });++}++++/** Play a live screen's viewer URL (the mediasoup sfu-viewer flow) in `video`; stop() ends it for good. */++function mcPlayStream(video, url, say) {++ let current = null, timer = null, attempt = 0, stopped = false;++ function teardown() {++ const session = current; current = null;++ video.srcObject = null;++ if (!session) return;++ for (const c of session.consumers) { try { c.close(); } catch { /* gone */ } }++ if (session.transport) { try { session.transport.close(); } catch { /* gone */ } }++ if (session.ws) { try { session.ws.close(); } catch { /* gone */ } }++ }++ function retry() {++ if (timer || stopped) return;++ video.hidden = true; teardown();++ timer = setTimeout(() => { timer = null; connect(); }, Math.min(10000, 500 * 2 ** attempt++));++ }++ async function connect() {++ const session = { ws: null, transport: null, consumers: [] };++ const stale = () => current !== session;++ current = session;++ try {++ const ws = new WebSocket(url);++ session.ws = ws;++ await new Promise((open, fail) => { ws.addEventListener('open', open, { once: true }); ws.addEventListener('close', () => fail(new Error('the screen signaling socket closed')), { once: true }); });++ if (stale()) return;++ ws.send(JSON.stringify({ type: 'watch' }));++ const ready = await mcViewerWait(ws, 'sfu-viewer-ready');++ if (stale()) return;++ if (!ready.producers || !ready.producers.length) throw new Error('the screen has no live track yet');++ const Device = await mcLoadMediasoup();++ if (stale()) return;++ const device = new Device();++ await device.load({ routerRtpCapabilities: ready.rtpCapabilities });++ if (stale()) return;++ ws.send(JSON.stringify({ type: 'sfu-viewer-create-transport' }));++ const created = await mcViewerWait(ws, 'sfu-viewer-transport-created');++ if (stale()) return;++ const transport = device.createRecvTransport({ id: created.id, iceParameters: created.iceParameters, iceCandidates: created.iceCandidates, dtlsParameters: created.dtlsParameters, iceServers: created.iceServers || [] });++ session.transport = transport;++ transport.on('connect', ({ dtlsParameters }, cb, errb) => {++ if (stale()) return errb(new Error('the screen session is gone'));++ ws.send(JSON.stringify({ type: 'sfu-viewer-connect-transport', transportId: transport.id, dtlsParameters }));++ mcViewerWait(ws, 'sfu-viewer-transport-connected').then(() => cb(), () => errb(new Error('the screen transport did not connect')));++ });++ const media = new MediaStream();++ for (const producer of ready.producers) {++ ws.send(JSON.stringify({ type: 'sfu-viewer-consume', transportId: transport.id, producerId: producer.id, rtpCapabilities: device.rtpCapabilities }));++ const consumed = await mcViewerWait(ws, 'sfu-viewer-consumed');++ if (stale()) return;++ const consumer = await transport.consume({ id: consumed.id, producerId: consumed.producerId, kind: consumed.kind, rtpParameters: consumed.rtpParameters });++ session.consumers.push(consumer);++ media.addTrack(consumer.track);++ }++ if (stale()) return;++ video.srcObject = media;++ video.hidden = false;++ say('Live.');++ attempt = 0;++ await video.play().catch(() => { /* autoplay holds the track anyway */ });++ ws.addEventListener('close', () => { if (!stale()) retry(); });++ } catch (e) { if (!stale()) retry(); }++ }++ connect();++ return { stop() { stopped = true; if (timer) { clearTimeout(timer); timer = null; } teardown(); video.hidden = true; } };++}++++/** Mount one player on the card (stopping any other) and poll the screen's state for its viewer URL. */++function mcMountPlayer(card, block, m, desk, takeOver) {++ mcUnmountPlayer(card);++ const figure = mcEl('figure', 'mc-screen', [], { tabindex: '0', 'data-desk': desk });++ const video = mcEl('video', '', [], { muted: '', autoplay: '', playsinline: '' });++ video.hidden = true;++ const status = mcEl('p', 'mc-confirm', ['Waiting for the machine…'], { role: 'status' });++ const use = mcEl('input', '', [], { type: 'checkbox', id: `mc-${m.id}-${desk.replace(/\W/g, '-')}-use` });++ const useRow = mcEl('label', 'mc-role mc-takeover', [use, mcEl('span', 'mc-role-ic', [mcIcon('fa-keyboard')]),++ mcEl('span', 'mc-role-t', [mcEl('b', '', ['Use the mouse and keyboard']), mcEl('small', '', [desk === 'main' ? 'Clicks and keys on the picture go to the computer.' : 'While you use it, the agent waits on this desk.'])])], { for: use.getAttribute('id') });++ const stop = mcEl('button', 'btn btn-outline btn-small', ['Stop showing it'], { type: 'button' });++ let player = null, timer = null, stopped = false;++ const say = (t) => { status.textContent = t || ''; };++ const poll = async () => {++ if (stopped) return;++ let s = null;++ try { s = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen`); } catch (e) { say(e.message || 'The screen could not be read.'); }++ if (s && s.live && s.signaling_url && !player) { say('Connecting to the screen…'); player = mcPlayStream(video, s.signaling_url, say); }++ else if (player && s && s.state === 'off') { player.stop(); player = null; say('The screen is no longer shown.'); }++ else if (!player) say(s && s.online === false ? 'The machine is offline.' : 'Waiting for the machine…');++ if (!stopped) timer = setTimeout(poll, player ? 5000 : 1500);++ };++ stop.addEventListener('click', async () => {++ stop.disabled = true;++ try {++ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen`, { method: 'DELETE' });++ say('Stopped.');++ } catch (e) { say(e.message || 'The screen could not be stopped.'); }++ stopped = true; clearTimeout(timer);++ if (player) { player.stop(); player = null; }++ stop.disabled = false;++ });++ mcAttachTakeOver(figure, video, m, use);++ figure.append(video, status, useRow, stop);++ block.append(figure);++ card._mcPlayer = { stop() { stopped = true; clearTimeout(timer); if (player) player.stop(); }, figure, use };++ poll();++ if (takeOver) figure.focus();++ return card._mcPlayer;++}++++function mcUnmountPlayer(card) {++ const p = card && card._mcPlayer;++ if (!p) return;++ p.stop();++ if (p.figure && p.figure.parent) p.figure.remove();++ card._mcPlayer = null;++}++++/** The owner's mouse and keyboard on the picture, as the removed page's "Take over": positions in picture pixels. */++function mcAttachTakeOver(figure, video, m, use) {++ const send = (value) => apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen/input`, { method: 'POST', body: JSON.stringify(value) }).catch((e) => mcSay(e.message || 'The computer did not do it.', true));++ const at = (ev) => {++ if (!video.videoWidth) return null;++ const r = video.getBoundingClientRect();++ const scale = Math.min(r.width / video.videoWidth, r.height / video.videoHeight);++ const ox = (r.width - video.videoWidth * scale) / 2, oy = (r.height - video.videoHeight * scale) / 2;++ const x = Math.round((ev.clientX - r.left - ox) / scale), y = Math.round((ev.clientY - r.top - oy) / scale);++ return x < 0 || y < 0 || x > video.videoWidth || y > video.videoHeight ? null : { x, y, width: video.videoWidth, height: video.videoHeight };++ };++ const on = () => !!(use && use.checked);++ let clickTimer = null;++ figure.addEventListener('click', (ev) => { if (!on()) return; const p = at(ev); if (!p) return; ev.preventDefault(); clearTimeout(clickTimer); clickTimer = setTimeout(() => send({ action: 'click', ...p }), 220); });++ figure.addEventListener('dblclick', (ev) => { if (!on()) return; const p = at(ev); if (!p) return; ev.preventDefault(); clearTimeout(clickTimer); send({ action: 'double_click', ...p }); });++ figure.addEventListener('contextmenu', (ev) => { if (!on()) return; const p = at(ev); if (!p) return; ev.preventDefault(); send({ action: 'right_click', ...p }); });++ let wheel = 0, wheelAt = null, wheelTimer = null;++ figure.addEventListener('wheel', (ev) => {++ if (!on()) return; const p = at(ev); if (!p) return; ev.preventDefault();++ wheel += ev.deltaY; wheelAt = p; clearTimeout(wheelTimer);++ wheelTimer = setTimeout(() => { const dy = Math.max(-20, Math.min(20, Math.round(wheel / 60))); wheel = 0; if (dy) send({ action: 'scroll', ...wheelAt, dy }); }, 150);++ }, { passive: false });++ const NAMES = { Enter: 'Return', Backspace: 'BackSpace', Tab: 'Tab', Escape: 'Escape', Delete: 'Delete', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', Home: 'Home', End: 'End', PageUp: 'Prior', PageDown: 'Next', Insert: 'Insert', ' ': 'space' };++ let text = '', textTimer = null;++ const flush = () => { if (text) { const t = text; text = ''; send({ action: 'type', text: t }); } };++ figure.addEventListener('keydown', (ev) => {++ if (!on()) return;++ const mods = [ev.ctrlKey && 'ctrl', ev.altKey && 'alt', ev.metaKey && 'super'].filter(Boolean);++ if (['Control', 'Alt', 'Shift', 'Meta'].includes(ev.key)) return;++ ev.preventDefault();++ if (!mods.length && ev.key.length === 1) { text += ev.key; clearTimeout(textTimer); textTimer = setTimeout(flush, 120); return; }++ flush();++ const name = NAMES[ev.key] || (/^F\d{1,2}$/.test(ev.key) ? ev.key : (ev.key.length === 1 ? ev.key.toLowerCase() : null));++ if (!name || !/^[A-Za-z0-9_]+$/.test(name)) return;++ if (ev.shiftKey && mods.length) mods.push('shift');++ send({ action: 'key', keys: [...mods, name].join('+') });++ });++}++++async function mcSaveRoles(card, m) {-- const roles = [...card.querySelectorAll('.mc-role input')].filter((b) => b.checked).map((b) => b.value);-- card.querySelectorAll('.mc-role input').forEach((b) => { b.disabled = true; });++ const roles = [...card.querySelectorAll('.mc-role-set input')].filter((b) => b.checked).map((b) => b.value);++ card.querySelectorAll('.mc-role-set input').forEach((b) => { b.disabled = true; });try {const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/roles`, { method: 'PUT', body: JSON.stringify({ roles }) });m.roles = (out && out.machine && out.machine.roles) || roles;mcSay(`Saved. ${m.name || 'The machine'} picks this up at its next check-in.`);} catch (e) {mcSay(e.message || 'The roles could not be saved.', true);-- card.querySelectorAll('.mc-role input').forEach((b) => { b.checked = (m.roles || []).includes(b.value); });++ card.querySelectorAll('.mc-role-set input').forEach((b) => { b.checked = (m.roles || []).includes(b.value); });} finally {-- card.querySelectorAll('.mc-role input').forEach((b) => { b.disabled = false; });++ card.querySelectorAll('.mc-role-set input').forEach((b) => { b.disabled = false; });}}@@@ -124,7 +124,9 @@ app.use(helmet({defaultSrc: ["'self'"],// Cloudflare Web Analytics: Cloudflare injects its beacon at the edge and the privacy text says it may// measure performance; script-src loads the beacon, connect-src is where it reports.-- scriptSrc: ["'self'", "'unsafe-inline'", "https://openvibe.network", "https://openvibe.live", "cdn.jsdelivr.net", "fonts.googleapis.com", "https://static.cloudflareinsights.com"],++ scriptSrc: ["'self'", "'unsafe-inline'", "https://openvibe.network", "https://openvibe.live", "cdn.jsdelivr.net", "fonts.googleapis.com", "https://static.cloudflareinsights.com",++ // The Link hub's live screen (/machines): mediasoup-client's pinned ESM build, the same one the Bot page loads.++ "https://esm.sh"],styleSrc: ["'self'", "'unsafe-inline'", "fonts.googleapis.com", "fonts.gstatic.com"],fontSrc: ["'self'", "fonts.gstatic.com"],// Avatars and media live on openvibe.media; the home page's live-stream thumbnails come from openvibe.live and@@@ -146,6 +148,8 @@ app.use(helmet({// Release notifications: release-watch's EventSource on the Events realtime stream (openvibe-shared 1.17)"https://openvibe.events","https://openvibe.link", // Link's page on its own origin++ // The machine's live screen on the Link hub: OpenRestream's viewer signaling socket.++ "wss://ingest.openre.stream",],frameSrc: ["'none'"],scriptSrcAttr: ["'unsafe-inline'"],@@@ -13,12 +13,22 @@* PATCH /api/v1/machines/:id network.machines.manage → { machine } (a new name)* PUT /api/v1/machines/:id/roles network.machines.manage → { machine } (the whole set)* POST /api/v1/machines/:id/revoke network.machines.manage → { machine }++ * POST /api/v1/machines/:id/agent-computer, /watch-probe → { machine } (the owner's own session)++ * POST /api/v1/machines/:id/screen network.machines.manage → 201 { machine_id, … } (the live screen)++ * GET /api/v1/machines/:id/screen network.machines.read → { live, signaling_url, … }++ * DELETE /api/v1/machines/:id/screen network.machines.manage → { stopped }++ * POST /api/v1/machines/:id/screen/input → { done } (mouse and keyboard)++ * GET /api/v1/machines/:id/desks network.machines.read → { desks, max_desks }++ * POST /api/v1/machines/:id/desks/:desk/stop, /live → { stopped } / 201 the screen++ * GET /api/v1/machines/:id/shares network.machines.read → { shares } (screen links)++ * DELETE /api/v1/machines/:id/shares/:share → { revoked }* POST /internal/machines/status network.link.manage → 202 (Bot's report)** Who may call: the person, with their own session; or an app holding the capability with the person's* consent — the token names the person in on_behalf_of, and the person's grant for the capability must still be* approved. A machine that is not that person's is not there at all (404, never 403), so nobody can probe whose-- * machine an id is.++ * machine an id is. The owner's switches, the live screen and its links, and the desks are their own session only:++ * no app or run token turns on what an agent may do with the computer, opens its screen or hands out its desks.** The events (through OpenVibe.Events, server/developer/event-relay.js): network.machine.online and* .offline (Bot's report of a machine's state, once per change), .paired (the machine redeemed its code),@@@ -103,6 +113,32 @@ function machineOf(m) {if (typeof m.last_seen_at === 'string' && m.last_seen_at) out.last_seen_at = m.last_seen_at;if (typeof m.node_version === 'string' && m.node_version) out.node_version = m.node_version;if (typeof m.node_id === 'string' && PRINCIPAL.test(m.node_id)) out.principal_id = m.node_id;++ // The owner's controls (the Link hub's machine card): each switch the machine itself reported, plus what its++ // computer plugin offers. The values are Bot's, never an assumption: a machine that has not said is off. Only++ // the shape is narrowed here (no file roots, tools or paths), as with the rest of the hub's view.++ const computer = m.computer && typeof m.computer === 'object' ? m.computer : null;++ out.controls = {++ agent_computer: m.agent_computer === true,++ watch_probe: m.watch_probe === true,++ computer: !!computer,++ ...(computer ? {++ level: typeof computer.level === 'string' ? computer.level : null,++ probe: !!(computer.probe && typeof computer.probe === 'object'),++ screen: {++ available: !!(computer.screen && computer.screen.stream === true),++ live: computer.screen_stream === 'live',++ },++ desk: computer.desk && typeof computer.desk === 'object' ? {++ available: true,++ screen: computer.desk.screen === true,++ named: computer.desk.named === true,++ sandboxed: computer.desk.isolation === 'sandbox',++ running: Number.isInteger(computer.desks_running) ? computer.desks_running : null,++ ...(Number.isInteger(computer.desk.max_desks) ? { max: computer.desk.max_desks } : {}),++ ...(Array.isArray(computer.desk.kinds) ? { kinds: computer.desk.kinds.filter((k) => typeof k === 'string').slice(0, 5) } : {}),++ } : { available: false },++ } : {}),++ };return out;}@@@ -157,6 +193,19 @@ function createBotClient({ baseUrl, privateKey, issuer, fetchImpl = globalThis.froles: (subject, id, roles) => call('PUT', `/api/v1/machines/${encodeURIComponent(id)}/roles`, { roles }, subject),revoke: (subject, id) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/revoke`, {}, subject),pairing: (subject) => call('POST', '/api/v1/machines/pairings', {}, subject),++ // The owner's switches and the live screen (the Link hub's machine card). Bot still owns every check:++ // a machine that is not the person's is 404 before any of these do anything.++ agentComputer: (subject, id, on) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/agent-computer`, { on: !!on }, subject),++ watchProbe: (subject, id, on) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/watch-probe`, { on: !!on }, subject),++ screenStart: (subject, id, body) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/screen`, body || {}, subject),++ screenState: (subject, id) => call('GET', `/api/v1/machines/${encodeURIComponent(id)}/screen`, null, subject),++ screenStop: (subject, id) => call('DELETE', `/api/v1/machines/${encodeURIComponent(id)}/screen`, null, subject),++ screenInput: (subject, id, value) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/screen/input`, value || {}, subject),++ desks: (subject, id) => call('GET', `/api/v1/machines/${encodeURIComponent(id)}/desks`, null, subject),++ deskStop: (subject, id, desk) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/desks/${encodeURIComponent(desk)}/stop`, {}, subject),++ deskLive: (subject, id, desk) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/desks/${encodeURIComponent(desk)}/live`, {}, subject),++ shares: (subject, id) => call('GET', `/api/v1/machines/${encodeURIComponent(id)}/shares`, null, subject),++ revokeShare: (subject, id, share) => call('DELETE', `/api/v1/machines/${encodeURIComponent(id)}/shares/${encodeURIComponent(share)}`, null, subject),};}@@@ -285,7 +334,7 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.loconst ownSession = (req, res, next) => {if (req.hub) {return http.sendProblem(res, 403, 'capability.owner_denied', {-- detail: 'pair a machine and choose its roles from your own session on openvibe.network',++ detail: 'only your own session on openvibe.network changes what a machine does',});}return next();@@@ -426,6 +475,158 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.lores.json({ machine });}));++ // ── The owner's own controls (the Link hub's machine card) ─────────────────────────────────────++ // Bot owns every check (the machine is the person's own, 404 otherwise; a switch is the owner's). The hub++ // answers only from the person's own session: an app or a run acting for them may read and rename and revoke,++ // but it may not turn on what an agent may do with the computer, show its screen, or hand its desks out.++ const booleanOn = (req) => {++ const on = (req.body || {}).on;++ if (typeof on !== 'boolean') throw new MachineError(422, 'registry.invalid_switch', 'on is true or false');++ return on;++ };++ hub.post('/:id/agent-computer', manage, ownSession, express.json({ limit: '8kb' }), async (req, res) => await send(res, async () => {++ res.set('Cache-Control', 'private, no-store');++ const subject = await subjectOf(req);++ const on = booleanOn(req);++ const out = await bot.agentComputer(subject, machineId(req), on);++ res.json({ machine: machineOf(out && out.machine) });++ }));++ hub.post('/:id/watch-probe', manage, ownSession, express.json({ limit: '8kb' }), async (req, res) => await send(res, async () => {++ res.set('Cache-Control', 'private, no-store');++ const subject = await subjectOf(req);++ const on = booleanOn(req);++ const out = await bot.watchProbe(subject, machineId(req), on);++ res.json({ machine: machineOf(out && out.machine) });++ }));++++ // The live screen, to the owner only: Start (10 minutes by Bot's default), the state the page polls, Stop, and++ // the owner's own mouse and keyboard while it is shown. `signaling_url` is a viewer credential, so it never++ // leaves the person's own session.++ const DESK = /^(main|private|desk-[a-z0-9][a-z0-9-]{0,23})$/;++ const screenOf = (id, out) => {++ const desk = DESK.test(String((out && out.desk) || '')) ? String(out.desk) : 'main';++ const channel = out && out.live && typeof out.live.channel_url === 'string' && /^https:\/\/openvibe\.live\/@[A-Za-z0-9_.-]{1,40}$/.test(out.live.channel_url)++ ? out.live.channel_url : null;++ return {++ machine_id: id,++ streaming: !!(out && out.streaming === true),++ seconds: Number.isInteger(out && out.seconds) ? out.seconds : null,++ until: typeof (out && out.until) === 'string' ? out.until : null,++ desk,++ ...(channel ? { channel_url: channel } : {}),++ };++ };++ hub.post('/:id/screen', manage, ownSession, express.json({ limit: '8kb' }), async (req, res) => await send(res, async () => {++ res.set('Cache-Control', 'private, no-store');++ const subject = await subjectOf(req);++ const id = machineId(req);++ const body = req.body || {};++ const desk = body.desk == null || body.desk === '' ? 'main' : String(body.desk);++ if (!DESK.test(desk)) throw new MachineError(422, 'registry.invalid_desk', 'desk is main, private or desk-<name>');++ const out = await bot.screenStart(subject, id, { desk });++ res.status(201).json(screenOf(id, out));++ }));++ hub.get('/:id/screen', read, ownSession, async (req, res) => await send(res, async () => {++ res.set('Cache-Control', 'private, no-store');++ const subject = await subjectOf(req);++ const id = machineId(req);++ const out = (await bot.screenState(subject, id)) || {};++ const url = typeof out.signaling_url === 'string' && /^wss?:\/\//.test(out.signaling_url) ? out.signaling_url : null;++ res.json({++ machine_id: id, live: !!url, signaling_url: url,++ state: out.state === 'live' ? 'live' : 'off', online: out.online === true,++ desk: DESK.test(String(out.desk || '')) ? out.desk : 'main',++ });++ }));++ hub.delete('/:id/screen', manage, ownSession, async (req, res) => await send(res, async () => {++ res.set('Cache-Control', 'private, no-store');++ const subject = await subjectOf(req);++ const id = machineId(req);++ const out = await bot.screenStop(subject, id);++ res.json({ machine_id: id, stopped: !!(out && out.stopped === true) });++ }));++ hub.post('/:id/screen/input', manage, ownSession, express.json({ limit: '4kb' }), async (req, res) => await send(res, async () => {++ res.set('Cache-Control', 'private, no-store');++ const subject = await subjectOf(req);++ const id = machineId(req);++ const out = await bot.screenInput(subject, id, req.body && typeof req.body === 'object' ? req.body : {});++ res.json({ machine_id: id, done: !!(out && out.done === true), desk: DESK.test(String((out && out.desk) || '')) ? out.desk : 'main' });++ }));++++ // The desks running on the machine: read on demand (never polled), then stopped or put on the owner's Live++ // channel. Watching one is the screen route with its name.++ const deskOf = (req) => {++ const desk = String((req.params && req.params.desk) || '');++ if (!DESK.test(desk) || desk === 'main') throw new MachineError(422, 'registry.invalid_desk', 'desk is private or desk-<name>');++ return desk;++ };++ hub.get('/:id/desks', read, ownSession, async (req, res) => await send(res, async () => {++ res.set('Cache-Control', 'private, no-store');++ const subject = await subjectOf(req);++ const id = machineId(req);++ const out = (await bot.desks(subject, id)) || {};++ const desks = (Array.isArray(out.desks) ? out.desks : []).flatMap((d) => {++ const desk = d && DESK.test(String(d.desk || '')) && d.desk !== 'main' ? String(d.desk) : null;++ if (!desk) return [];++ return [{++ desk,++ ...(typeof d.kind === 'string' ? { kind: d.kind.slice(0, 20) } : {}),++ ...(Number.isInteger(d.since) ? { since: d.since } : {}),++ ...(Number.isInteger(d.width) ? { width: d.width } : {}),++ ...(Number.isInteger(d.height) ? { height: d.height } : {}),++ }];++ }).slice(0, 50);++ res.json({ machine_id: id, desks, max_desks: Number.isInteger(out.max_desks) ? out.max_desks : null });++ }));++ hub.post('/:id/desks/:desk/stop', manage, ownSession, async (req, res) => await send(res, async () => {++ res.set('Cache-Control', 'private, no-store');++ const subject = await subjectOf(req);++ const id = machineId(req);++ const desk = deskOf(req);++ await bot.deskStop(subject, id, desk);++ res.json({ machine_id: id, stopped: desk });++ }));++ hub.post('/:id/desks/:desk/live', manage, ownSession, async (req, res) => await send(res, async () => {++ res.set('Cache-Control', 'private, no-store');++ const subject = await subjectOf(req);++ const id = machineId(req);++ const desk = deskOf(req);++ const out = await bot.deskLive(subject, id, desk);++ res.status(201).json(screenOf(id, { ...(out || {}), desk }));++ }));++++ // The screen links the owner made (their agent makes them through Bot's watch-links route): listed and revoked,++ // never shown again. Only the person in their own session sees the list.++ hub.get('/:id/shares', read, ownSession, async (req, res) => await send(res, async () => {++ res.set('Cache-Control', 'private, no-store');++ const subject = await subjectOf(req);++ const id = machineId(req);++ const out = (await bot.shares(subject, id)) || {};++ const shares = (Array.isArray(out.shares) ? out.shares : []).flatMap((s) => {++ const share = s && typeof s.id === 'string' && /^shr_[0-9A-Za-z]{1,64}$/.test(s.id) ? s.id : null;++ if (!share) return [];++ return [{++ id: share,++ ...(typeof s.label === 'string' && s.label ? { label: s.label.slice(0, 80) } : {}),++ control: s.control === true,++ desk: DESK.test(String(s.desk || '')) ? s.desk : 'main',++ ...(typeof s.created_at === 'string' ? { created_at: s.created_at } : {}),++ ...(typeof s.expires_at === 'string' ? { expires_at: s.expires_at } : {}),++ ...(typeof s.last_used_at === 'string' ? { last_used_at: s.last_used_at } : {}),++ }];++ });++ res.json({ machine_id: id, shares });++ }));++ hub.delete('/:id/shares/:share', manage, ownSession, async (req, res) => await send(res, async () => {++ res.set('Cache-Control', 'private, no-store');++ const subject = await subjectOf(req);++ const id = machineId(req);++ const share = String((req.params && req.params.share) || '');++ if (!/^shr_[0-9A-Za-z]{1,64}$/.test(share)) throw new MachineError(404, 'registry.share_not_found', 'there is no such screen link');++ const out = await bot.revokeShare(subject, id, share);++ res.json({ machine_id: id, revoked: !!(out && out.revoked === true) });++ }));++return { hub, compute };}@@@ -14,7 +14,12 @@ const { bootServer } = require('./helpers/boot-server');for (const origin of ['https://openvibe.media', 'https://openvibe.live', 'https://openvibe.community']) {assert.ok(img.includes(origin), `img-src lets ${origin} through: ${img.join(' ')}`);}-- console.log('home image CSP: media, live and community pictures allowed');++ // The machine hub's live screen (/machines) imports mediasoup-client's pinned ESM build and opens++ // OpenRestream's viewer signaling socket: exactly those two, in the two directives that gate them.++ const directive = (name) => csp.split(';').map((d) => d.trim()).find((d) => d.startsWith(name)) || '';++ assert.ok(directive('script-src').includes('https://esm.sh'), 'script-src lets the mediasoup client through');++ assert.ok(directive('connect-src').includes('wss://ingest.openre.stream'), 'connect-src lets the screen signaling through');++ console.log('CSP: home images, and the machine hub\'s live screen sources');await srv.stop();process.exit(0);} finally {@@@ -68,6 +68,9 @@ const SHAPED = {assert.ok(vhost.includes('add-computer(/complete)?'), 'the add-computer return flow reaches Link before the openvibe.network catch-all');const sendApi = vhost.slice(vhost.indexOf('location ~ ^/api/v1/me/nodes/(send-keys|send)$ {'));assert.ok(sendApi.length < vhost.length && /client_max_body_size 40m;/.test(sendApi.slice(0, sendApi.indexOf('\n }'))), 'the Link Send API has its own location with a body limit that fits a file');++ // …the machine hub's own API (the machines list, the owner's controls, the live screen, pools), which the page++ // asks for on this host: without it those calls left for openvibe.network's catch-all and the hub showed nothing.++ assert.ok(/location ~ \^\/api\/v1\/\(machines\|machine-pools\)\(\/\|\$\) \{[\s\S]*?proxy_pass http:\/\/127\.0\.0\.1:4000;/.test(vhost), 'the machine hub API is proxied on openvibe.link');// …and /auth/* to Link's own sign-in (Network#664), or openvibe.link never signs anyone in (11-08 audit).const linkBlock = vhost.slice(vhost.indexOf(' server_name openvibe.link;'));assert.ok(/location \^~ \/auth\/ \{[\s\S]*?proxy_pass http:\/\/127\.0\.0\.1:4000;/.test(linkBlock.slice(0, linkBlock.indexOf('\n}\n'))), 'the openvibe.link vhost proxies /auth/ to Network');@@@ -38,7 +38,20 @@ const bob = await mk('hubbob');// ── The fake Bot: the registry underneath. It answers per person, and it records every call. ──const calls = [];const owned = new Map(); // subject → [machine]--const machineOf = (id) => ({ id, name: 'garage-pi', kind: 'onboard', online: true, roles: [], running: [], node_id: null });++const flags = new Map(); // machine id → the owner's switches and the screen state the fake Link reports++const stateOf = (id) => flags.get(id) || {};++const machineOf = (id) => {++ const s = stateOf(id);++ return {++ id, name: 'garage-pi', kind: 'onboard', online: true, roles: [], running: [], node_id: null,++ agent_computer: s.agent_computer === true, watch_probe: s.watch_probe === true,++ computer: {++ level: 'ask', terminal: { level: 'ask' }, files: { level: 'ask' }, screen: { level: 'ask', stream: true },++ probe: { level: 'observe' }, desk: { level: 'ask', max_desks: 2, kinds: ['browser'], isolation: 'sandbox', screen: true, named: true },++ waiting: 0, desks_running: 0, screen_stream: s.screen_live ? 'live' : 'off',++ },++ };++};const botServer = http.createServer(async (req, res) => {const chunks = [];for await (const c of req) chunks.push(c);@@@ -47,26 +60,73 @@ const botServer = http.createServer(async (req, res) => {calls.push({ method: req.method, url: req.url, subject, body, auth: String(req.headers.authorization || '') });const answer = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };const list = () => (owned.get(subject) || []).map(machineOf);++ const mineOf = (id) => list().find((m) => m.id === id);++ const setFlag = (id, key, value) => { flags.set(id, { ...stateOf(id), [key]: value }); return machineOf(id); };if (req.method === 'GET' && req.url === '/api/v1/machines') return answer(200, { machines: list() });-- if (req.method === 'GET' && req.url.startsWith('/api/v1/machines/')) {-- const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length));-- const mine = list().find((m) => m.id === id);-- return mine ? answer(200, { machine: machineOf(id) }) : answer(404, { code: 'bot.device_not_found' });-- }if (req.method === 'POST' && req.url === '/api/v1/machines/pairings') {return answer(201, { pairing: { code: 'ABCD-EFGH', expires_at: new Date(Date.now() + 600000).toISOString(), pairing_id: `pair_${ids.ulid()}`, installer: 'curl -fsSL https://openvibe.link/install | sh -s -- --pairing pair_1 --code ABCD-EFGH --worker' } });}++ // The owner's own controls (the card): each as Bot checks it, the machine's own or 404.++ let m;++ if (req.method === 'POST' && (m = /^\/api\/v1\/machines\/([^/]+)\/(agent-computer|watch-probe)$/.exec(req.url))) {++ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });++ const key = m[2] === 'agent-computer' ? 'agent_computer' : 'watch_probe';++ if (typeof body.on !== 'boolean') return answer(422, { code: 'bot.invalid_input' });++ return answer(200, { machine: setFlag(decodeURIComponent(m[1]), key, body.on) });++ }++ if (req.method === 'POST' && (m = /^\/api\/v1\/machines\/([^/]+)\/screen$/.exec(req.url))) {++ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });++ return answer(201, { machine_id: decodeURIComponent(m[1]), streaming: true, seconds: 600, fps: 2, until: new Date(Date.now() + 600000).toISOString(), desk: body.desk || 'main' });++ }++ if (req.method === 'GET' && (m = /^\/api\/v1\/machines\/([^/]+)\/screen$/.exec(req.url))) {++ const id = decodeURIComponent(m[1]);++ if (!mineOf(id)) return answer(404, { code: 'bot.device_not_found' });++ const live = stateOf(id).screen_live === true;++ return answer(200, { machine_id: id, live, signaling_url: live ? 'wss://ingest.openre.stream/w/abc' : null, state: live ? 'live' : 'off', online: true, desk: 'main' });++ }++ if (req.method === 'DELETE' && (m = /^\/api\/v1\/machines\/([^/]+)\/screen$/.exec(req.url))) {++ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });++ setFlag(decodeURIComponent(m[1]), 'screen_live', false);++ return answer(200, { stopped: true });++ }++ if (req.method === 'POST' && (m = /^\/api\/v1\/machines\/([^/]+)\/screen\/input$/.exec(req.url))) {++ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });++ return answer(200, { done: true, desk: 'main' });++ }++ if (req.method === 'GET' && (m = /^\/api\/v1\/machines\/([^/]+)\/desks$/.exec(req.url))) {++ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });++ return answer(200, { machine_id: decodeURIComponent(m[1]), desks: [{ desk: 'desk-a', kind: 'browser', since: 1700000000, width: 1280, height: 720 }], max_desks: 2 });++ }++ if (req.method === 'POST' && (m = /^\/api\/v1\/machines\/([^/]+)\/desks\/([^/]+)\/(stop|live)$/.exec(req.url))) {++ const id = decodeURIComponent(m[1]);++ if (!mineOf(id)) return answer(404, { code: 'bot.device_not_found' });++ if (m[3] === 'stop') return answer(200, { stopped: decodeURIComponent(m[2]) });++ setFlag(id, 'screen_live', true);++ return answer(201, { machine_id: id, streaming: true, seconds: 1800, until: new Date(Date.now() + 1800000).toISOString(), desk: decodeURIComponent(m[2]), live: { channel_url: 'https://openvibe.live/@ana' } });++ }++ if (req.method === 'GET' && (m = /^\/api\/v1\/machines\/([^/]+)\/shares$/.exec(req.url))) {++ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });++ return answer(200, { machine_id: decodeURIComponent(m[1]), shares: [{ id: 'shr_abc', label: 'Staff room', control: false, desk: 'main', created_at: new Date().toISOString(), expires_at: new Date(Date.now() + 86400000).toISOString() }] });++ }++ if (req.method === 'DELETE' && (m = /^\/api\/v1\/machines\/([^/]+)\/shares\/([^/]+)$/.exec(req.url))) {++ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });++ return answer(200, { revoked: decodeURIComponent(m[2]) === 'shr_abc' });++ }if (req.method === 'PATCH' && req.url.startsWith('/api/v1/machines/')) {const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length));-- const m = machineOf(id); m.name = body.name; return answer(200, { machine: m });++ const row = machineOf(id); row.name = body.name; return answer(200, { machine: row });}if (req.method === 'PUT' && req.url.endsWith('/roles')) {const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length, -'/roles'.length));-- const m = machineOf(id); m.roles = body.roles; return answer(200, { machine: m });++ const row = machineOf(id); row.roles = body.roles; return answer(200, { machine: row });}if (req.method === 'POST' && req.url.endsWith('/revoke')) {const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length, -'/revoke'.length));-- const m = machineOf(id); m.online = false; return answer(200, { machine: m });++ const row = machineOf(id); row.online = false; return answer(200, { machine: row });++ }++ if (req.method === 'GET' && req.url.startsWith('/api/v1/machines/')) {++ const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length));++ return mineOf(id) ? answer(200, { machine: machineOf(id) }) : answer(404, { code: 'bot.device_not_found' });}answer(404, { code: 'bot.not_found' });});@@@ -180,6 +240,106 @@ try {const botCalls = calls.filter((c) => c.method !== 'GET');for (const c of botCalls) assert.strictEqual(c.subject, ana.subject_id, 'every change reaches Bot as the person');++ // ── The owner's controls on the machine card: Bot's report is the switch's state, and each control is a call++ // to Bot as the person. Nothing here is assumed on: the fake Link has both switches off until asked. ──++ const card = (await call('GET', '/api/v1/machines', { user: ana })).body.machines[0];++ assert.deepStrictEqual([card.controls.agent_computer, card.controls.watch_probe], [false, false], 'off because Bot said off');++ assert.deepStrictEqual(card.controls.screen, { available: true, live: false }, 'the screen it may show, not shown');++ assert.strictEqual(card.controls.computer, true);++ assert.strictEqual(card.controls.probe, true);++ assert.strictEqual(card.controls.desk.available, true);++++ const agentOn = await call('POST', `/api/v1/machines/${anaMachine}/agent-computer`, { user: ana, body: { on: true } });++ assert.strictEqual(agentOn.status, 200, JSON.stringify(agentOn.body));++ assert.strictEqual(agentOn.body.machine.controls.agent_computer, true);++ let sent = calls.at(-1);++ assert.deepStrictEqual([sent.method, sent.url, sent.subject, sent.body], ['POST', `/api/v1/machines/${anaMachine}/agent-computer`, ana.subject_id, { on: true }]);++ assert.strictEqual((await call('GET', '/api/v1/machines', { user: ana })).body.machines[0].controls.agent_computer, true, 'the switch now reads on from Bot');++ const agentOff = await call('POST', `/api/v1/machines/${anaMachine}/agent-computer`, { user: ana, body: { on: false } });++ assert.strictEqual(agentOff.body.machine.controls.agent_computer, false);++++ const watchOn = await call('POST', `/api/v1/machines/${anaMachine}/watch-probe`, { user: ana, body: { on: true } });++ assert.strictEqual(watchOn.status, 200, JSON.stringify(watchOn.body));++ assert.strictEqual(watchOn.body.machine.controls.watch_probe, true);++ sent = calls.at(-1);++ assert.deepStrictEqual([sent.method, sent.url, sent.subject, sent.body], ['POST', `/api/v1/machines/${anaMachine}/watch-probe`, ana.subject_id, { on: true }]);++ // A body that is not true/false never reaches Bot.++ const beforeBadSwitch = calls.length;++ const badSwitch = await call('POST', `/api/v1/machines/${anaMachine}/agent-computer`, { user: ana, body: { on: 'yes' } });++ assert.strictEqual(badSwitch.status, 422, JSON.stringify(badSwitch.body));++ assert.strictEqual(calls.length, beforeBadSwitch, 'Bot never saw it');++++ // The live screen: 10 minutes by Bot's default (the hub sends no seconds), the state, input, stop.++ const started = await call('POST', `/api/v1/machines/${anaMachine}/screen`, { user: ana, body: { desk: 'main' } });++ assert.strictEqual(started.status, 201, JSON.stringify(started.body));++ assert.deepStrictEqual([started.body.streaming, started.body.seconds, started.body.desk], [true, 600, 'main']);++ sent = calls.at(-1);++ assert.deepStrictEqual([sent.method, sent.url, sent.subject, sent.body], ['POST', `/api/v1/machines/${anaMachine}/screen`, ana.subject_id, { desk: 'main' }]);++ const state = await call('GET', `/api/v1/machines/${anaMachine}/screen`, { user: ana });++ assert.strictEqual(state.status, 200, JSON.stringify(state.body));++ assert.deepStrictEqual([state.body.live, state.body.state], [false, 'off']);++ const input = await call('POST', `/api/v1/machines/${anaMachine}/screen/input`, { user: ana, body: { action: 'click', x: 1, y: 2, width: 100, height: 100 } });++ assert.strictEqual(input.status, 200, JSON.stringify(input.body));++ assert.strictEqual(input.body.done, true);++ assert.strictEqual(calls.at(-1).url, `/api/v1/machines/${anaMachine}/screen/input`);++ const stopped = await call('DELETE', `/api/v1/machines/${anaMachine}/screen`, { user: ana });++ assert.deepStrictEqual([stopped.status, stopped.body.stopped], [200, true]);++++ // The screen links: listed without their token, then revoked by id.++ const shares = await call('GET', `/api/v1/machines/${anaMachine}/shares`, { user: ana });++ assert.strictEqual(shares.status, 200, JSON.stringify(shares.body));++ assert.deepStrictEqual(shares.body.shares.map((s) => [s.id, s.label, s.control, s.desk]), [['shr_abc', 'Staff room', false, 'main']]);++ assert.ok(!JSON.stringify(shares.body).includes('token'), 'never the token');++ const revokeShare = await call('DELETE', `/api/v1/machines/${anaMachine}/shares/shr_abc`, { user: ana });++ assert.deepStrictEqual([revokeShare.status, revokeShare.body.revoked], [200, true]);++ assert.strictEqual(calls.at(-1).url, `/api/v1/machines/${anaMachine}/shares/shr_abc`);++++ // The desks: read on demand, stopped, and put on the person's Live channel for 30 minutes.++ const desks = await call('GET', `/api/v1/machines/${anaMachine}/desks`, { user: ana });++ assert.strictEqual(desks.status, 200, JSON.stringify(desks.body));++ assert.deepStrictEqual(desks.body.desks.map((d) => [d.desk, d.kind, d.width, d.height]), [['desk-a', 'browser', 1280, 720]]);++ assert.strictEqual(desks.body.max_desks, 2);++ const deskStop = await call('POST', `/api/v1/machines/${anaMachine}/desks/desk-a/stop`, { user: ana, body: {} });++ assert.deepStrictEqual([deskStop.status, deskStop.body.stopped], [200, 'desk-a']);++ const live = await call('POST', `/api/v1/machines/${anaMachine}/desks/desk-a/live`, { user: ana, body: {} });++ assert.strictEqual(live.status, 201, JSON.stringify(live.body));++ assert.deepStrictEqual([live.body.desk, live.body.seconds, live.body.channel_url], ['desk-a', 1800, 'https://openvibe.live/@ana']);++ const afterLive = await call('GET', `/api/v1/machines/${anaMachine}/screen`, { user: ana });++ assert.deepStrictEqual([afterLive.body.live, afterLive.body.state, afterLive.body.signaling_url], [true, 'live', 'wss://ingest.openre.stream/w/abc']);++++ // Another person's machine is not there for any of them (404, never 403).++ const otherPaths = [++ ['POST', `/api/v1/machines/${bobMachine}/agent-computer`, { on: true }],++ ['POST', `/api/v1/machines/${bobMachine}/watch-probe`, { on: true }],++ ['POST', `/api/v1/machines/${bobMachine}/screen`, { desk: 'main' }],++ ['GET', `/api/v1/machines/${bobMachine}/screen`],++ ['GET', `/api/v1/machines/${bobMachine}/desks`],++ ['GET', `/api/v1/machines/${bobMachine}/shares`],++ ];++ for (const [method, url, body] of otherPaths) {++ const theirs = await call(method, url, { user: ana, body });++ assert.strictEqual(theirs.status, 404, `${method} ${url}: ${JSON.stringify(theirs.body)}`);++ assert.strictEqual(theirs.body.code, 'bot.device_not_found');++ }++++ // An app or a run acting for the person may not reach the owner's own controls: only their own session does.++ const beforeDelegatedControls = calls.length;++ for (const [name, tokenFor] of [['an app', appToken], ['a run', runToken]]) {++ for (const [method, url, body, cap] of [++ ['POST', `/api/v1/machines/${anaMachine}/agent-computer`, { on: true }, 'network.machines.manage'],++ ['POST', `/api/v1/machines/${anaMachine}/watch-probe`, { on: true }, 'network.machines.manage'],++ ['POST', `/api/v1/machines/${anaMachine}/screen`, { desk: 'main' }, 'network.machines.manage'],++ ['GET', `/api/v1/machines/${anaMachine}/screen`, undefined, 'network.machines.read'],++ ['GET', `/api/v1/machines/${anaMachine}/desks`, undefined, 'network.machines.read'],++ ['GET', `/api/v1/machines/${anaMachine}/shares`, undefined, 'network.machines.read'],++ ]) {++ const refused = await call(method, url, { token: tokenFor(cap), body });++ assert.strictEqual(refused.status, 403, `${name} ${method} ${url}: ${JSON.stringify(refused.body)}`);++ assert.strictEqual(refused.body.code, 'capability.owner_denied');++ }++ }++ assert.strictEqual(calls.length, beforeDelegatedControls, 'Bot was not called for a delegated control');++// ── A role outside the catalogue is refused, and Bot never sees it. ──const before = calls.length;const bad = await call('PUT', `/api/v1/machines/${bobMachine}/roles`, { user: ana, body: { roles: ['nope.nope'] } });@@@ -210,7 +370,7 @@ try {// ── Pairing a machine and choosing its roles are the person's own session, and nothing else. ──// A token that acts for the person (an app's, or a run's) may read, rename and revoke; it may never add a// machine or a role: a machine paired that way would carry the person's jobs and their desks.-- const OWN_SESSION = 'pair a machine and choose its roles from your own session on openvibe.network';++ const OWN_SESSION = 'only your own session on openvibe.network changes what a machine does';const beforeDelegated = calls.length;for (const [name, token] of [['an app', appToken('network.machines.manage')], ['a run', runToken('network.machines.manage')]]) {const pairing = await call('POST', '/api/v1/machines/pairings', { token, body: {} });@@@ -21,15 +21,17 @@ class Node {append(...kids) { for (const k of kids) { const n = typeof k === 'string' ? Object.assign(new Node('#text'), { _text: k }) : k; n.parent = this; this.children.push(n); } }replaceChildren(...kids) { this.children = []; this.append(...kids); }replaceWith(n) { const p = this.parent; if (!p) return; const i = p.children.indexOf(this); p.children[i] = n; n.parent = p; }++ remove() { const p = this.parent; if (!p) return; const i = p.children.indexOf(this); if (i >= 0) p.children.splice(i, 1); }get childNodes() { return this.children.slice(); }addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); }fire(t, ev = {}) { (this.listeners[t] || []).forEach((fn) => fn(ev)); }-- focus() {} select() {}++ focus() {} select() {} play() { return Promise.resolve(); }get classList() { const n = this; return { toggle: (c, on) => { n.className = on ? `${n.className} ${c}` : n.className.replace(c, ''); }, contains: (c) => n.className.split(' ').includes(c) }; }get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }set textContent(v) { this._text = String(v); this.children = []; }all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }querySelectorAll(sel) {++ if (sel === '.mc-role-set input') return this.all((n) => n.tagName === 'INPUT' && n.parent && n.parent.className === 'mc-role' && n.parent.parent && n.parent.parent.className.split(' ').includes('mc-role-set'));if (sel === '.mc-role input') return this.all((n) => n.tagName === 'INPUT' && n.parent && n.parent.className === 'mc-role');return [];}@@@ -45,6 +47,7 @@ function page({ answers }) {console, Date, Number, String, Set, Math, JSON, Promise, URLSearchParams, location: { search: '' },navigator: { clipboard: { writeText: async () => {} } },setInterval: (fn) => { timers.push(fn); return timers.length; }, clearInterval: () => { timers = []; },++ setTimeout: (fn) => { timers.push(fn); return timers.length; }, clearTimeout: () => {},apiFetch: async (p, o = {}) => { calls.push([o.method || 'GET', p, o.body ? JSON.parse(o.body) : null]); const a = answers(o.method || 'GET', p, o.body ? JSON.parse(o.body) : null); if (a instanceof Error) throw a; return a; },};vm.runInNewContext(SRC, sandbox);@@@ -109,6 +112,71 @@ assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the aawait flush();assert.ok(p.calls.some((c) => c[0] === 'POST' && c[1] === '/api/v1/machines/dev_A/revoke'), 'revoked on the second');++ // ── The owner's controls (the removed page's): two switches that read Bot's state, the live screen, its++ // links, and the machine's desks. Each is a call to Network's hub route, which calls Bot as the person. ──++ const switchMachine = {++ id: 'dev_C', kind: 'onboard', online: true, name: 'Desk PC', roles: [], running: [],++ controls: {++ computer: true, agent_computer: true, watch_probe: false, level: 'ask', probe: true,++ screen: { available: true, live: false },++ desk: { available: true, screen: true, named: true, running: 1, max: 2, kinds: ['browser'] },++ },++ };++ p = page({ answers: (m, url, body) => {++ if (url === '/api/v1/machines/roles') return { roles: ROLES };++ if (m === 'GET' && url === '/api/v1/machines') return { machines: [switchMachine] };++ if (m === 'POST' && /\/agent-computer$/.test(url)) { switchMachine.controls.agent_computer = body.on; return { machine: switchMachine }; }++ if (m === 'POST' && /\/watch-probe$/.test(url)) { switchMachine.controls.watch_probe = body.on; return { machine: switchMachine }; }++ if (m === 'POST' && /\/screen$/.test(url)) return { machine_id: 'dev_C', streaming: true, seconds: 600, desk: body.desk };++ if (m === 'GET' && /\/screen$/.test(url)) return { machine_id: 'dev_C', live: false, state: 'off', online: true };++ if (m === 'DELETE' && /\/screen$/.test(url)) return { stopped: true };++ if (m === 'GET' && /\/shares$/.test(url)) return { machine_id: 'dev_C', shares: [{ id: 'shr_abc', label: 'Staff room', control: false, desk: 'main' }] };++ if (m === 'DELETE' && /\/shares\/shr_abc$/.test(url)) return { revoked: true };++ if (m === 'GET' && /\/desks$/.test(url)) return { machine_id: 'dev_C', desks: [{ desk: 'desk-a', kind: 'browser', width: 1280, height: 720 }], max_desks: 2 };++ if (m === 'POST' && /\/desks\/desk-a\/stop$/.test(url)) return { stopped: 'desk-a' };++ if (m === 'POST' && /\/desks\/desk-a\/live$/.test(url)) return { machine_id: 'dev_C', desk: 'desk-a', seconds: 1800, channel_url: 'https://openvibe.live/@ana' };++ return {};++ } });++ await p.sandbox.loadMachines();++ const card = p.ids['mc-list'].children[0];++ assert.match(card.textContent, /Let my agent use this computer/);++ assert.match(card.textContent, /Let my watches check things from this computer/);++ const switches = card.all((n) => n.tagName === 'INPUT' && /-(agent|watch)$/.test(n.getAttribute('id') || ''));++ assert.deepStrictEqual(switches.map((b) => b.checked), [true, false], 'the switches start from Bot\'s own state');++ switches[0].checked = false; switches[0].fire('change');++ await flush();++ assert.deepStrictEqual(p.calls.find((c) => /\/agent-computer$/.test(c[1])), ['POST', '/api/v1/machines/dev_C/agent-computer', { on: false }], 'the agent switch saves on the person\'s click');++++ const button = (text) => card.all((n) => n.tagName === 'BUTTON' && new RegExp(text).test(n.textContent))[0];++ button('Show my screen').fire('click');++ await flush();++ assert.deepStrictEqual(p.calls.find((c) => c[0] === 'POST' && /\/screen$/.test(c[1])), ['POST', '/api/v1/machines/dev_C/screen', { desk: 'main' }], 'ten minutes by the server\'s default');++ assert.ok(p.calls.some((c) => c[0] === 'GET' && /\/screen$/.test(c[1])), 'the picture waits for the machine to publish');++ button('Stop showing my screen').fire('click');++ await flush();++ assert.ok(p.calls.some((c) => c[0] === 'DELETE' && /\/screen$/.test(c[1])), 'Stop reaches the machine');++++ button('Screen links').fire('click');++ await flush();++ assert.ok(p.calls.some((c) => c[0] === 'GET' && /\/shares$/.test(c[1])));++ assert.match(card.textContent, /Staff room/);++ const shareRevoke = card.all((n) => n.tagName === 'BUTTON' && n.parent && n.parent.className === 'mc-share-row')[0];++ shareRevoke.fire('click');++ await flush();++ assert.ok(p.calls.some((c) => c[0] === 'DELETE' && c[1] === '/api/v1/machines/dev_C/shares/shr_abc'), 'revoking one link');++++ button('See its desks').fire('click');++ await flush();++ assert.ok(p.calls.some((c) => c[0] === 'GET' && /\/desks$/.test(c[1])));++ assert.match(card.textContent, /desk-a.*browser.*1280×720/);++ card.all((n) => n.tagName === 'BUTTON' && n.textContent === 'Stop')[0].fire('click');++ await flush();++ assert.ok(p.calls.some((c) => c[0] === 'POST' && c[1] === '/api/v1/machines/dev_C/desks/desk-a/stop'), 'a desk stops from here');++ button('Go live with this desk').fire('click');++ await flush();++ assert.ok(p.calls.some((c) => c[0] === 'POST' && c[1] === '/api/v1/machines/dev_C/desks/desk-a/live'), 'the Live channel takes the desk with its warning first');++ assert.match(card.textContent, /openvibe\.live\/@ana/);++// ── Pairing: the code and the command, then the machine arriving ──machines = [];p = page({ answers: (m, url) => {
@@ -202,6 +202,18 @@ server {
@@ -202,6 +202,18 @@ server {
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Proto $scheme;
add_header Cache-Control "no-store" always;
add_header Cache-Control "no-store" always;
}
}
+ # The machine hub on openvibe.link/machines is Network's page, so its API is Network's too, on the host-only
+ # session this host signs in with: the machines list, the owner's controls and the live screen, plus machine pools.
+ location ~ ^/api/v1/(machines|machine-pools)(/|$) {
+ proxy_pass http://127.0.0.1:4000;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ add_header Cache-Control "no-store" always;
+ }
location ~ ^/(|install|add-computer(/complete)?|machines(/.*)?|pair|index\.md|send\.js|egress-settings\.js|locker\.mjs|llms\.txt|llms-full\.txt|robots\.txt|sitemap\.xml|\.well-known/agent\.json)$ {
location ~ ^/(|install|add-computer(/complete)?|machines(/.*)?|pair|index\.md|send\.js|egress-settings\.js|locker\.mjs|llms\.txt|llms-full\.txt|robots\.txt|sitemap\.xml|\.well-known/agent\.json)$ {
limit_except GET HEAD { deny all; }
limit_except GET HEAD { deny all; }
proxy_pass http://127.0.0.1:4000;
proxy_pass http://127.0.0.1:4000;
@@ -698,3 +698,18 @@ html[data-ov-motion=off] .me-act:hover{transform: none}
@@ -698,3 +698,18 @@ html[data-ov-motion=off] .me-act:hover{transform: none}
.my-email-row { display: flex; gap: 8px; align-items: stretch; }
.my-email-row { display: flex; gap: 8px; align-items: stretch; }
.my-email-row input { flex: 1; min-width: 0; }
.my-email-row input { flex: 1; min-width: 0; }
#account-emails-message:empty { display: none; }
#account-emails-message:empty { display: none; }
+
+/* The machine card's owner controls (my/machines.js, the Link hub): the two switches share .mc-role, the live
+ screen, its links and the machine's desks. Plain: the picture fills the card, a row wraps on a phone. */
+.mc-screen-block, .mc-shares, .mc-desks { display: grid; gap: 8px; min-width: 0; }
+.mc-screen { margin: 0; display: grid; gap: 8px; min-width: 0; border-radius: 10px; }
+.mc-screen:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
+.mc-screen video { width: 100%; max-height: 70vh; border-radius: 10px; background: #000; object-fit: contain; }
+.mc-screen video[hidden] { display: none; }
+.mc-shares-list, .mc-desks-list { display: grid; gap: 6px; }
+.mc-share-row, .mc-desk-row { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; padding: 8px 12px; border-radius: 10px; border: 1px solid var(--border); background: var(--bg-card); }
+.mc-share-row small, .mc-desk-row small { display: block; color: var(--text-muted); font-size: 12px; overflow-wrap: anywhere; }
+.mc-desk-live { flex-basis: 100%; }
+.mc-desk-live summary { display: inline-block; cursor: pointer; }
+.mc-desk-live[open] { display: grid; gap: 8px; }
+.mc-takeover { margin-top: 4px; }
@@ -51,7 +51,7 @@ function mcCard(m) {
@@ -51,7 +51,7 @@ function mcCard(m) {
}
}
// The roles: a switch each; a change saves the whole set at once.
// The roles: a switch each; a change saves the whole set at once.
- const roles = mcEl('fieldset', 'mc-roles', [mcEl('legend', '', ['What it does for you'])]);
+ const roles = mcEl('fieldset', 'mc-roles mc-role-set', [mcEl('legend', '', ['What it does for you'])]);
(mcRoles || []).forEach((role) => {
(mcRoles || []).forEach((role) => {
const id = `mc-${m.id}-${role.id.replace(/\W/g, '-')}`;
const id = `mc-${m.id}-${role.id.replace(/\W/g, '-')}`;
const box = mcEl('input', '', [], { type: 'checkbox', id, value: role.id });
const box = mcEl('input', '', [], { type: 'checkbox', id, value: role.id });
@@ -61,6 +61,15 @@ function mcCard(m) {
@@ -61,6 +61,15 @@ function mcCard(m) {
});
});
card.append(roles);
card.append(roles);
+ const own = mcOwnerSwitches(m);
+ if (own) card.append(own);
+ const screen = mcScreenBlock(card, m);
+ if (screen) card.append(screen);
+ const links = mcSharesBlock(m);
+ if (links) card.append(links);
+ const desks = mcDesksBlock(card, m);
+ if (desks) card.append(desks);
+
const acts = mcEl('div', 'mc-acts');
const acts = mcEl('div', 'mc-acts');
const rename = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-pen'), ' Rename'], { type: 'button' });
const rename = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-pen'), ' Rename'], { type: 'button' });
rename.addEventListener('click', () => mcRename(card, m, name));
rename.addEventListener('click', () => mcRename(card, m, name));
@@ -71,18 +80,389 @@ function mcCard(m) {
@@ -71,18 +80,389 @@ function mcCard(m) {
return card;
return card;
}
}
+/* ── The owner's switches, the live screen, its links, and the machine's desks ───────────────────────────────
+ Everything here answers from what Bot reports (the machine's controls) and calls the hub's routes, which call
+ Bot as the person. A switch is the person's own click; an app's token cannot reach these routes (Network refuses
+ it), so nothing turns on what an agent may do by itself. */
+
+/** A labelled switch row: a checkbox, an icon, a bold label and the sentence that says what it allows. */
+function mcSwitchRow(id, label, note, checked, onChange) {
+ const box = mcEl('input', '', [], { type: 'checkbox', id });
+ box.checked = checked === true;
+ box.addEventListener('change', () => onChange(box));
+ return mcEl('label', 'mc-role', [box, mcEl('span', 'mc-role-ic', [mcIcon(id.endsWith('-agent') ? 'fa-robot' : 'fa-tower-broadcast')]),
+ mcEl('span', 'mc-role-t', [mcEl('b', '', [label]), mcEl('small', '', [note])])], { for: id });
+}
+
+/** The two switches the removed page had for the person's agent and watches, off unless Bot says they are on. */
+function mcOwnerSwitches(m) {
+ const c = m.controls || null;
+ if (!c || !c.computer) return null;
+ const field = mcEl('fieldset', 'mc-roles mc-switches', [mcEl('legend', '', ['For your agent and your watches'])]);
+ field.append(mcSwitchRow(`mc-${m.id}-agent`, 'Let my agent use this computer',
+ 'Your OpenVibe.Actor agent may use its terminal, files and screen as you, at the level set on the computer. Off until you turn it on.',
+ c.agent_computer, (box) => mcSaveSwitch(m, box, 'agent-computer', `Saved. ${m.name || 'The machine'} ${box.checked ? 'may be used by your agent.' : 'is off limits to your agent now.'}`)));
+ if (c.probe) {
+ field.append(mcSwitchRow(`mc-${m.id}-watch`, 'Let my watches check things from this computer',
+ 'OpenVibe.Watch may probe your own network from here for your watches: one GET or one TCP connection, to private addresses only.',
+ c.watch_probe, (box) => mcSaveSwitch(m, box, 'watch-probe', `Saved. Your watches ${box.checked ? 'may check from this computer.' : 'no longer check from here.'}`)));
+ }
+ return field;
+}
+
+async function mcSaveSwitch(m, box, route, done) {
+ const before = !box.checked;
+ box.disabled = true;
+ try {
+ const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/${route}`, { method: 'POST', body: JSON.stringify({ on: box.checked }) });
+ if (out && out.machine && out.machine.controls) m.controls = out.machine.controls;
+ mcSay(done);
+ } catch (e) {
+ box.checked = before;
+ mcSay(e.message || 'The switch could not be saved.', true);
+ } finally { box.disabled = false; }
+}
+
+/** The live screen: Show/Stop, ten minutes by Bot's default, the picture, and Take over. */
+function mcScreenBlock(card, m) {
+ const c = m.controls || null;
+ const screen = c && c.screen;
+ if (!screen || !screen.available) return null;
+ // Offline with nothing showing: there is no picture to start; the links below can still be revoked.
+ if (!m.online && !screen.live) return null;
+ const block = mcEl('div', 'mc-screen-block', [], { 'data-screen-block': m.id });
+ const acts = mcEl('div', 'mc-acts');
+ const show = mcEl('button', 'btn btn-primary btn-small', [mcIcon('fa-display'), screen.live ? ' Watch my screen' : ' Show my screen'], { type: 'button' });
+ show.addEventListener('click', async () => {
+ show.disabled = true;
+ try {
+ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen`, { method: 'POST', body: JSON.stringify({ desk: 'main' }) });
+ mcSay('Showing the screen: the picture appears in a few seconds.');
+ mcMountPlayer(card, block, m, 'main', true);
+ show.hidden = true;
+ } catch (e) { mcSay(e.message || 'The screen could not be shown.', true); }
+ finally { show.disabled = false; }
+ });
+ const stop = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-stop'), ' Stop showing my screen'], { type: 'button' });
+ stop.addEventListener('click', async () => {
+ stop.disabled = true;
+ try {
+ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen`, { method: 'DELETE' });
+ mcUnmountPlayer(card);
+ mcSay('Your screen is no longer shown.');
+ show.hidden = false;
+ } catch (e) { mcSay(e.message || 'The screen could not be stopped.', true); }
+ finally { stop.disabled = false; }
+ });
+ acts.append(show, stop);
+ block.append(acts, mcEl('p', 'mc-confirm', ['Live to you only, for 10 minutes. Nothing is recorded, and the computer says on its desktop that its screen is live.']));
+ // A screen already showing when the card was drawn: connect without starting a second one.
+ if (screen.live && m.online) mcMountPlayer(card, block, m, 'main', false);
+ return block;
+}
+
+/** The links made for this machine's screen: list them, and revoke one for good. */
+function mcSharesBlock(m) {
+ const c = m.controls || {};
+ if (!c.computer || !((c.screen && c.screen.available) || (c.desk && c.desk.available))) return null;
+ const block = mcEl('div', 'mc-shares');
+ const button = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-link'), ' Screen links'], { type: 'button' });
+ const list = mcEl('div', 'mc-shares-list');
+ list.hidden = true;
+ const draw = (shares) => {
+ if (!shares.length) {
+ list.replaceChildren(mcEl('p', 'mc-confirm', ['No screen links. Your agent makes one when it shows you a desk.']));
+ return;
+ }
+ list.replaceChildren(...shares.map((s) => {
+ const meta = ` · ${s.control ? 'watch and use' : 'watch only'}${s.desk && s.desk !== 'main' ? ` · ${s.desk}` : ''}${s.expires_at ? ` · until ${new Date(s.expires_at).toLocaleString()}` : ''}`;
+ const row = mcEl('div', 'mc-share-row', [mcEl('span', '', [mcEl('b', '', [s.label || 'A screen link']), mcEl('small', '', [meta])])]);
+ const revoke = mcEl('button', 'btn btn-small mc-revoke', ['Revoke'], { type: 'button' });
+ revoke.addEventListener('click', async () => {
+ revoke.disabled = true;
+ try {
+ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/shares/${encodeURIComponent(s.id)}`, { method: 'DELETE' });
+ mcSay('The screen link is revoked.');
+ row.remove();
+ } catch (e) { mcSay(e.message || 'The link could not be revoked.', true); revoke.disabled = false; }
+ });
+ row.append(revoke);
+ return row;
+ }));
+ };
+ button.addEventListener('click', async () => {
+ button.disabled = true;
+ try {
+ const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/shares`);
+ draw((out && out.shares) || []);
+ list.hidden = false;
+ } catch (e) { mcSay(e.message || 'The screen links could not be read.', true); }
+ finally { button.disabled = false; }
+ });
+ block.append(button, list);
+ return block;
+}
+
+/** The desks running on this machine: watched/taken over, stopped, or put on the owner's Live channel. */
+function mcDesksBlock(card, m) {
+ const d = m.controls && m.controls.desk;
+ if (!d || !d.available || !m.online) return null;
+ const block = mcEl('div', 'mc-desks');
+ const button = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-display'), ' See its desks'], { type: 'button' });
+ const list = mcEl('div', 'mc-desks-list');
+ list.hidden = true;
+ const draw = (desks, maxDesks) => {
+ if (!desks.length) {
+ list.replaceChildren(mcEl('p', 'mc-confirm', ['No desks are running. An agent\'s task starts a desk of its own here, and stops it when it is done.']));
+ return;
+ }
+ list.replaceChildren(...desks.map((desk) => {
+ const meta = ` · ${desk.kind || 'desk'}${Number.isInteger(desk.width) ? ` · ${desk.width}×${desk.height}` : ''}`;
+ const row = mcEl('div', 'mc-desk-row', [mcEl('span', '', [mcEl('b', '', [desk.desk]), mcEl('small', '', [meta])])]);
+ const acts = mcEl('div', 'mc-acts');
+ const watch = mcEl('button', 'btn btn-small', ['Watch and take over'], { type: 'button' });
+ watch.addEventListener('click', async () => {
+ watch.disabled = true;
+ try {
+ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen`, { method: 'POST', body: JSON.stringify({ desk: desk.desk }) });
+ mcMountPlayer(card, block, m, desk.desk, true);
+ mcSay(`Showing ${desk.desk}.`);
+ } catch (e) { mcSay(e.message || 'The desk could not be shown.', true); }
+ finally { watch.disabled = false; }
+ });
+ const stop = mcEl('button', 'btn btn-outline btn-small', ['Stop'], { type: 'button' });
+ stop.addEventListener('click', async () => {
+ stop.disabled = true;
+ try {
+ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/desks/${encodeURIComponent(desk.desk)}/stop`, { method: 'POST', body: '{}' });
+ mcSay(`${desk.desk} is stopped.`);
+ } catch (e) { mcSay(e.message || 'The desk could not be stopped.', true); stop.disabled = false; }
+ });
+ const live = mcEl('details', 'mc-desk-live', [mcEl('summary', 'btn btn-outline btn-small', ['Show live on my channel']),
+ mcEl('p', 'mc-confirm', ['Everyone can watch this desk on your OpenVibe Live channel for up to 30 minutes: what your agent does and every page it opens, signed-in pages included.']),
+ (() => {
+ const go = mcEl('button', 'btn btn-small mc-revoke', ['Go live with this desk'], { type: 'button' });
+ go.addEventListener('click', async () => {
+ go.disabled = true;
+ try {
+ const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/desks/${encodeURIComponent(desk.desk)}/live`, { method: 'POST', body: '{}' });
+ mcSay(out && out.channel_url ? `Live on ${out.channel_url} for up to 30 minutes.` : `${desk.desk} is live on your OpenVibe Live channel for up to 30 minutes.`);
+ if (out && out.channel_url) list.append(mcEl('p', 'mc-confirm', [mcEl('a', '', [out.channel_url], { href: out.channel_url })]));
+ } catch (e) { mcSay(e.message || 'The desk could not go live.', true); go.disabled = false; }
+ });
+ return go;
+ })()]);
+ acts.append(watch, stop, live);
+ row.append(acts);
+ return row;
+ }).concat(maxDesks ? [mcEl('p', 'mc-confirm', [`At most ${maxDesks} whole ${maxDesks === 1 ? 'desk' : 'desks'} at once here; a browser desk counts as half.`])] : []));
+ };
+ button.addEventListener('click', async () => {
+ button.disabled = true;
+ try {
+ const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/desks`);
+ draw((out && out.desks) || [], out && out.max_desks);
+ list.hidden = false;
+ } catch (e) { mcSay(e.message || 'The machine\'s desks could not be read.', true); }
+ finally { button.disabled = false; }
+ });
+ block.append(button, list);
+ return block;
+}
+
+/* ── The picture: OpenRestream's viewer signaling, the same mediasoup flow the Bot page used ───────────────── */
+
+const MC_MEDIASOUP_URL = 'https://esm.sh/[email protected]';
+let mcMediasoup = null;
+const mcLoadMediasoup = () => (mcMediasoup ||= import(MC_MEDIASOUP_URL).then(
+ (m) => m.Device || (m.default && m.default.Device),
+ (e) => { mcMediasoup = null; throw e; }));
+
+function mcViewerWait(ws, type, timeoutMs = 15000) {
+ return new Promise((resolve, reject) => {
+ if (ws.readyState !== WebSocket.OPEN) return reject(new Error('the screen signaling socket closed'));
+ let timer;
+ const done = (finish) => { clearTimeout(timer); ws.removeEventListener('message', onMessage); ws.removeEventListener('close', onClose); finish(); };
+ const onMessage = (e) => {
+ let m; try { m = JSON.parse(e.data); } catch { return; }
+ if (m.type === type) done(() => resolve(m));
+ else if (m.type === 'watch-queued') done(() => reject(new Error('nothing is publishing yet')));
+ else if (m.type === 'sfu-error' || m.type === 'sfu-viewer-error') done(() => reject(new Error(m.error || m.type)));
+ };
+ const onClose = () => done(() => reject(new Error('the screen signaling socket closed')));
+ timer = setTimeout(() => done(() => reject(new Error(`no ${type} from the screen`))), timeoutMs);
+ ws.addEventListener('message', onMessage);
+ ws.addEventListener('close', onClose);
+ });
+}
+
+/** Play a live screen's viewer URL (the mediasoup sfu-viewer flow) in `video`; stop() ends it for good. */
+function mcPlayStream(video, url, say) {
+ let current = null, timer = null, attempt = 0, stopped = false;
+ function teardown() {
+ const session = current; current = null;
+ video.srcObject = null;
+ if (!session) return;
+ for (const c of session.consumers) { try { c.close(); } catch { /* gone */ } }
+ if (session.transport) { try { session.transport.close(); } catch { /* gone */ } }
+ if (session.ws) { try { session.ws.close(); } catch { /* gone */ } }
+ }
+ function retry() {
+ if (timer || stopped) return;
+ video.hidden = true; teardown();
+ timer = setTimeout(() => { timer = null; connect(); }, Math.min(10000, 500 * 2 ** attempt++));
+ }
+ async function connect() {
+ const session = { ws: null, transport: null, consumers: [] };
+ const stale = () => current !== session;
+ current = session;
+ try {
+ const ws = new WebSocket(url);
+ session.ws = ws;
+ await new Promise((open, fail) => { ws.addEventListener('open', open, { once: true }); ws.addEventListener('close', () => fail(new Error('the screen signaling socket closed')), { once: true }); });
+ if (stale()) return;
+ ws.send(JSON.stringify({ type: 'watch' }));
+ const ready = await mcViewerWait(ws, 'sfu-viewer-ready');
+ if (stale()) return;
+ if (!ready.producers || !ready.producers.length) throw new Error('the screen has no live track yet');
+ const Device = await mcLoadMediasoup();
+ if (stale()) return;
+ const device = new Device();
+ await device.load({ routerRtpCapabilities: ready.rtpCapabilities });
+ if (stale()) return;
+ ws.send(JSON.stringify({ type: 'sfu-viewer-create-transport' }));
+ const created = await mcViewerWait(ws, 'sfu-viewer-transport-created');
+ if (stale()) return;
+ const transport = device.createRecvTransport({ id: created.id, iceParameters: created.iceParameters, iceCandidates: created.iceCandidates, dtlsParameters: created.dtlsParameters, iceServers: created.iceServers || [] });
+ session.transport = transport;
+ transport.on('connect', ({ dtlsParameters }, cb, errb) => {
+ if (stale()) return errb(new Error('the screen session is gone'));
+ ws.send(JSON.stringify({ type: 'sfu-viewer-connect-transport', transportId: transport.id, dtlsParameters }));
+ mcViewerWait(ws, 'sfu-viewer-transport-connected').then(() => cb(), () => errb(new Error('the screen transport did not connect')));
+ });
+ const media = new MediaStream();
+ for (const producer of ready.producers) {
+ ws.send(JSON.stringify({ type: 'sfu-viewer-consume', transportId: transport.id, producerId: producer.id, rtpCapabilities: device.rtpCapabilities }));
+ const consumed = await mcViewerWait(ws, 'sfu-viewer-consumed');
+ if (stale()) return;
+ const consumer = await transport.consume({ id: consumed.id, producerId: consumed.producerId, kind: consumed.kind, rtpParameters: consumed.rtpParameters });
+ session.consumers.push(consumer);
+ media.addTrack(consumer.track);
+ }
+ if (stale()) return;
+ video.srcObject = media;
+ video.hidden = false;
+ say('Live.');
+ attempt = 0;
+ await video.play().catch(() => { /* autoplay holds the track anyway */ });
+ ws.addEventListener('close', () => { if (!stale()) retry(); });
+ } catch (e) { if (!stale()) retry(); }
+ }
+ connect();
+ return { stop() { stopped = true; if (timer) { clearTimeout(timer); timer = null; } teardown(); video.hidden = true; } };
+}
+
+/** Mount one player on the card (stopping any other) and poll the screen's state for its viewer URL. */
+function mcMountPlayer(card, block, m, desk, takeOver) {
+ mcUnmountPlayer(card);
+ const figure = mcEl('figure', 'mc-screen', [], { tabindex: '0', 'data-desk': desk });
+ const video = mcEl('video', '', [], { muted: '', autoplay: '', playsinline: '' });
+ video.hidden = true;
+ const status = mcEl('p', 'mc-confirm', ['Waiting for the machine…'], { role: 'status' });
+ const use = mcEl('input', '', [], { type: 'checkbox', id: `mc-${m.id}-${desk.replace(/\W/g, '-')}-use` });
+ const useRow = mcEl('label', 'mc-role mc-takeover', [use, mcEl('span', 'mc-role-ic', [mcIcon('fa-keyboard')]),
+ mcEl('span', 'mc-role-t', [mcEl('b', '', ['Use the mouse and keyboard']), mcEl('small', '', [desk === 'main' ? 'Clicks and keys on the picture go to the computer.' : 'While you use it, the agent waits on this desk.'])])], { for: use.getAttribute('id') });
+ const stop = mcEl('button', 'btn btn-outline btn-small', ['Stop showing it'], { type: 'button' });
+ let player = null, timer = null, stopped = false;
+ const say = (t) => { status.textContent = t || ''; };
+ const poll = async () => {
+ if (stopped) return;
+ let s = null;
+ try { s = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen`); } catch (e) { say(e.message || 'The screen could not be read.'); }
+ if (s && s.live && s.signaling_url && !player) { say('Connecting to the screen…'); player = mcPlayStream(video, s.signaling_url, say); }
+ else if (player && s && s.state === 'off') { player.stop(); player = null; say('The screen is no longer shown.'); }
+ else if (!player) say(s && s.online === false ? 'The machine is offline.' : 'Waiting for the machine…');
+ if (!stopped) timer = setTimeout(poll, player ? 5000 : 1500);
+ };
+ stop.addEventListener('click', async () => {
+ stop.disabled = true;
+ try {
+ await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen`, { method: 'DELETE' });
+ say('Stopped.');
+ } catch (e) { say(e.message || 'The screen could not be stopped.'); }
+ stopped = true; clearTimeout(timer);
+ if (player) { player.stop(); player = null; }
+ stop.disabled = false;
+ });
+ mcAttachTakeOver(figure, video, m, use);
+ figure.append(video, status, useRow, stop);
+ block.append(figure);
+ card._mcPlayer = { stop() { stopped = true; clearTimeout(timer); if (player) player.stop(); }, figure, use };
+ poll();
+ if (takeOver) figure.focus();
+ return card._mcPlayer;
+}
+
+function mcUnmountPlayer(card) {
+ const p = card && card._mcPlayer;
+ if (!p) return;
+ p.stop();
+ if (p.figure && p.figure.parent) p.figure.remove();
+ card._mcPlayer = null;
+}
+
+/** The owner's mouse and keyboard on the picture, as the removed page's "Take over": positions in picture pixels. */
+function mcAttachTakeOver(figure, video, m, use) {
+ const send = (value) => apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/screen/input`, { method: 'POST', body: JSON.stringify(value) }).catch((e) => mcSay(e.message || 'The computer did not do it.', true));
+ const at = (ev) => {
+ if (!video.videoWidth) return null;
+ const r = video.getBoundingClientRect();
+ const scale = Math.min(r.width / video.videoWidth, r.height / video.videoHeight);
+ const ox = (r.width - video.videoWidth * scale) / 2, oy = (r.height - video.videoHeight * scale) / 2;
+ const x = Math.round((ev.clientX - r.left - ox) / scale), y = Math.round((ev.clientY - r.top - oy) / scale);
+ return x < 0 || y < 0 || x > video.videoWidth || y > video.videoHeight ? null : { x, y, width: video.videoWidth, height: video.videoHeight };
+ };
+ const on = () => !!(use && use.checked);
+ let clickTimer = null;
+ figure.addEventListener('click', (ev) => { if (!on()) return; const p = at(ev); if (!p) return; ev.preventDefault(); clearTimeout(clickTimer); clickTimer = setTimeout(() => send({ action: 'click', ...p }), 220); });
+ figure.addEventListener('dblclick', (ev) => { if (!on()) return; const p = at(ev); if (!p) return; ev.preventDefault(); clearTimeout(clickTimer); send({ action: 'double_click', ...p }); });
+ figure.addEventListener('contextmenu', (ev) => { if (!on()) return; const p = at(ev); if (!p) return; ev.preventDefault(); send({ action: 'right_click', ...p }); });
+ let wheel = 0, wheelAt = null, wheelTimer = null;
+ figure.addEventListener('wheel', (ev) => {
+ if (!on()) return; const p = at(ev); if (!p) return; ev.preventDefault();
+ wheel += ev.deltaY; wheelAt = p; clearTimeout(wheelTimer);
+ wheelTimer = setTimeout(() => { const dy = Math.max(-20, Math.min(20, Math.round(wheel / 60))); wheel = 0; if (dy) send({ action: 'scroll', ...wheelAt, dy }); }, 150);
+ }, { passive: false });
+ const NAMES = { Enter: 'Return', Backspace: 'BackSpace', Tab: 'Tab', Escape: 'Escape', Delete: 'Delete', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left', ArrowRight: 'Right', Home: 'Home', End: 'End', PageUp: 'Prior', PageDown: 'Next', Insert: 'Insert', ' ': 'space' };
+ let text = '', textTimer = null;
+ const flush = () => { if (text) { const t = text; text = ''; send({ action: 'type', text: t }); } };
+ figure.addEventListener('keydown', (ev) => {
+ if (!on()) return;
+ const mods = [ev.ctrlKey && 'ctrl', ev.altKey && 'alt', ev.metaKey && 'super'].filter(Boolean);
+ if (['Control', 'Alt', 'Shift', 'Meta'].includes(ev.key)) return;
+ ev.preventDefault();
+ if (!mods.length && ev.key.length === 1) { text += ev.key; clearTimeout(textTimer); textTimer = setTimeout(flush, 120); return; }
+ flush();
+ const name = NAMES[ev.key] || (/^F\d{1,2}$/.test(ev.key) ? ev.key : (ev.key.length === 1 ? ev.key.toLowerCase() : null));
+ if (!name || !/^[A-Za-z0-9_]+$/.test(name)) return;
+ if (ev.shiftKey && mods.length) mods.push('shift');
+ send({ action: 'key', keys: [...mods, name].join('+') });
+ });
+}
+
+
async function mcSaveRoles(card, m) {
async function mcSaveRoles(card, m) {
- const roles = [...card.querySelectorAll('.mc-role input')].filter((b) => b.checked).map((b) => b.value);
- card.querySelectorAll('.mc-role input').forEach((b) => { b.disabled = true; });
+ const roles = [...card.querySelectorAll('.mc-role-set input')].filter((b) => b.checked).map((b) => b.value);
+ card.querySelectorAll('.mc-role-set input').forEach((b) => { b.disabled = true; });
try {
try {
const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/roles`, { method: 'PUT', body: JSON.stringify({ roles }) });
const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/roles`, { method: 'PUT', body: JSON.stringify({ roles }) });
m.roles = (out && out.machine && out.machine.roles) || roles;
m.roles = (out && out.machine && out.machine.roles) || roles;
mcSay(`Saved. ${m.name || 'The machine'} picks this up at its next check-in.`);
mcSay(`Saved. ${m.name || 'The machine'} picks this up at its next check-in.`);
} catch (e) {
} catch (e) {
mcSay(e.message || 'The roles could not be saved.', true);
mcSay(e.message || 'The roles could not be saved.', true);
- card.querySelectorAll('.mc-role input').forEach((b) => { b.checked = (m.roles || []).includes(b.value); });
+ card.querySelectorAll('.mc-role-set input').forEach((b) => { b.checked = (m.roles || []).includes(b.value); });
} finally {
} finally {
- card.querySelectorAll('.mc-role input').forEach((b) => { b.disabled = false; });
+ card.querySelectorAll('.mc-role-set input').forEach((b) => { b.disabled = false; });
}
}
}
}
@@ -124,7 +124,9 @@ app.use(helmet({
@@ -124,7 +124,9 @@ app.use(helmet({
defaultSrc: ["'self'"],
defaultSrc: ["'self'"],
// Cloudflare Web Analytics: Cloudflare injects its beacon at the edge and the privacy text says it may
// Cloudflare Web Analytics: Cloudflare injects its beacon at the edge and the privacy text says it may
// measure performance; script-src loads the beacon, connect-src is where it reports.
// measure performance; script-src loads the beacon, connect-src is where it reports.
- scriptSrc: ["'self'", "'unsafe-inline'", "https://openvibe.network", "https://openvibe.live", "cdn.jsdelivr.net", "fonts.googleapis.com", "https://static.cloudflareinsights.com"],
+ scriptSrc: ["'self'", "'unsafe-inline'", "https://openvibe.network", "https://openvibe.live", "cdn.jsdelivr.net", "fonts.googleapis.com", "https://static.cloudflareinsights.com",
+ // The Link hub's live screen (/machines): mediasoup-client's pinned ESM build, the same one the Bot page loads.
+ "https://esm.sh"],
styleSrc: ["'self'", "'unsafe-inline'", "fonts.googleapis.com", "fonts.gstatic.com"],
styleSrc: ["'self'", "'unsafe-inline'", "fonts.googleapis.com", "fonts.gstatic.com"],
fontSrc: ["'self'", "fonts.gstatic.com"],
fontSrc: ["'self'", "fonts.gstatic.com"],
// Avatars and media live on openvibe.media; the home page's live-stream thumbnails come from openvibe.live and
// Avatars and media live on openvibe.media; the home page's live-stream thumbnails come from openvibe.live and
@@ -146,6 +148,8 @@ app.use(helmet({
@@ -146,6 +148,8 @@ app.use(helmet({
// Release notifications: release-watch's EventSource on the Events realtime stream (openvibe-shared 1.17)
// Release notifications: release-watch's EventSource on the Events realtime stream (openvibe-shared 1.17)
"https://openvibe.events",
"https://openvibe.events",
"https://openvibe.link", // Link's page on its own origin
"https://openvibe.link", // Link's page on its own origin
+ // The machine's live screen on the Link hub: OpenRestream's viewer signaling socket.
+ "wss://ingest.openre.stream",
],
],
frameSrc: ["'none'"],
frameSrc: ["'none'"],
scriptSrcAttr: ["'unsafe-inline'"],
scriptSrcAttr: ["'unsafe-inline'"],
@@ -13,12 +13,22 @@
@@ -13,12 +13,22 @@
* PATCH /api/v1/machines/:id network.machines.manage → { machine } (a new name)
* PATCH /api/v1/machines/:id network.machines.manage → { machine } (a new name)
* PUT /api/v1/machines/:id/roles network.machines.manage → { machine } (the whole set)
* PUT /api/v1/machines/:id/roles network.machines.manage → { machine } (the whole set)
* POST /api/v1/machines/:id/revoke network.machines.manage → { machine }
* POST /api/v1/machines/:id/revoke network.machines.manage → { machine }
+ * POST /api/v1/machines/:id/agent-computer, /watch-probe → { machine } (the owner's own session)
+ * POST /api/v1/machines/:id/screen network.machines.manage → 201 { machine_id, … } (the live screen)
+ * GET /api/v1/machines/:id/screen network.machines.read → { live, signaling_url, … }
+ * DELETE /api/v1/machines/:id/screen network.machines.manage → { stopped }
+ * POST /api/v1/machines/:id/screen/input → { done } (mouse and keyboard)
+ * GET /api/v1/machines/:id/desks network.machines.read → { desks, max_desks }
+ * POST /api/v1/machines/:id/desks/:desk/stop, /live → { stopped } / 201 the screen
+ * GET /api/v1/machines/:id/shares network.machines.read → { shares } (screen links)
+ * DELETE /api/v1/machines/:id/shares/:share → { revoked }
* POST /internal/machines/status network.link.manage → 202 (Bot's report)
* POST /internal/machines/status network.link.manage → 202 (Bot's report)
*
*
* Who may call: the person, with their own session; or an app holding the capability with the person's
* Who may call: the person, with their own session; or an app holding the capability with the person's
* consent — the token names the person in on_behalf_of, and the person's grant for the capability must still be
* consent — the token names the person in on_behalf_of, and the person's grant for the capability must still be
* approved. A machine that is not that person's is not there at all (404, never 403), so nobody can probe whose
* approved. A machine that is not that person's is not there at all (404, never 403), so nobody can probe whose
- * machine an id is.
+ * machine an id is. The owner's switches, the live screen and its links, and the desks are their own session only:
+ * no app or run token turns on what an agent may do with the computer, opens its screen or hands out its desks.
*
*
* The events (through OpenVibe.Events, server/developer/event-relay.js): network.machine.online and
* The events (through OpenVibe.Events, server/developer/event-relay.js): network.machine.online and
* .offline (Bot's report of a machine's state, once per change), .paired (the machine redeemed its code),
* .offline (Bot's report of a machine's state, once per change), .paired (the machine redeemed its code),
@@ -103,6 +113,32 @@ function machineOf(m) {
@@ -103,6 +113,32 @@ function machineOf(m) {
if (typeof m.last_seen_at === 'string' && m.last_seen_at) out.last_seen_at = m.last_seen_at;
if (typeof m.last_seen_at === 'string' && m.last_seen_at) out.last_seen_at = m.last_seen_at;
if (typeof m.node_version === 'string' && m.node_version) out.node_version = m.node_version;
if (typeof m.node_version === 'string' && m.node_version) out.node_version = m.node_version;
if (typeof m.node_id === 'string' && PRINCIPAL.test(m.node_id)) out.principal_id = m.node_id;
if (typeof m.node_id === 'string' && PRINCIPAL.test(m.node_id)) out.principal_id = m.node_id;
+ // The owner's controls (the Link hub's machine card): each switch the machine itself reported, plus what its
+ // computer plugin offers. The values are Bot's, never an assumption: a machine that has not said is off. Only
+ // the shape is narrowed here (no file roots, tools or paths), as with the rest of the hub's view.
+ const computer = m.computer && typeof m.computer === 'object' ? m.computer : null;
+ out.controls = {
+ agent_computer: m.agent_computer === true,
+ watch_probe: m.watch_probe === true,
+ computer: !!computer,
+ ...(computer ? {
+ level: typeof computer.level === 'string' ? computer.level : null,
+ probe: !!(computer.probe && typeof computer.probe === 'object'),
+ screen: {
+ available: !!(computer.screen && computer.screen.stream === true),
+ live: computer.screen_stream === 'live',
+ },
+ desk: computer.desk && typeof computer.desk === 'object' ? {
+ available: true,
+ screen: computer.desk.screen === true,
+ named: computer.desk.named === true,
+ sandboxed: computer.desk.isolation === 'sandbox',
+ running: Number.isInteger(computer.desks_running) ? computer.desks_running : null,
+ ...(Number.isInteger(computer.desk.max_desks) ? { max: computer.desk.max_desks } : {}),
+ ...(Array.isArray(computer.desk.kinds) ? { kinds: computer.desk.kinds.filter((k) => typeof k === 'string').slice(0, 5) } : {}),
+ } : { available: false },
+ } : {}),
+ };
return out;
return out;
}
}
@@ -157,6 +193,19 @@ function createBotClient({ baseUrl, privateKey, issuer, fetchImpl = globalThis.f
@@ -157,6 +193,19 @@ function createBotClient({ baseUrl, privateKey, issuer, fetchImpl = globalThis.f
roles: (subject, id, roles) => call('PUT', `/api/v1/machines/${encodeURIComponent(id)}/roles`, { roles }, subject),
roles: (subject, id, roles) => call('PUT', `/api/v1/machines/${encodeURIComponent(id)}/roles`, { roles }, subject),
revoke: (subject, id) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/revoke`, {}, subject),
revoke: (subject, id) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/revoke`, {}, subject),
pairing: (subject) => call('POST', '/api/v1/machines/pairings', {}, subject),
pairing: (subject) => call('POST', '/api/v1/machines/pairings', {}, subject),
+ // The owner's switches and the live screen (the Link hub's machine card). Bot still owns every check:
+ // a machine that is not the person's is 404 before any of these do anything.
+ agentComputer: (subject, id, on) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/agent-computer`, { on: !!on }, subject),
+ watchProbe: (subject, id, on) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/watch-probe`, { on: !!on }, subject),
+ screenStart: (subject, id, body) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/screen`, body || {}, subject),
+ screenState: (subject, id) => call('GET', `/api/v1/machines/${encodeURIComponent(id)}/screen`, null, subject),
+ screenStop: (subject, id) => call('DELETE', `/api/v1/machines/${encodeURIComponent(id)}/screen`, null, subject),
+ screenInput: (subject, id, value) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/screen/input`, value || {}, subject),
+ desks: (subject, id) => call('GET', `/api/v1/machines/${encodeURIComponent(id)}/desks`, null, subject),
+ deskStop: (subject, id, desk) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/desks/${encodeURIComponent(desk)}/stop`, {}, subject),
+ deskLive: (subject, id, desk) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/desks/${encodeURIComponent(desk)}/live`, {}, subject),
+ shares: (subject, id) => call('GET', `/api/v1/machines/${encodeURIComponent(id)}/shares`, null, subject),
+ revokeShare: (subject, id, share) => call('DELETE', `/api/v1/machines/${encodeURIComponent(id)}/shares/${encodeURIComponent(share)}`, null, subject),
};
};
}
}
@@ -285,7 +334,7 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.lo
@@ -285,7 +334,7 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.lo
const ownSession = (req, res, next) => {
const ownSession = (req, res, next) => {
if (req.hub) {
if (req.hub) {
return http.sendProblem(res, 403, 'capability.owner_denied', {
return http.sendProblem(res, 403, 'capability.owner_denied', {
- detail: 'pair a machine and choose its roles from your own session on openvibe.network',
+ detail: 'only your own session on openvibe.network changes what a machine does',
});
});
}
}
return next();
return next();
@@ -426,6 +475,158 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.lo
@@ -426,6 +475,158 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.lo
res.json({ machine });
res.json({ machine });
}));
}));
+ // ── The owner's own controls (the Link hub's machine card) ─────────────────────────────────────
+ // Bot owns every check (the machine is the person's own, 404 otherwise; a switch is the owner's). The hub
+ // answers only from the person's own session: an app or a run acting for them may read and rename and revoke,
+ // but it may not turn on what an agent may do with the computer, show its screen, or hand its desks out.
+ const booleanOn = (req) => {
+ const on = (req.body || {}).on;
+ if (typeof on !== 'boolean') throw new MachineError(422, 'registry.invalid_switch', 'on is true or false');
+ return on;
+ };
+ hub.post('/:id/agent-computer', manage, ownSession, express.json({ limit: '8kb' }), async (req, res) => await send(res, async () => {
+ res.set('Cache-Control', 'private, no-store');
+ const subject = await subjectOf(req);
+ const on = booleanOn(req);
+ const out = await bot.agentComputer(subject, machineId(req), on);
+ res.json({ machine: machineOf(out && out.machine) });
+ }));
+ hub.post('/:id/watch-probe', manage, ownSession, express.json({ limit: '8kb' }), async (req, res) => await send(res, async () => {
+ res.set('Cache-Control', 'private, no-store');
+ const subject = await subjectOf(req);
+ const on = booleanOn(req);
+ const out = await bot.watchProbe(subject, machineId(req), on);
+ res.json({ machine: machineOf(out && out.machine) });
+ }));
+
+ // The live screen, to the owner only: Start (10 minutes by Bot's default), the state the page polls, Stop, and
+ // the owner's own mouse and keyboard while it is shown. `signaling_url` is a viewer credential, so it never
+ // leaves the person's own session.
+ const DESK = /^(main|private|desk-[a-z0-9][a-z0-9-]{0,23})$/;
+ const screenOf = (id, out) => {
+ const desk = DESK.test(String((out && out.desk) || '')) ? String(out.desk) : 'main';
+ const channel = out && out.live && typeof out.live.channel_url === 'string' && /^https:\/\/openvibe\.live\/@[A-Za-z0-9_.-]{1,40}$/.test(out.live.channel_url)
+ ? out.live.channel_url : null;
+ return {
+ machine_id: id,
+ streaming: !!(out && out.streaming === true),
+ seconds: Number.isInteger(out && out.seconds) ? out.seconds : null,
+ until: typeof (out && out.until) === 'string' ? out.until : null,
+ desk,
+ ...(channel ? { channel_url: channel } : {}),
+ };
+ };
+ hub.post('/:id/screen', manage, ownSession, express.json({ limit: '8kb' }), async (req, res) => await send(res, async () => {
+ res.set('Cache-Control', 'private, no-store');
+ const subject = await subjectOf(req);
+ const id = machineId(req);
+ const body = req.body || {};
+ const desk = body.desk == null || body.desk === '' ? 'main' : String(body.desk);
+ if (!DESK.test(desk)) throw new MachineError(422, 'registry.invalid_desk', 'desk is main, private or desk-<name>');
+ const out = await bot.screenStart(subject, id, { desk });
+ res.status(201).json(screenOf(id, out));
+ }));
+ hub.get('/:id/screen', read, ownSession, async (req, res) => await send(res, async () => {
+ res.set('Cache-Control', 'private, no-store');
+ const subject = await subjectOf(req);
+ const id = machineId(req);
+ const out = (await bot.screenState(subject, id)) || {};
+ const url = typeof out.signaling_url === 'string' && /^wss?:\/\//.test(out.signaling_url) ? out.signaling_url : null;
+ res.json({
+ machine_id: id, live: !!url, signaling_url: url,
+ state: out.state === 'live' ? 'live' : 'off', online: out.online === true,
+ desk: DESK.test(String(out.desk || '')) ? out.desk : 'main',
+ });
+ }));
+ hub.delete('/:id/screen', manage, ownSession, async (req, res) => await send(res, async () => {
+ res.set('Cache-Control', 'private, no-store');
+ const subject = await subjectOf(req);
+ const id = machineId(req);
+ const out = await bot.screenStop(subject, id);
+ res.json({ machine_id: id, stopped: !!(out && out.stopped === true) });
+ }));
+ hub.post('/:id/screen/input', manage, ownSession, express.json({ limit: '4kb' }), async (req, res) => await send(res, async () => {
+ res.set('Cache-Control', 'private, no-store');
+ const subject = await subjectOf(req);
+ const id = machineId(req);
+ const out = await bot.screenInput(subject, id, req.body && typeof req.body === 'object' ? req.body : {});
+ res.json({ machine_id: id, done: !!(out && out.done === true), desk: DESK.test(String((out && out.desk) || '')) ? out.desk : 'main' });
+ }));
+
+ // The desks running on the machine: read on demand (never polled), then stopped or put on the owner's Live
+ // channel. Watching one is the screen route with its name.
+ const deskOf = (req) => {
+ const desk = String((req.params && req.params.desk) || '');
+ if (!DESK.test(desk) || desk === 'main') throw new MachineError(422, 'registry.invalid_desk', 'desk is private or desk-<name>');
+ return desk;
+ };
+ hub.get('/:id/desks', read, ownSession, async (req, res) => await send(res, async () => {
+ res.set('Cache-Control', 'private, no-store');
+ const subject = await subjectOf(req);
+ const id = machineId(req);
+ const out = (await bot.desks(subject, id)) || {};
+ const desks = (Array.isArray(out.desks) ? out.desks : []).flatMap((d) => {
+ const desk = d && DESK.test(String(d.desk || '')) && d.desk !== 'main' ? String(d.desk) : null;
+ if (!desk) return [];
+ return [{
+ desk,
+ ...(typeof d.kind === 'string' ? { kind: d.kind.slice(0, 20) } : {}),
+ ...(Number.isInteger(d.since) ? { since: d.since } : {}),
+ ...(Number.isInteger(d.width) ? { width: d.width } : {}),
+ ...(Number.isInteger(d.height) ? { height: d.height } : {}),
+ }];
+ }).slice(0, 50);
+ res.json({ machine_id: id, desks, max_desks: Number.isInteger(out.max_desks) ? out.max_desks : null });
+ }));
+ hub.post('/:id/desks/:desk/stop', manage, ownSession, async (req, res) => await send(res, async () => {
+ res.set('Cache-Control', 'private, no-store');
+ const subject = await subjectOf(req);
+ const id = machineId(req);
+ const desk = deskOf(req);
+ await bot.deskStop(subject, id, desk);
+ res.json({ machine_id: id, stopped: desk });
+ }));
+ hub.post('/:id/desks/:desk/live', manage, ownSession, async (req, res) => await send(res, async () => {
+ res.set('Cache-Control', 'private, no-store');
+ const subject = await subjectOf(req);
+ const id = machineId(req);
+ const desk = deskOf(req);
+ const out = await bot.deskLive(subject, id, desk);
+ res.status(201).json(screenOf(id, { ...(out || {}), desk }));
+ }));
+
+ // The screen links the owner made (their agent makes them through Bot's watch-links route): listed and revoked,
+ // never shown again. Only the person in their own session sees the list.
+ hub.get('/:id/shares', read, ownSession, async (req, res) => await send(res, async () => {
+ res.set('Cache-Control', 'private, no-store');
+ const subject = await subjectOf(req);
+ const id = machineId(req);
+ const out = (await bot.shares(subject, id)) || {};
+ const shares = (Array.isArray(out.shares) ? out.shares : []).flatMap((s) => {
+ const share = s && typeof s.id === 'string' && /^shr_[0-9A-Za-z]{1,64}$/.test(s.id) ? s.id : null;
+ if (!share) return [];
+ return [{
+ id: share,
+ ...(typeof s.label === 'string' && s.label ? { label: s.label.slice(0, 80) } : {}),
+ control: s.control === true,
+ desk: DESK.test(String(s.desk || '')) ? s.desk : 'main',
+ ...(typeof s.created_at === 'string' ? { created_at: s.created_at } : {}),
+ ...(typeof s.expires_at === 'string' ? { expires_at: s.expires_at } : {}),
+ ...(typeof s.last_used_at === 'string' ? { last_used_at: s.last_used_at } : {}),
+ }];
+ });
+ res.json({ machine_id: id, shares });
+ }));
+ hub.delete('/:id/shares/:share', manage, ownSession, async (req, res) => await send(res, async () => {
+ res.set('Cache-Control', 'private, no-store');
+ const subject = await subjectOf(req);
+ const id = machineId(req);
+ const share = String((req.params && req.params.share) || '');
+ if (!/^shr_[0-9A-Za-z]{1,64}$/.test(share)) throw new MachineError(404, 'registry.share_not_found', 'there is no such screen link');
+ const out = await bot.revokeShare(subject, id, share);
+ res.json({ machine_id: id, revoked: !!(out && out.revoked === true) });
+ }));
+
return { hub, compute };
return { hub, compute };
}
}
@@ -14,7 +14,12 @@ const { bootServer } = require('./helpers/boot-server');
@@ -14,7 +14,12 @@ const { bootServer } = require('./helpers/boot-server');
for (const origin of ['https://openvibe.media', 'https://openvibe.live', 'https://openvibe.community']) {
for (const origin of ['https://openvibe.media', 'https://openvibe.live', 'https://openvibe.community']) {
assert.ok(img.includes(origin), `img-src lets ${origin} through: ${img.join(' ')}`);
assert.ok(img.includes(origin), `img-src lets ${origin} through: ${img.join(' ')}`);
}
}
- console.log('home image CSP: media, live and community pictures allowed');
+ // The machine hub's live screen (/machines) imports mediasoup-client's pinned ESM build and opens
+ // OpenRestream's viewer signaling socket: exactly those two, in the two directives that gate them.
+ const directive = (name) => csp.split(';').map((d) => d.trim()).find((d) => d.startsWith(name)) || '';
+ assert.ok(directive('script-src').includes('https://esm.sh'), 'script-src lets the mediasoup client through');
+ assert.ok(directive('connect-src').includes('wss://ingest.openre.stream'), 'connect-src lets the screen signaling through');
+ console.log('CSP: home images, and the machine hub\'s live screen sources');
await srv.stop();
await srv.stop();
process.exit(0);
process.exit(0);
} finally {
} finally {
@@ -68,6 +68,9 @@ const SHAPED = {
@@ -68,6 +68,9 @@ const SHAPED = {
assert.ok(vhost.includes('add-computer(/complete)?'), 'the add-computer return flow reaches Link before the openvibe.network catch-all');
assert.ok(vhost.includes('add-computer(/complete)?'), 'the add-computer return flow reaches Link before the openvibe.network catch-all');
const sendApi = vhost.slice(vhost.indexOf('location ~ ^/api/v1/me/nodes/(send-keys|send)$ {'));
const sendApi = vhost.slice(vhost.indexOf('location ~ ^/api/v1/me/nodes/(send-keys|send)$ {'));
assert.ok(sendApi.length < vhost.length && /client_max_body_size 40m;/.test(sendApi.slice(0, sendApi.indexOf('\n }'))), 'the Link Send API has its own location with a body limit that fits a file');
assert.ok(sendApi.length < vhost.length && /client_max_body_size 40m;/.test(sendApi.slice(0, sendApi.indexOf('\n }'))), 'the Link Send API has its own location with a body limit that fits a file');
+ // …the machine hub's own API (the machines list, the owner's controls, the live screen, pools), which the page
+ // asks for on this host: without it those calls left for openvibe.network's catch-all and the hub showed nothing.
+ assert.ok(/location ~ \^\/api\/v1\/\(machines\|machine-pools\)\(\/\|\$\) \{[\s\S]*?proxy_pass http:\/\/127\.0\.0\.1:4000;/.test(vhost), 'the machine hub API is proxied on openvibe.link');
// …and /auth/* to Link's own sign-in (Network#664), or openvibe.link never signs anyone in (11-08 audit).
// …and /auth/* to Link's own sign-in (Network#664), or openvibe.link never signs anyone in (11-08 audit).
const linkBlock = vhost.slice(vhost.indexOf(' server_name openvibe.link;'));
const linkBlock = vhost.slice(vhost.indexOf(' server_name openvibe.link;'));
assert.ok(/location \^~ \/auth\/ \{[\s\S]*?proxy_pass http:\/\/127\.0\.0\.1:4000;/.test(linkBlock.slice(0, linkBlock.indexOf('\n}\n'))), 'the openvibe.link vhost proxies /auth/ to Network');
assert.ok(/location \^~ \/auth\/ \{[\s\S]*?proxy_pass http:\/\/127\.0\.0\.1:4000;/.test(linkBlock.slice(0, linkBlock.indexOf('\n}\n'))), 'the openvibe.link vhost proxies /auth/ to Network');
@@ -38,7 +38,20 @@ const bob = await mk('hubbob');
@@ -38,7 +38,20 @@ const bob = await mk('hubbob');
// ── The fake Bot: the registry underneath. It answers per person, and it records every call. ──
// ── The fake Bot: the registry underneath. It answers per person, and it records every call. ──
const calls = [];
const calls = [];
const owned = new Map(); // subject → [machine]
const owned = new Map(); // subject → [machine]
-const machineOf = (id) => ({ id, name: 'garage-pi', kind: 'onboard', online: true, roles: [], running: [], node_id: null });
+const flags = new Map(); // machine id → the owner's switches and the screen state the fake Link reports
+const stateOf = (id) => flags.get(id) || {};
+const machineOf = (id) => {
+ const s = stateOf(id);
+ return {
+ id, name: 'garage-pi', kind: 'onboard', online: true, roles: [], running: [], node_id: null,
+ agent_computer: s.agent_computer === true, watch_probe: s.watch_probe === true,
+ computer: {
+ level: 'ask', terminal: { level: 'ask' }, files: { level: 'ask' }, screen: { level: 'ask', stream: true },
+ probe: { level: 'observe' }, desk: { level: 'ask', max_desks: 2, kinds: ['browser'], isolation: 'sandbox', screen: true, named: true },
+ waiting: 0, desks_running: 0, screen_stream: s.screen_live ? 'live' : 'off',
+ },
+ };
+};
const botServer = http.createServer(async (req, res) => {
const botServer = http.createServer(async (req, res) => {
const chunks = [];
const chunks = [];
for await (const c of req) chunks.push(c);
for await (const c of req) chunks.push(c);
@@ -47,26 +60,73 @@ const botServer = http.createServer(async (req, res) => {
@@ -47,26 +60,73 @@ const botServer = http.createServer(async (req, res) => {
calls.push({ method: req.method, url: req.url, subject, body, auth: String(req.headers.authorization || '') });
calls.push({ method: req.method, url: req.url, subject, body, auth: String(req.headers.authorization || '') });
const answer = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
const answer = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
const list = () => (owned.get(subject) || []).map(machineOf);
const list = () => (owned.get(subject) || []).map(machineOf);
+ const mineOf = (id) => list().find((m) => m.id === id);
+ const setFlag = (id, key, value) => { flags.set(id, { ...stateOf(id), [key]: value }); return machineOf(id); };
if (req.method === 'GET' && req.url === '/api/v1/machines') return answer(200, { machines: list() });
if (req.method === 'GET' && req.url === '/api/v1/machines') return answer(200, { machines: list() });
- if (req.method === 'GET' && req.url.startsWith('/api/v1/machines/')) {
- const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length));
- const mine = list().find((m) => m.id === id);
- return mine ? answer(200, { machine: machineOf(id) }) : answer(404, { code: 'bot.device_not_found' });
- }
if (req.method === 'POST' && req.url === '/api/v1/machines/pairings') {
if (req.method === 'POST' && req.url === '/api/v1/machines/pairings') {
return answer(201, { pairing: { code: 'ABCD-EFGH', expires_at: new Date(Date.now() + 600000).toISOString(), pairing_id: `pair_${ids.ulid()}`, installer: 'curl -fsSL https://openvibe.link/install | sh -s -- --pairing pair_1 --code ABCD-EFGH --worker' } });
return answer(201, { pairing: { code: 'ABCD-EFGH', expires_at: new Date(Date.now() + 600000).toISOString(), pairing_id: `pair_${ids.ulid()}`, installer: 'curl -fsSL https://openvibe.link/install | sh -s -- --pairing pair_1 --code ABCD-EFGH --worker' } });
}
}
+ // The owner's own controls (the card): each as Bot checks it, the machine's own or 404.
+ let m;
+ if (req.method === 'POST' && (m = /^\/api\/v1\/machines\/([^/]+)\/(agent-computer|watch-probe)$/.exec(req.url))) {
+ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });
+ const key = m[2] === 'agent-computer' ? 'agent_computer' : 'watch_probe';
+ if (typeof body.on !== 'boolean') return answer(422, { code: 'bot.invalid_input' });
+ return answer(200, { machine: setFlag(decodeURIComponent(m[1]), key, body.on) });
+ }
+ if (req.method === 'POST' && (m = /^\/api\/v1\/machines\/([^/]+)\/screen$/.exec(req.url))) {
+ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });
+ return answer(201, { machine_id: decodeURIComponent(m[1]), streaming: true, seconds: 600, fps: 2, until: new Date(Date.now() + 600000).toISOString(), desk: body.desk || 'main' });
+ }
+ if (req.method === 'GET' && (m = /^\/api\/v1\/machines\/([^/]+)\/screen$/.exec(req.url))) {
+ const id = decodeURIComponent(m[1]);
+ if (!mineOf(id)) return answer(404, { code: 'bot.device_not_found' });
+ const live = stateOf(id).screen_live === true;
+ return answer(200, { machine_id: id, live, signaling_url: live ? 'wss://ingest.openre.stream/w/abc' : null, state: live ? 'live' : 'off', online: true, desk: 'main' });
+ }
+ if (req.method === 'DELETE' && (m = /^\/api\/v1\/machines\/([^/]+)\/screen$/.exec(req.url))) {
+ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });
+ setFlag(decodeURIComponent(m[1]), 'screen_live', false);
+ return answer(200, { stopped: true });
+ }
+ if (req.method === 'POST' && (m = /^\/api\/v1\/machines\/([^/]+)\/screen\/input$/.exec(req.url))) {
+ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });
+ return answer(200, { done: true, desk: 'main' });
+ }
+ if (req.method === 'GET' && (m = /^\/api\/v1\/machines\/([^/]+)\/desks$/.exec(req.url))) {
+ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });
+ return answer(200, { machine_id: decodeURIComponent(m[1]), desks: [{ desk: 'desk-a', kind: 'browser', since: 1700000000, width: 1280, height: 720 }], max_desks: 2 });
+ }
+ if (req.method === 'POST' && (m = /^\/api\/v1\/machines\/([^/]+)\/desks\/([^/]+)\/(stop|live)$/.exec(req.url))) {
+ const id = decodeURIComponent(m[1]);
+ if (!mineOf(id)) return answer(404, { code: 'bot.device_not_found' });
+ if (m[3] === 'stop') return answer(200, { stopped: decodeURIComponent(m[2]) });
+ setFlag(id, 'screen_live', true);
+ return answer(201, { machine_id: id, streaming: true, seconds: 1800, until: new Date(Date.now() + 1800000).toISOString(), desk: decodeURIComponent(m[2]), live: { channel_url: 'https://openvibe.live/@ana' } });
+ }
+ if (req.method === 'GET' && (m = /^\/api\/v1\/machines\/([^/]+)\/shares$/.exec(req.url))) {
+ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });
+ return answer(200, { machine_id: decodeURIComponent(m[1]), shares: [{ id: 'shr_abc', label: 'Staff room', control: false, desk: 'main', created_at: new Date().toISOString(), expires_at: new Date(Date.now() + 86400000).toISOString() }] });
+ }
+ if (req.method === 'DELETE' && (m = /^\/api\/v1\/machines\/([^/]+)\/shares\/([^/]+)$/.exec(req.url))) {
+ if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });
+ return answer(200, { revoked: decodeURIComponent(m[2]) === 'shr_abc' });
+ }
if (req.method === 'PATCH' && req.url.startsWith('/api/v1/machines/')) {
if (req.method === 'PATCH' && req.url.startsWith('/api/v1/machines/')) {
const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length));
const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length));
- const m = machineOf(id); m.name = body.name; return answer(200, { machine: m });
+ const row = machineOf(id); row.name = body.name; return answer(200, { machine: row });
}
}
if (req.method === 'PUT' && req.url.endsWith('/roles')) {
if (req.method === 'PUT' && req.url.endsWith('/roles')) {
const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length, -'/roles'.length));
const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length, -'/roles'.length));
- const m = machineOf(id); m.roles = body.roles; return answer(200, { machine: m });
+ const row = machineOf(id); row.roles = body.roles; return answer(200, { machine: row });
}
}
if (req.method === 'POST' && req.url.endsWith('/revoke')) {
if (req.method === 'POST' && req.url.endsWith('/revoke')) {
const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length, -'/revoke'.length));
const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length, -'/revoke'.length));
- const m = machineOf(id); m.online = false; return answer(200, { machine: m });
+ const row = machineOf(id); row.online = false; return answer(200, { machine: row });
+ }
+ if (req.method === 'GET' && req.url.startsWith('/api/v1/machines/')) {
+ const id = decodeURIComponent(req.url.slice('/api/v1/machines/'.length));
+ return mineOf(id) ? answer(200, { machine: machineOf(id) }) : answer(404, { code: 'bot.device_not_found' });
}
}
answer(404, { code: 'bot.not_found' });
answer(404, { code: 'bot.not_found' });
});
});
@@ -180,6 +240,106 @@ try {
@@ -180,6 +240,106 @@ try {
const botCalls = calls.filter((c) => c.method !== 'GET');
const botCalls = calls.filter((c) => c.method !== 'GET');
for (const c of botCalls) assert.strictEqual(c.subject, ana.subject_id, 'every change reaches Bot as the person');
for (const c of botCalls) assert.strictEqual(c.subject, ana.subject_id, 'every change reaches Bot as the person');
+ // ── The owner's controls on the machine card: Bot's report is the switch's state, and each control is a call
+ // to Bot as the person. Nothing here is assumed on: the fake Link has both switches off until asked. ──
+ const card = (await call('GET', '/api/v1/machines', { user: ana })).body.machines[0];
+ assert.deepStrictEqual([card.controls.agent_computer, card.controls.watch_probe], [false, false], 'off because Bot said off');
+ assert.deepStrictEqual(card.controls.screen, { available: true, live: false }, 'the screen it may show, not shown');
+ assert.strictEqual(card.controls.computer, true);
+ assert.strictEqual(card.controls.probe, true);
+ assert.strictEqual(card.controls.desk.available, true);
+
+ const agentOn = await call('POST', `/api/v1/machines/${anaMachine}/agent-computer`, { user: ana, body: { on: true } });
+ assert.strictEqual(agentOn.status, 200, JSON.stringify(agentOn.body));
+ assert.strictEqual(agentOn.body.machine.controls.agent_computer, true);
+ let sent = calls.at(-1);
+ assert.deepStrictEqual([sent.method, sent.url, sent.subject, sent.body], ['POST', `/api/v1/machines/${anaMachine}/agent-computer`, ana.subject_id, { on: true }]);
+ assert.strictEqual((await call('GET', '/api/v1/machines', { user: ana })).body.machines[0].controls.agent_computer, true, 'the switch now reads on from Bot');
+ const agentOff = await call('POST', `/api/v1/machines/${anaMachine}/agent-computer`, { user: ana, body: { on: false } });
+ assert.strictEqual(agentOff.body.machine.controls.agent_computer, false);
+
+ const watchOn = await call('POST', `/api/v1/machines/${anaMachine}/watch-probe`, { user: ana, body: { on: true } });
+ assert.strictEqual(watchOn.status, 200, JSON.stringify(watchOn.body));
+ assert.strictEqual(watchOn.body.machine.controls.watch_probe, true);
+ sent = calls.at(-1);
+ assert.deepStrictEqual([sent.method, sent.url, sent.subject, sent.body], ['POST', `/api/v1/machines/${anaMachine}/watch-probe`, ana.subject_id, { on: true }]);
+ // A body that is not true/false never reaches Bot.
+ const beforeBadSwitch = calls.length;
+ const badSwitch = await call('POST', `/api/v1/machines/${anaMachine}/agent-computer`, { user: ana, body: { on: 'yes' } });
+ assert.strictEqual(badSwitch.status, 422, JSON.stringify(badSwitch.body));
+ assert.strictEqual(calls.length, beforeBadSwitch, 'Bot never saw it');
+
+ // The live screen: 10 minutes by Bot's default (the hub sends no seconds), the state, input, stop.
+ const started = await call('POST', `/api/v1/machines/${anaMachine}/screen`, { user: ana, body: { desk: 'main' } });
+ assert.strictEqual(started.status, 201, JSON.stringify(started.body));
+ assert.deepStrictEqual([started.body.streaming, started.body.seconds, started.body.desk], [true, 600, 'main']);
+ sent = calls.at(-1);
+ assert.deepStrictEqual([sent.method, sent.url, sent.subject, sent.body], ['POST', `/api/v1/machines/${anaMachine}/screen`, ana.subject_id, { desk: 'main' }]);
+ const state = await call('GET', `/api/v1/machines/${anaMachine}/screen`, { user: ana });
+ assert.strictEqual(state.status, 200, JSON.stringify(state.body));
+ assert.deepStrictEqual([state.body.live, state.body.state], [false, 'off']);
+ const input = await call('POST', `/api/v1/machines/${anaMachine}/screen/input`, { user: ana, body: { action: 'click', x: 1, y: 2, width: 100, height: 100 } });
+ assert.strictEqual(input.status, 200, JSON.stringify(input.body));
+ assert.strictEqual(input.body.done, true);
+ assert.strictEqual(calls.at(-1).url, `/api/v1/machines/${anaMachine}/screen/input`);
+ const stopped = await call('DELETE', `/api/v1/machines/${anaMachine}/screen`, { user: ana });
+ assert.deepStrictEqual([stopped.status, stopped.body.stopped], [200, true]);
+
+ // The screen links: listed without their token, then revoked by id.
+ const shares = await call('GET', `/api/v1/machines/${anaMachine}/shares`, { user: ana });
+ assert.strictEqual(shares.status, 200, JSON.stringify(shares.body));
+ assert.deepStrictEqual(shares.body.shares.map((s) => [s.id, s.label, s.control, s.desk]), [['shr_abc', 'Staff room', false, 'main']]);
+ assert.ok(!JSON.stringify(shares.body).includes('token'), 'never the token');
+ const revokeShare = await call('DELETE', `/api/v1/machines/${anaMachine}/shares/shr_abc`, { user: ana });
+ assert.deepStrictEqual([revokeShare.status, revokeShare.body.revoked], [200, true]);
+ assert.strictEqual(calls.at(-1).url, `/api/v1/machines/${anaMachine}/shares/shr_abc`);
+
+ // The desks: read on demand, stopped, and put on the person's Live channel for 30 minutes.
+ const desks = await call('GET', `/api/v1/machines/${anaMachine}/desks`, { user: ana });
+ assert.strictEqual(desks.status, 200, JSON.stringify(desks.body));
+ assert.deepStrictEqual(desks.body.desks.map((d) => [d.desk, d.kind, d.width, d.height]), [['desk-a', 'browser', 1280, 720]]);
+ assert.strictEqual(desks.body.max_desks, 2);
+ const deskStop = await call('POST', `/api/v1/machines/${anaMachine}/desks/desk-a/stop`, { user: ana, body: {} });
+ assert.deepStrictEqual([deskStop.status, deskStop.body.stopped], [200, 'desk-a']);
+ const live = await call('POST', `/api/v1/machines/${anaMachine}/desks/desk-a/live`, { user: ana, body: {} });
+ assert.strictEqual(live.status, 201, JSON.stringify(live.body));
+ assert.deepStrictEqual([live.body.desk, live.body.seconds, live.body.channel_url], ['desk-a', 1800, 'https://openvibe.live/@ana']);
+ const afterLive = await call('GET', `/api/v1/machines/${anaMachine}/screen`, { user: ana });
+ assert.deepStrictEqual([afterLive.body.live, afterLive.body.state, afterLive.body.signaling_url], [true, 'live', 'wss://ingest.openre.stream/w/abc']);
+
+ // Another person's machine is not there for any of them (404, never 403).
+ const otherPaths = [
+ ['POST', `/api/v1/machines/${bobMachine}/agent-computer`, { on: true }],
+ ['POST', `/api/v1/machines/${bobMachine}/watch-probe`, { on: true }],
+ ['POST', `/api/v1/machines/${bobMachine}/screen`, { desk: 'main' }],
+ ['GET', `/api/v1/machines/${bobMachine}/screen`],
+ ['GET', `/api/v1/machines/${bobMachine}/desks`],
+ ['GET', `/api/v1/machines/${bobMachine}/shares`],
+ ];
+ for (const [method, url, body] of otherPaths) {
+ const theirs = await call(method, url, { user: ana, body });
+ assert.strictEqual(theirs.status, 404, `${method} ${url}: ${JSON.stringify(theirs.body)}`);
+ assert.strictEqual(theirs.body.code, 'bot.device_not_found');
+ }
+
+ // An app or a run acting for the person may not reach the owner's own controls: only their own session does.
+ const beforeDelegatedControls = calls.length;
+ for (const [name, tokenFor] of [['an app', appToken], ['a run', runToken]]) {
+ for (const [method, url, body, cap] of [
+ ['POST', `/api/v1/machines/${anaMachine}/agent-computer`, { on: true }, 'network.machines.manage'],
+ ['POST', `/api/v1/machines/${anaMachine}/watch-probe`, { on: true }, 'network.machines.manage'],
+ ['POST', `/api/v1/machines/${anaMachine}/screen`, { desk: 'main' }, 'network.machines.manage'],
+ ['GET', `/api/v1/machines/${anaMachine}/screen`, undefined, 'network.machines.read'],
+ ['GET', `/api/v1/machines/${anaMachine}/desks`, undefined, 'network.machines.read'],
+ ['GET', `/api/v1/machines/${anaMachine}/shares`, undefined, 'network.machines.read'],
+ ]) {
+ const refused = await call(method, url, { token: tokenFor(cap), body });
+ assert.strictEqual(refused.status, 403, `${name} ${method} ${url}: ${JSON.stringify(refused.body)}`);
+ assert.strictEqual(refused.body.code, 'capability.owner_denied');
+ }
+ }
+ assert.strictEqual(calls.length, beforeDelegatedControls, 'Bot was not called for a delegated control');
+
// ── A role outside the catalogue is refused, and Bot never sees it. ──
// ── A role outside the catalogue is refused, and Bot never sees it. ──
const before = calls.length;
const before = calls.length;
const bad = await call('PUT', `/api/v1/machines/${bobMachine}/roles`, { user: ana, body: { roles: ['nope.nope'] } });
const bad = await call('PUT', `/api/v1/machines/${bobMachine}/roles`, { user: ana, body: { roles: ['nope.nope'] } });
@@ -210,7 +370,7 @@ try {
@@ -210,7 +370,7 @@ try {
// ── Pairing a machine and choosing its roles are the person's own session, and nothing else. ──
// ── Pairing a machine and choosing its roles are the person's own session, and nothing else. ──
// A token that acts for the person (an app's, or a run's) may read, rename and revoke; it may never add a
// A token that acts for the person (an app's, or a run's) may read, rename and revoke; it may never add a
// machine or a role: a machine paired that way would carry the person's jobs and their desks.
// machine or a role: a machine paired that way would carry the person's jobs and their desks.
- const OWN_SESSION = 'pair a machine and choose its roles from your own session on openvibe.network';
+ const OWN_SESSION = 'only your own session on openvibe.network changes what a machine does';
const beforeDelegated = calls.length;
const beforeDelegated = calls.length;
for (const [name, token] of [['an app', appToken('network.machines.manage')], ['a run', runToken('network.machines.manage')]]) {
for (const [name, token] of [['an app', appToken('network.machines.manage')], ['a run', runToken('network.machines.manage')]]) {
const pairing = await call('POST', '/api/v1/machines/pairings', { token, body: {} });
const pairing = await call('POST', '/api/v1/machines/pairings', { token, body: {} });
@@ -21,15 +21,17 @@ class Node {
@@ -21,15 +21,17 @@ class Node {
append(...kids) { for (const k of kids) { const n = typeof k === 'string' ? Object.assign(new Node('#text'), { _text: k }) : k; n.parent = this; this.children.push(n); } }
append(...kids) { for (const k of kids) { const n = typeof k === 'string' ? Object.assign(new Node('#text'), { _text: k }) : k; n.parent = this; this.children.push(n); } }
replaceChildren(...kids) { this.children = []; this.append(...kids); }
replaceChildren(...kids) { this.children = []; this.append(...kids); }
replaceWith(n) { const p = this.parent; if (!p) return; const i = p.children.indexOf(this); p.children[i] = n; n.parent = p; }
replaceWith(n) { const p = this.parent; if (!p) return; const i = p.children.indexOf(this); p.children[i] = n; n.parent = p; }
+ remove() { const p = this.parent; if (!p) return; const i = p.children.indexOf(this); if (i >= 0) p.children.splice(i, 1); }
get childNodes() { return this.children.slice(); }
get childNodes() { return this.children.slice(); }
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); }
addEventListener(t, fn) { (this.listeners[t] = this.listeners[t] || []).push(fn); }
fire(t, ev = {}) { (this.listeners[t] || []).forEach((fn) => fn(ev)); }
fire(t, ev = {}) { (this.listeners[t] || []).forEach((fn) => fn(ev)); }
- focus() {} select() {}
+ focus() {} select() {} play() { return Promise.resolve(); }
get classList() { const n = this; return { toggle: (c, on) => { n.className = on ? `${n.className} ${c}` : n.className.replace(c, ''); }, contains: (c) => n.className.split(' ').includes(c) }; }
get classList() { const n = this; return { toggle: (c, on) => { n.className = on ? `${n.className} ${c}` : n.className.replace(c, ''); }, contains: (c) => n.className.split(' ').includes(c) }; }
get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
get textContent() { return this._text + this.children.map((c) => c.textContent).join(''); }
set textContent(v) { this._text = String(v); this.children = []; }
set textContent(v) { this._text = String(v); this.children = []; }
all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }
all(pred, out = []) { for (const c of this.children) { if (pred(c)) out.push(c); c.all(pred, out); } return out; }
querySelectorAll(sel) {
querySelectorAll(sel) {
+ if (sel === '.mc-role-set input') return this.all((n) => n.tagName === 'INPUT' && n.parent && n.parent.className === 'mc-role' && n.parent.parent && n.parent.parent.className.split(' ').includes('mc-role-set'));
if (sel === '.mc-role input') return this.all((n) => n.tagName === 'INPUT' && n.parent && n.parent.className === 'mc-role');
if (sel === '.mc-role input') return this.all((n) => n.tagName === 'INPUT' && n.parent && n.parent.className === 'mc-role');
return [];
return [];
}
}
@@ -45,6 +47,7 @@ function page({ answers }) {
@@ -45,6 +47,7 @@ function page({ answers }) {
console, Date, Number, String, Set, Math, JSON, Promise, URLSearchParams, location: { search: '' },
console, Date, Number, String, Set, Math, JSON, Promise, URLSearchParams, location: { search: '' },
navigator: { clipboard: { writeText: async () => {} } },
navigator: { clipboard: { writeText: async () => {} } },
setInterval: (fn) => { timers.push(fn); return timers.length; }, clearInterval: () => { timers = []; },
setInterval: (fn) => { timers.push(fn); return timers.length; }, clearInterval: () => { timers = []; },
+ setTimeout: (fn) => { timers.push(fn); return timers.length; }, clearTimeout: () => {},
apiFetch: async (p, o = {}) => { calls.push([o.method || 'GET', p, o.body ? JSON.parse(o.body) : null]); const a = answers(o.method || 'GET', p, o.body ? JSON.parse(o.body) : null); if (a instanceof Error) throw a; return a; },
apiFetch: async (p, o = {}) => { calls.push([o.method || 'GET', p, o.body ? JSON.parse(o.body) : null]); const a = answers(o.method || 'GET', p, o.body ? JSON.parse(o.body) : null); if (a instanceof Error) throw a; return a; },
};
};
vm.runInNewContext(SRC, sandbox);
vm.runInNewContext(SRC, sandbox);
@@ -109,6 +112,71 @@ assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the a
@@ -109,6 +112,71 @@ assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the a
await flush();
await flush();
assert.ok(p.calls.some((c) => c[0] === 'POST' && c[1] === '/api/v1/machines/dev_A/revoke'), 'revoked on the second');
assert.ok(p.calls.some((c) => c[0] === 'POST' && c[1] === '/api/v1/machines/dev_A/revoke'), 'revoked on the second');
+ // ── The owner's controls (the removed page's): two switches that read Bot's state, the live screen, its
+ // links, and the machine's desks. Each is a call to Network's hub route, which calls Bot as the person. ──
+ const switchMachine = {
+ id: 'dev_C', kind: 'onboard', online: true, name: 'Desk PC', roles: [], running: [],
+ controls: {
+ computer: true, agent_computer: true, watch_probe: false, level: 'ask', probe: true,
+ screen: { available: true, live: false },
+ desk: { available: true, screen: true, named: true, running: 1, max: 2, kinds: ['browser'] },
+ },
+ };
+ p = page({ answers: (m, url, body) => {
+ if (url === '/api/v1/machines/roles') return { roles: ROLES };
+ if (m === 'GET' && url === '/api/v1/machines') return { machines: [switchMachine] };
+ if (m === 'POST' && /\/agent-computer$/.test(url)) { switchMachine.controls.agent_computer = body.on; return { machine: switchMachine }; }
+ if (m === 'POST' && /\/watch-probe$/.test(url)) { switchMachine.controls.watch_probe = body.on; return { machine: switchMachine }; }
+ if (m === 'POST' && /\/screen$/.test(url)) return { machine_id: 'dev_C', streaming: true, seconds: 600, desk: body.desk };
+ if (m === 'GET' && /\/screen$/.test(url)) return { machine_id: 'dev_C', live: false, state: 'off', online: true };
+ if (m === 'DELETE' && /\/screen$/.test(url)) return { stopped: true };
+ if (m === 'GET' && /\/shares$/.test(url)) return { machine_id: 'dev_C', shares: [{ id: 'shr_abc', label: 'Staff room', control: false, desk: 'main' }] };
+ if (m === 'DELETE' && /\/shares\/shr_abc$/.test(url)) return { revoked: true };
+ if (m === 'GET' && /\/desks$/.test(url)) return { machine_id: 'dev_C', desks: [{ desk: 'desk-a', kind: 'browser', width: 1280, height: 720 }], max_desks: 2 };
+ if (m === 'POST' && /\/desks\/desk-a\/stop$/.test(url)) return { stopped: 'desk-a' };
+ if (m === 'POST' && /\/desks\/desk-a\/live$/.test(url)) return { machine_id: 'dev_C', desk: 'desk-a', seconds: 1800, channel_url: 'https://openvibe.live/@ana' };
+ return {};
+ } });
+ await p.sandbox.loadMachines();
+ const card = p.ids['mc-list'].children[0];
+ assert.match(card.textContent, /Let my agent use this computer/);
+ assert.match(card.textContent, /Let my watches check things from this computer/);
+ const switches = card.all((n) => n.tagName === 'INPUT' && /-(agent|watch)$/.test(n.getAttribute('id') || ''));
+ assert.deepStrictEqual(switches.map((b) => b.checked), [true, false], 'the switches start from Bot\'s own state');
+ switches[0].checked = false; switches[0].fire('change');
+ await flush();
+ assert.deepStrictEqual(p.calls.find((c) => /\/agent-computer$/.test(c[1])), ['POST', '/api/v1/machines/dev_C/agent-computer', { on: false }], 'the agent switch saves on the person\'s click');
+
+ const button = (text) => card.all((n) => n.tagName === 'BUTTON' && new RegExp(text).test(n.textContent))[0];
+ button('Show my screen').fire('click');
+ await flush();
+ assert.deepStrictEqual(p.calls.find((c) => c[0] === 'POST' && /\/screen$/.test(c[1])), ['POST', '/api/v1/machines/dev_C/screen', { desk: 'main' }], 'ten minutes by the server\'s default');
+ assert.ok(p.calls.some((c) => c[0] === 'GET' && /\/screen$/.test(c[1])), 'the picture waits for the machine to publish');
+ button('Stop showing my screen').fire('click');
+ await flush();
+ assert.ok(p.calls.some((c) => c[0] === 'DELETE' && /\/screen$/.test(c[1])), 'Stop reaches the machine');
+
+ button('Screen links').fire('click');
+ await flush();
+ assert.ok(p.calls.some((c) => c[0] === 'GET' && /\/shares$/.test(c[1])));
+ assert.match(card.textContent, /Staff room/);
+ const shareRevoke = card.all((n) => n.tagName === 'BUTTON' && n.parent && n.parent.className === 'mc-share-row')[0];
+ shareRevoke.fire('click');
+ await flush();
+ assert.ok(p.calls.some((c) => c[0] === 'DELETE' && c[1] === '/api/v1/machines/dev_C/shares/shr_abc'), 'revoking one link');
+
+ button('See its desks').fire('click');
+ await flush();
+ assert.ok(p.calls.some((c) => c[0] === 'GET' && /\/desks$/.test(c[1])));
+ assert.match(card.textContent, /desk-a.*browser.*1280×720/);
+ card.all((n) => n.tagName === 'BUTTON' && n.textContent === 'Stop')[0].fire('click');
+ await flush();
+ assert.ok(p.calls.some((c) => c[0] === 'POST' && c[1] === '/api/v1/machines/dev_C/desks/desk-a/stop'), 'a desk stops from here');
+ button('Go live with this desk').fire('click');
+ await flush();
+ assert.ok(p.calls.some((c) => c[0] === 'POST' && c[1] === '/api/v1/machines/dev_C/desks/desk-a/live'), 'the Live channel takes the desk with its warning first');
+ assert.match(card.textContent, /openvibe\.live\/@ana/);
+
// ── Pairing: the code and the command, then the machine arriving ──
// ── Pairing: the code and the command, then the machine arriving ──
machines = [];
machines = [];
p = page({ answers: (m, url) => {
p = page({ answers: (m, url) => {