openvibers/openvibe.bot Commits

A private desk for agents, the person always wins a screen, and Vibe by voice (chat.vibe.ask) (#167)

OP OpenVibers committed 18 days ago · 0380bb3fc3 · 16 files, +456 −39

* The private desk: machines take desk start/stop/status, and screen, input, browser and the live screen act there with desk: private (contracts 0.214.0); a screen share link can show and drive the desk

* The person always wins a screen: a person's mouse, keyboard or browser action (the owner, or a share link) holds that screen for BOT_PERSON_TURN_MS, and an agent's acting command there is refused meanwhile (bot.person_driving)

* Vibe by voice: the owner turns voice on for a machine with a call word; a voice_ask is answered working, then with Vibe's answer asked of Chat as the owner (chat.vibe.ask)

Changes

ddiff --git a/.env.example b/.env.example
iindex 650ada4..3b3ac7e 100644
---- a/.env.example
++++ b/.env.example
@@@ -46,6 +46,11 @@ OV_OAUTH_CLIENT_SECRET=
#BOT_PAIRING_TTL_MS=600000
#BOT_PAIRING_MAX_TRIES=5
#BOT_HEARTBEAT_MS=1000
++# A person's input on a machine's screen holds it for them this long; an agent's input there is refused meanwhile (ms).
++#BOT_PERSON_TURN_MS=15000
++# Vibe by voice (server/voice.js): OpenVibe.Chat's loopback origin (empty turns voice off) and questions a machine an hour.
++#BOT_CHAT_INTERNAL_URL=http://127.0.0.1:4400
++#BOT_VOICE_PER_HOUR=30
#BOT_OFFLINE_MISSES=2
#BOT_OFFLINE_GRACE_MS=3000
#BOT_ROTATE_GRACE_MS=60000
ddiff --git a/README.md b/README.md
iindex d2eac63..6d43272 100644
---- a/README.md
++++ b/README.md
@@@ -51,10 +51,32 @@ Sitemap entries use the release commit time for these code-backed pages.
- **The viewer.** `GET` answers the session's viewer `signaling_url` once the machine publishes, and the page plays it with the panel's WebRTC viewer.
- **Stop.** `DELETE` (or Stop) tells the machine to stop and retires the key. Removing a machine archives its stream.
- **Refused** with `bot.computer_off` when the machine does not offer it (`screen.stream`), and no key is made.
++- **The private desk** (contracts 0.214.0 `bot.command@1` kind `desk`, OpenVibe.Node v0.15.0 on Linux): a screen of its
++ own on the machine (Xvfb with its own bus and window manager) where an agent works without touching the owner's.
++ - **Commands.** `desk` `{ action: start | stop | status, width?, height? }` reaches a machine whose status lists
++ `computer.desk`. `screen`, `input` and `browser` with `desk: "private"` act there. A machine without one answers
++ `bot.computer_off` ("no private desk"), so `desk: private` never falls back to the owner's own screen.
++ - **The audit** keeps the action and size, and marks `desk: private` on what acted there.
++ - **The person always wins.** A person's mouse, keyboard or browser action on a screen (the owner's, or through a
++ share link) holds that screen (main or the desk) for `BOT_PERSON_TURN_MS` (15 s). An agent's acting command there
++ meanwhile answers `409 bot.person_driving` ("a person is using this screen right now"); reading goes on.
++ - **The live screen** of the desk: `POST /machines/:id/screen` with `desk: "private"` (or the page's "Show the
++ private desk" button) sends `screen_stream` with `desk: private`.
++- **Vibe by voice** (`server/voice.js`, migration `0024_machine_voice.sql`, contracts 0.214.0 `voice_ask` /
++ `voice_answer`; consumer plan §8). On a machine whose Node runs the voice plugin (`capabilities.voice`), the owner
++ turns voice on and chooses a call word ("hey vibe" by default) on /machines or `POST /api/v1/machines/:id/voice`.
++ - **Config** then carries `voice: true` and `call_word`, and the machine listens for the word on its own. Only what
++ the person says after it comes as `voice_ask`.
++ - **Answers:** Bot answers `working` at once, then asks Vibe as the owner through Chat (`POST <BOT_CHAT_INTERNAL_URL>
++ /internal/chat/vibe/ask`, its own token for `openvibe.chat` with `chat.vibe.ask`, one source per machine, the
++ device id and question id as the idempotency key). Vibe's answer, or why not, comes back as `voice_answer`.
++ - **Limits:** with voice off or no owner, it's refused with where to turn it on. There are at most `BOT_VOICE_PER_HOUR`
++ questions a machine an hour. Bot never stores the text.
- **Screen share links** (`server/screen-shares.js`, migration `0022_screen_shares.sql`; owner 2026-10-25: a screen a group
watches and uses together). On the screen page the owner makes a link: **watch** (the live screen) or **watch and use**
(also start or stop the screen, and the mouse and keyboard). It goes where the group works, such as a workspace room's
-- Screen on OpenVibe.Chat.
++ Screen on OpenVibe.Chat. A link shows the owner's own screen or, on a machine with one, the **private desk**
++ (migration `0023_screen_share_desk.sql`): a room watches the agent work there, and a use link's clicks go to the desk.
- **The link** is shown once; Bot keeps its sha256. It lasts 1-365 days (30 by default), and stops at once when revoked
or when the machine is removed. The owner's screen page lists the links (where each went, its kind, end date, last
use) with a Stop button.
ddiff --git a/migrations/0023_screen_share_desk.sql b/migrations/0023_screen_share_desk.sql
nnew file mode 100644
iindex 0000000..e47888c
---- /dev/null
++++ b/migrations/0023_screen_share_desk.sql
@@@ -0,0 +1,5 @@
++-- phase: expand
++-- A screen share link shows the owner's own screen (main) or the machine's private desk (contracts 0.214.0 bot.command@1
++-- `desk: private`, OpenVibe.Node v0.15.0): the agent works on the desk, and a room watches it there, never the owner's
++-- screen. Links made before this show the owner's screen, as they did.
++ALTER TABLE machine_screen_shares ADD COLUMN IF NOT EXISTS desk text NOT NULL DEFAULT 'main' CHECK (desk IN ('main', 'private'));
ddiff --git a/migrations/0024_machine_voice.sql b/migrations/0024_machine_voice.sql
nnew file mode 100644
iindex 0000000..efb3217
---- /dev/null
++++ b/migrations/0024_machine_voice.sql
@@@ -0,0 +1,6 @@
++-- phase: expand
++-- Vibe by voice (consumer plan §8, contracts 0.214.0 bot.device-message@1 voice_ask / voice_answer): the owner lets
++-- Vibe answer their voice on this machine, and chooses its call word. Off until they turn it on on openvibe.bot/machines;
++-- the machine (OpenVibe Node's voice plugin) listens for the word only while it is on (config `voice`).
++ALTER TABLE devices ADD COLUMN IF NOT EXISTS voice_on boolean NOT NULL DEFAULT false;
++ALTER TABLE devices ADD COLUMN IF NOT EXISTS call_word text;
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex 7d022b1..2dbfbf3 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -36,6 +36,7 @@
*
* With BOT_PAIRING_AUTHORITY=network the pairing codes are Network's and POST /pair answers 410 bot.pairing_moved.
*/
++const { validCallWord, DEFAULT_CALL_WORD } = require('../voice');
const express = require('express');
const { parseSteps, stepHelp, AGENT_MAX_STEPS, AGENT_MAX_S } = require('../routines');
const { http } = require('openvibe-contracts');
@@@ -404,9 +405,10 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
const pick = (cap) => (offered && offered[cap] && typeof offered[cap] === 'object' ? offered[cap] : null);
return {
id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer, watch_probe: !!device.watch_probe,
++ voice: { on: !!device.voice_on, call_word: device.call_word || DEFAULT_CALL_WORD },
computer: offered ? {
level: typeof sensors.computer_level === 'string' ? sensors.computer_level : ((pick('files') || pick('terminal') || pick('screen') || {}).level || null),
-- terminal: pick('terminal'), files: pick('files'), screen: pick('screen'), probe: pick('probe'), input: pick('input'), browser: pick('browser'),
++ terminal: pick('terminal'), files: pick('files'), screen: pick('screen'), probe: pick('probe'), input: pick('input'), browser: pick('browser'), desk: pick('desk'),
waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
} : null,
};
@@@ -444,7 +446,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
};
r.post('/machines/:id/screen', manage, wrap(async (req, res) => {
const { device, subject } = await screenOwner(req);
-- const out = await machineScreen.start(device, subject, req.principal, { seconds: req.body?.seconds, fps: req.body?.fps });
++ const out = await machineScreen.start(device, subject, req.principal, { seconds: req.body?.seconds, fps: req.body?.fps, desk: req.body?.desk });
res.status(201).json({ machine_id: device.id, ...out });
}));
r.get('/machines/:id/screen', wrap(async (req, res) => {
@@@ -464,6 +466,17 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
const updated = await domain.machines.setWatchProbe(device.id, req.body.on);
res.json({ machine: machineOut(updated) });
}));
++ // The owner lets Vibe answer their voice on the machine, with a call word, or stops it (never an agent or a service).
++ r.post('/machines/:id/voice', manage, wrap(async (req, res) => {
++ if (req.principal.kind !== 'user') fail(403, 'bot.forbidden', 'only the machine\'s owner turns this on or off');
++ const { device } = await computerMachine(req, { agentNeedsOptIn: false });
++ if (typeof req.body?.on !== 'boolean') fail(422, 'bot.invalid_input', 'on is true or false');
++ const word = req.body.call_word == null ? null : validCallWord(req.body.call_word);
++ if (req.body.call_word != null && !word) fail(422, 'bot.invalid_input', 'a call word is 1 to 4 words, 3 to 40 characters, with one word of three letters or more');
++ const updated = await domain.machines.setVoice(device.id, req.body.on, word);
++ if (hub && hub.machineChanged) await hub.machineChanged(updated);
++ res.json({ machine: machineOut(updated) });
++ }));
// The owner turns their agent's use of the machine on or off (never the agent itself).
r.post('/machines/:id/agent-computer', manage, wrap(async (req, res) => {
if (req.principal.kind !== 'user') fail(403, 'bot.forbidden', 'only the machine\'s owner turns this on or off');
@@@ -592,7 +605,7 @@ function commandRefusalStatus(code) {
if (code === 'bot.robot_not_found') return 404;
if (code === 'bot.not_an_operator' || code === 'bot.read_only' || code === 'bot.forbidden' || code === 'bot.not_your_turn') return 403;
if (code === 'bot.cooldown' || code === 'bot.turn_budget') return 429;
-- if (code === 'bot.device_offline' || code === 'bot.estop_latched' || code === 'bot.command_pending' || code === 'bot.computer_off') return 409;
++ if (code === 'bot.device_offline' || code === 'bot.estop_latched' || code === 'bot.command_pending' || code === 'bot.computer_off' || code === 'bot.person_driving') return 409;
return 422; // unknown_command, command_not_allowed, unknown_actuator, invalid_input, text_too_long
}
ddiff --git a/server/config.js b/server/config.js
iindex b997f81..2ad172e 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -177,6 +177,9 @@ function loadConfig(env = process.env) {
// A Network-paired machine's node token lives 300 s; with no valid `reauth` within 330 s of the
// last token the socket closes 4002.
nodeReauthMs: int(env.BOT_NODE_REAUTH_MS, 330 * 1000),
++ // A person's mouse, keyboard or browser action on a machine's screen (as its owner, or through a share
++ // link) holds that screen for them this long: an agent's acting command there waits it out (refused).
++ personTurnMs: int(env.BOT_PERSON_TURN_MS, 15 * 1000),
},
// Control gate (ADR-043 decision 5/6/8). maxCommandMs is the absolute deadline cap; the queue is
// the only way a stranger drives a robot.
@@@ -237,6 +240,10 @@ function loadConfig(env = process.env) {
// BOT_OPENRE_URL unset, or neither an operator token nor the Network client credentials
// (OV_OAUTH_CLIENT_SECRET, OV_NETWORK_INTERNAL_URL): devices pair without video (no publish_key,
// `video: "not_configured"`) and Bot mints no key of its own.
++ // OpenVibe.Chat (Vibe by voice, server/voice.js): Bot asks Vibe as a machine's owner at <internalUrl>/internal/chat/
++ // vibe/ask with its own Network service token (audience openvibe.chat, chat.vibe.ask). Empty turns voice off.
++ chat: { internalUrl: trim(env.BOT_CHAT_INTERNAL_URL ?? 'http://127.0.0.1:4400') },
++ voice: { perHour: Math.max(1, int(env.BOT_VOICE_PER_HOUR, 30)) },
openre: {
url: trim(env.BOT_OPENRE_URL || ''),
token: String(env.BOT_OPENRE_TOKEN || '').trim(),
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex c81d5cb..6e9d2e2 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -744,6 +744,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
return true;
}
/** Let the owner's watches probe from this machine, or stop them (the owner check is the caller's). */
++ /** Vibe by voice on this machine, with its call word (the owner check is the caller's; null keeps the word). */
++ async function setVoice(deviceId, on, callWord = null) {
++ return db.maybe('UPDATE devices SET voice_on = $2, call_word = COALESCE($3, call_word), updated_at = $4 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!on, callWord, iso(now())]);
++ }
async function setWatchProbe(deviceId, value) {
return db.maybe('UPDATE devices SET watch_probe = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
}
@@@ -1244,7 +1248,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
members: { roleOf, add: addOperator, remove: removeOperator, list: listOperators },
pairing: { create: createPairingCode, redeem, prune: prunePairingCodes, installerCommand, driverForProfile, whipUrl, onPaired },
devices: { byCredential, bindNode, issuePublishKey, revokeVideo, updateDeclared, revokeNode, ensureServerDevice, get: getDevice, listForRobot: listDevicesForRobot, rotate: rotateDevice, revoke: revokeDevice, revokeByNode, touchSeen, setOnline, ownerOf, onChange },
-- machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setWatchProbe,
++ machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setWatchProbe, setVoice,
screen: { key: screenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
streaming: { get: streaming, set: setStreaming },
video: { live: liveVideo },
ddiff --git a/server/machine-screen.js b/server/machine-screen.js
iindex 23cbcf2..ac5b1e8 100644
---- a/server/machine-screen.js
++++ b/server/machine-screen.js
@@@ -4,9 +4,11 @@
* stream. Used by the API (POST/GET/DELETE /api/v1/machines/:id/screen) and the web page (/machines/:id/screen), for
* the owner signed in as themselves only — never an agent, a watch or another service.
*
-- * start(device, subject, principal, { seconds, fps }) a new key for the machine's stream (domain.machines.screen),
++ * start(device, subject, principal, { seconds, fps, desk }) a new key for the machine's stream (domain.machines.screen),
* then screen_stream start to the machine with the WHIP URL
-- * and key → { streaming, seconds, fps, until }
++ * and key → { streaming, seconds, fps, until, desk }; desk
++ * `private` shows the machine's private desk (contracts
++ * 0.214.0, Node v0.15.0) instead of the owner's own screen
* state(device, subject) → { live, signaling_url, state, stream } (the session's viewer
* URL once the machine is publishing; `state` the machine's own
* telemetry: live or off)
@@@ -35,14 +37,19 @@ function createMachineScreen({ domain, hub, now = () => Date.now() }) {
if (!hub) throw unavailable();
const seconds = whole(opts.seconds, SECONDS, 'seconds');
const fps = whole(opts.fps, FPS, 'fps');
++ const desk = opts.desk == null || opts.desk === '' ? 'main' : String(opts.desk);
++ if (desk !== 'main' && desk !== 'private') throw new BotError(422, 'bot.invalid_input', 'desk is main or private');
if (!hub.isOnline(device.id)) throw new BotError(409, 'bot.device_offline', 'the machine is offline');
// Before any key is made: the machine says whether it can (Node v0.12.0's computer plugin, a capture tool).
const offered = hub.computerOf(device.id);
if (!(offered && offered.screen && offered.screen.stream === true)) {
throw new BotError(409, 'bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your graphical session');
}
++ if (desk === 'private' && !(offered.desk && offered.desk.screen)) {
++ throw new BotError(409, 'bot.computer_off', 'this machine has no private desk to show: it needs OpenVibe Node v0.15.0 or newer on Linux, with Xvfb and allow_desk on');
++ }
const key = await domain.machines.screen.key(device, subject);
-- const r = await hub.machineScreen(device.id, principal, subject, { action: 'start', whip_url: key.whipUrl, publish_key: key.key, seconds, fps });
++ const r = await hub.machineScreen(device.id, principal, subject, { action: 'start', whip_url: key.whipUrl, publish_key: key.key, seconds, fps, ...(desk === 'private' ? { desk } : {}) });
if (r.result !== 'ack') {
const code = r.code || (r.result === 'expired' ? 'bot.device_timeout' : 'bot.device_refused');
const status = r.result === 'refused' ? (code === 'bot.computer_off' || code === 'bot.device_offline' ? 409 : 422) : r.result === 'expired' ? 504 : 409;
@@@ -50,7 +57,7 @@ function createMachineScreen({ domain, hub, now = () => Date.now() }) {
}
const data = r.data && typeof r.data === 'object' ? r.data : {};
const until = Number.isFinite(data.until_ms) ? new Date(data.until_ms).toISOString() : new Date(now() + seconds * 1000).toISOString();
-- return { streaming: true, seconds, fps, until };
++ return { streaming: true, seconds, fps, until, desk };
},
async state(device, subject) {
const sensors = hub ? ((hub.deviceState(device.id).telemetry || {}).sensors || {}) : {};
ddiff --git a/server/realtime.js b/server/realtime.js
iindex 002a418..d14b539 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -27,6 +27,7 @@ const { capabilities, ids, validate } = require('openvibe-contracts');
const { json, iso, prefixedId } = require('./util');
const { userPrincipal, verifyService, verifyNode, isNodeToken, PRINCIPAL_SUB, decodePayload } = require('./api/auth');
const { getProfile } = require('./profiles');
++const { createVoice } = require('./voice');
const OPEN = 1;
const MAX_BACKLOG = 64; // frames a device may send before its authentication completes
@@@ -38,8 +39,13 @@ const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leave
// (OpenVibe.Node protocol.MaxAckData), so a device frame may be that large; ws closes a socket on a larger frame.
const DEVICE_MAX_PAYLOAD = 640 * 1024;
// The Node's computer plugin (plan T14/T17): the kinds a person's machine takes, the capability each needs in its
--// status, and the reply window (the owner's approval on the machine plus the program's own run).
--const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser' };
++// status, and the reply window (the owner's approval on the machine plus the program's own run). `desk` (contracts
++// 0.214.0, Node v0.15.0) starts, stops or reports the machine's private desk; screen, input and browser (and the live
++// screen) act there with `desk: private`, never on the owner's own screen.
++const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser', desk: 'desk' };
++const NO_DESK = 'this machine has no private desk: it needs OpenVibe Node v0.15.0 or newer on Linux, with Xvfb and allow_desk on';
++/** A command that acts on a screen (the mouse and keyboard, or the browser doing more than reading it). */
++const actsOnScreen = (kind, value) => kind === 'input' || (kind === 'browser' && !['read', 'close'].includes(value.action));
const COMPUTER_KINDS = Object.keys(COMPUTER_CAP);
// The live screen (contracts 0.182.0): a device kind only Bot sends (machineScreen), with the machine's own private
// stream filled in. It is not one of bot.command@1's kinds, so no operator, agent or service can send it.
@@@ -48,19 +54,21 @@ const COMPUTER_DEFAULT_MS = 60000;
const COMPUTER_MAX_MS = 600000;
/** What an audit row keeps of a computer command: never a file's content, stdin or arguments (they may hold secrets). */
function computerSummary(kind, value) {
++ const where = value.desk === 'private' ? { desk: 'private' } : {};
++ if (kind === 'desk') return { action: value.action, ...(Number.isInteger(value.width) && Number.isInteger(value.height) ? { width: value.width, height: value.height } : {}) };
if (kind === 'exec') return { program: String(value.argv[0]).slice(0, 120), args: value.argv.length - 1 };
if (kind === 'fs') return { op: value.op, path: String(value.path).slice(0, 300), ...(typeof value.content_b64 === 'string' ? { bytes: Math.floor((value.content_b64.length * 3) / 4) } : {}) };
if (kind === 'probe') { try { const u = new URL(value.target); return { scheme: u.protocol.replace(':', ''), host: u.host }; } catch { return { scheme: null }; } }
// Typed text can be anything (a password the person asked for): the audit keeps its length, never the text.
-- if (kind === 'input') return { action: value.action, ...(value.x != null ? { x: value.x, y: value.y } : {}), ...(typeof value.text === 'string' ? { chars: value.text.length } : {}), ...(value.keys ? { keys: value.keys } : {}), ...(value.dy ? { dy: value.dy } : {}) };
++ if (kind === 'input') return { action: value.action, ...(value.x != null ? { x: value.x, y: value.y } : {}), ...(typeof value.text === 'string' ? { chars: value.text.length } : {}), ...(value.keys ? { keys: value.keys } : {}), ...(value.dy ? { dy: value.dy } : {}), ...where };
// The browser: where it went (the host only, never a path or query), what it clicked, how much was typed.
if (kind === 'browser') {
let host = null;
if (typeof value.url === 'string') { try { host = new URL(value.url).host; } catch { host = null; } }
return { action: value.action, ...(host ? { host } : {}), ...(typeof value.selector === 'string' ? { selector: value.selector.slice(0, 120) } : {}),
-- ...(value.action === 'fill' && typeof value.text === 'string' ? { chars: value.text.length } : {}), ...(value.key ? { key: value.key } : {}) };
++ ...(value.action === 'fill' && typeof value.text === 'string' ? { chars: value.text.length } : {}), ...(value.key ? { key: value.key } : {}), ...where };
}
-- return { max_width: value.max_width || null };
++ return { max_width: value.max_width || null, ...where };
}
function parseCookies(header) {
@@@ -106,6 +114,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
const pendingCmds = new Map(); // device-facing command id (server-minted) → pending
const results = new Map(); // operator key (who + their id) → { result, reason, at }
const inflight = new Map(); // operator key → device-facing id, while the command is pending
++ // The person always wins a screen (machines): `${device}|${main|private}` → until when a person's input holds it.
++ const personTurns = new Map();
const timers = [];
let closed = false;
@@@ -122,6 +132,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
const watchersByAddr = new Map(); // client address → open /watch sockets
const offlineAfterMs = config.device.heartbeatMs * config.device.offlineMisses + config.device.offlineGraceMs;
let jobs = null; // the jobs service (server/jobs/index.js), bound by server/app.js
++ const voice = createVoice({ config, now }); // Vibe by voice on a machine (server/voice.js)
const nextSeq = (conn) => ++conn.seq;
function send(ws, obj) { if (ws.readyState === OPEN) { ws.send(JSON.stringify(obj)); return true; } return false; }
@@@ -307,6 +318,8 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
sendFrame(conn, 'config', {
heartbeat_ms: config.device.heartbeatMs, limits, allowed_commands: allowed,
estop_latched: robot ? !!robot.estop_latched : false,
++ // Vibe by voice: the machine listens for its call word only while its owner has voice on.
++ ...(conn.device.voice_on ? { voice: true, call_word: voice.callWordOf(conn.device) } : {}),
});
}
/** Re-send `config` to the robot's connected devices (the owner changed its limits or allowlist, or the latch moved). */
@@@ -368,12 +381,41 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
case 'reauth': if (conn.node) return onReauth(conn, msg); return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);
case 'ack': case 'nack': return onAck(conn, msg);
case 'estop_state': return onEstopState(conn, msg);
++ case 'voice_ask': return onVoiceAsk(conn, msg);
// Jobs (platform.job-frame@1). Out: job, job_cancel, job_exit_ack (server/jobs/dispatch.js); in:
case 'job_started': case 'job_stdout': case 'job_usage': case 'job_exit': return jobFrame(conn, msg);
default: return sendError(conn, 'bot.unknown_message', `unknown type ${msg.type}`);
}
}
++ /**
++ * voice_ask (contracts 0.214.0): what the machine's owner said after its call word, transcribed on the machine. Only
++ * with voice on for the machine (read fresh, not from the socket's start); `working` at once, then Vibe's answer as
++ * the owner (server/voice.js), both voice_answer frames with the device's id. The text is never stored here.
++ */
++ async function onVoiceAsk(conn, msg) {
++ const id = typeof msg.id === 'string' && /^[A-Za-z0-9_.:-]{1,64}$/.test(msg.id) ? msg.id : null;
++ const text = typeof msg.text === 'string' ? msg.text.trim() : '';
++ if (!id || !text || text.length > 2000) return sendError(conn, 'bot.bad_frame', 'voice_ask needs id and text (1 to 2000 characters)');
++ const lang = typeof msg.lang === 'string' && /^[A-Za-z]{2,3}(-[A-Za-z0-9]{2,8}){0,2}$/.test(msg.lang) ? msg.lang : null;
++ const answer = (state, fields = {}) => sendFrame(conn, 'voice_answer', { id, state, ...fields });
++ const device = conn.sim ? null : await domain.devices.get(conn.device.id);
++ const owner = device && !device.revoked_at ? await domain.devices.ownerOf(device) : null;
++ if (!device || !owner || !device.voice_on) return answer('refused', { text: `Voice is off for this machine. Its owner turns it on at ${config.baseUrl}/machines.` });
++ if (!voice.allow(device.id)) return answer('refused', { text: 'That is a lot of questions this hour. Ask again in a few minutes.' });
++ answer('working', { text: 'On it.' });
++ const r = await voice.ask({ device, owner, id, text, lang });
++ return answer(r.state, { ...(r.text ? { text: r.text } : {}), ...(r.url ? { url: r.url } : {}) });
++ }
++
++ /** The owner changed a machine's settings (voice): its socket gets the fresh row and a new config. */
++ async function machineChanged(row) {
++ const conn = row && deviceConns.get(row.id);
++ if (!conn || conn.sim) return;
++ conn.device = { ...conn.device, ...row };
++ if (conn.online) await sendConfig(conn);
++ }
++
async function handlePair(conn, msg) {
conn.backlog = [];
try {
@@@ -708,7 +750,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
if (!check.valid) return refuse('bot.invalid_input', `invalid ${kind}: ${(check.errors && check.errors[0] && (check.errors[0].message || JSON.stringify(check.errors[0]))) || 'bad value'}`.slice(0, 300));
}
const summary = screen
-- ? { action: String(value.action), ...(Number.isInteger(value.seconds) ? { seconds: value.seconds } : {}), ...(Number.isInteger(value.fps) ? { fps: value.fps } : {}) }
++ ? { action: String(value.action), ...(Number.isInteger(value.seconds) ? { seconds: value.seconds } : {}), ...(Number.isInteger(value.fps) ? { fps: value.fps } : {}), ...(value.desk === 'private' ? { desk: 'private' } : {}) }
: computerSummary(kind, value);
const device = deviceConns.get(deviceId);
if (!device || !device.online) return refuse('bot.device_offline', 'the machine is offline', summary);
@@@ -717,7 +759,23 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
if (value.action === 'start' && !(offered && offered.screen && offered.screen.stream === true)) {
return refuse('bot.computer_off', 'this machine does not show its screen live: it needs OpenVibe Node v0.12.0 or newer, with the computer plugin running in your graphical session', summary);
}
-- } else if (!offered || !offered[COMPUTER_CAP[kind]]) return refuse('bot.computer_off', `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);
++ } else if (!offered || !offered[COMPUTER_CAP[kind]]) {
++ return refuse('bot.computer_off', kind === 'desk' ? NO_DESK : `this machine does not offer ${COMPUTER_CAP[kind]}: its computer plugin is off (openvibe-node, --computer)`, summary);
++ }
++ if (value.desk === 'private' && !(offered && offered.desk)) return refuse('bot.computer_off', NO_DESK, summary);
++ // Two hands, one screen: a person's input (the owner, or a share link's holder) holds the screen for a few
++ // seconds, and an agent's acting command there is refused meanwhile, so it never fights them for the mouse.
++ if (!screen && actsOnScreen(kind, value)) {
++ const turnKey = `${deviceId}|${value.desk === 'private' ? 'private' : 'main'}`;
++ const person = conn.principal.kind === 'user' || conn.principal.kind === 'share';
++ if (person) {
++ if (personTurns.size > 5000) personTurns.clear();
++ personTurns.set(turnKey, now() + config.device.personTurnMs);
++ } else if ((personTurns.get(turnKey) || 0) > now()) {
++ const left = Math.ceil((personTurns.get(turnKey) - now()) / 1000);
++ return refuse('bot.person_driving', `a person is using this screen right now: try again in ${left} s, after they stop`, summary);
++ }
++ }
const requested = Number(msg.ms);
const windowMs = Number.isInteger(requested) && requested > 0 ? Math.min(Math.max(requested, 1000), COMPUTER_MAX_MS) : COMPUTER_DEFAULT_MS;
const deviceCmdId = prefixedId('cmd', now());
@@@ -968,7 +1026,7 @@ function createRealtime({ config, keys, userAuth, log = console, now = () => Dat
/** A one-time ticket for the embedded panel's Drive (POST /panel/:id/ticket), or null. */
issueControlTicket(principal, robotId) { return tickets.issue(principal, robotId); },
isOnline, deviceState, sendToDevice, sendToRobotDevices, broadcast, closeDevice, robotState, refreshConfig, pushEstop, closeWatchers, dropSubject, operatorCommand,
-- machineCommand, machineScreen, computerOf,
++ machineCommand, machineScreen, computerOf, machineChanged,
// Real devices only: a simulator is not a machine anyone runs.
onlineCount() { let n = 0; for (const c of deviceConns.values()) if (c.online && !c.sim) n++; return n; },
devices() { return [...deviceConns.values()].filter((c) => !c.sim).map((c) => ({ device_id: c.device.id, online: c.online })); },
ddiff --git a/server/screen-shares.js b/server/screen-shares.js
iindex 8bc5678..62f6155 100644
---- a/server/screen-shares.js
++++ b/server/screen-shares.js
@@@ -13,7 +13,9 @@
* the machine is removed. Nothing here lets a holder run programs, read files or point the screen elsewhere: only the
* screen's start and stop, and the mouse and keyboard.
*
-- * create(device, owner, { label, control, days }) → { share, token }
++ * create(device, owner, { label, control, days, desk }) → { share, token }; desk `private`: the link shows and
++ * drives the machine's private desk (where an agent works), never the
++ * owner's own screen
* list(device, owner) / revoke(device, owner, id)
* open(token) → { share, device } or null
* state(token) / start(token) / stop(token) the screen, through the link
@@@ -30,21 +32,23 @@ function createScreenShares({ db, domain, hub, machineScreen, now = () => Date.n
const rate = new Map(); // share id → recent input timestamps (this process)
const shape = (r) => ({
-- id: r.id, device_id: r.device_id, label: r.label || null, control: !!r.control,
++ id: r.id, device_id: r.device_id, label: r.label || null, control: !!r.control, desk: r.desk === 'private' ? 'private' : 'main',
created_at: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
expires_at: r.expires_at ? (r.expires_at instanceof Date ? r.expires_at.toISOString() : r.expires_at) : null,
last_used_at: r.last_used_at ? (r.last_used_at instanceof Date ? r.last_used_at.toISOString() : r.last_used_at) : null,
});
-- async function create(device, owner, { label = null, control = false, days = DAYS.default } = {}) {
++ async function create(device, owner, { label = null, control = false, days = DAYS.default, desk = 'main' } = {}) {
if (!device || device.owner_subject !== owner) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');
const d = Number(days);
if (!Number.isInteger(d) || d < DAYS.min || d > DAYS.max) throw new BotError(422, 'bot.invalid_input', `days is a whole number from ${DAYS.min} to ${DAYS.max}`);
++ const where = desk == null || desk === '' ? 'main' : String(desk);
++ if (where !== 'main' && where !== 'private') throw new BotError(422, 'bot.invalid_input', 'desk is main or private');
const id = prefixedId('shr', now());
const t = newToken(32);
const l = label == null ? null : String(label).replace(/\s+/g, ' ').trim().slice(0, 80) || null;
-- await db.query(`INSERT INTO machine_screen_shares (id, device_id, owner_subject, token_hash, label, control, created_at, expires_at)
-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`, [id, device.id, owner, sha256(t), l, !!control, new Date(now()), new Date(now() + d * 86400000)]);
++ await db.query(`INSERT INTO machine_screen_shares (id, device_id, owner_subject, token_hash, label, control, created_at, expires_at, desk)
++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`, [id, device.id, owner, sha256(t), l, !!control, new Date(now()), new Date(now() + d * 86400000), where]);
const row = await db.maybe('SELECT * FROM machine_screen_shares WHERE id = $1', [id]);
return { share: shape(row), token: t };
}
@@@ -85,12 +89,12 @@ function createScreenShares({ db, domain, hub, machineScreen, now = () => Date.n
async function state(t) {
const s = await must(t);
const st = await machineScreen.state(s.device, s.share.owner_subject);
-- return { ...st, name: s.device.name || 'A computer', control: !!s.share.control, label: s.share.label || null };
++ return { ...st, name: s.device.name || 'A computer', control: !!s.share.control, label: s.share.label || null, desk: s.share.desk === 'private' ? 'private' : 'main' };
}
async function start(t) {
const s = await must(t, { control: true });
-- return await machineScreen.start(s.device, s.share.owner_subject, asShare(s), { seconds: 1800, fps: 4 });
++ return await machineScreen.start(s.device, s.share.owner_subject, asShare(s), { seconds: 1800, fps: 4, desk: s.share.desk === 'private' ? 'private' : 'main' });
}
async function stop(t) {
@@@ -115,6 +119,7 @@ function createScreenShares({ db, domain, hub, machineScreen, now = () => Date.n
if (v.action === 'scroll') out.dy = Math.max(-50, Math.min(50, Math.round(Number(v.dy) || 0)));
if (v.action === 'type') out.text = String(v.text || '').slice(0, 500);
if (v.action === 'key') out.keys = String(v.keys || '').slice(0, 40);
++ if (s.share.desk === 'private') out.desk = 'private'; // a desk link drives the desk, never the owner's screen
const recent = (rate.get(s.share.id) || []).filter((x) => now() - x < 1000);
if (recent.length >= PER_SECOND) throw new BotError(429, 'bot.rate_limited', 'slow down a little');
recent.push(now());
ddiff --git a/server/voice.js b/server/voice.js
nnew file mode 100644
iindex 0000000..c0145bd
---- /dev/null
++++ b/server/voice.js
@@@ -0,0 +1,78 @@
++'use strict';
++/**
++ * Vibe by voice on a person's own machine (consumer plan §8; openvibe-contracts 0.214.0 bot.device-message@1
++ * voice_ask / voice_answer, chat.vibe.ask). The owner turns voice on for a machine and chooses its call word
++ * (migrations/0024_machine_voice.sql); the machine (OpenVibe Node's voice plugin) listens for the word, transcribes what
++ * follows on the machine itself, and sends voice_ask. realtime.js answers `working` at once and hands the question here:
++ * Bot asks Vibe as the owner through OpenVibe.Chat (POST /internal/chat/vibe/ask with its own service token, audience
++ * openvibe.chat, chat.vibe.ask), which keeps it in the owner's "Voice on <machine>" chat, and the answer goes back to the
++ * machine to speak.
++ *
++ * createVoice({ config, fetchImpl, now }) → { enabled, callWordOf(device), validCallWord(v), allow(deviceId),
++ * ask({ device, owner, id, text, lang }) → { state, text, url? } }
++ *
++ * At most voice.perHour questions a machine an hour (in this process). Off (refused with why) without Bot's Network
++ * client or Chat's internal URL.
++ */
++const { serviceAuth } = require('openvibe-contracts');
++
++const DEFAULT_CALL_WORD = 'hey vibe';
++const ASK_TIMEOUT_MS = 60_000;
++const norm = (s) => String(s || '').toLowerCase().normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/[^\p{L}\p{N}' ]+/gu, ' ').replace(/\s+/g, ' ').trim();
++
++/** A call word the owner may choose (as Live's stream call word): 1–4 words, 3–40 characters, one word of 3 letters. */
++function validCallWord(v) {
++ const w = norm(v);
++ if (w.length < 3 || w.length > 40) return null;
++ const words = w.split(' ');
++ if (words.length > 4 || !words.some((x) => x.replace(/'/g, '').length >= 3)) return null;
++ return w;
++}
++
++function createVoice({ config, fetchImpl = globalThis.fetch, now = () => Date.now() }) {
++ const chatUrl = (config.chat && config.chat.internalUrl) || '';
++ const { clientId, clientSecret } = config.oauth || {};
++ const internalUrl = (config.network || {}).internalUrl;
++ const tokens = chatUrl && clientSecret && internalUrl
++ ? serviceAuth.createTokenClient({ tokenUrl: `${internalUrl}/oauth/token`, clientId, clientSecret, audience: 'openvibe.chat', scope: 'chat.vibe.ask', fetchImpl })
++ : null;
++ const perHour = (config.voice && config.voice.perHour) || 30;
++ const recent = new Map(); // device id → the times of its last questions (this process)
++
++ /** Room for one more question from this machine this hour (and counts it). */
++ function allow(deviceId) {
++ const t = now();
++ const list = (recent.get(deviceId) || []).filter((x) => t - x < 3_600_000);
++ if (list.length >= perHour) { recent.set(deviceId, list); return false; }
++ list.push(t);
++ if (recent.size > 5000) recent.clear();
++ recent.set(deviceId, list);
++ return true;
++ }
++
++ async function ask({ device, owner, id, text, lang }) {
++ if (!tokens) return { state: 'refused', text: 'Vibe cannot be reached from here right now.' };
++ let res;
++ try {
++ const auth = await tokens.authHeaders();
++ res = await fetchImpl(`${chatUrl}/internal/chat/vibe/ask`, {
++ method: 'POST', headers: { ...auth, 'Content-Type': 'application/json', Accept: 'application/json' },
++ body: JSON.stringify({ subject: owner, text, source: { kind: 'voice', id: device.id, name: String(device.name || '').slice(0, 80) || undefined },
++ ...(lang ? { lang } : {}), idempotency_key: `${device.id}:${id}`.slice(0, 128) }),
++ signal: AbortSignal.timeout(ASK_TIMEOUT_MS),
++ });
++ } catch (e) {
++ return { state: 'refused', text: e && e.name === 'TimeoutError' ? 'Vibe took too long to answer. Ask again in a minute.' : 'Vibe cannot be reached right now. Ask again in a minute.' };
++ }
++ const body = await res.json().catch(() => null);
++ if (!res.ok || !body || !['answered', 'refused'].includes(body.state)) return { state: 'refused', text: 'Vibe could not answer just now. Ask again in a minute.' };
++ return { state: body.state, text: String(body.text || '').slice(0, 2000), ...(typeof body.url === 'string' && body.url.startsWith('https://') ? { url: body.url } : {}) };
++ }
++
++ return {
++ enabled: !!tokens, allow, ask, validCallWord,
++ callWordOf: (device) => (device && device.call_word) || DEFAULT_CALL_WORD,
++ };
++}
++
++module.exports = { createVoice, validCallWord, DEFAULT_CALL_WORD };
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 5dbf37e..93c1779 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -676,6 +676,17 @@ function renderMachinesPage({ machines = [], pairing = null }) {
return { level: LEVELS[level] ? level : null, terminal: !!c.terminal, files: !!c.files, screen: !!c.screen, probe: !!c.probe, input: !!c.input,
screenStream: !!(c.screen && c.screen.stream === true) };
};
++ // Vibe by voice: shown for a machine whose Node runs the voice plugin (capabilities.voice).
++ const voiceRow = (m) => {
++ const v = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).voice;
++ if (!v || typeof v !== 'object') return '';
++ const word = m.call_word || 'hey vibe';
++ return `<div class="machine-voice"><span class="pill">${m.voice_on ? `Voice: "${esc(word)}"` : 'Voice: off'}</span>`
++ + (m.voice_on
++ ? `<form method="post" action="/machines/${esc(m.id)}/voice"><input type="hidden" name="on" value="0"><button type="submit" class="button quiet" aria-pressed="true">Stop Vibe answering my voice here</button></form>`
++ : `<form method="post" action="/machines/${esc(m.id)}/voice" class="inline-form"><input type="hidden" name="on" value="1"><label class="sr-only" for="cw-${esc(m.id)}">Call word</label><input id="cw-${esc(m.id)}" name="call_word" maxlength="40" value="${esc(word)}" aria-label="Call word"><button type="submit" class="button quiet" aria-pressed="false">Let Vibe answer my voice here</button></form>`)
++ + '<span class="muted">The machine listens for its call word on its own; only what you say after it goes to Vibe, and the answer is spoken here and kept in your Vibe chats.</span></div>';
++ };
const computerRow = (m) => {
const c = computerOf(m);
if (!c) return '';
@@@ -694,7 +705,7 @@ function renderMachinesPage({ machines = [], pairing = null }) {
? `<ul class="robots">${machines.map((m) => `<li class="robot-card"><span class="robot-name">${icon('plug')}<span>${esc(m.name || m.id)}</span></span>`
+ `<span class="robot-meta"><span class="pill">${m.online ? 'Online' : 'Offline'}</span>${classesOf(m).map((c) => ` <span class="pill">${esc(c)} jobs</span>`).join('')}`
+ `${m.agent_version ? ` <span class="pill">Node ${esc(m.agent_version)}</span>` : ''}${m.robot ? ` <span class="pill">Serves ${esc(m.robot)}</span>` : ''}</span>`
-- + computerRow(m)
++ + computerRow(m) + voiceRow(m)
+ `<span class="robot-actions"><form method="post" action="/machines/${esc(m.id)}/remove"><button type="submit" class="button quiet">Remove</button></form></span></li>`).join('')}</ul>`
: `<div class="empty">${icon('plug', 'icon empty-icon')}<p class="empty-title">No machines yet.</p><p>Add your computer or a server you own. It runs the jobs you start, such as a coding task on openvibe.codes, and nobody else's.</p></div>`;
const add = pairing
@@@ -725,8 +736,8 @@ ${add}
* session (public/panel.js polls `stateUrl`) and plays it like a robot's camera; Stop ends it at once.
*/
/** The owner's links to this screen (server/screen-shares.js): make one to watch or use it, see and stop the others. */
--function sharesBlock(machine, shares, madeLink, madeShare) {
-- const rows = (shares || []).map((x) => `<li><strong>${esc(x.label || 'A link')}</strong> · ${x.control ? 'watch and use' : 'watch only'} · until <time datetime="${esc(x.expires_at || '')}" data-local-time>${esc(String(x.expires_at || '').slice(0, 10))}</time>${x.last_used_at ? ` · last opened <time datetime="${esc(x.last_used_at)}" data-local-time>${esc(String(x.last_used_at).slice(0, 16).replace('T', ' '))}</time>` : ''}
++function sharesBlock(machine, shares, madeLink, madeShare, hasDesk = false) {
++ const rows = (shares || []).map((x) => `<li><strong>${esc(x.label || 'A link')}</strong> · ${x.desk === 'private' ? 'the private desk · ' : ''}${x.control ? 'watch and use' : 'watch only'} · until <time datetime="${esc(x.expires_at || '')}" data-local-time>${esc(String(x.expires_at || '').slice(0, 10))}</time>${x.last_used_at ? ` · last opened <time datetime="${esc(x.last_used_at)}" data-local-time>${esc(String(x.last_used_at).slice(0, 16).replace('T', ' '))}</time>` : ''}
<form method="post" action="/machines/${esc(machine.id)}/shares/${esc(x.id)}/revoke" class="inline-form"><button type="submit" class="button quiet">Stop this link</button></form></li>`).join('');
return `<section class="machine-shares" aria-labelledby="shares-h"><h2 id="shares-h">Share this screen</h2>
<p class="muted">Make a link for a group: put it in a workspace room on OpenVibe.Chat (the staff room's Screen) or anywhere people work together. <strong>Watch</strong> shows the screen live. <strong>Watch and use</strong> also lets them start it and use the mouse and keyboard, at the level you set on this computer: at <code>ask</code> each action waits for your approval there, and at <code>observe</code> nothing happens. Nobody can run programs or read files through a link.</p>
@@@ -734,6 +745,7 @@ ${madeLink ? `<p class="notice" role="status">Your new link${madeShare && madeSh
<form method="post" action="/machines/${esc(machine.id)}/shares" class="share-form">
<label class="field"><span class="field-label">Where it goes</span><input name="label" maxlength="80" placeholder="Staff room"></label>
<label class="field"><span class="field-label">Lasts (days)</span><input name="days" type="number" min="1" max="365" value="30"></label>
++${hasDesk ? '<label class="field"><span class="field-label">Which screen</span><select name="desk"><option value="main">Your own screen</option><option value="private">The private desk, where your agent works</option></select></label>' : ''}
<label class="check"><input type="checkbox" name="control" value="1"> They may use the mouse and keyboard too</label>
<button type="submit" class="primary">Make a link</button></form>
${rows ? `<ul class="share-list">${rows}</ul>` : ''}</section>`;
@@@ -745,11 +757,11 @@ function renderShareGone() {
}
/** /screen/:token: a shared screen, live, for a frame or a tab; with "use" links, the mouse and keyboard too. */
--function renderSharePage({ token, name, control, label }) {
++function renderSharePage({ token, name, control, label, desk = 'main' }) {
const base = `/screen/${encodeURIComponent(token)}`;
const body = `<main class="page share-page" data-share="${esc(base)}" data-control="${control ? '1' : '0'}">
<header class="share-head"><h1>${esc(name)}${label ? ` <span class="muted">· ${esc(label)}</span>` : ''}</h1>
--<p class="muted share-mode">${control ? 'Shared to watch and use' : 'Shared to watch'}</p></header>
++<p class="muted share-mode">${control ? 'Shared to watch and use' : 'Shared to watch'}${desk === 'private' ? ': the private desk, where an agent works (never the owner\'s own screen)' : ''}</p></header>
<figure class="camera machine-screen share-screen" data-screen-state="${esc(base)}/state.json" tabindex="${control ? '0' : '-1'}" aria-label="The shared screen${control ? ': turn on Use to click and type on it' : ''}">
<div class="camera-screen" data-camera-screen>${icon('plug', 'icon camera-icon')}<p class="camera-title" data-screen-status role="status">Looking for the screen…</p>
<p class="camera-sub">The picture comes from the computer itself, a few frames a second.</p></div>
@@@ -763,7 +775,7 @@ ${control ? `<div class="share-controls">
return page(`${name}: shared screen`, body, { scripts: ['/panel/panel.js', ...(control ? ['/panel/screen-share.js'] : [])], bodyClass: 'share-body', description: 'A shared computer screen on OpenVibe.Bot.' });
}
--function renderMachineScreenPage({ machine, until = null, error = null, idle = false, shares = [], madeLink = null, madeShare = null }) {
++function renderMachineScreenPage({ machine, until = null, error = null, idle = false, shares = [], madeLink = null, madeShare = null, hasDesk = false }) {
const name = machine.name || machine.id;
const stateUrl = `/machines/${encodeURIComponent(machine.id)}/screen.json`;
// Idle (nothing is shown, as from a link): a start button instead of waiting for a stream nobody started.
@@@ -772,8 +784,10 @@ function renderMachineScreenPage({ machine, until = null, error = null, idle = f
<main class="page">
<h1>${esc(name)}: screen</h1>
<p class="muted">Show this computer's screen here, live, to you only, for 10 minutes. Nothing is recorded, and the computer says on its desktop that its screen is live.</p>
--<form method="post" action="/machines/${esc(machine.id)}/screen"><button type="submit" class="primary">Show my screen</button></form>
--${sharesBlock(machine, shares, madeLink, madeShare)}
++<form method="post" action="/machines/${esc(machine.id)}/screen" class="inline-form"><button type="submit" class="primary">Show my screen</button></form>
++${hasDesk ? `<form method="post" action="/machines/${esc(machine.id)}/screen" class="inline-form"><input type="hidden" name="desk" value="private"><button type="submit" class="button">Show the private desk</button></form>
++<p class="muted">The private desk is a screen of its own on this computer, where your agent works without touching yours.</p>` : ''}
++${sharesBlock(machine, shares, madeLink, madeShare, hasDesk)}
</main>`;
return page(`${name}: screen`, body, { description: 'Your machine\'s screen, live, for you only.', canonical: '/machines' });
}
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 05a6521..97557f1 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -28,6 +28,7 @@
* Pages are never cached: a pairing page carries a live code. Adding a robot and minting a code count against
* the signed-in person's `bot.robot.manage` limit, the same one /api/v1 applies.
*/
++const { validCallWord } = require('../voice');
const path = require('path');
const express = require('express');
const seo = require('openvibe-shared/seo');
@@@ -585,17 +586,19 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
return mine;
};
const asUser = (me) => ({ kind: 'user', sub: me.subject });
++ // The machine has a private desk to show (Node v0.15.0 on Linux): its owner may show it, or share it, instead.
++ const hasDesk = (mine) => !!(hub && (hub.computerOf(mine.id) || {}).desk);
r.post('/machines/:id/screen', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
const mine = await myMachine(req, me);
try {
-- const out = await machineScreen.start(mine, me.subject, asUser(me));
++ const out = await machineScreen.start(mine, me.subject, asUser(me), { desk: req.body && req.body.desk === 'private' ? 'private' : 'main' });
res.redirect(303, `/machines/${encodeURIComponent(mine.id)}/screen?until=${encodeURIComponent(out.until)}`);
} catch (e) {
if (!(e instanceof BotError)) throw e;
res.setHeader('Content-Security-Policy', PANEL_CSP);
-- res.status(e.status).type('html').send(renderMachineScreenPage({ machine: mine, error: e.detail || e.message }));
++ res.status(e.status).type('html').send(renderMachineScreenPage({ machine: mine, error: e.detail || e.message, hasDesk: hasDesk(mine) }));
}
}));
r.get('/machines/:id/screen', page(async (req, res) => {
@@@ -606,7 +609,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const idle = !until && (await machineScreen.state(mine, me.subject)).state !== 'live';
res.setHeader('Vary', 'Cookie');
res.setHeader('Content-Security-Policy', PANEL_CSP);
-- res.type('html').send(renderMachineScreenPage({ machine: mine, until, idle, shares: await shares.list(mine, me.subject).catch(() => []) }));
++ res.type('html').send(renderMachineScreenPage({ machine: mine, until, idle, shares: await shares.list(mine, me.subject).catch(() => []), hasDesk: hasDesk(mine) }));
}));
r.get('/machines/:id/screen.json', page(async (req, res) => {
const me = requireUser(req);
@@@ -630,9 +633,9 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
const mine = await myMachine(req, me);
-- const made = await shares.create(mine, me.subject, { label: req.body && req.body.label, control: req.body && req.body.control === '1', days: (req.body && req.body.days) || 30 });
++ const made = await shares.create(mine, me.subject, { label: req.body && req.body.label, control: req.body && req.body.control === '1', days: (req.body && req.body.days) || 30, desk: req.body && req.body.desk === 'private' ? 'private' : 'main' });
res.setHeader('Content-Security-Policy', PANEL_CSP);
-- res.type('html').send(renderMachineScreenPage({ machine: mine, idle: true, shares: await shares.list(mine, me.subject), madeLink: `${config.baseUrl}/screen/${made.token}`, madeShare: made.share }));
++ res.type('html').send(renderMachineScreenPage({ machine: mine, idle: true, shares: await shares.list(mine, me.subject), madeLink: `${config.baseUrl}/screen/${made.token}`, madeShare: made.share, hasDesk: hasDesk(mine) }));
}));
r.post('/machines/:id/shares/:share/revoke', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
@@@ -656,7 +659,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
res.setHeader('Content-Security-Policy', shareCsp);
const s = await shares.open(req.params.token).catch(() => null);
if (!s) return res.status(404).type('html').send(renderShareGone());
-- res.type('html').send(renderSharePage({ token: req.params.token, name: s.device.name || 'A computer', control: !!s.share.control, label: s.share.label }));
++ res.type('html').send(renderSharePage({ token: req.params.token, name: s.device.name || 'A computer', control: !!s.share.control, label: s.share.label, desk: s.share.desk === 'private' ? 'private' : 'main' }));
});
r.get('/screen/:token/state.json', shareJson(async (req) => await shares.state(req.params.token)));
r.post('/screen/:token/start', ...shareLimit, shareJson(async (req) => await shares.start(req.params.token)));
@@@ -692,6 +695,19 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
await domain.machines.setAgentComputer(mine.id, req.body && req.body.on === '1');
res.redirect(303, '/machines');
}));
++ // The owner lets Vibe answer their voice on this machine (OpenVibe Node's voice plugin), with a call word, or stops it.
++ r.post('/machines/:id/voice', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
++ const me = requireUser(req);
++ if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
++ const mine = (await domain.machines.list(me.subject)).find((d) => d.id === req.params.id);
++ if (!mine) throw new BotError(404, 'bot.device_not_found', 'no such machine of yours');
++ const on = req.body && req.body.on === '1';
++ const word = req.body && req.body.call_word ? validCallWord(req.body.call_word) : null;
++ if (on && req.body && req.body.call_word && !word) throw new BotError(422, 'bot.invalid_input', 'a call word is 1 to 4 words, 3 to 40 characters, with one word of three letters or more');
++ const row = await domain.machines.setVoice(mine.id, on, word);
++ if (hub && hub.machineChanged) await hub.machineChanged(row);
++ res.redirect(303, '/machines');
++ }));
r.post('/machines/:id/remove', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
const me = requireUser(req);
if (!sameOrigin(req)) throw new BotError(403, 'bot.forbidden', 'cross-site form posts are refused');
ddiff --git a/test/machine-computer.test.js b/test/machine-computer.test.js
iindex c9b886b..b9ae535 100644
---- a/test/machine-computer.test.js
++++ b/test/machine-computer.test.js
@@@ -8,7 +8,7 @@ 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 t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0', BOT_PERSON_TURN_MS: '400' } });
const alex = t.network.newUser('alex');
const bob = t.network.newUser('bob');
let seq = 0;
@@@ -34,8 +34,13 @@ const { boot, check, done } = require('./helpers/app');
else if (m.kind === 'probe') c.send(frame('ack', { id: m.id, data: { status: 200, body: '{"up":true}', ms: 3 } }));
else if (m.kind === 'input') c.send(frame('ack', { id: m.id, data: { done: true, action: m.value.action } }));
else if (m.kind === 'browser') c.send(frame('ack', { id: m.id, data: { url: m.value.url || 'https://example.org/', title: 'Example', text: 'Example page', truncated: false, links: [] } }));
++ else if (m.kind === 'desk') c.send(frame('ack', { id: m.id, data: { running: m.value.action !== 'stop', display: ':91', width: 1280, height: 800 } }));
++ else if (m.kind === 'screen' && m.value.desk === 'private') c.send(frame('ack', { id: m.id, data: { format: 'png', width: 1280, height: 800, content_b64: 'iVBORw0KGgo=' } }));
else c.send(frame('nack', { id: m.id, fault_code: 'not_allowed', message: 'this machine allows observing only' }));
});
++ // Beat like a real Node while the socket is open, so a longer check never sees its machine go offline.
++ const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, Math.max(100, Math.floor(config.heartbeat_ms / 2)));
++ c.ws.on('close', () => clearInterval(beat));
await poll(() => t.hub.computerOf(deviceId) !== null || !computer);
return { c, deviceId, config };
}
@@@ -90,6 +95,51 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(!JSON.stringify(rows).includes('hunter3') && !JSON.stringify(rows).includes('token=abc'));
});
++ await check('the private desk (contracts 0.214.0): start, stop and status reach a machine that has one, and desk: private acts there; one without says so', async () => {
++ const withDesk = await machine(alex, { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true } });
++ const listed = await t.call('GET', `/api/v1/machines/${withDesk.deviceId}/computer`, { user: alex });
++ assert.strictEqual(listed.json.machine.computer.desk.window_manager, 'openbox', 'the machine says it has a desk');
++ const start = await cmd(withDesk.deviceId, { kind: 'desk', value: { action: 'start', width: 1280, height: 800 } }, { user: alex });
++ assert.deepStrictEqual([start.status, start.json.result, start.json.data.display], [200, 'ack', ':91']);
++ assert.deepStrictEqual(seen.at(-1).value, { action: 'start', width: 1280, height: 800 });
++ const look = await cmd(withDesk.deviceId, { kind: 'screen', value: { desk: 'private', max_width: 1280 } }, { user: alex });
++ assert.deepStrictEqual([look.json.result, look.json.data.width], ['ack', 1280]);
++ const click = await cmd(withDesk.deviceId, { kind: 'input', value: { action: 'click', x: 10, y: 10, width: 1280, height: 800, desk: 'private' } }, { user: alex });
++ assert.strictEqual(click.json.result, 'ack');
++ assert.strictEqual(seen.at(-1).value.desk, 'private', 'the machine is told to act on the desk');
++ const open = await cmd(withDesk.deviceId, { kind: 'browser', value: { action: 'open', url: 'https://example.org/', desk: 'private' } }, { user: alex });
++ assert.strictEqual(open.json.result, 'ack');
++ const rows = await t.db.many("SELECT kind, value FROM command_audit WHERE robot_id = $1 AND result = 'ack' ORDER BY id", [withDesk.deviceId]);
++ assert.deepStrictEqual(rows.map((r) => [r.kind, r.value]), [
++ ['desk', { action: 'start', width: 1280, height: 800 }], ['screen', { max_width: 1280, desk: 'private' }],
++ ['input', { action: 'click', x: 10, y: 10, desk: 'private' }], ['browser', { action: 'open', host: 'example.org', desk: 'private' }],
++ ], 'the audit says where it acted');
++ const tiny = await cmd(withDesk.deviceId, { kind: 'desk', value: { action: 'start', width: 100, height: 100 } }, { user: alex });
++ assert.deepStrictEqual([tiny.status, tiny.json.code], [422, 'bot.invalid_input'], 'the contract\'s sizes');
++ const none = await cmd(mine.deviceId, { kind: 'desk', value: { action: 'status' } }, { user: alex });
++ assert.deepStrictEqual([none.status, none.json.code], [409, 'bot.computer_off']);
++ assert.match(none.json.detail || none.json.reason || '', /no private desk/);
++ const blind = await cmd(mine.deviceId, { kind: 'input', value: { action: 'click', x: 1, y: 1, width: 2, height: 2, desk: 'private' } }, { user: alex });
++ assert.deepStrictEqual([blind.status, blind.json.code], [409, 'bot.computer_off'], 'desk: private on a machine without one never reaches its own screen');
++ // The person always wins: the owner just clicked on the desk, so their agent's click there waits; on the
++ // owner's own screen (another screen) and for reading the page it goes on.
++ const token = actor();
++ await t.call('POST', `/api/v1/machines/${withDesk.deviceId}/agent-computer`, { user: alex, body: { on: true } });
++ assert.strictEqual((await cmd(withDesk.deviceId, { kind: 'input', value: { action: 'move', x: 5, y: 5, width: 1280, height: 800, desk: 'private' } }, { user: alex })).json.result, 'ack');
++ const agentClick = await cmd(withDesk.deviceId, { kind: 'input', value: { action: 'click', x: 20, y: 20, width: 1280, height: 800, desk: 'private' } }, asActor(token, alex.subject));
++ assert.deepStrictEqual([agentClick.status, agentClick.json.code], [409, 'bot.person_driving']);
++ assert.match(agentClick.json.detail || agentClick.json.reason || '', /a person is using this screen right now/);
++ const agentRead = await cmd(withDesk.deviceId, { kind: 'browser', value: { action: 'read', desk: 'private' } }, asActor(token, alex.subject));
++ assert.strictEqual(agentRead.json.result, 'ack', 'reading never fights for the screen');
++ const elsewhere = await cmd(withDesk.deviceId, { kind: 'input', value: { action: 'move', x: 1, y: 1, width: 2, height: 2 } }, asActor(token, alex.subject));
++ assert.strictEqual(elsewhere.json.result, 'ack', 'the owner\'s own screen is another screen');
++ await t.wait(450); // BOT_PERSON_TURN_MS is 400 in this boot
++ const later = await cmd(withDesk.deviceId, { kind: 'input', value: { action: 'click', x: 20, y: 20, width: 1280, height: 800, desk: 'private' } }, asActor(token, alex.subject));
++ assert.strictEqual(later.json.result, 'ack', 'once the person stops, the agent goes on');
++ await t.call('POST', `/api/v1/machines/${withDesk.deviceId}/agent-computer`, { user: alex, body: { on: false } }); // the checks below start with no machine open to the agent
++ withDesk.c.close();
++ });
++
await check('the audit keeps what was asked in short, never stdin, arguments or content', async () => {
const rows = await t.db.many('SELECT * FROM command_audit WHERE robot_id = $1 ORDER BY id', [mine.deviceId]);
assert.ok(rows.length >= 3);
ddiff --git a/test/screen-shares.test.js b/test/screen-shares.test.js
iindex ac8298b..06881d0 100644
---- a/test/screen-shares.test.js
++++ b/test/screen-shares.test.js
@@@ -102,6 +102,28 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(!JSON.stringify(audit).includes('hello team'), 'typed text is not kept');
});
++ await check('a desk link shows and drives the private desk, never the owner\'s screen', async () => {
++ let r = await fetch(`${t.base}/machines/${deviceId}/screen`, { headers: cookie(alex) });
++ assert.ok(!(await r.text()).includes('Show the private desk'), 'no desk, no desk button');
++ c.send(frame('status', { capabilities: { computer: { ...COMPUTER, desk: { level: 'trusted', window_manager: 'openbox', input: true, screen: true } } }, drivers: [], faults: [] }));
++ await poll(() => (t.hub.computerOf(deviceId) || {}).desk);
++ r = await fetch(`${t.base}/machines/${deviceId}/screen`, { headers: cookie(alex) });
++ const page = await r.text();
++ assert.ok(page.includes('Show the private desk') && page.includes('<option value="private">The private desk, where your agent works</option>'));
++ r = await form(`/machines/${deviceId}/shares`, alex, 'label=Agent+desk&days=1&control=1&desk=private');
++ const html = await r.text();
++ const desk = linkOf(html);
++ assert.ok(desk, 'a desk link');
++ assert.match(html, /Agent desk<\/strong> · the private desk · watch and use/);
++ assert.match(await (await fetch(`${t.base}/screen/${desk}`)).text(), /the private desk, where an agent works/);
++ assert.strictEqual((await json('GET', `/screen/${desk}/state.json`)).json.desk, 'private');
++ assert.strictEqual((await json('POST', `/screen/${desk}/start`)).status, 200);
++ assert.strictEqual(seen.filter((m) => m.kind === 'screen_stream').at(-1).value.desk, 'private', 'the live screen of the desk');
++ assert.strictEqual((await json('POST', `/screen/${desk}/input`, { action: 'click', x: 5, y: 5, width: 100, height: 100 })).status, 200);
++ assert.strictEqual(seen.filter((m) => m.kind === 'input').at(-1).value.desk, 'private', 'a click on the desk, never the owner\'s screen');
++ assert.strictEqual((await t.db.maybe("SELECT desk FROM machine_screen_shares WHERE label = 'Agent desk'")).desk, 'private');
++ });
++
await check('a burst is slowed down; a revoked link stops at once', async () => {
let limited = 0;
for (let i = 0; i < 12; i++) { if ((await json('POST', `/screen/${use}/input`, { action: 'move', x: i, y: i, width: 100, height: 100 })).status === 429) limited++; }
ddiff --git a/test/voice.test.js b/test/voice.test.js
nnew file mode 100644
iindex 0000000..5175a2f
---- /dev/null
++++ b/test/voice.test.js
@@@ -0,0 +1,105 @@
++'use strict';
++// Vibe by voice on a person's own machine (server/voice.js, migration 0024, contracts 0.214.0 voice_ask / voice_answer):
++// the owner turns voice on for a machine (with a call word), its config says so, a voice_ask is answered `working` at
++// once and then with Vibe's answer, asked of OpenVibe.Chat as the owner (chat.vibe.ask, one source per machine, an
++// idempotency key per question); voice off, a machine nobody owns or too many questions an hour are refused with why.
++const assert = require('assert');
++const http = require('http');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const chatCalls = [];
++ let chatAnswer = { state: 'answered', text: 'Clear skies after six.', url: 'https://openvibe.chat/vibe/v-0123456789ab', chat: 'v-0123456789ab' };
++ const chat = http.createServer((req, res) => {
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ chatCalls.push({ path: req.url, auth: req.headers.authorization || '', body: JSON.parse(body || '{}') });
++ res.writeHead(200, { 'Content-Type': 'application/json' });
++ res.end(JSON.stringify(chatAnswer));
++ });
++ });
++ await new Promise((r) => chat.listen(0, '127.0.0.1', r));
++ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0', BOT_CHAT_INTERNAL_URL: `http://127.0.0.1:${chat.address().port}`, BOT_VOICE_PER_HOUR: '3' } });
++ const alex = t.network.newUser('alex');
++ let seq = 0;
++ const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });
++ 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(30); } return null; };
++
++ const principal = t.network.addNode({ owner: alex.subject, ref: `worker:${alex.subject}`, name: 'desk-pc' });
++ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
++ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;
++ const firstConfig = await c.waitFor((m) => m.type === 'config');
++ c.send(frame('status', { capabilities: { voice: { engine: 'vosk', call_word: true }, computer: { screen: { level: 'trusted' } } }, drivers: [], faults: [] }));
++ const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, Math.max(100, Math.floor(firstConfig.heartbeat_ms / 2)));
++ c.ws.on('close', () => clearInterval(beat));
++ const answers = [];
++ c.ws.on('message', (raw) => { try { const m = JSON.parse(raw.toString()); if (m.type === 'voice_answer') answers.push(m); } catch { /* */ } });
++ const ask = async (id, text = 'what is the weather like this evening') => {
++ c.send(frame('voice_ask', { id, text, lang: 'en-US' }));
++ return poll(() => { const got = answers.filter((a) => a.id === id); return got.some((a) => a.state !== 'working') ? got : null; });
++ };
++
++ try {
++ await check('voice off: the machine is told nothing about listening, and a voice_ask is refused with where to turn it on', async () => {
++ assert.strictEqual(firstConfig.voice, undefined, 'config says nothing of voice while it is off');
++ const got = await ask('va-1');
++ assert.deepStrictEqual(got.map((a) => a.state), ['refused']);
++ assert.match(got[0].text, /Voice is off for this machine.*\/machines/);
++ assert.strictEqual(chatCalls.length, 0, 'Chat was not asked');
++ });
++
++ await check('the owner turns it on with a call word; the machine\'s config says so; the machines page shows it', async () => {
++ const r = await t.call('POST', `/api/v1/machines/${deviceId}/voice`, { user: alex, body: { on: true, call_word: 'Hey Desk' } });
++ assert.deepStrictEqual([r.status, r.json.machine.voice], [200, { on: true, call_word: 'hey desk' }]);
++ const cfg = await c.waitFor((m) => m.type === 'config' && m.voice === true);
++ assert.strictEqual(cfg.call_word, 'hey desk');
++ const bad = await t.call('POST', `/api/v1/machines/${deviceId}/voice`, { user: alex, body: { on: true, call_word: 'a' } });
++ assert.deepStrictEqual([bad.status, bad.json.code], [422, 'bot.invalid_input']);
++ const page = await fetch(`${t.base}/machines`, { headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } }).then((x) => x.text());
++ assert.match(page, /Voice: &quot;hey desk&quot;|Voice: "hey desk"/);
++ assert.match(page, /Stop Vibe answering my voice here/);
++ });
++
++ await check('a voice_ask: working at once, then Vibe\'s answer, asked of Chat as the owner with one source per machine', async () => {
++ const got = await ask('va-2');
++ assert.deepStrictEqual(got.map((a) => a.state), ['working', 'answered']);
++ assert.deepStrictEqual([got[1].text, got[1].url], ['Clear skies after six.', 'https://openvibe.chat/vibe/v-0123456789ab']);
++ const call = chatCalls.at(-1);
++ assert.strictEqual(call.path, '/internal/chat/vibe/ask');
++ assert.match(call.auth, /^Bearer /);
++ assert.deepStrictEqual(call.body, { subject: alex.subject, text: 'what is the weather like this evening', source: { kind: 'voice', id: deviceId, name: 'desk-pc' }, lang: 'en-US', idempotency_key: `${deviceId}:va-2` });
++ const minted = t.network.tokenCalls.find((x) => x.audience === 'openvibe.chat');
++ assert.ok(minted && /chat\.vibe\.ask/.test(minted.scope || ''), 'its own token for Chat, scoped to chat.vibe.ask');
++ });
++
++ await check('Chat refuses (the allowance spent): the machine hears why; too many questions an hour are refused here', async () => {
++ chatAnswer = { state: 'refused', text: 'You have used today\'s free answers.', code: 'chat.vibe.allowance' };
++ const got = await ask('va-3');
++ assert.deepStrictEqual(got.map((a) => a.state), ['working', 'refused']);
++ assert.match(got[1].text, /free answers/);
++ // BOT_VOICE_PER_HOUR is 3 here and the asks with voice on so far are va-2 and va-3 (va-1 came while it was off).
++ assert.deepStrictEqual((await ask('va-4')).map((a) => a.state), ['working', 'refused'], 'the third this hour is still asked');
++ const before = chatCalls.length;
++ const over = await ask('va-5');
++ assert.deepStrictEqual(over.map((a) => a.state), ['refused'], 'the fourth this hour is refused without asking Chat');
++ assert.match(over[0].text, /a lot of questions this hour/);
++ assert.strictEqual(chatCalls.length, before);
++ });
++
++ await check('turned off again: the config stops it, and a bad frame is refused', async () => {
++ const r = await t.call('POST', `/api/v1/machines/${deviceId}/voice`, { user: alex, body: { on: false } });
++ assert.strictEqual(r.json.machine.voice.on, false);
++ await c.waitFor((m) => m.type === 'config' && m.voice === undefined);
++ c.send(frame('voice_ask', { id: 'bad id with spaces', text: 'x' }));
++ const err = await c.waitFor((m) => m.type === 'error');
++ assert.strictEqual(err.code, 'bot.bad_frame');
++ const bob = t.network.newUser('bob');
++ assert.strictEqual((await t.call('POST', `/api/v1/machines/${deviceId}/voice`, { user: bob, body: { on: true } })).status, 404, 'only its owner');
++ });
++ } finally {
++ c.close();
++ chat.close();
++ await done(t);
++ }
++})().catch((e) => { console.error(e); process.exit(1); });