openvibers/openvibe.actor Commits
Actor: a person's own computer as runtime tools (list, read, run, write through Bot, at the level they set on the machine) (#53)
Changes
@@@ -102,6 +102,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu- **OpenVibe.Search** (`ACTOR_SEARCH_URL`): content across the network, for the runtime's `search_openvibe` tool.- **OpenVibe.Tools** (`ACTOR_TOOLS_URL`): the catalog, the run API and the page reader, for `find_tools`, `run_tool` and `read_page`. Called as any anonymous caller would.- **OpenVibe.Bot** (`ACTOR_BOT_URL`, default `http://127.0.0.1:4630`; empty turns it off): a signed-in person's own robots, for `list_my_robots` and `robot_steps`. Actor calls Bot with its own client-credentials token (audience `openvibe.bot`, `bot.robot.read` + `bot.robot.control`) and `X-OV-Subject` naming the person. Bot answers only for the robot's owner, and only on robots where that owner turned on "Let my OpenVibe.Actor agent drive this robot". Bot's gate, limits and e-stop judge every step. App, agent and service tasks never see these tools.++- **A person's own computers, also through OpenVibe.Bot** (plan T14/T17, contracts 0.166.0): `list_my_computers`, `computer_list`, `computer_read`, `computer_run` (argv, never a shell line) and `computer_write`, on machines installed with OpenVibe Node's `--computer` plugin. Actor calls Bot's machine API with its own token (`bot.computer.control`) and `X-OV-Subject` naming the person. Bot answers only for the machine's owner, and only where that owner turned on "Let my agent use this computer". The level the owner set on the machine decides what may happen there; at `ask`, each run or write waits up to two minutes for the owner's approval on the machine. Only a signed-in person's task sees these tools (`server/agents/computers.js`, `test/computers.test.js`).- **OpenVibe.Events** (`ACTOR_EVENTS_URL`): the two ADR-033 subscriptions created at boot, and delivery to the loopback `/internal/events`.- **The model providers**: DeepSeek (`ACTOR_DEEPSEEK_API_KEY`, `api.deepseek.com`), OpenAI (`ACTOR_OPENAI_API_KEY`, `api.openai.com/v1`) for the web-search agent and the cross-family check, and an OpenAI-compatible local server (`ACTOR_LOCAL_LLM_URL`) for private mode.@@@ -20,9 +20,10 @@ const MAX_TURNS = 10;const today = () => new Date().toISOString().slice(0, 10);--const RUNTIME_SYSTEM = (robots = false) => `You are OpenVibe Actor's runtime: an agent that completes a person's task by acting through OpenVibe's services.++const RUNTIME_SYSTEM = (robots = false, computers = false) => `You are OpenVibe Actor's runtime: an agent that completes a person's task by acting through OpenVibe's services.Today is ${today()}. Your tools are OpenVibe's public APIs: search_openvibe (content on the OpenVibe network), find_tools then run_tool (OpenVibe.Tools: DNS, mail records, certificates, HTTP headers, WHOIS, conversions and 150 more), and read_page when it is offered.${robots ? `--The person's own robots on OpenVibe.Bot: list_my_robots, then robot_steps with step lines that robot accepts. Act on a robot only when the task asks for it, keep motions short, and report what the robot answered (done, or stopped and why). If a robot is not opened to you, say how the person opens it.` : ''}++The person's own robots on OpenVibe.Bot: list_my_robots, then robot_steps with step lines that robot accepts. Act on a robot only when the task asks for it, keep motions short, and report what the robot answered (done, or stopped and why). If a robot is not opened to you, say how the person opens it.` : ''}${computers ? `++The person's own computers: list_my_computers, then computer_list, computer_read, computer_run (argv: a program and its arguments, never a shell line) and computer_write. Use them only when the task asks about something on the person's computer; read before you change anything; never delete, never touch credentials or keys, and say exactly what you ran or wrote. At level "ask" the person approves each run or write on the machine: tell them when you are waiting.` : ''}Rules: use a tool whenever a fact can be checked with one instead of guessing; never invent a tool result, a URL or a number; if something cannot be found, say so plainly.Finish with the answer itself in Markdown, short and direct, then a "Sources" list of the links or tools you relied on (tool results as "OpenVibe.Tools <tool id>").`;@@@ -32,7 +33,8 @@ Never invent facts, numbers or links; if something cannot be found, say so.`;const summarize = (args) => {const a = args || {};-- const v = a.query || a.need || a.url || (a.tool ? `${a.tool} ${JSON.stringify(a.input || {})}` : a.robot ? `${a.robot}: ${[].concat(a.steps || []).join(', ')}` : JSON.stringify(a));++ const v = a.query || a.need || a.url || (a.tool ? `${a.tool} ${JSON.stringify(a.input || {})}` : a.robot ? `${a.robot}: ${[].concat(a.steps || []).join(', ')}`++ : a.machine ? `${a.machine}: ${Array.isArray(a.argv) ? a.argv.join(' ') : a.path || ''}` : JSON.stringify(a));return String(v).slice(0, 300);};@@@ -50,7 +52,8 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = conif (!deepseek) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });const tools = await ovTools.definitions({ signal, requester });const robots = tools.some((d) => d.function && d.function.name === 'robot_steps');-- const messages = [{ role: 'system', content: RUNTIME_SYSTEM(robots) }, { role: 'user', content: task }];++ const computers = tools.some((d) => d.function && d.function.name === 'computer_run');++ const messages = [{ role: 'system', content: RUNTIME_SYSTEM(robots, computers) }, { role: 'user', content: task }];let last = '';for (let turn = 1; turn <= MAX_TURNS; turn++) {if (budgetLeft() <= 0) throw new ProviderError('the task reached its budget before the runtime finished', { code: 'actor.budget.exceeded' });nnew file mode 100644@@@ -0,0 +1,126 @@++'use strict';++++/**++ * The person's own computers on OpenVibe.Bot, as runtime tools (plan T14/T17): OpenVibe.Node's computer plugin, reached++ * only through Bot. Actor calls Bot's public API with its own service token (audience openvibe.bot,++ * bot.computer.control) and X-OV-Subject naming the person the task is for. Bot answers only for the machine's owner,++ * and only where that owner turned on "Let my agent use this computer"; the level the owner set on the machine itself++ * decides what may happen there (observe, ask — which waits for their approval on the machine — trusted, full).++ *++ * createComputers({ config, fetchImpl, tokens? }) → { enabled, mine(subject), list, read, run, write }++ * mine → [{ id, name, online, level, can: [terminal|files|screen], folders, waiting }] or { error }++ * list → { path, entries: [{ name, type, size }], truncated } (a folder)++ * read → { path, size, text | bytes, truncated } (a file, as text when it is UTF-8)++ * run → { exit_code, stdout, stderr, truncated, timed_out } (a program and its arguments, never a shell line)++ * write → { path, written } (create, overwrite or append)++ *++ * Off (enabled false) without ACTOR_BOT_URL or without Actor's client secret.++ */++const { serviceAuth } = require('openvibe-contracts');++++const MACHINE_ID = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++// A command waits on the machine for its owner's approval at level "ask", then runs: two minutes, inside a task's three.++const ACT_WINDOW_MS = 120_000;++const READ_WINDOW_MS = 30_000;++const MAX_READ = 64 * 1024;++++function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensOverride = null }) {++ const base = config.services.botUrl;++ const pages = config.services.botPublicUrl || base;++ const enabled = !!(base && (tokensOverride || config.oauth.clientSecret));++ const tokens = !enabled ? null : tokensOverride || serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.bot', scope: 'bot.computer.control', fetchImpl,++ });++++ async function call(method, path, subject, { body, signal, timeoutMs = 15_000 } = {}) {++ const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);++ const res = await fetchImpl(`${base}/api/v1${path}`, {++ method,++ headers: { Accept: 'application/json', 'X-OV-Subject': subject, ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) },++ body: body ? JSON.stringify(body) : undefined, signal: sig,++ });++ const data = await res.json().catch(() => null);++ return { status: res.status, ok: res.ok, data };++ }++ const problem = (r, what) => ({ error: (r.data && (r.data.detail || r.data.title)) || `Bot answered ${r.status} for ${what}`, code: (r.data && r.data.code) || null });++ const guard = (subject, machine) => {++ if (!USER_SUBJECT.test(subject)) return { error: 'computers are only for a signed-in person' };++ if (!MACHINE_ID.test(String(machine || ''))) return { error: 'machine must be a machine id from list_my_computers (dev_…)' };++ return null;++ };++ /** One command through Bot; the machine's answer, or its refusal (a nack's reason), as an error. */++ async function command(subject, machine, kind, value, { signal, windowMs }) {++ const r = await call('POST', `/machines/${machine}/commands`, subject, { body: { kind, value, ms: windowMs }, signal, timeoutMs: windowMs + 10_000 });++ if (!r.ok) return problem(r, `the ${kind} command`);++ if (r.data.result === 'ack') return { data: r.data.data || {} };++ if (r.data.result === 'expired') return { error: 'the machine did not answer in time (at level "ask", nobody approved it there)' };++ return { error: r.data.reason || `the machine refused it (${r.data.result})` };++ }++++ async function mine(subject, { signal } = {}) {++ if (!USER_SUBJECT.test(subject)) return { error: 'computers are only for a signed-in person' };++ const r = await call('GET', '/machines', subject, { signal });++ if (!r.ok) return problem(r, 'your computers');++ const machines = ((r.data && r.data.machines) || []).filter((m) => m.computer).map((m) => ({++ id: m.id, name: m.name, online: m.online, level: m.computer.level,++ can: ['terminal', 'files', 'screen'].filter((k) => m.computer[k]),++ folders: (m.computer.files && m.computer.files.roots) || [],++ ...(m.computer.waiting ? { waiting_for_approval: m.computer.waiting } : {}),++ }));++ if (!machines.length) return { computers: [], note: `No computer is open to the agent. On ${pages}/machines the person adds one with --computer (for example "ask") and turns on "Let my agent use this computer".` };++ return { computers: machines, note: 'At level "observe" only reading works; at "ask" each run or write waits for the person to approve it on that machine.' };++ }++++ async function list(subject, machine, path, { signal } = {}) {++ const bad = guard(subject, machine);++ if (bad) return bad;++ const r = await command(subject, machine, 'fs', { op: 'list', path: String(path || '') }, { signal, windowMs: READ_WINDOW_MS });++ if (r.error) return r;++ return { path: r.data.path, entries: (r.data.entries || []).slice(0, 300).map((e) => ({ name: e.name, type: e.type, size: e.size })), truncated: !!r.data.truncated || (r.data.entries || []).length > 300 };++ }++++ async function read(subject, machine, path, { offset = 0, signal } = {}) {++ const bad = guard(subject, machine);++ if (bad) return bad;++ const off = Number.isInteger(offset) && offset > 0 ? offset : 0;++ const r = await command(subject, machine, 'fs', { op: 'read', path: String(path || ''), offset: off, length: MAX_READ }, { signal, windowMs: READ_WINDOW_MS });++ if (r.error) return r;++ const bytes = Buffer.from(String(r.data.content_b64 || ''), 'base64');++ const text = bytes.toString('utf8');++ const binary = text.includes('�') || /[\u0000-\u0008\u000e-\u001f]/.test(text);++ return { path: r.data.path, size: r.data.size, offset: off, ...(binary ? { bytes: bytes.length, note: 'not text' } : { text }), truncated: !!r.data.truncated };++ }++++ async function run(subject, machine, argv, { cwd = null, timeoutS = null, signal } = {}) {++ const bad = guard(subject, machine);++ if (bad) return bad;++ if (!Array.isArray(argv) || !argv.length || argv.length > 64 || !argv.every((a) => typeof a === 'string' && a.length && a.length <= 4096)) {++ return { error: 'argv is a program and its arguments, for example ["git", "status"]: never a shell line' };++ }++ const value = { argv };++ if (cwd) value.cwd = String(cwd);++ if (Number.isInteger(timeoutS) && timeoutS > 0) value.timeout_s = Math.min(timeoutS, 600);++ const r = await command(subject, machine, 'exec', value, { signal, windowMs: ACT_WINDOW_MS });++ if (r.error) return r;++ const d = r.data;++ return { exit_code: d.exit_code, stdout: d.stdout, stderr: d.stderr, truncated: !!d.truncated, timed_out: !!d.timed_out };++ }++++ async function write(subject, machine, path, text, { mode = 'create', signal } = {}) {++ const bad = guard(subject, machine);++ if (bad) return bad;++ if (typeof text !== 'string') return { error: 'text is the file\'s content' };++ const content = Buffer.from(text, 'utf8');++ if (content.length > 256 * 1024) return { error: 'at most 256 KB per write' };++ if (!['create', 'overwrite', 'append'].includes(mode)) return { error: 'mode is create, overwrite or append' };++ const r = await command(subject, machine, 'fs', { op: 'write', path: String(path || ''), content_b64: content.toString('base64'), mode }, { signal, windowMs: ACT_WINDOW_MS });++ if (r.error) return r;++ return { path: r.data.path, written: r.data.written };++ }++++ return { enabled, mine, list, read, run, write };++}++++module.exports = { createComputers };@@@ -12,12 +12,19 @@* list_my_robots() OpenVibe.Bot: the person's own robots, and for each one they opened to their agent its* state and the step lines it takes (agents/robots.js) — a signed-in person's task only* robot_steps(robot, steps) OpenVibe.Bot: run step lines on one of those robots, as the person, through Bot's gate++ * list_my_computers() OpenVibe.Bot: the person's own computers open to their agent (agents/computers.js), with++ * the level the person set on each machine++ * computer_list / computer_read / computer_run / computer_write++ * a folder, a file, a program (argv, never a shell line) or a write on one of them, as the++ * person, through Bot; at level "ask" each run or write waits for the person's approval++ * on the machine itself** Tools' own guard decides what a tool may fetch (public addresses only, per-target limits); Actor adds nothing to* what an anonymous caller could already do, and every result is clipped before it goes back to the model.*/const { createRobots } = require('./robots');++const { createComputers } = require('./computers');const CATALOG_TTL_MS = 10 * 60_000;const RESULT_CHARS = 6000;@@@ -28,9 +35,10 @@ const json = (v) => clip(JSON.stringify(v));/** The person a task is for, when it is a signed-in person ('user:usr_…' → 'usr_…'), else null. */const personOf = (requester) => (typeof requester === 'string' && requester.startsWith('user:') ? requester.slice(5) : null);--function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console, robots = null }) {++function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console, robots = null, computers = null }) {const { searchUrl, toolsUrl } = config.services;const bot = robots || createRobots({ config, fetchImpl });++ const pcs = computers || createComputers({ config, fetchImpl });let catalog = null;let catalogAt = 0;const schemas = new Map();@@@ -111,6 +119,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>defs.push({ type: 'function', function: { name: 'read_page', description: 'Read a public web page as text (title, description, main text, links). Use it for URLs the task gives or that a result links to.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } } });}if (bot.enabled && personOf(requester)) defs.push(...ROBOT_DEFS);++ if (pcs.enabled && personOf(requester)) defs.push(...COMPUTER_DEFS);return defs;}@@@ -123,6 +132,15 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>if (name === 'list_my_robots') return json(await bot.mine(person, { signal }));return json(await bot.steps(person, a.robot, a.steps, { signal }));}++ if (COMPUTER_TOOLS.has(name)) {++ const person = personOf(requester);++ if (!pcs.enabled || !person) return json({ error: 'computers are only for a signed-in person\'s task' });++ if (name === 'list_my_computers') return json(await pcs.mine(person, { signal }));++ if (name === 'computer_list') return json(await pcs.list(person, a.machine, a.path, { signal }));++ if (name === 'computer_read') return json(await pcs.read(person, a.machine, a.path, { offset: a.offset, signal }));++ if (name === 'computer_run') return json(await pcs.run(person, a.machine, a.argv, { cwd: a.cwd, timeoutS: a.timeout_s, signal }));++ return json(await pcs.write(person, a.machine, a.path, a.text, { mode: a.mode || 'create', signal }));++ }if (name === 'search_openvibe') return json(await searchOpenVibe(a.query, { signal }));if (name === 'find_tools') return json(await findTools(a.need, { signal }));if (name === 'run_tool') return json(await runTool(String(a.tool || ''), a.input, { signal }));@@@ -134,7 +152,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>}}-- return { definitions, call, tools, findTools, runTool, searchOpenVibe, robots: bot };++ return { definitions, call, tools, findTools, runTool, searchOpenVibe, robots: bot, computers: pcs };}const ROBOT_DEFS = [@@@ -142,4 +160,14 @@ const ROBOT_DEFS = [{ type: 'function', function: { name: 'robot_steps', description: 'Make one of the person\'s robots act, by step lines it accepts (from list_my_robots), for example ["forward 1", "left 0.5", "say Hello", "headlights #00ff00", "wait 1", "stop"]. At most 20 steps and 30 seconds per call. Answers "done" or "stopped: <why>" once they end.', parameters: { type: 'object', properties: { robot: { type: 'string', description: 'the robot id (rob_…)' }, steps: { type: 'array', items: { type: 'string' } } }, required: ['robot', 'steps'] } } },];++const machine = { type: 'string', description: 'the machine id (dev_…) from list_my_computers' };++const COMPUTER_DEFS = [++ { type: 'function', function: { name: 'list_my_computers', description: 'List the person\'s own computers open to their agent on OpenVibe.Bot: online or not, the level the person set on the machine (observe: read only; ask: each run or write waits for their approval on the machine; trusted; full), what it offers (terminal, files) and its folders.', parameters: { type: 'object', properties: {} } } },++ { type: 'function', function: { name: 'computer_list', description: 'List a folder on one of the person\'s computers (an absolute path inside its folders).', parameters: { type: 'object', properties: { machine, path: { type: 'string' } }, required: ['machine', 'path'] } } },++ { type: 'function', function: { name: 'computer_read', description: 'Read a file on one of the person\'s computers, as text (64 KB at a time; offset reads further).', parameters: { type: 'object', properties: { machine, path: { type: 'string' }, offset: { type: 'integer' } }, required: ['machine', 'path'] } } },++ { type: 'function', function: { name: 'computer_run', description: 'Run a program on one of the person\'s computers: argv is the program and its arguments, for example ["git", "status"] (never a shell line). Answers its exit code and output. At level "ask" it waits for the person to approve it on the machine.', parameters: { type: 'object', properties: { machine, argv: { type: 'array', items: { type: 'string' } }, cwd: { type: 'string' }, timeout_s: { type: 'integer' } }, required: ['machine', 'argv'] } } },++ { type: 'function', function: { name: 'computer_write', description: 'Write a text file on one of the person\'s computers (mode create, overwrite or append). At level "ask" it waits for the person to approve it on the machine.', parameters: { type: 'object', properties: { machine, path: { type: 'string' }, text: { type: 'string' }, mode: { type: 'string', enum: ['create', 'overwrite', 'append'] } }, required: ['machine', 'path', 'text'] } } },++];++const COMPUTER_TOOLS = new Set(COMPUTER_DEFS.map((d) => d.function.name));++module.exports = { createOpenVibeTools, personOf };nnew file mode 100644@@@ -0,0 +1,108 @@++'use strict';++// The person's own computers as runtime tools (plan T14/T17, agents/computers.js): only a signed-in person's task sees++// them; Actor calls OpenVibe.Bot's machine API with its own token and X-OV-Subject naming that person; a program is++// argv (never a shell line); the machine's refusal (its level, "ask" unanswered) comes back to the model as an error;++// with no computer open the tool says how the person opens one.++const assert = require('assert');++const { createOpenVibeTools } = require('../server/agents/tools');++const { createComputers } = require('../server/agents/computers');++const { createRobots } = require('../server/agents/robots');++const { check, done } = require('./helpers/boot');++++const ME = 'usr_01M4W5JBFEG3QG9ER8W1A2K0RX';++const PC = 'dev_01M4W5JBFDG2TJ8C4FRDMCWBZ9';++const config = {++ networkInternalUrl: 'http://network.test', oauth: { clientId: 'actor', clientSecret: 'actor-secret' },++ services: { searchUrl: 'http://search.test', toolsUrl: 'http://tools.test', botUrl: 'http://bot.test', botPublicUrl: 'https://openvibe.bot' },++};++++/** A fake OpenVibe.Bot machine API: one computer open to the agent at level "ask"; records every call. */++function fakeBot({ machines = null } = {}) {++ const calls = [];++ const reply = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });++ const fetchImpl = async (url, init = {}) => {++ const u = new URL(url);++ const headers = init.headers || {};++ const body = init.body ? JSON.parse(init.body) : null;++ calls.push({ method: init.method || 'GET', path: u.pathname + u.search, subject: headers['X-OV-Subject'], auth: headers.Authorization, body });++ if (u.host === 'tools.test') return reply(200, { tools: [] });++ if (u.pathname === '/api/v1/machines') {++ return reply(200, { machines: machines || [{ id: PC, name: 'desk-pc', online: true, agent_computer: true, computer: { level: 'ask', terminal: { level: 'ask' }, files: { level: 'ask', roots: ['/home/me'] }, screen: null, waiting: 0 } }] });++ }++ if (u.pathname === `/api/v1/machines/${PC}/commands`) {++ const v = body.value;++ if (body.kind === 'exec' && v.argv[0] === 'git') return reply(200, { machine_id: PC, id: 'op', result: 'ack', data: { exit_code: 0, stdout: 'On branch main\n', stderr: '', truncated: false, timed_out: false } });++ if (body.kind === 'exec') return reply(200, { machine_id: PC, id: 'op', result: 'nack', reason: 'the owner denied it on the machine' });++ if (v.op === 'list') return reply(200, { result: 'ack', data: { path: v.path, entries: [{ name: 'notes.md', type: 'file', size: 12, mtime: 1 }], total: 1, truncated: false } });++ if (v.op === 'read') return reply(200, { result: 'ack', data: { path: v.path, size: 12, offset: 0, length: 12, truncated: false, content_b64: Buffer.from('hello notes\n').toString('base64') } });++ if (v.op === 'write') return reply(200, { result: 'expired', reason: 'no acknowledgement before the deadline' });++ }++ if (u.pathname.startsWith('/api/v1/machines/')) return reply(404, { code: 'bot.device_not_found', detail: 'no such machine of yours' });++ return reply(404, { code: 'not_found' });++ };++ return { calls, fetchImpl };++}++const tokens = { authHeaders: async () => ({ Authorization: 'Bearer svc-token' }) };++const tools = (bot) => createOpenVibeTools({ config, fetchImpl: bot.fetchImpl, robots: createRobots({ config, fetchImpl: bot.fetchImpl, tokens }), computers: createComputers({ config, fetchImpl: bot.fetchImpl, tokens }) });++const as = { requester: `user:${ME}` };++++(async () => {++ await check('only a signed-in person\'s task gets the computer tools', async () => {++ const bot = fakeBot();++ const t = tools(bot);++ const names = async (requester) => (await t.definitions({ requester })).map((d) => d.function.name);++ for (const n of ['list_my_computers', 'computer_list', 'computer_read', 'computer_run', 'computer_write']) assert.ok((await names(`user:${ME}`)).includes(n), n);++ for (const r of ['app:app_x', 'agent:agt_x', 'service:live', null]) assert.ok(!(await names(r)).includes('computer_run'), String(r));++ const refused = JSON.parse(await t.call('computer_run', { machine: PC, argv: ['id'] }, { requester: 'app:app_x' }));++ assert.match(refused.error, /signed-in person/);++ assert.ok(!bot.calls.some((c) => c.path.includes('/machines')), 'nothing reached Bot for an app');++ });++++ await check('list_my_computers: the open computers with their level and folders; none says how to open one', async () => {++ const bot = fakeBot();++ const list = JSON.parse(await tools(bot).call('list_my_computers', {}, as));++ assert.deepStrictEqual(list.computers, [{ id: PC, name: 'desk-pc', online: true, level: 'ask', can: ['terminal', 'files'], folders: ['/home/me'] }]);++ assert.match(list.note, /approve/);++ assert.ok(bot.calls.every((c) => c.subject === ME && c.auth === 'Bearer svc-token'), 'as the person, with Actor\'s token');++ const none = JSON.parse(await tools(fakeBot({ machines: [] })).call('list_my_computers', {}, as));++ assert.deepStrictEqual(none.computers, []);++ assert.match(none.note, /https:\/\/openvibe\.bot\/machines.*--computer.*Let my agent use this computer/);++ });++++ await check('files and programs go to Bot as the person; refusals and an unanswered approval come back as errors', async () => {++ const bot = fakeBot();++ const t = tools(bot);++ const ls = JSON.parse(await t.call('computer_list', { machine: PC, path: '/home/me' }, as));++ assert.deepStrictEqual(ls, { path: '/home/me', entries: [{ name: 'notes.md', type: 'file', size: 12 }], truncated: false });++ const file = JSON.parse(await t.call('computer_read', { machine: PC, path: '/home/me/notes.md' }, as));++ assert.strictEqual(file.text, 'hello notes\n');++ const git = JSON.parse(await t.call('computer_run', { machine: PC, argv: ['git', 'status'], cwd: '/home/me' }, as));++ assert.deepStrictEqual([git.exit_code, git.stdout], [0, 'On branch main\n']);++ const sent = bot.calls.find((c) => c.method === 'POST' && c.body.kind === 'exec');++ assert.deepStrictEqual([sent.path, sent.subject, sent.body.value], [`/api/v1/machines/${PC}/commands`, ME, { argv: ['git', 'status'], cwd: '/home/me' }]);++ assert.ok(sent.body.ms >= 120000, 'room for the person to approve it on the machine');++ const denied = JSON.parse(await t.call('computer_run', { machine: PC, argv: ['rm', 'x'] }, as));++ assert.strictEqual(denied.error, 'the owner denied it on the machine');++ const late = JSON.parse(await t.call('computer_write', { machine: PC, path: '/home/me/a.txt', text: 'hi' }, as));++ assert.match(late.error, /nobody approved it there/);++ const written = bot.calls.find((c) => c.body && c.body.value && c.body.value.op === 'write');++ assert.deepStrictEqual([written.body.value.content_b64, written.body.value.mode], [Buffer.from('hi').toString('base64'), 'create']);++ });++++ await check('bad input never leaves Actor', async () => {++ const bot = fakeBot();++ const t = tools(bot);++ const before = bot.calls.length;++ assert.match(JSON.parse(await t.call('computer_run', { machine: PC, argv: 'rm -rf /' }, as)).error, /never a shell line/);++ assert.match(JSON.parse(await t.call('computer_run', { machine: '../admin', argv: ['id'] }, as)).error, /machine id/);++ assert.match(JSON.parse(await t.call('computer_write', { machine: PC, path: '/x', text: 'y', mode: 'delete' }, as)).error, /mode/);++ assert.strictEqual(bot.calls.length, before);++ });++++ await check('off without a Bot URL or a client secret', async () => {++ assert.strictEqual(createComputers({ config: { ...config, services: { ...config.services, botUrl: '' } } }).enabled, false);++ assert.strictEqual(createComputers({ config: { ...config, oauth: { clientId: 'actor', clientSecret: '' } } }).enabled, false);++ });++++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -107,6 +107,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };'server/auth/sso.js': 'Network OAuth token and revoke (configured networkInternalUrl)','server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys','server/agents/codes.js': 'the configured OpenVibe.Codes base (ACTOR_CODES_URL) with Actor\'s own service token; the subject must be usr_<ULID> and every task id tsk_<ULID> before it goes in a path; the repository named in the task is data for the person\'s own machine, never fetched here (test/codes.test.js)',++ 'server/agents/computers.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token (bot.computer.control); the machine id must be dev_<ULID> and the subject usr_<ULID> before anything is sent; a path or program is data for the person\'s own machine, never fetched here (test/computers.test.js)','server/agents/robots.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token; the robot id must be rob_<ULID> and the subject usr_<ULID> before anything is sent (test/robots.test.js)','server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input','server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',
@@ -102,6 +102,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
@@ -102,6 +102,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
- **OpenVibe.Search** (`ACTOR_SEARCH_URL`): content across the network, for the runtime's `search_openvibe` tool.
- **OpenVibe.Search** (`ACTOR_SEARCH_URL`): content across the network, for the runtime's `search_openvibe` tool.
- **OpenVibe.Tools** (`ACTOR_TOOLS_URL`): the catalog, the run API and the page reader, for `find_tools`, `run_tool` and `read_page`. Called as any anonymous caller would.
- **OpenVibe.Tools** (`ACTOR_TOOLS_URL`): the catalog, the run API and the page reader, for `find_tools`, `run_tool` and `read_page`. Called as any anonymous caller would.
- **OpenVibe.Bot** (`ACTOR_BOT_URL`, default `http://127.0.0.1:4630`; empty turns it off): a signed-in person's own robots, for `list_my_robots` and `robot_steps`. Actor calls Bot with its own client-credentials token (audience `openvibe.bot`, `bot.robot.read` + `bot.robot.control`) and `X-OV-Subject` naming the person. Bot answers only for the robot's owner, and only on robots where that owner turned on "Let my OpenVibe.Actor agent drive this robot". Bot's gate, limits and e-stop judge every step. App, agent and service tasks never see these tools.
- **OpenVibe.Bot** (`ACTOR_BOT_URL`, default `http://127.0.0.1:4630`; empty turns it off): a signed-in person's own robots, for `list_my_robots` and `robot_steps`. Actor calls Bot with its own client-credentials token (audience `openvibe.bot`, `bot.robot.read` + `bot.robot.control`) and `X-OV-Subject` naming the person. Bot answers only for the robot's owner, and only on robots where that owner turned on "Let my OpenVibe.Actor agent drive this robot". Bot's gate, limits and e-stop judge every step. App, agent and service tasks never see these tools.
+- **A person's own computers, also through OpenVibe.Bot** (plan T14/T17, contracts 0.166.0): `list_my_computers`, `computer_list`, `computer_read`, `computer_run` (argv, never a shell line) and `computer_write`, on machines installed with OpenVibe Node's `--computer` plugin. Actor calls Bot's machine API with its own token (`bot.computer.control`) and `X-OV-Subject` naming the person. Bot answers only for the machine's owner, and only where that owner turned on "Let my agent use this computer". The level the owner set on the machine decides what may happen there; at `ask`, each run or write waits up to two minutes for the owner's approval on the machine. Only a signed-in person's task sees these tools (`server/agents/computers.js`, `test/computers.test.js`).
- **OpenVibe.Events** (`ACTOR_EVENTS_URL`): the two ADR-033 subscriptions created at boot, and delivery to the loopback `/internal/events`.
- **OpenVibe.Events** (`ACTOR_EVENTS_URL`): the two ADR-033 subscriptions created at boot, and delivery to the loopback `/internal/events`.
- **The model providers**: DeepSeek (`ACTOR_DEEPSEEK_API_KEY`, `api.deepseek.com`), OpenAI (`ACTOR_OPENAI_API_KEY`, `api.openai.com/v1`) for the web-search agent and the cross-family check, and an OpenAI-compatible local server (`ACTOR_LOCAL_LLM_URL`) for private mode.
- **The model providers**: DeepSeek (`ACTOR_DEEPSEEK_API_KEY`, `api.deepseek.com`), OpenAI (`ACTOR_OPENAI_API_KEY`, `api.openai.com/v1`) for the web-search agent and the cross-family check, and an OpenAI-compatible local server (`ACTOR_LOCAL_LLM_URL`) for private mode.
@@ -20,9 +20,10 @@ const MAX_TURNS = 10;
@@ -20,9 +20,10 @@ const MAX_TURNS = 10;
const today = () => new Date().toISOString().slice(0, 10);
const today = () => new Date().toISOString().slice(0, 10);
-const RUNTIME_SYSTEM = (robots = false) => `You are OpenVibe Actor's runtime: an agent that completes a person's task by acting through OpenVibe's services.
+const RUNTIME_SYSTEM = (robots = false, computers = false) => `You are OpenVibe Actor's runtime: an agent that completes a person's task by acting through OpenVibe's services.
Today is ${today()}. Your tools are OpenVibe's public APIs: search_openvibe (content on the OpenVibe network), find_tools then run_tool (OpenVibe.Tools: DNS, mail records, certificates, HTTP headers, WHOIS, conversions and 150 more), and read_page when it is offered.${robots ? `
Today is ${today()}. Your tools are OpenVibe's public APIs: search_openvibe (content on the OpenVibe network), find_tools then run_tool (OpenVibe.Tools: DNS, mail records, certificates, HTTP headers, WHOIS, conversions and 150 more), and read_page when it is offered.${robots ? `
-The person's own robots on OpenVibe.Bot: list_my_robots, then robot_steps with step lines that robot accepts. Act on a robot only when the task asks for it, keep motions short, and report what the robot answered (done, or stopped and why). If a robot is not opened to you, say how the person opens it.` : ''}
+The person's own robots on OpenVibe.Bot: list_my_robots, then robot_steps with step lines that robot accepts. Act on a robot only when the task asks for it, keep motions short, and report what the robot answered (done, or stopped and why). If a robot is not opened to you, say how the person opens it.` : ''}${computers ? `
+The person's own computers: list_my_computers, then computer_list, computer_read, computer_run (argv: a program and its arguments, never a shell line) and computer_write. Use them only when the task asks about something on the person's computer; read before you change anything; never delete, never touch credentials or keys, and say exactly what you ran or wrote. At level "ask" the person approves each run or write on the machine: tell them when you are waiting.` : ''}
Rules: use a tool whenever a fact can be checked with one instead of guessing; never invent a tool result, a URL or a number; if something cannot be found, say so plainly.
Rules: use a tool whenever a fact can be checked with one instead of guessing; never invent a tool result, a URL or a number; if something cannot be found, say so plainly.
Finish with the answer itself in Markdown, short and direct, then a "Sources" list of the links or tools you relied on (tool results as "OpenVibe.Tools <tool id>").`;
Finish with the answer itself in Markdown, short and direct, then a "Sources" list of the links or tools you relied on (tool results as "OpenVibe.Tools <tool id>").`;
@@ -32,7 +33,8 @@ Never invent facts, numbers or links; if something cannot be found, say so.`;
@@ -32,7 +33,8 @@ Never invent facts, numbers or links; if something cannot be found, say so.`;
const summarize = (args) => {
const summarize = (args) => {
const a = args || {};
const a = args || {};
- const v = a.query || a.need || a.url || (a.tool ? `${a.tool} ${JSON.stringify(a.input || {})}` : a.robot ? `${a.robot}: ${[].concat(a.steps || []).join(', ')}` : JSON.stringify(a));
+ const v = a.query || a.need || a.url || (a.tool ? `${a.tool} ${JSON.stringify(a.input || {})}` : a.robot ? `${a.robot}: ${[].concat(a.steps || []).join(', ')}`
+ : a.machine ? `${a.machine}: ${Array.isArray(a.argv) ? a.argv.join(' ') : a.path || ''}` : JSON.stringify(a));
return String(v).slice(0, 300);
return String(v).slice(0, 300);
};
};
@@ -50,7 +52,8 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
@@ -50,7 +52,8 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
if (!deepseek) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });
if (!deepseek) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });
const tools = await ovTools.definitions({ signal, requester });
const tools = await ovTools.definitions({ signal, requester });
const robots = tools.some((d) => d.function && d.function.name === 'robot_steps');
const robots = tools.some((d) => d.function && d.function.name === 'robot_steps');
- const messages = [{ role: 'system', content: RUNTIME_SYSTEM(robots) }, { role: 'user', content: task }];
+ const computers = tools.some((d) => d.function && d.function.name === 'computer_run');
+ const messages = [{ role: 'system', content: RUNTIME_SYSTEM(robots, computers) }, { role: 'user', content: task }];
let last = '';
let last = '';
for (let turn = 1; turn <= MAX_TURNS; turn++) {
for (let turn = 1; turn <= MAX_TURNS; turn++) {
if (budgetLeft() <= 0) throw new ProviderError('the task reached its budget before the runtime finished', { code: 'actor.budget.exceeded' });
if (budgetLeft() <= 0) throw new ProviderError('the task reached its budget before the runtime finished', { code: 'actor.budget.exceeded' });
new file mode 100644
new file mode 100644
@@ -0,0 +1,126 @@
@@ -0,0 +1,126 @@
+'use strict';
+
+/**
+ * The person's own computers on OpenVibe.Bot, as runtime tools (plan T14/T17): OpenVibe.Node's computer plugin, reached
+ * only through Bot. Actor calls Bot's public API with its own service token (audience openvibe.bot,
+ * bot.computer.control) and X-OV-Subject naming the person the task is for. Bot answers only for the machine's owner,
+ * and only where that owner turned on "Let my agent use this computer"; the level the owner set on the machine itself
+ * decides what may happen there (observe, ask — which waits for their approval on the machine — trusted, full).
+ *
+ * createComputers({ config, fetchImpl, tokens? }) → { enabled, mine(subject), list, read, run, write }
+ * mine → [{ id, name, online, level, can: [terminal|files|screen], folders, waiting }] or { error }
+ * list → { path, entries: [{ name, type, size }], truncated } (a folder)
+ * read → { path, size, text | bytes, truncated } (a file, as text when it is UTF-8)
+ * run → { exit_code, stdout, stderr, truncated, timed_out } (a program and its arguments, never a shell line)
+ * write → { path, written } (create, overwrite or append)
+ *
+ * Off (enabled false) without ACTOR_BOT_URL or without Actor's client secret.
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+const MACHINE_ID = /^dev_[0-9A-HJKMNP-TV-Z]{26}$/;
+const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+// A command waits on the machine for its owner's approval at level "ask", then runs: two minutes, inside a task's three.
+const ACT_WINDOW_MS = 120_000;
+const READ_WINDOW_MS = 30_000;
+const MAX_READ = 64 * 1024;
+
+function createComputers({ config, fetchImpl = globalThis.fetch, tokens: tokensOverride = null }) {
+ const base = config.services.botUrl;
+ const pages = config.services.botPublicUrl || base;
+ const enabled = !!(base && (tokensOverride || config.oauth.clientSecret));
+ const tokens = !enabled ? null : tokensOverride || serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.bot', scope: 'bot.computer.control', fetchImpl,
+ });
+
+ async function call(method, path, subject, { body, signal, timeoutMs = 15_000 } = {}) {
+ const sig = signal ? AbortSignal.any([signal, AbortSignal.timeout(timeoutMs)]) : AbortSignal.timeout(timeoutMs);
+ const res = await fetchImpl(`${base}/api/v1${path}`, {
+ method,
+ headers: { Accept: 'application/json', 'X-OV-Subject': subject, ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) },
+ body: body ? JSON.stringify(body) : undefined, signal: sig,
+ });
+ const data = await res.json().catch(() => null);
+ return { status: res.status, ok: res.ok, data };
+ }
+ const problem = (r, what) => ({ error: (r.data && (r.data.detail || r.data.title)) || `Bot answered ${r.status} for ${what}`, code: (r.data && r.data.code) || null });
+ const guard = (subject, machine) => {
+ if (!USER_SUBJECT.test(subject)) return { error: 'computers are only for a signed-in person' };
+ if (!MACHINE_ID.test(String(machine || ''))) return { error: 'machine must be a machine id from list_my_computers (dev_…)' };
+ return null;
+ };
+ /** One command through Bot; the machine's answer, or its refusal (a nack's reason), as an error. */
+ async function command(subject, machine, kind, value, { signal, windowMs }) {
+ const r = await call('POST', `/machines/${machine}/commands`, subject, { body: { kind, value, ms: windowMs }, signal, timeoutMs: windowMs + 10_000 });
+ if (!r.ok) return problem(r, `the ${kind} command`);
+ if (r.data.result === 'ack') return { data: r.data.data || {} };
+ if (r.data.result === 'expired') return { error: 'the machine did not answer in time (at level "ask", nobody approved it there)' };
+ return { error: r.data.reason || `the machine refused it (${r.data.result})` };
+ }
+
+ async function mine(subject, { signal } = {}) {
+ if (!USER_SUBJECT.test(subject)) return { error: 'computers are only for a signed-in person' };
+ const r = await call('GET', '/machines', subject, { signal });
+ if (!r.ok) return problem(r, 'your computers');
+ const machines = ((r.data && r.data.machines) || []).filter((m) => m.computer).map((m) => ({
+ id: m.id, name: m.name, online: m.online, level: m.computer.level,
+ can: ['terminal', 'files', 'screen'].filter((k) => m.computer[k]),
+ folders: (m.computer.files && m.computer.files.roots) || [],
+ ...(m.computer.waiting ? { waiting_for_approval: m.computer.waiting } : {}),
+ }));
+ if (!machines.length) return { computers: [], note: `No computer is open to the agent. On ${pages}/machines the person adds one with --computer (for example "ask") and turns on "Let my agent use this computer".` };
+ return { computers: machines, note: 'At level "observe" only reading works; at "ask" each run or write waits for the person to approve it on that machine.' };
+ }
+
+ async function list(subject, machine, path, { signal } = {}) {
+ const bad = guard(subject, machine);
+ if (bad) return bad;
+ const r = await command(subject, machine, 'fs', { op: 'list', path: String(path || '') }, { signal, windowMs: READ_WINDOW_MS });
+ if (r.error) return r;
+ return { path: r.data.path, entries: (r.data.entries || []).slice(0, 300).map((e) => ({ name: e.name, type: e.type, size: e.size })), truncated: !!r.data.truncated || (r.data.entries || []).length > 300 };
+ }
+
+ async function read(subject, machine, path, { offset = 0, signal } = {}) {
+ const bad = guard(subject, machine);
+ if (bad) return bad;
+ const off = Number.isInteger(offset) && offset > 0 ? offset : 0;
+ const r = await command(subject, machine, 'fs', { op: 'read', path: String(path || ''), offset: off, length: MAX_READ }, { signal, windowMs: READ_WINDOW_MS });
+ if (r.error) return r;
+ const bytes = Buffer.from(String(r.data.content_b64 || ''), 'base64');
+ const text = bytes.toString('utf8');
+ const binary = text.includes('�') || /[\u0000-\u0008\u000e-\u001f]/.test(text);
+ return { path: r.data.path, size: r.data.size, offset: off, ...(binary ? { bytes: bytes.length, note: 'not text' } : { text }), truncated: !!r.data.truncated };
+ }
+
+ async function run(subject, machine, argv, { cwd = null, timeoutS = null, signal } = {}) {
+ const bad = guard(subject, machine);
+ if (bad) return bad;
+ if (!Array.isArray(argv) || !argv.length || argv.length > 64 || !argv.every((a) => typeof a === 'string' && a.length && a.length <= 4096)) {
+ return { error: 'argv is a program and its arguments, for example ["git", "status"]: never a shell line' };
+ }
+ const value = { argv };
+ if (cwd) value.cwd = String(cwd);
+ if (Number.isInteger(timeoutS) && timeoutS > 0) value.timeout_s = Math.min(timeoutS, 600);
+ const r = await command(subject, machine, 'exec', value, { signal, windowMs: ACT_WINDOW_MS });
+ if (r.error) return r;
+ const d = r.data;
+ return { exit_code: d.exit_code, stdout: d.stdout, stderr: d.stderr, truncated: !!d.truncated, timed_out: !!d.timed_out };
+ }
+
+ async function write(subject, machine, path, text, { mode = 'create', signal } = {}) {
+ const bad = guard(subject, machine);
+ if (bad) return bad;
+ if (typeof text !== 'string') return { error: 'text is the file\'s content' };
+ const content = Buffer.from(text, 'utf8');
+ if (content.length > 256 * 1024) return { error: 'at most 256 KB per write' };
+ if (!['create', 'overwrite', 'append'].includes(mode)) return { error: 'mode is create, overwrite or append' };
+ const r = await command(subject, machine, 'fs', { op: 'write', path: String(path || ''), content_b64: content.toString('base64'), mode }, { signal, windowMs: ACT_WINDOW_MS });
+ if (r.error) return r;
+ return { path: r.data.path, written: r.data.written };
+ }
+
+ return { enabled, mine, list, read, run, write };
+}
+
+module.exports = { createComputers };
@@ -12,12 +12,19 @@
@@ -12,12 +12,19 @@
* list_my_robots() OpenVibe.Bot: the person's own robots, and for each one they opened to their agent its
* list_my_robots() OpenVibe.Bot: the person's own robots, and for each one they opened to their agent its
* state and the step lines it takes (agents/robots.js) — a signed-in person's task only
* state and the step lines it takes (agents/robots.js) — a signed-in person's task only
* robot_steps(robot, steps) OpenVibe.Bot: run step lines on one of those robots, as the person, through Bot's gate
* robot_steps(robot, steps) OpenVibe.Bot: run step lines on one of those robots, as the person, through Bot's gate
+ * list_my_computers() OpenVibe.Bot: the person's own computers open to their agent (agents/computers.js), with
+ * the level the person set on each machine
+ * computer_list / computer_read / computer_run / computer_write
+ * a folder, a file, a program (argv, never a shell line) or a write on one of them, as the
+ * person, through Bot; at level "ask" each run or write waits for the person's approval
+ * on the machine itself
*
*
* Tools' own guard decides what a tool may fetch (public addresses only, per-target limits); Actor adds nothing to
* Tools' own guard decides what a tool may fetch (public addresses only, per-target limits); Actor adds nothing to
* what an anonymous caller could already do, and every result is clipped before it goes back to the model.
* what an anonymous caller could already do, and every result is clipped before it goes back to the model.
*/
*/
const { createRobots } = require('./robots');
const { createRobots } = require('./robots');
+const { createComputers } = require('./computers');
const CATALOG_TTL_MS = 10 * 60_000;
const CATALOG_TTL_MS = 10 * 60_000;
const RESULT_CHARS = 6000;
const RESULT_CHARS = 6000;
@@ -28,9 +35,10 @@ const json = (v) => clip(JSON.stringify(v));
@@ -28,9 +35,10 @@ const json = (v) => clip(JSON.stringify(v));
/** The person a task is for, when it is a signed-in person ('user:usr_…' → 'usr_…'), else null. */
/** The person a task is for, when it is a signed-in person ('user:usr_…' → 'usr_…'), else null. */
const personOf = (requester) => (typeof requester === 'string' && requester.startsWith('user:') ? requester.slice(5) : null);
const personOf = (requester) => (typeof requester === 'string' && requester.startsWith('user:') ? requester.slice(5) : null);
-function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console, robots = null }) {
+function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console, robots = null, computers = null }) {
const { searchUrl, toolsUrl } = config.services;
const { searchUrl, toolsUrl } = config.services;
const bot = robots || createRobots({ config, fetchImpl });
const bot = robots || createRobots({ config, fetchImpl });
+ const pcs = computers || createComputers({ config, fetchImpl });
let catalog = null;
let catalog = null;
let catalogAt = 0;
let catalogAt = 0;
const schemas = new Map();
const schemas = new Map();
@@ -111,6 +119,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -111,6 +119,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
defs.push({ type: 'function', function: { name: 'read_page', description: 'Read a public web page as text (title, description, main text, links). Use it for URLs the task gives or that a result links to.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } } });
defs.push({ type: 'function', function: { name: 'read_page', description: 'Read a public web page as text (title, description, main text, links). Use it for URLs the task gives or that a result links to.', parameters: { type: 'object', properties: { url: { type: 'string' } }, required: ['url'] } } });
}
}
if (bot.enabled && personOf(requester)) defs.push(...ROBOT_DEFS);
if (bot.enabled && personOf(requester)) defs.push(...ROBOT_DEFS);
+ if (pcs.enabled && personOf(requester)) defs.push(...COMPUTER_DEFS);
return defs;
return defs;
}
}
@@ -123,6 +132,15 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -123,6 +132,15 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
if (name === 'list_my_robots') return json(await bot.mine(person, { signal }));
if (name === 'list_my_robots') return json(await bot.mine(person, { signal }));
return json(await bot.steps(person, a.robot, a.steps, { signal }));
return json(await bot.steps(person, a.robot, a.steps, { signal }));
}
}
+ if (COMPUTER_TOOLS.has(name)) {
+ const person = personOf(requester);
+ if (!pcs.enabled || !person) return json({ error: 'computers are only for a signed-in person\'s task' });
+ if (name === 'list_my_computers') return json(await pcs.mine(person, { signal }));
+ if (name === 'computer_list') return json(await pcs.list(person, a.machine, a.path, { signal }));
+ if (name === 'computer_read') return json(await pcs.read(person, a.machine, a.path, { offset: a.offset, signal }));
+ if (name === 'computer_run') return json(await pcs.run(person, a.machine, a.argv, { cwd: a.cwd, timeoutS: a.timeout_s, signal }));
+ return json(await pcs.write(person, a.machine, a.path, a.text, { mode: a.mode || 'create', signal }));
+ }
if (name === 'search_openvibe') return json(await searchOpenVibe(a.query, { signal }));
if (name === 'search_openvibe') return json(await searchOpenVibe(a.query, { signal }));
if (name === 'find_tools') return json(await findTools(a.need, { signal }));
if (name === 'find_tools') return json(await findTools(a.need, { signal }));
if (name === 'run_tool') return json(await runTool(String(a.tool || ''), a.input, { signal }));
if (name === 'run_tool') return json(await runTool(String(a.tool || ''), a.input, { signal }));
@@ -134,7 +152,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
@@ -134,7 +152,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
}
}
}
}
- return { definitions, call, tools, findTools, runTool, searchOpenVibe, robots: bot };
+ return { definitions, call, tools, findTools, runTool, searchOpenVibe, robots: bot, computers: pcs };
}
}
const ROBOT_DEFS = [
const ROBOT_DEFS = [
@@ -142,4 +160,14 @@ const ROBOT_DEFS = [
@@ -142,4 +160,14 @@ const ROBOT_DEFS = [
{ type: 'function', function: { name: 'robot_steps', description: 'Make one of the person\'s robots act, by step lines it accepts (from list_my_robots), for example ["forward 1", "left 0.5", "say Hello", "headlights #00ff00", "wait 1", "stop"]. At most 20 steps and 30 seconds per call. Answers "done" or "stopped: <why>" once they end.', parameters: { type: 'object', properties: { robot: { type: 'string', description: 'the robot id (rob_…)' }, steps: { type: 'array', items: { type: 'string' } } }, required: ['robot', 'steps'] } } },
{ type: 'function', function: { name: 'robot_steps', description: 'Make one of the person\'s robots act, by step lines it accepts (from list_my_robots), for example ["forward 1", "left 0.5", "say Hello", "headlights #00ff00", "wait 1", "stop"]. At most 20 steps and 30 seconds per call. Answers "done" or "stopped: <why>" once they end.', parameters: { type: 'object', properties: { robot: { type: 'string', description: 'the robot id (rob_…)' }, steps: { type: 'array', items: { type: 'string' } } }, required: ['robot', 'steps'] } } },
];
];
+const machine = { type: 'string', description: 'the machine id (dev_…) from list_my_computers' };
+const COMPUTER_DEFS = [
+ { type: 'function', function: { name: 'list_my_computers', description: 'List the person\'s own computers open to their agent on OpenVibe.Bot: online or not, the level the person set on the machine (observe: read only; ask: each run or write waits for their approval on the machine; trusted; full), what it offers (terminal, files) and its folders.', parameters: { type: 'object', properties: {} } } },
+ { type: 'function', function: { name: 'computer_list', description: 'List a folder on one of the person\'s computers (an absolute path inside its folders).', parameters: { type: 'object', properties: { machine, path: { type: 'string' } }, required: ['machine', 'path'] } } },
+ { type: 'function', function: { name: 'computer_read', description: 'Read a file on one of the person\'s computers, as text (64 KB at a time; offset reads further).', parameters: { type: 'object', properties: { machine, path: { type: 'string' }, offset: { type: 'integer' } }, required: ['machine', 'path'] } } },
+ { type: 'function', function: { name: 'computer_run', description: 'Run a program on one of the person\'s computers: argv is the program and its arguments, for example ["git", "status"] (never a shell line). Answers its exit code and output. At level "ask" it waits for the person to approve it on the machine.', parameters: { type: 'object', properties: { machine, argv: { type: 'array', items: { type: 'string' } }, cwd: { type: 'string' }, timeout_s: { type: 'integer' } }, required: ['machine', 'argv'] } } },
+ { type: 'function', function: { name: 'computer_write', description: 'Write a text file on one of the person\'s computers (mode create, overwrite or append). At level "ask" it waits for the person to approve it on the machine.', parameters: { type: 'object', properties: { machine, path: { type: 'string' }, text: { type: 'string' }, mode: { type: 'string', enum: ['create', 'overwrite', 'append'] } }, required: ['machine', 'path', 'text'] } } },
+];
+const COMPUTER_TOOLS = new Set(COMPUTER_DEFS.map((d) => d.function.name));
+
module.exports = { createOpenVibeTools, personOf };
module.exports = { createOpenVibeTools, personOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -0,0 +1,108 @@
+'use strict';
+// The person's own computers as runtime tools (plan T14/T17, agents/computers.js): only a signed-in person's task sees
+// them; Actor calls OpenVibe.Bot's machine API with its own token and X-OV-Subject naming that person; a program is
+// argv (never a shell line); the machine's refusal (its level, "ask" unanswered) comes back to the model as an error;
+// with no computer open the tool says how the person opens one.
+const assert = require('assert');
+const { createOpenVibeTools } = require('../server/agents/tools');
+const { createComputers } = require('../server/agents/computers');
+const { createRobots } = require('../server/agents/robots');
+const { check, done } = require('./helpers/boot');
+
+const ME = 'usr_01M4W5JBFEG3QG9ER8W1A2K0RX';
+const PC = 'dev_01M4W5JBFDG2TJ8C4FRDMCWBZ9';
+const config = {
+ networkInternalUrl: 'http://network.test', oauth: { clientId: 'actor', clientSecret: 'actor-secret' },
+ services: { searchUrl: 'http://search.test', toolsUrl: 'http://tools.test', botUrl: 'http://bot.test', botPublicUrl: 'https://openvibe.bot' },
+};
+
+/** A fake OpenVibe.Bot machine API: one computer open to the agent at level "ask"; records every call. */
+function fakeBot({ machines = null } = {}) {
+ const calls = [];
+ const reply = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+ const fetchImpl = async (url, init = {}) => {
+ const u = new URL(url);
+ const headers = init.headers || {};
+ const body = init.body ? JSON.parse(init.body) : null;
+ calls.push({ method: init.method || 'GET', path: u.pathname + u.search, subject: headers['X-OV-Subject'], auth: headers.Authorization, body });
+ if (u.host === 'tools.test') return reply(200, { tools: [] });
+ if (u.pathname === '/api/v1/machines') {
+ return reply(200, { machines: machines || [{ id: PC, name: 'desk-pc', online: true, agent_computer: true, computer: { level: 'ask', terminal: { level: 'ask' }, files: { level: 'ask', roots: ['/home/me'] }, screen: null, waiting: 0 } }] });
+ }
+ if (u.pathname === `/api/v1/machines/${PC}/commands`) {
+ const v = body.value;
+ if (body.kind === 'exec' && v.argv[0] === 'git') return reply(200, { machine_id: PC, id: 'op', result: 'ack', data: { exit_code: 0, stdout: 'On branch main\n', stderr: '', truncated: false, timed_out: false } });
+ if (body.kind === 'exec') return reply(200, { machine_id: PC, id: 'op', result: 'nack', reason: 'the owner denied it on the machine' });
+ if (v.op === 'list') return reply(200, { result: 'ack', data: { path: v.path, entries: [{ name: 'notes.md', type: 'file', size: 12, mtime: 1 }], total: 1, truncated: false } });
+ if (v.op === 'read') return reply(200, { result: 'ack', data: { path: v.path, size: 12, offset: 0, length: 12, truncated: false, content_b64: Buffer.from('hello notes\n').toString('base64') } });
+ if (v.op === 'write') return reply(200, { result: 'expired', reason: 'no acknowledgement before the deadline' });
+ }
+ if (u.pathname.startsWith('/api/v1/machines/')) return reply(404, { code: 'bot.device_not_found', detail: 'no such machine of yours' });
+ return reply(404, { code: 'not_found' });
+ };
+ return { calls, fetchImpl };
+}
+const tokens = { authHeaders: async () => ({ Authorization: 'Bearer svc-token' }) };
+const tools = (bot) => createOpenVibeTools({ config, fetchImpl: bot.fetchImpl, robots: createRobots({ config, fetchImpl: bot.fetchImpl, tokens }), computers: createComputers({ config, fetchImpl: bot.fetchImpl, tokens }) });
+const as = { requester: `user:${ME}` };
+
+(async () => {
+ await check('only a signed-in person\'s task gets the computer tools', async () => {
+ const bot = fakeBot();
+ const t = tools(bot);
+ const names = async (requester) => (await t.definitions({ requester })).map((d) => d.function.name);
+ for (const n of ['list_my_computers', 'computer_list', 'computer_read', 'computer_run', 'computer_write']) assert.ok((await names(`user:${ME}`)).includes(n), n);
+ for (const r of ['app:app_x', 'agent:agt_x', 'service:live', null]) assert.ok(!(await names(r)).includes('computer_run'), String(r));
+ const refused = JSON.parse(await t.call('computer_run', { machine: PC, argv: ['id'] }, { requester: 'app:app_x' }));
+ assert.match(refused.error, /signed-in person/);
+ assert.ok(!bot.calls.some((c) => c.path.includes('/machines')), 'nothing reached Bot for an app');
+ });
+
+ await check('list_my_computers: the open computers with their level and folders; none says how to open one', async () => {
+ const bot = fakeBot();
+ const list = JSON.parse(await tools(bot).call('list_my_computers', {}, as));
+ assert.deepStrictEqual(list.computers, [{ id: PC, name: 'desk-pc', online: true, level: 'ask', can: ['terminal', 'files'], folders: ['/home/me'] }]);
+ assert.match(list.note, /approve/);
+ assert.ok(bot.calls.every((c) => c.subject === ME && c.auth === 'Bearer svc-token'), 'as the person, with Actor\'s token');
+ const none = JSON.parse(await tools(fakeBot({ machines: [] })).call('list_my_computers', {}, as));
+ assert.deepStrictEqual(none.computers, []);
+ assert.match(none.note, /https:\/\/openvibe\.bot\/machines.*--computer.*Let my agent use this computer/);
+ });
+
+ await check('files and programs go to Bot as the person; refusals and an unanswered approval come back as errors', async () => {
+ const bot = fakeBot();
+ const t = tools(bot);
+ const ls = JSON.parse(await t.call('computer_list', { machine: PC, path: '/home/me' }, as));
+ assert.deepStrictEqual(ls, { path: '/home/me', entries: [{ name: 'notes.md', type: 'file', size: 12 }], truncated: false });
+ const file = JSON.parse(await t.call('computer_read', { machine: PC, path: '/home/me/notes.md' }, as));
+ assert.strictEqual(file.text, 'hello notes\n');
+ const git = JSON.parse(await t.call('computer_run', { machine: PC, argv: ['git', 'status'], cwd: '/home/me' }, as));
+ assert.deepStrictEqual([git.exit_code, git.stdout], [0, 'On branch main\n']);
+ const sent = bot.calls.find((c) => c.method === 'POST' && c.body.kind === 'exec');
+ assert.deepStrictEqual([sent.path, sent.subject, sent.body.value], [`/api/v1/machines/${PC}/commands`, ME, { argv: ['git', 'status'], cwd: '/home/me' }]);
+ assert.ok(sent.body.ms >= 120000, 'room for the person to approve it on the machine');
+ const denied = JSON.parse(await t.call('computer_run', { machine: PC, argv: ['rm', 'x'] }, as));
+ assert.strictEqual(denied.error, 'the owner denied it on the machine');
+ const late = JSON.parse(await t.call('computer_write', { machine: PC, path: '/home/me/a.txt', text: 'hi' }, as));
+ assert.match(late.error, /nobody approved it there/);
+ const written = bot.calls.find((c) => c.body && c.body.value && c.body.value.op === 'write');
+ assert.deepStrictEqual([written.body.value.content_b64, written.body.value.mode], [Buffer.from('hi').toString('base64'), 'create']);
+ });
+
+ await check('bad input never leaves Actor', async () => {
+ const bot = fakeBot();
+ const t = tools(bot);
+ const before = bot.calls.length;
+ assert.match(JSON.parse(await t.call('computer_run', { machine: PC, argv: 'rm -rf /' }, as)).error, /never a shell line/);
+ assert.match(JSON.parse(await t.call('computer_run', { machine: '../admin', argv: ['id'] }, as)).error, /machine id/);
+ assert.match(JSON.parse(await t.call('computer_write', { machine: PC, path: '/x', text: 'y', mode: 'delete' }, as)).error, /mode/);
+ assert.strictEqual(bot.calls.length, before);
+ });
+
+ await check('off without a Bot URL or a client secret', async () => {
+ assert.strictEqual(createComputers({ config: { ...config, services: { ...config.services, botUrl: '' } } }).enabled, false);
+ assert.strictEqual(createComputers({ config: { ...config, oauth: { clientId: 'actor', clientSecret: '' } } }).enabled, false);
+ });
+
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -107,6 +107,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -107,6 +107,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'server/auth/sso.js': 'Network OAuth token and revoke (configured networkInternalUrl)',
'server/auth/sso.js': 'Network OAuth token and revoke (configured networkInternalUrl)',
'server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys',
'server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys',
'server/agents/codes.js': 'the configured OpenVibe.Codes base (ACTOR_CODES_URL) with Actor\'s own service token; the subject must be usr_<ULID> and every task id tsk_<ULID> before it goes in a path; the repository named in the task is data for the person\'s own machine, never fetched here (test/codes.test.js)',
'server/agents/codes.js': 'the configured OpenVibe.Codes base (ACTOR_CODES_URL) with Actor\'s own service token; the subject must be usr_<ULID> and every task id tsk_<ULID> before it goes in a path; the repository named in the task is data for the person\'s own machine, never fetched here (test/codes.test.js)',
+ 'server/agents/computers.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token (bot.computer.control); the machine id must be dev_<ULID> and the subject usr_<ULID> before anything is sent; a path or program is data for the person\'s own machine, never fetched here (test/computers.test.js)',
'server/agents/robots.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token; the robot id must be rob_<ULID> and the subject usr_<ULID> before anything is sent (test/robots.test.js)',
'server/agents/robots.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token; the robot id must be rob_<ULID> and the subject usr_<ULID> before anything is sent (test/robots.test.js)',
'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
'server/agents/tools.js': 'the configured OpenVibe.Tools and OpenVibe.Search bases; ids come from Tools\' catalog, a page URL goes to Tools as input',
'server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',
'server/events-consumer.js': 'OpenVibe.Events: the configured events.url (ACTOR_EVENTS_URL), to create the two ADR-033 subscriptions at boot',