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)

OP OpenVibers committed 17 days ago · 9cd7b5af50 · 12 files, +90 −14

Changes

ddiff --git a/README.md b/README.md
iindex eec3d37..c9b6e7b 100644
---- a/README.md
++++ b/README.md
@@@ -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.
<!-- versions:start -->
--- openvibe-contracts: v0.218.0
++- openvibe-contracts: v0.219.0
- openvibe-sdk: v0.65.0
- openvibe-shared: v4.15.2
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 09220ff..ad1c9b7 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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",
ddiff --git a/migrations/0025_machine_ai.sql b/migrations/0025_machine_ai.sql
nnew file mode 100644
iindex 0000000..3f01a28
---- /dev/null
++++ b/migrations/0025_machine_ai.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex 277f9c5..4efc8ba 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex a4fd466..85d9253 100644
---- a/package.json
++++ b/package.json
@@@ -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",
ddiff --git a/public/panel.css b/public/panel.css
iindex 1e76225..652a57b 100644
---- a/public/panel.css
++++ b/public/panel.css
@@@ -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); }
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex dc9a218..3bb15a0 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -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, subm
const 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, subm
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);
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, subm
if (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');
ddiff --git a/server/domain/index.js b/server/domain/index.js
iindex 6e9d2e2..f2dd906 100644
---- a/server/domain/index.js
++++ b/server/domain/index.js
@@@ -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 },
ddiff --git a/server/realtime.js b/server/realtime.js
iindex e817b6e..cc1b8ad 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -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.
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 4eae118..f8b2125 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -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>';
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex 651c502..c46b9e4 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -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_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, limi
res.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');
ddiff --git a/test/machine-computer.test.js b/test/machine-computer.test.js
iindex fa4ddfb..28bce85 100644
---- a/test/machine-computer.test.js
++++ b/test/machine-computer.test.js
@@@ -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 } };