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)
Changes
@@@ -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": [@@@ -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",@@@ -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",@@@ -399,7 +399,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, submid: 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,};@@@ -39,7 +39,7 @@ const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leaveconst 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 };}@@@ -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);
@@ -5,7 +5,7 @@
@@ -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",
"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,
"port": 4630,
"domain": "openvibe.bot (www -> apex; /device and /control WebSockets; /install)",
"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",
"sdk": "openvibe-sdk v0.44.0",
"shared": "openvibe-shared v4.1.3",
"shared": "openvibe-shared v4.1.3",
"packages": [
"packages": [
@@ -13,7 +13,7 @@
@@ -13,7 +13,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -845,9 +845,9 @@
@@ -845,9 +845,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -25,7 +25,7 @@
@@ -25,7 +25,7 @@
"dotenv": "^16.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express": "^4.21.2",
"iovalkey": "^0.4.0",
"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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -399,7 +399,7 @@ function v1Router({ domain, apiAuth, limits, hub, jobs: jobService, config, subm
@@ -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,
id: device.id, name: device.name || null, online: !!st.online, agent_computer: !!device.agent_computer, watch_probe: !!device.watch_probe,
computer: offered ? {
computer: offered ? {
level: typeof sensors.computer_level === 'string' ? sensors.computer_level : ((pick('files') || pick('terminal') || pick('screen') || {}).level || null),
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,
waiting: Number.isInteger(sensors.computer_waiting) ? sensors.computer_waiting : null,
} : null,
} : null,
};
};
@@ -39,7 +39,7 @@ const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leave
@@ -39,7 +39,7 @@ const WATCH_MAX_PAYLOAD = 4 * 1024; // a watcher only ever sends join/leave
const DEVICE_MAX_PAYLOAD = 640 * 1024;
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
// 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).
// 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_KINDS = Object.keys(COMPUTER_CAP);
const COMPUTER_DEFAULT_MS = 60000;
const COMPUTER_DEFAULT_MS = 60000;
const COMPUTER_MAX_MS = 600000;
const COMPUTER_MAX_MS = 600000;
@@ -50,6 +50,13 @@ function computerSummary(kind, value) {
@@ -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 }; } }
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.
// 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 } : {}) };
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 };
return { max_width: value.max_width || null };
}
}
@@ -14,7 +14,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -14,7 +14,7 @@ const { boot, check, done } = require('./helpers/app');
let seq = 0;
let seq = 0;
const frame = (type, fields) => ({ v: 1, seq: ++seq, ts: Date.now(), type, ...fields });
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 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 = [];
const seen = [];
/** A Network-paired machine of `owner` whose fake Node answers computer commands like the plugin would. */
/** 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');
@@ -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 === '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 === '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 === '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' }));
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);
await poll(() => t.hub.computerOf(deviceId) !== null || !computer);
@@ -75,6 +76,20 @@ const { boot, check, done } = require('./helpers/app');
@@ -75,6 +76,20 @@ const { boot, check, done } = require('./helpers/app');
assert.deepStrictEqual([shell.status, shell.json.code], [422, 'bot.invalid_input']);
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 () => {
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]);
const rows = await t.db.many('SELECT * FROM command_audit WHERE robot_id = $1 ORDER BY id', [mine.deviceId]);
assert.ok(rows.length >= 3);
assert.ok(rows.length >= 3);