openvibers/openvibe.bot Commits

Serve GET /install as a redirect and add --driver from the robot profile

OP OpenVibers committed 3 Oct 2026 · 752cda7980 · 8 files, +123 −17

Asked: T15 step 1 — OpenVibe.Bot: serve GET /install and derive --driver from the robot profile. Today the one-paste installer the owner copies (`curl -fsSL https://openvibe.bot/install | sh -s -- --robot … --code …`, server/domain/index.js installerCommand) 404s: server/web/routes.js registers only `/` a…
Harness job bot-t15-step-1-openvibe-bot-102359 (plan T15).

Changes

ddiff --git a/.env.example b/.env.example
iindex 87b3309..b1513bb 100644
---- a/.env.example
++++ b/.env.example
@@@ -62,5 +62,8 @@ OV_OAUTH_CLIENT_SECRET=
#BOT_JOBS=on
#BOT_JOBS_INTERVAL_MS=5000
#BOT_INSTALLER_URL=https://openvibe.bot/install
++# Where GET /install redirects (302): OpenVibe.Node's installer script. https on raw.githubusercontent.com,
++# github.com, objects.githubusercontent.com or openvibe.bot only; anything else refuses to boot.
++#BOT_INSTALLER_SOURCE_URL=https://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh
# The WHIP ingest base each device publishes to; whip_url = <base>/<publish key>. Unset or empty: pair without video.
#BOT_WHIP_BASE=https://example.invalid/whip
ddiff --git a/README.md b/README.md
iindex a2f6039..80b4393 100644
---- a/README.md
++++ b/README.md
@@@ -12,7 +12,7 @@ Set `BOT_PAIRING_AUTHORITY` to `bot` (the default) or `network`; any other value
Set `BOT_WHIP_BASE` in `/etc/openvibe/bot.env` to the WHIP ingest base each device publishes to (`whip_url = <base>/<publish key>`, sent once with the pairing); leave it unset to pair devices without video.
--`/install` is proxied to the app; the installer script itself ships with the T15 device-agent job.
++`/install` is proxied to the app, which answers a 302 to OpenVibe.Node's installer script (`BOT_INSTALLER_SOURCE_URL`, by default `install/install.sh` on OpenVibe.Node's `main`; https on an allow-listed GitHub or openvibe.bot host, checked at boot, and no query parameter changes the target). The pairing's installer command adds `--driver adeept|adeept-mecanum|cozmo` for those profiles and nothing for the others (the dry-run `none`).
## Deploy files
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex 0dfaafb..58745d3 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -32,11 +32,16 @@ so a reconnecting agent cannot be shadowed by a stale socket.
**Pairing authority** (`BOT_PAIRING_AUTHORITY`, `bot` by default, or `network`). With `network` the
pairing code is minted by OpenVibe.Network (Bot calls `POST /internal/node-pairings` with its own service
token, `{owner: {kind: 'user', subject}, ref: <robot id>}`) and Bot stores none; the installer command
--carries `--network <Network URL> --pairing pair_… --code XXXX-XXXX`, and the machine pairs on Network and
++carries `--network <Network URL> --pairing pair_… --code XXXX-XXXX` (plus `--driver <kind>`, as below), and the machine pairs on Network and
connects with a node token (below). The `pair` frame is then answered `error` `bot.pairing_moved` (its
`detail` names the Network URL) and the socket stays unpaired. Devices that already hold a Bot credential
keep connecting with it either way.
++**The installer command** is `curl -fsSL <BOT_INSTALLER_URL> | sh -s -- --robot rob_… --code XXXX-XXXX` (or the
++Network form above), plus `--driver adeept`, `adeept-mecanum` or `cozmo` for the `adeept.adr036`,
++`adeept.adr036.mecanum` and `cozmo` profiles; any other profile gets no `--driver` (the installer's dry-run
++`none`). `GET /install` answers a 302 to `BOT_INSTALLER_SOURCE_URL` (OpenVibe.Node's `install/install.sh`).
++
**A Network-paired machine** (a node principal `nod_…`, paired on OpenVibe.Network for one of its
owner's robots) carries `Authorization: Bearer <node token>` instead: a Network JWT with `actor_type`
`node`, `sub` `node:nod_…` and audience `openvibe.bot`. Bot binds it to a device on first use (from
ddiff --git a/server/app.js b/server/app.js
iindex e02a9ec..a15904b 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -80,7 +80,7 @@ function createApp(opts = {}) {
app.use('/api/v1', express.json({ limit: '64kb' }), (req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); }, apiAuth.middleware, v1Router({ domain, apiAuth, limits, hub }));
app.use('/auth', createSessionRoutes(config, userAuth, { fetchImpl }));
app.use(viewerMiddleware(userAuth));
-- app.use(createWebRoutes());
++ app.use(createWebRoutes(config));
app.use((req, res) => {
if (req.path.startsWith('/api/') || req.path.startsWith('/internal/')) return http.sendProblem(res, 404, 'not_found', { ctx: req.ov });
ddiff --git a/server/config.js b/server/config.js
iindex 7f6bf16..7437d99 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -15,6 +15,18 @@ const bool = (v, d = false) => (v == null || v === '' ? d : ['1', 'true', 'yes',
const trim = (u) => String(u || '').replace(/\/+$/, '');
const list = (v) => String(v || '').split(',').map((s) => s.trim()).filter(Boolean);
const PAIRING_AUTHORITIES = ['bot', 'network'];
++// Where GET /install may send the client: OpenVibe.Node's canonical script lives on GitHub (raw, a release
++// asset) or on openvibe.bot itself. Anything else refuses to boot.
++const INSTALLER_SOURCE_HOSTS = ['raw.githubusercontent.com', 'github.com', 'objects.githubusercontent.com', 'openvibe.bot'];
++
++/** BOT_INSTALLER_SOURCE_URL must be https on an allow-listed host, so /install can never become an open redirect. */
++function checkInstallerSource(sourceUrl) {
++ let u = null;
++ try { u = new URL(sourceUrl); } catch { /* reported below */ }
++ if (!u || u.protocol !== 'https:' || !INSTALLER_SOURCE_HOSTS.includes(u.hostname)) {
++ throw new Error(`BOT_INSTALLER_SOURCE_URL must be an https URL on ${INSTALLER_SOURCE_HOSTS.join(', ')}, not ${JSON.stringify(sourceUrl)}`);
++ }
++}
function loadConfig(env = process.env) {
const nodeEnv = env.NODE_ENV || 'development';
@@@ -26,6 +38,8 @@ function loadConfig(env = process.env) {
if (!PAIRING_AUTHORITIES.includes(pairingAuthority)) {
throw new Error(`BOT_PAIRING_AUTHORITY must be ${PAIRING_AUTHORITIES.join(' or ')}, not ${JSON.stringify(pairingAuthority)}`);
}
++ const installerSource = String(env.BOT_INSTALLER_SOURCE_URL || 'https://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh').trim();
++ checkInstallerSource(installerSource);
return {
nodeEnv,
isProduction,
@@@ -115,9 +129,14 @@ function loadConfig(env = process.env) {
media: {
whipBase: trim(env.BOT_WHIP_BASE || ''),
},
-- // The one-line installer the owner copies next to the pairing code (the agent job builds it).
++ // The one-line installer the owner copies next to the pairing code. scriptUrl is what the command
++ // prints; sourceUrl is where GET /install sends the client (302). Bot keeps no copy of the script:
++ // it redirects to OpenVibe.Node's canonical one, and only this config (never a query parameter)
++ // chooses the target.
installer: {
scriptUrl: trim(env.BOT_INSTALLER_URL || 'https://openvibe.bot/install'),
++ sourceUrl: installerSource,
++ sourceHosts: INSTALLER_SOURCE_HOSTS,
},
};
}
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 687874a..b0c762a 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -48,6 +48,11 @@ function newCode() {
const formatCode = (s) => `${s.slice(0, 4)}-${s.slice(4)}`;
const isCodeShape = (s) => /^[0-9A-HJKMNP-TV-Z]{8}$/.test(s);
++// The installer driver a robot's profile needs (OpenVibe.Node install/install.sh --driver). Simulated,
++// camera and unknown profiles get `none`, the dry-run plugin, which is also the installer's default.
++const DRIVER_BY_PROFILE = Object.freeze({ 'adeept.adr036': 'adeept', 'adeept.adr036.mecanum': 'adeept-mecanum', cozmo: 'cozmo' });
++const driverForProfile = (profileId) => (Object.hasOwn(DRIVER_BY_PROFILE, profileId) ? DRIVER_BY_PROFILE[profileId] : 'none');
++
const clampNum = (v, lo, hi, d = 0) => { const n = Number(v); return Number.isFinite(n) ? Math.min(hi, Math.max(lo, n)) : d; };
// Scientific pitch: 'A4' is 440 Hz; a sharp (#) or flat (b) after the letter.
@@@ -116,7 +121,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
const id = prefixedId('rob', now());
const at = iso(now());
// A Network-minted code comes first, so a Network that does not answer leaves no robot behind.
-- const minted = networkPairing() ? await mintOnNetwork(id, owner, installerUrl) : null;
++ const minted = networkPairing() ? await mintOnNetwork(id, owner, installerUrl, driverForProfile(profile.id)) : null;
const created = await db.tx(async (t) => {
await t.query(`INSERT INTO robots (id, owner_subject, name, profile_id, profile_version, access_policy, limits, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $8)`,
@@@ -124,7 +129,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
await t.query(`INSERT INTO robot_operators (robot_id, subject, role, added_by, created_at) VALUES ($1, $2, 'owner', $2, $3)`, [id, owner, at]);
return t.maybe('SELECT * FROM robots WHERE id = $1', [id]);
});
-- const pairing = minted || await createPairingCode(id, owner, installerUrl);
++ const pairing = minted || await createPairingCode(id, owner, installerUrl, created);
return { robot: created, pairing };
}
function cleanLimits(limits = {}) {
@@@ -158,8 +163,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
// ── Pairing (ADR-043 decision 2) ──────────────────────────────────────────────────────────────
const networkPairing = () => config.pairing.authority === 'network';
-- async function createPairingCode(robotId, createdBy, installerUrl) {
-- if (networkPairing()) return mintOnNetwork(robotId, (await getRobot(robotId)).owner_subject, installerUrl);
++ async function createPairingCode(robotId, createdBy, installerUrl, robotRow = null) {
++ const robot = robotRow || await getRobot(robotId);
++ const driver = driverForProfile(robot && robot.profile_id);
++ if (networkPairing()) return mintOnNetwork(robotId, robot.owner_subject, installerUrl, driver);
const code = newCode();
const at = iso(now());
const expires = iso(now() + config.pairing.ttlMs);
@@@ -169,25 +176,27 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
await t.query('INSERT INTO pairing_codes (id, robot_id, code_hash, created_by, expires_at, created_at) VALUES ($1, $2, $3, $4, $5, $6)',
[prefixedId('pair', now()), robotId, hashSecret(code), createdBy || null, expires, at]);
});
-- return { code: formatCode(code), expires_at: expires, installer: installerCommand(robotId, formatCode(code), installerUrl) };
++ return { code: formatCode(code), expires_at: expires, installer: installerCommand(robotId, formatCode(code), installerUrl, driver) };
}
-- function installerCommand(robotId, code, installerUrl) {
++ /** ` --driver <kind>` for a profile that needs a real driver; nothing for `none` (the installer's default). */
++ const driverFlag = (driver) => (driver && driver !== 'none' ? ` --driver ${driver}` : '');
++ function installerCommand(robotId, code, installerUrl, driver = 'none') {
const url = installerUrl || config.installer.scriptUrl;
-- return `curl -fsSL ${url} | sh -s -- --robot ${robotId} --code ${code}`;
++ return `curl -fsSL ${url} | sh -s -- --robot ${robotId} --code ${code}${driverFlag(driver)}`;
}
/**
* BOT_PAIRING_AUTHORITY=network (plan T15 B2): Network mints the code for the robot's owner (POST
* /internal/node-pairings, ref = the robot) and Bot stores none. The machine redeems it on Network and
* reaches Bot with a node token (bindNode). A Network that does not answer is 503 bot.network_unavailable.
*/
-- async function mintOnNetwork(robotId, ownerSubject, installerUrl) {
++ async function mintOnNetwork(robotId, ownerSubject, installerUrl, driver = 'none') {
if (!nodes) fail(503, 'bot.network_unavailable', 'Network pairing is not configured here');
const p = await nodes.pair({ subject: ownerSubject, ref: robotId });
if (!p || typeof p.pairing_id !== 'string' || typeof p.code !== 'string') fail(503, 'bot.network_unavailable', 'Network answered no pairing code');
const url = installerUrl || config.installer.scriptUrl;
return {
code: p.code, expires_at: p.expires_at, pairing_id: p.pairing_id,
-- installer: `curl -fsSL ${url} | sh -s -- --network ${config.network.url} --pairing ${p.pairing_id} --code ${p.code}`,
++ installer: `curl -fsSL ${url} | sh -s -- --network ${config.network.url} --pairing ${p.pairing_id} --code ${p.code}${driverFlag(driver)}`,
};
}
/**
@@@ -672,7 +681,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
present: { robot: presentRobot, device: presentDevice },
robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, remove: removeRobot },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
-- pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, whipUrl },
++ pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
devices: { byCredential, bindNode, issuePublishKey, updateDeclared, revokeNode, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, touchSeen, setOnline },
estop: { set: setEstop, clear: clearEstop },
queue: { join: joinQueue, state: queueState, currentTurn, consume: consumeTurn, sweep: sweepQueues },
@@@ -681,4 +690,4 @@ function createDomain({ db, config, outbox, link = null, nodes = null, now = ()
};
}
--module.exports = { createDomain, DEFAULT_ALLOW, KINDS, normaliseCode, formatCode, newCode, isCodeShape, noteToHz };
++module.exports = { createDomain, DEFAULT_ALLOW, DRIVER_BY_PROFILE, driverForProfile, KINDS, normaliseCode, formatCode, newCode, isCodeShape, noteToHz };
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex b03d71f..a5af8e0 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -2,15 +2,18 @@
/**
* The non-API surface: a one-line text placeholder at `/` for the health of the process (the panel and
-- * the site are designed separately), and nothing else. No pages, no CSS, no copy beyond this.
++ * the site are designed separately), and `/install`, a 302 to OpenVibe.Node's installer script (the
++ * one-paste command, ADR-043). No pages, no CSS, no copy beyond this.
*/
const express = require('express');
const VERSION = require('../../package.json').version;
--function createWebRoutes() {
++function createWebRoutes(config) {
const r = express.Router();
r.get('/', (req, res) => res.type('text/plain').send(`OpenVibe.Bot ${VERSION} — ok (devices, pairing and control; API under /api/v1)\n`));
r.get('/robots.txt', (req, res) => res.type('text/plain').send('User-agent: *\nDisallow: /\n'));
++ // `curl -fsSL` follows the redirect. The target is config only (checked at boot): no query parameter steers it.
++ r.get('/install', (req, res) => res.redirect(302, config.installer.sourceUrl));
return r;
}
ddiff --git a/test/install.test.js b/test/install.test.js
nnew file mode 100644
iindex 0000000..cf4ca2b
---- /dev/null
++++ b/test/install.test.js
@@@ -0,0 +1,67 @@
++'use strict';
++// GET /install (plan T15 step 1): a 302 to OpenVibe.Node's installer script, chosen by config only and checked at
++// boot (https, allow-listed host). The pairing's installer command carries `--driver <kind>` from the profile,
++// and nothing for a profile whose driver is `none`.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++const { loadConfig } = require('../server/config');
++const { driverForProfile } = require('../server/domain');
++
++(async () => {
++ const NODE_INSTALLER = 'https://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh';
++
++ await check('BOT_INSTALLER_SOURCE_URL: Node\'s script by default, https on an allow-listed host or no boot', async () => {
++ assert.strictEqual(loadConfig({}).installer.sourceUrl, NODE_INSTALLER);
++ const release = 'https://github.com/OpenVibers/OpenVibe.Node/releases/latest/download/install.sh';
++ assert.strictEqual(loadConfig({ BOT_INSTALLER_SOURCE_URL: release }).installer.sourceUrl, release);
++ for (const bad of ['http://raw.githubusercontent.com/OpenVibers/OpenVibe.Node/main/install/install.sh', 'https://evil.test/install.sh',
++ 'https://raw.githubusercontent.com.evil.test/x.sh', 'javascript:alert(1)', 'not a url']) {
++ assert.throws(() => loadConfig({ BOT_INSTALLER_SOURCE_URL: bad }), /BOT_INSTALLER_SOURCE_URL must be an https URL on raw\.githubusercontent\.com/, bad);
++ }
++ });
++
++ await check('driverForProfile: adeept, adeept-mecanum, cozmo, else none', async () => {
++ assert.strictEqual(driverForProfile('adeept.adr036'), 'adeept');
++ assert.strictEqual(driverForProfile('adeept.adr036.mecanum'), 'adeept-mecanum');
++ assert.strictEqual(driverForProfile('cozmo'), 'cozmo');
++ for (const id of ['sim.rover', 'camera.onvif', 'toString', undefined]) assert.strictEqual(driverForProfile(id), 'none', String(id));
++ });
++
++ const source = 'https://github.com/OpenVibers/OpenVibe.Node/releases/download/v1.2.3/install.sh';
++ const t = await boot({ env: { BOT_INSTALLER_SOURCE_URL: source } });
++ const alex = t.network.newUser('alex');
++
++ await check('GET /install → 302 to the configured source; no query parameter changes the target', async () => {
++ for (const q of ['', '?url=https://evil.test/x.sh', '?to=https://evil.test/x.sh', '?url=//evil.test&to=/x&next=https://evil.test']) {
++ const res = await fetch(`${t.base}/install${q}`, { redirect: 'manual' });
++ assert.strictEqual(res.status, 302, q);
++ assert.strictEqual(res.headers.get('location'), source, q);
++ }
++ });
++
++ await check('the installer command ends with --driver for an adeept, mecanum or cozmo robot, and has none for sim.rover', async () => {
++ for (const [profile_id, driver] of [['adeept.adr036', 'adeept'], ['adeept.adr036.mecanum', 'adeept-mecanum'], ['cozmo', 'cozmo']]) {
++ const { robot, pairing } = await t.robot(alex, { profile_id });
++ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --robot ${robot.id} --code ${pairing.code} --driver ${driver}`);
++ const again = await t.call('POST', `/api/v1/robots/${robot.id}/pairing-code`, { user: alex });
++ assert.strictEqual(again.status, 201, again.text);
++ assert.match(again.json.installer, new RegExp(`--robot ${robot.id} --code ${again.json.code} --driver ${driver}$`));
++ }
++ const { robot, pairing } = await t.robot(alex, { profile_id: 'sim.rover' });
++ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --robot ${robot.id} --code ${pairing.code}`);
++ });
++ await t.close();
++
++ const n = await boot({ env: { BOT_PAIRING_AUTHORITY: 'network' } });
++ await check('network authority: the Network form gains --driver too, on create and on a new code', async () => {
++ const sam = n.network.newUser('sam');
++ const { robot, pairing } = await n.robot(sam, { profile_id: 'cozmo' });
++ const asked = n.network.pairings.at(-1);
++ assert.strictEqual(pairing.installer, `curl -fsSL https://openvibe.bot/install | sh -s -- --network ${n.network.url} --pairing ${asked.pairing_id} --code ${asked.code} --driver cozmo`);
++ const again = await n.call('POST', `/api/v1/robots/${robot.id}/pairing-code`, { user: sam });
++ assert.strictEqual(again.status, 201, again.text);
++ assert.match(again.json.installer, / --code \S+ --driver cozmo$/);
++ });
++ await n.close();
++ done();
++})();