openvibers/openvibe.bot Commits
A phone pairs as a device of kind phone, from a code at /link/phone (#329)
Changes
@@@ -118,3 +118,8 @@ OV_OAUTH_CLIENT_SECRET=# The anonymous read-only /watch socket behind the embed: open sockets per client address and watchers per robot.#BOT_WATCH_MAX_PER_IP=20#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.++# LINK_ANDROID_APK_URL=https://github.com/OpenVibers/OpenVibe.Node/releases/download/vX.Y.Z/openvibe-link.apknnew file mode 100644@@@ -0,0 +1,7 @@++-- phase: expand++-- Link on the phone (plan 2026-11-07 §13.2): a Network-paired phone binds as a device of its own kind. It serves no++-- robot and publishes no video; it is the person's device, with their subject and no robot ids. Every kind made++-- before keeps it.++ALTER TABLE devices DROP CONSTRAINT IF EXISTS devices_kind_check;++ALTER TABLE devices ADD CONSTRAINT devices_kind_check++ CHECK (kind IN ('onboard', 'bridge', 'server', 'phone'));@@@ -80,6 +80,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =/** The last online state Bot reported to Network for a machine, so a change is reported once. */const reportedState = new Map();const workerRef = (subject) => `worker:${subject}`;++ // The phone app (plan 2026-11-07 §13.2): its pairing names the person, and its device is kind 'phone'.++ const phoneRef = (subject) => `phone:${subject}`;// ── Presenters ────────────────────────────────────────────────────────────────────────────────const presentRobot = (r) => (r ? {id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,@@@ -697,8 +699,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =// A worker machine (plan T14/T16) runs its owner's own jobs and serves no robot: its pairing names the owner// (ref `worker:<owner>`), and the device keeps that owner.const worker = !!(p.owner && p.owner.kind === 'user' && USER_SUBJECT.test(String(p.owner.subject)) && p.paired_for.ref === workerRef(p.owner.subject));-- const robot = !worker && isRobotId(p.paired_for.ref) ? await getRobot(p.paired_for.ref) : null;-- if (!worker && (!robot || !p.owner || p.owner.kind !== 'user' || p.owner.subject !== robot.owner_subject)) refuse("the machine was not paired for one of its owner's robots");++ // A phone pairs for its person, not for a robot: it is a device of kind 'phone' with no robot and no video.++ const phone = !!(p.owner && p.owner.kind === 'user' && USER_SUBJECT.test(String(p.owner.subject)) && p.paired_for.ref === phoneRef(p.owner.subject));++ const robot = !worker && !phone && isRobotId(p.paired_for.ref) ? await getRobot(p.paired_for.ref) : null;++ if (!worker && !phone && (!robot || !p.owner || p.owner.kind !== 'user' || p.owner.subject !== robot.owner_subject)) refuse("the machine was not paired for one of its owner's robots");const at = iso(now());const name = typeof p.name === 'string' ? storable(p.name).trim().slice(0, 80) || null : null;// Two first connections at once: the unique index lets one insert win, and the other reads it.@@@ -706,8 +710,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =`INSERT INTO devices (id, robot_ids, name, kind, agent_version, drivers, capabilities, credential_hash, node_principal, owner_subject, created_at, updated_at)VALUES ($1, $2::jsonb, $3, $4, NULL, '[]'::jsonb, '{}'::jsonb, NULL, $5, $6, $7, $7)ON CONFLICT (node_principal) WHERE node_principal IS NOT NULL AND revoked_at IS NULL DO NOTHING RETURNING *`,-- [prefixedId('dev', now()), JSON.stringify(worker ? [] : [robot.id]), name, worker ? 'server' : 'onboard', principalId,-- worker ? p.owner.subject : null, at]);++ [prefixedId('dev', now()), JSON.stringify(worker || phone ? [] : [robot.id]), name, worker ? 'server' : phone ? 'phone' : 'onboard', principalId,++ worker || phone ? p.owner.subject : null, at]);changed();return inserted || db.maybe('SELECT * FROM devices WHERE node_principal = $1 AND revoked_at IS NULL', [principalId]);}@@@ -732,10 +736,27 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =async function listMachines(subject) {return db.many(`SELECT d.* FROM devices d-- WHERE d.revoked_at IS NULL AND (d.owner_subject = $1 OR (d.capabilities -> 'worker' IS NOT NULL AND EXISTS (++ WHERE d.revoked_at IS NULL AND d.kind <> 'phone' AND (d.owner_subject = $1 OR (d.capabilities -> 'worker' IS NOT NULL AND EXISTS (SELECT 1 FROM robots r WHERE r.owner_subject = $1 AND d.robot_ids @> jsonb_build_array(r.id))))ORDER BY d.created_at DESC`, [subject]);}++ // ── Link on the phone (plan 2026-11-07 §13.2) ──────────────────────────────────────────────────++ /**++ * A one-time code for the person's phone (OpenVibe Link for Android). Network pairs it with ref `phone:<owner>`;++ * the app redeems the code there and binds on Bot with a node token, and the device it makes is kind 'phone',++ * with that owner and no robot. Network keeps one live code per ref, so a new one replaces an unused one.++ * → { code, expires_at, pairing_id, qr }: `qr` is what openvibe.bot/link/phone draws, and what the app's scanner reads.++ */++ async function mintPhone(ownerSubject) {++ if (!nodes) fail(503, 'bot.network_unavailable', 'Network pairing is not configured here');++ const p = await nodes.pair({ subject: ownerSubject, ref: phoneRef(ownerSubject) });++ if (!p || typeof p.pairing_id !== 'string' || typeof p.code !== 'string') fail(503, 'bot.network_unavailable', 'Network answered no pairing code');++ return {++ code: p.code, expires_at: p.expires_at, pairing_id: p.pairing_id,++ // No network=: the app talks only to its own build's server, and a link naming another is refused.++ qr: `openvibe-link://pair?code=${encodeURIComponent(p.code)}&pairing=${encodeURIComponent(p.pairing_id)}`,++ };++ }// ── The live screen (plan T14, contracts 0.182.0) ──────────────────────────────────────────────────────/*** A new ingest key for the machine's own screen stream on OpenRestream, as its owner → { streamId, key, whipUrl }.@@@ -934,7 +955,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =async function issuePublishKey(id) {const live = await db.maybe('SELECT * FROM devices WHERE id = $1 AND revoked_at IS NULL', [id]);if (!live) fail(404, 'bot.device_not_found', 'no live device');-- const robot = await getRobot(json(live.robot_ids, [])[0]);++ const robotIds = json(live.robot_ids, []);++ // A phone (kind 'phone') serves no robot, so there is no stream key to issue for it.++ const robot = robotIds.length ? await getRobot(robotIds[0]) : null;const issued = await newStreamKey(robot);const device = await db.tx(async (t) => {if (issued) await keepStream(t, robot.id, issued.streamId, id);@@@ -1496,6 +1519,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice, setSharedDesks, listSharedDesks,setRoles: setMachineRoles, rename: renameMachine, runningJobs, reportStatus: reportMachineStatus,screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },++ phone: { mint: mintPhone, ref: phoneRef },deskLeases,streaming: { get: streaming, set: setStreaming },video: { live: liveVideo },@@@ -728,6 +728,43 @@ ${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}return page('Add a device', body);}++/**++ * /link/phone (plan 2026-11-07 §13.2): link the Android app to this account. The code is Network's one-time pairing++ * code; the QR carries it in the app's own link, so scanning it opens OpenVibe Link with the code filled in.++ */++function renderPhoneLinkPage({ pairing = null, apkUrl = null } = {}) {++ if (!apkUrl || !pairing) {++ const body = `${topbar('<span class="crumb">Link on your phone</span>')}++<main class="page pairing">++<h1>Link on your phone</h1>++<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>++</main>`;++ return page('Link on your phone', body, { description: 'OpenVibe Link for Android: in testing, not published yet.' });++ }++ const qr = pairing.qr ? qrSvg(pairing.qr, { label: 'Opens OpenVibe Link with this pairing code' }) : '';++ const minutes = Math.max(1, Math.round((Date.parse(pairing.expires_at) - Date.now()) / 60000)) || 15;++ const body = `${topbar('<span class="crumb">Link on your phone</span>')}++<main class="page pairing">++<h1>Link on your phone</h1>++<p>OpenVibe Link puts your agents' approvals in your pocket and adds <b>Send to OpenVibe</b> to every app's share sheet.++First <a href="${esc(apkUrl)}" rel="noopener">install it on your Android phone</a>, then scan or type the code below.</p>++<ol class="steps">++<li class="card phone-pair"><h2>Scan this with the phone</h2>++<div class="qr-box">${qr}</div>++<p class="muted">The phone's camera opens OpenVibe Link with the code filled in. No password is typed on the phone here:++the code pairs the device, and the app asks you to sign in separately.</p></li>++<li class="card"><h2>Or type the code in the app</h2>++<p><strong class="code">${esc(pairing.code)}</strong></p>++<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>++</main>`;++ return page('Link on your phone', body, { description: 'Pair the OpenVibe Link Android app with a one-time code or its QR code.' });++}++function renderClaimPage({ claim, models = [], values = {}, error = null, now = Date.now() }) {const hw = claim.hardware || {};const minutes = (ms) => Math.max(0, Math.round(ms / 60000));@@@ -782,7 +819,7 @@ ${chatUrl ? `<li class="card"><h2>Drive it from your chat</h2><p class="muted">L* /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 }) {++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 : [];@@@ -931,6 +968,7 @@ ${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama,<p class="muted"><a href="https://openvibe.network/machines">All your machines are on openvibe.network/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' });}@@@ -1170,4 +1208,4 @@ function renderFirstRunPage({ rows = [] }) {return page('First run', body, { description: 'The guided first run of OpenVibe Node, 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, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, camerasOf, esc };++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 };@@@ -43,7 +43,13 @@ const { frameAncestors } = require('../config');const { BotError, json } = require('../util');const { aiOf } = require('../machine-ai');const kits = require('../kits');--const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');++// 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.++function linkApkUrl() {++ const v = String(process.env.LINK_ANDROID_APK_URL || '').trim();++ return /^https:\/\/(github\.com\/OpenVibers\/OpenVibe\.Node\/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 { createMachineScreen } = require('../machine-screen');const { createScreenShares } = require('../screen-shares');const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');@@@ -803,13 +809,25 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limir.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) }));++ 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).++ r.get('/link/phone', ...manage, page(async (req, res) => {++ const me = requireUser(req);++ if (crossSiteOrPrefetch(req)) return res.redirect(303, '/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).++ const apkUrl = linkApkUrl();++ 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 }));++ 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) => {nnew file mode 100644@@@ -0,0 +1,66 @@++'use strict';++// Link on the phone (plan 2026-11-07 §13.2): a phone pairs with Network's one-time code under ref `phone:<owner>`,++// binds on Bot as a device of kind 'phone' with no robot and its owner set, is not one of the person's machines, and++// /link/phone shows the code and the app's own link for the QR.++const assert = require('assert');++const { boot, check, done } = require('./helpers/app');++const { renderPhoneLinkPage } = require('../server/web/render');++++(async () => {++ const t = await boot({});++ const alex = t.network.newUser('alex');++++ await check('a phone pairing binds a device of kind phone, owned by the person and serving no robot', async () => {++ const principal = t.network.addNode({ owner: alex.subject, ref: `phone:${alex.subject}`, name: 'Pixel' });++ const r = await t.call('POST', '/api/v1/devices/bind', { token: t.network.signNode(principal) });++ assert.strictEqual(r.status, 201, r.text);++ const d = await t.db.maybe('SELECT * FROM devices WHERE id = $1', [r.json.device_id]);++ assert.strictEqual(d.kind, 'phone');++ assert.deepStrictEqual(d.robot_ids, []);++ assert.deepStrictEqual(d.drivers, []);++ assert.strictEqual(d.owner_subject, alex.subject);++ assert.strictEqual(d.node_principal, principal);++ assert.ok(r.json.robot_id == null, "a phone serves no robot");++ });++++ await check('a phone is not listed among the person\'s machines', async () => {++ const worker = t.network.addNode({ owner: alex.subject, ref: `worker:${alex.subject}`, name: 'desk' });++ await t.call('POST', '/api/v1/devices/bind', { token: t.network.signNode(worker) });++ const list = await t.domain.machines.list(alex.subject);++ assert.ok(list.length >= 1, 'the worker machine is there');++ assert.ok(list.every((m) => m.kind !== 'phone'), 'the phone is not a machine');++ });++++ await check('mint answers the code and the link the QR carries', async () => {++ const p = await t.domain.phone.mint(alex.subject);++ assert.match(p.code, /^[0-9A-Z]{4}-?[0-9A-Z]{4}$/);++ assert.match(p.pairing_id, /^pair_/);++ assert.strictEqual(p.qr, `openvibe-link://pair?code=${encodeURIComponent(p.code)}&pairing=${encodeURIComponent(p.pairing_id)}`, 'no server in the link: the app has its own');++ assert.doesNotMatch(p.qr, /network=/);++ });++++ await check('/link/phone is the person\'s own page, and it shows the code and the app link', async () => {++ const guest = await t.call('GET', '/link/phone');++ assert.ok([302, 401].includes(guest.status), `a guest got ${guest.status}`);++ const pairing = { code: 'ABCD-1234', expires_at: new Date(Date.now() + 15 * 60000).toISOString(), qr: 'openvibe-link://pair?code=ABCD-1234&pairing=pair_01JD2C3D4E5F6G7H8J9K0MNPQR' };++ const apkUrl = 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v0.46.0/openvibe-link.apk';++ const html = renderPhoneLinkPage({ pairing, apkUrl });++ assert.match(html, /ABCD-1234/);++ assert.match(html, /qr-box/);++ assert.match(html, /kind <b>phone<\/b>/);++ assert.ok(html.includes(`href="${apkUrl}"`), 'the install link is the published download');++ 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 () => {++ 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();++})();
@@ -118,3 +118,8 @@ OV_OAUTH_CLIENT_SECRET=
@@ -118,3 +118,8 @@ OV_OAUTH_CLIENT_SECRET=
# The anonymous read-only /watch socket behind the embed: open sockets per client address and watchers per robot.
# The anonymous read-only /watch socket behind the embed: open sockets per client address and watchers per robot.
#BOT_WATCH_MAX_PER_IP=20
#BOT_WATCH_MAX_PER_IP=20
#BOT_WATCH_MAX_PER_ROBOT=500
#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.
+# LINK_ANDROID_APK_URL=https://github.com/OpenVibers/OpenVibe.Node/releases/download/vX.Y.Z/openvibe-link.apk
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+-- phase: expand
+-- Link on the phone (plan 2026-11-07 §13.2): a Network-paired phone binds as a device of its own kind. It serves no
+-- robot and publishes no video; it is the person's device, with their subject and no robot ids. Every kind made
+-- before keeps it.
+ALTER TABLE devices DROP CONSTRAINT IF EXISTS devices_kind_check;
+ALTER TABLE devices ADD CONSTRAINT devices_kind_check
+ CHECK (kind IN ('onboard', 'bridge', 'server', 'phone'));
@@ -80,6 +80,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -80,6 +80,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
/** The last online state Bot reported to Network for a machine, so a change is reported once. */
/** The last online state Bot reported to Network for a machine, so a change is reported once. */
const reportedState = new Map();
const reportedState = new Map();
const workerRef = (subject) => `worker:${subject}`;
const workerRef = (subject) => `worker:${subject}`;
+ // The phone app (plan 2026-11-07 §13.2): its pairing names the person, and its device is kind 'phone'.
+ const phoneRef = (subject) => `phone:${subject}`;
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
const presentRobot = (r) => (r ? {
const presentRobot = (r) => (r ? {
id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
@@ -697,8 +699,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -697,8 +699,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
// A worker machine (plan T14/T16) runs its owner's own jobs and serves no robot: its pairing names the owner
// A worker machine (plan T14/T16) runs its owner's own jobs and serves no robot: its pairing names the owner
// (ref `worker:<owner>`), and the device keeps that owner.
// (ref `worker:<owner>`), and the device keeps that owner.
const worker = !!(p.owner && p.owner.kind === 'user' && USER_SUBJECT.test(String(p.owner.subject)) && p.paired_for.ref === workerRef(p.owner.subject));
const worker = !!(p.owner && p.owner.kind === 'user' && USER_SUBJECT.test(String(p.owner.subject)) && p.paired_for.ref === workerRef(p.owner.subject));
- const robot = !worker && isRobotId(p.paired_for.ref) ? await getRobot(p.paired_for.ref) : null;
- if (!worker && (!robot || !p.owner || p.owner.kind !== 'user' || p.owner.subject !== robot.owner_subject)) refuse("the machine was not paired for one of its owner's robots");
+ // A phone pairs for its person, not for a robot: it is a device of kind 'phone' with no robot and no video.
+ const phone = !!(p.owner && p.owner.kind === 'user' && USER_SUBJECT.test(String(p.owner.subject)) && p.paired_for.ref === phoneRef(p.owner.subject));
+ const robot = !worker && !phone && isRobotId(p.paired_for.ref) ? await getRobot(p.paired_for.ref) : null;
+ if (!worker && !phone && (!robot || !p.owner || p.owner.kind !== 'user' || p.owner.subject !== robot.owner_subject)) refuse("the machine was not paired for one of its owner's robots");
const at = iso(now());
const at = iso(now());
const name = typeof p.name === 'string' ? storable(p.name).trim().slice(0, 80) || null : null;
const name = typeof p.name === 'string' ? storable(p.name).trim().slice(0, 80) || null : null;
// Two first connections at once: the unique index lets one insert win, and the other reads it.
// Two first connections at once: the unique index lets one insert win, and the other reads it.
@@ -706,8 +710,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -706,8 +710,8 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
`INSERT INTO devices (id, robot_ids, name, kind, agent_version, drivers, capabilities, credential_hash, node_principal, owner_subject, created_at, updated_at)
`INSERT INTO devices (id, robot_ids, name, kind, agent_version, drivers, capabilities, credential_hash, node_principal, owner_subject, created_at, updated_at)
VALUES ($1, $2::jsonb, $3, $4, NULL, '[]'::jsonb, '{}'::jsonb, NULL, $5, $6, $7, $7)
VALUES ($1, $2::jsonb, $3, $4, NULL, '[]'::jsonb, '{}'::jsonb, NULL, $5, $6, $7, $7)
ON CONFLICT (node_principal) WHERE node_principal IS NOT NULL AND revoked_at IS NULL DO NOTHING RETURNING *`,
ON CONFLICT (node_principal) WHERE node_principal IS NOT NULL AND revoked_at IS NULL DO NOTHING RETURNING *`,
- [prefixedId('dev', now()), JSON.stringify(worker ? [] : [robot.id]), name, worker ? 'server' : 'onboard', principalId,
- worker ? p.owner.subject : null, at]);
+ [prefixedId('dev', now()), JSON.stringify(worker || phone ? [] : [robot.id]), name, worker ? 'server' : phone ? 'phone' : 'onboard', principalId,
+ worker || phone ? p.owner.subject : null, at]);
changed();
changed();
return inserted || db.maybe('SELECT * FROM devices WHERE node_principal = $1 AND revoked_at IS NULL', [principalId]);
return inserted || db.maybe('SELECT * FROM devices WHERE node_principal = $1 AND revoked_at IS NULL', [principalId]);
}
}
@@ -732,10 +736,27 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -732,10 +736,27 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
async function listMachines(subject) {
async function listMachines(subject) {
return db.many(
return db.many(
`SELECT d.* FROM devices d
`SELECT d.* FROM devices d
- WHERE d.revoked_at IS NULL AND (d.owner_subject = $1 OR (d.capabilities -> 'worker' IS NOT NULL AND EXISTS (
+ WHERE d.revoked_at IS NULL AND d.kind <> 'phone' AND (d.owner_subject = $1 OR (d.capabilities -> 'worker' IS NOT NULL AND EXISTS (
SELECT 1 FROM robots r WHERE r.owner_subject = $1 AND d.robot_ids @> jsonb_build_array(r.id))))
SELECT 1 FROM robots r WHERE r.owner_subject = $1 AND d.robot_ids @> jsonb_build_array(r.id))))
ORDER BY d.created_at DESC`, [subject]);
ORDER BY d.created_at DESC`, [subject]);
}
}
+ // ── Link on the phone (plan 2026-11-07 §13.2) ──────────────────────────────────────────────────
+ /**
+ * A one-time code for the person's phone (OpenVibe Link for Android). Network pairs it with ref `phone:<owner>`;
+ * the app redeems the code there and binds on Bot with a node token, and the device it makes is kind 'phone',
+ * with that owner and no robot. Network keeps one live code per ref, so a new one replaces an unused one.
+ * → { code, expires_at, pairing_id, qr }: `qr` is what openvibe.bot/link/phone draws, and what the app's scanner reads.
+ */
+ async function mintPhone(ownerSubject) {
+ if (!nodes) fail(503, 'bot.network_unavailable', 'Network pairing is not configured here');
+ const p = await nodes.pair({ subject: ownerSubject, ref: phoneRef(ownerSubject) });
+ if (!p || typeof p.pairing_id !== 'string' || typeof p.code !== 'string') fail(503, 'bot.network_unavailable', 'Network answered no pairing code');
+ return {
+ code: p.code, expires_at: p.expires_at, pairing_id: p.pairing_id,
+ // No network=: the app talks only to its own build's server, and a link naming another is refused.
+ qr: `openvibe-link://pair?code=${encodeURIComponent(p.code)}&pairing=${encodeURIComponent(p.pairing_id)}`,
+ };
+ }
// ── The live screen (plan T14, contracts 0.182.0) ──────────────────────────────────────────────────────
// ── The live screen (plan T14, contracts 0.182.0) ──────────────────────────────────────────────────────
/**
/**
* A new ingest key for the machine's own screen stream on OpenRestream, as its owner → { streamId, key, whipUrl }.
* A new ingest key for the machine's own screen stream on OpenRestream, as its owner → { streamId, key, whipUrl }.
@@ -934,7 +955,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -934,7 +955,9 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
async function issuePublishKey(id) {
async function issuePublishKey(id) {
const live = await db.maybe('SELECT * FROM devices WHERE id = $1 AND revoked_at IS NULL', [id]);
const live = await db.maybe('SELECT * FROM devices WHERE id = $1 AND revoked_at IS NULL', [id]);
if (!live) fail(404, 'bot.device_not_found', 'no live device');
if (!live) fail(404, 'bot.device_not_found', 'no live device');
- const robot = await getRobot(json(live.robot_ids, [])[0]);
+ const robotIds = json(live.robot_ids, []);
+ // A phone (kind 'phone') serves no robot, so there is no stream key to issue for it.
+ const robot = robotIds.length ? await getRobot(robotIds[0]) : null;
const issued = await newStreamKey(robot);
const issued = await newStreamKey(robot);
const device = await db.tx(async (t) => {
const device = await db.tx(async (t) => {
if (issued) await keepStream(t, robot.id, issued.streamId, id);
if (issued) await keepStream(t, robot.id, issued.streamId, id);
@@ -1496,6 +1519,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1496,6 +1519,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice, setSharedDesks, listSharedDesks,
machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice, setSharedDesks, listSharedDesks,
setRoles: setMachineRoles, rename: renameMachine, runningJobs, reportStatus: reportMachineStatus,
setRoles: setMachineRoles, rename: renameMachine, runningJobs, reportStatus: reportMachineStatus,
screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
screen: { key: screenKey, liveKey: liveScreenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
+ phone: { mint: mintPhone, ref: phoneRef },
deskLeases,
deskLeases,
streaming: { get: streaming, set: setStreaming },
streaming: { get: streaming, set: setStreaming },
video: { live: liveVideo },
video: { live: liveVideo },
@@ -728,6 +728,43 @@ ${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
@@ -728,6 +728,43 @@ ${error ? `<p class="error" role="alert">${esc(error)}</p>` : ''}
return page('Add a device', body);
return page('Add a device', body);
}
}
+/**
+ * /link/phone (plan 2026-11-07 §13.2): link the Android app to this account. The code is Network's one-time pairing
+ * code; the QR carries it in the app's own link, so scanning it opens OpenVibe Link with the code filled in.
+ */
+function renderPhoneLinkPage({ pairing = null, apkUrl = null } = {}) {
+ if (!apkUrl || !pairing) {
+ const body = `${topbar('<span class="crumb">Link on your phone</span>')}
+<main class="page pairing">
+<h1>Link on your phone</h1>
+<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>
+</main>`;
+ return page('Link on your phone', body, { description: 'OpenVibe Link for Android: in testing, not published yet.' });
+ }
+ const qr = pairing.qr ? qrSvg(pairing.qr, { label: 'Opens OpenVibe Link with this pairing code' }) : '';
+ const minutes = Math.max(1, Math.round((Date.parse(pairing.expires_at) - Date.now()) / 60000)) || 15;
+ const body = `${topbar('<span class="crumb">Link on your phone</span>')}
+<main class="page pairing">
+<h1>Link on your phone</h1>
+<p>OpenVibe Link puts your agents' approvals in your pocket and adds <b>Send to OpenVibe</b> to every app's share sheet.
+First <a href="${esc(apkUrl)}" rel="noopener">install it on your Android phone</a>, then scan or type the code below.</p>
+<ol class="steps">
+<li class="card phone-pair"><h2>Scan this with the phone</h2>
+<div class="qr-box">${qr}</div>
+<p class="muted">The phone's camera opens OpenVibe Link with the code filled in. No password is typed on the phone here:
+the code pairs the device, and the app asks you to sign in separately.</p></li>
+<li class="card"><h2>Or type the code in the app</h2>
+<p><strong class="code">${esc(pairing.code)}</strong></p>
+<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>
+</main>`;
+ return page('Link on your phone', body, { description: 'Pair the OpenVibe Link Android app with a one-time code or its QR code.' });
+}
+
function renderClaimPage({ claim, models = [], values = {}, error = null, now = Date.now() }) {
function renderClaimPage({ claim, models = [], values = {}, error = null, now = Date.now() }) {
const hw = claim.hardware || {};
const hw = claim.hardware || {};
const minutes = (ms) => Math.max(0, Math.round(ms / 60000));
const minutes = (ms) => Math.max(0, Math.round(ms / 60000));
@@ -782,7 +819,7 @@ ${chatUrl ? `<li class="card"><h2>Drive it from your chat</h2><p class="muted">L
@@ -782,7 +819,7 @@ ${chatUrl ? `<li class="card"><h2>Drive it from your chat</h2><p class="muted">L
* /machines (plan T14/T16): a person's own machines that run jobs for them, and the command that adds one. A machine
* /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.
* 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 }) {
+function renderMachinesPage({ machines = [], pairing = null, phoneReady = false }) {
const classesOf = (m) => {
const classesOf = (m) => {
const w = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).worker || {};
const w = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).worker || {};
return Array.isArray(w.runtime_classes) ? w.runtime_classes : [];
return Array.isArray(w.runtime_classes) ? w.runtime_classes : [];
@@ -931,6 +968,7 @@ ${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama,
@@ -931,6 +968,7 @@ ${choice('Use the models you run on it', ' --ai', 'If this computer runs Ollama,
<p class="muted"><a href="https://openvibe.network/machines">All your machines are on openvibe.network/machines</a></p>
<p class="muted"><a href="https://openvibe.network/machines">All your machines are on openvibe.network/machines</a></p>
${list}
${list}
${add}
${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>`;
</main>`;
return page('Your machines', body, { scripts: pairing ? ['/panel/panel.js'] : [], description: 'Your own machines that run your jobs on OpenVibe.', canonical: '/machines' });
return page('Your machines', body, { scripts: pairing ? ['/panel/panel.js'] : [], description: 'Your own machines that run your jobs on OpenVibe.', canonical: '/machines' });
}
}
@@ -1170,4 +1208,4 @@ function renderFirstRunPage({ rows = [] }) {
@@ -1170,4 +1208,4 @@ function renderFirstRunPage({ rows = [] }) {
return page('First run', body, { description: 'The guided first run of OpenVibe Node, measured step by step.', canonical: '/admin/first-run' });
return page('First run', body, { description: 'The guided first run of OpenVibe Node, 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, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage, camerasOf, esc };
+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 };
@@ -43,7 +43,13 @@ const { frameAncestors } = require('../config');
@@ -43,7 +43,13 @@ const { frameAncestors } = require('../config');
const { BotError, json } = require('../util');
const { BotError, json } = require('../util');
const { aiOf } = require('../machine-ai');
const { aiOf } = require('../machine-ai');
const kits = require('../kits');
const kits = require('../kits');
-const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');
+// 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.
+function linkApkUrl() {
+ const v = String(process.env.LINK_ANDROID_APK_URL || '').trim();
+ return /^https:\/\/(github\.com\/OpenVibers\/OpenVibe\.Node\/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 { createMachineScreen } = require('../machine-screen');
const { createMachineScreen } = require('../machine-screen');
const { createScreenShares } = require('../screen-shares');
const { createScreenShares } = require('../screen-shares');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
const { renderDocsIndex, renderDriversPage, renderProfilesPage, renderAutomationPage, renderComputersPage } = require('./docs');
@@ -803,13 +809,25 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -803,13 +809,25 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
r.get('/machines', frameCsp, page(async (req, res) => {
r.get('/machines', frameCsp, page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
res.setHeader('Vary', 'Cookie');
res.setHeader('Vary', 'Cookie');
- res.type('html').send(renderMachinesPage({ machines: await machineRows(me.subject) }));
+ 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).
+ r.get('/link/phone', ...manage, page(async (req, res) => {
+ const me = requireUser(req);
+ if (crossSiteOrPrefetch(req)) return res.redirect(303, '/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).
+ const apkUrl = linkApkUrl();
+ 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) => {
r.post('/machines', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
const pairing = await domain.machines.mint(me.subject);
const pairing = await domain.machines.mint(me.subject);
- res.status(201).type('html').send(renderMachinesPage({ machines: await machineRows(me.subject), pairing }));
+ 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.
// 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) => {
r.post('/machines/:id/watch-probe', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+'use strict';
+// Link on the phone (plan 2026-11-07 §13.2): a phone pairs with Network's one-time code under ref `phone:<owner>`,
+// binds on Bot as a device of kind 'phone' with no robot and its owner set, is not one of the person's machines, and
+// /link/phone shows the code and the app's own link for the QR.
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/app');
+const { renderPhoneLinkPage } = require('../server/web/render');
+
+(async () => {
+ const t = await boot({});
+ const alex = t.network.newUser('alex');
+
+ await check('a phone pairing binds a device of kind phone, owned by the person and serving no robot', async () => {
+ const principal = t.network.addNode({ owner: alex.subject, ref: `phone:${alex.subject}`, name: 'Pixel' });
+ const r = await t.call('POST', '/api/v1/devices/bind', { token: t.network.signNode(principal) });
+ assert.strictEqual(r.status, 201, r.text);
+ const d = await t.db.maybe('SELECT * FROM devices WHERE id = $1', [r.json.device_id]);
+ assert.strictEqual(d.kind, 'phone');
+ assert.deepStrictEqual(d.robot_ids, []);
+ assert.deepStrictEqual(d.drivers, []);
+ assert.strictEqual(d.owner_subject, alex.subject);
+ assert.strictEqual(d.node_principal, principal);
+ assert.ok(r.json.robot_id == null, "a phone serves no robot");
+ });
+
+ await check('a phone is not listed among the person\'s machines', async () => {
+ const worker = t.network.addNode({ owner: alex.subject, ref: `worker:${alex.subject}`, name: 'desk' });
+ await t.call('POST', '/api/v1/devices/bind', { token: t.network.signNode(worker) });
+ const list = await t.domain.machines.list(alex.subject);
+ assert.ok(list.length >= 1, 'the worker machine is there');
+ assert.ok(list.every((m) => m.kind !== 'phone'), 'the phone is not a machine');
+ });
+
+ await check('mint answers the code and the link the QR carries', async () => {
+ const p = await t.domain.phone.mint(alex.subject);
+ assert.match(p.code, /^[0-9A-Z]{4}-?[0-9A-Z]{4}$/);
+ assert.match(p.pairing_id, /^pair_/);
+ assert.strictEqual(p.qr, `openvibe-link://pair?code=${encodeURIComponent(p.code)}&pairing=${encodeURIComponent(p.pairing_id)}`, 'no server in the link: the app has its own');
+ assert.doesNotMatch(p.qr, /network=/);
+ });
+
+ await check('/link/phone is the person\'s own page, and it shows the code and the app link', async () => {
+ const guest = await t.call('GET', '/link/phone');
+ assert.ok([302, 401].includes(guest.status), `a guest got ${guest.status}`);
+ const pairing = { code: 'ABCD-1234', expires_at: new Date(Date.now() + 15 * 60000).toISOString(), qr: 'openvibe-link://pair?code=ABCD-1234&pairing=pair_01JD2C3D4E5F6G7H8J9K0MNPQR' };
+ const apkUrl = 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v0.46.0/openvibe-link.apk';
+ const html = renderPhoneLinkPage({ pairing, apkUrl });
+ assert.match(html, /ABCD-1234/);
+ assert.match(html, /qr-box/);
+ assert.match(html, /kind <b>phone<\/b>/);
+ assert.ok(html.includes(`href="${apkUrl}"`), 'the install link is the published download');
+ 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 () => {
+ 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();
+})();