openvibers/openvibe.network Commits

The machine card in plain words: For you kept apart from For others (#894)

OP OpenVibers committed 8 h ago · aefeefe7ed · 6 files, +232 −64

Tabs For you / For others / Coding apps / Screen. Every switch says what it does for whom, in plain words; no
catalogue jargon on the card. New: Use the AI models on this computer (the role and the owner's yes together) and
Lend browsers to other people's agents (dimmed, with the command that fixes it, where the browsers are not each
locked in a box). Routes POST /api/v1/machines/:id/ai-models and /shared-desks, own session only.

Changes

ddiff --git a/public/css/my.css b/public/css/my.css
iindex 6d37734c..62ecce0f 100644
---- a/public/css/my.css
++++ b/public/css/my.css
@@@ -421,6 +421,15 @@ button.td-stat:hover span { color: var(--accent-light); }
.mc-role-t { display: grid; gap: 1px; min-width: 0; }
.mc-role-t b { font-size: 13.5px; }
.mc-role-t small { font-size: 12px; color: var(--text-muted); line-height: 1.35; }
++/* 2026-11-12: the card in plain words. A line under each group says whom it is for; a hint under a row says what it
++ needs or how many; a row that cannot be turned on here is dimmed; a status line says what is set on the computer. */
++.mc-roles > .mc-lede { grid-column: 1 / -1; margin: 0 0 2px; font-size: 13px; color: var(--text-secondary); }
++.mc-role-t .mc-hint { color: var(--text-secondary); }
++.mc-role.is-off { opacity: .62; cursor: default; }
++.mc-status-line { grid-column: 1 / -1; margin: 0; display: flex; gap: 8px; align-items: center; font-size: 12.5px; color: var(--text-muted); }
++.mc-status-line.is-on { color: var(--text-secondary); }
++.mc-status-line.is-on > i { color: var(--success, #22c55e); }
++.mc-roles > .ma-switches { display: contents; }
.mc-acts { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.mc-revoke { background: transparent; border: 1px solid color-mix(in srgb, #ef4444 45%, var(--border)); color: var(--danger-text, #ef4444); }
.mc-revoke.is-sure { background: #ef4444; color: #fff; border-color: #ef4444; }
ddiff --git a/public/js/my/access.js b/public/js/my/access.js
iindex 25c62f3d..6ebc442b 100644
---- a/public/js/my/access.js
++++ b/public/js/my/access.js
@@@ -245,15 +245,17 @@ async function loadMachineAgents() {
box.hidden = false;
}
--/** The three access switches on a paired machine's card, read when the card is drawn. */
--function mcAccessSwitches(m) {
++/** The access switches on a paired machine's card, read when the card is drawn: the rows about you ('you') or about
++ * other people ('others'), in plain words (2026-11-12). */
++function mcAccessSwitches(m, side) {
if (!m.principal_id) return null;
-- const field = mcEl('fieldset', 'mc-roles mc-switches ma-switches', [mcEl('legend', '', ['Other machines and agents'])]);
-- const rows = [
-- ['others_may_ask', 'Others may ask for access', 'People you follow can ask this machine for access; you answer each time. Off: nobody else can ask.', 'fa-user-group'],
-- ['auto_agent_send', 'My machines need no answer', 'Your own machines may talk to its agents and send it files without asking you first.', 'fa-laptop'],
-- ['agents_talk', 'Its agents may talk to other agents', 'Agents here may send tasks to your other agents, and to others where they are allowed.', 'fa-robot'],
++ const field = mcEl('div', 'ma-switches');
++ const all = [
++ ['auto_agent_send', 'My other computers may use it without asking', 'Your own computers may send it files and tasks without waiting for your yes.', 'fa-laptop', 'you'],
++ ['agents_talk', 'Its agents may work with other agents', 'Agents here may hand tasks to your other agents, and to other people\u2019s agents where those people allow it.', 'fa-robot', 'you'],
++ ['others_may_ask', 'Let people ask to use it', 'People you follow may ask to use this computer. You say yes or no each time; off, nobody can ask.', 'fa-user-group', 'others'],
];
++ const rows = all.filter((r) => !side || r[4] === side);
const boxes = {};
for (const [key, label, note, icon] of rows) {
const id = `mc-${m.id}-${key}`;
ddiff --git a/public/js/my/machines.js b/public/js/my/machines.js
iindex b436637a..1a2fa4e6 100644
---- a/public/js/my/machines.js
++++ b/public/js/my/machines.js
@@@ -27,6 +27,9 @@ function mcAgo(at) {
}
const MC_KIND = { onboard: 'This computer', bridge: 'A bridge', server: 'A server' };
const MC_ROLE_ICON = { 'codes.worker': 'fa-code', 'desks.host': 'fa-display', 'ai.host': 'fa-microchip', 'bot.robot': 'fa-robot' };
++/* The words a person reads (2026-11-12, the owner: "easy to understand for layman's"). The catalogue's names (Codes
++ worker, Desk host, AI host) stay the API's; the card says what each does, for whom. */
++const MC_SHORT = { 'codes.worker': 'Runs my coding tasks', 'desks.host': 'Browsers for my agents', 'ai.host': 'My AI models', 'bot.robot': 'My robot' };
function mcSay(text, bad) {
const s = document.getElementById('mc-status');
@@@ -46,32 +49,23 @@ function mcCard(m) {
const meta = mcEl('p', 'mc-meta', [mcIcon(osIcon), ' ', [MC_KIND[m.kind], m.os, m.node_version ? `Node ${m.node_version}` : ''].filter(Boolean).join(' · ')]);
const head = mcEl('div', 'mc-head', [dot, mcEl('div', 'mc-title', [name, state]), meta]);
card.append(head);
-- const roleChips = (m.roles || []).map((id) => (mcRoles || []).find((r) => r.id === id)).filter(Boolean);
-- card.append(mcEl('div', 'mc-role-chips', roleChips.length ? roleChips.map((r) => mcEl('span', 'mc-role-chip', [r.name])) : [mcEl('span', 'mc-role-chip is-empty', ['Nothing turned on yet'])]));
++ const on = mcOnNow(m);
++ card.append(mcEl('div', 'mc-role-chips', on.length ? on.map((t) => mcEl('span', 'mc-role-chip', [t])) : [mcEl('span', 'mc-role-chip is-empty', ['Nothing turned on yet'])]));
if (Array.isArray(m.running) && m.running.length) {
card.append(mcEl('ul', 'mc-running', m.running.slice(0, 6).map((r) => mcEl('li', '', [String((r && (r.title || r.kind || r.id)) || r)]))));
}
-- // The roles: a switch each; a change saves the whole set at once.
-- const roles = mcEl('fieldset', 'mc-roles mc-role-set', [mcEl('legend', '', ['What it does for you'])]);
-- (mcRoles || []).forEach((role) => {
-- const id = `mc-${m.id}-${role.id.replace(/\W/g, '-')}`;
-- const box = mcEl('input', '', [], { type: 'checkbox', role: 'switch', id, value: role.id });
-- box.checked = (m.roles || []).includes(role.id);
-- box.addEventListener('change', () => mcSaveRoles(card, m));
-- roles.append(mcEl('label', 'mc-role', [box, mcEl('span', 'mc-role-ic', [mcIcon(MC_ROLE_ICON[role.id] || 'fa-gear')]), mcEl('span', 'mc-role-t', [mcEl('b', '', [role.name]), mcEl('small', '', [role.shows || ''])])], { for: id }));
-- });
-- const own = mcOwnerSwitches(m);
-- // Who else may ask this machine for access, and its agents (my/access.js).
-- const access = typeof mcAccessSwitches === 'function' ? mcAccessSwitches(m) : null;
++ // What it does for you, and, kept apart, what it does for other people (2026-11-12).
++ const forYou = mcForYou(card, m);
++ const forOthers = mcForOthers(m);
const screen = mcScreenBlock(card, m);
const links = mcSharesBlock(m);
const desks = mcDesksBlock(card, m);
const ready = (Array.isArray(m.fleet) ? m.fleet : []).filter((a) => a && a.installed && a.signed_in).length;
card.append(mcTabs(m.id, [
-- ['agents', 'Agents', [mcAgentsBlock(card, m), ...(m.pools || []).map((pool) => mcPoolBlock(pool))], ready],
-- ['roles', 'Roles', [roles], (m.roles || []).length],
-- ['access', 'Access', [own, access]],
++ ['you', 'For you', [forYou], mcCountYou(m)],
++ ['others', 'For others', [forOthers], mcCountOthers(m)],
++ ['agents', 'Coding apps', [mcAgentsBlock(card, m), ...(m.pools || []).map((pool) => mcPoolBlock(pool))], ready],
['screen', 'Screen', [screen, links, desks]],
].filter(([, , blocks]) => blocks.some(Boolean))));
@@@ -112,12 +106,12 @@ function mcAgentsBlock(card, m) {
const fresh = fleet.length && Number.isFinite(age) && age <= 10 * 60000;
let lede;
if (!m.online) lede = 'Offline: what it has is shown as last reported.';
-- else if (!worker) lede = 'Turn on Codes worker and this machine runs your coding jobs with the agents it has, and says which ones here.';
-- else if (!fleet.length) lede = 'Codes worker is on. This machine has not reported its agents yet: they show a minute after the Link connects.';
++ else if (!worker) lede = 'Turn on \u201cRun my coding tasks\u201d and this computer runs your coding tasks with the coding apps it has, and lists them here.';
++ else if (!fleet.length) lede = 'Coding tasks are on. This computer has not listed its coding apps yet: they show a minute after it connects.';
else lede = fresh ? `Reported ${mcAgo(m.fleet_at)}.` : `Last reported ${mcAgo(m.fleet_at)}.`;
const head = mcEl('div', 'mc-agents-head', [mcEl('p', 'mc-agents-lede', [lede])]);
if (!worker) {
-- const on = mcEl('button', 'btn btn-primary btn-small', [mcIcon('fa-power-off'), ' Turn on Codes worker'], { type: 'button' });
++ const on = mcEl('button', 'btn btn-primary btn-small', [mcIcon('fa-power-off'), ' Run my coding tasks here'], { type: 'button' });
on.addEventListener('click', () => {
const box = card.querySelector('input[value="codes.worker"]');
if (box && !box.checked) { box.checked = true; box.dispatchEvent(new Event('change', { bubbles: true })); }
@@@ -202,30 +196,120 @@ function mcTabs(machineId, panes) {
it), so nothing turns on what an agent may do by itself. */
/** A labelled switch row: a checkbox, an icon, a bold label and the sentence that says what it allows. */
--function mcSwitchRow(id, label, note, checked, onChange) {
-- const box = mcEl('input', '', [], { type: 'checkbox', role: 'switch', id });
++
++/** What is turned on, in a few words each, for the card's head. */
++function mcOnNow(m) {
++ const c = m.controls || {};
++ const out = (m.roles || []).filter((id) => id !== 'ai.host' || c.ai_models).map((id) => MC_SHORT[id]).filter(Boolean);
++ if (c.agent_computer) out.push('My agents may use it');
++ if (c.shared_desks) out.push('Lends browsers to others');
++ if (c.computer && c.ai && c.ai.offered) out.push('Shares its AI models');
++ return out;
++}
++const mcCountYou = (m) => (m.roles || []).length + ((m.controls || {}).agent_computer ? 1 : 0) + ((m.controls || {}).watch_probe ? 1 : 0);
++const mcCountOthers = (m) => ((m.controls || {}).shared_desks ? 1 : 0) + (((m.controls || {}).ai || {}).offered ? 1 : 0);
++
++/** One switch row: a switch, its mark, what it does in a few words, and one plain sentence. */
++function mcRow(id, icon, label, note, checked, onChange, opts) {
++ const o = opts || {};
++ const box = mcEl('input', o.roleId ? 'mc-role-input' : '', [], { type: 'checkbox', role: 'switch', id, ...(o.roleId ? { value: o.roleId } : {}) });
box.checked = checked === true;
++ if (o.disabled) box.disabled = true;
box.addEventListener('change', () => onChange(box));
-- return mcEl('label', 'mc-role', [box, mcEl('span', 'mc-role-ic', [mcIcon(id.endsWith('-agent') ? 'fa-robot' : 'fa-tower-broadcast')]),
-- mcEl('span', 'mc-role-t', [mcEl('b', '', [label]), mcEl('small', '', [note])])], { for: id });
++ const text = [mcEl('b', '', [label]), mcEl('small', '', [note])];
++ if (o.hint) text.push(mcEl('small', 'mc-hint', [o.hint]));
++ return mcEl('label', `mc-role${o.disabled ? ' is-off' : ''}`, [box, mcEl('span', 'mc-role-ic', [mcIcon(icon)]), mcEl('span', 'mc-role-t', text)], { for: id });
}
--/** The two switches the removed page had for the person's agent and watches, off unless Bot says they are on. */
--function mcOwnerSwitches(m) {
-- const c = m.controls || null;
-- if (!c || !c.computer) return null;
-- const field = mcEl('fieldset', 'mc-roles mc-switches', [mcEl('legend', '', ['For your agent and your watches'])]);
-- field.append(mcSwitchRow(`mc-${m.id}-agent`, 'Let my agent use this computer',
-- 'Your OpenVibe.Actor agent may use its terminal, files and screen as you, at the level set on the computer. Off until you turn it on.',
-- c.agent_computer, (box) => mcSaveSwitch(m, box, 'agent-computer', `Saved. ${m.name || 'The machine'} ${box.checked ? 'may be used by your agent.' : 'is off limits to your agent now.'}`)));
-- if (c.probe) {
-- field.append(mcSwitchRow(`mc-${m.id}-watch`, 'Let my watches check things from this computer',
-- 'OpenVibe.Watch may probe your own network from here for your watches: one GET or one TCP connection, to private addresses only.',
-- c.watch_probe, (box) => mcSaveSwitch(m, box, 'watch-probe', `Saved. Your watches ${box.checked ? 'may check from this computer.' : 'no longer check from here.'}`)));
++/** For you: what this computer does for you and your own agents. */
++function mcForYou(card, m) {
++ const c = m.controls || {};
++ const has = (id) => (m.roles || []).includes(id);
++ const known = (id) => !mcRoles || !mcRoles.length || mcRoles.some((r) => r.id === id);
++ const field = mcEl('fieldset', 'mc-roles mc-role-set', [mcEl('legend', '', ['For you and your agents'])]);
++ field.append(mcEl('p', 'mc-lede', ['Only you, and agents you run, use what you turn on here.']));
++ const name = m.name || 'This computer';
++ if (known('codes.worker')) {
++ field.append(mcRow(`mc-${m.id}-codes`, 'fa-code', 'Run my coding tasks',
++ 'Coding tasks you start on openvibe.codes run on this computer, with the coding apps it has (see Coding apps).',
++ has('codes.worker'), () => mcSaveRoles(card, m), { roleId: 'codes.worker' }));
}
++ if (known('desks.host')) {
++ const desk = c.desk || {};
++ field.append(mcRow(`mc-${m.id}-desks`, 'fa-display', 'Give my agents a browser here',
++ 'Your agents get their own private browser on this computer, to look things up and fill in websites for you. Watch them on the Screen tab.',
++ has('desks.host'), () => mcSaveRoles(card, m),
++ { roleId: 'desks.host', hint: c.computer && desk.available === false ? 'This computer was set up without browsers for agents. Set it up again with: openvibe-link install --desk-host' : (desk.max ? `Up to ${desk.max} at once.` : '') }));
++ }
++ if (c.computer) {
++ field.append(mcRow(`mc-${m.id}-agent`, 'fa-robot', 'Let my agents use this computer',
++ `Your agents may open files and look at the screen here, as far as this computer allows${c.level ? ` (it is set to “${c.level}”)` : ''}.`,
++ c.agent_computer, (box) => mcSaveSwitch(m, box, 'agent-computer', `Saved. ${box.checked ? `Your agents may use ${name}.` : `Your agents may no longer use ${name}.`}`)));
++ }
++ if (known('ai.host')) {
++ const ai = c.ai || {};
++ const aiOn = has('ai.host') && c.ai_models === true;
++ field.append(mcRow(`mc-${m.id}-ai`, 'fa-microchip', 'Use the AI models on this computer',
++ 'OpenVibe AI may answer you with the models installed here: free, and your words stay on your own computer.',
++ aiOn, (box) => mcSaveAi(card, m, box),
++ { hint: ai.available ? (ai.models ? `${ai.models} model${ai.models === 1 ? '' : 's'} installed.` : 'No models installed yet: add one with Ollama or LM Studio.') : (c.computer ? 'No model app found here. Install Ollama or LM Studio, then set it up again with: openvibe-link install --ai' : '') }));
++ }
++ if (c.computer && c.probe) {
++ field.append(mcRow(`mc-${m.id}-watch`, 'fa-tower-broadcast', 'Let my watches check things from here',
++ 'Your watches on openvibe.watch can check websites and devices that only this computer’s network can reach.',
++ c.watch_probe, (box) => mcSaveSwitch(m, box, 'watch-probe', `Saved. Your watches ${box.checked ? 'may check from here.' : 'no longer check from here.'}`)));
++ }
++ if (known('bot.robot') && (has('bot.robot') || m.kind === 'onboard')) {
++ field.append(mcRow(`mc-${m.id}-robot`, 'fa-robot', 'Drive my robot from here',
++ 'A robot is plugged into this computer, and OpenVibe passes it your commands.',
++ has('bot.robot'), () => mcSaveRoles(card, m), { roleId: 'bot.robot' }));
++ }
++ // My own machines and agents (my/access.js): the rows about you.
++ if (typeof mcAccessSwitches === 'function') field.append(...[mcAccessSwitches(m, 'you')].filter(Boolean));
return field;
}
++/** For other people: what this computer lends to people other than you. Off unless turned on. */
++function mcForOthers(m) {
++ const c = m.controls || {};
++ const field = mcEl('fieldset', 'mc-roles mc-switches mc-others', [mcEl('legend', '', ['For other people'])]);
++ field.append(mcEl('p', 'mc-lede', ['Everything here is off until you turn it on. Other people never see your files, your screen or your programs.']));
++ if (c.computer) {
++ const desk = c.desk || {};
++ const can = desk.available === true && desk.sandboxed === true;
++ field.append(mcRow(`mc-${m.id}-lend`, 'fa-people-arrows', 'Lend browsers to other people’s agents',
++ 'Agents that other people run may borrow a browser here, each one locked in its own box that reaches only the public internet.',
++ c.shared_desks, (box) => mcSaveSwitch(m, box, 'shared-desks', `Saved. ${m.name || 'This computer'} ${box.checked ? 'lends browsers to other people’s agents.' : 'no longer lends browsers.'}`),
++ { disabled: !can && !c.shared_desks, hint: can ? (desk.max ? `Up to ${desk.max} browsers at once, yours and theirs together.` : '') : 'This computer keeps no locked box for each browser, so it cannot lend them. Set it up again with: openvibe-link install --desk-host --shared' }));
++ const ai = c.ai || {};
++ if (ai.available) {
++ field.append(mcEl('p', `mc-status-line${ai.offered ? ' is-on' : ''}`, [mcIcon(ai.offered ? 'fa-circle-check' : 'fa-circle'), ' ',
++ ai.offered ? 'Its AI models answer other people too (set on the computer itself; each answer is marked Community).' : 'Its AI models answer only you. Offering them to others is set on the computer itself.']));
++ }
++ }
++ if (typeof mcAccessSwitches === 'function') field.append(...[mcAccessSwitches(m, 'others')].filter(Boolean));
++ return field;
++}
++
++/** "Use the AI models on this computer" is two settings that only work together: the role, and the owner's yes. */
++async function mcSaveAi(card, m, box) {
++ const want = box.checked;
++ box.disabled = true;
++ try {
++ const roles = new Set(m.roles || []);
++ if (want) roles.add('ai.host'); else roles.delete('ai.host');
++ const ids = [...roles];
++ const r = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/roles`, { method: 'PUT', body: JSON.stringify({ roles: ids }) });
++ m.roles = (r && r.machine && r.machine.roles) || ids;
++ const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/ai-models`, { method: 'POST', body: JSON.stringify({ on: want }) });
++ if (out && out.machine && out.machine.controls) m.controls = out.machine.controls;
++ mcSay(want ? `Saved. OpenVibe AI may use the models on ${m.name || 'this computer'}.` : `Saved. OpenVibe AI no longer uses the models on ${m.name || 'this computer'}.`);
++ } catch (e) {
++ box.checked = !want;
++ mcSay(e.message || 'That could not be saved.', true);
++ } finally { box.disabled = false; }
++}
++
async function mcSaveSwitch(m, box, route, done) {
const before = !box.checked;
box.disabled = true;
@@@ -568,17 +652,18 @@ function mcAttachTakeOver(figure, video, m, use) {
async function mcSaveRoles(card, m) {
-- const roles = [...card.querySelectorAll('.mc-role-set input')].filter((b) => b.checked).map((b) => b.value);
-- card.querySelectorAll('.mc-role-set input').forEach((b) => { b.disabled = true; });
++ const keep = (m.roles || []).filter((id) => id === 'ai.host'); // the AI row saves its own role (mcSaveAi)
++ const roles = [...new Set([...keep, ...[...card.querySelectorAll('.mc-role-input')].filter((b) => b.checked).map((b) => b.value)])];
++ card.querySelectorAll('.mc-role-input').forEach((b) => { b.disabled = true; });
try {
const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/roles`, { method: 'PUT', body: JSON.stringify({ roles }) });
m.roles = (out && out.machine && out.machine.roles) || roles;
-- mcSay(`Saved. ${m.name || 'The machine'} picks this up at its next check-in.`);
++ mcSay(`Saved. ${m.name || 'The computer'} picks this up within a minute.`);
} catch (e) {
-- mcSay(e.message || 'The roles could not be saved.', true);
-- card.querySelectorAll('.mc-role-set input').forEach((b) => { b.checked = (m.roles || []).includes(b.value); });
++ mcSay(e.message || 'That could not be saved.', true);
++ card.querySelectorAll('.mc-role-input').forEach((b) => { b.checked = (m.roles || []).includes(b.value); });
} finally {
-- card.querySelectorAll('.mc-role-set input').forEach((b) => { b.disabled = false; });
++ card.querySelectorAll('.mc-role-input').forEach((b) => { b.disabled = false; });
}
}
ddiff --git a/server/registry/machines.js b/server/registry/machines.js
iindex c782f737..c401f731 100644
---- a/server/registry/machines.js
++++ b/server/registry/machines.js
@@@ -13,7 +13,7 @@
* PATCH /api/v1/machines/:id network.machines.manage → { machine } (a new name)
* PUT /api/v1/machines/:id/roles network.machines.manage → { machine } (the whole set)
* POST /api/v1/machines/:id/revoke network.machines.manage → { machine }
-- * POST /api/v1/machines/:id/agent-computer, /watch-probe → { machine } (the owner's own session)
++ * POST /api/v1/machines/:id/agent-computer, /watch-probe, /ai-models, /shared-desks → { machine } (own session)
* POST /api/v1/machines/:id/screen network.machines.manage → 201 { machine_id, … } (the live screen)
* GET /api/v1/machines/:id/screen network.machines.read → { live, signaling_url, … }
* DELETE /api/v1/machines/:id/screen network.machines.manage → { stopped }
@@@ -127,6 +127,9 @@ function machineOf(m) {
out.controls = {
agent_computer: m.agent_computer === true,
watch_probe: m.watch_probe === true,
++ // 2026-11-12: the owner's AI may use the models here; the desks are lent to other people's agents.
++ ai_models: m.ai_models === true,
++ shared_desks: m.shared_desks === true,
computer: !!computer,
...(computer ? {
level: typeof computer.level === 'string' ? computer.level : null,
@@@ -144,6 +147,12 @@ function machineOf(m) {
...(Number.isInteger(computer.desk.max_desks) ? { max: computer.desk.max_desks } : {}),
...(Array.isArray(computer.desk.kinds) ? { kinds: computer.desk.kinds.filter((k) => typeof k === 'string').slice(0, 5) } : {}),
} : { available: false },
++ // The models the machine serves (its ai plugin): how many, and whether it offers them to everyone.
++ ai: computer.ai && typeof computer.ai === 'object' ? {
++ available: true,
++ models: Array.isArray(computer.ai.models) ? computer.ai.models.length : 0,
++ offered: !!(computer.ai.offer && typeof computer.ai.offer === 'object' && computer.ai.offer.enabled === true),
++ } : { available: false },
} : {}),
};
return out;
@@@ -204,6 +213,8 @@ function createBotClient({ baseUrl, privateKey, issuer, fetchImpl = globalThis.f
// a machine that is not the person's is 404 before any of these do anything.
agentComputer: (subject, id, on) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/agent-computer`, { on: !!on }, subject),
watchProbe: (subject, id, on) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/watch-probe`, { on: !!on }, subject),
++ aiModels: (subject, id, on) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/ai-models`, { on: !!on }, subject),
++ sharedDesks: (subject, id, on) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/shared-desks`, { on: !!on }, subject),
screenStart: (subject, id, body) => call('POST', `/api/v1/machines/${encodeURIComponent(id)}/screen`, body || {}, subject),
screenState: (subject, id) => call('GET', `/api/v1/machines/${encodeURIComponent(id)}/screen`, null, subject),
screenStop: (subject, id) => call('DELETE', `/api/v1/machines/${encodeURIComponent(id)}/screen`, null, subject),
@@@ -382,7 +393,7 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.lo
const ready = m.online && m.roles.includes('codes.worker');
return { id: m.id, ...(m.principal_id ? { principal_id: m.principal_id } : {}), kind: 'personal', name: m.name || m.id,
status: ready ? 'ready' : 'unavailable', price: { amount_usd: 0, unit: 'hour' },
-- reason: ready ? null : m.online ? 'Turn on the Codes worker role in Link.' : 'This machine is offline.' };
++ reason: ready ? null : m.online ? 'Turn on \u201cRun my coding tasks\u201d for this computer on openvibe.link/machines.' : 'This computer is offline.' };
});
const sources = [
{ kind: 'personal', name: 'Your Link machines', status: candidates.some((c) => c.status === 'ready') ? 'ready' : 'unavailable', reason: candidates.length ? null : 'Pair a machine in Link.' },
@@@ -506,6 +517,22 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.lo
const out = await bot.watchProbe(subject, machineId(req), on);
res.json({ machine: machineOf(out && out.machine) });
}));
++ // 2026-11-12: "Use the AI models on this computer" and "Lend browsers to other people's agents". Bot refuses to lend
++ // the desks of a computer that does not keep each one in a sandbox of its own (409 bot.desks_not_sandboxed).
++ hub.post('/:id/ai-models', manage, ownSession, express.json({ limit: '8kb' }), async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const on = booleanOn(req);
++ const out = await bot.aiModels(subject, machineId(req), on);
++ res.json({ machine: machineOf(out && out.machine) });
++ }));
++ hub.post('/:id/shared-desks', manage, ownSession, express.json({ limit: '8kb' }), async (req, res) => await send(res, async () => {
++ res.set('Cache-Control', 'private, no-store');
++ const subject = await subjectOf(req);
++ const on = booleanOn(req);
++ const out = await bot.sharedDesks(subject, machineId(req), on);
++ res.json({ machine: machineOf(out && out.machine) });
++ }));
// The live screen, to the owner only: Start (10 minutes by Bot's default), the state the page polls, Stop, and
// the owner's own mouse and keyboard while it is shown. `signaling_url` is a viewer credential, so it never
ddiff --git a/test/machines-hub.test.js b/test/machines-hub.test.js
iindex 6c69d2dc..6030a76c 100644
---- a/test/machines-hub.test.js
++++ b/test/machines-hub.test.js
@@@ -50,7 +50,7 @@ const machineOf = (id) => {
const s = stateOf(id);
return {
id, name: 'garage-pi', kind: 'onboard', online: true, roles: [], running: [], node_id: null,
-- agent_computer: s.agent_computer === true, watch_probe: s.watch_probe === true,
++ agent_computer: s.agent_computer === true, watch_probe: s.watch_probe === true, ai_models: s.ai_models === true, shared_desks: s.shared_desks === true,
computer: {
level: 'ask', terminal: { level: 'ask' }, files: { level: 'ask' }, screen: { level: 'ask', stream: true },
probe: { level: 'observe' }, desk: { level: 'ask', max_desks: 2, kinds: ['browser'], isolation: 'sandbox', screen: true, named: true },
@@@ -74,9 +74,9 @@ const botServer = http.createServer(async (req, res) => {
}
// The owner's own controls (the card): each as Bot checks it, the machine's own or 404.
let m;
-- if (req.method === 'POST' && (m = /^\/api\/v1\/machines\/([^/]+)\/(agent-computer|watch-probe)$/.exec(req.url))) {
++ if (req.method === 'POST' && (m = /^\/api\/v1\/machines\/([^/]+)\/(agent-computer|watch-probe|ai-models|shared-desks)$/.exec(req.url))) {
if (!mineOf(decodeURIComponent(m[1]))) return answer(404, { code: 'bot.device_not_found' });
-- const key = m[2] === 'agent-computer' ? 'agent_computer' : 'watch_probe';
++ const key = { 'agent-computer': 'agent_computer', 'watch-probe': 'watch_probe', 'ai-models': 'ai_models', 'shared-desks': 'shared_desks' }[m[2]];
if (typeof body.on !== 'boolean') return answer(422, { code: 'bot.invalid_input' });
return answer(200, { machine: setFlag(decodeURIComponent(m[1]), key, body.on) });
}
@@@ -269,6 +269,14 @@ try {
assert.strictEqual(watchOn.body.machine.controls.watch_probe, true);
sent = calls.at(-1);
assert.deepStrictEqual([sent.method, sent.url, sent.subject, sent.body], ['POST', `/api/v1/machines/${anaMachine}/watch-probe`, ana.subject_id, { on: true }]);
++ // 2026-11-12: the card's two new switches, the models and lending browsers to other people's agents.
++ for (const [route, key] of [['ai-models', 'ai_models'], ['shared-desks', 'shared_desks']]) {
++ const r = await call('POST', `/api/v1/machines/${anaMachine}/${route}`, { user: ana, body: { on: true } });
++ assert.strictEqual(r.status, 200, `${route}: ${JSON.stringify(r.body)}`);
++ assert.strictEqual(r.body.machine.controls[key], true, route);
++ sent = calls.at(-1);
++ assert.deepStrictEqual([sent.method, sent.url, sent.subject, sent.body], ['POST', `/api/v1/machines/${anaMachine}/${route}`, ana.subject_id, { on: true }], route);
++ }
// A body that is not true/false never reaches Bot.
const beforeBadSwitch = calls.length;
const badSwitch = await call('POST', `/api/v1/machines/${anaMachine}/agent-computer`, { user: ana, body: { on: 'yes' } });
ddiff --git a/test/my-machines.test.js b/test/my-machines.test.js
iindex 17711f87..08390547 100644
---- a/test/my-machines.test.js
++++ b/test/my-machines.test.js
@@@ -33,6 +33,7 @@ class Node {
querySelectorAll(sel) {
if (sel === '.mc-role-set input') return this.all((n) => n.tagName === 'INPUT' && n.parent && n.parent.className === 'mc-role' && n.parent.parent && n.parent.parent.className.split(' ').includes('mc-role-set'));
if (sel === '.mc-role input') return this.all((n) => n.tagName === 'INPUT' && n.parent && n.parent.className === 'mc-role');
++ if (sel === '.mc-role-input') return this.all((n) => n.tagName === 'INPUT' && String(n.className || '').split(' ').includes('mc-role-input'));
return [];
}
}
@@@ -92,18 +93,29 @@ assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the a
const noCard = { querySelector: () => null };
assert.match(p.sandbox.mcAgentsBlock(noCard, { online: true, roles: ['codes.worker'], fleet: [{ agent: 'muse', installed: true, signed_in: false }] }).textContent, /Muse Code[^]*?Sign in/);
assert.match(p.sandbox.mcAgentsBlock(noCard, { online: true, roles: ['codes.worker'], fleet: [{ agent: 'muse', installed: false, signed_in: false }] }).textContent, /Muse Code[^]*?Not installed/);
-- assert.match(p.sandbox.mcAgentsBlock(noCard, { online: true, roles: [] }).textContent, /Turn on Codes worker/, 'a machine that is not a Codes worker offers to become one');
-- assert.match(p.sandbox.mcAgentsBlock(noCard, { online: true, roles: ['codes.worker'] }).textContent, /has not reported its agents yet/);
++ assert.match(p.sandbox.mcAgentsBlock(noCard, { online: true, roles: [] }).textContent, /Run my coding tasks here/, 'a computer that does not run coding tasks offers to');
++ assert.match(p.sandbox.mcAgentsBlock(noCard, { online: true, roles: ['codes.worker'] }).textContent, /has not listed its coding apps yet/);
assert.match(p.sandbox.mcAgentsBlock(noCard, { online: true, roles: ['codes.worker'], fleet: [{ agent: 'muse', installed: true, signed_in: true }], fleet_at: ago(30) }).textContent, /Last reported 30 min ago/, 'an old reading says how old it is');
assert.match(cards[1].textContent, /Offline · seen 2 h ago/);
-- const boxes = cards[0].querySelectorAll('.mc-role input');
-- assert.deepStrictEqual(boxes.map((b) => [b.value, b.checked]), [['codes.worker', true], ['desks.host', false], ['ai.host', false]], 'a switch per role, the machine\'s own on');
++ // 2026-11-12: the card in plain words, what it does for you apart from what it does for other people.
++ assert.match(cards[0].textContent, /For you.*For others/, 'two separate tabs');
++ assert.match(cards[0].textContent, /Run my coding tasks.*Give my agents a browser here.*Use the AI models on this computer/);
++ assert.ok(!/Desk host|Codes worker|AI host/.test(cards[0].textContent), 'no catalogue jargon on the card');
++ const boxes = cards[0].querySelectorAll('.mc-role-input');
++ assert.deepStrictEqual(boxes.map((b) => [b.value, b.checked]), [['codes.worker', true], ['desks.host', false]], 'a switch per role, the machine\'s own on');
-- // A switch saves the whole set.
-- boxes[2].checked = true; boxes[2].fire('change');
++ // A role switch saves the whole set.
++ boxes[1].checked = true; boxes[1].fire('change');
await flush();
-- assert.deepStrictEqual(p.calls.find((c) => c[0] === 'PUT'), ['PUT', '/api/v1/machines/dev_B/roles', { roles: ['codes.worker', 'ai.host'] }]);
-- assert.match(p.ids['mc-status'].textContent, /picks this up at its next check-in/);
++ assert.deepStrictEqual(p.calls.find((c) => c[0] === 'PUT'), ['PUT', '/api/v1/machines/dev_B/roles', { roles: ['codes.worker', 'desks.host'] }]);
++ assert.match(p.ids['mc-status'].textContent, /picks this up within a minute/);
++ // "Use the AI models on this computer" is the role and the owner's yes, saved together.
++ const ai = cards[0].all((n) => n.tagName === 'INPUT' && /-ai$/.test(n.getAttribute('id') || ''))[0];
++ ai.checked = true; ai.fire('change');
++ await flush(); await flush();
++ const puts = p.calls.filter((c) => c[0] === 'PUT');
++ assert.ok(puts[puts.length - 1][2].roles.includes('ai.host'), 'the role');
++ assert.deepStrictEqual(p.calls.find((c) => /\/ai-models$/.test(c[1])), ['POST', '/api/v1/machines/dev_B/ai-models', { on: true }], 'and the owner\'s yes');
// Rename on Enter.
const rename = cards[0].all((n) => n.tagName === 'BUTTON' && /Rename/.test(n.textContent))[0];
@@@ -151,8 +163,12 @@ assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the a
} });
await p.sandbox.loadMachines();
const card = p.ids['mc-list'].children[0];
-- assert.match(card.textContent, /Let my agent use this computer/);
-- assert.match(card.textContent, /Let my watches check things from this computer/);
++ assert.match(card.textContent, /Let my agents use this computer/);
++ assert.match(card.textContent, /Let my watches check things from here/);
++ // For other people: lending browsers needs a locked box for each; this computer has none, so it cannot.
++ const lend = card.all((n) => n.tagName === 'INPUT' && /-lend$/.test(n.getAttribute('id') || ''))[0];
++ assert.ok(lend && lend.disabled === true, 'no sandbox, no lending');
++ assert.match(card.textContent, /cannot lend them.*--desk-host --shared/);
const switches = card.all((n) => n.tagName === 'INPUT' && /-(agent|watch)$/.test(n.getAttribute('id') || ''));
assert.deepStrictEqual(switches.map((b) => b.checked), [true, false], 'the switches start from Bot\'s own state');
switches[0].checked = false; switches[0].fire('change');
@@@ -189,6 +205,27 @@ assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the a
assert.ok(p.calls.some((c) => c[0] === 'POST' && c[1] === '/api/v1/machines/dev_C/desks/desk-a/live'), 'the Live channel takes the desk with its warning first');
assert.match(card.textContent, /openvibe\.live\/@ana/);
++ // ── For other people: a shared desk host lends its browsers on the owner's click ──
++ const lender = { id: 'dev_D', kind: 'server', online: true, name: 'ov-node-01', roles: ['desks.host'], running: [],
++ controls: { computer: true, agent_computer: false, watch_probe: false, shared_desks: false, ai_models: false, level: 'trusted', probe: false,
++ screen: { available: false, live: false }, desk: { available: true, sandboxed: true, max: 7 }, ai: { available: true, models: 2, offered: true } } };
++ p = page({ answers: (m, url, body) => {
++ if (url === '/api/v1/machines/roles') return { roles: ROLES };
++ if (m === 'GET' && url === '/api/v1/machines') return { machines: [lender] };
++ if (m === 'POST' && /\/shared-desks$/.test(url)) { lender.controls.shared_desks = body.on; return { machine: lender }; }
++ return {};
++ } });
++ await p.sandbox.loadMachines();
++ const lcard = p.ids['mc-list'].children[0];
++ assert.match(lcard.textContent, /Lend browsers to other people\u2019s agents.*Up to 7 browsers at once/);
++ assert.match(lcard.textContent, /Its AI models answer other people too/);
++ const lendBox = lcard.all((n) => n.tagName === 'INPUT' && /-lend$/.test(n.getAttribute('id') || ''))[0];
++ assert.ok(lendBox.disabled !== true, 'a sandboxed computer may lend');
++ lendBox.checked = true; lendBox.fire('change');
++ await flush();
++ assert.deepStrictEqual(p.calls.find((c) => /\/shared-desks$/.test(c[1])), ['POST', '/api/v1/machines/dev_D/shared-desks', { on: true }]);
++ assert.match(p.ids['mc-status'].textContent, /lends browsers to other people\u2019s agents/);
++
// ── Pairing: the code and the command, then the machine arriving ──
machines = [];
p = page({ answers: (m, url) => {