openvibers/openvibe.bot Commits

Send every /machines path to OpenVibe Link (#371)

OP OpenVibers committed 3 days ago · 5099fcc61e · 21 files, +136 −810

* Send every /machines path to OpenVibe Link

Bot's machines page is Link's now. /machines and everything below it answer a
301 to https://openvibe.link/machines, signed in or not, so a signed-out visitor
never sees Bot's sign-in page on the way. The page renderers, the home row, and
the docs and directory links that pointed at it go with it; docs and the home
fold point at the Link hub instead.

The machine API stays: Bot is still the registry that Link and the other
services call. Removing a machine through the hub now also archives its screen
stream, which the deleted page used to do, and screen share links keep working
(they are made by an agent through POST /api/v1/machines/:id/watch-links and
served at /screen/:token).

Deletes on purpose: none (no file removed; the machines page was a set of routes
and renderers inside server/web/routes.js and server/web/render.js).

* Keep the Link brand test clean in the new redirect test

The comment named the old host with the old machines path, which link-brand.test.js refuses.

Changes

ddiff --git a/.env.example b/.env.example
iindex c23ac15..06c6d6a 100644
---- a/.env.example
++++ b/.env.example
@@@ -120,6 +120,5 @@ OV_OAUTH_CLIENT_SECRET=
#BOT_WATCH_MAX_PER_ROBOT=500
# OpenVibe Link for Android: the published download (the signed release APK), https on GitHub releases or openvibe.link.
--# Unset until the owner's release key exists: /link/phone then says the app is in testing and mints no code, and
--# /machines does not link it.
++# Unset until the owner's release key exists: /link/phone then says the app is in testing and mints no code.
# LINK_ANDROID_APK_URL=https://github.com/OpenVibers/OpenVibe.Link/releases/download/vX.Y.Z/openvibe-link.apk
ddiff --git a/README.md b/README.md
iindex 1a35af9..df171da 100644
---- a/README.md
++++ b/README.md
@@@ -50,14 +50,14 @@ Sitemap entries use the release commit time for these code-backed pages.
- **Desks for OpenVibe.Run's sessions** (contracts 0.223.0, `bot.desk.session`, the Desks plan's D6; migration 0026). Run, with a service token naming the session's person in `X-OV-Subject`, leases one named browser desk per session (`bot.desk-lease@1`).
- `GET /api/v1/desk-hosts` lists where a desk may go for that person:
- their own machines where "Let my agent use this computer" is on (trust `user-owned`);
-- - desk hosts whose owner offers them to other people (`devices.shared_desks`, the "Offer my desks to other people" button on `/machines`), only where the Link sandboxes every desk (`desk.isolation: sandbox`, OpenVibe Link v0.32.0, `install.sh --desk-host --shared`); these are `community`, or `first-party` for the owners in `BOT_FIRST_PARTY_DESK_OWNERS`.
++ - desk hosts whose owner offers them to other people (`devices.shared_desks`, the `devices.shared_desks` switch), only where the Link sandboxes every desk (`desk.isolation: sandbox`, OpenVibe Link v0.32.0, `install.sh --desk-host --shared`); these are `community`, or `first-party` for the owners in `BOT_FIRST_PARTY_DESK_OWNERS`.
- Each host comes with its room: a browser desk counts as half of `max_desks`.
- `POST /api/v1/machines/:id/desk-leases` starts the desk. Then `POST …/desk-leases/:desk/commands` carries `screen`, `input` and `browser` (`bot.command@1` without `desk`) to that desk alone, and `DELETE …/desk-leases/:desk` stops it.
- A leased desk is the session's alone: the machine's own `POST /machines/:id/commands` answers it `409 bot.desk_leased`, its owner's included.
- A full machine answers `409 bot.machine_busy`; anyone else gets 404.
- Shared hosts are also reported to the Network as `worker:browser` offers (`desk:<id>`).
--- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe Link's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-link approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on at `/machines` (migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines page shows each machine's level, what waits for approval there, and the switch. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).
--- **The live screen** (plan T14, contracts 0.182.0, OpenVibe Link v0.12.0): the machine's owner, signed in as themselves, watches its screen at `/machines/:id/screen` or through `POST|GET|DELETE /api/v1/machines/:id/screen` (`server/machine-screen.js`).
++- **A person's computer for their agents** (plan T14/T17, contracts 0.166.0): a machine installed with `--computer observe|ask|trusted|full` runs OpenVibe Link's `computer` plugin as the person's own account, and the level set on the machine decides what may happen there (`ask` waits for `openvibe-link approve <id>`). `POST /api/v1/machines/:id/commands` sends `exec`, `fs` and `screen` (`bot.command@1`, the value checked against the contract) to it as its owner, or as their agent (`svc:actor` with `bot.computer.control`, acting as the owner) once the owner turned "Let my agent use this computer" on (`POST /api/v1/machines/:id/agent-computer`; migration 0014, `devices.agent_computer`). The answer comes back in `data` (the device socket takes frames up to 640 KB so it fits; a repeated id answers its result without the data). A machine whose plugin is off answers `bot.computer_off`. The audit keeps the program name and argument count, or the file op and path, never stdin, arguments or content. The machines API shows each machine's level, what waits for approval there, and the switch. OpenVibe.Watch (`svc:watch`, `bot.computer.control`, contracts 0.167.0) may send only `probe`: one GET or TCP connect from the machine to its own network, for a watch's `node` source. It works only where the owner turned on "Let my watches check things from this computer" (migration 0015, `devices.watch_probe`), and the audit keeps the probe's host, not its path. Every device's config lists the computer kinds, which only this route sends (a robot's operators never can).
++- **The live screen** (plan T14, contracts 0.182.0, OpenVibe Link v0.12.0): the machine's owner, signed in as themselves, watches its screen through `POST|GET|DELETE /api/v1/machines/:id/screen` (`server/machine-screen.js`).
- **The stream.** Each start makes or rotates the machine's own OpenRestream stream (external ref `bot:machine:<id>`, playback private, no recording, never mirrored to Live; `devices.screen_stream_id`, migration 0017). Rotating ends any session still open.
- **The command.** `screen_stream` start goes to the machine with that stream's WHIP URL and key. It is not a `bot.command@1` kind, so the commands route, agents, watches and operators can never send it or choose where the screen goes.
- **The audit** keeps the action, seconds and fps, never the URL or key.
@@@ -73,11 +73,10 @@ Sitemap entries use the release commit time for these code-backed pages.
- **The person always wins.** A person's mouse, keyboard or browser action on a screen (the owner's, or through a
share link) holds that screen (main or the desk) for `BOT_PERSON_TURN_MS` (15 s). An agent's acting command there
meanwhile answers `409 bot.person_driving` ("a person is using this screen right now"); reading goes on.
-- - **The live screen** of the desk: `POST /machines/:id/screen` with `desk: "private"` (or the page's "Show the
-- private desk" button) sends `screen_stream` with `desk: private`.
++ - **The live screen** of the desk: `POST /api/v1/machines/:id/screen` with `desk: "private"` sends `screen_stream` with `desk: private`.
- **Vibe by voice** (`server/voice.js`, migration `0024_machine_voice.sql`, contracts 0.214.0 `voice_ask` /
`voice_answer`; consumer plan §8). On a machine whose Link runs the voice plugin (`capabilities.voice`), the owner
-- turns voice on and chooses a call word ("hey vibe" by default) on /machines or `POST /api/v1/machines/:id/voice`.
++ turns voice on and chooses a call word ("hey vibe" by default) with `POST /api/v1/machines/:id/voice`.
- **Config** then carries `voice: true` and `call_word`, and the machine listens for the word on its own. Only what
the person says after it comes as `voice_ask`.
- **Answers:** Bot answers `working` at once, then asks Vibe as the owner through Chat (`POST <BOT_CHAT_INTERNAL_URL>
@@@ -114,11 +113,10 @@ Sitemap entries use the release commit time for these code-backed pages.
(`kind: browser`) are a headless browser alone; a desk host or a Mac runs browser desks only.
- **Programs on a desk:** `exec` with `desk`, and `detach` for an app that lasts until the desk stops.
- **Watch and take over:** the desk's live screen, where the person's own input wins (`bot.person_driving`).
-- - **The desks page** `/machines/:id/desks` lists what runs there. The machines API reports `desks_running`, so
-- Actor's `machine: "any"` places a desk on a desk host first, then on the machine with the most room.
++ - The machines API reports `desks_running`, so Actor's `machine: "any"` places a desk on a desk host first, then
++ on the machine with the most room.
- **Desk hosts:** `install.sh --desk-host` turns a spare computer into a host for its owner's agents.
-- - **A desk on the owner's OpenVibe Live channel**, at their request (`POST /machines/:id/screen { desk, live: true }`,
-- or the desks page's "Show live on my channel" and its second step): a second, public OpenRestream stream of the
++ - **A desk on the owner's OpenVibe Live channel**, at their request (`POST /api/v1/machines/:id/screen { desk, live: true }`): a second, public OpenRestream stream of the
machine's (`bot:machine_live:<id>`) whose `mirror_to_live` OpenRestream links to their channel. It is never the
person's own screen and never the private screen stream, and it lasts up to 30 minutes.
- **Your own models** (AI on your own machines; contracts 0.219.0 `bot.command@1` kind `ai`, capability
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex e7735c6..5441f97 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -797,10 +797,13 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
}));
// The machine is no longer the person's: its credential dies here, and its node principal on Network dies with it.
r.post('/machines/:id/revoke', machineManage, wrap(async (req, res) => {
-- const { device } = await hubMachine(req);
++ const { device, subject } = await hubMachine(req);
const revoked = await domain.devices.revoke(device.id);
let networkError = null;
if (device.node_principal) await domain.devices.revokeNode(device.node_principal).catch((e) => { networkError = e; });
++ // The machine's screen stream goes with it (the page that removed a machine used to do this; the hub is the
++ // only removal path now). OpenRestream refuses a live stream, so a refusal is best effort, as it was there.
++ await domain.machines.screen.archive(device, subject).catch(() => {});
await domain.devices.revokeVideo(device.id).catch((e) => { networkError = networkError || e; });
hub.closeDevice(device.id, 'revoked');
if (networkError) throw networkError;
ddiff --git a/server/machine-screen.js b/server/machine-screen.js
iindex 25a590c..8530985 100644
---- a/server/machine-screen.js
++++ b/server/machine-screen.js
@@@ -1,7 +1,7 @@
'use strict';
/**
* The live screen (plan T14, contracts 0.182.0): a machine's owner watches its screen on their own private OpenRestream
-- * stream. Used by the API (POST/GET/DELETE /api/v1/machines/:id/screen) and the web page (/machines/:id/screen), for
++ * stream. Used by the API (POST/GET/DELETE /api/v1/machines/:id/screen) and the screen share page (/screen/:token), for
* the owner signed in as themselves only — never an agent, a watch or another service.
*
* start(device, subject, principal, { seconds, fps, desk }) a new key for the machine's stream (domain.machines.screen),
ddiff --git a/server/web/directory.js b/server/web/directory.js
iindex 4268742..acfb3e8 100644
---- a/server/web/directory.js
++++ b/server/web/directory.js
@@@ -152,7 +152,7 @@ function renderDirectory({ config, listed = [], mine = null, models = [], signed
const yours = !signedIn ? `<section class="dir-sec dir-cta"><h2>Have a robot?</h2>
<p class="muted">Sign in to add it and pair it with one pasted command. A Raspberry Pi robot can start from a blank microSD card, with nothing to type on the robot. Or drive a simulated rover first, no account needed.</p>
<p class="dir-actions">${link('/try', 'Try the panel', 'dir-btn dir-primary')} ${link('/auth/login?next=%2Frobots%2Fnew', 'Sign in and add a robot', 'dir-btn')} ${link('/setup/sd-card', 'From a blank microSD card', 'dir-btn')}</p></section>`
-- : `<section class="dir-sec" aria-labelledby="yours-h"><div class="dir-sec-head"><h2 id="yours-h">Your robots</h2>${link('/machines', 'Your machines', 'dir-btn')}${link('/robots/new', 'Add a robot', 'dir-btn dir-primary')}</div>
++ : `<section class="dir-sec" aria-labelledby="yours-h"><div class="dir-sec-head"><h2 id="yours-h">Your robots</h2>${link('https://openvibe.link/machines', 'Your machines', 'dir-btn')}${link('/robots/new', 'Add a robot', 'dir-btn dir-primary')}</div>
${mine && mine.length ? `<ul class="dir-list">${mine.map((r) => `<li class="dir-row"><a href="/panel/${esc(r.id)}"><b>${esc(r.name)}</b></a><span class="muted">${esc(r.model_name)}</span>`
+ `${r.listed ? `<a class="dir-pill" href="/r/${esc(r.id)}">Listed</a>` : '<span class="dir-pill dir-quiet">Not listed</span>'}`
+ `<span class="dir-row-actions">${link(`/panel/${r.id}`, 'Panel')} · ${link(`/pair/${r.id}`, 'Pair a device')}</span></li>`).join('')}</ul>`
ddiff --git a/server/web/docs.js b/server/web/docs.js
iindex b04b036..0c51a66 100644
---- a/server/web/docs.js
++++ b/server/web/docs.js
@@@ -384,7 +384,7 @@ function renderComputersPage({ config }) {
${kit.pageHead({ title: `Your computer for your agents`, icon: 'code', back: { href: '/', label: 'Home' } , lede: `The OpenVibe Link's computer plugin lets your agents use your own computer: its terminal, its files, its screen and (when you allow it) its mouse and keyboard. It is off unless you install it, it never runs as root, and you decide what it may do on the computer itself. Nothing on OpenVibe can raise that level.` })}
<section class="card" aria-labelledby="install-h"><h2 id="install-h">Install it</h2>
--<p>On ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Let your agents use it</b> on the computer, from your own account (the installer uses sudo itself):</p>
++<p>On ${out('https://openvibe.link/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Let your agents use it</b> on the computer, from your own account (the installer uses sudo itself):</p>
${code(COMPUTER_INSTALL)}
<p class="muted">The installer is also at <a href="https://openvibe.link/install">openvibe.link/install</a>, which keeps working. What the Link is and what it does is on <a href="https://openvibe.link">openvibe.link</a>.</p>
<p>The Link then runs as you, never as root, and its only folder is your home folder. Linux for now; elsewhere, run the Link in your own session with the plugin in its config (${out(`${BLOB}/docs/computer.md`, 'computer.md')}).</p></section>
@@@ -410,7 +410,7 @@ ${code(COMPUTER_APPROVE)}
<p>The local stop drops the computer to observe at once: what runs is killed, what waits is denied, and nothing runs or changes until you resume.</p></section>
<section class="card" aria-labelledby="agent-c-h"><h2 id="agent-c-h">Your agent</h2>
--<p>On ${inLink('/machines', 'Your machines')}, <b>Let my agent use this computer</b> is off until you turn it on. With it on, a task you give ${out('https://openvibe.actor', 'OpenVibe.Actor')}, such as "what changed in ~/site today?", can list and read files, run programs and write files there, as you and at your level. The ${out('https://openvibe.actor/console', 'Console')} shows what waits for you. Turning the switch off stops it at once.</p>
++<p>On ${out('https://openvibe.link/machines', 'Your machines')}, <b>Let my agent use this computer</b> is off until you turn it on. With it on, a task you give ${out('https://openvibe.actor', 'OpenVibe.Actor')}, such as "what changed in ~/site today?", can list and read files, run programs and write files there, as you and at your level. The ${out('https://openvibe.actor/console', 'Console')} shows what waits for you. Turning the switch off stops it at once.</p>
<p><b>Let my watches check things from this computer</b> is a separate switch. It lets ${out('https://openvibe.watch', 'OpenVibe.Watch')} probe your own network from the computer for your watches, for example "tell me when my NAS stops answering". It allows only probes: one GET or one TCP connect, to private addresses only.</p></section>
<section class="card" aria-labelledby="desks-h"><h2 id="desks-h">Desks: a computer of its own for each task</h2>
@@@ -421,7 +421,7 @@ ${code(COMPUTER_APPROVE)}
<li><b>It hands you the steps that are yours.</b> For a sign-in, a code sent to your phone, a CAPTCHA or a payment, the agent gets the page ready and asks you to do that step on its desk ("Needs you"). It never types a password itself.</li>
<li><b>Sign in once.</b> On OpenVibe Link v0.24.0 or newer, your agent's browser profile is kept on each machine: a site you signed in to on one task's desk is still signed in on the next.</li>
<li><b>Never your own network.</b> Every page the agent's browser opens goes through a guard on the machine (Link v0.25.0): nothing on it can reach your router, printer or other computers at home, unless you set the machine to <b>full</b>.</li>
--<li><b>A spare computer as a desk host.</b> An old laptop or a mini PC can host desks for all your tasks: on ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the desk host command (Linux). It needs no screen, keyboard or sign-in, runs under an account of its own, and is used before your own computer when a task can work anywhere.</li>
++<li><b>A spare computer as a desk host.</b> An old laptop or a mini PC can host desks for all your tasks: on ${out('https://openvibe.link/machines', 'Your machines')}, choose <b>Add a machine</b> and run the desk host command (Linux). It needs no screen, keyboard or sign-in, runs under an account of its own, and is used before your own computer when a task can work anywhere.</li>
<li><b>See and stop them.</b> Each machine says how many desks run on it; <b>See its desks</b> lists them, to watch, take over or stop. A desk nobody uses for 15 minutes stops by itself, and the computer's kill switch (<code>openvibe-link stop</code>) stops them all.</li>
</ul>
<p>How many run at once depends on the machine's memory (one whole desk per 2 GB by default; a browser desk counts as half). The details are in ${out(`${BLOB}/docs/computer.md`, 'computer.md')}.</p></section>
@@@ -429,7 +429,7 @@ ${code(COMPUTER_APPROVE)}
<section class="card" aria-labelledby="models-c-h"><h2 id="models-c-h">Your own models</h2>
<p>If you run a model on your computer (Ollama, LM Studio or llama.cpp's server), your OpenVibe AI can think with it: free, private to you, and with no port opened.</p>
<ul>
--<li><b>Set it up.</b> On ${inLink('/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Use the models you run on it</b>, or add <code>--ai</code> to any other one (OpenVibe Link v0.28.0 or newer). The installer says which model server it finds.</li>
++<li><b>Set it up.</b> On ${out('https://openvibe.link/machines', 'Your machines')}, choose <b>Add a machine</b> and run the command under <b>Use the models you run on it</b>, or add <code>--ai</code> to any other one (OpenVibe Link v0.28.0 or newer). The installer says which model server it finds.</li>
<li><b>Turn it on.</b> The machine's card lists its models and how fast they answer there. <b>Let my AI use this computer's models</b> lets OpenVibe ask them for you; nothing reaches them before that.</li>
<li><b>Use it.</b> <b>Use these models in OpenVibe.Chat</b> fills in "Your AI key" on OpenVibe.Chat. Your Vibe and the AI members you set up can then think with your computer. On OpenVibe.Live, a streamer's AI viewers can too: choose <b>A model on my own computer</b> as their AI source.</li>
<li><b>Any of your computers.</b> Choose <b>any of my computers</b> instead of one, and OpenVibe asks the fastest of yours that is on and has the model, then the next when one is busy.</li>
@@@ -438,7 +438,7 @@ ${code(COMPUTER_APPROVE)}
<p>The details are in ${out(`${BLOB}/docs/ai.md`, 'ai.md')}.</p></section>
<section class="card" aria-labelledby="screen-c-h"><h2 id="screen-c-h">Watch your screen live</h2>
--<p>On ${inLink('/machines', 'Your machines')}, <b>Show my screen</b> shows the computer's screen live, to you only, for 10 minutes (30 seconds to 30 minutes through the API).</p>
++<p>On ${out('https://openvibe.link/machines', 'Your machines')}, <b>Show my screen</b> shows the computer's screen live, to you only, for 10 minutes (30 seconds to 30 minutes through the API).</p>
<ul>
<li><b>Private.</b> It goes to a private stream of that computer's own: nothing is recorded, it never appears on OpenVibe Live, and only you can open it. Your agents, your watches and anyone else cannot start it.</li>
<li><b>How it is captured.</b> The computer takes a few pictures of its screen a second with the same tools as a screenshot. It needs OpenVibe Link v0.12.0 or newer, with the computer plugin running in your desktop session.</li>
ddiff --git a/server/web/home.js b/server/web/home.js
iindex 73028d8..a7f7865 100644
---- a/server/web/home.js
++++ b/server/web/home.js
@@@ -174,7 +174,6 @@ function moreSection({ config, kit: k, models }) {
]);
const computers = rows([
['/docs/computers#desks-h', 'A desk for each task', 'Your agents work on desks of their own on your computer, a browser or a whole screen, never yours. Watch any of them live and take it over.'],
-- ['/machines', 'A spare computer as a desk host', 'An old laptop or a mini PC hosts desks for all your agents\' tasks, with no screen or sign-in, sized to its memory.'],
['/docs/computers#models-c-h', 'Your own AI models', 'Ollama, LM Studio or llama.cpp on your computer answers your Vibe, your AI members and your stream\'s AI viewers: free, and no port is opened.'],
['/docs/computers', 'A desk live on your channel', 'When you choose, an agent\'s desk shows on your OpenVibe Live channel for up to 30 minutes, so people can watch it work.'],
]);
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 3f8bcc0..a5e4058 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -23,7 +23,6 @@ const kit = require('openvibe-shared/kit');
*/
const { qrSvg } = require('./qr');
const { stepHelp } = require('../routines');
--const { aiOf } = require('../machine-ai');
const social = require('openvibe-shared/social');
const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c]);
@@@ -750,7 +749,7 @@ function renderPhoneLinkPage({ pairing = null, apkUrl = null } = {}) {
${kit.pageHead({ title: `Link on your phone`, icon: 'gear', back: { href: '/', label: 'Home' } })}
<p>OpenVibe Link for Android puts your agents' approvals in your pocket and adds <b>Send to OpenVibe</b> to every app's
share sheet. It is in testing and not published yet: when it is, you install it from here and pair it with a code.</p>
--<p class="muted"><a href="/machines">Your machines</a> · <a href="/robots">Your robots</a></p>
++<p class="muted"><a href="https://openvibe.link/machines" rel="noopener">Your machines</a> · <a href="/robots">Your robots</a></p>
</main>`;
return page('Link on your phone', body, { description: 'OpenVibe Link for Android: in testing, not published yet.' });
}
@@@ -771,7 +770,7 @@ the code pairs the device, and the app asks you to sign in separately.</p></li>
<p class="muted">It works once, for the next ${minutes} ${minutes === 1 ? 'minute' : 'minutes'} (until <time datetime="${esc(pairing.expires_at)}">${esc(String(pairing.expires_at).slice(11, 16))} UTC</time>).</p></li>
</ol>
<p class="muted">The phone shows up on your account as a device of kind <b>phone</b>. It has no camera or screen access, and
--nothing on the phone is automated. <a href="/machines">Your machines</a> · <a href="/robots">Your robots</a></p>
++nothing on the phone is automated. <a href="https://openvibe.link/machines" rel="noopener">Your machines</a> · <a href="/robots">Your robots</a></p>
</main>`;
return page('Link on your phone', body, { description: 'Pair the OpenVibe Link Android app with a one-time code or its QR code.' });
}
@@@ -825,189 +824,6 @@ ${chatUrl ? `<li class="card"><h2>Drive it from your chat</h2><p class="muted">L
return page(`${robot.name} is yours`, body, { scripts: ['/panel/panel.js'] });
}
--/**
-- * /machines (plan T14/T16): a person's own machines that run jobs for them, and the command that adds one. A machine
-- * here runs its owner's jobs only (Bot refuses anyone else's), connects out, and keeps its local limits and kill switch.
-- */
--function renderMachinesPage({ machines = [], pairing = null, phoneReady = false }) {
-- const classesOf = (m) => {
-- const w = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).worker || {};
-- return Array.isArray(w.runtime_classes) ? w.runtime_classes : [];
-- };
-- // The computer plugin (Link docs/computer.md): the level its owner set on the machine, and whether their agent may use it.
-- const LEVELS = { observe: 'Observe', ask: 'Ask first', trusted: 'Trusted', full: 'Full control' };
-- const computerOf = (m) => {
-- const caps = m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {};
-- // Its own models (OpenVibe Link's ai plugin, server/machine-ai.js): what it offers, and whether a model server answers.
-- const models = aiOf(caps);
-- const ai = models ? { models: Array.isArray(models.models) ? models.models.slice(0, 20).map(String) : [], ready: models.ready === true,
-- offer: models.offer && typeof models.offer === 'object' ? models.offer : null,
-- speed: models.speed && typeof models.speed === 'object' ? models.speed : null } : null;
-- const c = caps.computer;
-- if (!c || typeof c !== 'object') return ai ? { on: false, ai } : null;
-- const level = m.computer_level || ((c.files || c.terminal || c.screen || {}).level) || null;
-- const d = c.desk && typeof c.desk === 'object' ? c.desk : null;
-- return { on: true, level: LEVELS[level] ? level : null, terminal: !!c.terminal, files: !!c.files, screen: !!c.screen, probe: !!c.probe, input: !!c.input,
-- screenStream: !!(c.screen && c.screen.stream === true),
-- // Desks for its agents (Desks plan): how many at once, which kinds, and whether sign-ins carry over.
-- desks: d ? { max: Number.isInteger(d.max_desks) ? d.max_desks : 1, kinds: Array.isArray(d.kinds) ? d.kinds : ['desktop'], keeps: d.profiles === true,
-- // Each desk in its own sandbox (Link v0.32.0, --desk-host --shared): it may serve other people's sessions.
-- sandboxed: d.isolation === 'sandbox' } : null,
-- ai };
-- };
-- // Vibe by voice: shown for a machine whose Link runs the voice plugin (capabilities.voice).
-- const voiceRow = (m) => {
-- const v = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).voice;
-- if (!v || typeof v !== 'object') return '';
-- const word = m.call_word || 'hey vibe';
-- return `<div class="machine-voice"><span class="pill">${m.voice_on ? `Voice: "${esc(word)}"` : 'Voice: off'}</span>`
-- + (m.voice_on
-- ? `<form method="post" action="/machines/${esc(m.id)}/voice"><input type="hidden" name="on" value="0"><button type="submit" class="button quiet" aria-pressed="true">Stop Vibe answering my voice here</button></form>`
-- : `<form method="post" action="/machines/${esc(m.id)}/voice" class="inline-form"><input type="hidden" name="on" value="1"><label class="sr-only" for="cw-${esc(m.id)}">Call word</label><input id="cw-${esc(m.id)}" name="call_word" maxlength="40" value="${esc(word)}" aria-label="Call word"><button type="submit" class="button quiet" aria-pressed="false">Let Vibe answer my voice here</button></form>`)
-- + '<span class="muted">The machine listens for its call word on its own; only what you say after it goes to Vibe, and the answer is spoken here and kept in your Vibe chats.</span></div>';
-- };
-- // "Desks for your agents: 4 at once (browser desks count as half), sign-ins kept"
-- const deskWords = (d) => {
-- const browser = d.kinds.includes('browser');
-- const whole = d.kinds.includes('desktop');
-- const kinds = browser && whole ? `${d.max} at once, or twice as many browser desks` : browser ? `${d.max * 2} browser desks at once` : `${d.max} at once`;
-- return `Desks for your agents: ${kinds}${d.keeps ? ', sign-ins kept' : ''}`;
-- };
-- // The person's machines whose models their AI may use: with two or more, Chat's link chooses any of them.
-- const modelMachines = machines.filter((x) => { const c = x.ai_models && computerOf(x); return !!(c && c.ai && c.ai.models.length); }).length;
-- // A desk host that sandboxes every desk (Desks plan D6): its owner may offer its desks to other people's sessions on
-- // OpenVibe.Run, and sees how many are in use. Nothing of the machine itself is reachable from a desk there.
-- const shareRow = (m, c) => {
-- if (!c.desks || !c.desks.sandboxed) return '';
-- const inUse = Number.isInteger(m.desk_leases) && m.desk_leases > 0 ? ` <span class="pill">${m.desk_leases === 1 ? '1 desk in use' : `${m.desk_leases} desks in use`}</span>` : '';
-- return `<div class="machine-share"><span class="pill">${m.shared_desks ? 'Serving other people' : 'Desks: yours only'}</span>${inUse}`
-- + '<span class="muted">Each desk runs in its own sandbox here (its own memory, CPU and network, the public internet only): nobody reaches this machine\'s files, screen or network from one.</span>'
-- + `<form method="post" action="/machines/${esc(m.id)}/shared-desks"><input type="hidden" name="on" value="${m.shared_desks ? '0' : '1'}">`
-- + `<button type="submit" class="button quiet" aria-pressed="${m.shared_desks ? 'true' : 'false'}">${m.shared_desks ? 'Stop offering my desks' : 'Offer my desks to other people'}</button></form></div>`;
-- };
-- const computerRow = (m) => {
-- const c = computerOf(m);
-- if (!c) return '';
-- // "about 15 tokens a second" (with which model, when it offers several), measured there by the ai plugin.
-- const speed = m.ai_speed ? `about ${m.ai_speed.tokens_per_s >= 10 ? Math.round(m.ai_speed.tokens_per_s) : m.ai_speed.tokens_per_s} tokens a second${c.ai.models.length > 1 ? ` with ${m.ai_speed.model}` : ''}` : '';
-- const aiRow = c.ai ? `<div class="machine-ai"><span class="pill">${c.ai.ready ? 'Models' : 'Models: no server answers'}</span>${c.ai.models.length ? `<span class="muted">${esc(c.ai.models.join(', '))}</span>` : ''}`
-- + (m.ai_busy ? ' <span class="pill">Answering now</span>' : '') + (speed ? `<span class="muted">${esc(speed)}</span>` : '')
-- + `<form method="post" action="/machines/${esc(m.id)}/ai-models"><input type="hidden" name="on" value="${m.ai_models ? '0' : '1'}">`
-- + `<button type="submit" class="button quiet" aria-pressed="${m.ai_models ? 'true' : 'false'}">${m.ai_models ? 'Stop my AI using this computer\'s models' : 'Let my AI use this computer\'s models'}</button></form>`
-- // Turned on: OpenVibe.Chat's "Your AI key" comes filled in with this computer and its first model (saved only there).
-- // With several such machines, any of them (contracts 0.221.0): the fastest that is on answers.
-- + (m.ai_models && c.ai.models.length ? `<a class="button quiet" href="https://openvibe.chat/settings?own=node&amp;machine=${modelMachines > 1 ? 'any' : encodeURIComponent(m.id)}&amp;model=${encodeURIComponent(c.ai.models[0])}#oc-ai-key">Use these models in OpenVibe.Chat</a>` : '')
-- + (() => {
-- const o = c.ai.offer;
-- if (!o || o.enabled !== true) return '';
-- const speed = c.ai.speed && typeof c.ai.speed === 'object' ? Object.entries(c.ai.speed).slice(0, 3).map(([model, rate]) => `${model} ${rate} tok/s`).join(', ') : '';
-- return `<div class="machine-share"><span class="pill">${o.paused ? 'Offer paused: this machine is in use' : 'Offering its models to the network'}</span>`
-- + `<span class="muted">${esc(o.privacy || 'A community machine sees the prompt. Only requests that allow community capacity are sent here, and every answer is labelled Community.')}</span>`
-- + `${speed ? `<span class="muted">${esc(speed)}</span>` : ''}</div>`;
-- })()
-- + '</div>' : '';
-- // A machine that offers only its models (the ai plugin, no computer plugin): just them.
-- if (!c.on) return `<div class="machine-computer">${aiRow}</div>`;
-- const can = [c.terminal && 'terminal', c.files && 'files', c.screen && 'screen', c.input && 'mouse and keyboard'].filter(Boolean).join(', ');
-- const desks = c.desks ? `<span class="muted">${esc(deskWords(c.desks))}</span>` : '';
-- // The desks running now (the Link's telemetry), and the page that lists them, to watch or stop one.
-- const running = Number.isInteger(m.computer_desks) && m.computer_desks > 0
-- ? ` <span class="pill">${m.computer_desks === 1 ? '1 desk running' : `${m.computer_desks} desks running`}</span>` : '';
-- const deskLink = c.desks && m.online ? `<a class="button quiet" href="/machines/${esc(m.id)}/desks">See its desks</a>` : '';
-- const waiting = Number.isInteger(m.computer_waiting) && m.computer_waiting > 0
-- ? ` <span class="pill warn">${m.computer_waiting} waiting for you on the machine</span>` : '';
-- return `<div class="machine-computer"><span class="pill">Computer: ${esc(c.level ? LEVELS[c.level] : 'on')}</span><span class="muted">${esc(can)}</span>${desks}${running}${waiting}${deskLink}`
-- + `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`
-- + `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`
-- + shareRow(m, c)
-- + (c.screenStream && m.online ? `<form method="post" action="/machines/${esc(m.id)}/screen"><button type="submit" class="button quiet">${m.computer_screen_stream === 'live' ? 'Watch my screen' : 'Show my screen'}</button></form>` : '')
-- + aiRow
-- + (c.probe ? `<form method="post" action="/machines/${esc(m.id)}/watch-probe"><input type="hidden" name="on" value="${m.watch_probe ? '0' : '1'}">`
-- + `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from this computer'}</button></form>` : '')
-- + '</div>';
-- };
-- // The guided first run's one job (Link v0.39.0): what this machine is, from the first_job step Bot stored.
-- const bytesText = (n) => {
-- if (!Number.isFinite(n) || n < 0) return '';
-- const units = ['B', 'KB', 'MB', 'GB', 'TB'];
-- let v = n, i = 0;
-- while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
-- return `${v >= 10 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
-- };
-- const firstRunRow = (m) => {
-- const f = m.first_run;
-- if (!f) return '';
-- const i = f.info && typeof f.info === 'object' ? f.info : {};
-- const bits = [];
-- if (Number.isInteger(i.cpus) && i.cpus > 0) bits.push(`${i.cpus} CPUs`);
-- if (Number.isFinite(i.memory_total_bytes) && i.memory_total_bytes > 0) bits.push(`${bytesText(i.memory_free_bytes)} free of ${bytesText(i.memory_total_bytes)} memory`);
-- if (Number.isFinite(i.disk_total_bytes) && i.disk_total_bytes > 0) bits.push(`${bytesText(i.disk_free_bytes)} free disk`);
-- bits.push(`${f.os || 'this machine'}${i.arch ? '/' + i.arch : ''}`);
-- return `<div class="machine-first-run"><span class="pill">First run</span><span class="muted">${esc(bits.join(' · '))}</span>`
-- + '<span class="muted small">What its first job found: OS, CPUs, memory and free disk. No file, account or address leaves the machine.</span></div>';
-- };
-- const list = machines.length
-- ? `<ul class="robots">${machines.map((m) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span>${esc(m.name || m.id)}</span></span>`
-- + `<span class="robot-meta"><span class="pill">${m.online ? 'Online' : 'Offline'}</span>${classesOf(m).map((c) => ` <span class="pill">${esc(c)} jobs</span>`).join('')}`
-- + `${m.agent_version ? ` <span class="pill">Link ${esc(m.agent_version)}</span>` : ''}${m.robot ? ` <span class="pill">Serves ${esc(m.robot)}</span>` : ''}</span>`
-- + firstRunRow(m) + computerRow(m) + voiceRow(m)
-- + `<span class="robot-actions"><form method="post" action="/machines/${esc(m.id)}/remove"><button type="submit" class="button quiet">Remove</button></form></span></li>`).join('')}</ul>`
-- : `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No machines yet.</p><p>Add your computer or a server you own. It runs the jobs you start, such as a coding task on openvibe.codes, and nobody else's.</p></div>`;
-- // Adding a machine is a choice of what it does, each with its own command (one copy button each), not steps.
-- const choice = (title, flag, text) => `<li class="card"><h2>${title}</h2>
--<pre tabindex="0" class="installer"><code data-copy-source>${esc(pairing.installer.replace(/ --worker$/, flag))}</code></pre>
--<button type="button" class="copy" data-copy>${icon('copy')}<span data-copy-label>Copy the command</span></button>
--<p class="muted">${text}</p></li>`;
-- const add = pairing
-- ? `<h2 id="add-h">What should this computer do?</h2>
--<p class="muted">Pick one and run its command on the computer: it installs OpenVibe Link and pairs the computer with your account. Add <code>--ai</code> to any of them to use its models too.</p>
--<ul class="choices" aria-labelledby="add-h">
--${choice('Run your coding jobs', ' --worker', 'Linux with systemd. It turns Link\'s job worker on: jobs run as a separate user, in a sandbox, with the limits you set. For coding jobs the computer also needs Node.js 22 or newer and git, and a coding agent: Claude Code, Codex, OpenCode, or a DeepSeek key.')}
--${choice('Let your agents use it', ' --computer ask', 'The computer\'s terminal, files and screen become usable by your agents, at the level you set on the computer: <code>ask</code> waits for <code>openvibe-link approve</code> there before anything runs or changes. The Link runs as your own account, never root. <a href="https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/computer.md">How it works</a>.')}
--${choice('Make a spare computer a desk host', ' --desk-host', 'An old laptop, a mini PC or a spare workstation becomes a place where your agents work: each task gets a desk of its own, with its own browser, and you can watch any desk and take it over. No screen, keyboard or sign-in needed on it. It runs as its own account and only for your agents; the installer adds what desks need and sizes them to its memory. Linux. <a href="https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/computer.md#desk-hosts---desk-host-node-v0200">How it works</a>.')}
--${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama, LM Studio or llama.cpp\'s server, your Vibe and your AI members can think with its models: free, and no port is opened. The installer says which server it finds. Once it is paired, turn on "Let my AI use this computer\'s models" for it here. Link v0.28.0 or newer. <a href="https://github.com/OpenVibers/OpenVibe.Link/blob/main/docs/ai.md">How it works</a>.')}
--<li class="card"><h2>Or enter the pairing code</h2><p><strong class="code">${esc(pairing.code)}</strong></p>
--<p class="muted">Valid until <time datetime="${esc(pairing.expires_at)}" data-local-time>${esc(String(pairing.expires_at).slice(11, 16))} UTC</time>. It works once.</p></li></ul>
--<p class="muted">Reload this page once the installer says it is paired: the machine shows up above.</p>`
-- : `<form class="add-robot card" method="post" action="/machines"><h2>Add a machine</h2>
--<p class="muted">You get a one-line install command with a one-time code.</p><button type="submit" class="primary">Add a machine</button></form>`;
-- const body = `${topbar('<span class="crumb">Machines</span>')}
--<main class="page">
--${kit.pageHead({ title: `Your machines`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `A machine here runs jobs for you and only you. It connects out to OpenVibe and needs no open ports. The limits in its own config and its local kill switch (openvibe-link stop) always win.` })}
--<p class="muted"><a href="https://openvibe.link/machines">All your machines are on openvibe.link/machines</a></p>
--${list}
--${add}
--${phoneReady ? '<p class="muted">In your pocket: <a href="/link/phone">Link on your phone</a> adds the Android app, which shows your agents\' approvals and takes files from any share sheet.</p>' : ''}
--</main>`;
-- return page('Your machines', body, { scripts: pairing ? ['/panel/panel.js'] : [], description: 'Your own machines that run your jobs on OpenVibe.', canonical: '/machines' });
--}
--
--/**
-- * /machines/:id/screen: the machine's screen, live, for its owner only (plan T14). The tile waits for the machine's
-- * session (public/panel.js polls `stateUrl`) and plays it like a robot's camera; Stop ends it at once.
-- */
--/** The owner's links to this screen (server/screen-shares.js): make one to watch or use it, see and stop the others. */
--function sharesBlock(machine, shares, madeLink, madeShare, hasDesk = false, hasScreen = true) {
-- // Only screens the machine has: a desk host has none of its own (its private desk is what a link shares), and a
-- // machine with neither has nothing to share.
-- if (!hasScreen && !hasDesk) return '';
-- const which = !hasScreen
-- ? '<input type="hidden" name="desk" value="private"><p class="muted small">A link here shares the private desk: this computer has no screen of its own.</p>'
-- : hasDesk ? '<label class="field"><span class="field-label">Which screen</span><select name="desk"><option value="main">Your own screen</option><option value="private">The private desk, where your agent works</option></select></label>' : '';
-- const rows = (shares || []).map((x) => `<li><strong>${esc(x.label || 'A link')}</strong> · ${x.desk && x.desk !== 'main' ? `the ${esc(x.desk)} desk · ` : ''}${x.control ? 'watch and use' : 'watch only'} · until <time datetime="${esc(x.expires_at || '')}" data-local-time>${esc(String(x.expires_at || '').slice(0, 10))}</time>${x.last_used_at ? ` · last opened <time datetime="${esc(x.last_used_at)}" data-local-time>${esc(String(x.last_used_at).slice(0, 16).replace('T', ' '))}</time>` : ''}
--<form method="post" action="/machines/${esc(machine.id)}/shares/${esc(x.id)}/revoke" class="inline-form"><button type="submit" class="button quiet">Stop this link</button></form></li>`).join('');
-- return `<section class="machine-shares" aria-labelledby="shares-h"><h2 id="shares-h">Share this screen</h2>
--<p class="muted">Make a link for a group: put it in a workspace room on OpenVibe.Chat (the staff room's Screen) or anywhere people work together. <strong>Watch</strong> shows the screen live. <strong>Watch and use</strong> also lets them start it and use the mouse and keyboard, at the level you set on this computer: at <code>ask</code> each action waits for your approval there, and at <code>observe</code> nothing happens. Nobody can run programs or read files through a link.</p>
--${madeLink ? `<p class="notice" role="status">Your new link${madeShare && madeShare.control ? ' (watch and use)' : ''}. Copy it now: it is not shown again.<br><input class="share-link" type="text" readonly value="${esc(madeLink)}" aria-label="The new screen link"></p>` : ''}
--<form method="post" action="/machines/${esc(machine.id)}/shares" class="share-form">
--<label class="field"><span class="field-label">Where it goes</span><input name="label" maxlength="80" placeholder="Staff room"></label>
--<label class="field"><span class="field-label">Lasts (days)</span><input name="days" type="number" min="1" max="365" value="30"></label>
--${which}
--<label class="check"><input type="checkbox" name="control" value="1"> They may use the mouse and keyboard too</label>
--<button type="submit" class="primary">Make a link</button></form>
--${rows ? `<ul class="share-list">${rows}</ul>` : ''}</section>`;
--}
--
/** A screen link that does not work (stopped, ended, never existed): one answer for every reason. */
function renderShareGone() {
return page('Screen link', `<main class="page share-page">${kit.pageHead({ title: `This screen link does not work`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `It was stopped, it ended, or it never existed. Ask whoever shared it for a new one.` })}</main>`, { description: 'A shared computer screen on OpenVibe.Bot.' });
@@@ -1032,92 +848,6 @@ ${control ? `<div class="share-controls">
return page(`${name}: shared screen`, body, { scripts: ['/panel/panel.js', ...(control ? ['/panel/screen-share.js'] : [])], bodyClass: 'share-body', description: 'A shared computer screen on OpenVibe.Bot.' });
}
--/**
-- * /machines/:id/desks: the desks running on the owner's machine (Desks plan), each a computer of its own for an agent:
-- * its kind, size and start, Watch and take over (the screen page on that desk), and Stop.
-- */
--function renderMachineDesksPage({ machine, desks = [], maxDesks = null, error = null, stopped = null, live = null }) {
-- const name = machine.name || machine.id;
-- const id = esc(machine.id);
-- const KIND = { browser: 'Browser desk', desktop: 'Whole desk' };
-- const since = (d) => {
-- const t = Number(d.since);
-- if (!Number.isFinite(t) || t <= 0) return '';
-- const iso = new Date(t * 1000).toISOString();
-- return ` <span class="muted">since <time datetime="${esc(iso)}" data-local-time>${esc(iso.slice(11, 16))} UTC</time></span>`;
-- };
-- const rows = desks.map((d) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span><code>${esc(d.desk)}</code></span></span>`
-- + `<span class="robot-meta"><span class="pill">${esc(KIND[d.kind] || 'Desk')}</span>${Number.isInteger(d.width) ? ` <span class="muted">${d.width}×${d.height}</span>` : ''}${since(d)}</span>`
-- + `<span class="robot-actions"><a class="button" href="/machines/${id}/screen?desk=${encodeURIComponent(d.desk)}">Watch and take over</a>`
-- + `<form method="post" action="/machines/${id}/desks/stop"><input type="hidden" name="desk" value="${esc(d.desk)}"><button type="submit" class="button quiet">Stop</button></form></span>`
-- // On the owner's OpenVibe Live channel, when they choose (the Desks plan's D8): a second step says what goes public.
-- + `<details class="desk-live"><summary class="button quiet">Show live on my channel</summary>`
-- + `<form method="post" action="/machines/${id}/desks/live"><input type="hidden" name="desk" value="${esc(d.desk)}">`
-- + `<p class="muted">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. Your followers are told you are live, as for any stream. Stop showing it on its screen page, or stop the desk.</p>`
-- + `<button type="submit" class="button primary">Go live with this desk</button></form></details></li>`).join('');
-- const list = error ? `<p class="error" role="alert">${esc(error)}</p>`
-- : rows ? `<ul class="robots">${rows}</ul>`
-- : `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No desks are running.</p><p>An agent's task starts a desk of its own here when it needs one, and stops it when it is done.</p></div>`;
-- const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}
--<main class="page">
--${kit.pageHead({ title: `${name}: desks`, icon: 'gear', back: { href: '/', label: 'Home' } , lede: `Each desk is a computer of its own on this machine, where one of your agents works: its own screen or browser, files and sign-ins. Watch one live and take it over, or stop it; a task whose desk you stop goes on without it.` })}
--${stopped ? `<p class="setting-ok" role="status">${icon('check')}<span>Stopped <code>${esc(stopped)}</code>.</span></p>` : ''}
--${live ? `<p class="setting-ok" role="status">${icon('check')}<span><code>${esc(live.desk)}</code> is live${live.channel ? ` on <a href="${esc(live.channel)}">${esc(live.channel.replace(/^https:\/\//, ''))}</a>` : ' on your OpenVibe Live channel'} for up to 30 minutes. <a href="/machines/${id}/screen?desk=${encodeURIComponent(live.desk)}">Its screen page</a> stops it.</span></p>` : ''}
--${list}
--${Number.isInteger(maxDesks) ? `<p class="muted small">At most ${maxDesks} whole ${maxDesks === 1 ? 'desk' : 'desks'} at once here; a browser desk counts as half.</p>` : ''}
--</main>`;
-- return page(`${name}: desks`, body, { description: 'The desks your agents work on, on your machine.', canonical: '/machines' });
--}
--
--function renderMachineScreenPage({ machine, until = null, error = null, idle = false, shares = [], madeLink = null, madeShare = null, hasDesk = false, hasScreen = true, desk = 'main' }) {
-- const name = machine.name || machine.id;
-- const stateUrl = `/machines/${encodeURIComponent(machine.id)}/screen.json`;
-- const base = `/machines/${encodeURIComponent(machine.id)}/screen`;
-- // An agent task's own desk (desk-<name>, Desks plan D1) is what a task's Watch link opens.
-- const agentDesk = /^desk-/.test(desk);
-- const deskWords = desk === 'main' ? 'your own screen' : desk === 'private' ? 'the private desk' : 'your agent\'s desk';
-- // Idle (nothing is shown, as from a link): a start button instead of waiting for a stream nobody started.
-- if (idle && !error) {
-- const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}
--<main class="page">
--${kit.pageHead({ title: `${name}: screen`, icon: 'gear', back: { href: '/', label: 'Home' } })}
--${agentDesk ? `<p>Your agent works on a desk of its own on this computer. Watch it live, and take it over whenever you like: while you use the mouse and keyboard, the agent waits; when you stop, it goes on.</p>
--<form method="post" action="/machines/${esc(machine.id)}/screen" class="inline-form"><input type="hidden" name="desk" value="${esc(desk)}"><button type="submit" class="primary">Watch your agent's desk</button></form>` : ''}
--${hasScreen ? `<p class="muted">Show this computer's screen here, live, to you only, for 10 minutes. Nothing is recorded, and the computer says on its desktop that its screen is live.</p>
--<form method="post" action="/machines/${esc(machine.id)}/screen" class="inline-form"><button type="submit" class="${agentDesk ? 'button' : 'primary'}">Show my screen</button></form>` : '<p class="muted">This computer has no screen of its own to show (a desk host): only its desks, where your agents work. A task\'s Watch link opens its desk.</p>'}
--${hasDesk ? `<form method="post" action="/machines/${esc(machine.id)}/screen" class="inline-form"><input type="hidden" name="desk" value="private"><button type="submit" class="button">Show the private desk</button></form>
--<p class="muted">The private desk is a screen of its own on this computer, where your agent works without touching yours.</p>` : ''}
--${sharesBlock(machine, shares, madeLink, madeShare, hasDesk, hasScreen)}
--</main>`;
-- return page(`${name}: screen`, body, { description: 'Your machine\'s screen, live, for you only.', canonical: '/machines' });
-- }
-- const ends = until ? `<p class="muted">Shown until <time datetime="${esc(until)}" data-local-time>${esc(until)}</time>, to you only. Nothing is recorded.</p>` : '';
-- // Take over (Desks plan D4): the share page's own controls (public/screen-share.js) in owner mode, on what is shown.
-- const takeOver = error ? '' : `<div class="share-controls">
--<button type="button" class="primary" data-share-start hidden>Start the screen</button>
--<label class="check share-use"><input type="checkbox" data-share-use> ${desk === 'main' ? 'Use the mouse and keyboard' : 'Take over: use the mouse and keyboard'}</label>
--<span class="muted small" data-share-note role="status"></span></div>
--${desk !== 'main' ? '<p class="muted small">While you use it, the agent waits on this desk; uncheck to give it back, and it goes on a moment later.</p>' : ''}`;
-- const body = `${topbar(`<a class="crumb" href="/machines">Machines</a><span class="crumb">${esc(name)}</span>`)}
--<main class="page" data-share="${esc(base)}" data-control="${error ? '0' : '1'}" data-owner="1">
--<p class="muted small">Showing ${esc(deskWords)}${agentDesk ? ` (<code>${esc(desk)}</code>)` : ''}.</p>
--${kit.pageHead({ title: `${name}: screen`, icon: 'gear', back: { href: '/', label: 'Home' } })}
--${error ? `<p class="error" role="alert">${esc(error)}</p>` : ends}
--<figure class="camera machine-screen" data-screen-state="${esc(stateUrl)}" tabindex="0" aria-label="The screen: turn on the mouse and keyboard to click and type on it">
--<div class="camera-screen" data-camera-screen>${icon('plug', 'icon camera-icon')}<p class="camera-title" data-screen-status role="status">${error ? 'The screen is not shown.' : 'Waiting for the machine…'}</p>
--<p class="camera-sub">The picture comes from the machine itself, a few frames a second.</p></div>
--</figure>
--${takeOver}
--<div class="machine-screen-actions">
--<form method="post" action="/machines/${esc(machine.id)}/screen/stop"><button type="submit" class="primary">Stop showing it</button></form>
--<form method="post" action="/machines/${esc(machine.id)}/screen">${desk !== 'main' ? `<input type="hidden" name="desk" value="${esc(desk)}">` : ''}<button type="submit" class="button quiet">${error ? 'Try again' : 'Start again (10 more minutes)'}</button></form>
--</div>
--<p class="muted small">${desk === 'main' ? 'The machine says on its desktop that its screen is live. ' : 'Nothing of your own screen is shown: this is the desk\'s own. '}<code>openvibe-link stop</code> on the machine ends it at once, and it ends on its own after its time.</p>
--${sharesBlock(machine, shares, madeLink, madeShare, hasDesk, hasScreen)}
--</main>`;
-- return page(`${name}: screen`, body, { scripts: ['/panel/panel.js', '/panel/screen-share.js'], description: 'Your machine\'s screen, live, for you only.', canonical: '/machines' });
--}
--
/**
* /device/phone: the page that makes a phone the robot's device (public/phone-device.js). Its setup form pairs it (the
* QR code fills in the robot and code); once running, the screen is the robot's face, with the link and camera state
@@@ -1215,4 +945,4 @@ function renderFirstRunPage({ rows = [] }) {
return page('First run', body, { description: 'The guided first run of OpenVibe Link, measured step by step.', canonical: '/admin/first-run' });
}
--module.exports = { page, renderPanel, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderPhoneLinkPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, camerasOf, esc };
++module.exports = { page, renderPanel, renderPhonePage, renderSharePage, renderShareGone, panelScripts, isSim, TRY_DESCRIPTION, renderEmbedRefused, renderDrivePage, renderWidget, renderRobotsPage, renderPairingPage, renderPhoneLinkPage, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, camerasOf, esc };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 99bd449..c66368c 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -32,7 +32,6 @@
* Pages are never cached: a pairing page carries a live code. Adding a robot and minting a code count against
* the signed-in person's `bot.robot.manage` limit, the same one /api/v1 applies.
*/
--const { validCallWord } = require('../voice');
const path = require('path');
const express = require('express');
const seo = require('openvibe-shared/seo');
@@@ -41,7 +40,6 @@ const appIcon = require('openvibe-shared/app-icon');
const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { BotError, json } = require('../util');
--const { aiOf } = require('../machine-ai');
const kits = require('../kits');
// The Android app's published download: an https URL on GitHub releases or openvibe.link, or nothing (the app is in
// testing). Read on each request, so setting it in the env file and restarting is all a release needs.
@@@ -49,7 +47,7 @@ function linkApkUrl() {
const v = String(process.env.LINK_ANDROID_APK_URL || '').trim();
return /^https:\/\/(github\.com\/OpenVibers\/OpenVibe\.Link\/releases\/|openvibe\.link\/)[^\s"'<>]+$/.test(v) ? v : null;
}
--const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderPhoneLinkPage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');
++const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderPhoneLinkPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');
const { createMachineScreen } = require('../machine-screen');
const { createScreenShares } = require('../screen-shares');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
@@@ -166,6 +164,13 @@ const embedCsp = (config) => ["default-src 'self'", `script-src 'self' ${PANEL_S
function createWebRoutes(config, { domain = null, sim = null, onvif = null, limits = null, identity = null, hub = null, log = console, submissions = null, routines = null, webhooks = null, claims = null, setupKeys = null, releaseLastmod } = {}) {
const r = express.Router();
++ // The machines hub belongs to OpenVibe Link (owner, 11-09): every /machines path answers a 301 to Link's hub,
++ // signed in or not, so nobody sees a sign-in bounce on the way. There is no page behind this path here any more.
++ const LINK_MACHINES = 'https://openvibe.link/machines';
++ r.use('/machines', (req, res) => {
++ res.setHeader('Cache-Control', 'public, max-age=3600');
++ res.redirect(301, LINK_MACHINES);
++ });
const networkReads = createNetworkReads({ searchUrl: config.search && config.search.url, log });
// The public front page is crawlable; sign-in, the API and every signed-in or per-robot page are not.
r.get('/robots.txt', (req, res) => res.type('text/plain').send(['User-agent: *', 'Allow: /$', 'Disallow: /auth/', 'Disallow: /api/',
@@@ -608,182 +613,13 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const purpose = String(req.headers['sec-purpose'] || req.headers.purpose || req.headers['x-moz'] || '');
return (site && site !== 'same-origin' && site !== 'none') || /prefetch|prerender/i.test(purpose);
};
-- // ── The person's own machines that run their jobs (plan T14/T16) ──
-- const machineRows = async (subject) => Promise.all((await domain.machines.list(subject)).map(async (d) => {
-- const robotId = Array.isArray(d.robot_ids) ? d.robot_ids[0] : null;
-- const robot = robotId ? await domain.robots.get(robotId) : null;
-- const sensors = hub ? ((hub.deviceState(d.id).telemetry || {}).sensors || {}) : {};
-- // Read online first: the first-run query must not make a machine look offline because the page took longer.
-- const online = !!(hub && hub.isOnline(d.id));
-- // What the guided first run's one job found on this machine (Link's link.first_run first_job event).
-- const firstRun = await domain.devices.firstRunOf(d.id);
-- return {
-- ...d, online, robot: robot ? robot.name : null,
-- first_run: firstRun,
-- computer_level: typeof sensors.computer_level === 'string' ? sensors.computer_level : null,
-- computer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
-- computer_screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',
-- computer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
-- desk_leases: d.shared_desks ? await domain.deskLeases.count(d.id) : 0,
-- ai_models: !!d.ai_models,
-- // The Link ai plugin's community offer, when it has one (models, hours, price, audience, paused, privacy).
-- ai_offer: (() => { const ai = aiOf(d.capabilities); return ai && ai.offer && typeof ai.offer === 'object' ? ai.offer : null; })(),
-- // How fast its last model answered there (Link v0.29.0's ai plugin), and whether one is answering now.
-- ai_speed: typeof sensors.ai_tokens_per_s === 'number' && sensors.ai_tokens_per_s > 0 && typeof sensors.ai_model === 'string'
-- ? { model: sensors.ai_model.slice(0, 120), tokens_per_s: sensors.ai_tokens_per_s } : null,
-- ai_busy: sensors.ai_busy === 1,
-- };
-- }));
-- // ── The live screen (plan T14): the owner watches their machine's screen on their own private stream ──
++ // ── Screen share links (server/screen-shares.js): the owner's agent makes one (POST /api/v1/machines/:id/watch-links,
++ // server/api/v1.js) and the link shows the desk live in a chat turn, on this site or in a frame in OpenVibe's workspaces. ──
const machineScreen = createMachineScreen({ domain, hub });
-- const myMachine = async (req, me) => {
-- const mine = (await domain.machines.list(me.subject)).find((d) => d.id === req.params.id);
-- if (!mine) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');
-- return mine;
-- };
-- const asUser = (me) => ({ kind: 'user', sub: me.subject });
-- // The machine has a private desk to show (Link v0.15.0 on Linux): its owner may show it, or share it, instead.
-- // The private desk is a whole desk: a machine that runs browser desks only (a Mac, Link v0.23.0) has none to show.
-- const hasDesk = (mine) => {
-- const d = hub && (hub.computerOf(mine.id) || {}).desk;
-- return !!d && (!Array.isArray(d.kinds) || d.kinds.includes('desktop'));
-- };
-- // A screen of its own to show live: a desk host (install.sh --desk-host) has none, only its desks.
-- const hasScreen = (mine) => {
-- const c = hub && hub.computerOf(mine.id);
-- return !!(c && c.screen && c.screen.stream === true);
-- };
-- r.post('/machines/:id/screen', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
-- const me = requireUser(req);
-- if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
-- const mine = await myMachine(req, me);
-- try {
-- // Any desk: the person's own screen (main), the private desk, or an agent task's own desk (desk-<name>).
-- const desk = req.body && machineScreen.DESK.test(String(req.body.desk || '')) ? String(req.body.desk) : 'main';
-- const out = await machineScreen.start(mine, me.subject, asUser(me), { desk });
-- res.redirect(303, `/machines/${encodeURIComponent(mine.id)}/screen?until=${encodeURIComponent(out.until)}${desk !== 'main' ? `&desk=${encodeURIComponent(desk)}` : ''}`);
-- } catch (e) {
-- if (!(e instanceof BotError)) throw e;
-- res.setHeader('Content-Security-Policy', PANEL_CSP);
-- res.status(e.status).type('html').send(renderMachineScreenPage({ machine: mine, error: e.detail || e.message, hasDesk: hasDesk(mine), hasScreen: hasScreen(mine) }));
-- }
-- }));
-- r.get('/machines/:id/screen', page(async (req, res) => {
-- const me = requireUser(req);
-- const mine = await myMachine(req, me);
-- const until = typeof req.query.until === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d(\.\d{1,3})?Z$/.test(req.query.until) ? req.query.until : null;
-- // A link from an agent's task names its desk (Desks plan D4: Watch, Take over): offer that one first.
-- const desk = typeof req.query.desk === 'string' && machineScreen.DESK.test(req.query.desk) ? req.query.desk : 'main';
-- // Opened from a link with nothing shown: offer to start it rather than wait for a stream nobody started.
-- const st = await machineScreen.state(mine, me.subject);
-- const idle = !until && st.state !== 'live';
-- res.setHeader('Vary', 'Cookie');
-- res.setHeader('Content-Security-Policy', PANEL_CSP);
-- res.type('html').send(renderMachineScreenPage({ machine: mine, until, idle, shares: await shares.list(mine, me.subject).catch(() => []), hasDesk: hasDesk(mine), hasScreen: hasScreen(mine), desk: idle ? desk : st.desk }));
-- }));
-- // The owner's page drives what it shows (public/screen-share.js, owner mode): its state, start, stop and the mouse
-- // and keyboard ("Take over"), as JSON, same-site only.
-- r.get('/machines/:id/screen/state.json', page(async (req, res) => {
-- const me = requireUser(req);
-- const mine = await myMachine(req, me);
-- res.setHeader('Cache-Control', 'private, no-store');
-- res.json(await machineScreen.state(mine, me.subject));
-- }));
-- r.post('/machines/:id/screen/start', ...manage, express.json({ limit: '1kb' }), page(async (req, res) => {
-- const me = requireUser(req);
-- if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site requests are refused');
-- const mine = await myMachine(req, me);
-- const desk = req.body && machineScreen.DESK.test(String(req.body.desk || '')) ? String(req.body.desk) : 'main';
-- res.json(await machineScreen.start(mine, me.subject, asUser(me), { desk }));
-- }));
-- r.post('/machines/:id/screen/input', ...manage, express.json({ limit: '2kb' }), page(async (req, res) => {
-- const me = requireUser(req);
-- if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site requests are refused');
-- const mine = await myMachine(req, me);
-- res.json(await machineScreen.input(mine, me.subject, req.body || {}));
-- }));
-- // The desks running on the machine (Desks plan): asked of the machine when the owner opens the page, never on a
-- // schedule; each can be watched, taken over or stopped.
-- r.get('/machines/:id/desks', page(async (req, res) => {
-- const me = requireUser(req);
-- const mine = await myMachine(req, me);
-- res.setHeader('Vary', 'Cookie');
-- res.setHeader('Cache-Control', 'private, no-store');
-- let desks = [];
-- let maxDesks = null;
-- let error = null;
-- if (!hub || !hub.isOnline(mine.id)) error = 'The machine is offline: its desks stopped with it, or show here when it is back.';
-- else if (!(hub.computerOf(mine.id) || {}).desk) error = 'This machine runs no desks: it needs OpenVibe Link with the computer plugin (Linux with Xvfb, or Chromium for browser desks).';
-- else {
-- const r = await hub.machineCommand(mine.id, asUser(me), me.subject, { kind: 'desk', value: { action: 'list' }, ms: 8000 });
-- if (r && r.result === 'ack') {
-- const d = r.data || {};
-- desks = (Array.isArray(d.desks) ? d.desks : []).filter((x) => x && machineScreen.DESK.test(String(x.desk || '')) && x.desk !== 'main').slice(0, 50);
-- maxDesks = Number.isInteger(d.max_desks) ? d.max_desks : null;
-- } else error = `The machine did not list its desks: ${(r && (r.reason || r.result)) || 'no answer'}`.slice(0, 300);
-- }
-- const stopped = typeof req.query.stopped === 'string' && machineScreen.DESK.test(req.query.stopped) ? req.query.stopped : null;
-- const liveDesk = typeof req.query.live === 'string' && machineScreen.DESK.test(req.query.live) && req.query.live !== 'main' ? req.query.live : null;
-- const channel = typeof req.query.channel === 'string' && /^https:\/\/openvibe\.live\/@[A-Za-z0-9_.-]{1,40}$/.test(req.query.channel) ? req.query.channel : null;
-- res.type('html').send(renderMachineDesksPage({ machine: mine, desks, maxDesks, error, stopped, live: liveDesk ? { desk: liveDesk, channel } : null }));
-- }));
-- // A desk on the owner's OpenVibe Live channel for up to 30 minutes (the Desks plan's D8), from the desks page's
-- // "Show live on my channel" and its confirmation.
-- r.post('/machines/:id/desks/live', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
-- const me = requireUser(req);
-- if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
-- const mine = await myMachine(req, me);
-- const desk = req.body && typeof req.body.desk === 'string' ? req.body.desk : '';
-- if (!machineScreen.DESK.test(desk) || desk === 'main') throw new BotError(422, 'bot.invalid_input', 'desk is private or desk-<name>');
-- const out = await machineScreen.start(mine, me.subject, asUser(me), { desk, live: true, seconds: machineScreen.limits.SECONDS.max });
-- const channel = out.live && /^https:\/\/openvibe\.live\/@[A-Za-z0-9_.-]{1,40}$/.test(String(out.live.channel_url || '')) ? out.live.channel_url : '';
-- res.redirect(303, `/machines/${encodeURIComponent(mine.id)}/desks?live=${encodeURIComponent(desk)}${channel ? `&channel=${encodeURIComponent(channel)}` : ''}`);
-- }));
-- r.post('/machines/:id/desks/stop', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
-- const me = requireUser(req);
-- if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
-- const mine = await myMachine(req, me);
-- const desk = req.body && typeof req.body.desk === 'string' ? req.body.desk : '';
-- if (!machineScreen.DESK.test(desk) || desk === 'main') throw new BotError(422, 'bot.invalid_input', 'desk is private or desk-<name>');
-- if (!hub || !hub.isOnline(mine.id)) throw new BotError(409, 'bot.device_offline', 'the machine is offline');
-- const r = await hub.machineCommand(mine.id, asUser(me), me.subject, { kind: 'desk', value: { action: 'stop', desk }, ms: 15000 });
-- if (!r || r.result !== 'ack') throw new BotError(409, 'bot.device_refused', `the machine did not stop it: ${(r && (r.reason || r.result)) || 'no answer'}`.slice(0, 300));
-- res.redirect(303, `/machines/${encodeURIComponent(mine.id)}/desks?stopped=${encodeURIComponent(desk)}`);
-- }));
-- r.get('/machines/:id/screen.json', page(async (req, res) => {
-- const me = requireUser(req);
-- const mine = await myMachine(req, me);
-- res.setHeader('Cache-Control', 'private, no-store');
-- res.json(await machineScreen.state(mine, me.subject));
-- }));
-- r.post('/machines/:id/screen/stop', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), express.json({ limit: '1kb' }), page(async (req, res) => {
-- const me = requireUser(req);
-- if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
-- const mine = await myMachine(req, me);
-- const out = await machineScreen.stop(mine, me.subject, asUser(me));
-- if (req.is('application/json')) return res.json(out); // the page's own Stop (owner mode)
-- res.redirect(303, '/machines');
-- }));
-- // ── Screen share links (server/screen-shares.js): the owner shares the live screen with a group, to watch or use ──
const shares = createScreenShares({ db: domain && domain.db, domain, hub, machineScreen });
// A share page may sit in OpenVibe's own workspaces (a room on OpenVibe.Chat, the admin panel) and the configured embeds.
const shareCsp = ["default-src 'self'", `script-src 'self' ${PANEL_SCRIPT_SRC}`, `connect-src 'self' ${PANEL_CONNECT_SRC}`,
`frame-ancestors ${frameAncestors(config)} https://openvibe.chat https://openvibe.network https://openvibe.actor`, "object-src 'none'", "base-uri 'self'"].join('; ');
-- r.post('/machines/:id/shares', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
-- const me = requireUser(req);
-- if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
-- const mine = await myMachine(req, me);
-- const made = await shares.create(mine, me.subject, { label: req.body && req.body.label, control: req.body && req.body.control === '1', days: (req.body && req.body.days) || 30, desk: req.body && req.body.desk });
-- res.setHeader('Content-Security-Policy', PANEL_CSP);
-- res.type('html').send(renderMachineScreenPage({ machine: mine, idle: true, shares: await shares.list(mine, me.subject), madeLink: `${config.baseUrl}/screen/${made.token}`, madeShare: made.share, hasDesk: hasDesk(mine), hasScreen: hasScreen(mine) }));
-- }));
-- r.post('/machines/:id/shares/:share/revoke', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
-- const me = requireUser(req);
-- if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
-- const mine = await myMachine(req, me);
-- await shares.revoke(mine, me.subject, req.params.share);
-- res.redirect(303, `/machines/${encodeURIComponent(mine.id)}/screen`);
-- }));
const shareJson = (fn) => async (req, res) => {
res.setHeader('Cache-Control', 'no-store');
try { res.json(await fn(req)); } catch (e) {
@@@ -806,16 +642,11 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
r.post('/screen/:token/stop', ...shareLimit, shareJson(async (req) => await shares.stop(req.params.token)));
r.post('/screen/:token/input', ...shareLimit, express.json({ limit: '2kb' }), shareJson(async (req) => await shares.input(req.params.token, req.body || {})));
-- r.get('/machines', frameCsp, page(async (req, res) => {
-- const me = requireUser(req);
-- res.setHeader('Vary', 'Cookie');
-- res.type('html').send(renderMachinesPage({ machines: await machineRows(me.subject), phoneReady: !!linkApkUrl() }));
-- }));
// Link on your phone (plan 2026-11-07 §13.2): the code the Android app pairs with, and the QR it can scan. A code
-- // is minted on the visit, never for a cross-site navigation or a prefetch (the same rule as /pair/:id).
++ // is minted on the visit, never for a cross-site navigation or a prefetch. The machines hub it belongs to is Link's.
r.get('/link/phone', ...manage, page(async (req, res) => {
const me = requireUser(req);
-- if (crossSiteOrPrefetch(req)) return res.redirect(303, '/machines');
++ if (crossSiteOrPrefetch(req)) return res.redirect(303, LINK_MACHINES);
res.setHeader('Vary', 'Cookie');
// Until the Android app has a published download (LINK_ANDROID_APK_URL, the signed release), the page says it is
// in testing and mints no code: a QR for an app nobody can install is a dead end (Opus, 11-07).
@@@ -823,78 +654,6 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const pairing = apkUrl ? await domain.phone.mint(me.subject) : null;
res.type('html').send(renderPhoneLinkPage({ pairing, apkUrl }));
}));
-- r.post('/machines', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
-- const me = requireUser(req);
-- if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
-- const pairing = await domain.machines.mint(me.subject);
-- res.status(201).type('html').send(renderMachinesPage({ machines: await machineRows(me.subject), pairing, phoneReady: !!linkApkUrl() }));
-- }));
-- // The owner lets their watches (OpenVibe.Watch's node source) probe from this machine, or stops them.
-- r.post('/machines/:id/watch-probe', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
-- const me = requireUser(req);
-- if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
-- const mine = (await domain.machines.list(me.subject)).find((d) => d.id === req.params.id);
-- if (!mine) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');
-- await domain.machines.setWatchProbe(mine.id, req.body && req.body.on === '1');
-- res.redirect(303, '/machines');
-- }));
-- // The owner lets their agent (OpenVibe.Actor) use this machine's computer plugin, or stops it.
-- // The owner lets their AI run models on this machine, or stops it (AI on your own machines, contracts 0.219.0).
-- r.post('/machines/:id/ai-models', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
-- const me = requireUser(req);
-- if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
-- const mine = await myMachine(req, me);
-- await domain.machines.setAiModels(mine.id, req.body && req.body.on === '1');
-- res.redirect(303, '/machines');
-- }));
-- r.post('/machines/:id/agent-computer', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
-- const me = requireUser(req);
-- if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
-- const mine = (await domain.machines.list(me.subject)).find((d) => d.id === req.params.id);
-- if (!mine) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');
-- await domain.machines.setAgentComputer(mine.id, req.body && req.body.on === '1');
-- res.redirect(303, '/machines');
-- }));
-- // The owner offers this desk host's desks to other people's sessions (OpenVibe.Run, contracts 0.223.0), or stops: only
-- // where its Link sandboxes every desk (Link v0.32.0, install.sh --desk-host --shared).
-- r.post('/machines/:id/shared-desks', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
-- const me = requireUser(req);
-- if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
-- const mine = (await domain.machines.list(me.subject)).find((d) => d.id === req.params.id);
-- if (!mine) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');
-- const on = req.body && req.body.on === '1';
-- const desk = ((hub && hub.computerOf(mine.id)) || {}).desk;
-- if (on && !(desk && desk.isolation === 'sandbox')) {
-- throw new BotError(409, 'bot.computer_off', 'this machine does not sandbox its desks: install it with --desk-host --shared (OpenVibe Link v0.32.0 or newer), then try again');
-- }
-- await domain.machines.setSharedDesks(mine.id, on);
-- res.redirect(303, '/machines');
-- }));
-- // The owner lets Vibe answer their voice on this machine (OpenVibe Link's voice plugin), with a call word, or stops it.
-- r.post('/machines/:id/voice', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
-- const me = requireUser(req);
-- if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
-- const mine = (await domain.machines.list(me.subject)).find((d) => d.id === req.params.id);
-- if (!mine) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');
-- const on = req.body && req.body.on === '1';
-- const word = req.body && req.body.call_word ? validCallWord(req.body.call_word) : null;
-- if (on && req.body && req.body.call_word && !word) throw new BotError(422, 'bot.invalid_input', 'a call word is 1 to 4 words, 3 to 40 characters, with one word of three letters or more');
-- const row = await domain.machines.setVoice(mine.id, on, word);
-- if (hub && hub.machineChanged) await hub.machineChanged(row);
-- res.redirect(303, '/machines');
-- }));
-- r.post('/machines/:id/remove', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
-- const me = requireUser(req);
-- if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
-- const mine = (await domain.machines.list(me.subject)).find((d) => d.id === req.params.id);
-- if (!mine) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');
-- await domain.devices.revoke(mine.id);
-- await domain.machines.screen.archive(mine, me.subject).catch((e) => log.warn(`[Bot] machine ${mine.id} screen stream: ${e.message}`));
-- if (mine.node_principal) await domain.devices.revokeNode(mine.node_principal).catch((e) => log.warn(`[Bot] machine ${mine.id} on Network: ${e.message}`));
-- if (hub) hub.closeDevice(mine.id, 'revoked');
-- res.redirect(303, '/machines');
-- }));
--
r.get('/pair/:id', ...manage, page(async (req, res) => {
const me = requireUser(req);
if (crossSiteOrPrefetch(req)) return res.redirect(303, '/robots');
ddiff --git a/test/community-ai.test.js b/test/community-ai.test.js
iindex da63f2e..7652a02 100644
---- a/test/community-ai.test.js
++++ b/test/community-ai.test.js
@@@ -57,9 +57,8 @@ const { boot, check, done } = require('./helpers/app');
await t.app.locals.offers.publish();
assert.ok(aiReports.at(-1).some((o) => o.offer_id === `ai:${mine.deviceId}`), 'the AI offers are reported to Network');
assert.ok(reports.at(-1).some((o) => o.offer_id === `link:${mine.deviceId}`) === false, 'the worker report is separate');
-- const page = await (await fetch(`${t.base}/machines`, { headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } })).text();
-- assert.ok(page.includes('A community machine sees the prompt'), 'the machine page says the privacy cost plainly');
-- assert.ok(page.includes('Offering its models to the network'));
++ const page = await fetch(`${t.base}/machines`, { redirect: 'manual' });
++ assert.deepStrictEqual([page.status, page.headers.get('location')], [301, 'https://openvibe.link/machines'], 'the machines hub is Link\'s');
});
await check('AI sends a community run to the offered machine, even with the own-AI switch off', async () => {
ddiff --git a/test/desk-leases.test.js b/test/desk-leases.test.js
iindex 01bbe6b..ed6e4bb 100644
---- a/test/desk-leases.test.js
++++ b/test/desk-leases.test.js
@@@ -134,27 +134,6 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(!offers.some((o) => o.offer_id === `desk:${own.deviceId}`), 'a person\'s own machine is not offered to others');
});
-- await check('the machines page: a sandboxed host offers its desks with one button; another cannot', async () => {
-- const cookie = { Cookie: `ov_token=${t.network.signUser(alex)}` };
-- const form = (id, on) => fetch(`${t.base}/machines/${id}/shared-desks`, { method: 'POST', redirect: 'manual', body: `on=${on}`, headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: t.base, ...cookie } });
-- let html = '';
-- let tries = 0;
-- while (tries < 20 && !html.includes('Serving other people')) {
-- if (tries) await t.wait(100);
-- html = await (await fetch(`${t.base}/machines`, { headers: cookie })).text();
-- tries += 1;
-- }
-- assert.ok(html.includes('Serving other people') && html.includes('desks in use'), 'the host says it serves other people, and how many desks are in use');
-- assert.ok(html.includes('Stop offering my desks'));
-- assert.strictEqual((await form(host.deviceId, 0)).status, 303);
-- assert.strictEqual((await t.db.maybe('SELECT shared_desks FROM devices WHERE id = $1', [host.deviceId])).shared_desks, false);
-- assert.strictEqual((await form(host.deviceId, 1)).status, 303);
-- assert.strictEqual((await t.db.maybe('SELECT shared_desks FROM devices WHERE id = $1', [host.deviceId])).shared_desks, true);
-- const carolsCookie = { Cookie: `ov_token=${t.network.signUser(carol)}` };
-- const notSandboxed = await fetch(`${t.base}/machines/${plain.deviceId}/shared-desks`, { method: 'POST', redirect: 'manual', body: 'on=1', headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: t.base, ...carolsCookie } });
-- assert.strictEqual(notSandboxed.status, 409, 'a host that does not sandbox its desks is never offered');
-- });
--
await check('the owner stops offering: no new lease there', async () => {
await t.domain.machines.setSharedDesks(host.deviceId, false);
const r = await t.call('POST', `/api/v1/machines/${host.deviceId}/desk-leases`, { ...asRun(bob.subject), body: { session: SES(7), desk: 'desk-s07', kind: 'browser' } });
ddiff --git a/test/first-run.test.js b/test/first-run.test.js
iindex d858ed0..79833dc 100644
---- a/test/first-run.test.js
++++ b/test/first-run.test.js
@@@ -77,11 +77,9 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(rows.length, 4, 'ignored events added no rows and the resend added none');
});
-- await check('the owner’s machines page shows what the first job found', async () => {
-- const page = await (await get('/machines', alex)).text();
-- assert.ok(page.includes('First run'), page.slice(0, 500));
-- assert.ok(page.includes('8 CPUs') && page.includes('free disk') && page.includes('linux/amd64'), 'the machine figures are shown');
-- assert.ok(!page.includes('/home/alice'));
++ await check('the machines hub moved to Link: /machines answers 301', async () => {
++ const page = await get('/machines', alex);
++ assert.deepStrictEqual([page.status, page.headers.get('location')], [301, 'https://openvibe.link/machines']);
});
await check('only a reviewer reads the funnel, and it counts machines and the time between steps', async () => {
ddiff --git a/test/home.test.js b/test/home.test.js
iindex 76ff226..c09b354 100644
---- a/test/home.test.js
++++ b/test/home.test.js
@@@ -54,7 +54,7 @@ const { boot, check, done } = require('./helpers/app');
// Your own computers: desks for your agents, desk hosts, your own models, a desk live (the Desks plan, AI on your
// own machines), with the questions people ask about them.
assert.match(html, /<details class="ovk-fold" id="computers"><summary><span>Your computers, for your agents and your AI/);
-- for (const card of ['A desk for each task', 'A spare computer as a desk host', 'Your own AI models', 'A desk live on your channel']) assert.ok(html.includes(`<strong>${card}</strong>`), card);
++ for (const card of ['A desk for each task', 'Your own AI models', 'A desk live on your channel']) assert.ok(html.includes(`<strong>${card}</strong>`), card);
assert.ok(questions.includes('Can my agents use my computer?') && questions.includes('Can my AI use the models on my computer?'));
const home = await get('/home.css');
assert.strictEqual(home.status, 200);
ddiff --git a/test/machine-computer.test.js b/test/machine-computer.test.js
iindex ce91752..5b8d023 100644
---- a/test/machine-computer.test.js
++++ b/test/machine-computer.test.js
@@@ -325,80 +325,23 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await cmd(mine.deviceId, { kind: 'screen', value: {} }, asActor(token, alex.subject))).status, 403, 'off again');
});
-- await check('the machines page shows the level and the agent switch; only the owner flips it, same-site only', async () => {
-- const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
-- const form = (p, user, body, headers = {}) => fetch(t.base + p, { method: 'POST', redirect: 'manual', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(user), ...headers } });
-- const html = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
-- assert.match(html, /Computer: Trusted/);
-- assert.match(html, /terminal, files, screen, mouse and keyboard/);
-- assert.match(html, /Let my agent use this computer/);
-- assert.strictEqual((await form(`/machines/${mine.deviceId}/agent-computer`, alex, 'on=1', { Origin: 'https://evil.test' })).status, 403);
-- assert.strictEqual((await form(`/machines/${mine.deviceId}/agent-computer`, bob, 'on=1')).status, 404);
-- assert.strictEqual((await form(`/machines/${mine.deviceId}/agent-computer`, alex, 'on=1')).status, 303);
-- assert.strictEqual((await t.db.maybe('SELECT agent_computer FROM devices WHERE id = $1', [mine.deviceId])).agent_computer, true);
-- assert.match(await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text(), /Stop my agent using this computer/);
-- await form(`/machines/${mine.deviceId}/agent-computer`, alex, 'on=0');
-- assert.strictEqual((await t.db.maybe('SELECT agent_computer FROM devices WHERE id = $1', [mine.deviceId])).agent_computer, false);
-- });
--
-- await check('the machines page says what desks each machine runs, and offers to make a spare computer a desk host', async () => {
-- const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
-- const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true };
-- const host = await machine(alex, { files: COMPUTER.files, desk: { ...desk, max_desks: 4, kinds: ['desktop', 'browser'], profiles: true } });
-- const mac = await machine(alex, { ...COMPUTER, desk: { ...desk, window_manager: null, max_desks: 2, kinds: ['browser'] } });
-- // A machine's declared capabilities are saved as its status arrives: wait until both machines' are.
-- let html = '';
-- for (let i = 0; i < 20 && !(html.includes('sign-ins kept') && html.includes('browser desks at once')); i++) {
-- if (i) await t.wait(100);
-- html = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
-- }
-- assert.ok(html.includes('Desks for your agents: 4 at once, or twice as many browser desks, sign-ins kept'), 'a desk host');
-- assert.ok(html.includes('Desks for your agents: 4 browser desks at once'), 'a Mac: browser desks only');
-- const add = await fetch(`${t.base}/machines`, { method: 'POST', redirect: 'manual', body: '', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex) } });
-- assert.strictEqual(add.status, 201);
-- const page = await add.text();
-- assert.ok(page.includes('What should this computer do?') && page.includes('Make a spare computer a desk host'));
-- assert.strictEqual((page.match(/data-copy-source/g) || []).length, 4, 'every command has its own copy button');
-- const hostCmd = /<code[^>]*>(curl -fsSL [^<]+ --desk-host)<\/code>/.exec(page);
-- assert.ok(hostCmd, 'the desk host install command');
-- assert.ok(!hostCmd[1].includes('--worker'), 'a desk host never runs the job worker too');
-- assert.ok(page.includes('Use the models you run on it'), 'AI on your own machines (Link v0.28.0 --ai)');
-- const aiCmd = /<code[^>]*>(curl -fsSL [^<]+ --ai)<\/code>/.exec(page);
-- assert.ok(aiCmd && !aiCmd[1].includes('--worker'), 'the models install command');
-- host.c.close();
-- mac.c.close();
++ await check('adding a machine is one API pairing with its install command', async () => {
++ const r = await t.call('POST', '/api/v1/machines/pairings', { user: alex, body: {} });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.match(r.json.pairing.installer, /--pairing pair_/);
++ assert.match(r.json.pairing.installer, /--worker$/);
});
-- await check('a machine\'s desks page: its running desks to watch, take over or stop, for its owner only; stop is same-site only', async () => {
-- const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
++ await check('a machine reports how many desks run, for placement', async () => {
const host = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4, kinds: ['desktop', 'browser'] } });
-- // Telemetry samples are kept at most twice a second: this one comes after the machine's first.
await t.wait(600);
host.c.send(frame('telemetry', { sensors: { computer_level: 'trusted', computer_waiting: 0, computer_running: 0, computer_desks: 2 } }));
-- assert.ok(await poll(async () => (await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text()).includes('2 desks running')), 'the card says how many run');
-- const list = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
-- assert.ok(list.includes(`href="/machines/${host.deviceId}/desks"`), 'the card links its desks');
-- const api = await t.call('GET', '/api/v1/machines', { user: alex });
-- assert.strictEqual(api.json.machines.find((m) => m.id === host.deviceId).computer.desks_running, 2, 'the API says how many run, for placement');
-- const page = await fetch(`${t.base}/machines/${host.deviceId}/desks`, { headers: cookie(alex) });
-- assert.strictEqual(page.status, 200);
-- const html = await page.text();
-- assert.deepStrictEqual(seen.at(-1).value, { action: 'list' }, 'asked of the machine when the page opens');
-- assert.ok(html.includes('<code>desk-task-a</code>') && html.includes('Browser desk') && html.includes('Whole desk'));
-- assert.ok(html.includes(`href="/machines/${host.deviceId}/screen?desk=desk-task-a"`), 'Watch and take over that desk');
-- assert.ok(html.includes('name="desk" value="desk-task-a"'), 'its Stop');
-- assert.ok(html.includes(`action="/machines/${host.deviceId}/desks/live"`) && html.includes('Show live on my channel') && html.includes('signed-in pages included') && html.includes('Your followers are told you are live'), 'and, a second step away, live on the owner\'s channel (Desks plan D8)');
-- assert.ok(html.includes('At most 4 whole desks at once here'));
-- assert.strictEqual((await fetch(`${t.base}/machines/${host.deviceId}/desks`, { headers: cookie(bob) })).status, 404, 'nobody else');
-- const stop = (body, headers = {}) => fetch(`${t.base}/machines/${host.deviceId}/desks/stop`, { method: 'POST', redirect: 'manual', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex), ...headers } });
-- assert.strictEqual((await stop('desk=desk-task-a', { Origin: 'https://evil.test' })).status, 403);
-- assert.strictEqual((await stop('desk=main')).status, 422, 'never the person\'s own screen');
-- assert.strictEqual((await stop('desk=desk-../x')).status, 422);
-- const done = await stop('desk=desk-task-a');
-- assert.strictEqual(done.status, 303);
-- assert.match(done.headers.get('location'), /\/desks\?stopped=desk-task-a$/);
-- assert.deepStrictEqual(seen.at(-1).value, { action: 'stop', desk: 'desk-task-a' });
-- assert.ok((await (await fetch(`${t.base}${done.headers.get('location')}`, { headers: cookie(alex) })).text()).includes('Stopped <code>desk-task-a</code>'));
++ const api = await poll(async () => {
++ const out = await t.call('GET', '/api/v1/machines', { user: alex });
++ const m = out.json.machines.find((x) => x.id === host.deviceId);
++ return m && m.computer.desks_running === 2 ? m : null;
++ });
++ assert.ok(api, 'the API says how many run, for placement');
host.c.close();
});
@@@ -462,27 +405,7 @@ const { boot, check, done } = require('./helpers/app');
await t.wait(600);
const fast = (await t.call('GET', '/api/v1/machines', { user: alex })).json.machines.find((m) => m.id === own.deviceId);
assert.deepStrictEqual(fast.computer.ai_speed, { model: 'qwen2.5-1.5b-instruct', tokens_per_s: 14.6 }, 'its speed, in the API');
-- let ownCard = '';
-- for (let i = 0; i < 20 && !ownCard.includes('qwen2.5-1.5b-instruct'); i++) {
-- if (i) await t.wait(100);
-- ownCard = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
-- }
-- assert.ok(ownCard.includes('qwen2.5-1.5b-instruct'), 'the card shows its models');
-- assert.ok(ownCard.includes('about 15 tokens a second'), 'and how fast they answer there');
-- // Two machines whose models the owner's AI may use (gpu, switched on below, and this one once on): any of them.
-- await t.call('POST', `/api/v1/machines/${own.deviceId}/ai-models`, { user: alex, body: { on: true } });
-- await t.call('POST', `/api/v1/machines/${gpu.deviceId}/ai-models`, { user: alex, body: { on: true } });
-- const both = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
-- assert.ok(both.includes('settings?own=node&amp;machine=any&amp;model='), 'with two, the link chooses any of them');
-- await t.call('POST', `/api/v1/machines/${own.deviceId}/ai-models`, { user: alex, body: { on: false } });
own.c.close();
-- // The machines page: its models and the switch.
-- const html = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
-- assert.ok(html.includes('llama3.1:8b'), 'its models');
-- assert.ok(html.includes("Stop my AI using this computer's models"), 'and the switch, on');
-- assert.ok(html.includes(`href="https://openvibe.chat/settings?own=node&amp;machine=${gpu.deviceId}&amp;model=llama3.1%3A8b#oc-ai-key">Use these models in OpenVibe.Chat</a>`), 'and where to use them');
-- await t.call('POST', `/api/v1/machines/${gpu.deviceId}/ai-models`, { user: alex, body: { on: false } });
-- assert.ok(!(await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text()).includes('Use these models in OpenVibe.Chat'), 'only while it is on');
gpu.c.close();
});
ddiff --git a/test/machine-screen.test.js b/test/machine-screen.test.js
iindex 4535f0a..362382b 100644
---- a/test/machine-screen.test.js
++++ b/test/machine-screen.test.js
@@@ -44,7 +44,6 @@ const { boot, check, done } = require('./helpers/app');
}
const screen = (method, deviceId, opts = {}) => t.call(method, `/api/v1/machines/${deviceId}/screen`, opts);
const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
-- const form = (p, user, headers = {}) => fetch(t.base + p, { method: 'POST', redirect: 'manual', body: '', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers } });
let mine;
let streamId;
@@@ -130,33 +129,12 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(!t.openre.admits(key));
});
-- await check('the web page: Show my screen, the viewer page with the panel\'s CSP, its state, Stop', async () => {
-- const list = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
-- assert.ok(list.includes(`action="/machines/${mine.deviceId}/screen"`) && list.includes('Watch my screen'), 'the machine offers it (Watch: its telemetry says the screen is live)');
-- const started = await form(`/machines/${mine.deviceId}/screen`, alex);
-- assert.strictEqual(started.status, 303);
-- const where = started.headers.get('location');
-- assert.match(where, new RegExp(`^/machines/${mine.deviceId}/screen\\?until=\\d{4}-`));
-- const page = await fetch(t.base + where, { headers: cookie(alex) });
-- assert.strictEqual(page.status, 200);
-- assert.match(page.headers.get('content-security-policy'), /connect-src 'self' wss:\/\/ingest\.openre\.stream/);
-- const html = await page.text();
-- assert.ok(html.includes(`data-screen-state="/machines/${mine.deviceId}/screen.json"`) && html.includes('/panel/panel.js'));
-- const state = await fetch(`${t.base}/machines/${mine.deviceId}/screen.json`, { headers: cookie(alex) });
-- assert.strictEqual(state.status, 200);
-- assert.strictEqual(state.headers.get('cache-control'), 'private, no-store');
-- assert.strictEqual((await fetch(`${t.base}/machines/${mine.deviceId}/screen.json`, { headers: cookie(bob) })).status, 404);
-- assert.strictEqual((await form(`/machines/${mine.deviceId}/screen`, alex, { Origin: 'https://evil.test' })).status, 403, 'no cross-site post');
-- const stopped = await form(`/machines/${mine.deviceId}/screen/stop`, alex);
-- await t.wait(600); // telemetry samples are kept at most twice a second
-- mine.c.send(frame('telemetry', { sensors: { computer_level: 'observe', computer_screen_stream: 'off' } }));
-- const idle = await poll(async () => {
-- const html = await (await fetch(`${t.base}/machines/${mine.deviceId}/screen`, { headers: cookie(alex) })).text();
-- return !html.includes('data-screen-state') ? html : null;
-- });
-- assert.ok(idle && idle.includes(`action="/machines/${mine.deviceId}/screen"`), 'opened from a link with nothing shown, it offers to start');
-- assert.deepStrictEqual([stopped.status, stopped.headers.get('location')], [303, '/machines']);
-- assert.deepStrictEqual(seen.at(-1).value, { action: 'stop' });
++ await check('the machines pages moved to Link: /machines and every sub-path answer 301', async () => {
++ for (const p of ['/machines', `/machines/${mine.deviceId}/screen`, `/machines/${mine.deviceId}/screen.json`, '/machines/x/desks']) {
++ const r = await fetch(t.base + p, { redirect: 'manual', headers: cookie(alex) });
++ assert.strictEqual(r.status, 301, p);
++ assert.strictEqual(r.headers.get('location'), 'https://openvibe.link/machines', p);
++ }
});
await check('a desk on the live screen: private, or a named desk on a Link that runs several (contracts 0.215.0); never a bad name', async () => {
@@@ -174,21 +152,8 @@ const { boot, check, done } = require('./helpers/app');
single.c.close();
});
-- await check('a desk host (Link v0.20.0 --desk-host) shows its desks live though it has no screen of its own', async () => {
++ await check('a desk host (Link v0.20.0 --desk-host) streams its desks though it has no screen of its own', async () => {
const host = await machine(alex, { files: SCREEN.files, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 } });
-- // Its page offers its desks, never a screen it does not have.
-- const idle = await (await fetch(`${t.base}/machines/${host.deviceId}/screen?desk=desk-task-a`, { headers: cookie(alex) })).text();
-- assert.ok(!idle.includes('Show my screen'), 'no screen of its own to show');
-- assert.ok(idle.includes('no screen of its own'));
-- assert.ok(idle.includes('Show the private desk'));
-- assert.ok(!idle.includes('<option value="main">Your own screen</option>'), 'a link never offers a screen it does not have');
-- assert.ok(idle.includes('<input type="hidden" name="desk" value="private">'), 'a link here shares the private desk');
-- // A Mac runs browser desks only: no private desk (a whole desk) to show.
-- const mac = await machine(alex, { ...SCREEN, desk: { level: 'trusted', window_manager: null, input: true, screen: true, named: true, max_desks: 2, kinds: ['browser'] } });
-- const macPage = await (await fetch(`${t.base}/machines/${mac.deviceId}/screen`, { headers: cookie(alex) })).text();
-- assert.ok(macPage.includes('Show my screen'));
-- assert.ok(!macPage.includes('Show the private desk'), 'a Mac has no whole desk');
-- mac.c.close();
const r = await screen('POST', host.deviceId, { user: alex, body: { seconds: 60, desk: 'desk-task-a' } });
assert.strictEqual(r.status, 201, r.text);
assert.strictEqual(seen.at(-1).value.desk, 'desk-task-a');
@@@ -197,38 +162,6 @@ const { boot, check, done } = require('./helpers/app');
host.c.close();
});
-- await check('watch and take over an agent\'s desk (Desks plan D4): a task\'s link opens its desk; the owner\'s mouse and keyboard go to the desk shown, as the owner; same site only', async () => {
-- const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true, max_desks: 4 };
-- const m = await machine(alex, { ...SCREEN, input: { level: 'trusted', tool: 'xdotool' }, desk });
-- const idle = await (await fetch(`${t.base}/machines/${m.deviceId}/screen?desk=desk-task-a`, { headers: cookie(alex) })).text();
-- assert.ok(idle.includes('Watch your agent&#39;s desk') || idle.includes("Watch your agent's desk"), 'a task\'s link offers its desk first');
-- assert.ok(idle.includes('name="desk" value="desk-task-a"'));
-- const bad = await (await fetch(`${t.base}/machines/${m.deviceId}/screen?desk=desk-../x`, { headers: cookie(alex) })).text();
-- assert.ok(!bad.includes('name="desk" value="desk-../x"'), 'a bad name is never offered');
-- const started = await fetch(`${t.base}/machines/${m.deviceId}/screen`, { method: 'POST', redirect: 'manual', body: 'desk=desk-task-a', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex) } });
-- assert.strictEqual(started.status, 303);
-- assert.match(started.headers.get('location'), /&desk=desk-task-a$/);
-- assert.strictEqual(seen.at(-1).value.desk, 'desk-task-a', 'the machine streams that desk');
-- const html = await (await fetch(t.base + started.headers.get('location'), { headers: cookie(alex) })).text();
-- assert.ok(html.includes('data-owner="1"') && html.includes('/panel/screen-share.js') && html.includes('Take over: use the mouse and keyboard'), 'the page offers Take over');
-- const st = await (await fetch(`${t.base}/machines/${m.deviceId}/screen/state.json`, { headers: cookie(alex) })).json();
-- assert.strictEqual(st.desk, 'desk-task-a');
-- const json = (body, headers = {}) => ({ method: 'POST', body: JSON.stringify(body), headers: { 'Content-Type': 'application/json', ...cookie(alex), ...headers } });
-- const click = await fetch(`${t.base}/machines/${m.deviceId}/screen/input`, json({ action: 'click', x: 10, y: 20, width: 100, height: 100 }));
-- assert.strictEqual(click.status, 200, await click.clone().text());
-- assert.deepStrictEqual(await click.json(), { done: true, desk: 'desk-task-a' });
-- const sent = seen.at(-1);
-- assert.deepStrictEqual([sent.kind, sent.value.desk, sent.value.action, sent.value.x], ['input', 'desk-task-a', 'click', 10], 'on the desk shown, never the owner\'s own screen');
-- assert.strictEqual(sent.operator && sent.operator.subject, alex.subject, 'as the owner');
-- assert.strictEqual((await fetch(`${t.base}/machines/${m.deviceId}/screen/input`, json({ action: 'click', x: 1, y: 1, width: 2, height: 2 }, { Origin: 'https://evil.test' }))).status, 403, 'no cross-site input');
-- assert.strictEqual((await fetch(`${t.base}/machines/${m.deviceId}/screen/input`, json({ action: 'reboot' }))).status, 422);
-- const bobInput = await fetch(`${t.base}/machines/${m.deviceId}/screen/input`, { method: 'POST', body: '{"action":"click"}', headers: { 'Content-Type': 'application/json', ...cookie(bob) } });
-- assert.strictEqual(bobInput.status, 404, 'only the owner');
-- const stop = await fetch(`${t.base}/machines/${m.deviceId}/screen/stop`, json({}));
-- assert.deepStrictEqual([stop.status, (await stop.json()).stopped !== undefined], [200, true], 'the page\'s own Stop answers JSON');
-- m.c.close();
-- });
--
await check('a desk on the owner\'s OpenVibe Live channel (Desks plan D8): a public stream of its own, linked to their channel; never their own screen; Live\'s refusal said', async () => {
const host = await machine(alex, { ...SCREEN, desk: { screen: true, named: true, kinds: ['desktop', 'browser'] } });
const privateBefore = [...t.openre.streams.values()].filter((x) => x.refs.some((r) => r.type === 'machine' && r.id === host.deviceId)).map((x) => [x.id, x.mirror_to_live, x.playback_visibility]);
@@@ -248,14 +181,6 @@ const { boot, check, done } = require('./helpers/app');
const again = await screen('POST', host.deviceId, { user: alex, body: { desk: 'desk-task-a', live: true } });
assert.strictEqual(again.json.live.channel_url, r.json.live.channel_url);
assert.strictEqual([...t.openre.streams.values()].filter((x) => x.refs.some((ref) => ref.type === 'machine_live' && ref.id === host.deviceId)).length, 1);
-- // From the desks page: the second step posts the desk, and the page says where it is live.
-- assert.strictEqual((await form(`/machines/${host.deviceId}/desks/live`, alex, { Origin: 'https://evil.test' })).status, 403, 'no cross-site post');
-- const posted = await fetch(`${t.base}/machines/${host.deviceId}/desks/live`, { method: 'POST', redirect: 'manual', body: 'desk=desk-task-a', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex) } });
-- assert.strictEqual(posted.status, 303);
-- const where = posted.headers.get('location');
-- assert.match(where, /\/desks\?live=desk-task-a&channel=https%3A%2F%2Fopenvibe\.live%2F%40/);
-- const page = await (await fetch(t.base + where, { headers: cookie(alex) })).text();
-- assert.ok(page.includes('<code>desk-task-a</code> is live on <a href="https://openvibe.live/@'), 'the page says where');
// Without a Live account: Live's reason, and nothing is sent to the machine.
const bobs = await machine(bob, { ...SCREEN, desk: { screen: true, named: true } });
t.openre.noLive.add(bob.subject);
@@@ -269,8 +194,11 @@ const { boot, check, done } = require('./helpers/app');
});
await check('removing the machine archives its screen stream', async () => {
-- const r = await form(`/machines/${mine.deviceId}/remove`, alex);
-- assert.strictEqual(r.status, 303);
++ for (const session of [...t.openre.sessions.values()]) if (session.stream_id === streamId) t.openre.endLive(session.id);
++ const r = await t.call('POST', `/api/v1/machines/${mine.deviceId}/revoke`, {
++ sub: 'svc:network', cap: ['bot.machine.manage'], headers: { 'X-OV-Subject': alex.subject }, body: {},
++ });
++ assert.strictEqual(r.status, 200, r.text);
assert.ok(t.openre.calls.some((c) => c.method === 'DELETE' && c.path === `/api/v1/streams/${streamId}`));
});
ddiff --git a/test/machines-moved.test.js b/test/machines-moved.test.js
nnew file mode 100644
iindex 0000000..0162890
---- /dev/null
++++ b/test/machines-moved.test.js
@@@ -0,0 +1,34 @@
++'use strict';
++// The machines hub belongs to OpenVibe Link (owner, 11-09): Bot's /machines and every path below it answer a 301 to
++// https://openvibe.link/machines, signed in or not, so a signed-out visitor never sees Bot's sign-in page first.
++const assert = require('assert');
++// Loaded directly, not only through the app, so a change to the web or API surface is in this test's closure.
++require('../server/web/routes');
++require('../server/web/docs');
++require('../server/api/v1');
++require('../server/machine-screen');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot();
++ const alex = t.network.newUser('alex');
++ const LINK = 'https://openvibe.link/machines';
++
++ await check('/machines answers 301 to Link signed out, with no sign-in bounce', async () => {
++ for (const p of ['/machines', '/machines/dev_01ABC/screen?desk=desk-task-a', '/machines/dev_01ABC/desks']) {
++ const r = await fetch(`${t.base}${p}`, { redirect: 'manual' });
++ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, LINK], p);
++ }
++ });
++
++ await check('it answers 301 the same way signed in, and for a form post', async () => {
++ const headers = { Cookie: `ov_token=${t.network.signUser(alex)}` };
++ const r = await fetch(`${t.base}/machines`, { redirect: 'manual', headers });
++ assert.deepStrictEqual([r.status, r.headers.get('location')], [301, LINK]);
++ const post = await fetch(`${t.base}/machines`, { method: 'POST', redirect: 'manual', headers });
++ assert.deepStrictEqual([post.status, post.headers.get('location')], [301, LINK], 'no page is left behind here');
++ });
++
++ await t.close();
++ done();
++})().catch((e) => { console.error(e); process.exit(1); });
ddiff --git a/test/panel-screen-client.test.js b/test/panel-screen-client.test.js
iindex 2f1971c..1fefddf 100644
---- a/test/panel-screen-client.test.js
++++ b/test/panel-screen-client.test.js
@@@ -1,5 +1,5 @@
'use strict';
--// public/panel.js on a page with no robot panel (a machine's live screen, /machines/:id/screen): the tile polls the
++// public/panel.js on a page with no robot panel (a shared screen, /screen/:token): the tile polls the
// machine's screen.json until the machine publishes, then opens the session's viewer signaling socket; once the
// machine stops, it says so and stops reconnecting. The live video must not wait for #panel, which only a robot's
// panel has (2026-10-15: it did, and the screen page stayed on "Waiting for the machine…").
ddiff --git a/test/phone-pairing.test.js b/test/phone-pairing.test.js
iindex be1aa11..be2ad4f 100644
---- a/test/phone-pairing.test.js
++++ b/test/phone-pairing.test.js
@@@ -52,14 +52,11 @@ const { renderPhoneLinkPage } = require('../server/web/render');
assert.match(html, /for the next 15 minutes \(until <time datetime="[^"]+">\d\d:\d\d UTC<\/time>\)/, 'the expiry in plain words');
});
-- await check('before the Android app is published, /link/phone says so and mints no code; /machines does not link it', async () => {
++ await check('before the Android app is published, /link/phone says so and mints no code', async () => {
const html = renderPhoneLinkPage({ pairing: null, apkUrl: null });
assert.match(html, /in testing and not published yet/);
assert.doesNotMatch(html, /qr-box|class="code"/, 'no QR and no code for an app nobody can install');
assert.doesNotMatch(html, /releases"/, 'no link to a release that has no APK');
-- const { renderMachinesPage } = require('../server/web/render');
-- assert.doesNotMatch(renderMachinesPage({ machines: [] }), /href="\/link\/phone"/);
-- assert.match(renderMachinesPage({ machines: [], phoneReady: true }), /href="\/link\/phone"/);
});
done();
ddiff --git a/test/screen-shares.test.js b/test/screen-shares.test.js
iindex 5c56912..243d2fd 100644
---- a/test/screen-shares.test.js
++++ b/test/screen-shares.test.js
@@@ -31,31 +31,23 @@ const { boot, check, done } = require('./helpers/app');
const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, Math.max(100, Math.floor(config.heartbeat_ms / 2)));
c.ws.on('close', () => clearInterval(beat));
await poll(() => t.hub.computerOf(deviceId) !== null);
-- const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
-- const form = (p, user, body = '', headers = {}) => fetch(t.base + p, { method: 'POST', redirect: 'manual', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers } });
const json = async (method, p, body) => { const r = await fetch(t.base + p, { method, headers: { 'Content-Type': 'application/json' }, body: body ? JSON.stringify(body) : undefined }); return { status: r.status, json: await r.json().catch(() => null), headers: r.headers, text: '' }; };
-- const linkOf = (html) => (/value="(https?:\/\/[^"]+\/screen\/([A-Za-z0-9_-]+))"/.exec(html) || [])[2];
let watch, use;
-- await check('the owner makes links, each shown once; the page lists them; nobody else makes one', async () => {
-- let r = await form(`/machines/${deviceId}/shares`, alex, 'label=Staff+room&days=30');
-- assert.strictEqual(r.status, 200);
-- watch = linkOf(await r.text());
++ await check('the owner makes links through the API, each shown once; only hashes are kept; nobody else makes one', async () => {
++ const make = (user, body) => t.call('POST', `/api/v1/machines/${deviceId}/watch-links`, { user, body });
++ let r = await make(alex, { label: 'Staff room', minutes: 60 });
++ assert.strictEqual(r.status, 201, r.text);
++ watch = r.json.url.split('/screen/')[1];
assert.ok(watch && watch.length >= 40, 'a watch link');
-- r = await form(`/machines/${deviceId}/shares`, alex, 'label=Staff+room+(use)&days=7&control=1');
-- const html = await r.text();
-- use = linkOf(html);
++ r = await make(alex, { label: 'Staff room (use)', minutes: 60, control: true });
++ use = r.json.url.split('/screen/')[1];
assert.ok(use && use !== watch);
-- assert.match(html, /watch and use/);
++ assert.deepStrictEqual([r.json.control, r.json.desk], [true, 'main']);
const rows = await t.db.many('SELECT * FROM machine_screen_shares WHERE device_id = $1 ORDER BY created_at', [deviceId]);
assert.deepStrictEqual(rows.map((x) => [x.label, x.control]), [['Staff room', false], ['Staff room (use)', true]]);
assert.ok(rows.every((x) => !JSON.stringify(x).includes(watch) && !JSON.stringify(x).includes(use)), 'only hashes are kept');
-- r = await fetch(`${t.base}/machines/${deviceId}/screen`, { headers: cookie(alex) });
-- const page = await r.text();
-- assert.match(page, /Staff room \(use\)/);
-- assert.ok(!page.includes(use), 'a link is never shown again');
-- assert.ok((await form(`/machines/${deviceId}/shares`, bob, 'label=mine')).status >= 400, 'another person cannot share it');
-- assert.strictEqual((await form(`/machines/${deviceId}/shares`, alex, 'label=x', { Origin: 'https://evil.test' })).status, 403, 'no cross-site post');
++ assert.strictEqual((await make(bob, { label: 'mine' })).status, 404, 'another person cannot share it');
});
await check('the share page: framed by OpenVibe\'s workspaces, controls only on a use link', async () => {
@@@ -104,18 +96,13 @@ const { boot, check, done } = require('./helpers/app');
});
await check('a desk link shows and drives the private desk, never the owner\'s screen', async () => {
-- let r = await fetch(`${t.base}/machines/${deviceId}/screen`, { headers: cookie(alex) });
-- assert.ok(!(await r.text()).includes('Show the private desk'), 'no desk, no desk button');
c.send(frame('status', { capabilities: { computer: { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true } } }, drivers: [], faults: [] }));
await poll(() => (t.hub.computerOf(deviceId) || {}).desk);
-- r = await fetch(`${t.base}/machines/${deviceId}/screen`, { headers: cookie(alex) });
-- const page = await r.text();
-- assert.ok(page.includes('Show the private desk') && page.includes('<option value="private">The private desk, where your agent works</option>'));
-- r = await form(`/machines/${deviceId}/shares`, alex, 'label=Agent+desk&days=1&control=1&desk=private');
-- const html = await r.text();
-- const desk = linkOf(html);
++ let r = await t.call('POST', `/api/v1/machines/${deviceId}/watch-links`, { user: alex, body: { label: 'Agent desk', minutes: 60, control: true, desk: 'private' } });
++ assert.strictEqual(r.status, 201, r.text);
++ const desk = r.json.url.split('/screen/')[1];
assert.ok(desk, 'a desk link');
-- assert.match(html, /Agent desk<\/strong> · the private desk · watch and use/);
++ assert.deepStrictEqual([r.json.desk, r.json.control], ['private', true]);
assert.match(await (await fetch(`${t.base}/screen/${desk}`)).text(), /the private desk, where an agent works/);
assert.strictEqual((await json('GET', `/screen/${desk}/state.json`)).json.desk, 'private');
assert.strictEqual((await json('POST', `/screen/${desk}/start`)).status, 200);
@@@ -126,17 +113,17 @@ const { boot, check, done } = require('./helpers/app');
});
await check('a link may show any desk the machine has, not only the private one (plan L55)', async () => {
-- let r = await form(`/machines/${deviceId}/shares`, alex, 'label=Task+desk&days=1&control=1&desk=desk-task-a');
-- const html = await r.text();
-- const task = linkOf(html);
++ let r = await t.call('POST', `/api/v1/machines/${deviceId}/watch-links`, { user: alex, body: { label: 'Task desk', minutes: 60, control: true, desk: 'desk-task-a' } });
++ assert.strictEqual(r.status, 201, r.text);
++ const task = r.json.url.split('/screen/')[1];
assert.ok(task, 'a link to a named desk');
-- assert.match(html, /the desk-task-a desk/);
++ assert.deepStrictEqual([r.json.desk, r.json.control], ['desk-task-a', true]);
assert.match(await (await fetch(`${t.base}/screen/${task}`)).text(), /the desk-task-a desk/);
assert.strictEqual((await json('GET', `/screen/${task}/state.json`)).json.desk, 'desk-task-a');
assert.strictEqual((await t.db.maybe("SELECT desk FROM machine_screen_shares WHERE label = 'Task desk'")).desk, 'desk-task-a');
// A desk the machine does not have is refused by the contract, not stored.
-- r = await form(`/machines/${deviceId}/shares`, alex, 'label=Bad&days=1&desk=desk-../x');
-- assert.ok(r.status >= 400, 'not a desk name');
++ r = await t.call('POST', `/api/v1/machines/${deviceId}/watch-links`, { user: alex, body: { label: 'Bad', minutes: 60, desk: 'desk-../x' } });
++ assert.strictEqual(r.status, 422, 'not a desk name');
});
await check('a burst is slowed down; a revoked link stops at once', async () => {
@@@ -144,8 +131,7 @@ const { boot, check, done } = require('./helpers/app');
for (let i = 0; i < 12; i++) { if ((await json('POST', `/screen/${use}/input`, { action: 'move', x: i, y: i, width: 100, height: 100 })).status === 429) limited++; }
assert.ok(limited > 0, 'rate-limited');
const id = (await t.db.maybe("SELECT id FROM machine_screen_shares WHERE label = 'Staff room (use)'")).id;
-- const r = await form(`/machines/${deviceId}/shares/${id}/revoke`, alex);
-- assert.strictEqual(r.status, 303);
++ await t.db.query('UPDATE machine_screen_shares SET revoked_at = now() WHERE id = $1', [id]);
assert.strictEqual((await fetch(`${t.base}/screen/${use}`)).status, 404);
assert.strictEqual((await json('POST', `/screen/${use}/input`, { action: 'click', x: 1, y: 1, width: 2, height: 2 })).status, 404);
// An ended link is gone too.
ddiff --git a/test/voice.test.js b/test/voice.test.js
iindex 5175a2f..6ea1a90 100644
---- a/test/voice.test.js
++++ b/test/voice.test.js
@@@ -56,9 +56,8 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(cfg.call_word, 'hey desk');
const bad = await t.call('POST', `/api/v1/machines/${deviceId}/voice`, { user: alex, body: { on: true, call_word: 'a' } });
assert.deepStrictEqual([bad.status, bad.json.code], [422, 'bot.invalid_input']);
-- const page = await fetch(`${t.base}/machines`, { headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } }).then((x) => x.text());
-- assert.match(page, /Voice: &quot;hey desk&quot;|Voice: "hey desk"/);
-- assert.match(page, /Stop Vibe answering my voice here/);
++ const page = await fetch(`${t.base}/machines`, { redirect: 'manual' });
++ assert.deepStrictEqual([page.status, page.headers.get('location')], [301, 'https://openvibe.link/machines'], 'the machines hub is Link\'s');
});
await check('a voice_ask: working at once, then Vibe\'s answer, asked of Chat as the owner with one source per machine', async () => {
ddiff --git a/test/worker-machines.test.js b/test/worker-machines.test.js
iindex 7398255..c7161f8 100644
---- a/test/worker-machines.test.js
++++ b/test/worker-machines.test.js
@@@ -19,7 +19,6 @@ const { boot, check, done } = require('./helpers/app');
const { dispatch } = require('../server/jobs/dispatch');
const poll = async (fn, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(50); } return null; };
const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
-- const form = (p, user, headers = {}) => fetch(t.base + p, { method: 'POST', redirect: 'manual', body: '', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...(user ? cookie(user) : {}), ...headers } });
let seq = 0;
const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });
const offers = t.app.locals.offers;
@@@ -83,24 +82,20 @@ const { boot, check, done } = require('./helpers/app');
}
});
-- await check('/machines lists them, adds one with a one-time --worker command, and removes one', async () => {
-- const anon = await fetch(`${t.base}/machines`, { redirect: 'manual' });
-- assert.strictEqual(anon.status, 401, 'signed out: a gate page here, noindex (SEO-7)');
-- const page = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
-- for (const text of ['Your machines', 'desk-pc', 'Online', 'code jobs']) {
-- assert.ok(page.includes(text), `machine list is missing ${text}`);
++ await check('/machines answers 301 to Link signed in or not; the machine stays in the API and revokes through the hub', async () => {
++ for (const headers of [{}, cookie(alex)]) {
++ const r = await fetch(`${t.base}/machines`, { redirect: 'manual', headers });
++ assert.strictEqual(r.status, 301, 'Link owns the hub now');
++ assert.strictEqual(r.headers.get('location'), 'https://openvibe.link/machines');
}
-- assert.ok(!page.includes(bob.subject));
-- const added = await form('/machines', alex);
-- assert.strictEqual(added.status, 201);
-- const html = await added.text();
-- const pairing = t.network.pairings.at(-1);
-- assert.strictEqual(pairing.body.ref, `worker:${alex.subject}`);
-- assert.ok(html.includes('--worker') && html.includes('--pairing pair_'), html);
-- assert.strictEqual((await form('/machines', alex, { Origin: 'https://evil.test' })).status, 403, 'no cross-site post');
-- assert.strictEqual((await form(`/machines/${mine.deviceId}/remove`, bob)).status, 404, 'not bob’s');
-- const removed = await form(`/machines/${mine.deviceId}/remove`, alex);
-- assert.strictEqual(removed.status, 303);
++ const sub = await fetch(`${t.base}/machines/${mine.deviceId}/screen`, { redirect: 'manual', headers: cookie(alex) });
++ assert.deepStrictEqual([sub.status, sub.headers.get('location')], [301, 'https://openvibe.link/machines'], 'every sub-path too');
++ const list = await t.call('GET', '/api/v1/machines', { user: alex });
++ assert.deepStrictEqual(list.json.machines.map((m) => m.id), [mine.deviceId]);
++ const removed = await t.call('POST', `/api/v1/machines/${mine.deviceId}/revoke`, {
++ sub: 'svc:network', cap: ['bot.machine.manage'], headers: { 'X-OV-Subject': alex.subject }, body: {},
++ });
++ assert.strictEqual(removed.status, 200, removed.text);
assert.ok((await t.db.maybe('SELECT revoked_at FROM devices WHERE id = $1', [mine.deviceId])).revoked_at);
assert.ok(t.network.revokes.includes(mine.principal));
assert.strictEqual((await offers.build()).find((o) => o.node_id === mine.deviceId), undefined, 'a removed machine offers nothing');