openvibers/openvibe.bot Commits

Community AI offers: report a machine's offer and place an opted-in run (#308)

OP OpenVibers committed 7 days ago · 9903c77633 · 9 files, +233 −23

* Community AI offers: report a machine's offer and place an opted-in run

* Clarify what svc:ai may run on a machine

* machines: show the community offer, its privacy sentence and speed

Changes

ddiff --git a/docs/ai-offers.md b/docs/ai-offers.md
nnew file mode 100644
iindex 0000000..e4015f8
---- /dev/null
++++ b/docs/ai-offers.md
@@@ -0,0 +1,21 @@
++# Community AI offers through Bot
++
++OpenVibe.Node's `ai` plugin can offer a person's local models to the network. Bot is the transport and the reporter:
++
++- **Report** — `server/jobs/offers.js` `buildAi()` reads every live Network-paired device whose
++ `capabilities.ai.ai.offer.enabled` is true and publishes one offer per machine to Network's
++ `POST /internal/ai/offers/report` (`source: bot`) with the owner, models, hours, concurrency, credit price, audience,
++ ready flag and health. It is a separate report from the worker/desk `platform.resource-offer@1` set, sent by the same
++ publisher (`createAiOfferReporter`, `server/network.js`).
++- **Dispatch** — `POST /api/v1/machines/:id/commands` with `kind: ai` reaches a machine only from OpenVibe.AI
++ (`svc:ai`, `bot.machine.ai`). A machine's own models need `ai_models` (the owner's "Let my AI use this computer's
++ models" switch). A **community** run is marked with the `community/<model>` model prefix and is allowed when the
++ machine's own offer is enabled: the offer is the opt-in, and the Node plugin enforces hours, concurrency and its
++ model list. The prefix never reaches the local model server.
++
++The command's value is `bot.command@1`, which is closed to extra fields, so the prefix carries the community flag
++without changing the contract. The offer itself is not a `platform.resource-offer@1`: that contract closes `detail`
++for kind node/provider and cannot carry a model list, hours, a price and an audience. Network has the dedicated
++`platform_ai_offers` table.
++
++Test: `node test/community-ai.test.js`.
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex da92cbc..eec3ebe 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -53,6 +53,7 @@ const machineRoles = require('../machine-roles');
const kits = require('../kits');
const resourceIndex = require('./resource-index');
const resourceControl = require('./resource-control');
++const { aiOf } = require('../machine-ai');
const deskLeases = require('./desk-leases');
const CAP = {
@@@ -78,9 +79,22 @@ 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' },
++ // AI (contracts 0.219.0): OpenVibe.AI runs a model on the owner's own model server for the owner, or — only for a
++ // machine whose ai plugin offers its models to the network — a community request that opted in (below).
++ // A command that says `community: true` runs on the machine's offer to the network instead, so the Node ai plugin's
++ // own opt-in (capabilities.ai.offer.enabled) stands in for the "Let my AI use this computer's models" switch.
++ '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', community: true },
};
++/**
++ * A machine offering its models to the network (OpenVibe.Node ai plugin 0.3.0): the owner turned on "Offer to the
++ * network" and the offer names its models. Hours, concurrency and price are the plugin's to enforce; Bot only decides
++ * whether AI may send a community run here at all.
++ */
++function communityAi(capabilities) {
++ const ai = aiOf(capabilities);
++ const offer = ai && ai.offer;
++ return Boolean(offer && offer.enabled === true && Array.isArray(offer.models) && offer.models.length);
++}
// The network's machines hub (plan L34): the one service that acts for a person on their own machines. The
// capability alone is not enough: any service holding it could name any person in X-OV-Subject, so the hub is
// this service and no other.
@@@ -498,7 +512,12 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
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;
if (!ownerSubject || ownerSubject !== subject) fail(404, 'bot.device_not_found', 'no such machine of yours');
-- if (agentNeedsOptIn && svc && !device[svc.flag]) fail(403, 'bot.agent_not_allowed', svc.off);
++ // A community run is the machine's own offer to the network, which is its own opt-in; anything else AI asks
++ // of it (the owner's own models) still needs "Let my AI use this computer's models".
++ const community = Boolean(svc && svc.community && req.body && req.body.value && typeof req.body.value.model === 'string' && req.body.value.model.startsWith('community/'));
++ if (agentNeedsOptIn && svc && !device[svc.flag] && !(community && communityAi(device.capabilities))) {
++ fail(403, 'bot.agent_not_allowed', community ? 'the owner has not offered this computer\'s models to the network' : svc.off);
++ }
return { device, subject };
}
const machineOut = (device) => {
ddiff --git a/server/app.js b/server/app.js
iindex 20eae37..cd7fe37 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -90,7 +90,11 @@ function createApp(opts = {}) {
// started by server/index.js with the other jobs. Without Network credentials nothing is reported.
const offerReporter = opts.offerReporter !== undefined ? opts.offerReporter
: (config.oauth.clientSecret ? createOfferReporter(config, { fetchImpl }) : null);
-- const offers = createOfferPublisher({ db, hub, reporter: offerReporter, log, now, firstPartyOwners: (config.deskHosts && config.deskHosts.firstPartyOwners) || [] });
++ // A test that injects its own worker reporter gets no real Network client for AI offers unless it injects that too.
++ const aiOfferReporter = opts.aiOfferReporter !== undefined ? opts.aiOfferReporter
++ : (opts.offerReporter !== undefined ? null : (config.oauth.clientSecret ? require('./network').createAiOfferReporter(config, { fetchImpl }) : null));
++ const offers = createOfferPublisher({ db, hub, reporter: offerReporter, aiReporter: aiOfferReporter, log, now,
++ firstPartyOwners: (config.deskHosts && config.deskHosts.firstPartyOwners) || [] });
domain.devices.onChange(() => offers.poke());
const sim = opts.sim || createSimulator({ config, domain, hub, now, log });
// Server-side ONVIF cameras (server/onvif): the same in-process attach as the simulator, but with a real
ddiff --git a/server/jobs/offers.js b/server/jobs/offers.js
iindex 35fc73f..3d4d38e 100644
---- a/server/jobs/offers.js
++++ b/server/jobs/offers.js
@@@ -9,6 +9,12 @@
*
* createOfferPublisher({ db, hub, reporter, log, now, intervalMs, settleMs }) → { build, publish, start, stop, poke }
*
++ * The same set carries a community AI offer (the design's "Offer to the network"): a machine whose OpenVibe.Node ai
++ * plugin advertises `capabilities.ai.offer.enabled` with models, hours, concurrency, a credit price and an audience is
++ * one user-owned provider offer, `ai:<device>`, whose detail.kind is `ai` and whose health follows its connection and
++ * its own open/ready flags. Network validates the detail; OpenVibe.AI reads the internal list and places only requests
++ * that opt in to community capacity on it.
++ *
* An offer that does not match the contract is left out (Network would refuse the whole report for one bad offer).
* Without a reporter (no Network credentials) nothing is reported.
*/
@@@ -18,7 +24,25 @@ const { json } = require('../util');
const CONTRACT = 'platform.resource-offer@1';
const CLASSES = new Set(['function', 'code', 'browser', 'linux', 'desktop', 'gpu']);
--function createOfferPublisher({ db, hub, reporter = null, log = console, now = () => Date.now(), intervalMs = 60_000, settleMs = 2_000, firstPartyOwners = [] }) {
++/** The ai plugin's offer, or null when it is off or incomplete (an incomplete one is left out, never half reported). */
++function aiOfferOf(capabilities) {
++ // The Node reports each plugin's describe under its name, so the ai plugin's {ai: {models, offer}} arrives as
++ // capabilities.ai.ai; a direct capabilities.ai.offer is accepted too (an older/other reporter).
++ let ai = json(capabilities, {}).ai;
++ if (ai && ai.ai) ai = ai.ai;
++ const offer = ai && ai.offer;
++ if (!offer || offer.enabled !== true || !Array.isArray(offer.models) || !offer.models.length) return null;
++ if (!offer.hours || typeof offer.hours !== 'object' || !offer.price || typeof offer.price !== 'object') return null;
++ if (!Number.isInteger(offer.concurrency) || offer.concurrency < 1 || offer.concurrency > 8) return null;
++ if (!['everyone', 'followers', 'group'].includes(offer.audience)) return null;
++ if (offer.audience === 'group' && (typeof offer.group !== 'string' || !offer.group.trim())) return null;
++ for (const key of ['in_per_million', 'out_per_million']) {
++ if (typeof offer.price[key] !== 'number' || !Number.isFinite(offer.price[key]) || offer.price[key] < 0) return null;
++ }
++ return offer;
++}
++
++function createOfferPublisher({ db, hub, reporter = null, aiReporter = null, log = console, now = () => Date.now(), intervalMs = 60_000, settleMs = 2_000, firstPartyOwners = [] }) {
const firstParty = new Set(firstPartyOwners);
let timer = null;
let soon = null;
@@@ -80,33 +104,64 @@ function createOfferPublisher({ db, hub, reporter = null, log = console, now = (
return offers;
}
++ /** The community AI offers: one per machine whose Node ai plugin advertises an enabled offer. */
++ async function buildAi() {
++ const rows = await db.many(
++ `SELECT d.id, d.capabilities, d.owner_subject, r.owner_subject AS robot_owner
++ FROM devices d LEFT JOIN robots r ON r.id = d.robot_ids ->> 0
++ WHERE d.revoked_at IS NULL AND (d.capabilities -> 'ai' -> 'ai' -> 'offer' ->> 'enabled' = 'true'
++ OR d.capabilities -> 'ai' -> 'offer' ->> 'enabled' = 'true')
++ ORDER BY d.id`);
++ const at = new Date(now()).toISOString();
++ const offers = [];
++ for (const row of rows) {
++ const offer = aiOfferOf(row.capabilities);
++ const owner = row.owner_subject || row.robot_owner;
++ if (!offer || !owner) continue;
++ const online = Boolean(hub && hub.isOnline(row.id));
++ offers.push({
++ offer_id: `ai:${row.id}`, machine_id: row.id, owner,
++ models: offer.models.slice(0, 40),
++ hours: { start: offer.hours.start, end: offer.hours.end, tz: offer.hours.tz },
++ concurrency: offer.concurrency,
++ price: { in_per_million: offer.price.in_per_million, out_per_million: offer.price.out_per_million },
++ audience: offer.audience, group: offer.audience === 'group' ? offer.group : null,
++ ready: online && offer.ready === true,
++ health: { status: online && offer.ready === true ? 'up' : 'down', checked_at: at },
++ });
++ }
++ return offers;
++ }
++
/** Report the whole set now (one report at a time; a change during one reports again after it). */
async function publish() {
-- if (!reporter) return null;
++ if (!reporter && !aiReporter) return null;
if (running) { again = true; return running; }
running = (async () => {
++ const out = {};
try {
-- return await reporter.report(await build());
-- } catch (e) {
-- log.warn(`[Bot] offers report: ${e.message}`);
-- return null;
-- } finally {
-- running = null;
-- if (again) { again = false; poke(); }
-- }
-- })();
++ if (reporter) out.offers = await reporter.report(await build());
++ } catch (e) { log.warn(`[Bot] offers report: ${e.message}`); }
++ try {
++ if (aiReporter) out.ai = await aiReporter.report(await buildAi());
++ } catch (e) { log.warn(`[Bot] ai offers report: ${e.message}`); }
++ return out;
++ })().finally(() => {
++ running = null;
++ if (again) { again = false; poke(); }
++ });
return running;
}
/** A device changed: report a moment later (changes in a burst are reported once). Only once started. */
function poke() {
-- if (!reporter || !timer || soon) return;
++ if ((!reporter && !aiReporter) || !timer || soon) return;
soon = setTimeout(() => { soon = null; void publish(); }, settleMs);
if (soon.unref) soon.unref();
}
function start() {
-- if (!reporter || timer) return;
++ if ((!reporter && !aiReporter) || timer) return;
timer = setInterval(() => { void publish(); }, intervalMs);
if (timer.unref) timer.unref();
poke();
@@@ -117,7 +172,7 @@ function createOfferPublisher({ db, hub, reporter = null, log = console, now = (
timer = null;
soon = null;
}
-- return { build, publish, start, stop, poke };
++ return { build, buildAi, publish, start, stop, poke };
}
--module.exports = { createOfferPublisher };
++module.exports = { createOfferPublisher, aiOfferOf };
ddiff --git a/server/network.js b/server/network.js
iindex 1814716..27c5ad4 100644
---- a/server/network.js
++++ b/server/network.js
@@@ -89,7 +89,7 @@ function createNodePrincipals(config, { fetchImpl = globalThis.fetch } = {}) {
* report(offers) → Network's answer { source, offers, marked_down }
* A failure throws 503 bot.network_unavailable.
*/
--function createOfferReporter(config, { fetchImpl = globalThis.fetch } = {}) {
++function createOfferReporter(config, { fetchImpl = globalThis.fetch, path = '/internal/resources/report' } = {}) {
const tokens = serviceAuth.createTokenClient({
tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
audience: 'openvibe.network', scope: 'network.resource.report', fetchImpl,
@@@ -97,7 +97,7 @@ function createOfferReporter(config, { fetchImpl = globalThis.fetch } = {}) {
async function report(offers) {
let res;
try {
-- res = await fetchImpl(`${config.network.internalUrl}/internal/resources/report`, {
++ res = await fetchImpl(`${config.network.internalUrl}${path}`, {
method: 'POST', headers: { ...(await tokens.authHeaders()), Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ source: 'bot', offers }), signal: AbortSignal.timeout(8000),
});
@@@ -186,4 +186,9 @@ function createIdentity(config, { fetchImpl = globalThis.fetch, now = () => Date
return { byUsername, names };
}
--module.exports = { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity, createOfferReporter };
++/** The community AI offers Bot reports (design: "Offer to the network"; Network /internal/ai/offers/report). */
++function createAiOfferReporter(config, { fetchImpl = globalThis.fetch } = {}) {
++ return createOfferReporter(config, { fetchImpl, path: '/internal/ai/offers/report' });
++}
++
++module.exports = { createKeyProvider, createUserAuth, createNodePrincipals, createIdentity, createOfferReporter, createAiOfferReporter };
ddiff --git a/server/web/render.js b/server/web/render.js
iindex 0e0a3ea..402f8c2 100644
---- a/server/web/render.js
++++ b/server/web/render.js
@@@ -793,7 +793,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {
const caps = m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {};
// Its own models (OpenVibe Node's ai plugin, server/machine-ai.js): what it offers, and whether a model server answers.
const models = aiOf(caps);
-- const ai = models ? { models: Array.isArray(models.models) ? models.models.slice(0, 20).map(String) : [], ready: models.ready === true } : null;
++ const ai = models ? { models: Array.isArray(models.models) ? models.models.slice(0, 20).map(String) : [], ready: models.ready === true,
++ offer: models.offer && typeof models.offer === 'object' ? models.offer : null,
++ speed: models.speed && typeof models.speed === 'object' ? models.speed : null } : null;
const c = caps.computer;
if (!c || typeof c !== 'object') return ai ? { on: false, ai } : null;
const level = m.computer_level || ((c.files || c.terminal || c.screen || {}).level) || null;
@@@ -848,6 +850,14 @@ function renderMachinesPage({ machines = [], pairing = null }) {
// Turned on: OpenVibe.Chat's "Your AI key" comes filled in with this computer and its first model (saved only there).
// With several such machines, any of them (contracts 0.221.0): the fastest that is on answers.
+ (m.ai_models && c.ai.models.length ? `<a class="button quiet" href="https://openvibe.chat/settings?own=node&amp;machine=${modelMachines > 1 ? 'any' : encodeURIComponent(m.id)}&amp;model=${encodeURIComponent(c.ai.models[0])}#oc-ai-key">Use these models in OpenVibe.Chat</a>` : '')
++ + (() => {
++ const o = c.ai.offer;
++ if (!o || o.enabled !== true) return '';
++ const speed = c.ai.speed && typeof c.ai.speed === 'object' ? Object.entries(c.ai.speed).slice(0, 3).map(([model, rate]) => `${model} ${rate} tok/s`).join(', ') : '';
++ return `<div class="machine-share"><span class="pill">${o.paused ? 'Offer paused: this machine is in use' : 'Offering its models to the network'}</span>`
++ + `<span class="muted">${esc(o.privacy || 'A community machine sees the prompt. Only requests that allow community capacity are sent here, and every answer is labelled Community.')}</span>`
++ + `${speed ? `<span class="muted">${esc(speed)}</span>` : ''}</div>`;
++ })()
+ '</div>' : '';
// A machine that offers only its models (the ai plugin, no computer plugin): just them.
if (!c.on) return `<div class="machine-computer">${aiRow}</div>`;
ddiff --git a/server/web/routes.js b/server/web/routes.js
iindex e63c0bf..f57959a 100644
---- a/server/web/routes.js
++++ b/server/web/routes.js
@@@ -40,6 +40,7 @@ const appIcon = require('openvibe-shared/app-icon');
const { getProfile, listProfiles, loadProfiles } = require('../profiles');
const { frameAncestors } = require('../config');
const { BotError, json } = require('../util');
++const { aiOf } = require('../machine-ai');
const kits = require('../kits');
const { renderPanel, renderEmbedRefused, renderDrivePage, renderPairingPage, renderPhonePage, renderMachinesPage, renderMachineDesksPage, renderMachineScreenPage, renderSharePage, renderShareGone, renderLinkPage, renderSignInPage, renderClaimPage, renderClaimApproved, renderFirstRunPage } = require('./render');
const { createMachineScreen } = require('../machine-screen');
@@@ -615,6 +616,8 @@ function createWebRoutes(config, { domain = null, sim = null, onvif = null, limi
computer_desks: Number.isInteger(sensors.computer_desks) ? sensors.computer_desks : null,
desk_leases: d.shared_desks ? await domain.deskLeases.count(d.id) : 0,
ai_models: !!d.ai_models,
++ // The Node ai plugin's community offer, when it has one (models, hours, price, audience, paused, privacy).
++ ai_offer: (() => { const ai = aiOf(d.capabilities); return ai && ai.offer && typeof ai.offer === 'object' ? ai.offer : null; })(),
// How fast its last model answered there (Node v0.29.0's ai plugin), and whether one is answering now.
ai_speed: typeof sensors.ai_tokens_per_s === 'number' && sensors.ai_tokens_per_s > 0 && typeof sensors.ai_model === 'string'
? { model: sensors.ai_model.slice(0, 120), tokens_per_s: sensors.ai_tokens_per_s } : null,
ddiff --git a/test/community-ai.test.js b/test/community-ai.test.js
nnew file mode 100644
iindex 0000000..6fdfce0
---- /dev/null
++++ b/test/community-ai.test.js
@@@ -0,0 +1,90 @@
++'use strict';
++// "Offer to the network" (the design's community machines; plan E4): a Node ai plugin advertises its owner's models,
++// hours, concurrency, price and audience in status.capabilities.ai.offer; Bot reports it as a user-owned provider offer
++// whose detail.kind is `ai`, and AI may send a community run to that machine even with "Let my AI use this computer's
++// models" off, because the offer is the opt-in. Without the offer the same run is refused. This is Bot's half: the
++// report Network validates and the command gate AI places through.
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const reports = [];
++ const aiReports = [];
++ const reporter = { report: async (offers) => { reports.push(offers); return { source: 'bot', offers: offers.length, marked_down: 0 }; } };
++ const aiReporter = { report: async (offers) => { aiReports.push(offers); return { source: 'bot', offers: offers.length, marked_down: 0 }; } };
++ const t = await boot({ env: { BOT_ROUTINE_TICK_MS: '0', BOT_WEBHOOK_TICK_MS: '0' }, appOpts: { offerReporter: reporter, aiOfferReporter: aiReporter } });
++ 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 seen = [];
++ const OFFER = { enabled: true, models: ['llama3.1:8b'], hours: { start: '00:00', end: '23:59', tz: 'UTC' }, concurrency: 2,
++ price: { in_per_million: 10, out_per_million: 30 }, audience: 'followers', group: null, open_now: true, ready: true };
++
++ /** A Network-paired machine whose fake Node advertises an ai offer and answers one ai command. */
++ async function machine() {
++ const principal = t.network.addNode({ owner: alex.subject, ref: `worker:${alex.subject}`, name: 'gpu-box' });
++ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(principal)}` } });
++ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;
++ await c.waitFor((m) => m.type === 'config');
++ c.send(frame('status', { capabilities: { ai: { ai: { models: ['llama3.1:8b'], ready: true, offer: OFFER } } }, drivers: [], faults: [] }));
++ c.ws.on('message', (raw) => {
++ let m; try { m = JSON.parse(raw.toString()); } catch { return; }
++ if (m.type !== 'command') return;
++ seen.push(m);
++ if (m.kind === 'ai') c.send(frame('ack', { id: m.id, data: { model: m.value.model, message: { role: 'assistant', content: 'hello from the box' }, usage: { prompt_tokens: 8, completion_tokens: 4, total_tokens: 12 } } }));
++ else c.send(frame('nack', { id: m.id, fault_code: 'not_allowed', message: 'no' }));
++ });
++ await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [deviceId]); return d && d.capabilities.ai && d.capabilities.ai.ai && d.capabilities.ai.ai.offer; });
++ return { c, deviceId };
++ }
++
++ const cmd = (deviceId, body) => t.call('POST', `/api/v1/machines/${deviceId}/commands`, {
++ body, sub: 'svc:ai', cap: ['bot.machine.ai'], headers: { 'X-OV-Subject': alex.subject },
++ });
++
++ let mine;
++ await check('an ai offer becomes one user-owned provider offer with its settings', async () => {
++ mine = await machine();
++ const built = await t.app.locals.offers.buildAi();
++ const offer = built.find((o) => o.offer_id === `ai:${mine.deviceId}`);
++ assert.ok(offer, JSON.stringify(built));
++ assert.deepStrictEqual([offer.machine_id, offer.owner, offer.models], [mine.deviceId, alex.subject, ['llama3.1:8b']]);
++ assert.deepStrictEqual(offer.hours, { start: '00:00', end: '23:59', tz: 'UTC' });
++ assert.deepStrictEqual([offer.concurrency, offer.price, offer.audience], [2, { in_per_million: 10, out_per_million: 30 }, 'followers']);
++ assert.strictEqual(offer.ready, true);
++ assert.strictEqual(offer.health.status, 'up');
++ await t.app.locals.offers.publish();
++ assert.ok(aiReports.at(-1).some((o) => o.offer_id === `ai:${mine.deviceId}`), 'the AI offers are reported to Network');
++ assert.ok(reports.at(-1).some((o) => o.offer_id === `node:${mine.deviceId}`) === false, 'the worker report is separate');
++ const page = await (await fetch(`${t.base}/machines`, { headers: { Cookie: `ov_token=${t.network.signUser(alex)}` } })).text();
++ assert.ok(page.includes('A community machine sees the prompt'), 'the machine page says the privacy cost plainly');
++ assert.ok(page.includes('Offering its models to the network'));
++ });
++
++ await check('AI sends a community run to the offered machine, even with the own-AI switch off', async () => {
++ const r = await cmd(mine.deviceId, { id: 'c1', kind: 'ai', value: { model: 'community/llama3.1:8b', messages: [{ role: 'user', content: 'hello' }] } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([r.json.result, r.json.data.message.content], ['ack', 'hello from the box']);
++ assert.strictEqual(seen.at(-1).value.model, 'community/llama3.1:8b', 'the community/ prefix marks it; the local model name is stripped by the plugin');
++ const row = await t.db.maybe('SELECT * FROM command_audit WHERE robot_id = $1 AND kind = $2 ORDER BY id DESC LIMIT 1', [mine.deviceId, 'ai']);
++ assert.strictEqual(row.result, 'ack', 'and it is audited as any machine command is');
++ });
++
++ await check('a machine without the offer still refuses a community run, and the owner switch has not moved', async () => {
++ const other = t.network.addNode({ owner: alex.subject, ref: `worker:${alex.subject}`, name: 'plain-box' });
++ const c = await t.ws('/device', { headers: { Authorization: `Bearer ${t.network.signNode(other)}` } });
++ const deviceId = (await c.waitFor((m) => m.type === 'hello')).device_id;
++ await c.waitFor((m) => m.type === 'config');
++ c.send(frame('status', { capabilities: { ai: { ai: { models: ['llama3.1:8b'], ready: true, offer: { enabled: false } } } }, drivers: [], faults: [] }));
++ await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [deviceId]); return d && d.capabilities.ai; });
++ const run = await cmd(deviceId, { kind: 'ai', value: { model: 'community/llama3.1:8b', messages: [{ role: 'user', content: 'x' }] } });
++ assert.deepStrictEqual([run.status, run.json.code], [403, 'bot.agent_not_allowed']);
++ assert.match(run.json.detail || '', /not offered/i);
++ const own = await cmd(deviceId, { kind: 'ai', value: { model: 'llama3.1:8b', messages: [{ role: 'user', content: 'x' }] } });
++ assert.deepStrictEqual([own.status, own.json.code], [403, 'bot.agent_not_allowed'], 'without either opt-in AI runs nothing there');
++ assert.ok(!(await t.app.locals.offers.buildAi()).some((o) => o.offer_id === `ai:${deviceId}`), 'and no offer is reported');
++ });
++
++ await done(t);
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/worker-machines.test.js b/test/worker-machines.test.js
iindex 78ea0d5..fa7e973 100644
---- a/test/worker-machines.test.js
++++ b/test/worker-machines.test.js
@@@ -31,6 +31,9 @@ const { boot, check, done } = require('./helpers/app');
c.send(frame('status', { capabilities: { worker: { capabilities: classes.map((x) => `worker:${x}`), runtime_classes: classes } } }));
await poll(async () => { const d = await t.db.maybe('SELECT capabilities FROM devices WHERE id = $1', [deviceId]); return d && d.capabilities.worker; });
}
++ // Beat like a real Node while the socket is open: the later checks take longer than two missed heartbeats.
++ const beat = setInterval(() => { try { c.send(frame('heartbeat', { t: Date.now() })); } catch { /* closed */ } }, 100);
++ c.ws.on('close', () => clearInterval(beat));
return { principal, c, deviceId };
}