openvibers/openvibe.network Commits
Link hub: access between machines and your agents, on openvibe.link/machines (#855)
Changes
@@@ -764,3 +764,54 @@ body.my-machines-open { padding-bottom: max(112px, calc(100vh - 90px)); }body.link-hub.my-machines-open { padding-bottom: 0; }.link-hub .mc-title { margin: 0 0 6px; font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2rem); line-height: 1.15; }.mc-empty > i { flex: none; }++++/* Access between machines and your agents (my/access.js), under the machine cards on openvibe.link/machines. A request++ asked of you is a card with what is asked, in words with an icon each, and the answer under it; the rest are rows. */++.ma-box { display: grid; gap: 12px; margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); min-width: 0; }++.ma-box[hidden] { display: none; }++.ma-h { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 1.05rem; }++.ma-note { margin: 0; font-size: 13px; line-height: 1.5; }++.ma-sec { display: grid; gap: 8px; }++.ma-sec h4, .ma-sub { margin: 4px 0 0; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); }++.ma-ask { display: grid; gap: 8px; padding: 14px 16px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); background: color-mix(in srgb, var(--accent) 7%, var(--bg-secondary)); }++.ma-ask-title { margin: 0; font-size: 15px; line-height: 1.4; }++.ma-ask-when { margin: 0; font-size: 13px; color: var(--text-muted); }++.ma-scopes { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; }++.ma-scopes li { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 999px; background: var(--bg-card); border: 1px solid var(--border); font-size: 13px; font-weight: 600; }++.ma-scopes i { color: var(--accent-light); }++.ma-reason { margin: 0; padding: 6px 12px; border-left: 3px solid var(--border); color: var(--text-secondary); font-size: 13px; font-style: italic; }++.ma-ask-acts { display: flex; flex-wrap: wrap; gap: 8px; }++.ma-ask-acts .btn { min-height: 44px; min-width: 112px; justify-content: center; }++.ma-less summary { min-height: 44px; display: inline-flex; align-items: center; cursor: pointer; color: var(--text-secondary); font-size: 13px; }++.ma-less-body { display: grid; gap: 6px; padding: 8px 0 2px; }++.ma-less-scope, .ma-less-time { display: flex; align-items: center; gap: 8px; min-height: 40px; font-size: 14px; }++.ma-less-time select { min-height: 40px; }++.ma-less-body .btn { justify-self: start; min-height: 44px; }++.ma-say:empty { display: none; }++.ma-say { margin: 0; font-size: 13px; color: var(--danger-text, #ef4444); }++.ma-grants, .ma-list, .ma-agents, .ma-threads { list-style: none; display: grid; gap: 8px; margin: 0; padding: 0; }++.ma-grant { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-secondary); }++.ma-grant > i { color: var(--success); }++.ma-grant-t b, .ma-agent-t b { display: block; font-size: 14px; overflow-wrap: anywhere; }++.ma-grant-t small, .ma-agent-t small { display: block; color: var(--text-muted); font-size: 12.5px; }++.ma-grant .btn { min-height: 40px; }++.ma-list li { font-size: 13px; color: var(--text-secondary); line-height: 1.45; }++.ma-fold summary { min-height: 40px; display: flex; align-items: center; cursor: pointer; font-size: 13px; font-weight: 600; color: var(--text-secondary); }++.ma-fold .ma-list { padding: 4px 0 6px; }++.ma-empty code { font-size: 12px; }++.ma-agent { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-secondary); }++.ma-agent-sw { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; font-size: 13px; color: var(--text-secondary); cursor: pointer; }++.ma-thread { display: grid; gap: 8px; padding: 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-secondary); }++.ma-thread-h { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 6px; margin: 0; font-size: 14px; }++.ma-state { margin-left: auto; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 700; }++.ma-state.is-open { color: var(--success); background: color-mix(in srgb, var(--success) 14%, transparent); }++.ma-state.is-closed { color: var(--text-muted); background: var(--bg-card); }++.ma-tasks { margin: 0; padding-left: 20px; display: grid; gap: 4px; font-size: 13px; }++.ma-task-msg { display: block; overflow-wrap: anywhere; }++.ma-tasks small { color: var(--text-muted); overflow-wrap: anywhere; }++.ma-tasks li.is-failed small { color: var(--danger-text, #ef4444); }++.ma-thread .btn { justify-self: start; min-height: 40px; }++@media (max-width: 560px) {++ .ma-grant, .ma-agent { grid-template-columns: auto minmax(0, 1fr); }++ .ma-grant .btn, .ma-agent-sw { grid-column: 2; justify-self: start; }++}nnew file mode 100644@@@ -0,0 +1,277 @@++/* ── Machine access and agents on openvibe.link/machines (Link #157, Network #849/#853, Bot #407) ──────────────────++ One machine asks another for access; its owner answers here (or in the Link tray) and can take it back at any time.++ Agents on any of the person's machines (and on Actor or Codes) talk to each other in threads the owner can stop.++++ The routes (all the signed-in person's own rows, named by server/link/names.js):++ GET /api/v1/me/nodes/access-requests asked of you (side "in"), asked by you ("out"), between yours ("own")++ POST /api/v1/me/nodes/access-requests/:id/answer { approve, scopes?, duration_seconds? }: only narrower or shorter++ GET /api/v1/me/nodes/access-grants POST …/:id/revoke++ GET /api/v1/me/nodes/access-audit each use of a grant++ GET/PUT /api/v1/me/nodes/:principal/access-settings the three switches on a machine's card++ GET /api/v1/me/agents PUT …/:id/allow-others GET /api/v1/me/agent-threads POST …/:id/stop++ Uses mcEl, mcIcon, mcAgo and mcSay from machines.js. Every name is a text node. */++++const MA_SCOPE = {++ agent: ['fa-robot', 'Talk to your agents'],++ send: ['fa-paper-plane', 'Send you files'],++ screen: ['fa-display', 'Watch the screen'],++ desk: ['fa-window-maximize', 'Use a desk'],++ computer: ['fa-keyboard', 'Control the computer'],++ worker: ['fa-microchip', 'Run jobs on it'],++};++const MA_TIMES = [[600, '10 minutes'], [3600, '1 hour'], [4 * 3600, '4 hours'], [86400, '1 day'], [7 * 86400, '1 week'], [30 * 86400, '30 days']];++++const maPerson = (p) => (p && (p.display_name || p.username)) || 'Someone';++/** "Jo's laptop" for someone else's machine, "your laptop" for your own. */++function maMachine(row, end) {++ const m = row[`${end}_machine`] || {};++ const mine = row.side === 'own' || (row.side === 'in' ? end === 'to' : end === 'from');++ return mine ? `your ${m.name || 'machine'}` : `${maPerson(row[`${end}_person`])}'s ${m.name || 'machine'}`;++}++const maCap = (s) => s.charAt(0).toUpperCase() + s.slice(1);++function maFor(seconds) {++ if (seconds == null) return 'until you take it back';++ const hit = MA_TIMES.find(([n]) => n === Number(seconds));++ if (hit) return `for ${hit[1]}`;++ const m = Math.round(Number(seconds) / 60);++ return m < 120 ? `for ${m} minutes` : m < 2880 ? `for ${Math.round(m / 60)} hours` : `for ${Math.round(m / 1440)} days`;++}++function maLeft(expiresAt) {++ if (!expiresAt) return 'until you take it back';++ const ms = Date.parse(expiresAt) - Date.now();++ if (!(ms > 0)) return 'ended';++ const min = Math.round(ms / 60000);++ return min < 60 ? `${min} min left` : min < 2880 ? `${Math.floor(min / 60)} h ${min % 60} min left` : `${Math.round(min / 1440)} days left`;++}++const maLive = (g) => !g.revoked_at && (!g.expires_at || Date.parse(g.expires_at) > Date.now());++function maScopes(scopes) {++ return mcEl('ul', 'ma-scopes', (scopes || []).map((s) => mcEl('li', '', [mcIcon((MA_SCOPE[s] || [])[0] || 'fa-key'), ' ', (MA_SCOPE[s] || [])[1] || s])));++}++const maScopeWords = (scopes) => (scopes || []).map((s) => (MA_SCOPE[s] || [])[1] || s).join(', ');++++async function maPost(path, body) {++ return apiFetch(path, { method: 'POST', body: JSON.stringify(body || {}) });++}++++/** A request asked of you: who, from which machine, to which of yours, what, how long, why; Allow, Allow less, Deny. */++function maAskCard(r) {++ const card = mcEl('article', 'ma-ask');++ const title = mcEl('p', 'ma-ask-title', [mcEl('b', '', [maCap(maMachine(r, 'from'))]), ` asks for access to ${maMachine(r, 'to')}`]);++ const when = mcEl('p', 'ma-ask-when', [`${maCap(maFor(r.duration_seconds))} · asked ${mcAgo(r.asked_at)}`]);++ card.append(title, maScopes(r.scopes), when);++ if (r.reason) card.append(mcEl('blockquote', 'ma-reason', [r.reason]));++ const say = mcEl('p', 'ma-say', [], { role: 'status' });++ const answer = async (body, btns) => {++ btns.forEach((b) => { b.disabled = true; });++ try {++ await maPost(`/api/v1/me/nodes/access-requests/${encodeURIComponent(r.id)}/answer`, body);++ mcSay(body.approve ? `Allowed: ${maMachine(r, 'from')} may ${maScopeWords(body.scopes || r.scopes).toLowerCase()}.` : 'Denied. They can ask again tomorrow.');++ loadMachineAccess();++ } catch (e) {++ say.textContent = e.status === 410 ? 'This request waited too long and has ended.' : (e.message || 'The answer could not be saved.');++ btns.forEach((b) => { b.disabled = false; });++ }++ };++ const allow = mcEl('button', 'btn btn-primary btn-small', [mcIcon('fa-check'), ' Allow'], { type: 'button' });++ const deny = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-xmark'), ' Deny'], { type: 'button' });++ const less = mcEl('details', 'ma-less');++ less.append(mcEl('summary', '', ['Allow less…']));++ // Narrower only: the asked scopes, each on by default, and times no longer than asked.++ const boxes = (r.scopes || []).map((s) => {++ const box = mcEl('input', '', [], { type: 'checkbox', value: s });++ box.checked = true;++ return mcEl('label', 'ma-less-scope', [box, ' ', (MA_SCOPE[s] || [])[1] || s]);++ });++ const times = MA_TIMES.filter(([n]) => r.duration_seconds == null || n <= Number(r.duration_seconds));++ const sel = mcEl('select', '', [++ ...(r.duration_seconds == null ? [mcEl('option', '', ['Until I take it back'], { value: '' })] : []),++ ...times.map(([n, label]) => mcEl('option', '', [label], { value: String(n) })),++ ], { 'aria-label': 'For how long' });++ if (r.duration_seconds != null) sel.value = String(times.length ? times[times.length - 1][0] : r.duration_seconds);++ const allowLess = mcEl('button', 'btn btn-primary btn-small', ['Allow this much'], { type: 'button' });++ less.append(mcEl('div', 'ma-less-body', [...boxes, mcEl('label', 'ma-less-time', ['For ', sel]), allowLess]));++ const btns = [allow, deny, allowLess];++ allow.addEventListener('click', () => answer({ approve: true }, btns));++ deny.addEventListener('click', () => answer({ approve: false }, btns));++ allowLess.addEventListener('click', () => {++ const scopes = boxes.map((l) => l.querySelector('input')).filter((b) => b.checked).map((b) => b.value);++ if (!scopes.length) { say.textContent = 'Choose at least one thing to allow, or press Deny.'; return; }++ answer({ approve: true, scopes, ...(sel.value ? { duration_seconds: Number(sel.value) } : {}) }, btns);++ });++ card.append(mcEl('div', 'ma-ask-acts', [allow, deny]), less, say);++ return card;++}++++/** An allowed grant: from → to, what, how long is left; Take back. */++function maGrantRow(g) {++ const row = mcEl('li', 'ma-grant');++ const text = mcEl('div', 'ma-grant-t', [++ mcEl('b', '', [`${maCap(maMachine(g, 'from'))} → ${maMachine(g, 'to')}`]),++ mcEl('small', '', [`${maScopeWords(g.scopes)} · ${maLeft(g.expires_at)}`]),++ ]);++ const back = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-hand'), ' Take back'], { type: 'button' });++ back.addEventListener('click', async () => {++ back.disabled = true;++ try {++ await maPost(`/api/v1/me/nodes/access-grants/${encodeURIComponent(g.id)}/revoke`);++ mcSay('Taken back. It ends now, on both machines.');++ loadMachineAccess();++ } catch (e) { back.disabled = false; mcSay(e.message || 'It could not be taken back.', true); }++ });++ row.append(mcIcon('fa-key'), text, back);++ return row;++}++++function maSection(title, note, kids) {++ return mcEl('section', 'ma-sec', [mcEl('h4', '', [title]), ...(note ? [mcEl('p', 'muted ma-note', [note])] : []), ...kids]);++}++++async function loadMachineAccess() {++ const box = document.getElementById('mc-access');++ if (!box) return;++ const [req, gr, au] = await Promise.all([++ apiFetch('/api/v1/me/nodes/access-requests').catch(() => null),++ apiFetch('/api/v1/me/nodes/access-grants').catch(() => null),++ apiFetch('/api/v1/me/nodes/access-audit').catch(() => null),++ ]);++ if (!req && !gr) { box.replaceChildren(); box.hidden = true; return; }++ const requests = (req && req.requests) || [];++ const grants = (gr && gr.grants) || [];++ const audit = ((au && au.audit) || []).slice(0, 20);++ const asking = requests.filter((r) => r.status === 'pending' && (r.side === 'in' || r.side === 'own'));++ const waiting = requests.filter((r) => r.status === 'pending' && r.side === 'out');++ const live = grants.filter(maLive);++ const ended = [...requests.filter((r) => r.status === 'denied' || r.status === 'expired'), ...grants.filter((g) => !maLive(g))]++ .sort((a, b) => Date.parse(b.answered_at || b.revoked_at || b.expires_at || 0) - Date.parse(a.answered_at || a.revoked_at || a.expires_at || 0)).slice(0, 10);++ const parts = [mcEl('h3', 'ma-h', [mcEl('span', 'icon', [mcIcon('fa-key')]), ' Access between machines'])];++ parts.push(mcEl('p', 'muted ma-note', ['A machine can ask another for access: to send files, talk to its agents, watch its screen. Each owner answers, and can take it back at any time. People can only ask if they follow you and the machine allows it (on its card above).']));++ if (asking.length) parts.push(maSection(`Asking you (${asking.length})`, '', asking.map(maAskCard)));++ if (live.length) parts.push(maSection('Allowed now', '', [mcEl('ul', 'ma-grants', live.map(maGrantRow))]));++ if (waiting.length) {++ parts.push(maSection('Waiting for an answer', '', [mcEl('ul', 'ma-list', waiting.map((r) => mcEl('li', '', [++ mcEl('b', '', [maCap(maMachine(r, 'to'))]), ` · ${maScopeWords(r.scopes)} · ${maFor(r.duration_seconds)} · asked ${mcAgo(r.asked_at)}`])))]));++ }++ if (!asking.length && !live.length && !waiting.length) {++ parts.push(mcEl('div', 'mc-empty ma-empty', [mcIcon('fa-key'), mcEl('p', '', [mcEl('b', '', ['Nothing asked or allowed yet.']), ' From a machine, run ', mcEl('code', '', ['openvibe-link access request @person/machine send']), ', or let an agent ask. The answer shows here and in the Link tray.'])]));++ }++ if (audit.length) {++ const used = mcEl('details', 'ma-fold');++ used.append(mcEl('summary', '', [`What was used (${audit.length})`]),++ mcEl('ul', 'ma-list', audit.map((a) => mcEl('li', '', [++ mcEl('time', '', [mcAgo(a.at)], { datetime: a.at }), ' · ', maCap(maMachine(a, 'from')), ` used “${(MA_SCOPE[a.scope] || [])[1] || a.scope}” on ${maMachine(a, 'to')}`, a.action ? ` (${a.action})` : '']))));++ parts.push(used);++ }++ if (ended.length) {++ const old = mcEl('details', 'ma-fold');++ old.append(mcEl('summary', '', [`Ended (${ended.length})`]),++ mcEl('ul', 'ma-list', ended.map((x) => mcEl('li', '', [maCap(maMachine(x, 'from')), ` → ${maMachine(x, 'to')} · ${maScopeWords(x.scopes)} · `,++ x.status === 'denied' ? 'denied' : x.status === 'expired' ? 'no answer in time' : x.revoked_at ? 'taken back' : 'time ran out']))));++ parts.push(old);++ }++ box.replaceChildren(...parts);++ box.hidden = false;++ loadMachineAgents();++}++++/* ── Agents ── */++const MA_THREAD_STATE = (t) => (t.stopped_at ? 'Stopped' : Date.parse(t.closes_at) <= Date.now() ? 'Ended (a day quiet)' : 'Open');++const maTaskText = (m) => {++ const parts = (m && Array.isArray(m.parts)) ? m.parts : [];++ return parts.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join(' ').trim().slice(0, 240);++};++function maAgentLabel(a) {++ if (!a) return 'An agent';++ const where = a.machine ? (a.mine ? `your ${a.machine.name}` : `${maPerson(a.person)}'s ${a.machine.name}`) : a.service ? `OpenVibe.${maCap(a.service)}` : (a.mine ? 'yours' : maPerson(a.person));++ return `${a.name} on ${where}`;++}++++async function loadMachineAgents() {++ const box = document.getElementById('mc-agents');++ if (!box) return;++ const [ag, th] = await Promise.all([++ apiFetch('/api/v1/me/agents').catch(() => null),++ apiFetch('/api/v1/me/agent-threads').catch(() => null),++ ]);++ if (!ag && !th) { box.replaceChildren(); box.hidden = true; return; }++ const agents = (ag && ag.agents) || [];++ const threads = (th && th.threads) || [];++ const parts = [mcEl('h3', 'ma-h', [mcEl('span', 'icon', [mcIcon('fa-robot')]), ' Your agents'])];++ if (!agents.length) {++ parts.push(mcEl('div', 'mc-empty ma-empty', [mcIcon('fa-robot'), mcEl('p', '', [mcEl('b', '', ['No agents yet.']), ' Run the Link on a computer with an AI coding app (Codex, Claude Code, Muse …): each one shows up here and can talk to your other agents.'])]));++ } else {++ parts.push(mcEl('ul', 'ma-agents', agents.map((a) => {++ const id = `ma-agent-${String(a.id).replace(/\W/g, '-')}`;++ const box2 = mcEl('input', '', [], { type: 'checkbox', id });++ box2.checked = !!a.allow_others;++ box2.addEventListener('change', async () => {++ box2.disabled = true;++ try {++ await apiFetch(`/api/v1/me/agents/${encodeURIComponent(a.id)}/allow-others`, { method: 'PUT', body: JSON.stringify({ allow_others: box2.checked }) });++ mcSay(box2.checked ? `${a.name}: other people's agents may ask it now (each one still needs a machine's permission).` : `${a.name} answers only your own agents.`);++ } catch (e) { box2.checked = !box2.checked; mcSay(e.message || 'That could not be saved.', true); }++ finally { box2.disabled = false; }++ });++ const where = a.machine ? `on ${a.machine.name}` : a.service ? `on OpenVibe.${maCap(a.service)}` : '';++ return mcEl('li', 'ma-agent', [++ mcEl('span', `mc-dot ${a.status === 'online' ? 'is-on' : ''}`, [], { 'aria-hidden': 'true' }),++ mcEl('div', 'ma-agent-t', [mcEl('b', '', [a.name]), mcEl('small', '', [[/^(link-local|actor|codes)$/.test(String(a.kind)) ? '' : a.kind, where, a.status === 'online' ? 'online' : 'offline'].filter(Boolean).join(' · ')])]),++ mcEl('label', 'ma-agent-sw', [box2, mcEl('span', '', ["Others' agents may ask it"])], { for: id }),++ ]);++ })));++ }++ if (threads.length) {++ parts.push(mcEl('h4', 'ma-sub', ['Conversations between agents']));++ parts.push(mcEl('ul', 'ma-threads', threads.slice(0, 20).map((t) => {++ const state = MA_THREAD_STATE(t);++ const head = mcEl('p', 'ma-thread-h', [mcEl('b', '', [maAgentLabel(t.from_agent)]), ' ↔ ', mcEl('b', '', [maAgentLabel(t.to_agent)]), mcEl('span', `ma-state is-${state === 'Open' ? 'open' : 'closed'}`, [state])]);++ const tasks = mcEl('ol', 'ma-tasks', (t.tasks || []).slice(-6).map((k) => mcEl('li', `is-${k.status}`, [++ mcEl('span', 'ma-task-msg', [maTaskText(k.message) || 'A task']),++ mcEl('small', '', [k.status === 'completed' && maTaskText(k.result) ? `→ ${maTaskText(k.result)}` : k.status]),++ ])));++ const li = mcEl('li', 'ma-thread', [head, tasks]);++ if (state === 'Open') {++ const stop = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-stop'), ' Stop this conversation'], { type: 'button' });++ stop.addEventListener('click', async () => {++ stop.disabled = true;++ try { await maPost(`/api/v1/me/agent-threads/${encodeURIComponent(t.id)}/stop`); mcSay('Stopped. Its open tasks are cancelled.'); loadMachineAgents(); }++ catch (e) { stop.disabled = false; mcSay(e.message || 'It could not be stopped.', true); }++ });++ li.append(stop);++ }++ return li;++ })));++ }++ box.replaceChildren(...parts);++ box.hidden = false;++}++++/** The three access switches on a paired machine's card, read when the card is drawn. */++function mcAccessSwitches(m) {++ 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 boxes = {};++ for (const [key, label, note, icon] of rows) {++ const id = `mc-${m.id}-${key}`;++ const box = mcEl('input', '', [], { type: 'checkbox', id, disabled: '' });++ boxes[key] = box;++ box.addEventListener('change', async () => {++ box.disabled = true;++ try {++ await apiFetch(`/api/v1/me/nodes/${encodeURIComponent(m.principal_id)}/access-settings`, { method: 'PUT', body: JSON.stringify({ [key]: box.checked }) });++ mcSay(`Saved. ${m.name || 'The machine'}: ${label.toLowerCase()} ${box.checked ? 'on' : 'off'}.`);++ } catch (e) { box.checked = !box.checked; mcSay(e.message || 'The switch could not be saved.', true); }++ finally { box.disabled = false; }++ });++ field.append(mcEl('label', 'mc-role', [box, mcEl('span', 'mc-role-ic', [mcIcon(icon)]), mcEl('span', 'mc-role-t', [mcEl('b', '', [label]), mcEl('small', '', [note])])], { for: id }));++ }++ apiFetch(`/api/v1/me/nodes/${encodeURIComponent(m.principal_id)}/access-settings`).then((out) => {++ const s = (out && out.settings) || {};++ for (const [key, box] of Object.entries(boxes)) { box.checked = s[key] === true; box.disabled = false; }++ }).catch(() => { field.append(mcEl('p', 'muted', ['These switches could not be read just now.'])); });++ return field;++}@@@ -68,6 +68,9 @@ function mcCard(m) {const own = mcOwnerSwitches(m);if (own) card.append(own);++ // Who else may ask this machine for access, and its agents (my/access.js).++ const access = typeof mcAccessSwitches === 'function' ? mcAccessSwitches(m) : null;++ if (access) card.append(access);const screen = mcScreenBlock(card, m);if (screen) card.append(screen);const links = mcSharesBlock(m);@@@ -600,6 +603,7 @@ async function loadMachines() {mcNames = new Map(machines.map((m) => [m.id, m.name || MC_KIND[m.kind] || 'A machine']));for (const m of machines) m.pools = (pools.pools || []).filter((p) => p.machines.some((x) => x.id === m.id));list.replaceChildren(...machines.map(mcCard), ...mcPoolPickers(pools.candidates || []));++ if (typeof loadMachineAccess === 'function') loadMachineAccess().catch(() => {});} catch (e) {if (count) count.textContent = '';list.replaceChildren(mcEl('p', 'muted', [e.status === 401 ? 'Sign in to see your machines.' : 'Your machines could not be read just now.']));@@@ -507,6 +507,10 @@<div class="mc-pair" id="mc-pair" hidden></div><p class="mc-status" id="mc-status" role="status"></p><div class="mc-list" id="mc-list"><p class="muted">Loading your machines…</p></div>++ <!-- Access between machines and your agents (my/access.js): asked of you, allowed now, what was used,++ and the conversations between agents. Hidden until it can be read. -->++ <div class="ma-box" id="mc-access" hidden></div>++ <div class="ma-box" id="mc-agents" hidden></div></div></div>@@@ -647,6 +651,7 @@<script src="/js/my/tools.js" defer></script><script src="/js/my/today.js" defer></script><script src="/js/my/machines.js" defer></script>++<script src="/js/my/access.js" defer></script><script src="/js/my/themes.js" defer></script><script src="/js/my/profile.js" defer></script><script src="/js/my/emails.js" defer></script>@@@ -8,6 +8,7 @@ const express = require('express');const { ids, http, validate } = require('openvibe-contracts');const { ensureUserSubject } = require('../identity/subjects');const eventRelay = require('../developer/event-relay');++const names = require('./names');const SCOPES = new Set(['agent', 'send', 'screen', 'desk', 'computer', 'worker']);const PRINCIPAL = /^lnk_[0-9A-HJKMNP-TV-Z]{26}$/;@@@ -324,7 +325,8 @@ function routers({ requireAuth, nodeGuard, relayGuard }) {const who = await owner(db, req);await expirePending(db);const rows = await db.prepare('SELECT * FROM link_access_requests WHERE from_owner = ? OR to_owner = ? ORDER BY asked_at DESC LIMIT 100').all(who, who);-- res.json({ requests: rows.map(requestView) });++ // The owner's page names each side (openvibe.link/machines: "Jo's laptop asks for access to your desktop").++ res.json({ requests: await names.nameAccessRows(db, rows.map(requestView), who) });}));user.post('/access-requests', express.json({ limit: '8kb' }), wrap(async (req, res, db) => {const who = await owner(db, req), from = await machine(db, req.body?.from_principal);@@@ -338,13 +340,16 @@ function routers({ requireAuth, nodeGuard, relayGuard }) {user.get('/access-grants', wrap(async (req, res, db) => {const who = await owner(db, req);const rows = await db.prepare('SELECT * FROM link_access_grants WHERE from_owner = ? OR to_owner = ? ORDER BY created_at DESC LIMIT 100').all(who, who);-- res.json({ grants: rows.map(grantView) });++ res.json({ grants: await names.nameAccessRows(db, rows.map(grantView), who) });}));user.post('/access-grants/:id/revoke', wrap(async (req, res, db) => res.json({ grant: await revoke(db, req.params.id, await owner(db, req)) })));user.get('/access-audit', wrap(async (req, res, db) => {const who = await owner(db, req);-- const rows = await db.prepare('SELECT id, grant_id, from_owner, to_owner, scope, action, at FROM link_access_audit WHERE from_owner = ? OR to_owner = ? ORDER BY at DESC LIMIT 200').all(who, who);-- res.json({ audit: rows });++ // Each use names the two machines of its grant, so the page can say "Jo's laptop sent a file to your desktop".++ const rows = await db.prepare(`SELECT a.id, a.grant_id, a.from_owner, a.to_owner, a.scope, a.action, a.at, g.from_principal, g.to_principal++ FROM link_access_audit a JOIN link_access_grants g ON g.id = a.grant_id++ WHERE a.from_owner = ? OR a.to_owner = ? ORDER BY a.at DESC LIMIT 200`).all(who, who);++ res.json({ audit: await names.nameAccessRows(db, rows, who) });}));user.get('/:id/access-settings', wrap(async (req, res, db) => {const who = await owner(db, req), p = await machine(db, req.params.id);@@@ -310,7 +310,7 @@ function routers({ requireAuth, registerGuard, resultGuard, bot, config, privateme.get('/agents', wrap(async (req, res, db) => {const who = await ensureUserSubject(db, req.user);const rows = await db.prepare('SELECT * FROM agent_directory WHERE owner_subject = ? ORDER BY updated_at DESC, id LIMIT 200').all(who);-- res.json({ agents: rows.map(directoryView) });++ res.json({ agents: await names.nameAgents(db, rows.map(directoryView)) });}));me.put('/agents/:id/allow-others', express.json({ limit: '2kb' }), wrap(async (req, res, db) => {if (typeof req.body?.allow_others !== 'boolean') fail(422, 'agents.allow_others', 'allow_others must be boolean');@@@ -324,11 +324,13 @@ function routers({ requireAuth, registerGuard, resultGuard, bot, config, privateconst who = await ensureUserSubject(db, req.user);await expireThreads(db);const threads = await db.prepare('SELECT * FROM agent_threads WHERE from_owner = ? OR to_owner = ? ORDER BY created_at DESC LIMIT 100').all(who, who);-- res.json({ threads: await Promise.all(threads.map(async (thread) => ({++ const out = await Promise.all(threads.map(async (thread) => ({id: thread.id, from: thread.from_agent, to: thread.to_agent, created_at: thread.created_at,closes_at: thread.closes_at, stopped_at: thread.stopped_at || null,tasks: (await db.prepare('SELECT * FROM agent_tasks WHERE thread_id = ? ORDER BY created_at LIMIT 100').all(thread.id)).map(taskView),-- }))) });++ })));++ // Each side by name, machine and person, for the owner's Agents section on openvibe.link/machines.++ res.json({ threads: await names.nameThreads(db, out, who) });}));me.post('/agent-threads/:id/stop', wrap(async (req, res, db) =>res.json({ thread: await stopThread(db, req.params.id, await ensureUserSubject(db, req.user)) })));nnew file mode 100644@@@ -0,0 +1,77 @@++'use strict';++++/**++ * Names for the owner's machine-access and agent pages (openvibe.link/machines): a request, a grant or an agent++ * thread names its machines, people and agents by id, and the page must say "Jo's laptop asks for access", never++ * "lnk_01J…". One batched read per kind of name, only for ids the person's own rows already carry: nothing here lets++ * a page look up a machine or a person it is not party to.++ */++++const uniq = (xs) => [...new Set(xs.filter(Boolean).map(String))];++const marks = (n) => Array.from({ length: n }, () => '?').join(', ');++++/** Link principal id → the machine's name. */++async function machineNames(db, ids) {++ const list = uniq(ids);++ if (!list.length) return new Map();++ const rows = await db.prepare(`SELECT id, name FROM platform_node_principals WHERE id IN (${marks(list.length)})`).all(...list);++ return new Map(rows.map((r) => [r.id, r.name || 'A machine']));++}++++/** Subject → { username, display_name }: the public name a profile shows. */++async function people(db, subjects) {++ const list = uniq(subjects);++ if (!list.length) return new Map();++ const rows = await db.prepare(`SELECT subject_id, username, display_name FROM users WHERE subject_id IN (${marks(list.length)})`).all(...list);++ return new Map(rows.map((r) => [r.subject_id, { username: r.username, display_name: r.display_name || r.username }]));++}++++/** Agent id → { name, kind, machine_id, owner }. */++async function agents(db, ids) {++ const list = uniq(ids);++ if (!list.length) return new Map();++ const rows = await db.prepare(`SELECT id, name, kind, machine_id, service, owner_subject FROM agent_directory WHERE id IN (${marks(list.length)})`).all(...list);++ return new Map(rows.map((r) => [r.id, { name: r.name, kind: r.kind, machine_id: r.machine_id || null, service: r.service || null, owner: r.owner_subject }]));++}++++const personOf = (map, subject) => (subject && map.get(subject)) || null;++++/**++ * Requests or grants (from_principal, to_principal, from_owner, to_owner) with their machines' and people's names,++ * and the side the viewer is on: "in" (asked of the viewer), "out" (the viewer asked) or "own" (between the viewer's++ * own machines).++ */++async function nameAccessRows(db, rows, viewer) {++ const machines = await machineNames(db, rows.flatMap((r) => [r.from_principal, r.to_principal]));++ const who = await people(db, rows.flatMap((r) => [r.from_owner, r.to_owner]));++ return rows.map((r) => ({++ ...r,++ side: r.from_owner === viewer && r.to_owner === viewer ? 'own' : r.to_owner === viewer ? 'in' : 'out',++ from_machine: { id: r.from_principal, name: machines.get(r.from_principal) || 'A machine' },++ to_machine: { id: r.to_principal, name: machines.get(r.to_principal) || 'A machine' },++ from_person: personOf(who, r.from_owner),++ to_person: personOf(who, r.to_owner),++ }));++}++++/** Agent threads ({ from, to } agent ids, tasks) with each agent's name, its machine and its person. */++async function nameThreads(db, threads, viewer) {++ const byId = await agents(db, threads.flatMap((t) => [t.from, t.to]));++ const machines = await machineNames(db, [...byId.values()].map((a) => a.machine_id));++ const who = await people(db, [...byId.values()].map((a) => a.owner));++ const named = (id) => {++ const a = byId.get(id);++ if (!a) return { id, name: 'An agent', kind: null, machine: null, person: null, mine: false };++ return { id, name: a.name, kind: a.kind, machine: a.machine_id ? { id: a.machine_id, name: machines.get(a.machine_id) || 'A machine' } : null,++ service: a.service, person: personOf(who, a.owner), mine: a.owner === viewer };++ };++ return threads.map((t) => ({ ...t, from_agent: named(t.from), to_agent: named(t.to) }));++}++++/** The viewer's own agents, with the name of the machine each runs on. */++async function nameAgents(db, list) {++ const machines = await machineNames(db, list.map((a) => a.machine_id));++ return list.map((a) => (a.machine_id ? { ...a, machine: { id: a.machine_id, name: machines.get(a.machine_id) || 'A machine' } } : a));++}++++module.exports = { machineNames, people, agents, nameAccessRows, nameThreads, nameAgents };@@@ -167,5 +167,29 @@ const { DEFAULT_GRANTS } = require('../server/identity/principals');VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(`tsk_${crypto.randomBytes(16).toString('hex')}`, created.task.thread, from.id, to.id, owner, JSON.stringify(message), minute, minute);await assert.rejects(agents.createTask(db, { type: 'node', id: a.id }, to.id, { from: from.id, message }), (e) => e.code === 'agents.rate_limit');await access.revoke(db, replacement.grant.id, other);++++ // The owner's page names both sides (server/link/names.js): machines, people and agents, and which side the++ // viewer is on, never a bare lnk_/usr_/agt_ id.++ const names = require('../server/link/names');++ await db.prepare('UPDATE platform_node_principals SET name = ? WHERE id = ?').run('Alex laptop', a.id);++ await db.prepare('UPDATE platform_node_principals SET name = ? WHERE id = ?').run('Sam desktop', b.id);++ await db.prepare('UPDATE platform_node_principals SET name = ? WHERE id = ?').run('Alex desktop', c.id);++ const rows = (await db.prepare('SELECT * FROM link_access_requests WHERE from_owner = ? OR to_owner = ? ORDER BY asked_at').all(owner, owner)).map(access.requestView);++ const asSam = await names.nameAccessRows(db, rows.filter((r) => r.to_owner === other), other);++ assert.equal(asSam[0].side, 'in', 'a request to Sam is "in" for Sam');++ assert.equal(asSam[0].from_machine.name, 'Alex laptop');++ assert.equal(asSam[0].to_machine.name, 'Sam desktop');++ assert.equal(asSam[0].from_person.username, 'alex');++ const asAlex = await names.nameAccessRows(db, rows, owner);++ assert.equal(asAlex.find((r) => r.to_principal === b.id).side, 'out', 'the same request is "out" for Alex');++ assert.equal(asAlex.find((r) => r.to_principal === c.id).side, 'own', 'between Alex\'s own machines');++ const [thread] = await names.nameThreads(db, [{ id: 'thr_x', from: from.id, to: to.id, tasks: [] }], owner);++ assert.equal(thread.from_agent.name, 'Codex');++ assert.equal(thread.from_agent.mine, true);++ assert.equal(thread.to_agent.mine, false);++ assert.equal(thread.to_agent.person.username, 'sam');++ assert.equal(thread.to_agent.machine.name, 'Sam desktop');++ const [mineAgent] = await names.nameAgents(db, [agents.directoryView(await db.prepare('SELECT * FROM agent_directory WHERE id = ?').get(from.id))]);++ assert.equal(mineAgent.machine.name, 'Alex laptop');console.log('link access and agents: ownership, follow gate, defaults, narrowing, time, limits, token, revocation and relay refusals passed');})().catch((err) => { console.error(err); process.exitCode = 1; });@@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) asconst files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();// The module map, in load order (public/js/my/…).--const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'emails.js', 'consent.js', 'labels.js', 'security.js', 'passkeys.js', 'mfa.js', 'approvals.js', 'apps.js', 'receipts.js', 'boot.js'];++const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'access.js', 'themes.js', 'profile.js', 'emails.js', 'consent.js', 'labels.js', 'security.js', 'passkeys.js', 'mfa.js', 'approvals.js', 'apps.js', 'receipts.js', 'boot.js'];assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];const loaded = tags.map((m) => m[2]);
@@ -764,3 +764,54 @@ body.my-machines-open { padding-bottom: max(112px, calc(100vh - 90px)); }
@@ -764,3 +764,54 @@ body.my-machines-open { padding-bottom: max(112px, calc(100vh - 90px)); }
body.link-hub.my-machines-open { padding-bottom: 0; }
body.link-hub.my-machines-open { padding-bottom: 0; }
.link-hub .mc-title { margin: 0 0 6px; font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2rem); line-height: 1.15; }
.link-hub .mc-title { margin: 0 0 6px; font-size: clamp(1.5rem, 1.1rem + 1.6vw, 2rem); line-height: 1.15; }
.mc-empty > i { flex: none; }
.mc-empty > i { flex: none; }
+
+/* Access between machines and your agents (my/access.js), under the machine cards on openvibe.link/machines. A request
+ asked of you is a card with what is asked, in words with an icon each, and the answer under it; the rest are rows. */
+.ma-box { display: grid; gap: 12px; margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); min-width: 0; }
+.ma-box[hidden] { display: none; }
+.ma-h { display: flex; align-items: center; gap: 8px; margin: 0; font-size: 1.05rem; }
+.ma-note { margin: 0; font-size: 13px; line-height: 1.5; }
+.ma-sec { display: grid; gap: 8px; }
+.ma-sec h4, .ma-sub { margin: 4px 0 0; font-size: 12px; font-weight: 700; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted); }
+.ma-ask { display: grid; gap: 8px; padding: 14px 16px; border-radius: 14px; border: 1px solid color-mix(in srgb, var(--accent) 45%, var(--border)); background: color-mix(in srgb, var(--accent) 7%, var(--bg-secondary)); }
+.ma-ask-title { margin: 0; font-size: 15px; line-height: 1.4; }
+.ma-ask-when { margin: 0; font-size: 13px; color: var(--text-muted); }
+.ma-scopes { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; }
+.ma-scopes li { display: inline-flex; align-items: center; gap: 6px; padding: 5px 10px; border-radius: 999px; background: var(--bg-card); border: 1px solid var(--border); font-size: 13px; font-weight: 600; }
+.ma-scopes i { color: var(--accent-light); }
+.ma-reason { margin: 0; padding: 6px 12px; border-left: 3px solid var(--border); color: var(--text-secondary); font-size: 13px; font-style: italic; }
+.ma-ask-acts { display: flex; flex-wrap: wrap; gap: 8px; }
+.ma-ask-acts .btn { min-height: 44px; min-width: 112px; justify-content: center; }
+.ma-less summary { min-height: 44px; display: inline-flex; align-items: center; cursor: pointer; color: var(--text-secondary); font-size: 13px; }
+.ma-less-body { display: grid; gap: 6px; padding: 8px 0 2px; }
+.ma-less-scope, .ma-less-time { display: flex; align-items: center; gap: 8px; min-height: 40px; font-size: 14px; }
+.ma-less-time select { min-height: 40px; }
+.ma-less-body .btn { justify-self: start; min-height: 44px; }
+.ma-say:empty { display: none; }
+.ma-say { margin: 0; font-size: 13px; color: var(--danger-text, #ef4444); }
+.ma-grants, .ma-list, .ma-agents, .ma-threads { list-style: none; display: grid; gap: 8px; margin: 0; padding: 0; }
+.ma-grant { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-secondary); }
+.ma-grant > i { color: var(--success); }
+.ma-grant-t b, .ma-agent-t b { display: block; font-size: 14px; overflow-wrap: anywhere; }
+.ma-grant-t small, .ma-agent-t small { display: block; color: var(--text-muted); font-size: 12.5px; }
+.ma-grant .btn { min-height: 40px; }
+.ma-list li { font-size: 13px; color: var(--text-secondary); line-height: 1.45; }
+.ma-fold summary { min-height: 40px; display: flex; align-items: center; cursor: pointer; font-size: 13px; font-weight: 600; color: var(--text-secondary); }
+.ma-fold .ma-list { padding: 4px 0 6px; }
+.ma-empty code { font-size: 12px; }
+.ma-agent { display: grid; grid-template-columns: auto minmax(0, 1fr) auto; gap: 10px; align-items: center; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-secondary); }
+.ma-agent-sw { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; font-size: 13px; color: var(--text-secondary); cursor: pointer; }
+.ma-thread { display: grid; gap: 8px; padding: 12px; border-radius: 12px; border: 1px solid var(--border); background: var(--bg-secondary); }
+.ma-thread-h { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 6px; margin: 0; font-size: 14px; }
+.ma-state { margin-left: auto; padding: 2px 8px; border-radius: 999px; font-size: 12px; font-weight: 700; }
+.ma-state.is-open { color: var(--success); background: color-mix(in srgb, var(--success) 14%, transparent); }
+.ma-state.is-closed { color: var(--text-muted); background: var(--bg-card); }
+.ma-tasks { margin: 0; padding-left: 20px; display: grid; gap: 4px; font-size: 13px; }
+.ma-task-msg { display: block; overflow-wrap: anywhere; }
+.ma-tasks small { color: var(--text-muted); overflow-wrap: anywhere; }
+.ma-tasks li.is-failed small { color: var(--danger-text, #ef4444); }
+.ma-thread .btn { justify-self: start; min-height: 40px; }
+@media (max-width: 560px) {
+ .ma-grant, .ma-agent { grid-template-columns: auto minmax(0, 1fr); }
+ .ma-grant .btn, .ma-agent-sw { grid-column: 2; justify-self: start; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,277 @@
@@ -0,0 +1,277 @@
+/* ── Machine access and agents on openvibe.link/machines (Link #157, Network #849/#853, Bot #407) ──────────────────
+ One machine asks another for access; its owner answers here (or in the Link tray) and can take it back at any time.
+ Agents on any of the person's machines (and on Actor or Codes) talk to each other in threads the owner can stop.
+
+ The routes (all the signed-in person's own rows, named by server/link/names.js):
+ GET /api/v1/me/nodes/access-requests asked of you (side "in"), asked by you ("out"), between yours ("own")
+ POST /api/v1/me/nodes/access-requests/:id/answer { approve, scopes?, duration_seconds? }: only narrower or shorter
+ GET /api/v1/me/nodes/access-grants POST …/:id/revoke
+ GET /api/v1/me/nodes/access-audit each use of a grant
+ GET/PUT /api/v1/me/nodes/:principal/access-settings the three switches on a machine's card
+ GET /api/v1/me/agents PUT …/:id/allow-others GET /api/v1/me/agent-threads POST …/:id/stop
+ Uses mcEl, mcIcon, mcAgo and mcSay from machines.js. Every name is a text node. */
+
+const MA_SCOPE = {
+ agent: ['fa-robot', 'Talk to your agents'],
+ send: ['fa-paper-plane', 'Send you files'],
+ screen: ['fa-display', 'Watch the screen'],
+ desk: ['fa-window-maximize', 'Use a desk'],
+ computer: ['fa-keyboard', 'Control the computer'],
+ worker: ['fa-microchip', 'Run jobs on it'],
+};
+const MA_TIMES = [[600, '10 minutes'], [3600, '1 hour'], [4 * 3600, '4 hours'], [86400, '1 day'], [7 * 86400, '1 week'], [30 * 86400, '30 days']];
+
+const maPerson = (p) => (p && (p.display_name || p.username)) || 'Someone';
+/** "Jo's laptop" for someone else's machine, "your laptop" for your own. */
+function maMachine(row, end) {
+ const m = row[`${end}_machine`] || {};
+ const mine = row.side === 'own' || (row.side === 'in' ? end === 'to' : end === 'from');
+ return mine ? `your ${m.name || 'machine'}` : `${maPerson(row[`${end}_person`])}'s ${m.name || 'machine'}`;
+}
+const maCap = (s) => s.charAt(0).toUpperCase() + s.slice(1);
+function maFor(seconds) {
+ if (seconds == null) return 'until you take it back';
+ const hit = MA_TIMES.find(([n]) => n === Number(seconds));
+ if (hit) return `for ${hit[1]}`;
+ const m = Math.round(Number(seconds) / 60);
+ return m < 120 ? `for ${m} minutes` : m < 2880 ? `for ${Math.round(m / 60)} hours` : `for ${Math.round(m / 1440)} days`;
+}
+function maLeft(expiresAt) {
+ if (!expiresAt) return 'until you take it back';
+ const ms = Date.parse(expiresAt) - Date.now();
+ if (!(ms > 0)) return 'ended';
+ const min = Math.round(ms / 60000);
+ return min < 60 ? `${min} min left` : min < 2880 ? `${Math.floor(min / 60)} h ${min % 60} min left` : `${Math.round(min / 1440)} days left`;
+}
+const maLive = (g) => !g.revoked_at && (!g.expires_at || Date.parse(g.expires_at) > Date.now());
+function maScopes(scopes) {
+ return mcEl('ul', 'ma-scopes', (scopes || []).map((s) => mcEl('li', '', [mcIcon((MA_SCOPE[s] || [])[0] || 'fa-key'), ' ', (MA_SCOPE[s] || [])[1] || s])));
+}
+const maScopeWords = (scopes) => (scopes || []).map((s) => (MA_SCOPE[s] || [])[1] || s).join(', ');
+
+async function maPost(path, body) {
+ return apiFetch(path, { method: 'POST', body: JSON.stringify(body || {}) });
+}
+
+/** A request asked of you: who, from which machine, to which of yours, what, how long, why; Allow, Allow less, Deny. */
+function maAskCard(r) {
+ const card = mcEl('article', 'ma-ask');
+ const title = mcEl('p', 'ma-ask-title', [mcEl('b', '', [maCap(maMachine(r, 'from'))]), ` asks for access to ${maMachine(r, 'to')}`]);
+ const when = mcEl('p', 'ma-ask-when', [`${maCap(maFor(r.duration_seconds))} · asked ${mcAgo(r.asked_at)}`]);
+ card.append(title, maScopes(r.scopes), when);
+ if (r.reason) card.append(mcEl('blockquote', 'ma-reason', [r.reason]));
+ const say = mcEl('p', 'ma-say', [], { role: 'status' });
+ const answer = async (body, btns) => {
+ btns.forEach((b) => { b.disabled = true; });
+ try {
+ await maPost(`/api/v1/me/nodes/access-requests/${encodeURIComponent(r.id)}/answer`, body);
+ mcSay(body.approve ? `Allowed: ${maMachine(r, 'from')} may ${maScopeWords(body.scopes || r.scopes).toLowerCase()}.` : 'Denied. They can ask again tomorrow.');
+ loadMachineAccess();
+ } catch (e) {
+ say.textContent = e.status === 410 ? 'This request waited too long and has ended.' : (e.message || 'The answer could not be saved.');
+ btns.forEach((b) => { b.disabled = false; });
+ }
+ };
+ const allow = mcEl('button', 'btn btn-primary btn-small', [mcIcon('fa-check'), ' Allow'], { type: 'button' });
+ const deny = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-xmark'), ' Deny'], { type: 'button' });
+ const less = mcEl('details', 'ma-less');
+ less.append(mcEl('summary', '', ['Allow less…']));
+ // Narrower only: the asked scopes, each on by default, and times no longer than asked.
+ const boxes = (r.scopes || []).map((s) => {
+ const box = mcEl('input', '', [], { type: 'checkbox', value: s });
+ box.checked = true;
+ return mcEl('label', 'ma-less-scope', [box, ' ', (MA_SCOPE[s] || [])[1] || s]);
+ });
+ const times = MA_TIMES.filter(([n]) => r.duration_seconds == null || n <= Number(r.duration_seconds));
+ const sel = mcEl('select', '', [
+ ...(r.duration_seconds == null ? [mcEl('option', '', ['Until I take it back'], { value: '' })] : []),
+ ...times.map(([n, label]) => mcEl('option', '', [label], { value: String(n) })),
+ ], { 'aria-label': 'For how long' });
+ if (r.duration_seconds != null) sel.value = String(times.length ? times[times.length - 1][0] : r.duration_seconds);
+ const allowLess = mcEl('button', 'btn btn-primary btn-small', ['Allow this much'], { type: 'button' });
+ less.append(mcEl('div', 'ma-less-body', [...boxes, mcEl('label', 'ma-less-time', ['For ', sel]), allowLess]));
+ const btns = [allow, deny, allowLess];
+ allow.addEventListener('click', () => answer({ approve: true }, btns));
+ deny.addEventListener('click', () => answer({ approve: false }, btns));
+ allowLess.addEventListener('click', () => {
+ const scopes = boxes.map((l) => l.querySelector('input')).filter((b) => b.checked).map((b) => b.value);
+ if (!scopes.length) { say.textContent = 'Choose at least one thing to allow, or press Deny.'; return; }
+ answer({ approve: true, scopes, ...(sel.value ? { duration_seconds: Number(sel.value) } : {}) }, btns);
+ });
+ card.append(mcEl('div', 'ma-ask-acts', [allow, deny]), less, say);
+ return card;
+}
+
+/** An allowed grant: from → to, what, how long is left; Take back. */
+function maGrantRow(g) {
+ const row = mcEl('li', 'ma-grant');
+ const text = mcEl('div', 'ma-grant-t', [
+ mcEl('b', '', [`${maCap(maMachine(g, 'from'))} → ${maMachine(g, 'to')}`]),
+ mcEl('small', '', [`${maScopeWords(g.scopes)} · ${maLeft(g.expires_at)}`]),
+ ]);
+ const back = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-hand'), ' Take back'], { type: 'button' });
+ back.addEventListener('click', async () => {
+ back.disabled = true;
+ try {
+ await maPost(`/api/v1/me/nodes/access-grants/${encodeURIComponent(g.id)}/revoke`);
+ mcSay('Taken back. It ends now, on both machines.');
+ loadMachineAccess();
+ } catch (e) { back.disabled = false; mcSay(e.message || 'It could not be taken back.', true); }
+ });
+ row.append(mcIcon('fa-key'), text, back);
+ return row;
+}
+
+function maSection(title, note, kids) {
+ return mcEl('section', 'ma-sec', [mcEl('h4', '', [title]), ...(note ? [mcEl('p', 'muted ma-note', [note])] : []), ...kids]);
+}
+
+async function loadMachineAccess() {
+ const box = document.getElementById('mc-access');
+ if (!box) return;
+ const [req, gr, au] = await Promise.all([
+ apiFetch('/api/v1/me/nodes/access-requests').catch(() => null),
+ apiFetch('/api/v1/me/nodes/access-grants').catch(() => null),
+ apiFetch('/api/v1/me/nodes/access-audit').catch(() => null),
+ ]);
+ if (!req && !gr) { box.replaceChildren(); box.hidden = true; return; }
+ const requests = (req && req.requests) || [];
+ const grants = (gr && gr.grants) || [];
+ const audit = ((au && au.audit) || []).slice(0, 20);
+ const asking = requests.filter((r) => r.status === 'pending' && (r.side === 'in' || r.side === 'own'));
+ const waiting = requests.filter((r) => r.status === 'pending' && r.side === 'out');
+ const live = grants.filter(maLive);
+ const ended = [...requests.filter((r) => r.status === 'denied' || r.status === 'expired'), ...grants.filter((g) => !maLive(g))]
+ .sort((a, b) => Date.parse(b.answered_at || b.revoked_at || b.expires_at || 0) - Date.parse(a.answered_at || a.revoked_at || a.expires_at || 0)).slice(0, 10);
+ const parts = [mcEl('h3', 'ma-h', [mcEl('span', 'icon', [mcIcon('fa-key')]), ' Access between machines'])];
+ parts.push(mcEl('p', 'muted ma-note', ['A machine can ask another for access: to send files, talk to its agents, watch its screen. Each owner answers, and can take it back at any time. People can only ask if they follow you and the machine allows it (on its card above).']));
+ if (asking.length) parts.push(maSection(`Asking you (${asking.length})`, '', asking.map(maAskCard)));
+ if (live.length) parts.push(maSection('Allowed now', '', [mcEl('ul', 'ma-grants', live.map(maGrantRow))]));
+ if (waiting.length) {
+ parts.push(maSection('Waiting for an answer', '', [mcEl('ul', 'ma-list', waiting.map((r) => mcEl('li', '', [
+ mcEl('b', '', [maCap(maMachine(r, 'to'))]), ` · ${maScopeWords(r.scopes)} · ${maFor(r.duration_seconds)} · asked ${mcAgo(r.asked_at)}`])))]));
+ }
+ if (!asking.length && !live.length && !waiting.length) {
+ parts.push(mcEl('div', 'mc-empty ma-empty', [mcIcon('fa-key'), mcEl('p', '', [mcEl('b', '', ['Nothing asked or allowed yet.']), ' From a machine, run ', mcEl('code', '', ['openvibe-link access request @person/machine send']), ', or let an agent ask. The answer shows here and in the Link tray.'])]));
+ }
+ if (audit.length) {
+ const used = mcEl('details', 'ma-fold');
+ used.append(mcEl('summary', '', [`What was used (${audit.length})`]),
+ mcEl('ul', 'ma-list', audit.map((a) => mcEl('li', '', [
+ mcEl('time', '', [mcAgo(a.at)], { datetime: a.at }), ' · ', maCap(maMachine(a, 'from')), ` used “${(MA_SCOPE[a.scope] || [])[1] || a.scope}” on ${maMachine(a, 'to')}`, a.action ? ` (${a.action})` : '']))));
+ parts.push(used);
+ }
+ if (ended.length) {
+ const old = mcEl('details', 'ma-fold');
+ old.append(mcEl('summary', '', [`Ended (${ended.length})`]),
+ mcEl('ul', 'ma-list', ended.map((x) => mcEl('li', '', [maCap(maMachine(x, 'from')), ` → ${maMachine(x, 'to')} · ${maScopeWords(x.scopes)} · `,
+ x.status === 'denied' ? 'denied' : x.status === 'expired' ? 'no answer in time' : x.revoked_at ? 'taken back' : 'time ran out']))));
+ parts.push(old);
+ }
+ box.replaceChildren(...parts);
+ box.hidden = false;
+ loadMachineAgents();
+}
+
+/* ── Agents ── */
+const MA_THREAD_STATE = (t) => (t.stopped_at ? 'Stopped' : Date.parse(t.closes_at) <= Date.now() ? 'Ended (a day quiet)' : 'Open');
+const maTaskText = (m) => {
+ const parts = (m && Array.isArray(m.parts)) ? m.parts : [];
+ return parts.map((p) => (p && typeof p.text === 'string' ? p.text : '')).join(' ').trim().slice(0, 240);
+};
+function maAgentLabel(a) {
+ if (!a) return 'An agent';
+ const where = a.machine ? (a.mine ? `your ${a.machine.name}` : `${maPerson(a.person)}'s ${a.machine.name}`) : a.service ? `OpenVibe.${maCap(a.service)}` : (a.mine ? 'yours' : maPerson(a.person));
+ return `${a.name} on ${where}`;
+}
+
+async function loadMachineAgents() {
+ const box = document.getElementById('mc-agents');
+ if (!box) return;
+ const [ag, th] = await Promise.all([
+ apiFetch('/api/v1/me/agents').catch(() => null),
+ apiFetch('/api/v1/me/agent-threads').catch(() => null),
+ ]);
+ if (!ag && !th) { box.replaceChildren(); box.hidden = true; return; }
+ const agents = (ag && ag.agents) || [];
+ const threads = (th && th.threads) || [];
+ const parts = [mcEl('h3', 'ma-h', [mcEl('span', 'icon', [mcIcon('fa-robot')]), ' Your agents'])];
+ if (!agents.length) {
+ parts.push(mcEl('div', 'mc-empty ma-empty', [mcIcon('fa-robot'), mcEl('p', '', [mcEl('b', '', ['No agents yet.']), ' Run the Link on a computer with an AI coding app (Codex, Claude Code, Muse …): each one shows up here and can talk to your other agents.'])]));
+ } else {
+ parts.push(mcEl('ul', 'ma-agents', agents.map((a) => {
+ const id = `ma-agent-${String(a.id).replace(/\W/g, '-')}`;
+ const box2 = mcEl('input', '', [], { type: 'checkbox', id });
+ box2.checked = !!a.allow_others;
+ box2.addEventListener('change', async () => {
+ box2.disabled = true;
+ try {
+ await apiFetch(`/api/v1/me/agents/${encodeURIComponent(a.id)}/allow-others`, { method: 'PUT', body: JSON.stringify({ allow_others: box2.checked }) });
+ mcSay(box2.checked ? `${a.name}: other people's agents may ask it now (each one still needs a machine's permission).` : `${a.name} answers only your own agents.`);
+ } catch (e) { box2.checked = !box2.checked; mcSay(e.message || 'That could not be saved.', true); }
+ finally { box2.disabled = false; }
+ });
+ const where = a.machine ? `on ${a.machine.name}` : a.service ? `on OpenVibe.${maCap(a.service)}` : '';
+ return mcEl('li', 'ma-agent', [
+ mcEl('span', `mc-dot ${a.status === 'online' ? 'is-on' : ''}`, [], { 'aria-hidden': 'true' }),
+ mcEl('div', 'ma-agent-t', [mcEl('b', '', [a.name]), mcEl('small', '', [[/^(link-local|actor|codes)$/.test(String(a.kind)) ? '' : a.kind, where, a.status === 'online' ? 'online' : 'offline'].filter(Boolean).join(' · ')])]),
+ mcEl('label', 'ma-agent-sw', [box2, mcEl('span', '', ["Others' agents may ask it"])], { for: id }),
+ ]);
+ })));
+ }
+ if (threads.length) {
+ parts.push(mcEl('h4', 'ma-sub', ['Conversations between agents']));
+ parts.push(mcEl('ul', 'ma-threads', threads.slice(0, 20).map((t) => {
+ const state = MA_THREAD_STATE(t);
+ const head = mcEl('p', 'ma-thread-h', [mcEl('b', '', [maAgentLabel(t.from_agent)]), ' ↔ ', mcEl('b', '', [maAgentLabel(t.to_agent)]), mcEl('span', `ma-state is-${state === 'Open' ? 'open' : 'closed'}`, [state])]);
+ const tasks = mcEl('ol', 'ma-tasks', (t.tasks || []).slice(-6).map((k) => mcEl('li', `is-${k.status}`, [
+ mcEl('span', 'ma-task-msg', [maTaskText(k.message) || 'A task']),
+ mcEl('small', '', [k.status === 'completed' && maTaskText(k.result) ? `→ ${maTaskText(k.result)}` : k.status]),
+ ])));
+ const li = mcEl('li', 'ma-thread', [head, tasks]);
+ if (state === 'Open') {
+ const stop = mcEl('button', 'btn btn-outline btn-small', [mcIcon('fa-stop'), ' Stop this conversation'], { type: 'button' });
+ stop.addEventListener('click', async () => {
+ stop.disabled = true;
+ try { await maPost(`/api/v1/me/agent-threads/${encodeURIComponent(t.id)}/stop`); mcSay('Stopped. Its open tasks are cancelled.'); loadMachineAgents(); }
+ catch (e) { stop.disabled = false; mcSay(e.message || 'It could not be stopped.', true); }
+ });
+ li.append(stop);
+ }
+ return li;
+ })));
+ }
+ box.replaceChildren(...parts);
+ box.hidden = false;
+}
+
+/** The three access switches on a paired machine's card, read when the card is drawn. */
+function mcAccessSwitches(m) {
+ 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 boxes = {};
+ for (const [key, label, note, icon] of rows) {
+ const id = `mc-${m.id}-${key}`;
+ const box = mcEl('input', '', [], { type: 'checkbox', id, disabled: '' });
+ boxes[key] = box;
+ box.addEventListener('change', async () => {
+ box.disabled = true;
+ try {
+ await apiFetch(`/api/v1/me/nodes/${encodeURIComponent(m.principal_id)}/access-settings`, { method: 'PUT', body: JSON.stringify({ [key]: box.checked }) });
+ mcSay(`Saved. ${m.name || 'The machine'}: ${label.toLowerCase()} ${box.checked ? 'on' : 'off'}.`);
+ } catch (e) { box.checked = !box.checked; mcSay(e.message || 'The switch could not be saved.', true); }
+ finally { box.disabled = false; }
+ });
+ field.append(mcEl('label', 'mc-role', [box, mcEl('span', 'mc-role-ic', [mcIcon(icon)]), mcEl('span', 'mc-role-t', [mcEl('b', '', [label]), mcEl('small', '', [note])])], { for: id }));
+ }
+ apiFetch(`/api/v1/me/nodes/${encodeURIComponent(m.principal_id)}/access-settings`).then((out) => {
+ const s = (out && out.settings) || {};
+ for (const [key, box] of Object.entries(boxes)) { box.checked = s[key] === true; box.disabled = false; }
+ }).catch(() => { field.append(mcEl('p', 'muted', ['These switches could not be read just now.'])); });
+ return field;
+}
@@ -68,6 +68,9 @@ function mcCard(m) {
@@ -68,6 +68,9 @@ function mcCard(m) {
const own = mcOwnerSwitches(m);
const own = mcOwnerSwitches(m);
if (own) card.append(own);
if (own) card.append(own);
+ // Who else may ask this machine for access, and its agents (my/access.js).
+ const access = typeof mcAccessSwitches === 'function' ? mcAccessSwitches(m) : null;
+ if (access) card.append(access);
const screen = mcScreenBlock(card, m);
const screen = mcScreenBlock(card, m);
if (screen) card.append(screen);
if (screen) card.append(screen);
const links = mcSharesBlock(m);
const links = mcSharesBlock(m);
@@ -600,6 +603,7 @@ async function loadMachines() {
@@ -600,6 +603,7 @@ async function loadMachines() {
mcNames = new Map(machines.map((m) => [m.id, m.name || MC_KIND[m.kind] || 'A machine']));
mcNames = new Map(machines.map((m) => [m.id, m.name || MC_KIND[m.kind] || 'A machine']));
for (const m of machines) m.pools = (pools.pools || []).filter((p) => p.machines.some((x) => x.id === m.id));
for (const m of machines) m.pools = (pools.pools || []).filter((p) => p.machines.some((x) => x.id === m.id));
list.replaceChildren(...machines.map(mcCard), ...mcPoolPickers(pools.candidates || []));
list.replaceChildren(...machines.map(mcCard), ...mcPoolPickers(pools.candidates || []));
+ if (typeof loadMachineAccess === 'function') loadMachineAccess().catch(() => {});
} catch (e) {
} catch (e) {
if (count) count.textContent = '';
if (count) count.textContent = '';
list.replaceChildren(mcEl('p', 'muted', [e.status === 401 ? 'Sign in to see your machines.' : 'Your machines could not be read just now.']));
list.replaceChildren(mcEl('p', 'muted', [e.status === 401 ? 'Sign in to see your machines.' : 'Your machines could not be read just now.']));
@@ -507,6 +507,10 @@
@@ -507,6 +507,10 @@
<div class="mc-pair" id="mc-pair" hidden></div>
<div class="mc-pair" id="mc-pair" hidden></div>
<p class="mc-status" id="mc-status" role="status"></p>
<p class="mc-status" id="mc-status" role="status"></p>
<div class="mc-list" id="mc-list"><p class="muted">Loading your machines…</p></div>
<div class="mc-list" id="mc-list"><p class="muted">Loading your machines…</p></div>
+ <!-- Access between machines and your agents (my/access.js): asked of you, allowed now, what was used,
+ and the conversations between agents. Hidden until it can be read. -->
+ <div class="ma-box" id="mc-access" hidden></div>
+ <div class="ma-box" id="mc-agents" hidden></div>
</div>
</div>
</div>
</div>
@@ -647,6 +651,7 @@
@@ -647,6 +651,7 @@
<script src="/js/my/tools.js" defer></script>
<script src="/js/my/tools.js" defer></script>
<script src="/js/my/today.js" defer></script>
<script src="/js/my/today.js" defer></script>
<script src="/js/my/machines.js" defer></script>
<script src="/js/my/machines.js" defer></script>
+<script src="/js/my/access.js" defer></script>
<script src="/js/my/themes.js" defer></script>
<script src="/js/my/themes.js" defer></script>
<script src="/js/my/profile.js" defer></script>
<script src="/js/my/profile.js" defer></script>
<script src="/js/my/emails.js" defer></script>
<script src="/js/my/emails.js" defer></script>
@@ -8,6 +8,7 @@ const express = require('express');
@@ -8,6 +8,7 @@ const express = require('express');
const { ids, http, validate } = require('openvibe-contracts');
const { ids, http, validate } = require('openvibe-contracts');
const { ensureUserSubject } = require('../identity/subjects');
const { ensureUserSubject } = require('../identity/subjects');
const eventRelay = require('../developer/event-relay');
const eventRelay = require('../developer/event-relay');
+const names = require('./names');
const SCOPES = new Set(['agent', 'send', 'screen', 'desk', 'computer', 'worker']);
const SCOPES = new Set(['agent', 'send', 'screen', 'desk', 'computer', 'worker']);
const PRINCIPAL = /^lnk_[0-9A-HJKMNP-TV-Z]{26}$/;
const PRINCIPAL = /^lnk_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -324,7 +325,8 @@ function routers({ requireAuth, nodeGuard, relayGuard }) {
@@ -324,7 +325,8 @@ function routers({ requireAuth, nodeGuard, relayGuard }) {
const who = await owner(db, req);
const who = await owner(db, req);
await expirePending(db);
await expirePending(db);
const rows = await db.prepare('SELECT * FROM link_access_requests WHERE from_owner = ? OR to_owner = ? ORDER BY asked_at DESC LIMIT 100').all(who, who);
const rows = await db.prepare('SELECT * FROM link_access_requests WHERE from_owner = ? OR to_owner = ? ORDER BY asked_at DESC LIMIT 100').all(who, who);
- res.json({ requests: rows.map(requestView) });
+ // The owner's page names each side (openvibe.link/machines: "Jo's laptop asks for access to your desktop").
+ res.json({ requests: await names.nameAccessRows(db, rows.map(requestView), who) });
}));
}));
user.post('/access-requests', express.json({ limit: '8kb' }), wrap(async (req, res, db) => {
user.post('/access-requests', express.json({ limit: '8kb' }), wrap(async (req, res, db) => {
const who = await owner(db, req), from = await machine(db, req.body?.from_principal);
const who = await owner(db, req), from = await machine(db, req.body?.from_principal);
@@ -338,13 +340,16 @@ function routers({ requireAuth, nodeGuard, relayGuard }) {
@@ -338,13 +340,16 @@ function routers({ requireAuth, nodeGuard, relayGuard }) {
user.get('/access-grants', wrap(async (req, res, db) => {
user.get('/access-grants', wrap(async (req, res, db) => {
const who = await owner(db, req);
const who = await owner(db, req);
const rows = await db.prepare('SELECT * FROM link_access_grants WHERE from_owner = ? OR to_owner = ? ORDER BY created_at DESC LIMIT 100').all(who, who);
const rows = await db.prepare('SELECT * FROM link_access_grants WHERE from_owner = ? OR to_owner = ? ORDER BY created_at DESC LIMIT 100').all(who, who);
- res.json({ grants: rows.map(grantView) });
+ res.json({ grants: await names.nameAccessRows(db, rows.map(grantView), who) });
}));
}));
user.post('/access-grants/:id/revoke', wrap(async (req, res, db) => res.json({ grant: await revoke(db, req.params.id, await owner(db, req)) })));
user.post('/access-grants/:id/revoke', wrap(async (req, res, db) => res.json({ grant: await revoke(db, req.params.id, await owner(db, req)) })));
user.get('/access-audit', wrap(async (req, res, db) => {
user.get('/access-audit', wrap(async (req, res, db) => {
const who = await owner(db, req);
const who = await owner(db, req);
- const rows = await db.prepare('SELECT id, grant_id, from_owner, to_owner, scope, action, at FROM link_access_audit WHERE from_owner = ? OR to_owner = ? ORDER BY at DESC LIMIT 200').all(who, who);
- res.json({ audit: rows });
+ // Each use names the two machines of its grant, so the page can say "Jo's laptop sent a file to your desktop".
+ const rows = await db.prepare(`SELECT a.id, a.grant_id, a.from_owner, a.to_owner, a.scope, a.action, a.at, g.from_principal, g.to_principal
+ FROM link_access_audit a JOIN link_access_grants g ON g.id = a.grant_id
+ WHERE a.from_owner = ? OR a.to_owner = ? ORDER BY a.at DESC LIMIT 200`).all(who, who);
+ res.json({ audit: await names.nameAccessRows(db, rows, who) });
}));
}));
user.get('/:id/access-settings', wrap(async (req, res, db) => {
user.get('/:id/access-settings', wrap(async (req, res, db) => {
const who = await owner(db, req), p = await machine(db, req.params.id);
const who = await owner(db, req), p = await machine(db, req.params.id);
@@ -310,7 +310,7 @@ function routers({ requireAuth, registerGuard, resultGuard, bot, config, private
@@ -310,7 +310,7 @@ function routers({ requireAuth, registerGuard, resultGuard, bot, config, private
me.get('/agents', wrap(async (req, res, db) => {
me.get('/agents', wrap(async (req, res, db) => {
const who = await ensureUserSubject(db, req.user);
const who = await ensureUserSubject(db, req.user);
const rows = await db.prepare('SELECT * FROM agent_directory WHERE owner_subject = ? ORDER BY updated_at DESC, id LIMIT 200').all(who);
const rows = await db.prepare('SELECT * FROM agent_directory WHERE owner_subject = ? ORDER BY updated_at DESC, id LIMIT 200').all(who);
- res.json({ agents: rows.map(directoryView) });
+ res.json({ agents: await names.nameAgents(db, rows.map(directoryView)) });
}));
}));
me.put('/agents/:id/allow-others', express.json({ limit: '2kb' }), wrap(async (req, res, db) => {
me.put('/agents/:id/allow-others', express.json({ limit: '2kb' }), wrap(async (req, res, db) => {
if (typeof req.body?.allow_others !== 'boolean') fail(422, 'agents.allow_others', 'allow_others must be boolean');
if (typeof req.body?.allow_others !== 'boolean') fail(422, 'agents.allow_others', 'allow_others must be boolean');
@@ -324,11 +324,13 @@ function routers({ requireAuth, registerGuard, resultGuard, bot, config, private
@@ -324,11 +324,13 @@ function routers({ requireAuth, registerGuard, resultGuard, bot, config, private
const who = await ensureUserSubject(db, req.user);
const who = await ensureUserSubject(db, req.user);
await expireThreads(db);
await expireThreads(db);
const threads = await db.prepare('SELECT * FROM agent_threads WHERE from_owner = ? OR to_owner = ? ORDER BY created_at DESC LIMIT 100').all(who, who);
const threads = await db.prepare('SELECT * FROM agent_threads WHERE from_owner = ? OR to_owner = ? ORDER BY created_at DESC LIMIT 100').all(who, who);
- res.json({ threads: await Promise.all(threads.map(async (thread) => ({
+ const out = await Promise.all(threads.map(async (thread) => ({
id: thread.id, from: thread.from_agent, to: thread.to_agent, created_at: thread.created_at,
id: thread.id, from: thread.from_agent, to: thread.to_agent, created_at: thread.created_at,
closes_at: thread.closes_at, stopped_at: thread.stopped_at || null,
closes_at: thread.closes_at, stopped_at: thread.stopped_at || null,
tasks: (await db.prepare('SELECT * FROM agent_tasks WHERE thread_id = ? ORDER BY created_at LIMIT 100').all(thread.id)).map(taskView),
tasks: (await db.prepare('SELECT * FROM agent_tasks WHERE thread_id = ? ORDER BY created_at LIMIT 100').all(thread.id)).map(taskView),
- }))) });
+ })));
+ // Each side by name, machine and person, for the owner's Agents section on openvibe.link/machines.
+ res.json({ threads: await names.nameThreads(db, out, who) });
}));
}));
me.post('/agent-threads/:id/stop', wrap(async (req, res, db) =>
me.post('/agent-threads/:id/stop', wrap(async (req, res, db) =>
res.json({ thread: await stopThread(db, req.params.id, await ensureUserSubject(db, req.user)) })));
res.json({ thread: await stopThread(db, req.params.id, await ensureUserSubject(db, req.user)) })));
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+
+/**
+ * Names for the owner's machine-access and agent pages (openvibe.link/machines): a request, a grant or an agent
+ * thread names its machines, people and agents by id, and the page must say "Jo's laptop asks for access", never
+ * "lnk_01J…". One batched read per kind of name, only for ids the person's own rows already carry: nothing here lets
+ * a page look up a machine or a person it is not party to.
+ */
+
+const uniq = (xs) => [...new Set(xs.filter(Boolean).map(String))];
+const marks = (n) => Array.from({ length: n }, () => '?').join(', ');
+
+/** Link principal id → the machine's name. */
+async function machineNames(db, ids) {
+ const list = uniq(ids);
+ if (!list.length) return new Map();
+ const rows = await db.prepare(`SELECT id, name FROM platform_node_principals WHERE id IN (${marks(list.length)})`).all(...list);
+ return new Map(rows.map((r) => [r.id, r.name || 'A machine']));
+}
+
+/** Subject → { username, display_name }: the public name a profile shows. */
+async function people(db, subjects) {
+ const list = uniq(subjects);
+ if (!list.length) return new Map();
+ const rows = await db.prepare(`SELECT subject_id, username, display_name FROM users WHERE subject_id IN (${marks(list.length)})`).all(...list);
+ return new Map(rows.map((r) => [r.subject_id, { username: r.username, display_name: r.display_name || r.username }]));
+}
+
+/** Agent id → { name, kind, machine_id, owner }. */
+async function agents(db, ids) {
+ const list = uniq(ids);
+ if (!list.length) return new Map();
+ const rows = await db.prepare(`SELECT id, name, kind, machine_id, service, owner_subject FROM agent_directory WHERE id IN (${marks(list.length)})`).all(...list);
+ return new Map(rows.map((r) => [r.id, { name: r.name, kind: r.kind, machine_id: r.machine_id || null, service: r.service || null, owner: r.owner_subject }]));
+}
+
+const personOf = (map, subject) => (subject && map.get(subject)) || null;
+
+/**
+ * Requests or grants (from_principal, to_principal, from_owner, to_owner) with their machines' and people's names,
+ * and the side the viewer is on: "in" (asked of the viewer), "out" (the viewer asked) or "own" (between the viewer's
+ * own machines).
+ */
+async function nameAccessRows(db, rows, viewer) {
+ const machines = await machineNames(db, rows.flatMap((r) => [r.from_principal, r.to_principal]));
+ const who = await people(db, rows.flatMap((r) => [r.from_owner, r.to_owner]));
+ return rows.map((r) => ({
+ ...r,
+ side: r.from_owner === viewer && r.to_owner === viewer ? 'own' : r.to_owner === viewer ? 'in' : 'out',
+ from_machine: { id: r.from_principal, name: machines.get(r.from_principal) || 'A machine' },
+ to_machine: { id: r.to_principal, name: machines.get(r.to_principal) || 'A machine' },
+ from_person: personOf(who, r.from_owner),
+ to_person: personOf(who, r.to_owner),
+ }));
+}
+
+/** Agent threads ({ from, to } agent ids, tasks) with each agent's name, its machine and its person. */
+async function nameThreads(db, threads, viewer) {
+ const byId = await agents(db, threads.flatMap((t) => [t.from, t.to]));
+ const machines = await machineNames(db, [...byId.values()].map((a) => a.machine_id));
+ const who = await people(db, [...byId.values()].map((a) => a.owner));
+ const named = (id) => {
+ const a = byId.get(id);
+ if (!a) return { id, name: 'An agent', kind: null, machine: null, person: null, mine: false };
+ return { id, name: a.name, kind: a.kind, machine: a.machine_id ? { id: a.machine_id, name: machines.get(a.machine_id) || 'A machine' } : null,
+ service: a.service, person: personOf(who, a.owner), mine: a.owner === viewer };
+ };
+ return threads.map((t) => ({ ...t, from_agent: named(t.from), to_agent: named(t.to) }));
+}
+
+/** The viewer's own agents, with the name of the machine each runs on. */
+async function nameAgents(db, list) {
+ const machines = await machineNames(db, list.map((a) => a.machine_id));
+ return list.map((a) => (a.machine_id ? { ...a, machine: { id: a.machine_id, name: machines.get(a.machine_id) || 'A machine' } } : a));
+}
+
+module.exports = { machineNames, people, agents, nameAccessRows, nameThreads, nameAgents };
@@ -167,5 +167,29 @@ const { DEFAULT_GRANTS } = require('../server/identity/principals');
@@ -167,5 +167,29 @@ const { DEFAULT_GRANTS } = require('../server/identity/principals');
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(`tsk_${crypto.randomBytes(16).toString('hex')}`, created.task.thread, from.id, to.id, owner, JSON.stringify(message), minute, minute);
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(`tsk_${crypto.randomBytes(16).toString('hex')}`, created.task.thread, from.id, to.id, owner, JSON.stringify(message), minute, minute);
await assert.rejects(agents.createTask(db, { type: 'node', id: a.id }, to.id, { from: from.id, message }), (e) => e.code === 'agents.rate_limit');
await assert.rejects(agents.createTask(db, { type: 'node', id: a.id }, to.id, { from: from.id, message }), (e) => e.code === 'agents.rate_limit');
await access.revoke(db, replacement.grant.id, other);
await access.revoke(db, replacement.grant.id, other);
+
+ // The owner's page names both sides (server/link/names.js): machines, people and agents, and which side the
+ // viewer is on, never a bare lnk_/usr_/agt_ id.
+ const names = require('../server/link/names');
+ await db.prepare('UPDATE platform_node_principals SET name = ? WHERE id = ?').run('Alex laptop', a.id);
+ await db.prepare('UPDATE platform_node_principals SET name = ? WHERE id = ?').run('Sam desktop', b.id);
+ await db.prepare('UPDATE platform_node_principals SET name = ? WHERE id = ?').run('Alex desktop', c.id);
+ const rows = (await db.prepare('SELECT * FROM link_access_requests WHERE from_owner = ? OR to_owner = ? ORDER BY asked_at').all(owner, owner)).map(access.requestView);
+ const asSam = await names.nameAccessRows(db, rows.filter((r) => r.to_owner === other), other);
+ assert.equal(asSam[0].side, 'in', 'a request to Sam is "in" for Sam');
+ assert.equal(asSam[0].from_machine.name, 'Alex laptop');
+ assert.equal(asSam[0].to_machine.name, 'Sam desktop');
+ assert.equal(asSam[0].from_person.username, 'alex');
+ const asAlex = await names.nameAccessRows(db, rows, owner);
+ assert.equal(asAlex.find((r) => r.to_principal === b.id).side, 'out', 'the same request is "out" for Alex');
+ assert.equal(asAlex.find((r) => r.to_principal === c.id).side, 'own', 'between Alex\'s own machines');
+ const [thread] = await names.nameThreads(db, [{ id: 'thr_x', from: from.id, to: to.id, tasks: [] }], owner);
+ assert.equal(thread.from_agent.name, 'Codex');
+ assert.equal(thread.from_agent.mine, true);
+ assert.equal(thread.to_agent.mine, false);
+ assert.equal(thread.to_agent.person.username, 'sam');
+ assert.equal(thread.to_agent.machine.name, 'Sam desktop');
+ const [mineAgent] = await names.nameAgents(db, [agents.directoryView(await db.prepare('SELECT * FROM agent_directory WHERE id = ?').get(from.id))]);
+ assert.equal(mineAgent.machine.name, 'Alex laptop');
console.log('link access and agents: ownership, follow gate, defaults, narrowing, time, limits, token, revocation and relay refusals passed');
console.log('link access and agents: ownership, follow gate, defaults, narrowing, time, limits, token, revocation and relay refusals passed');
})().catch((err) => { console.error(err); process.exitCode = 1; });
})().catch((err) => { console.error(err); process.exitCode = 1; });
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
@@ -12,7 +12,7 @@ for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) as
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
// The module map, in load order (public/js/my/…).
// The module map, in load order (public/js/my/…).
-const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'themes.js', 'profile.js', 'emails.js', 'consent.js', 'labels.js', 'security.js', 'passkeys.js', 'mfa.js', 'approvals.js', 'apps.js', 'receipts.js', 'boot.js'];
+const modules = ['core.js', 'tools.js', 'today.js', 'machines.js', 'access.js', 'themes.js', 'profile.js', 'emails.js', 'consent.js', 'labels.js', 'security.js', 'passkeys.js', 'mfa.js', 'approvals.js', 'apps.js', 'receipts.js', 'boot.js'];
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
const loaded = tags.map((m) => m[2]);
const loaded = tags.map((m) => m[2]);