openvibers/openvibe.bot Commits

Bot: browser — the agent's own browser on a person's machine, audited by host and length only (contracts 0.174.0) (#106)

OP OpenVibers committed 29 days ago · 67abdc346a · 6 files, +31 −9

Plan T14/T17 next step: the browser kind (contracts 0.174.0, OpenVibe.Node#46).

- `browser` is a computer kind (`COMPUTER_CAP.browser`). A machine whose computer plugin offers a browser takes it from its owner, or from their agent once the owner let the agent use the computer. Watch still sends only `probe`.
- **Audit.** The audit keeps the action, the host of an `open` (never its path or query, which may hold tokens), the selector, the key, and the length of what was filled (never the text).
- **The machines page** lists the `browser` capability beside terminal, files, screen and input.
- **Pins:** contracts v0.174.0. Its schema refuses a non-http(s) `open` before anything leaves Bot.

Tests: `machine-computer.test.js` adds the browser check (it reaches the machine, `file:` is refused with 422, and the audit keeps the host and length only). `npm test`: 40/40.

Changes

ddiff --git a/STATUS.json b/STATUS.json
iindex 35afc67..73f84bb 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -5,7 +5,7 @@
"runtime": "node 22, express 4, ws; PostgreSQL 18 via openvibe-sdk/db (pg, or embedded PGlite in tests); Valkey optional; no build step",
"port": 4630,
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
-- "contracts": "openvibe-contracts v0.171.0",
++ "contracts": "openvibe-contracts v0.174.0",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.1.3",
"packages": [
ddiff --git a/package-lock.json b/package-lock.json
iindex 6919324..746ff95 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.171.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.174.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
@@@ -845,9 +845,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.171.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.171.0",
-- "integrity": "sha512-GtmDgY/zee9u3qfVOLoh0YA4KEMAqO/HDjEg/wlKtpHSJNmoJnVR669THVVtSE4WK7ro8VJ+b0v/yj+iboch+Q==",
++ "version": "0.174.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.174.0",
++ "integrity": "sha512-EN8iOkrAuOBCLP9sfnLw6HNz9152TNERk7tEY7upLpNoTGkbFPF6JXfrCNoqGDYU0T/HRz5XZaC7ImPAdr8fPw==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex ecaf423..0f0a8c1 100644
---- a/package.json
++++ b/package.json
@@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.171.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.174.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
ddiff --git a/server/api/v1.js b/server/api/v1.js
iindex a166bbf..75a5b61 100644
---- a/server/api/v1.js
++++ b/server/api/v1.js
@@@ -399,7 +399,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer, watch_probe: !!device.watch_probe,
computer: offered ? {
level: typeof sensors.computer_level === 'string' ? sensors.computer_level : ((pick('files') || pick('terminal') || pick('screen') || {}).level || null),
-- terminal: pick('terminal'), files: pick('files'), screen: pick('screen'), probe: pick('probe'), input: pick('input'),
++ terminal: pick('terminal'), files: pick('files'), screen: pick('screen'), probe: pick('probe'), input: pick('input'), browser: pick('browser'),
waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
} : null,
};
ddiff --git a/server/realtime.js b/server/realtime.js
iindex a8b40bf..c980059 100644
---- a/server/realtime.js
++++ b/server/realtime.js
@@@ -39,7 +39,7 @@ const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leave
const DEVICE_MAX_PAYLOAD = 640 * 1024;
// The Node's computer plugin (plan T14/T17): the kinds a person's machine takes, the capability each needs in its
// status, and the reply window (the owner's approval on the machine plus the program's own run).
--const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input' };
++const COMPUTER_CAP = { exec: 'terminal', fs: 'files', screen: 'screen', probe: 'probe', input: 'input', browser: 'browser' };
const COMPUTER_KINDS = Object.keys(COMPUTER_CAP);
const COMPUTER_DEFAULT_MS = 60000;
const COMPUTER_MAX_MS = 600000;
@@@ -50,6 +50,13 @@ function computerSummary(kind, value) {
if (kind === 'probe') { try { const u = new URL(value.target); return { scheme: u.protocol.replace(':', ''), host: u.host }; } catch { return { scheme: null }; } }
// Typed text can be anything (a password the person asked for): the audit keeps its length, never the text.
if (kind === 'input') return { action: value.action, ...(value.x != null ? { x: value.x, y: value.y } : {}), ...(typeof value.text === 'string' ? { chars: value.text.length } : {}), ...(value.keys ? { keys: value.keys } : {}), ...(value.dy ? { dy: value.dy } : {}) };
++ // The browser: where it went (the host only, never a path or query), what it clicked, how much was typed.
++ if (kind === 'browser') {
++ let host = null;
++ if (typeof value.url === 'string') { try { host = new URL(value.url).host; } catch { host = null; } }
++ return { action: value.action, ...(host ? { host } : {}), ...(typeof value.selector === 'string' ? { selector: value.selector.slice(0, 120) } : {}),
++ ...(value.action === 'fill' && typeof value.text === 'string' ? { chars: value.text.length } : {}), ...(value.key ? { key: value.key } : {}) };
++ }
return { max_width: value.max_width || null };
}
ddiff --git a/test/machine-computer.test.js b/test/machine-computer.test.js
iindex 3a97240..c9b886b 100644
---- a/test/machine-computer.test.js
++++ b/test/machine-computer.test.js
@@@ -14,7 +14,7 @@ const { boot, check, done } = require('./helpers/app');
let seq = 0;
const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });
const poll = async (fn, ms = 8000) => { const end = Date.now() + ms; while (Date.now() < end) { const v = await fn(); if (v) return v; await t.wait(30); } return null; };
-- const COMPUTER = { terminal: { level: 'trusted', timeout_s: 30 }, files: { level: 'trusted', roots: ['/home/alex'] }, screen: { level: 'trusted', tool: 'grim' }, probe: { lan_only: true }, input: { level: 'trusted', tool: 'xdotool', scroll: true } };
++ const COMPUTER = { terminal: { level: 'trusted', timeout_s: 30 }, files: { level: 'trusted', roots: ['/home/alex'] }, screen: { level: 'trusted', tool: 'grim' }, probe: { lan_only: true }, input: { level: 'trusted', tool: 'xdotool', scroll: true }, browser: { level: 'trusted', browser: 'chromium', headless: true } };
const seen = [];
/** A Network-paired machine of `owner` whose fake Node answers computer commands like the plugin would. */
@@@ -33,6 +33,7 @@ const { boot, check, done } = require('./helpers/app');
else if (m.kind === 'fs') c.send(frame('ack', { id: m.id, data: { path: m.value.path, entries: [], total: 0, truncated: false } }));
else if (m.kind === 'probe') c.send(frame('ack', { id: m.id, data: { status: 200, body: '{"up":true}', ms: 3 } }));
else if (m.kind === 'input') c.send(frame('ack', { id: m.id, data: { done: true, action: m.value.action } }));
++ else if (m.kind === 'browser') c.send(frame('ack', { id: m.id, data: { url: m.value.url || 'https://example.org/', title: 'Example', text: 'Example page', truncated: false, links: [] } }));
else c.send(frame('nack', { id: m.id, fault_code: 'not_allowed', message: 'this machine allows observing only' }));
});
await poll(() => t.hub.computerOf(deviceId) !== null || !computer);
@@@ -75,6 +76,20 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual([shell.status, shell.json.code], [422, 'bot.invalid_input']);
});
++ await check('the browser kind reaches the machine; a file: address is refused before it leaves', async () => {
++ const open = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'open', url: 'https://example.org/account?token=abc' } }, { user: alex });
++ assert.deepStrictEqual([open.status, open.json.result, open.json.data.title], [200, 'ack', 'Example']);
++ const fill = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'fill', selector: '#password', text: 'hunter3 secret' } }, { user: alex });
++ assert.strictEqual(fill.json.result, 'ack');
++ assert.deepStrictEqual(seen.filter((m) => m.kind === 'browser').map((m) => m.value.action), ['open', 'fill']);
++ const file = await cmd(mine.deviceId, { kind: 'browser', value: { action: 'open', url: 'file:///etc/passwd' } }, { user: alex });
++ assert.deepStrictEqual([file.status, file.json.code], [422, 'bot.invalid_input']);
++ const rows = await t.db.many("SELECT * FROM command_audit WHERE robot_id = $1 AND kind = 'browser' AND result = 'ack' ORDER BY id", [mine.deviceId]);
++ assert.deepStrictEqual(rows.map((r) => r.value), [{ action: 'open', host: 'example.org' }, { action: 'fill', selector: '#password', chars: 14 }],
++ 'the audit keeps the host (never the path or query) and how much was typed (never the text)');
++ assert.ok(!JSON.stringify(rows).includes('hunter3') && !JSON.stringify(rows).includes('token=abc'));
++ });
++
await check('the audit keeps what was asked in short, never stdin, arguments or content', async () => {
const rows = await t.db.many('SELECT * FROM command_audit WHERE robot_id = $1 ORDER BY id', [mine.deviceId]);
assert.ok(rows.length >= 3);