openvibers/openvibe.bot Commits
Machines page: make a spare computer a desk host (the --desk-host install, one click away), and each machine says what desks it runs for its agents (#183)
Changes
@@@ -720,8 +720,11 @@ function renderMachinesPage({ machines = [], pairing = null }) {const c = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).computer;if (!c || typeof c !== 'object') return null;const level = m.computer_level || ((c.files || c.terminal || c.screen || {}).level) || null;++ const d = c.desk && typeof c.desk === 'object' ? c.desk : null;return { level: LEVELS[level] ? level : null, terminal: !!c.terminal, files: !!c.files, screen: !!c.screen, probe: !!c.probe, input: !!c.input,-- screenStream: !!(c.screen && c.screen.stream === true) };++ screenStream: !!(c.screen && c.screen.stream === true),++ // Desks for its agents (Desks plan): how many at once, which kinds, and whether sign-ins carry over.++ desks: d ? { max: Number.isInteger(d.max_desks) ? d.max_desks : 1, kinds: Array.isArray(d.kinds) ? d.kinds : ['desktop'], keeps: d.profiles === true } : null };};// Vibe by voice: shown for a machine whose Node runs the voice plugin (capabilities.voice).const voiceRow = (m) => {@@@ -734,13 +737,21 @@ function renderMachinesPage({ machines = [], pairing = null }) {: `<form method="post" action="/machines/${esc(m.id)}/voice" class="inline-form"><input type="hidden" name="on" value="1"><label class="sr-only" for="cw-${esc(m.id)}">Call word</label><input id="cw-${esc(m.id)}" name="call_word" maxlength="40" value="${esc(word)}" aria-label="Call word"><button type="submit" class="button quiet" aria-pressed="false">Let Vibe answer my voice here</button></form>`)+ '<span class="muted">The machine listens for its call word on its own; only what you say after it goes to Vibe, and the answer is spoken here and kept in your Vibe chats.</span></div>';};++ // "Desks for your agents: 4 at once (browser desks count as half), sign-ins kept"++ const deskWords = (d) => {++ const browser = d.kinds.includes('browser');++ const whole = d.kinds.includes('desktop');++ const kinds = browser && whole ? `${d.max} at once, or twice as many browser desks` : browser ? `${d.max * 2} browser desks at once` : `${d.max} at once`;++ return `Desks for your agents: ${kinds}${d.keeps ? ', sign-ins kept' : ''}`;++ };const computerRow = (m) => {const c = computerOf(m);if (!c) return '';const can = [c.terminal && 'terminal', c.files && 'files', c.screen && 'screen', c.input && 'mouse and keyboard'].filter(Boolean).join(', ');++ const desks = c.desks ? `<span class="muted">${esc(deskWords(c.desks))}</span>` : '';const waiting = Number.isInteger(m.computer_waiting) && m.computer_waiting > 0? ` <span class="pill warn">${m.computer_waiting} waiting for you on the machine</span>` : '';-- return `<div class="machine-computer"><span class="pill">Computer: ${esc(c.level ? LEVELS[c.level] : 'on')}</span><span class="muted">${esc(can)}</span>${waiting}`++ return `<div class="machine-computer"><span class="pill">Computer: ${esc(c.level ? LEVELS[c.level] : 'on')}</span><span class="muted">${esc(can)}</span>${desks}${waiting}`+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`+ (c.screenStream && m.online ? `<form method="post" action="/machines/${esc(m.id)}/screen"><button type="submit" class="button quiet">${m.computer_screen_stream === 'live' ? 'Watch my screen' : 'Show my screen'}</button></form>` : '')@@@ -763,6 +774,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {<li class="card"><h2>Or let your agents use this computer</h2><pre tabindex="0" class="installer"><code>${esc(pairing.installer.replace(/ --worker$/, ' --computer ask'))}</code></pre><p class="muted">Instead of running jobs, the machine's terminal, files and screen become usable by your agents, at the level you set on the machine: <code>ask</code> waits for <code>openvibe-node approve</code> there before anything runs or changes. The Node runs as your own account, never root. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md">How it works</a>.</p></li>++<li class="card"><h2>Or make a spare computer a desk host</h2>++<pre tabindex="0" class="installer"><code>${esc(pairing.installer.replace(/ --worker$/, ' --desk-host'))}</code></pre>++<p class="muted">An old laptop, a mini PC or a spare workstation becomes a place where your agents work: each task gets a desk of its own, with its own browser, and you can watch any desk and take it over. No screen, keyboard or sign-in needed on it. It runs as its own account and only for your agents; the installer adds what desks need and sizes them to its memory. Linux. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md#desk-hosts---desk-host-node-v0200">How it works</a>.</p></li><li class="card"><h2>Or enter the pairing code</h2><p><strong class="code">${esc(pairing.code)}</strong></p><p class="muted">Valid until <time datetime="${esc(pairing.expires_at)}">${esc(pairing.expires_at)}</time>. It works once.</p></li></ol><p class="muted">Reload this page once the installer says it is paired: the machine shows up above.</p>`@@@ -320,6 +320,25 @@ const { boot, check, done } = require('./helpers/app');assert.strictEqual((await t.db.maybe('SELECT agent_computer FROM devices WHERE id = $1', [mine.deviceId])).agent_computer, false);});++ await check('the machines page says what desks each machine runs, and offers to make a spare computer a desk host', async () => {++ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });++ const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true };++ const host = await machine(alex, { files: COMPUTER.files, desk: { ...desk, max_desks: 4, kinds: ['desktop', 'browser'], profiles: true } });++ const mac = await machine(alex, { ...COMPUTER, desk: { ...desk, window_manager: null, max_desks: 2, kinds: ['browser'] } });++ const html = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();++ assert.ok(html.includes('Desks for your agents: 4 at once, or twice as many browser desks, sign-ins kept'), 'a desk host');++ assert.ok(html.includes('Desks for your agents: 4 browser desks at once'), 'a Mac: browser desks only');++ const add = await fetch(`${t.base}/machines`, { method: 'POST', redirect: 'manual', body: '', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex) } });++ assert.strictEqual(add.status, 201);++ const page = await add.text();++ assert.ok(page.includes('Or make a spare computer a desk host'));++ const hostCmd = /<code>(curl -fsSL [^<]+ --desk-host)<\/code>/.exec(page);++ assert.ok(hostCmd, 'the desk host install command');++ assert.ok(!hostCmd[1].includes('--worker'), 'a desk host never runs the job worker too');++ host.c.close();++ mac.c.close();++ });++await check('Watch sends only probes, only where the owner let their watches use the computer', async () => {const watch = t.network.signService({ sub: 'svc:watch', cap: ['bot.computer.control'] });const asWatch = { token: watch, headers: { 'X-OV-Subject': alex.subject } };
@@ -720,8 +720,11 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -720,8 +720,11 @@ function renderMachinesPage({ machines = [], pairing = null }) {
const c = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).computer;
const c = (m.capabilities && typeof m.capabilities === 'object' ? m.capabilities : {}).computer;
if (!c || typeof c !== 'object') return null;
if (!c || typeof c !== 'object') return null;
const level = m.computer_level || ((c.files || c.terminal || c.screen || {}).level) || null;
const level = m.computer_level || ((c.files || c.terminal || c.screen || {}).level) || null;
+ const d = c.desk && typeof c.desk === 'object' ? c.desk : null;
return { level: LEVELS[level] ? level : null, terminal: !!c.terminal, files: !!c.files, screen: !!c.screen, probe: !!c.probe, input: !!c.input,
return { level: LEVELS[level] ? level : null, terminal: !!c.terminal, files: !!c.files, screen: !!c.screen, probe: !!c.probe, input: !!c.input,
- screenStream: !!(c.screen && c.screen.stream === true) };
+ screenStream: !!(c.screen && c.screen.stream === true),
+ // Desks for its agents (Desks plan): how many at once, which kinds, and whether sign-ins carry over.
+ desks: d ? { max: Number.isInteger(d.max_desks) ? d.max_desks : 1, kinds: Array.isArray(d.kinds) ? d.kinds : ['desktop'], keeps: d.profiles === true } : null };
};
};
// Vibe by voice: shown for a machine whose Node runs the voice plugin (capabilities.voice).
// Vibe by voice: shown for a machine whose Node runs the voice plugin (capabilities.voice).
const voiceRow = (m) => {
const voiceRow = (m) => {
@@ -734,13 +737,21 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -734,13 +737,21 @@ function renderMachinesPage({ machines = [], pairing = null }) {
: `<form method="post" action="/machines/${esc(m.id)}/voice" class="inline-form"><input type="hidden" name="on" value="1"><label class="sr-only" for="cw-${esc(m.id)}">Call word</label><input id="cw-${esc(m.id)}" name="call_word" maxlength="40" value="${esc(word)}" aria-label="Call word"><button type="submit" class="button quiet" aria-pressed="false">Let Vibe answer my voice here</button></form>`)
: `<form method="post" action="/machines/${esc(m.id)}/voice" class="inline-form"><input type="hidden" name="on" value="1"><label class="sr-only" for="cw-${esc(m.id)}">Call word</label><input id="cw-${esc(m.id)}" name="call_word" maxlength="40" value="${esc(word)}" aria-label="Call word"><button type="submit" class="button quiet" aria-pressed="false">Let Vibe answer my voice here</button></form>`)
+ '<span class="muted">The machine listens for its call word on its own; only what you say after it goes to Vibe, and the answer is spoken here and kept in your Vibe chats.</span></div>';
+ '<span class="muted">The machine listens for its call word on its own; only what you say after it goes to Vibe, and the answer is spoken here and kept in your Vibe chats.</span></div>';
};
};
+ // "Desks for your agents: 4 at once (browser desks count as half), sign-ins kept"
+ const deskWords = (d) => {
+ const browser = d.kinds.includes('browser');
+ const whole = d.kinds.includes('desktop');
+ const kinds = browser && whole ? `${d.max} at once, or twice as many browser desks` : browser ? `${d.max * 2} browser desks at once` : `${d.max} at once`;
+ return `Desks for your agents: ${kinds}${d.keeps ? ', sign-ins kept' : ''}`;
+ };
const computerRow = (m) => {
const computerRow = (m) => {
const c = computerOf(m);
const c = computerOf(m);
if (!c) return '';
if (!c) return '';
const can = [c.terminal && 'terminal', c.files && 'files', c.screen && 'screen', c.input && 'mouse and keyboard'].filter(Boolean).join(', ');
const can = [c.terminal && 'terminal', c.files && 'files', c.screen && 'screen', c.input && 'mouse and keyboard'].filter(Boolean).join(', ');
+ const desks = c.desks ? `<span class="muted">${esc(deskWords(c.desks))}</span>` : '';
const waiting = Number.isInteger(m.computer_waiting) && m.computer_waiting > 0
const waiting = Number.isInteger(m.computer_waiting) && m.computer_waiting > 0
? ` <span class="pill warn">${m.computer_waiting} waiting for you on the machine</span>` : '';
? ` <span class="pill warn">${m.computer_waiting} waiting for you on the machine</span>` : '';
- return `<div class="machine-computer"><span class="pill">Computer: ${esc(c.level ? LEVELS[c.level] : 'on')}</span><span class="muted">${esc(can)}</span>${waiting}`
+ return `<div class="machine-computer"><span class="pill">Computer: ${esc(c.level ? LEVELS[c.level] : 'on')}</span><span class="muted">${esc(can)}</span>${desks}${waiting}`
+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`
+ `<form method="post" action="/machines/${esc(m.id)}/agent-computer"><input type="hidden" name="on" value="${m.agent_computer ? '0' : '1'}">`
+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`
+ `<button type="submit" class="button quiet" aria-pressed="${m.agent_computer ? 'true' : 'false'}">${m.agent_computer ? 'Stop my agent using this computer' : 'Let my agent use this computer'}</button></form>`
+ (c.screenStream && m.online ? `<form method="post" action="/machines/${esc(m.id)}/screen"><button type="submit" class="button quiet">${m.computer_screen_stream === 'live' ? 'Watch my screen' : 'Show my screen'}</button></form>` : '')
+ (c.screenStream && m.online ? `<form method="post" action="/machines/${esc(m.id)}/screen"><button type="submit" class="button quiet">${m.computer_screen_stream === 'live' ? 'Watch my screen' : 'Show my screen'}</button></form>` : '')
@@ -763,6 +774,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {
@@ -763,6 +774,9 @@ function renderMachinesPage({ machines = [], pairing = null }) {
<li class="card"><h2>Or let your agents use this computer</h2>
<li class="card"><h2>Or let your agents use this computer</h2>
<pre tabindex="0" class="installer"><code>${esc(pairing.installer.replace(/ --worker$/, ' --computer ask'))}</code></pre>
<pre tabindex="0" class="installer"><code>${esc(pairing.installer.replace(/ --worker$/, ' --computer ask'))}</code></pre>
<p class="muted">Instead of running jobs, the machine's terminal, files and screen become usable by your agents, at the level you set on the machine: <code>ask</code> waits for <code>openvibe-node approve</code> there before anything runs or changes. The Node runs as your own account, never root. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md">How it works</a>.</p></li>
<p class="muted">Instead of running jobs, the machine's terminal, files and screen become usable by your agents, at the level you set on the machine: <code>ask</code> waits for <code>openvibe-node approve</code> there before anything runs or changes. The Node runs as your own account, never root. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md">How it works</a>.</p></li>
+<li class="card"><h2>Or make a spare computer a desk host</h2>
+<pre tabindex="0" class="installer"><code>${esc(pairing.installer.replace(/ --worker$/, ' --desk-host'))}</code></pre>
+<p class="muted">An old laptop, a mini PC or a spare workstation becomes a place where your agents work: each task gets a desk of its own, with its own browser, and you can watch any desk and take it over. No screen, keyboard or sign-in needed on it. It runs as its own account and only for your agents; the installer adds what desks need and sizes them to its memory. Linux. <a href="https://github.com/OpenVibers/OpenVibe.Node/blob/main/docs/computer.md#desk-hosts---desk-host-node-v0200">How it works</a>.</p></li>
<li class="card"><h2>Or enter the pairing code</h2><p><strong class="code">${esc(pairing.code)}</strong></p>
<li class="card"><h2>Or enter the pairing code</h2><p><strong class="code">${esc(pairing.code)}</strong></p>
<p class="muted">Valid until <time datetime="${esc(pairing.expires_at)}">${esc(pairing.expires_at)}</time>. It works once.</p></li></ol>
<p class="muted">Valid until <time datetime="${esc(pairing.expires_at)}">${esc(pairing.expires_at)}</time>. It works once.</p></li></ol>
<p class="muted">Reload this page once the installer says it is paired: the machine shows up above.</p>`
<p class="muted">Reload this page once the installer says it is paired: the machine shows up above.</p>`
@@ -320,6 +320,25 @@ const { boot, check, done } = require('./helpers/app');
@@ -320,6 +320,25 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual((await t.db.maybe('SELECT agent_computer FROM devices WHERE id = $1', [mine.deviceId])).agent_computer, false);
assert.strictEqual((await t.db.maybe('SELECT agent_computer FROM devices WHERE id = $1', [mine.deviceId])).agent_computer, false);
});
});
+ await check('the machines page says what desks each machine runs, and offers to make a spare computer a desk host', async () => {
+ const cookie = (user) => ({ Cookie: `ov_token=${t.network.signUser(user)}` });
+ const desk = { level: 'trusted', window_manager: 'openbox', input: true, screen: true, named: true };
+ const host = await machine(alex, { files: COMPUTER.files, desk: { ...desk, max_desks: 4, kinds: ['desktop', 'browser'], profiles: true } });
+ const mac = await machine(alex, { ...COMPUTER, desk: { ...desk, window_manager: null, max_desks: 2, kinds: ['browser'] } });
+ const html = await (await fetch(`${t.base}/machines`, { headers: cookie(alex) })).text();
+ assert.ok(html.includes('Desks for your agents: 4 at once, or twice as many browser desks, sign-ins kept'), 'a desk host');
+ assert.ok(html.includes('Desks for your agents: 4 browser desks at once'), 'a Mac: browser desks only');
+ const add = await fetch(`${t.base}/machines`, { method: 'POST', redirect: 'manual', body: '', headers: { 'Content-Type': 'application/x-www-form-urlencoded', ...cookie(alex) } });
+ assert.strictEqual(add.status, 201);
+ const page = await add.text();
+ assert.ok(page.includes('Or make a spare computer a desk host'));
+ const hostCmd = /<code>(curl -fsSL [^<]+ --desk-host)<\/code>/.exec(page);
+ assert.ok(hostCmd, 'the desk host install command');
+ assert.ok(!hostCmd[1].includes('--worker'), 'a desk host never runs the job worker too');
+ host.c.close();
+ mac.c.close();
+ });
+
await check('Watch sends only probes, only where the owner let their watches use the computer', async () => {
await check('Watch sends only probes, only where the owner let their watches use the computer', async () => {
const watch = t.network.signService({ sub: 'svc:watch', cap: ['bot.computer.control'] });
const watch = t.network.signService({ sub: 'svc:watch', cap: ['bot.computer.control'] });
const asWatch = { token: watch, headers: { 'X-OV-Subject': alex.subject } };
const asWatch = { token: watch, headers: { 'X-OV-Subject': alex.subject } };