openvibers/openvibe.bot Commits

Bot: the owner's OpenVibe.Actor agent may drive their robot (a per-robot switch; steps and commands as the owner, through the same gate) (#94)

OP OpenVibers committed 14 Oct 2026 · c03a82598c · 10 files, +274 −48

Plan T15 says "an Actor agent can pilot a robot under the owner's grant, always through Bot". This PR is the Bot side: the owner opts in per robot, and their OpenVibe.Actor agent then reads the robot and drives it as that owner, through the same gate as everyone else.

**The switch**
- The owner's panel has a new card, **Your agent**: "Let my OpenVibe.Actor agent drive this robot". It is off for every robot until the owner turns it on.
- The card posts to `POST /robots/:id/agent`: owner only, same-site, `on` or `off`.
- Stored as `robots.agent_control` (migration `0012_robot_agent_control`, expand, default false). Robot reads carry `agent_control`.

**What the agent may do** (`svc:actor` with `X-OV-Subject` naming the robot's owner)
- `GET /api/v1/robots/:id/steps` (`bot.robot.read`) answers the robot's name, model, online state, e-stop, battery and sensors, plus one example line per step word this robot takes (`stepHelp`, which moved from render.js into routines.js).
- `POST /api/v1/robots/:id/steps` (`bot.robot.control`) takes routine step lines (`forward 1`, `left 0.5`, `say Hi`, `headlights #00ff00`, …).
  - Limits: at most 20 steps and 30 seconds, waits included.
  - The steps run now, and the call answers when they end: `done`, or `stopped: <why>` (the e-stop, the device going offline, a cooldown).
  - Each step passes `hub.operatorCommand` as the owner and is audited as the service. Held steps are re-sent inside the deadman window, the same runner routines use (`execute`, refactored out of `run`).
  - `409 bot.routine_busy` while a routine runs.
- `POST /api/v1/robots/:id/commands` holds `svc:actor` to the same switch and owner.
- Anything else answers `403 bot.agent_not_allowed`: the switch off, a subject other than the owner (even an invited operator). Any other service on `/steps` gets `403 bot.forbidden`.
- The owner's own user token may use `/steps` too. Another person gets `403 bot.forbidden`.

**Tests** (new `test/agent-control.test.js`)
- Refused before the switch.
- The panel card; only the owner; same-site; on or off.
- State and step words.
- Steps run as the owner with the audit naming the service; one command over `/commands`.
- Not for an operator, another service or a read-only token.
- The wrong line named; the 20-step and 30-second bounds.
- The e-stop stops it with its reason; switching off refuses again.
- The full suite passes: 38/38.

Network grants Actor `bot.robot.read` and `bot.robot.control` for `openvibe.bot` in OpenVibe.Network#175. Actor's tools come in its own PR.

Changes

ddiff --git a/README.md b/README.md
iindex f0ddec2..761af3d 100644
---- a/README.md
++++ b/README.md
@@@ -17,6 +17,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
- The signed-in pages (`server/web/routes.js`): `/robots` to list and add robots, `/pair/:id` for a pairing code and the installer command, and `/panel/:id`, the panel rendered from the profile. A `sim.rover` robot is driven by an in-process simulator (`server/sim`), so the panel works before any hardware exists. It gets no jobs, and it is not counted as a device or reported online in the outbox. The rover lives in a small arena (`public/sim-world.js`, shared by the server and the browser): walls and crates stop it, its telemetry carries a range finder (`distance_cm`), three line sensors over a strip of tape and its pose, and its panel draws the camera from that pose (`public/sim-view.js`) with a map of the arena in the corner.
- The robot directory (`server/web/directory.js`, inside the OpenVibe Frame): `/robots` is public and lists the robots their owners list (online state, model, owner, who may drive), every model Bot drives and, signed in, your own robots; `/models` and `/models/:id` say how each model connects, its setup steps, its guide and the controls its panel gets; `/r/:id` is a listed robot's public page, watching it live through the embeddable panel; `/robots/new` adds one (name, what it is, who may drive). The owner lists a robot from its panel (`POST /robots/:id/listing`, migration 0009): listing turns on watching (`embed_public`), and turning watching off unlists it.
- Routines (`server/routines.js`): an owner writes a robot's steps one per line (`forward 2`, `right 0.5`, `wait 1`, `say Hello`, `face happy`, `headlights #ff8800`, `horn A4 0.3`, `press horn`, `pan 0.5 1`, … only what its profile has) and runs them on demand, every N minutes, daily at a UTC time on chosen weekdays, or each time the robot comes online. Every step passes the same gate as a person's command, as the owner (limits, cooldowns, e-stop, audit); held steps are re-sent inside the deadman window and released at the end; anything refused stops the routine with its reason. One routine runs per robot at a time. The owner's panel card and `GET|POST /api/v1/robots/:id/routines`, `POST …/routines/:rid/run`, `DELETE …/routines/:rid`.
++- The owner's agent: the panel's **Your agent** card ("Let my OpenVibe.Actor agent drive this robot", `robots.agent_control`, migration 0012) lets `svc:actor`, acting for the robot's owner and nobody else, read the robot (`GET /api/v1/robots/:id/steps`: online, the e-stop, battery, sensors and the step lines it takes) and drive it with plain step lines (`POST …/steps`, at most 20 steps and 30 seconds, answered when they end) or one command (`POST …/commands`). Every step passes the same gate as the owner's own commands and is audited as the service. Off, or for anyone but the owner, it answers `403 bot.agent_not_allowed`.
- Webhooks (`server/webhooks.js`, `server/net/webhook-post.js`): up to five https endpoints per robot for its bot.* events, each with its own `whsec_` secret shown once. Deliveries are written in the transaction that emits the event, signed like OpenVibe.Events deliveries (`X-OpenVibe-Signature-V2`), posted only to public addresses with no redirects, and retried after 30 s, 2, 10, 30 and 120 minutes. The owner's panel card and `GET|POST /api/v1/robots/:id/webhooks`, `DELETE …/webhooks/:hid`.
- Community robots (`server/submissions.js`): anyone signed in submits a profile (id `community.<name>`, driver `plugin` for their own OpenVibe Node plugin, named in `mapping.plugin`, Contracts 0.160.0) with the driver's source, a setup guide and a licence at `/robots/submit`; `/robots/submissions` shows where each stands, and an OpenVibe admin approves or rejects (with a note) there. Approving signs the profile (Ed25519 over its canonical JSON, `BOT_PROFILE_SIGNING_KEY`) and puts it in the catalogue: `/models` "From the community", `/robots/new`, `GET /api/v1/profiles/:id` with its `signature`, and Bot's key at `GET /api/v1/profiles/signing-key`. Account deletion removes a person's unapproved submissions and keeps approved ones authorless.
- A phone as the robot (`phone.camera`, driver `phone`): the pairing page shows a QR code (`server/web/qr.js`) that opens `/device/phone` with the robot and code in the URL fragment; the phone pairs over `POST /api/v1/pair` and its browser is the device (`public/phone-device.js`): `/device` with the credential offered as the subprotocol `ov.device.<credential>` (a browser cannot set `Authorization`), its camera published over WHIP to OpenRestream, `say` through speech synthesis, `display` faces and text full-screen, a torch and a chime, battery and tilt telemetry, and a Stop button that latches a local stop. Nothing to install.
ddiff --git a/STATUS.json b/STATUS.json
iindex 77d790b..e472971 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -48,6 +48,7 @@
"community robots (server/submissions.js, migration 0010): submit at /robots/submit (id community.<name>, driver plugin + mapping.plugin, Contracts 0.160.0, https driver and guide links, licence), status at /robots/submissions, admin review there (approve signs Ed25519 over canonical JSON with BOT_PROFILE_SIGNING_KEY and inserts robot_profiles; reject needs a note); GET /api/v1/profiles/:id carries the signature, GET /api/v1/profiles/signing-key the public key; /models lists them under From the community with credits; in the ADR-033 export, and deletion drops unapproved ones and keeps approved ones authorless",
"routines (server/routines.js, migration 0011): steps in plain lines parsed against the profile, run on demand, every N minutes (>= 5), daily at a UTC time on chosen days, or on bot.robot.online; each step through hub.operatorCommand as the owner, held steps re-sent within the deadman and released, a refusal stops it with its reason, one per robot at a time; panel card + /api/v1/robots/:id/routines",
"webhooks (server/webhooks.js, migration 0011): up to 5 https endpoints per robot for bot.robot.online/offline, bot.estop.set/cleared, bot.command.refused; whsec_ secret shown once; deliveries written in the emitting transaction, signed X-OpenVibe-Signature-V2 (openvibe-sdk/events), posted through the public-only poster with no redirects, retried 30 s/2/10/30/120 min then failed; panel card + /api/v1/robots/:id/webhooks",
++ "the owner's agent (migration 0012 robots.agent_control): panel 'Your agent' switch; svc:actor acting for the owner only reads GET /api/v1/robots/:id/steps (state + step words) and runs POST …/steps (<= 20 steps, <= 30 s, through hub.operatorCommand, audited as the service) or POST …/commands; otherwise 403 bot.agent_not_allowed",
"the panel's People card: the owner adds an operator or viewer by @username (OpenVibe.Network resolves the name to a subject) and removes them again, shown with Network's names; a signed-in visitor on an open-queue robot sees their turn or their place in line and can leave the queue, promoting the next person (POST /robots/:id/operators and …/operators/:subject/remove, POST /robots/:id/queue/leave)",
"panel input: a touch joystick, the keyboard and a gamepad (Gamepad API), each held control re-sent while held and zeroed at once on release; a latency meter (round-trip median and telemetry age); one camera tile per camera the profile lists, playing the robot's live OpenRestream WebRTC session (the Adeept and Cozmo profiles carry a camera widget since 2026-10-13); a readout shows the sensor keys the Node's drivers report (distance_cm, line, cliff and picked_up); a live online indicator on /pair/:id read from /control",
"platform.job@1 jobs dispatched to a paired Node over /device (platform.job-frame@1: resent unacked on reconnect, only to an advertised runtime class) and metered per second into run_usage_outbox for billing.usage.record",
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex 27f4ee6..7104e6a 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -475,6 +475,8 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
| `POST /robots/:id/estop` | owner/operator, `bot.robot.control` | `{ robot }` |
| `POST /robots/:id/estop/clear` | **owner only**, `bot.robot.control` | `{ robot }` |
| `POST /robots/:id/commands` | owner/operator, `bot.robot.control` | `{ robot_id, id, result, code?, reason?, latency_ms?, cached? }` — one command through the same gate, audit and per-subject `id` idempotency key as `/control` (a bound channel's chat forwards here instead of a hardware socket). `result` is `ack`, `nack` or `expired` (a repeated `id` answers `cached: true`); a gate refusal is its code as an RFC 9457 problem: `403 bot.not_an_operator` / `bot.read_only` / `bot.not_your_turn`, `404 bot.robot_not_found`, `409 bot.device_offline` / `bot.estop_latched` / `bot.command_pending` (the same `id` is still in flight), `422` the command-shape codes, `429 bot.cooldown` / `bot.turn_budget`. A service acting for a subject is rate-limited as that person (`user:<subject>`) and also, as a service, at 1200 a minute and 12000 an hour on its own token (`bot.control.service`), so naming a fresh well-formed subject on every request never lifts every ceiling |
++| `GET /robots/:id/steps` | the owner, or `svc:actor` with `bot.robot.read` acting for the owner of a robot with `agent_control` on | `{ robot_id, name, profile_id, online, estop, battery, sensors, steps[], max_steps, max_seconds }`: the robot's state and one example line per step word it takes (server/routines.js `stepHelp`) |
++| `POST /robots/:id/steps` | the owner, or `svc:actor` with `bot.robot.control` acting for the owner of a robot with `agent_control` on | body `{ steps: "forward 1\nsay Hi" }` (routine step lines, at most 20 steps and 30 seconds, waits included) → `{ robot_id, result, steps }` once they end; `result` is `done` or `stopped: <why>` (the e-stop, the device going offline, a cooldown). Each step passes the gate as the owner and is audited as the caller. `403 bot.agent_not_allowed` while the owner's switch is off or for anyone but the owner, `403 bot.forbidden` for any other service, `409 bot.routine_busy` while a routine runs, `422 bot.invalid_routine` naming the wrong line. `POST /robots/:id/commands` holds `svc:actor` to the same switch and owner |
| `GET /robots/:id/audit?limit=&before=` | owner, or `bot.robot.read` | `{ audit[], next_before }` (newest first) |
| `GET /robots/:id/streaming` | a member, or `bot.robot.read` | `{ available, media{on}, live{on,effective}, stream_id }` (plus `reason` when unavailable), read from the robot's OpenRestream stream; `available:false` is `not_configured` (OpenRestream unset), `not_paired` (no stream yet) or `stream_missing` (OpenRestream answered 404) |
| `POST /robots/:id/streaming` | **the owner only**, or `bot.robot.manage` | body `{ to: "media"\|"live", on: bool }` → the new state (as `GET`). `media` sets the stream's `recording_mode` `vod`/`none` (record to OpenVibe.Media); `live` sets `mirror_to_live` (show on the owner's OpenVibe.Live channel). One audit row per real change (`streaming.media`/`streaming.live`); an idempotent repeat answers the state and writes none. `422 bot.invalid_streaming`; `409 bot.not_paired` / `bot.openre_not_configured` |
ddiff --git a/migrations/0012_robot_agent_control.sql b/migrations/0012_robot_agent_control.sql
nnew file mode 100644
iindex 0000000..dfc37b2
---- /dev/null
++++ b/migrations/0012_robot_agent_control.sql
@@@ -0,0 +1,8 @@
++-- phase: expand
++--
++-- The owner's agent (plan T15 "an Actor agent can pilot a robot under the owner's grant", always through Bot): with
++-- agent_control on, the owner's OpenVibe.Actor agent may send this robot commands as the owner (Actor's service token,
++-- grant bot.robot.control, X-OV-Subject = the owner). Bot's gate, limits and e-stop still decide every command; the
++-- device's deadman still stops it. Off for every robot until its owner turns it on. Additive only.
++
++ALTER TABLE robots ADD COLUMN agent_control boolean NOT NULL DEFAULT false;
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 0b653c7..633ffce 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -35,6 +35,7 @@
* With BOT_PAIRING_AUTHORITY=network the pairing codes are Network's and POST /pair answers 410 bot.pairing_moved.
*/
const express = require('express');
++const { parseSteps, stepHelp, AGENT_MAX_STEPS, AGENT_MAX_S } = require('../routines');
const { http } = require('openvibe-contracts');
const { fail, userSubject, isRobotId, json } = require('../util');
const { getProfile, listProfiles, checkProfile } = require('../profiles');
@@@ -49,6 +50,9 @@ const CAP = {
dispatch: 'bot.job.dispatch',
};
++/** Services that act as a person's agent (plan T15/T17): they drive a robot only where its owner turned agent_control on. */
++const AGENT_SERVICES = new Set(['svc:actor']);
++
function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null }) {
const r = express.Router();
r.use(limits.reads('bot.read'));
@@@ -345,6 +349,13 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
r.post('/robots/:id/commands', commandControl, wrap(async (req, res) => {
requireControlCap(req);
const subject = actedFor(req);
++ // The owner's agent (OpenVibe.Actor) drives only a robot its owner opened to it, and only as that owner.
++ if (req.principal.kind === 'service' && AGENT_SERVICES.has(req.principal.sub)) {
++ const robot = await domain.robots.get(req.params.id);
++ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
++ if (!robot.agent_control) fail(403, 'bot.agent_not_allowed', 'the owner has not let their agent drive this robot');
++ if (subject !== robot.owner_subject) fail(403, 'bot.agent_not_allowed', 'an agent drives a robot only for its owner');
++ }
const principal = req.principal.kind === 'service' ? { ...req.principal, subject } : req.principal;
const result = await hub.operatorCommand(req.params.id, principal, subject, {
id: req.body?.id, kind: req.body?.kind, value: req.body?.value, ms: req.body?.ms,
@@@ -353,6 +364,43 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.json({ robot_id: req.params.id, ...result });
}));
++ // ── Steps (the owner's agent): plain step lines, run now as the owner and answered when they end ──
++ // The owner themself, or an agent service (OpenVibe.Actor) acting for the owner of a robot with agent_control on.
++ // Every step passes the same gate as a person's command (server/routines.js runSteps).
++ async function agentRobot(req) {
++ const subject = req.principal.kind === 'service' ? actedFor(req) : me(req);
++ const robot = await domain.robots.get(req.params.id);
++ if (!robot) fail(404, 'bot.robot_not_found', 'no such robot');
++ if (req.principal.kind === 'service') {
++ if (!AGENT_SERVICES.has(req.principal.sub)) fail(403, 'bot.forbidden', 'steps are for the owner and their agent');
++ if (!robot.agent_control) fail(403, 'bot.agent_not_allowed', 'the owner has not let their agent drive this robot');
++ }
++ if (subject !== robot.owner_subject) fail(403, req.principal.kind === 'service' ? 'bot.agent_not_allowed' : 'bot.forbidden', 'steps run only for the robot\'s owner');
++ const profile = await getProfile(domain.db, robot.profile_id, robot.profile_version);
++ if (!profile) fail(409, 'bot.profile_missing', 'the robot\'s profile is gone');
++ return { robot, subject, profile: profile.profile };
++ }
++ r.get('/robots/:id/steps', wrap(async (req, res) => {
++ needAutomation();
++ requireRead(req);
++ const { robot, profile } = await agentRobot(req);
++ const st = await hub.robotState(robot.id);
++ const sensors = st.telemetry && st.telemetry.sensors && typeof st.telemetry.sensors === 'object' ? st.telemetry.sensors : {};
++ res.json({
++ robot_id: robot.id, name: robot.name, profile_id: robot.profile_id, online: st.online, estop: st.estop, battery: st.battery, sensors,
++ steps: stepHelp(profile), max_steps: AGENT_MAX_STEPS, max_seconds: AGENT_MAX_S,
++ });
++ }));
++ r.post('/robots/:id/steps', commandControl, wrap(async (req, res) => {
++ needAutomation();
++ requireControlCap(req);
++ const { robot, subject, profile } = await agentRobot(req);
++ const { steps } = parseSteps(req.body?.steps, profile);
++ const principal = req.principal.kind === 'service' ? { ...req.principal, subject } : req.principal;
++ const result = await routines.runSteps(robot, steps, { principal, subject });
++ res.json({ robot_id: robot.id, result, steps: steps.length });
++ }));
++
// ── Audit ─────────────────────────────────────────────────────────────────────────────────────
r.get('/robots/:id/audit', wrap(async (req, res) => {
await ownerOrRead(req, req.params.id);
@@@ -423,4 +471,4 @@ function auditPresent(a) {
return { id: a.id, robot_id: a.robot_id, device_id: a.device_id, operator_subject: a.operator_subject, operator_kind: a.operator_kind, role: a.role, kind: a.kind, value: a.value, result: a.result, reason: a.reason, latency_ms: a.latency_ms, at: a.at };
}
--module.exports = { v1Router, CAP };
++module.exports = { v1Router, CAP, AGENT_SERVICES };
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex c3fbe5e..d8eca19 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -69,7 +69,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
// ── Presenters ────────────────────────────────────────────────────────────────────────────────
const presentRobot = (r) => (r ? {
id: r.id, name: r.name, profile_id: r.profile_id, profile_version: r.profile_version,
-- access_policy: r.access_policy, limits: json(r.limits, {}),
++ access_policy: r.access_policy, limits: json(r.limits, {}), agent_control: !!r.agent_control,
estop: { latched: !!r.estop_latched, by: r.estop_by || null, at: r.estop_at ? iso(new Date(r.estop_at).getTime()) : null },
created_at: iso(new Date(r.created_at).getTime()), updated_at: iso(new Date(r.updated_at).getTime()),
} : null);
@@@ -254,6 +254,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
* anyone (embed_public), since a directory entry nobody can watch is no entry; unlisting leaves embedding as it was.
* The owner check is the caller's.
*/
++ /** Let the owner's OpenVibe.Actor agent drive the robot as the owner, or stop it (the owner check is the caller's). */
++ async function setAgentControl(id, value) {
++ await db.query('UPDATE robots SET agent_control = $2, updated_at = $3 WHERE id = $1', [id, !!value, iso(now())]);
++ return getRobot(id);
++ }
async function setListed(id, value) {
if (value) await db.query('UPDATE robots SET listed = true, listed_at = COALESCE(listed_at, $2), embed_public = true, updated_at = $2 WHERE id = $1', [id, iso(now())]);
else await db.query('UPDATE robots SET listed = false, listed_at = NULL, updated_at = $2 WHERE id = $1', [id, iso(now())]);
@@@ -1018,7 +1023,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
return {
db, now, config, log, outbox, link,
present: { robot: presentRobot, device: presentDevice, video: presentVideo },
-- robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, setListed, listListed, remove: removeRobot,
++ robots: { create: createRobot, list: listRobots, get: getRobot, update: updateRobot, setEmbedPublic, setListed, listListed, setAgentControl, remove: removeRobot,
localProfile, saveLocalProfile, useCatalogueProfile },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
ddiff --git a/server/routines.js b/server/routines.js
iindex 4b47259..1e04aea 100644
---- a/server/routines.js
++++ b/server/routines.js
@@@ -10,9 +10,12 @@
* wait <seconds> say <text> face <name> show <text> press <button>
* <actuator> <value> [seconds] (number, #rrggbb or off, a note like A4 or off, on/off; held for the seconds)
* pan|tilt|zoom <value> [seconds]
++ * stepHelp(profile) → one example line per step word this robot takes
* parseTrigger({ type, every, at, days }) → { type: 'manual' } | { type: 'schedule', every?|at+days } | { type: 'online' }
* nextRun(trigger, from) → Date | null
-- * createRoutines({ db, domain, hub, now, log }) → { list, get, create, remove, run, tick, onOnline, stop }
++ * createRoutines({ db, domain, hub, now, log }) → { list, get, create, remove, run, runSteps, tick, onOnline, stop }
++ * runSteps(robot, steps, { principal, subject }) runs steps now and resolves with how it ended (the owner's agent,
++ * POST /api/v1/robots/:id/steps); at most AGENT_MAX_STEPS steps and AGENT_MAX_S seconds, nothing stored.
*
* A held step (a motion or a tone) is re-sent every HOLD_MS so the device's deadman never stops it early, and a zero
* drive ends it. A refused or failed command stops the routine there (an e-stop, the robot going offline, a cooldown
@@@ -26,6 +29,8 @@ const MAX_STEP_S = 60;
const MAX_TOTAL_S = 600;
const MIN_EVERY_MIN = 5;
const TICK_MS = 30000;
++const AGENT_MAX_STEPS = 20;
++const AGENT_MAX_S = 30;
const fail = (detail) => { throw new BotError(422, 'bot.invalid_routine', detail); };
const num = (s, what, lo, hi) => {
@@@ -127,6 +132,23 @@ function parseSteps(source, profile) {
}
const TIME = /^([01]\d|2[0-3]):([0-5]\d)$/;
++/** The step words this robot takes (server/routines.js parseSteps), for the routine form's help line and the agent's view. */
++function stepHelp(profile) {
++ const c = profile.commands || {};
++ const words = [];
++ if (c.drive) words.push('forward 2', 'back 1', 'left 0.5', 'right 0.5', 'stop');
++ words.push('wait 1');
++ if (c.say) words.push('say Hello');
++ if (c.display && c.display.faces) words.push(`face ${c.display.faces[0]}`);
++ if (c.display && c.display.modes.includes('text')) words.push('show Hi');
++ if (c.ptz) words.push(...Object.keys(c.ptz.axes).map((a) => `${a} 0.5 1`));
++ for (const [name, a] of Object.entries((c.actuator && c.actuator.names) || {})) {
++ words.push(a.type === 'rgb' ? `${name} #ff8800` : a.type === 'tone' ? `${name} A4 0.3` : a.type === 'bool' ? `${name} on` : `${name} 0.5`);
++ }
++ for (const name of Object.keys((c.button && c.button.names) || {})) words.push(`press ${name}`);
++ return words;
++}
++
function parseTrigger(t = {}) {
const type = String(t.type || 'manual');
if (type === 'manual' || type === 'online') return { type };
@@@ -193,14 +215,40 @@ function createRoutines({ db, domain, hub, now = () => Date.now(), log = console
await db.query('UPDATE robot_routines SET last_result = $2, updated_at = $3 WHERE id = $1', [id, String(result).slice(0, 200), iso(now())]);
}
-- /** Send one command as the owner; resolves true when the device ran it, else false with the reason kept. */
-- async function send(robot, step, value, ms) {
-- const principal = { kind: 'user', subject: robot.owner_subject, role: 'user', via: 'routine' };
-- const r = await hub.operatorCommand(robot.id, principal, robot.owner_subject, { id: prefixedId('rcmd', now()), kind: step.kind, value, ms });
++ const asOwner = (robot) => ({ principal: { kind: 'user', subject: robot.owner_subject, role: 'user', via: 'routine' }, subject: robot.owner_subject });
++
++ /** Send one command as `who` (the owner for a routine); resolves ok when the device ran it, else the reason. */
++ async function send(robot, step, value, ms, who) {
++ const r = await hub.operatorCommand(robot.id, who.principal, who.subject, { id: prefixedId('rcmd', now()), kind: step.kind, value, ms });
if (r.result === 'ack') return { ok: true };
return { ok: false, why: r.reason || r.code || r.result };
}
++ /** Run parsed steps in order through the gate; resolves 'done' or 'stopped: <why>'. */
++ async function execute(robot, steps, who, state) {
++ for (const step of steps) {
++ if (state.stop) return 'stopped: removed';
++ if (step.kind === 'wait') { await sleep(step.seconds * 1000); continue; }
++ if (!step.hold) {
++ const r = await send(robot, step, step.value, null, who);
++ if (!r.ok) return `stopped: ${r.why}`;
++ continue;
++ }
++ // Held: re-sent within the deadman window until the step's time is up, then released.
++ const end = now() + step.seconds * 1000;
++ let why = null;
++ while (now() < end && !state.stop) {
++ const r = await send(robot, step, step.value, Math.min(300, end - now() + HOLD_MS), who);
++ if (!r.ok) { why = r.why; break; }
++ await sleep(Math.min(HOLD_MS, Math.max(0, end - now())));
++ }
++ const release = step.kind === 'drive' ? {} : step.release;
++ if (release) await send(robot, step, release, null, who).catch(() => {});
++ if (why) return `stopped: ${why}`;
++ }
++ return state.stop ? 'stopped: removed' : 'done';
++ }
++
/** Run a routine now (in the background); false when one is already running on the robot. */
async function run(id, { reason = 'run' } = {}) {
const routine = await get(id);
@@@ -215,26 +263,7 @@ function createRoutines({ db, domain, hub, now = () => Date.now(), log = console
const steps = typeof routine.steps === 'string' ? JSON.parse(routine.steps) : routine.steps;
let result = 'done';
try {
-- for (const step of steps) {
-- if (state.stop) { result = 'stopped: removed'; break; }
-- if (step.kind === 'wait') { await sleep(step.seconds * 1000); continue; }
-- if (!step.hold) {
-- const r = await send(robot, step, step.value, null);
-- if (!r.ok) { result = `stopped: ${r.why}`; break; }
-- continue;
-- }
-- // Held: re-sent within the deadman window until the step's time is up, then released.
-- const end = now() + step.seconds * 1000;
-- let ok = true;
-- while (now() < end && !state.stop) {
-- const r = await send(robot, step, step.value, Math.min(300, end - now() + HOLD_MS));
-- if (!r.ok) { result = `stopped: ${r.why}`; ok = false; break; }
-- await sleep(Math.min(HOLD_MS, Math.max(0, end - now())));
-- }
-- const release = step.kind === 'drive' ? {} : step.release;
-- if (release) await send(robot, step, release, null).catch(() => {});
-- if (!ok) break;
-- }
++ result = await execute(robot, steps, asOwner(robot), state);
} catch (e) {
result = `stopped: ${e.message}`;
log.warn(`[Bot] routine ${id}: ${e.message}`);
@@@ -247,6 +276,24 @@ function createRoutines({ db, domain, hub, now = () => Date.now(), log = console
return true;
}
++ /**
++ * Run steps now as `who` and wait for the end (the owner's agent). 409 while a routine runs on the robot; the
++ * steps must fit AGENT_MAX_STEPS and AGENT_MAX_S, waits included.
++ */
++ async function runSteps(robot, steps, who) {
++ if (steps.length > AGENT_MAX_STEPS) throw new BotError(422, 'bot.invalid_routine', `at most ${AGENT_MAX_STEPS} steps at a time`);
++ const total = steps.reduce((n, s) => n + (s.seconds || 0), 0);
++ if (total > AGENT_MAX_S) throw new BotError(422, 'bot.invalid_routine', `at most ${AGENT_MAX_S} seconds at a time`);
++ if (running.has(robot.id)) throw new BotError(409, 'bot.routine_busy', 'a routine is already running on this robot');
++ const state = { routineId: null, stop: false };
++ running.set(robot.id, state);
++ try {
++ return await execute(robot, steps, who, state);
++ } catch (e) {
++ return `stopped: ${e.message}`;
++ } finally { running.delete(robot.id); }
++ }
++
/** The scheduler: each due, enabled scheduled routine runs (if its robot is free) and gets its next time. */
async function tick() {
const due = await db.many("SELECT * FROM robot_routines WHERE enabled AND next_run_at IS NOT NULL AND next_run_at <= $1 ORDER BY next_run_at LIMIT 50", [iso(now())]);
@@@ -272,7 +319,7 @@ function createRoutines({ db, domain, hub, now = () => Date.now(), log = console
}
function stop() { if (timer) clearInterval(timer); timer = null; for (const s of running.values()) s.stop = true; }
-- return { list, get, create, remove, run, tick, onOnline, start, stop, isRunning: (robotId) => running.has(robotId), describeTrigger };
++ return { list, get, create, remove, run, runSteps, tick, onOnline, start, stop, isRunning: (robotId) => running.has(robotId), describeTrigger };
}
--module.exports = { createRoutines, parseSteps, parseTrigger, nextRun, describeTrigger, HOLD_MS };
++module.exports = { createRoutines, parseSteps, parseTrigger, nextRun, describeTrigger, stepHelp, HOLD_MS, AGENT_MAX_STEPS, AGENT_MAX_S };
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 15d7919..106aa72 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -19,6 +19,7 @@
* esc(value)
*/
const { qrSvg } = require('./qr');
++const { stepHelp } = require('../routines');
const ESC = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c]);
/** A data-* attribute's JSON, escaped for a double-quoted attribute. */
@@@ -253,22 +254,6 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
const toggle = (name, on, title, note, disabled = false, was = false) => `${was ? `<input type="hidden" name="${esc(name)}_was" value="${on ? 'on' : 'off'}">` : ''}<input type="hidden" name="${esc(name)}" value="off">
<label class="toggle"><input type="checkbox" name="${esc(name)}" value="on"${on ? ' checked' : ''}${disabled ? ' disabled' : ''}><span><b>${esc(title)}</b><small>${esc(note)}</small></span></label>`;
--/** The step words this robot takes (server/routines.js parseSteps), for the routine form's help line. */
--function stepHelp(profile) {
-- const c = profile.commands || {};
-- const words = [];
-- if (c.drive) words.push('forward 2', 'back 1', 'left 0.5', 'right 0.5', 'stop');
-- words.push('wait 1');
-- if (c.say) words.push('say Hello');
-- if (c.display && c.display.faces) words.push(`face ${c.display.faces[0]}`);
-- if (c.display && c.display.modes.includes('text')) words.push('show Hi');
-- if (c.ptz) words.push(...Object.keys(c.ptz.axes).map((a) => `${a} 0.5 1`));
-- for (const [name, a] of Object.entries((c.actuator && c.actuator.names) || {})) {
-- words.push(a.type === 'rgb' ? `${name} #ff8800` : a.type === 'tone' ? `${name} A4 0.3` : a.type === 'bool' ? `${name} on` : `${name} 0.5`);
-- }
-- for (const name of Object.keys((c.button && c.button.names) || {})) words.push(`press ${name}`);
-- return words;
--}
const DAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
/** The owner's routines: what exists, Run and Remove, and the add form (plain markup, works without JavaScript). */
@@@ -318,6 +303,15 @@ ${a.hooks.length ? `<ul class="people-list">${a.hooks.map(row).join('\n')}</ul>`
</form></section>`;
}
++/** The owner's agent switch: their OpenVibe.Actor agent may drive the robot as them, through the same gate. */
++function agentForm(robot) {
++ return `<section class="setting-card" id="agent" aria-labelledby="agent-h"><h2 id="agent-h">Your agent</h2>
++<form class="embed-form" method="post" action="/robots/${esc(robot.id)}/agent">
++${toggle('agent_control', robot.agent_control, 'Let my OpenVibe.Actor agent drive this robot', 'Ask your agent on openvibe.actor to move it, speak or show something. It acts as you, through the same limits, cooldowns and e-stop, and every command it sends is on the record.')}
++<button type="submit">Save</button>
++</form></section>`;
++}
++
/** The owner's directory switch: list the robot on /robots with a public page (/r/:id), watchable by anyone. */
function listingForm(robot) {
return `<section class="setting-card" aria-labelledby="listing-h"><h2 id="listing-h">Directory</h2>
@@@ -465,7 +459,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-driv
<div class="widgets">
${widgets.join('\n')}
</div>
--${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
++${tryIt ? tryOutro(profile) : ''}${role === 'owner' && !embed && !tryIt ? `<div class="owner-settings">${localProfileForm(robot, profile, profileForm)}${automation ? routinesCard(robot, profile, automation) + webhooksCard(robot, automation) : ''}${agentForm(robot)}${listingForm(robot)}${embedForm(robot)}${streamingForm(robot, streaming)}${people ? peopleCard(robot, people) : ''}</div>` : ''}
</main>`;
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex f6d3e51..04dda6c 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -269,7 +269,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const phoneUrl = (robotId, pairing) => `${config.baseUrl}/device/phone#robot=${encodeURIComponent(robotId)}&code=${encodeURIComponent(pairing.code)}`;
// The embed flag is web-only: domain.present.robot (and so /api/v1) does not carry it.
-- const presentForPage = (row) => ({ ...domain.present.robot(row), embed_public: !!row.embed_public, listed: !!row.listed });
++ const presentForPage = (row) => ({ ...domain.present.robot(row), embed_public: !!row.embed_public, listed: !!row.listed, agent_control: !!row.agent_control });
// The front page of openvibe.bot (server/web/home.js): public, indexable, with the OpenVibe Frame. Its two
// actions differ by sign-in, so it is never stored by a shared cache.
@@@ -482,6 +482,19 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
return res.redirect(303, `/panel/${robot.id}#webhooks`);
}));
++ r.post('/robots/:id/agent', ...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 robot = await domain.robots.get(req.params.id);
++ if (!robot) throw new BotError(404, 'bot.robot_not_found', 'no such robot');
++ if (robot.owner_subject !== me.subject) throw new BotError(403, 'bot.forbidden', 'only the owner decides about their agent');
++ const sent = req.body && req.body.agent_control;
++ const value = Array.isArray(sent) ? sent[sent.length - 1] : sent;
++ if (value !== 'on' && value !== 'off') throw new BotError(422, 'bot.invalid_input', 'agent_control must be on or off');
++ await domain.robots.setAgentControl(robot.id, value === 'on');
++ res.redirect(303, `/panel/${robot.id}#agent`);
++ }));
++
r.post('/robots/:id/listing', ...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');
ddiff --git a/test/agent-control.test.js b/test/agent-control.test.js
nnew file mode 100644
iindex 0000000..c439416
---- /dev/null
++++ b/test/agent-control.test.js
@@@ -0,0 +1,107 @@
++'use strict';
++// The owner's agent (plan T15/T17: OpenVibe.Actor uses every OpenVibe service): an owner turns on "Let my OpenVibe.Actor
++// agent drive this robot" on their panel, and from then on svc:actor, acting for that owner and nobody else, reads the
++// robot's state and the steps it takes (GET /api/v1/robots/:id/steps) and runs plain step lines through the same gate
++// as a person's commands (POST …/steps, or one command on POST …/commands). Off, or for anyone but the owner, it is
++// refused with bot.agent_not_allowed.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0' } });
++ const alex = t.network.newUser('alex');
++ const bob = t.network.newUser('bob');
++ 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 { robot } = await t.robot(alex, { name: 'Desk rover', profile_id: 'sim.rover' });
++ assert.ok(await t.app.locals.sim.attach(robot.id));
++ await poll(() => t.hub.isOnline(`dev_sim_${robot.id}`));
++ const actor = t.network.signService({ sub: 'svc:actor', cap: ['bot.robot.read', 'bot.robot.control'] });
++ const asAgent = (method, path, forUser, body, token = actor) => t.call(method, `/api/v1/robots/${robot.id}${path}`, { token, headers: { 'X-OV-Subject': forUser.subject }, body });
++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
++ const post = (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 refused = (r, status, code) => { assert.strictEqual(r.status, status, r.text); assert.strictEqual(r.json.code, code); };
++
++ await check('before the owner allows it, the agent can neither read the steps nor drive', async () => {
++ refused(await asAgent('GET', '/steps', alex), 403, 'bot.agent_not_allowed');
++ refused(await asAgent('POST', '/steps', alex, { steps: 'forward 0.3' }), 403, 'bot.agent_not_allowed');
++ refused(await asAgent('POST', '/commands', alex, { id: 'a0', kind: 'drive', value: { throttle: 0.2 } }), 403, 'bot.agent_not_allowed');
++ assert.strictEqual((await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex })).json.robot.agent_control, false);
++ });
++
++ await check('the owner turns it on from the panel (owner only, same-site, on or off)', async () => {
++ const panel = await (await fetch(`${t.base}/panel/${robot.id}`, { headers: cookie(alex) })).text();
++ assert.ok(panel.includes('id="agent"') && panel.includes('Let my OpenVibe.Actor agent drive this robot'));
++ assert.ok(panel.includes(`action="/robots/${robot.id}/agent"`));
++ const path = `/robots/${robot.id}/agent`;
++ assert.strictEqual((await post(path, bob, 'agent_control=off&agent_control=on')).status, 403, 'only the owner');
++ assert.strictEqual((await post(path, alex, 'agent_control=off&agent_control=on', { Origin: 'https://evil.test' })).status, 403, 'no cross-site post');
++ assert.strictEqual((await post(path, alex, 'agent_control=maybe')).status, 422);
++ const on = await post(path, alex, 'agent_control=off&agent_control=on');
++ assert.strictEqual(on.status, 303);
++ assert.strictEqual(on.headers.get('location'), `/panel/${robot.id}#agent`);
++ assert.strictEqual((await t.call('GET', `/api/v1/robots/${robot.id}`, { user: alex })).json.robot.agent_control, true);
++ });
++
++ await check('the agent reads the robot for its owner: online, the e-stop, and the steps this robot takes', async () => {
++ const r = await asAgent('GET', '/steps', alex);
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.json.online, true);
++ assert.strictEqual(r.json.estop.latched, false);
++ assert.strictEqual(r.json.name, 'Desk rover');
++ assert.ok(r.json.steps.includes('forward 2') && r.json.steps.includes('headlights #ff8800'), r.json.steps.join(', '));
++ assert.deepStrictEqual([r.json.max_steps, r.json.max_seconds], [20, 30]);
++ });
++
++ await check('the agent runs steps as the owner through the gate, and the audit names the service', async () => {
++ const r = await asAgent('POST', '/steps', alex, { steps: 'forward 0.4\nheadlights #00ff00\nstop' });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.json.result, r.json.steps], ['done', 3]);
++ const audit = await t.domain.audit.list(robot.id, { limit: 50 });
++ assert.ok(audit.some((x) => x.kind === 'drive' && x.result === 'ack' && x.operator_kind === 'service'), 'driven as a service, on the record');
++ assert.ok(audit.some((x) => x.kind === 'actuator' && x.result === 'ack'), 'the lights too');
++ const one = await asAgent('POST', '/commands', alex, { id: 'a1', kind: 'drive', value: { throttle: 0.2 } });
++ assert.strictEqual(one.status, 200, one.text);
++ assert.strictEqual(one.json.result, 'ack');
++ });
++
++ await check('only for the owner: not for an operator, not another service, not without the control capability', async () => {
++ await t.call('POST', `/api/v1/robots/${robot.id}/operators`, { user: alex, body: { subject: bob.subject, role: 'operator' } });
++ refused(await asAgent('POST', '/steps', bob, { steps: 'forward 0.3' }), 403, 'bot.agent_not_allowed');
++ refused(await asAgent('POST', '/commands', bob, { id: 'b1', kind: 'drive', value: { throttle: 0.2 } }), 403, 'bot.agent_not_allowed');
++ refused(await asAgent('GET', '/steps', bob), 403, 'bot.agent_not_allowed');
++ const live = t.network.signService({ sub: 'svc:live', cap: ['bot.robot.read', 'bot.robot.control'] });
++ refused(await asAgent('POST', '/steps', alex, { steps: 'forward 0.3' }, live), 403, 'bot.forbidden');
++ const readOnly = t.network.signService({ sub: 'svc:actor', cap: ['bot.robot.read'] });
++ assert.strictEqual((await asAgent('POST', '/steps', alex, { steps: 'forward 0.3' }, readOnly)).status, 403);
++ // A person drives their own robot's steps themself; someone else's are refused.
++ const own = await t.call('POST', `/api/v1/robots/${robot.id}/steps`, { user: alex, body: { steps: 'left 0.2' } });
++ assert.strictEqual(own.status, 200, own.text);
++ refused(await t.call('POST', `/api/v1/robots/${robot.id}/steps`, { user: bob, body: { steps: 'left 0.2' } }), 403, 'bot.forbidden');
++ });
++
++ await check('steps are checked and bounded: a wrong line names it, at most 20 steps and 30 seconds', async () => {
++ const bad = await asAgent('POST', '/steps', alex, { steps: 'forward 0.2\nfly 3' });
++ assert.strictEqual(bad.status, 422);
++ assert.match(bad.json.detail, /line 2/);
++ assert.strictEqual((await asAgent('POST', '/steps', alex, { steps: Array(21).fill('wait 0.1').join('\n') })).status, 422);
++ const long = await asAgent('POST', '/steps', alex, { steps: 'forward 20\nforward 20' });
++ assert.strictEqual(long.status, 422);
++ assert.match(long.json.detail, /30 seconds/);
++ });
++
++ await check('the e-stop stops the agent with its reason; turning the switch off refuses it again', async () => {
++ assert.strictEqual((await t.call('POST', `/api/v1/robots/${robot.id}/estop`, { user: alex })).status, 200);
++ const r = await asAgent('POST', '/steps', alex, { steps: 'forward 0.3' });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.match(r.json.result, /^stopped: /);
++ assert.strictEqual((await asAgent('GET', '/steps', alex)).json.estop.latched, true);
++ assert.strictEqual((await post(`/robots/${robot.id}/agent`, alex, 'agent_control=off')).status, 303);
++ refused(await asAgent('POST', '/steps', alex, { steps: 'forward 0.3' }), 403, 'bot.agent_not_allowed');
++ });
++
++ await t.close();
++ done();
++})();