openvibers/openvibe.network Commits
The machine card in plain words: For you kept apart from For others (#894)
Changes
@@@ -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; }@@@ -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}`;@@@ -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; });}}@@@ -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.loconst 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.loconst 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@@@ -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' } });@@@ -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 aconst 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 aassert.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) => {
@@ -421,6 +421,15 @@ button.td-stat:hover span { color: var(--accent-light); }
@@ -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 { display: grid; gap: 1px; min-width: 0; }
.mc-role-t b { font-size: 13.5px; }
.mc-role-t b { font-size: 13.5px; }
.mc-role-t small { font-size: 12px; color: var(--text-muted); line-height: 1.35; }
.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-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 { 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; }
.mc-revoke.is-sure { background: #ef4444; color: #fff; border-color: #ef4444; }
@@ -245,15 +245,17 @@ async function loadMachineAgents() {
@@ -245,15 +245,17 @@ async function loadMachineAgents() {
box.hidden = false;
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;
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 = {};
const boxes = {};
for (const [key, label, note, icon] of rows) {
for (const [key, label, note, icon] of rows) {
const id = `mc-${m.id}-${key}`;
const id = `mc-${m.id}-${key}`;
@@ -27,6 +27,9 @@ function mcAgo(at) {
@@ -27,6 +27,9 @@ function mcAgo(at) {
}
}
const MC_KIND = { onboard: 'This computer', bridge: 'A bridge', server: 'A server' };
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' };
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) {
function mcSay(text, bad) {
const s = document.getElementById('mc-status');
const s = document.getElementById('mc-status');
@@ -46,32 +49,23 @@ function mcCard(m) {
@@ -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 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]);
const head = mcEl('div', 'mc-head', [dot, mcEl('div', 'mc-title', [name, state]), meta]);
card.append(head);
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) {
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)]))));
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 screen = mcScreenBlock(card, m);
const links = mcSharesBlock(m);
const links = mcSharesBlock(m);
const desks = mcDesksBlock(card, m);
const desks = mcDesksBlock(card, m);
const ready = (Array.isArray(m.fleet) ? m.fleet : []).filter((a) => a && a.installed && a.signed_in).length;
const ready = (Array.isArray(m.fleet) ? m.fleet : []).filter((a) => a && a.installed && a.signed_in).length;
card.append(mcTabs(m.id, [
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]],
['screen', 'Screen', [screen, links, desks]],
].filter(([, , blocks]) => blocks.some(Boolean))));
].filter(([, , blocks]) => blocks.some(Boolean))));
@@ -112,12 +106,12 @@ function mcAgentsBlock(card, m) {
@@ -112,12 +106,12 @@ function mcAgentsBlock(card, m) {
const fresh = fleet.length && Number.isFinite(age) && age <= 10 * 60000;
const fresh = fleet.length && Number.isFinite(age) && age <= 10 * 60000;
let lede;
let lede;
if (!m.online) lede = 'Offline: what it has is shown as last reported.';
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)}.`;
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])]);
const head = mcEl('div', 'mc-agents-head', [mcEl('p', 'mc-agents-lede', [lede])]);
if (!worker) {
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', () => {
on.addEventListener('click', () => {
const box = card.querySelector('input[value="codes.worker"]');
const box = card.querySelector('input[value="codes.worker"]');
if (box && !box.checked) { box.checked = true; box.dispatchEvent(new Event('change', { bubbles: true })); }
if (box && !box.checked) { box.checked = true; box.dispatchEvent(new Event('change', { bubbles: true })); }
@@ -202,30 +196,120 @@ function mcTabs(machineId, panes) {
@@ -202,30 +196,120 @@ function mcTabs(machineId, panes) {
it), so nothing turns on what an agent may do by itself. */
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. */
/** 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;
box.checked = checked === true;
+ if (o.disabled) box.disabled = true;
box.addEventListener('change', () => onChange(box));
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;
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) {
async function mcSaveSwitch(m, box, route, done) {
const before = !box.checked;
const before = !box.checked;
box.disabled = true;
box.disabled = true;
@@ -568,17 +652,18 @@ function mcAttachTakeOver(figure, video, m, use) {
@@ -568,17 +652,18 @@ function mcAttachTakeOver(figure, video, m, use) {
async function mcSaveRoles(card, m) {
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 {
try {
const out = await apiFetch(`/api/v1/machines/${encodeURIComponent(m.id)}/roles`, { method: 'PUT', body: JSON.stringify({ roles }) });
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;
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) {
} 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 {
} finally {
- card.querySelectorAll('.mc-role-set input').forEach((b) => { b.disabled = false; });
+ card.querySelectorAll('.mc-role-input').forEach((b) => { b.disabled = false; });
}
}
}
}
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
* PATCH /api/v1/machines/:id network.machines.manage → { machine } (a new name)
* 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)
* 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/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)
* 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, … }
* GET /api/v1/machines/:id/screen network.machines.read → { live, signaling_url, … }
* DELETE /api/v1/machines/:id/screen network.machines.manage → { stopped }
* DELETE /api/v1/machines/:id/screen network.machines.manage → { stopped }
@@ -127,6 +127,9 @@ function machineOf(m) {
@@ -127,6 +127,9 @@ function machineOf(m) {
out.controls = {
out.controls = {
agent_computer: m.agent_computer === true,
agent_computer: m.agent_computer === true,
watch_probe: m.watch_probe === 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: !!computer,
...(computer ? {
...(computer ? {
level: typeof computer.level === 'string' ? computer.level : null,
level: typeof computer.level === 'string' ? computer.level : null,
@@ -144,6 +147,12 @@ function machineOf(m) {
@@ -144,6 +147,12 @@ function machineOf(m) {
...(Number.isInteger(computer.desk.max_desks) ? { max: computer.desk.max_desks } : {}),
...(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) } : {}),
...(Array.isArray(computer.desk.kinds) ? { kinds: computer.desk.kinds.filter((k) => typeof k === 'string').slice(0, 5) } : {}),
} : { available: false },
} : { 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;
return out;
@@ -204,6 +213,8 @@ function createBotClient({ baseUrl, privateKey, issuer, fetchImpl = globalThis.f
@@ -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.
// 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),
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),
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),
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),
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),
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
@@ -382,7 +393,7 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.lo
const ready = m.online && m.roles.includes('codes.worker');
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,
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' },
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 = [
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.' },
{ 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
@@ -506,6 +517,22 @@ function routers({ bot, requireAuth, publicKey, issuer, db = (req) => req.app.lo
const out = await bot.watchProbe(subject, machineId(req), on);
const out = await bot.watchProbe(subject, machineId(req), on);
res.json({ machine: machineOf(out && out.machine) });
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 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
// the owner's own mouse and keyboard while it is shown. `signaling_url` is a viewer credential, so it never
@@ -50,7 +50,7 @@ const machineOf = (id) => {
@@ -50,7 +50,7 @@ const machineOf = (id) => {
const s = stateOf(id);
const s = stateOf(id);
return {
return {
id, name: 'garage-pi', kind: 'onboard', online: true, roles: [], running: [], node_id: null,
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: {
computer: {
level: 'ask', terminal: { level: 'ask' }, files: { level: 'ask' }, screen: { level: 'ask', stream: true },
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 },
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) => {
@@ -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.
// The owner's own controls (the card): each as Bot checks it, the machine's own or 404.
let m;
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' });
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' });
if (typeof body.on !== 'boolean') return answer(422, { code: 'bot.invalid_input' });
return answer(200, { machine: setFlag(decodeURIComponent(m[1]), key, body.on) });
return answer(200, { machine: setFlag(decodeURIComponent(m[1]), key, body.on) });
}
}
@@ -269,6 +269,14 @@ try {
@@ -269,6 +269,14 @@ try {
assert.strictEqual(watchOn.body.machine.controls.watch_probe, true);
assert.strictEqual(watchOn.body.machine.controls.watch_probe, true);
sent = calls.at(-1);
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 }]);
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.
// A body that is not true/false never reaches Bot.
const beforeBadSwitch = calls.length;
const beforeBadSwitch = calls.length;
const badSwitch = await call('POST', `/api/v1/machines/${anaMachine}/agent-computer`, { user: ana, body: { on: 'yes' } });
const badSwitch = await call('POST', `/api/v1/machines/${anaMachine}/agent-computer`, { user: ana, body: { on: 'yes' } });
@@ -33,6 +33,7 @@ class Node {
@@ -33,6 +33,7 @@ class Node {
querySelectorAll(sel) {
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-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' && 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 [];
return [];
}
}
}
}
@@ -92,18 +93,29 @@ assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the a
@@ -92,18 +93,29 @@ assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the a
const noCard = { querySelector: () => null };
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: 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: ['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(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/);
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();
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.
// Rename on Enter.
const rename = cards[0].all((n) => n.tagName === 'BUTTON' && /Rename/.test(n.textContent))[0];
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
@@ -151,8 +163,12 @@ assert.ok(NOT_FOUND.ACCOUNT_HUB_PATHS.includes('/machines'), '/machines is the a
} });
} });
await p.sandbox.loadMachines();
await p.sandbox.loadMachines();
const card = p.ids['mc-list'].children[0];
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') || ''));
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');
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');
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
@@ -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.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/);
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 ──
// ── Pairing: the code and the command, then the machine arriving ──
machines = [];
machines = [];
p = page({ answers: (m, url) => {
p = page({ answers: (m, url) => {