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)
Changes
@@@ -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.@@@ -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",@@@ -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` |nnew file mode 100644@@@ -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;@@@ -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, submr.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, submres.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 };@@@ -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 },@@@ -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 = consoleawait 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 = consoleconst 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 = consolereturn 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 };@@@ -19,6 +19,7 @@* esc(value)*/const { qrSvg } = require('./qr');++const { stepHelp } = require('../routines');const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };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',@@@ -269,7 +269,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limiconst 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, limireturn 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');nnew file mode 100644@@@ -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();++})();
@@ -17,6 +17,7 @@ OpenVibe.Bot pairs a robot's machines, keeps their state and gates every operato
@@ -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 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.
- 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`.
- 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`.
- 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.
- 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.
- 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.
@@ -48,6 +48,7 @@
@@ -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",
"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",
"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",
"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)",
"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",
"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",
"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",
@@ -475,6 +475,8 @@ node token, and a service token is judged on `bot.job.dispatch` alone.
@@ -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` | owner/operator, `bot.robot.control` | `{ robot }` |
| `POST /robots/:id/estop/clear` | **owner only**, `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 |
| `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/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) |
| `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` |
| `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` |
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -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;
@@ -35,6 +35,7 @@
@@ -35,6 +35,7 @@
* With BOT_PAIRING_AUTHORITY=network the pairing codes are Network's and POST /pair answers 410 bot.pairing_moved.
* With BOT_PAIRING_AUTHORITY=network the pairing codes are Network's and POST /pair answers 410 bot.pairing_moved.
*/
*/
const express = require('express');
const express = require('express');
+const { parseSteps, stepHelp, AGENT_MAX_STEPS, AGENT_MAX_S } = require('../routines');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const { fail, userSubject, isRobotId, json } = require('../util');
const { fail, userSubject, isRobotId, json } = require('../util');
const { getProfile, listProfiles, checkProfile } = require('../profiles');
const { getProfile, listProfiles, checkProfile } = require('../profiles');
@@ -49,6 +50,9 @@ const CAP = {
@@ -49,6 +50,9 @@ const CAP = {
dispatch: 'bot.job.dispatch',
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 }) {
function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null }) {
const r = express.Router();
const r = express.Router();
r.use(limits.reads('bot.read'));
r.use(limits.reads('bot.read'));
@@ -345,6 +349,13 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -345,6 +349,13 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
r.post('/robots/:id/commands', commandControl, wrap(async (req, res) => {
r.post('/robots/:id/commands', commandControl, wrap(async (req, res) => {
requireControlCap(req);
requireControlCap(req);
const subject = actedFor(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 principal = req.principal.kind === 'service' ? { ...req.principal, subject } : req.principal;
const result = await hub.operatorCommand(req.params.id, principal, subject, {
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,
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
@@ -353,6 +364,43 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
res.json({ robot_id: req.params.id, ...result });
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 ─────────────────────────────────────────────────────────────────────────────────────
// ── Audit ─────────────────────────────────────────────────────────────────────────────────────
r.get('/robots/:id/audit', wrap(async (req, res) => {
r.get('/robots/:id/audit', wrap(async (req, res) => {
await ownerOrRead(req, req.params.id);
await ownerOrRead(req, req.params.id);
@@ -423,4 +471,4 @@ function auditPresent(a) {
@@ -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 };
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 };
@@ -69,7 +69,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -69,7 +69,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
// ── 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,
- 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 },
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()),
created_at: iso(new Date(r.created_at).getTime()), updated_at: iso(new Date(r.updated_at).getTime()),
} : null);
} : null);
@@ -254,6 +254,11 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -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.
* 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.
* 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) {
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())]);
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())]);
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 =
@@ -1018,7 +1023,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
return {
return {
db, now, config, log, outbox, link,
db, now, config, log, outbox, link,
present: { robot: presentRobot, device: presentDevice, video: presentVideo },
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 },
localProfile, saveLocalProfile, useCatalogueProfile },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl },
@@ -10,9 +10,12 @@
@@ -10,9 +10,12 @@
* wait <seconds> say <text> face <name> show <text> press <button>
* 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)
* <actuator> <value> [seconds] (number, #rrggbb or off, a note like A4 or off, on/off; held for the seconds)
* pan|tilt|zoom <value> [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' }
* parseTrigger({ type, every, at, days }) → { type: 'manual' } | { type: 'schedule', every?|at+days } | { type: 'online' }
* nextRun(trigger, from) → Date | null
* 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
* 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
* 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;
@@ -26,6 +29,8 @@ const MAX_STEP_S = 60;
const MAX_TOTAL_S = 600;
const MAX_TOTAL_S = 600;
const MIN_EVERY_MIN = 5;
const MIN_EVERY_MIN = 5;
const TICK_MS = 30000;
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 fail = (detail) => { throw new BotError(422, 'bot.invalid_routine', detail); };
const num = (s, what, lo, hi) => {
const num = (s, what, lo, hi) => {
@@ -127,6 +132,23 @@ function parseSteps(source, profile) {
@@ -127,6 +132,23 @@ function parseSteps(source, profile) {
}
}
const TIME = /^([01]\d|2[0-3]):([0-5]\d)$/;
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 = {}) {
function parseTrigger(t = {}) {
const type = String(t.type || 'manual');
const type = String(t.type || 'manual');
if (type === 'manual' || type === 'online') return { type };
if (type === 'manual' || type === 'online') return { type };
@@ -193,14 +215,40 @@ function createRoutines({ db, domain, hub, now = () => Date.now(), log = console
@@ -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())]);
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 };
if (r.result === 'ack') return { ok: true };
return { ok: false, why: r.reason || r.code || r.result };
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. */
/** Run a routine now (in the background); false when one is already running on the robot. */
async function run(id, { reason = 'run' } = {}) {
async function run(id, { reason = 'run' } = {}) {
const routine = await get(id);
const routine = await get(id);
@@ -215,26 +263,7 @@ function createRoutines({ db, domain, hub, now = () => Date.now(), log = console
@@ -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;
const steps = typeof routine.steps === 'string' ? JSON.parse(routine.steps) : routine.steps;
let result = 'done';
let result = 'done';
try {
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) {
} catch (e) {
result = `stopped: ${e.message}`;
result = `stopped: ${e.message}`;
log.warn(`[Bot] routine ${id}: ${e.message}`);
log.warn(`[Bot] routine ${id}: ${e.message}`);
@@ -247,6 +276,24 @@ function createRoutines({ db, domain, hub, now = () => Date.now(), log = console
@@ -247,6 +276,24 @@ function createRoutines({ db, domain, hub, now = () => Date.now(), log = console
return true;
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. */
/** The scheduler: each due, enabled scheduled routine runs (if its robot is free) and gets its next time. */
async function tick() {
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())]);
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
@@ -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; }
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 };
@@ -19,6 +19,7 @@
@@ -19,6 +19,7 @@
* esc(value)
* esc(value)
*/
*/
const { qrSvg } = require('./qr');
const { qrSvg } = require('./qr');
+const { stepHelp } = require('../routines');
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c]);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c]);
/** A data-* attribute's JSON, escaped for a double-quoted attribute. */
/** A data-* attribute's JSON, escaped for a double-quoted attribute. */
@@ -253,22 +254,6 @@ function renderWidget(w, { profile = {}, allowed_commands = [], signaling_url =
@@ -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">
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>`;
<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'];
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). */
/** 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>`
@@ -318,6 +303,15 @@ ${a.hooks.length ? `<ul class="people-list">${a.hooks.map(row).join('\n')}</ul>`
</form></section>`;
</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. */
/** The owner's directory switch: list the robot on /robots with a public page (/r/:id), watchable by anyone. */
function listingForm(robot) {
function listingForm(robot) {
return `<section class="setting-card" aria-labelledby="listing-h"><h2 id="listing-h">Directory</h2>
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
@@ -465,7 +459,7 @@ ${embed && role === 'watcher' && !signedIn ? `<p class="embed-sign-in" data-driv
<div class="widgets">
<div class="widgets">
${widgets.join('\n')}
${widgets.join('\n')}
</div>
</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>`;
</main>`;
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
return page(tryIt ? 'Try the panel with a simulated rover' : robot.name, body, {
scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',
scripts: panelScripts(profile, tryIt), bodyClass: embed ? 'embed-page' : 'panel-page',
@@ -269,7 +269,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -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)}`;
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.
// 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
// 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.
// 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
@@ -482,6 +482,19 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
return res.redirect(303, `/panel/${robot.id}#webhooks`);
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) => {
r.post('/robots/:id/listing', ...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');
new file mode 100644
new file mode 100644
@@ -0,0 +1,107 @@
@@ -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();
+})();