openvibers/openvibe.bot Commits
AI on your own machines: kind ai to a machine's own model server, for its owner and for OpenVibe.AI once they turn it on (bot.machine.ai), audited without the text; contracts v0.219.0 (#189)
Changes
@@@ -187,7 +187,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras BotOpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.<!-- versions:start -->--- openvibe-contracts: v0.218.0++- openvibe-contracts: v0.219.0- openvibe-sdk: v0.65.0- openvibe-shared: v4.15.2<!-- versions:end -->@@@ -5,11 +5,11 @@"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step","port": 4630,"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",-- "contracts": "openvibe-contracts v0.218.0",++ "contracts": "openvibe-contracts v0.219.0","sdk": "openvibe-sdk v0.65.0","shared": "openvibe-shared v4.15.2","packages": [-- "openvibe-contracts v0.218.0",++ "openvibe-contracts v0.219.0","openvibe-sdk v0.65.0","openvibe-shared v4.15.2"],@@@ -18,6 +18,7 @@"bot.robot.manage","bot.robot.control","bot.computer.control",++ "bot.machine.ai","bot.device.connect","bot.job.dispatch","bot.resource.read",nnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- AI on your own machines (contracts 0.219.0, bot.command@1 kind ai; capability bot.machine.ai): the owner lets their++-- AI (OpenVibe.AI, acting for them) run a model on this machine's own model server through OpenVibe Node's ai plugin.++-- Off until they turn it on on openvibe.bot/machines.++ALTER TABLE devices ADD COLUMN IF NOT EXISTS ai_models boolean NOT NULL DEFAULT false;@@@ -13,7 +13,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.218.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.219.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.2","pg": "^8.23.0",@@@ -845,9 +845,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.218.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.218.0",-- "integrity": "sha512-/cgAdxibISceSva51oCYz/382iLsXW5RU6kt8Lyt5JWPiL6QhL0193boUZS9Ls4SxnEgT/fiAtMU3IJY4qMKwg==",++ "version": "0.219.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.219.0",++ "integrity": "sha512-Ka5nyz0UGjfQrN91vKL4TJWV84Fb6Nr8bGrfoSuPSKNv1F5U/uvPG9lFsw5bw9ocn6xMrypUDwsyu+hn/NnmVA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -25,7 +25,7 @@"dotenv": "^16.4.7","express": "^4.21.2","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.218.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.219.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.2","pg": "^8.23.0",@@@ -215,6 +215,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf.robot-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }/* A machine's computer plugin (Node docs/computer.md): its level, what waits on it, and the agent switch, full width. */.machine-computer { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--line); }++.machine-ai { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; flex-basis: 100%; }@media (min-width: 36rem) { .robot-card { grid-template-columns: minmax(0, 1fr) auto; align-items: center; } .robot-meta { grid-column: 1; grid-row: 2; } .robot-actions { grid-column: 2; grid-row: 1 / span 2; } .machine-computer { grid-column: 1 / -1; grid-row: 3; } .machine-computer form { margin-left: auto; } }.empty { text-align: center; padding: 2rem 1rem; margin-bottom: 1.5rem; border: 1px dashed var(--line); border-radius: var(--radius); color: var(--muted); }.empty-icon { width: 3rem; height: 3rem; stroke-width: 1.5; color: var(--accent); }@@@ -55,6 +55,7 @@ const CAP = {connect: 'bot.device.connect',dispatch: 'bot.job.dispatch',computer: 'bot.computer.control',++ ai: 'bot.machine.ai',};/** Services that act as a person's agent (plan T15/T17): they drive a robot only where its owner turned agent_control on. */@@@ -66,7 +67,11 @@ const AGENT_SERVICES = new Set(['svc:actor']);const MACHINE_SERVICES = {'svc:actor': { kinds: null, flag: 'agent_computer', off: 'the owner has not let their agent use this computer' },'svc:watch': { kinds: new Set(['probe']), flag: 'watch_probe', off: 'the owner has not let their watches check things from this computer' },++ // AI on your own machines (contracts 0.219.0): OpenVibe.AI runs a model on the owner's own model server, nothing else.++ 'svc:ai': { kinds: new Set(['ai']), flag: 'ai_models', off: 'the owner has not let their AI use this computer\'s models', cap: 'bot.machine.ai' },};++// A model on a person's machine is OpenVibe.AI's and the owner's only: no other service sends kind ai.++const AI_KIND = 'ai';function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null, claims = null }) {const r = express.Router();@@@ -408,8 +413,10 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submconst svc = req.principal.kind === 'service' ? MACHINE_SERVICES[req.principal.sub] : null;if (req.principal.kind === 'service') {if (!svc) fail(403, 'bot.forbidden', 'a machine is used by its owner, their agent and their watches only');-- if (!apiAuth.granted(req.principal, CAP.computer)) fail(403, 'capability.denied', `${CAP.computer} not granted`);++ const cap = svc.cap || CAP.computer;++ if (!apiAuth.granted(req.principal, cap)) fail(403, 'capability.denied', `${cap} not granted`);if (kind != null && svc.kinds && !svc.kinds.has(String(kind))) fail(403, 'bot.forbidden', `${req.principal.sub} may send ${[...svc.kinds].join(', ')} only`);++ if (String(kind) === AI_KIND && !(svc.kinds && svc.kinds.has(AI_KIND))) fail(403, 'bot.forbidden', 'a model on a person\'s machine runs for OpenVibe.AI and the owner only');}const device = await domain.devices.get(String(req.params.id || '').slice(0, 64));const ownerSubject = device && !device.revoked_at ? await domain.devices.ownerOf(device) : null;@@@ -423,11 +430,11 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submconst sensors = st.telemetry && st.telemetry.sensors && typeof st.telemetry.sensors === 'object' ? st.telemetry.sensors : {};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,++ id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer, ai_models: !!device.ai_models, 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'), desk: pick('desk'),++ terminal: pick('terminal'), files: pick('files'), screen: pick('screen'), probe: pick('probe'), input: pick('input'), browser: pick('browser'), desk: pick('desk'), ai: pick('ai'),waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,// Desks running now (the Node's telemetry): where an agent's next desk has room (Desks plan D6, placement).desks_running: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,@@@ -507,6 +514,14 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submif (hub && hub.machineChanged) await hub.machineChanged(updated);res.json({ machine: machineOut(updated) });}));++ // The owner lets their AI run models on this machine, or stops it (never the AI itself).++ r.post('/machines/:id/ai-models', 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 updated = await domain.machines.setAiModels(device.id, req.body.on);++ 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');@@@ -751,6 +751,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =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())]);}++ /** Let the owner's AI run models on this machine (OpenVibe Node's ai plugin), or stop it (the owner check is the caller's). */++ async function setAiModels(deviceId, value) {++ return db.maybe('UPDATE devices SET ai_models = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);++ }/** Let the owner's agent use this machine's computer plugin, or stop it (the owner check is the caller's). */async function setAgentComputer(deviceId, value) {return db.maybe('UPDATE devices SET agent_computer = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);@@@ -1248,7 +1252,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, setVoice,++ machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice,screen: { key: screenKey, live: screenLive, end: screenEnd, archive: screenArchive } },streaming: { get: streaming, set: setStreaming },video: { live: liveVideo },@@@ -42,7 +42,7 @@ const DEVICE_MAX_PAYLOAD = 640 * 1024;// 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 COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser', desk: 'desk', ai: 'ai' };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';const NO_NAMED_DESKS = 'this machine runs one desk only (private): several named desks need OpenVibe Node v0.19.0 or newer';const NO_DESK_PROGRAMS = 'running a program on a desk needs OpenVibe Node v0.26.0 or newer (an older one would run it on the person\'s own screen)';@@@ -68,6 +68,8 @@ function computerSummary(kind, value) {const where = deskOf(value) !== 'main' ? { desk: deskOf(value) } : {};if (kind === 'desk') return { action: value.action, ...(typeof value.desk === 'string' ? { desk: value.desk } : {}), ...(typeof value.kind === 'string' ? { kind: value.kind } : {}), ...(typeof value.profile === 'string' ? { profile: value.profile } : {}), ...(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, ...where, ...(value.detach === true ? { detach: true } : {}) };++ // A model's prompt is the person's own words: the audit keeps the model, how many messages and how long, never the text.++ if (kind === 'ai') return { model: String(value.model).slice(0, 120), messages: value.messages.length, chars: value.messages.reduce((n, m) => n + String(m.content || '').length, 0) };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.@@@ -724,7 +724,9 @@ 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),// Desks for its agents (Desks plan): how many at once, which kinds, and whether sign-ins carry over.-- desks: d ? { max: Number.isInteger(d.max_desks) ? d.max_desks : 1, kinds: Array.isArray(d.kinds) ? d.kinds : ['desktop'], keeps: d.profiles === true } : null };++ desks: d ? { max: Number.isInteger(d.max_desks) ? d.max_desks : 1, kinds: Array.isArray(d.kinds) ? d.kinds : ['desktop'], keeps: d.profiles === true } : null,++ // Its own models (OpenVibe Node's ai plugin): what it offers, and whether a model server answers.++ ai: c.ai && typeof c.ai === 'object' ? { models: Array.isArray(c.ai.models) ? c.ai.models.slice(0, 20).map(String) : [], ready: c.ai.ready === true } : null };};// Vibe by voice: shown for a machine whose Node runs the voice plugin (capabilities.voice).const voiceRow = (m) => {@@@ -759,6 +761,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`+ (c.screenStream && m.online ? `<form method="post" action="/machines/${esc(m.id)}/screen"><button type="submit" class="button quiet">${m.computer_screen_stream === 'live' ? 'Watch my screen' : 'Show my screen'}</button></form>` : '')++ + (c.ai ? `<div class="machine-ai"><span class="pill">${c.ai.ready ? 'Models' : 'Models: no server answers'}</span>${c.ai.models.length ? `<span class="muted">${esc(c.ai.models.join(', '))}</span>` : ''}`++ + `<form method="post" action="/machines/${esc(m.id)}/ai-models"><input type="hidden" name="on" value="${m.ai_models ? '0' : '1'}">`++ + `<button type="submit" class="button quiet" aria-pressed="${m.ai_models ? 'true' : 'false'}">${m.ai_models ? 'Stop my AI using this computer\'s models' : 'Let my AI use this computer\'s models'}</button></form></div>` : '')+ (c.probe ? `<form method="post" action="/machines/${esc(m.id)}/watch-probe"><input type="hidden" name="on" value="${m.watch_probe ? '0' : '1'}">`+ `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from this computer'}</button></form>` : '')+ '</div>';@@@ -580,6 +580,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limicomputer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,computer_screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',computer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,++ ai_models: !!d.ai_models,};}));// ── The live screen (plan T14): the owner watches their machine's screen on their own private stream ──@@@ -761,6 +762,14 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limires.redirect(303, '/machines');}));// The owner lets their agent (OpenVibe.Actor) use this machine's computer plugin, or stops it.++ // The owner lets their AI run models on this machine, or stops it (AI on your own machines, contracts 0.219.0).++ r.post('/machines/:id/ai-models', ...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);++ await domain.machines.setAiModels(mine.id, req.body && req.body.on === '1');++ res.redirect(303, '/machines');++ }));r.post('/machines/:id/agent-computer', ...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');@@@ -34,6 +34,7 @@ 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 === 'ai') c.send(frame('ack', { id: m.id, data: { model: m.value.model, message: { role: 'assistant', content: 'A lock one thread holds at a time.' }, usage: { total_tokens: 20 } } }));else if (m.kind === 'desk' && m.value.action === 'list') c.send(frame('ack', { id: m.id, data: { desks: [{ desk: 'desk-task-a', kind: 'browser', running: true, width: 1280, height: 800, since: 1793102400 }, { desk: 'private', kind: 'desktop', running: true, display: ':91', width: 1280, height: 800, since: 1793102000 }], max_desks: 4, available: true } }));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 && m.value.desk !== 'main') c.send(frame('ack', { id: m.id, data: { format: 'png', width: 1280, height: 800, content_b64: 'iVBORw0KGgo=' } }));@@@ -391,6 +392,39 @@ const { boot, check, done } = require('./helpers/app');host.c.close();});++ await check('AI on your own machines (contracts 0.219.0): the owner, and OpenVibe.AI for them once they turn it on; nobody else; the audit keeps no text', async () => {++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });++ const gpu = await machine(alex, { ai: { models: ['llama3.1:8b'], ready: true, max_tokens: 2048 } });++ const ask = { kind: 'ai', value: { model: 'llama3.1:8b', messages: [{ role: 'user', content: 'What is a mutex? secret words' }] } };++ const mine = await cmd(gpu.deviceId, ask, { user: alex });++ assert.strictEqual(mine.json.result, 'ack', mine.text);++ assert.strictEqual(mine.json.data.message.content, 'A lock one thread holds at a time.');++ const ai = t.network.signService({ sub: 'svc:ai', cap: ['bot.machine.ai'] });++ const asAi = (subject) => ({ token: ai, headers: { 'X-OV-Subject': subject } });++ const before = await cmd(gpu.deviceId, ask, asAi(alex.subject));++ assert.deepStrictEqual([before.status, before.json.code], [403, 'bot.agent_not_allowed'], 'off until the owner turns it on');++ const on = await t.call('POST', `/api/v1/machines/${gpu.deviceId}/ai-models`, { user: alex, body: { on: true } });++ assert.strictEqual(on.json.machine.ai_models, true);++ assert.deepStrictEqual(on.json.machine.computer.ai.models, ['llama3.1:8b']);++ const viaAi = await cmd(gpu.deviceId, ask, asAi(alex.subject));++ assert.strictEqual(viaAi.json.result, 'ack', viaAi.text);++ // Nobody else: another person through OpenVibe.AI, a service without the capability, another service, another kind.++ assert.strictEqual((await cmd(gpu.deviceId, ask, asAi(bob.subject))).status, 404, 'never another person\'s machine');++ const noCap = t.network.signService({ sub: 'svc:ai', cap: ['bot.computer.control'] });++ assert.strictEqual((await cmd(gpu.deviceId, ask, { token: noCap, headers: { 'X-OV-Subject': alex.subject } })).json.code, 'capability.denied');++ assert.strictEqual((await cmd(gpu.deviceId, ask, asActor(t.network.signService({ sub: 'svc:actor', cap: ['bot.computer.control'] }), alex.subject))).status, 403, 'not the agent');++ assert.strictEqual((await cmd(gpu.deviceId, { kind: 'exec', value: { argv: ['id'] } }, asAi(alex.subject))).status, 403, 'OpenVibe.AI sends ai only');++ assert.strictEqual((await t.call('POST', `/api/v1/machines/${gpu.deviceId}/ai-models`, { ...asAi(alex.subject), body: { on: false } })).status, 403, 'only the owner flips it');++ const rows = await t.db.many('SELECT value FROM command_audit WHERE robot_id = $1 AND kind = $2 AND result = $3', [gpu.deviceId, 'ai', 'ack']);++ assert.deepStrictEqual(rows[0].value, { model: 'llama3.1:8b', messages: 1, chars: 29 });++ assert.ok(!JSON.stringify(rows).includes('secret words'), 'never the text');++ // The machines page: its models and the switch.++ const html = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();++ assert.ok(html.includes('llama3.1:8b'), 'its models');++ assert.ok(html.includes("Stop my AI using this computer's models"), 'and the switch, on');++ gpu.c.close();++ });++await check('Watch sends only probes, only where the owner let their watches use the computer', async () => {const watch = t.network.signService({ sub: 'svc:watch', cap: ['bot.computer.control'] });const asWatch = { token: watch, headers: { 'X-OV-Subject': alex.subject } };
@@ -187,7 +187,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
@@ -187,7 +187,7 @@ Set `BOT_ONVIF_CAMERAS` (JSON, unset by default) to the server-side cameras Bot
OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.
OpenVibe.Sites no longer generates `deploy/nginx/openvibe.bot.conf` (sites.json marks the vhost as owned by OpenVibe.Bot), and Bot serves nothing from a Sites checkout: the legal pages, `robots.txt`, `sitemap.xml`, `manifest.webmanifest` and the 404 page all come from the app. A Sites deploy leaves an installed vhost it no longer generates in place, so the current Sites vhost keeps serving openvibe.bot until Bot's first deploy installs this file over it; later Sites deploys never overwrite Bot's `/device` and `/control` routes.
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.218.0
+- openvibe-contracts: v0.219.0
- openvibe-sdk: v0.65.0
- openvibe-sdk: v0.65.0
- openvibe-shared: v4.15.2
- openvibe-shared: v4.15.2
<!-- versions:end -->
<!-- versions:end -->
@@ -5,11 +5,11 @@
@@ -5,11 +5,11 @@
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"port": 4630,
"port": 4630,
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
- "contracts": "openvibe-contracts v0.218.0",
+ "contracts": "openvibe-contracts v0.219.0",
"sdk": "openvibe-sdk v0.65.0",
"sdk": "openvibe-sdk v0.65.0",
"shared": "openvibe-shared v4.15.2",
"shared": "openvibe-shared v4.15.2",
"packages": [
"packages": [
- "openvibe-contracts v0.218.0",
+ "openvibe-contracts v0.219.0",
"openvibe-sdk v0.65.0",
"openvibe-sdk v0.65.0",
"openvibe-shared v4.15.2"
"openvibe-shared v4.15.2"
],
],
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
"bot.robot.manage",
"bot.robot.manage",
"bot.robot.control",
"bot.robot.control",
"bot.computer.control",
"bot.computer.control",
+ "bot.machine.ai",
"bot.device.connect",
"bot.device.connect",
"bot.job.dispatch",
"bot.job.dispatch",
"bot.resource.read",
"bot.resource.read",
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- AI on your own machines (contracts 0.219.0, bot.command@1 kind ai; capability bot.machine.ai): the owner lets their
+-- AI (OpenVibe.AI, acting for them) run a model on this machine's own model server through OpenVibe Node's ai plugin.
+-- Off until they turn it on on openvibe.bot/machines.
+ALTER TABLE devices ADD COLUMN IF NOT EXISTS ai_models boolean NOT NULL DEFAULT false;
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.218.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.219.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.2",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -845,9 +845,9 @@
@@ -845,9 +845,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.218.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.218.0",
- "integrity": "sha512-/cgAdxibISceSva51oCYz/382iLsXW5RU6kt8Lyt5JWPiL6QhL0193boUZS9Ls4SxnEgT/fiAtMU3IJY4qMKwg==",
+ "version": "0.219.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.219.0",
+ "integrity": "sha512-Ka5nyz0UGjfQrN91vKL4TJWV84Fb6Nr8bGrfoSuPSKNv1F5U/uvPG9lFsw5bw9ocn6xMrypUDwsyu+hn/NnmVA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.218.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.219.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.65.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.2",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.15.2",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -215,6 +215,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
@@ -215,6 +215,7 @@ main { padding: 1rem max(1rem, env(safe-area-inset-right)) calc(1.5rem + env(saf
.robot-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.robot-actions { display: flex; flex-wrap: wrap; gap: 0.5rem; }
/* A machine's computer plugin (Node docs/computer.md): its level, what waits on it, and the agent switch, full width. */
/* A machine's computer plugin (Node docs/computer.md): its level, what waits on it, and the agent switch, full width. */
.machine-computer { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--line); }
.machine-computer { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--line); }
+.machine-ai { display: flex; flex-wrap: wrap; align-items: center; gap: 0.4rem 0.6rem; flex-basis: 100%; }
@media (min-width: 36rem) { .robot-card { grid-template-columns: minmax(0, 1fr) auto; align-items: center; } .robot-meta { grid-column: 1; grid-row: 2; } .robot-actions { grid-column: 2; grid-row: 1 / span 2; } .machine-computer { grid-column: 1 / -1; grid-row: 3; } .machine-computer form { margin-left: auto; } }
@media (min-width: 36rem) { .robot-card { grid-template-columns: minmax(0, 1fr) auto; align-items: center; } .robot-meta { grid-column: 1; grid-row: 2; } .robot-actions { grid-column: 2; grid-row: 1 / span 2; } .machine-computer { grid-column: 1 / -1; grid-row: 3; } .machine-computer form { margin-left: auto; } }
.empty { text-align: center; padding: 2rem 1rem; margin-bottom: 1.5rem; border: 1px dashed var(--line); border-radius: var(--radius); color: var(--muted); }
.empty { text-align: center; padding: 2rem 1rem; margin-bottom: 1.5rem; border: 1px dashed var(--line); border-radius: var(--radius); color: var(--muted); }
.empty-icon { width: 3rem; height: 3rem; stroke-width: 1.5; color: var(--accent); }
.empty-icon { width: 3rem; height: 3rem; stroke-width: 1.5; color: var(--accent); }
@@ -55,6 +55,7 @@ const CAP = {
@@ -55,6 +55,7 @@ const CAP = {
connect: 'bot.device.connect',
connect: 'bot.device.connect',
dispatch: 'bot.job.dispatch',
dispatch: 'bot.job.dispatch',
computer: 'bot.computer.control',
computer: 'bot.computer.control',
+ ai: 'bot.machine.ai',
};
};
/** Services that act as a person's agent (plan T15/T17): they drive a robot only where its owner turned agent_control on. */
/** Services that act as a person's agent (plan T15/T17): they drive a robot only where its owner turned agent_control on. */
@@ -66,7 +67,11 @@ const AGENT_SERVICES = new Set(['svc:actor']);
@@ -66,7 +67,11 @@ const AGENT_SERVICES = new Set(['svc:actor']);
const MACHINE_SERVICES = {
const MACHINE_SERVICES = {
'svc:actor': { kinds: null, flag: 'agent_computer', off: 'the owner has not let their agent use this computer' },
'svc:actor': { kinds: null, flag: 'agent_computer', off: 'the owner has not let their agent use this computer' },
'svc:watch': { kinds: new Set(['probe']), flag: 'watch_probe', off: 'the owner has not let their watches check things from this computer' },
'svc:watch': { kinds: new Set(['probe']), flag: 'watch_probe', off: 'the owner has not let their watches check things from this computer' },
+ // AI on your own machines (contracts 0.219.0): OpenVibe.AI runs a model on the owner's own model server, nothing else.
+ 'svc:ai': { kinds: new Set(['ai']), flag: 'ai_models', off: 'the owner has not let their AI use this computer\'s models', cap: 'bot.machine.ai' },
};
};
+// A model on a person's machine is OpenVibe.AI's and the owner's only: no other service sends kind ai.
+const AI_KIND = 'ai';
function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null, claims = null }) {
function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submissions = null, routines = null, webhooks = null, claims = null }) {
const r = express.Router();
const r = express.Router();
@@ -408,8 +413,10 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -408,8 +413,10 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
const svc = req.principal.kind === 'service' ? MACHINE_SERVICES[req.principal.sub] : null;
const svc = req.principal.kind === 'service' ? MACHINE_SERVICES[req.principal.sub] : null;
if (req.principal.kind === 'service') {
if (req.principal.kind === 'service') {
if (!svc) fail(403, 'bot.forbidden', 'a machine is used by its owner, their agent and their watches only');
if (!svc) fail(403, 'bot.forbidden', 'a machine is used by its owner, their agent and their watches only');
- if (!apiAuth.granted(req.principal, CAP.computer)) fail(403, 'capability.denied', `${CAP.computer} not granted`);
+ const cap = svc.cap || CAP.computer;
+ if (!apiAuth.granted(req.principal, cap)) fail(403, 'capability.denied', `${cap} not granted`);
if (kind != null && svc.kinds && !svc.kinds.has(String(kind))) fail(403, 'bot.forbidden', `${req.principal.sub} may send ${[...svc.kinds].join(', ')} only`);
if (kind != null && svc.kinds && !svc.kinds.has(String(kind))) fail(403, 'bot.forbidden', `${req.principal.sub} may send ${[...svc.kinds].join(', ')} only`);
+ if (String(kind) === AI_KIND && !(svc.kinds && svc.kinds.has(AI_KIND))) fail(403, 'bot.forbidden', 'a model on a person\'s machine runs for OpenVibe.AI and the owner only');
}
}
const device = await domain.devices.get(String(req.params.id || '').slice(0, 64));
const device = await domain.devices.get(String(req.params.id || '').slice(0, 64));
const ownerSubject = device && !device.revoked_at ? await domain.devices.ownerOf(device) : null;
const ownerSubject = device && !device.revoked_at ? await domain.devices.ownerOf(device) : null;
@@ -423,11 +430,11 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -423,11 +430,11 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
const sensors = st.telemetry && st.telemetry.sensors && typeof st.telemetry.sensors === 'object' ? st.telemetry.sensors : {};
const sensors = st.telemetry && st.telemetry.sensors && typeof st.telemetry.sensors === 'object' ? st.telemetry.sensors : {};
const pick = (cap) => (offered && offered[cap] && typeof offered[cap] === 'object' ? offered[cap] : null);
const pick = (cap) => (offered && offered[cap] && typeof offered[cap] === 'object' ? offered[cap] : null);
return {
return {
- id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer, watch_probe: !!device.watch_probe,
+ id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer, ai_models: !!device.ai_models, watch_probe: !!device.watch_probe,
voice: { on: !!device.voice_on, call_word: device.call_word || DEFAULT_CALL_WORD },
voice: { on: !!device.voice_on, call_word: device.call_word || DEFAULT_CALL_WORD },
computer: offered ? {
computer: offered ? {
level: typeof sensors.computer_level === 'string' ? sensors.computer_level : ((pick('files') || pick('terminal') || pick('screen') || {}).level || null),
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'), desk: pick('desk'),
+ terminal: pick('terminal'), files: pick('files'), screen: pick('screen'), probe: pick('probe'), input: pick('input'), browser: pick('browser'), desk: pick('desk'), ai: pick('ai'),
waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
// Desks running now (the Node's telemetry): where an agent's next desk has room (Desks plan D6, placement).
// Desks running now (the Node's telemetry): where an agent's next desk has room (Desks plan D6, placement).
desks_running: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
desks_running: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
@@ -507,6 +514,14 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -507,6 +514,14 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
if (hub && hub.machineChanged) await hub.machineChanged(updated);
if (hub && hub.machineChanged) await hub.machineChanged(updated);
res.json({ machine: machineOut(updated) });
res.json({ machine: machineOut(updated) });
}));
}));
+ // The owner lets their AI run models on this machine, or stops it (never the AI itself).
+ r.post('/machines/:id/ai-models', 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 updated = await domain.machines.setAiModels(device.id, req.body.on);
+ res.json({ machine: machineOut(updated) });
+ }));
// The owner turns their agent's use of the machine on or off (never the agent itself).
// 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) => {
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');
if (req.principal.kind !== 'user') fail(403, 'bot.forbidden', 'only the machine\'s owner turns this on or off');
@@ -751,6 +751,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -751,6 +751,10 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
async function setWatchProbe(deviceId, value) {
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())]);
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())]);
}
}
+ /** Let the owner's AI run models on this machine (OpenVibe Node's ai plugin), or stop it (the owner check is the caller's). */
+ async function setAiModels(deviceId, value) {
+ return db.maybe('UPDATE devices SET ai_models = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
+ }
/** Let the owner's agent use this machine's computer plugin, or stop it (the owner check is the caller's). */
/** Let the owner's agent use this machine's computer plugin, or stop it (the owner check is the caller's). */
async function setAgentComputer(deviceId, value) {
async function setAgentComputer(deviceId, value) {
return db.maybe('UPDATE devices SET agent_computer = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
return db.maybe('UPDATE devices SET agent_computer = $2, updated_at = $3 WHERE id = $1 AND revoked_at IS NULL RETURNING *', [deviceId, !!value, iso(now())]);
@@ -1248,7 +1252,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
@@ -1248,7 +1252,7 @@ function createDomain({ db, config, outbox, link = null, nodes = null, openre =
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, onPaired },
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 },
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, setVoice,
+ machines: { mint: mintMachine, list: listMachines, workerRef, setAgentComputer, setAiModels, setWatchProbe, setVoice,
screen: { key: screenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
screen: { key: screenKey, live: screenLive, end: screenEnd, archive: screenArchive } },
streaming: { get: streaming, set: setStreaming },
streaming: { get: streaming, set: setStreaming },
video: { live: liveVideo },
video: { live: liveVideo },
@@ -42,7 +42,7 @@ const DEVICE_MAX_PAYLOAD = 640 * 1024;
@@ -42,7 +42,7 @@ const DEVICE_MAX_PAYLOAD = 640 * 1024;
// status, and the reply window (the owner's approval on the machine plus the program's own run). `desk` (contracts
// 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
// 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.
// 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 COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser', desk: 'desk', ai: 'ai' };
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';
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';
const NO_NAMED_DESKS = 'this machine runs one desk only (private): several named desks need OpenVibe Node v0.19.0 or newer';
const NO_NAMED_DESKS = 'this machine runs one desk only (private): several named desks need OpenVibe Node v0.19.0 or newer';
const NO_DESK_PROGRAMS = 'running a program on a desk needs OpenVibe Node v0.26.0 or newer (an older one would run it on the person\'s own screen)';
const NO_DESK_PROGRAMS = 'running a program on a desk needs OpenVibe Node v0.26.0 or newer (an older one would run it on the person\'s own screen)';
@@ -68,6 +68,8 @@ function computerSummary(kind, value) {
@@ -68,6 +68,8 @@ function computerSummary(kind, value) {
const where = deskOf(value) !== 'main' ? { desk: deskOf(value) } : {};
const where = deskOf(value) !== 'main' ? { desk: deskOf(value) } : {};
if (kind === 'desk') return { action: value.action, ...(typeof value.desk === 'string' ? { desk: value.desk } : {}), ...(typeof value.kind === 'string' ? { kind: value.kind } : {}), ...(typeof value.profile === 'string' ? { profile: value.profile } : {}), ...(Number.isInteger(value.width) && Number.isInteger(value.height) ? { width: value.width, height: value.height } : {}) };
if (kind === 'desk') return { action: value.action, ...(typeof value.desk === 'string' ? { desk: value.desk } : {}), ...(typeof value.kind === 'string' ? { kind: value.kind } : {}), ...(typeof value.profile === 'string' ? { profile: value.profile } : {}), ...(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, ...where, ...(value.detach === true ? { detach: true } : {}) };
if (kind === 'exec') return { program: String(value.argv[0]).slice(0, 120), args: value.argv.length - 1, ...where, ...(value.detach === true ? { detach: true } : {}) };
+ // A model's prompt is the person's own words: the audit keeps the model, how many messages and how long, never the text.
+ if (kind === 'ai') return { model: String(value.model).slice(0, 120), messages: value.messages.length, chars: value.messages.reduce((n, m) => n + String(m.content || '').length, 0) };
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 === '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 }; } }
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.
// Typed text can be anything (a password the person asked for): the audit keeps its length, never the text.
@@ -724,7 +724,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -724,7 +724,9 @@ 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,
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),
screenStream: !!(c.screen && c.screen.stream === true),
// Desks for its agents (Desks plan): how many at once, which kinds, and whether sign-ins carry over.
// Desks for its agents (Desks plan): how many at once, which kinds, and whether sign-ins carry over.
- desks: d ? { max: Number.isInteger(d.max_desks) ? d.max_desks : 1, kinds: Array.isArray(d.kinds) ? d.kinds : ['desktop'], keeps: d.profiles === true } : null };
+ desks: d ? { max: Number.isInteger(d.max_desks) ? d.max_desks : 1, kinds: Array.isArray(d.kinds) ? d.kinds : ['desktop'], keeps: d.profiles === true } : null,
+ // Its own models (OpenVibe Node's ai plugin): what it offers, and whether a model server answers.
+ ai: c.ai && typeof c.ai === 'object' ? { models: Array.isArray(c.ai.models) ? c.ai.models.slice(0, 20).map(String) : [], ready: c.ai.ready === true } : null };
};
};
// Vibe by voice: shown for a machine whose Node runs the voice plugin (capabilities.voice).
// Vibe by voice: shown for a machine whose Node runs the voice plugin (capabilities.voice).
const voiceRow = (m) => {
const voiceRow = (m) => {
@@ -759,6 +761,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -759,6 +761,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {
+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`
+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`
+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`
+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`
+ (c.screenStream && m.online ? `<form method="post" action="/machines/${esc(m.id)}/screen"><button type="submit" class="button quiet">${m.computer_screen_stream === 'live' ? 'Watch my screen' : 'Show my screen'}</button></form>` : '')
+ (c.screenStream && m.online ? `<form method="post" action="/machines/${esc(m.id)}/screen"><button type="submit" class="button quiet">${m.computer_screen_stream === 'live' ? 'Watch my screen' : 'Show my screen'}</button></form>` : '')
+ + (c.ai ? `<div class="machine-ai"><span class="pill">${c.ai.ready ? 'Models' : 'Models: no server answers'}</span>${c.ai.models.length ? `<span class="muted">${esc(c.ai.models.join(', '))}</span>` : ''}`
+ + `<form method="post" action="/machines/${esc(m.id)}/ai-models"><input type="hidden" name="on" value="${m.ai_models ? '0' : '1'}">`
+ + `<button type="submit" class="button quiet" aria-pressed="${m.ai_models ? 'true' : 'false'}">${m.ai_models ? 'Stop my AI using this computer\'s models' : 'Let my AI use this computer\'s models'}</button></form></div>` : '')
+ (c.probe ? `<form method="post" action="/machines/${esc(m.id)}/watch-probe"><input type="hidden" name="on" value="${m.watch_probe ? '0' : '1'}">`
+ (c.probe ? `<form method="post" action="/machines/${esc(m.id)}/watch-probe"><input type="hidden" name="on" value="${m.watch_probe ? '0' : '1'}">`
+ `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from this computer'}</button></form>` : '')
+ `<button type="submit" class="button quiet" aria-pressed="${m.watch_probe ? 'true' : 'false'}">${m.watch_probe ? 'Stop my watches checking from here' : 'Let my watches check things from this computer'}</button></form>` : '')
+ '</div>';
+ '</div>';
@@ -580,6 +580,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -580,6 +580,7 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
computer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
computer_waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
computer_screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',
computer_screen_stream: sensors.computer_screen_stream === 'live' ? 'live' : 'off',
computer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
computer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
+ ai_models: !!d.ai_models,
};
};
}));
}));
// ── The live screen (plan T14): the owner watches their machine's screen on their own private stream ──
// ── The live screen (plan T14): the owner watches their machine's screen on their own private stream ──
@@ -761,6 +762,14 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
@@ -761,6 +762,14 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
res.redirect(303, '/machines');
res.redirect(303, '/machines');
}));
}));
// The owner lets their agent (OpenVibe.Actor) use this machine's computer plugin, or stops it.
// The owner lets their agent (OpenVibe.Actor) use this machine's computer plugin, or stops it.
+ // The owner lets their AI run models on this machine, or stops it (AI on your own machines, contracts 0.219.0).
+ r.post('/machines/:id/ai-models', ...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);
+ await domain.machines.setAiModels(mine.id, req.body && req.body.on === '1');
+ res.redirect(303, '/machines');
+ }));
r.post('/machines/:id/agent-computer', ...manage, express.urlencoded({ extended: false, limit: '1kb' }), page(async (req, res) => {
r.post('/machines/:id/agent-computer', ...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');
@@ -34,6 +34,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -34,6 +34,7 @@ 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 === '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 === '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 === '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 === 'ai') c.send(frame('ack', { id: m.id, data: { model: m.value.model, message: { role: 'assistant', content: 'A lock one thread holds at a time.' }, usage: { total_tokens: 20 } } }));
else if (m.kind === 'desk' && m.value.action === 'list') c.send(frame('ack', { id: m.id, data: { desks: [{ desk: 'desk-task-a', kind: 'browser', running: true, width: 1280, height: 800, since: 1793102400 }, { desk: 'private', kind: 'desktop', running: true, display: ':91', width: 1280, height: 800, since: 1793102000 }], max_desks: 4, available: true } }));
else if (m.kind === 'desk' && m.value.action === 'list') c.send(frame('ack', { id: m.id, data: { desks: [{ desk: 'desk-task-a', kind: 'browser', running: true, width: 1280, height: 800, since: 1793102400 }, { desk: 'private', kind: 'desktop', running: true, display: ':91', width: 1280, height: 800, since: 1793102000 }], max_desks: 4, available: true } }));
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 === '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 && m.value.desk !== 'main') c.send(frame('ack', { id: m.id, data: { format: 'png', width: 1280, height: 800, content_b64: 'iVBORw0KGgo=' } }));
else if (m.kind === 'screen' && m.value.desk && m.value.desk !== 'main') c.send(frame('ack', { id: m.id, data: { format: 'png', width: 1280, height: 800, content_b64: 'iVBORw0KGgo=' } }));
@@ -391,6 +392,39 @@ const { boot, check, done } = require('./helpers/app');
@@ -391,6 +392,39 @@ const { boot, check, done } = require('./helpers/app');
host.c.close();
host.c.close();
});
});
+ await check('AI on your own machines (contracts 0.219.0): the owner, and OpenVibe.AI for them once they turn it on; nobody else; the audit keeps no text', async () => {
+ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
+ const gpu = await machine(alex, { ai: { models: ['llama3.1:8b'], ready: true, max_tokens: 2048 } });
+ const ask = { kind: 'ai', value: { model: 'llama3.1:8b', messages: [{ role: 'user', content: 'What is a mutex? secret words' }] } };
+ const mine = await cmd(gpu.deviceId, ask, { user: alex });
+ assert.strictEqual(mine.json.result, 'ack', mine.text);
+ assert.strictEqual(mine.json.data.message.content, 'A lock one thread holds at a time.');
+ const ai = t.network.signService({ sub: 'svc:ai', cap: ['bot.machine.ai'] });
+ const asAi = (subject) => ({ token: ai, headers: { 'X-OV-Subject': subject } });
+ const before = await cmd(gpu.deviceId, ask, asAi(alex.subject));
+ assert.deepStrictEqual([before.status, before.json.code], [403, 'bot.agent_not_allowed'], 'off until the owner turns it on');
+ const on = await t.call('POST', `/api/v1/machines/${gpu.deviceId}/ai-models`, { user: alex, body: { on: true } });
+ assert.strictEqual(on.json.machine.ai_models, true);
+ assert.deepStrictEqual(on.json.machine.computer.ai.models, ['llama3.1:8b']);
+ const viaAi = await cmd(gpu.deviceId, ask, asAi(alex.subject));
+ assert.strictEqual(viaAi.json.result, 'ack', viaAi.text);
+ // Nobody else: another person through OpenVibe.AI, a service without the capability, another service, another kind.
+ assert.strictEqual((await cmd(gpu.deviceId, ask, asAi(bob.subject))).status, 404, 'never another person\'s machine');
+ const noCap = t.network.signService({ sub: 'svc:ai', cap: ['bot.computer.control'] });
+ assert.strictEqual((await cmd(gpu.deviceId, ask, { token: noCap, headers: { 'X-OV-Subject': alex.subject } })).json.code, 'capability.denied');
+ assert.strictEqual((await cmd(gpu.deviceId, ask, asActor(t.network.signService({ sub: 'svc:actor', cap: ['bot.computer.control'] }), alex.subject))).status, 403, 'not the agent');
+ assert.strictEqual((await cmd(gpu.deviceId, { kind: 'exec', value: { argv: ['id'] } }, asAi(alex.subject))).status, 403, 'OpenVibe.AI sends ai only');
+ assert.strictEqual((await t.call('POST', `/api/v1/machines/${gpu.deviceId}/ai-models`, { ...asAi(alex.subject), body: { on: false } })).status, 403, 'only the owner flips it');
+ const rows = await t.db.many('SELECT value FROM command_audit WHERE robot_id = $1 AND kind = $2 AND result = $3', [gpu.deviceId, 'ai', 'ack']);
+ assert.deepStrictEqual(rows[0].value, { model: 'llama3.1:8b', messages: 1, chars: 29 });
+ assert.ok(!JSON.stringify(rows).includes('secret words'), 'never the text');
+ // The machines page: its models and the switch.
+ const html = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
+ assert.ok(html.includes('llama3.1:8b'), 'its models');
+ assert.ok(html.includes("Stop my AI using this computer's models"), 'and the switch, on');
+ gpu.c.close();
+ });
+
await check('Watch sends only probes, only where the owner let their watches use the computer', async () => {
await check('Watch sends only probes, only where the owner let their watches use the computer', async () => {
const watch = t.network.signService({ sub: 'svc:watch', cap: ['bot.computer.control'] });
const watch = t.network.signService({ sub: 'svc:watch', cap: ['bot.computer.control'] });
const asWatch = { token: watch, headers: { 'X-OV-Subject': alex.subject } };
const asWatch = { token: watch, headers: { 'X-OV-Subject': alex.subject } };