openvibers/openvibe.actor Commits

An approval says the floor first, the command as one line, the agent by its name (#328)

OP OpenVibers committed 6 days ago · e7fc984472 · 3 files, +67 −2

The hub's Needs you now opens the approval itself (Network#650), so its page must be read in a glance:
- the always-ask floor in plain words first ("This deletes something. It cannot be undone.", money, passwords, a
  new recipient), including a raw rm/shred caught by its argv (actions.deletes), not only by words;
- a computer step's command as one line, with the machine and folder; the exact request stays below it;
- the agent by its name instead of "your agent"; times as "2 minutes ago" / "expires in 7 days" from the
  service's clock, the exact UTC in the title.
Test: approvals (the caution, the command, the name, the times).

Changes

ddiff --git a/public/css/agents.css b/public/css/agents.css
iindex 8c8d4e0..13fad5e 100644
---- a/public/css/agents.css
++++ b/public/css/agents.css
@@@ -103,3 +103,8 @@ img.ac-face { background: var(--bg-card, #131c2e); }
.ac-floor svg { width: 18px; height: 18px; flex: none; margin-top: 1px; fill: none; stroke: currentColor; stroke-width: 1.8; stroke-linecap: round; stroke-linejoin: round; }
.ac-floor b { color: var(--text-primary, #e6edf7); font-weight: 600; }
@media (max-width: 30rem) { .ac-sheet-head .ac-face { --s: 52px; } .ac-row { padding: .75rem .8rem; } }
++/* An approval (11-07, Opus): the always-ask floor said first, the command as one line, then the exact request. */
++.pa-caution { margin: 0 0 1rem; padding: .75rem 1rem; border-radius: 12px; border: 1px solid color-mix(in srgb, var(--danger-text, #f87171) 45%, transparent); background: color-mix(in srgb, var(--danger-text, #f87171) 12%, transparent); color: var(--text-primary, #e6edf7); font-weight: 650; }
++.pa-cmd-where { margin: .4rem 0 .35rem; font-size: 14px; color: var(--text-secondary, #96a7c2); }
++.pa-cmd { white-space: pre-wrap; overflow-wrap: anywhere; font-weight: 650; }
++.pa-sub { margin: 1rem 0 .4rem; font-size: 12px; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-secondary, #96a7c2); }
ddiff --git a/server/http/agent-pages.js b/server/http/agent-pages.js
iindex ff3ab81..d09045a 100644
---- a/server/http/agent-pages.js
++++ b/server/http/agent-pages.js
@@@ -9,6 +9,36 @@ const personal = require('../agents/personal');
const contacts = require('../agents/contacts');
const ui = require('./agent-contacts-ui');
const approvals = require('../agents/approvals');
++const levels = require('../agents/levels');
++
++/** "22 minutes ago", "in 6 days": a person reads this, the exact UTC instant stays in the <time> element's title. */
++function ago(iso, now = Date.now()) {
++ const t = Date.parse(iso);
++ if (!Number.isFinite(t)) return '';
++ const d = Math.round((t - now) / 1000);
++ const a = Math.abs(d);
++ const [n, unit] = a < 60 ? [a, 'second'] : a < 3600 ? [Math.round(a / 60), 'minute'] : a < 86400 ? [Math.round(a / 3600), 'hour'] : [Math.round(a / 86400), 'day'];
++ if (a < 45) return 'just now';
++ const words = `${n} ${unit}${n === 1 ? '' : 's'}`;
++ return d < 0 ? `${words} ago` : `in ${words}`;
++}
++const whenAt = (iso, now = Date.now()) => (iso ? html`<time datetime="${iso}" title="${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}">${ago(iso, now)}</time>` : '');
++
++/** What an always-ask step does, in one plain line, so the person sees it before the details. */
++const CAUTION = {
++ delete: 'This deletes something. It cannot be undone.',
++ money: 'This spends money.',
++ password: 'This types a password or a secret.',
++ new_recipient: 'This sends to someone your agent has not written to before.',
++};
++
++/** The command a computer step runs, as one line, when the request is one; otherwise null. */
++function commandLine(p) {
++ const argv = p && Array.isArray(p.argv) ? p.argv.map(String) : null;
++ if (!argv || !argv.length) return null;
++ const quote = (a) => (/^[A-Za-z0-9_./:=@%+,-]+$/.test(a) ? a : `'${a.replace(/'/g, "'\\''")}'`);
++ return argv.map(quote).join(' ').slice(0, 2000);
++}
const memoryStore = require('../memory');
function createAgentPages(ctx) {
@@@ -131,10 +161,23 @@ ${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-c
const a = await approvals.get(s, owner(req), req.params.id);
if (!a) return page(req, res, 'Request not found', html`<h1>Request not found</h1><p><a href="/me/approvals">Approvals</a></p>`, 404);
const p = typeof a.payload === 'string' ? JSON.parse(a.payload) : a.payload;
++ // The agent by its name (a person's own agent: another person's is never looked up here).
++ const agent = a.agent_id ? await contacts.get(s, owner(req), a.agent_id).catch(() => null) : null;
++ const agentName = agent && agent.name ? agent.name : 'your agent';
++ // The always-ask floor, said in plain words before anything technical.
++ // A raw command deletes by its argv (rm, shred …), which words alone do not show: the same check stepFor uses.
++ const argvDeletes = p && Array.isArray(p.argv) && require('../agents/actions').deletes(p.argv);
++ const floor = [...new Set([...levels.classify({ summary: a.summary, payload: p }), ...(argvDeletes ? ['delete'] : [])])].filter((c) => CAUTION[c]);
++ const cmd = a.kind === 'actor.action' ? commandLine(p) : null;
++ const nowMs = Date.parse(s.iso()) || Date.now(); // the service's own clock (a test's clock in a test)
++ const where = p && typeof p.machine === 'string' && p.machine ? p.machine : null;
page(req, res, 'Approval', html`<p class="pa-eyebrow"><a href="/me/approvals">Approvals</a></p><h1>${a.summary}</h1>
--<p>${approvalPill(a)} <span class="pa-when">Asked ${time(a.created_at)}${a.agent_id ? html` by <a href="/me/agents/${a.agent_id}">your agent</a>` : ''}${a.task_id ? html` in <a href="/tasks/${a.task_id}">this run</a>` : ''}</span></p>
++<p>${approvalPill(a)} <span class="pa-when">Asked ${whenAt(a.created_at, nowMs)}${a.agent_id ? html` by <a href="/me/agents/${a.agent_id}">${agentName}</a>` : ''}${a.task_id ? html` in <a href="/tasks/${a.task_id}">this run</a>` : ''}${a.state === 'pending' && a.expires_at ? html` · expires ${whenAt(a.expires_at, nowMs)}` : ''}</span></p>
++${floor.length ? html`<p class="pa-caution" role="note">${floor.map((c) => CAUTION[c]).join(' ')}</p>` : ''}
${a.kind === 'actor.action'
-- ? html`<section class="pa-panel"><h2>What it would do</h2><p>${a.summary}</p><pre class="pa-pre">${JSON.stringify(p, null, 2).slice(0, 8000)}</pre></section>`
++ ? html`<section class="pa-panel"><h2>What it would do</h2><p>${a.summary}</p>
++${cmd ? html`<p class="pa-cmd-where">${where ? html`On <b>${where}</b>, it runs:` : 'It runs:'}</p><pre class="pa-pre pa-cmd">${cmd}</pre>${p && p.cwd ? html`<p class="pa-hint">In ${String(p.cwd).slice(0, 300)}</p>` : ''}` : ''}
++<h3 class="pa-sub">The exact request</h3><pre class="pa-pre">${JSON.stringify(p, null, 2).slice(0, 8000)}</pre></section>`
: html`<section class="pa-panel"><h2>What it would do</h2><p>Start a coding task on your own machine through <a href="https://openvibe.codes/run">OpenVibe.Codes</a> with this request. You get the summary, the changed files and the patch; it pushes nowhere unless the request asks for a branch and your machine holds a GitHub token.</p>
<pre class="pa-pre">${String(p.task || '').slice(0, 8000)}</pre></section>`}
${a.state === 'pending' ? html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">${a.kind === 'actor.action' ? 'Approve for a retry' : 'Approve and start it'}</button> <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>`
ddiff --git a/test/approvals.test.js b/test/approvals.test.js
iindex 3e65139..4b1cd1b 100644
---- a/test/approvals.test.js
++++ b/test/approvals.test.js
@@@ -123,6 +123,23 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
assert.match(deleteResult.answer, /Nothing has run: approve or decline it at https:\/\/openvibe\.actor\/me\/approvals\/apr_/);
assert.strictEqual(codesCalls.length, before, 'a deletion never runs without its owner, even at full access');
});
++ await check('an approval page says the floor first, the command as one line and the agent by its name', async () => {
++ const contacts = require('../server/agents/contacts');
++ const owner = `user:${ada.subject}`;
++ const helper = (await contacts.create(t.ctx.s, t.config, owner, { name: 'Tidy helper', level: 'ask' })).row;
++ const ask = await approvals.request(t.ctx.s, { owner, agentId: helper.id, kind: 'actor.action',
++ summary: 'Run "rm -rf /tmp/old build" on your computer', payload: { argv: ['rm', '-rf', '/tmp/old build'], machine: 'dev_x', cwd: '/home/ada' } });
++ const one = await t.get(`/me/approvals/${ask.id}`, { as: ada });
++ assert.strictEqual(one.status, 200, one.text);
++ assert.match(one.text, /class="pa-caution"[^>]*>This deletes something\. It cannot be undone\./, 'the floor first, in words');
++ assert.match(one.text, /On <b>dev_x<\/b>, it runs:/);
++ assert.ok(one.text.includes('rm -rf &#39;/tmp/old build&#39;'), 'the command as one line, the spaced path quoted (escaped in the page)');
++ assert.match(one.text, />Tidy helper<\/a>/, 'the agent by its name');
++ const asked = (one.text.match(/Asked [\s\S]{0,240}/) || [''])[0];
++ assert.match(asked, /title="[^"]+ UTC">(just now|[^<]* ago)</, `a time a person reads, the exact one in its title: ${asked}`);
++ assert.match(one.text, /expires <time[^>]*>in [67] days</);
++ assert.ok(one.text.includes('The exact request'), 'the details stay, after the plain words');
++ });
} finally {
await t.close();
}