openvibers/openvibe.actor Commits

Actor: the runtime acts on the person's own OpenVibe.Bot robots (list_my_robots, robot_steps; only where the owner opened the robot to their agent) (#47)

OP OpenVibers committed 14 Oct 2026 · 1c8f4ab816 · 9 files, +206 −14

Plan T15 and T17: "an Actor agent can pilot a robot under the owner's grant, always through Bot". The Actor runtime now acts on the person's own OpenVibe.Bot robots.

**Tools** (`server/agents/robots.js`, wired in `server/agents/tools.js`)
- `list_my_robots` lists the person's robots.
  - For each robot the person opened to their agent: online, the e-stop, battery, sensor readings and the step lines it accepts (Bot's `GET /api/v1/robots/:id/steps`).
  - For a robot that is not open, it says how to open it: "Let my OpenVibe.Actor agent drive this robot" at `openvibe.bot/panel/<id>#agent`.
- `robot_steps(robot, steps[])` runs step lines (`forward 1`, `left 0.5`, `say Hello`, `headlights #00ff00`, …) through Bot's `POST /api/v1/robots/:id/steps`.
  - At most 20 steps and 30 seconds per call.
  - Answers `done` or `stopped: <why>`.
  - A refusal from Bot comes back to the model as an error with Bot's code.
- Only a signed-in person's task (`user:usr_…`) gets these tools. An app, agent or service task never sees them, and calling one anyway is refused before anything reaches Bot.
- The runtime's system prompt gains one paragraph when the tools are present: act only when the task asks, keep motions short, report what the robot answered.

**How Actor calls Bot**
- With its own client-credentials token: audience `openvibe.bot`, scope `bot.robot.read bot.robot.control`. The grants shipped in OpenVibe.Network#175.
- `X-OV-Subject` names the person.
- Bot (OpenVibe.Bot#94) answers only for the robot's owner, and only where that owner turned the switch on. Its gate, limits, cooldowns and e-stop judge every step, and its audit records each one as `svc:actor`.
- The robot id must be `rob_<ULID>` and the subject `usr_<ULID>` before a request is made.
- `ACTOR_BOT_URL` (default `http://127.0.0.1:4630`; empty turns the tools off) and `ACTOR_BOT_PUBLIC_URL` (the panel links).
- The engine passes the task's requester to the adapter. The SSRF ratchet lists the new outbound site.

**Tests**
- New `test/robots.test.js`:
  - only a person's task gets the tools;
  - the list with state, steps and the open-it note;
  - steps sent as the person with Actor's token;
  - Bot's refusal surfaced;
  - a bad id or no steps never leaves;
  - off without a URL or secret.
- The full suite passes: 26/26.

Changes

ddiff --git a/README.md b/README.md
iindex 4d087d3..f4652b5 100644
---- a/README.md
++++ b/README.md
@@@ -100,6 +100,7 @@ The resource index is for OpenVibe.Services on loopback and is blocked at the pu
- **OpenVibe.Network** (`OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`): sign-in (OAuth2 + PKCE, client `actor`), the JWKS that verifies session and app tokens, and the internal routes (with this service's own client-credentials token) where account export parts and deletion confirmations are pushed.
- **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.
- **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.
ddiff --git a/STATUS.json b/STATUS.json
iindex 5219c81..3763d64 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -44,6 +44,7 @@
"features": [
"agent router (OpenRouter for agents)",
"OpenVibe runtime acting through OpenVibe services",
++ "the person's own robots (agents/robots.js): list_my_robots + robot_steps on OpenVibe.Bot for a signed-in person's task only, with Actor's service token (bot.robot.read/control) and X-OV-Subject = the person; Bot allows it only on robots whose owner turned on agent_control",
"other platforms' agents",
"private mode on OpenVibe hardware",
"cross-family answer checks",
ddiff --git a/server/agents/adapters.js b/server/agents/adapters.js
iindex 3af819c..fc7abec 100644
---- a/server/agents/adapters.js
++++ b/server/agents/adapters.js
@@@ -6,7 +6,8 @@
* task's cost and the person's spend are true even when a run is cancelled half-way. `budgetLeft()` is checked
* before every paid call; an adapter that would go over stops and says so.
*
-- * openvibe-runtime DeepSeek with OpenVibe's services as tools (agents/tools.js), up to MAX_TURNS model calls
++ * openvibe-runtime DeepSeek with OpenVibe's services as tools (agents/tools.js), up to MAX_TURNS model calls; a
++ * signed-in person's task also gets their own robots (agents/robots.js)
* openai-agent OpenAI's Responses agent with its hosted web search
* open-model the open model on OpenVibe's own server, one call, no tools
*/
@@@ -17,8 +18,9 @@ const MAX_TURNS = 10;
const today = () => new Date().toISOString().slice(0, 10);
--const RUNTIME_SYSTEM = () => `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.
++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.
++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.` : ''}
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>").`;
@@@ -28,7 +30,7 @@ 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 || {})}` : 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(', ')}` : JSON.stringify(a));
return String(v).slice(0, 300);
};
@@@ -42,10 +44,11 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
const openaiAgent = p.openai.apiKey ? responsesAgent({ baseUrl: p.openai.baseUrl, apiKey: p.openai.apiKey, model: p.openai.model, rates: rates.openai, fetchImpl }) : null;
const local = p.local.url && p.local.model ? chatClient({ baseUrl: p.local.url, apiKey: '', model: p.local.model, rates: rates.local, fetchImpl, timeoutMs: 120_000 }) : null;
-- async function runtime({ task, signal, emit, meter, budgetLeft }) {
++ async function runtime({ task, requester = null, signal, emit, meter, budgetLeft }) {
if (!deepseek) throw new ProviderError('the runtime has no model configured', { code: 'actor.agent.unavailable' });
-- const tools = await ovTools.definitions({ signal });
-- const messages = [{ role: 'system', content: RUNTIME_SYSTEM() }, { role: 'user', content: task }];
++ 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 }];
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' });
@@@ -62,7 +65,7 @@ function createAdapters({ config, rates, fetchImpl = globalThis.fetch, log = con
let args = {};
try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { args = {}; }
await emit({ step: 'tool_call', tool: name, input: summarize(args) });
-- const out = await ovTools.call(name, args, { signal });
++ const out = await ovTools.call(name, args, { signal, requester });
const isError = /^\{"error"/.test(out);
await emit({ step: 'tool_result', tool: name, text: out.slice(0, 600), is_error: isError });
messages.push({ role: 'tool', tool_call_id: call.id, content: out });
ddiff --git a/server/agents/robots.js b/server/agents/robots.js
nnew file mode 100644
iindex 0000000..c5cefef
---- /dev/null
++++ b/server/agents/robots.js
@@@ -0,0 +1,75 @@
++'use strict';
++
++/**
++ * The person's own robots on OpenVibe.Bot, as two runtime tools (plan T15/T17: an Actor agent pilots a robot under the
++ * owner's grant, always through Bot). Actor calls Bot's public API with its own service token (audience openvibe.bot,
++ * bot.robot.read + bot.robot.control) and X-OV-Subject naming the person the task is for. Bot answers only for the
++ * robot's owner, and only where that owner turned on "Let my OpenVibe.Actor agent drive this robot"; its gate, limits,
++ * cooldowns and e-stop judge every step, and its audit records each one as svc:actor.
++ *
++ * createRobots({ config, fetchImpl, tokens? }) → { enabled, mine(subject), steps(subject, robotId, lines) }
++ * mine → [{ id, name, model, agent_control, online?, estop?, battery?, sensors?, steps?[], panel }]
++ * steps → { result: 'done' | 'stopped: …', steps } or { error }
++ *
++ * Off (enabled false) without ACTOR_BOT_URL or without Actor's client secret.
++ */
++const { serviceAuth } = require('openvibe-contracts');
++
++const ROBOT_ID = /^rob_[0-9A-HJKMNP-TV-Z]{26}$/;
++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function createRobots({ 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.robot.read bot.robot.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 });
++
++ async function mine(subject, { signal } = {}) {
++ if (!USER_SUBJECT.test(subject)) return { error: 'robots are only for a signed-in person' };
++ const r = await call('GET', `/robots?owner=${encodeURIComponent(subject)}`, subject, { signal });
++ if (!r.ok) return problem(r, 'your robots');
++ const out = [];
++ for (const robot of (r.data && r.data.robots) || []) {
++ const item = { id: robot.id, name: robot.name, model: robot.profile_id, agent_control: !!robot.agent_control, panel: `${pages}/panel/${robot.id}` };
++ if (!robot.agent_control) {
++ item.note = `Not open to the agent. The owner can turn on "Let my OpenVibe.Actor agent drive this robot" at ${pages}/panel/${robot.id}#agent`;
++ } else {
++ const s = await call('GET', `/robots/${robot.id}/steps`, subject, { signal });
++ if (s.ok && s.data) Object.assign(item, { online: s.data.online, estop: s.data.estop, battery: s.data.battery, sensors: s.data.sensors, steps: s.data.steps, max_steps: s.data.max_steps, max_seconds: s.data.max_seconds });
++ else item.note = problem(s, 'the robot').error;
++ }
++ out.push(item);
++ }
++ return out;
++ }
++
++ async function steps(subject, robotId, lines, { signal } = {}) {
++ if (!USER_SUBJECT.test(subject)) return { error: 'robots are only for a signed-in person' };
++ if (!ROBOT_ID.test(String(robotId || ''))) return { error: 'robot must be a robot id from list_my_robots (rob_…)' };
++ const text = Array.isArray(lines) ? lines.map(String).join('\n') : String(lines || '');
++ if (!text.trim()) return { error: 'steps are one or more lines, for example "forward 1" and "say Hello"' };
++ // The robot runs the steps before Bot answers: up to 30 s of steps, plus room for the device's replies.
++ const r = await call('POST', `/robots/${robotId}/steps`, subject, { body: { steps: text.slice(0, 2000) }, signal, timeoutMs: 45_000 });
++ if (!r.ok) return problem(r, 'the steps');
++ return { result: r.data.result, steps: r.data.steps };
++ }
++
++ return { enabled, mine, steps };
++}
++
++module.exports = { createRobots };
ddiff --git a/server/agents/tools.js b/server/agents/tools.js
iindex c99002c..8eddd69 100644
---- a/server/agents/tools.js
++++ b/server/agents/tools.js
@@@ -9,19 +9,28 @@
* run_tool(tool, input) OpenVibe.Tools' run API (POST /api/v1/tools/<id>/run): DNS, mail records, certificates,
* headers, WHOIS, conversions, hashes, … — only tools the catalog marks callable by anyone
* read_page(url) OpenVibe.Tools' page reader, when Tools offers it: a web page as text
++ * 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
*
* 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 CATALOG_TTL_MS = 10 * 60_000;
const RESULT_CHARS = 6000;
const clip = (s, n = RESULT_CHARS) => (s.length > n ? `${s.slice(0, n)}\n…(clipped)` : s);
const json = (v) => clip(JSON.stringify(v));
--function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
++/** 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 }) {
const { searchUrl, toolsUrl } = config.services;
++ const bot = robots || createRobots({ config, fetchImpl });
let catalog = null;
let catalogAt = 0;
const schemas = new Map();
@@@ -91,7 +100,7 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
}
/** The function definitions the runtime's model sees, and the dispatcher that runs one call. */
-- async function definitions({ signal } = {}) {
++ async function definitions({ signal, requester = null } = {}) {
const list = await tools({ signal });
const defs = [
{ type: 'function', function: { name: 'search_openvibe', description: 'Search everything published on the OpenVibe network (streams, clips, pastes, community posts, wiki pages, blog posts, threads). Returns titles, summaries and links.', parameters: { type: 'object', properties: { query: { type: 'string' } }, required: ['query'] } } },
@@@ -101,12 +110,19 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
if (list.some((t) => t.id === 'read')) {
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);
return defs;
}
-- async function call(name, args, { signal } = {}) {
++ async function call(name, args, { signal, requester = null } = {}) {
const a = args && typeof args === 'object' ? args : {};
try {
++ if (name === 'list_my_robots' || name === 'robot_steps') {
++ const person = personOf(requester);
++ if (!bot.enabled || !person) return json({ error: 'robots are only for a signed-in person\'s task' });
++ if (name === 'list_my_robots') return json(await bot.mine(person, { signal }));
++ return json(await bot.steps(person, a.robot, a.steps, { 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 }));
@@@ -118,7 +134,12 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
}
}
-- return { definitions, call, tools, findTools, runTool, searchOpenVibe };
++ return { definitions, call, tools, findTools, runTool, searchOpenVibe, robots: bot };
}
--module.exports = { createOpenVibeTools };
++const ROBOT_DEFS = [
++ { type: 'function', function: { name: 'list_my_robots', description: 'List the robots the person owns on OpenVibe.Bot. For each robot they opened to their agent: whether it is online, the e-stop, battery, sensor readings and the step lines it accepts. A robot not opened to the agent says how the person can open it.', parameters: { type: 'object', properties: {} } } },
++ { 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'] } } },
++];
++
++module.exports = { createOpenVibeTools, personOf };
ddiff --git a/server/config.js b/server/config.js
iindex d45de45..7a2dcb1 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -65,6 +65,9 @@ function load(env = process.env) {
services: {
searchUrl: trim(env.ACTOR_SEARCH_URL || 'https://search.openvibe.network'),
toolsUrl: trim(env.ACTOR_TOOLS_URL || 'https://openvibe.tools'),
++ // OpenVibe.Bot: the person's own robots (list_my_robots, robot_steps). Empty turns the robot tools off.
++ botUrl: trim(env.ACTOR_BOT_URL != null ? env.ACTOR_BOT_URL : 'http://127.0.0.1:4630'),
++ botPublicUrl: trim(env.ACTOR_BOT_PUBLIC_URL || 'https://openvibe.bot'),
},
// What one person may spend without paying (plan T17: "free tier from the bounded allowance"), and the
ddiff --git a/server/tasks/engine.js b/server/tasks/engine.js
iindex a7b940e..bcb42a1 100644
---- a/server/tasks/engine.js
++++ b/server/tasks/engine.js
@@@ -113,7 +113,7 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
await emit(id, { kind: 'output', step: attempt > 1 ? 'handoff' : 'plan', agent: agentId, text: `${agent.name} takes it${placement.reasons && placement.reasons[0] ? `: ${placement.reasons[0]}` : ''}.` });
let out;
try {
-- out = await adapters.run[agentId]({ task: row.task, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });
++ out = await adapters.run[agentId]({ task: row.task, requester: row.requester, cls, mode: row.mode, signal, meter, budgetLeft, emit: (e) => emit(id, { kind: 'output', agent: agentId, ...e }) });
} catch (err) {
if (signal.aborted) throw err;
const why = (err && err.message) || String(err);
ddiff --git a/test/robots.test.js b/test/robots.test.js
nnew file mode 100644
iindex 0000000..ca0a359
---- /dev/null
++++ b/test/robots.test.js
@@@ -0,0 +1,87 @@
++'use strict';
++// The person's own robots as runtime tools (plan T15/T17, agents/robots.js): only a signed-in person's task sees
++// list_my_robots and robot_steps; Actor calls OpenVibe.Bot with its own service token and X-OV-Subject naming that
++// person; a robot not opened to the agent says how to open it; Bot's refusals come back to the model as errors.
++const assert = require('assert');
++const { createOpenVibeTools, personOf } = require('../server/agents/tools');
++const { createRobots } = require('../server/agents/robots');
++const { check, done } = require('./helpers/boot');
++
++
++const ME = 'usr_01M4W5JBFEG3QG9ER8W1A2K0RX';
++const OPEN = 'rob_01M4W5JBFDG2TJ8C4FRDMCWBZ9';
++const CLOSED = 'rob_01M4W5JBFDG2TJ8C4FRDMCWBZ8';
++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: the owner's two robots, one opened to the agent; records every call. */
++function fakeBot() {
++ 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 || {};
++ calls.push({ method: init.method || 'GET', path: u.pathname + u.search, subject: headers['X-OV-Subject'], auth: headers.Authorization, body: init.body ? JSON.parse(init.body) : null });
++ if (u.host === 'tools.test') return reply(200, { tools: [] });
++ if (u.pathname === '/api/v1/robots') return reply(200, { robots: [{ id: OPEN, name: 'Desk rover', profile_id: 'sim.rover', agent_control: true }, { id: CLOSED, name: 'Cozmo', profile_id: 'cozmo', agent_control: false }] });
++ if (u.pathname === `/api/v1/robots/${OPEN}/steps` && (init.method || 'GET') === 'GET') return reply(200, { robot_id: OPEN, online: true, estop: { latched: false }, battery: 0.8, sensors: { distance_cm: 42 }, steps: ['forward 2', 'stop'], max_steps: 20, max_seconds: 30 });
++ if (u.pathname === `/api/v1/robots/${OPEN}/steps`) return reply(200, { robot_id: OPEN, result: 'done', steps: 2 });
++ if (u.pathname === `/api/v1/robots/${CLOSED}/steps`) return reply(403, { code: 'bot.agent_not_allowed', detail: 'the owner has not let their agent drive this robot' });
++ return reply(404, { code: 'not_found' });
++ };
++ return { calls, fetchImpl };
++}
++const tokens = { authHeaders: async () => ({ Authorization: 'Bearer svc-token' }) };
++
++(async () => {
++ await check('only a signed-in person\'s task gets the robot tools', async () => {
++ const bot = fakeBot();
++ const t = createOpenVibeTools({ config, fetchImpl: bot.fetchImpl, robots: createRobots({ config, fetchImpl: bot.fetchImpl, tokens }) });
++ const names = async (requester) => (await t.definitions({ requester })).map((d) => d.function.name);
++ assert.ok((await names(`user:${ME}`)).includes('robot_steps') && (await names(`user:${ME}`)).includes('list_my_robots'));
++ for (const r of ['app:app_x', 'agent:agt_x', 'service:live', null]) assert.ok(!(await names(r)).includes('robot_steps'), String(r));
++ assert.strictEqual(personOf(`user:${ME}`), ME);
++ assert.strictEqual(personOf('app:app_x'), null);
++ const refused = JSON.parse(await t.call('robot_steps', { robot: OPEN, steps: ['forward 1'] }, { requester: 'app:app_x' }));
++ assert.match(refused.error, /signed-in person/);
++ assert.ok(!bot.calls.some((c) => c.path.includes('/robots')), 'nothing reached Bot for an app');
++ });
++
++ await check('list_my_robots: the person\'s robots, state and steps for the opened one, how to open the other', async () => {
++ const bot = fakeBot();
++ const t = createOpenVibeTools({ config, fetchImpl: bot.fetchImpl, robots: createRobots({ config, fetchImpl: bot.fetchImpl, tokens }) });
++ const list = JSON.parse(await t.call('list_my_robots', {}, { requester: `user:${ME}` }));
++ assert.strictEqual(list.length, 2);
++ assert.deepStrictEqual([list[0].online, list[0].steps, list[0].sensors.distance_cm], [true, ['forward 2', 'stop'], 42]);
++ assert.match(list[1].note, /Let my OpenVibe\.Actor agent drive this robot.*https:\/\/openvibe\.bot\/panel\/rob_\w+#agent/);
++ assert.ok(bot.calls.filter((c) => c.path.startsWith('/api/v1/robots')).every((c) => c.subject === ME && c.auth === 'Bearer svc-token'), 'as the person, with Actor\'s token');
++ assert.ok(bot.calls.some((c) => c.path === `/api/v1/robots?owner=${ME}`));
++ assert.ok(!bot.calls.some((c) => c.path === `/api/v1/robots/${CLOSED}/steps`), 'a closed robot is not asked');
++ });
++
++ await check('robot_steps: step lines go to Bot as the person; a refusal comes back as an error; bad input never leaves', async () => {
++ const bot = fakeBot();
++ const t = createOpenVibeTools({ config, fetchImpl: bot.fetchImpl, robots: createRobots({ config, fetchImpl: bot.fetchImpl, tokens }) });
++ const ok = JSON.parse(await t.call('robot_steps', { robot: OPEN, steps: ['forward 1', 'stop'] }, { requester: `user:${ME}` }));
++ assert.deepStrictEqual(ok, { result: 'done', steps: 2 });
++ const sent = bot.calls.find((c) => c.method === 'POST');
++ assert.deepStrictEqual([sent.path, sent.subject, sent.body], [`/api/v1/robots/${OPEN}/steps`, ME, { steps: 'forward 1\nstop' }]);
++ const no = JSON.parse(await t.call('robot_steps', { robot: CLOSED, steps: ['forward 1'] }, { requester: `user:${ME}` }));
++ assert.deepStrictEqual([no.code, /not let their agent/.test(no.error)], ['bot.agent_not_allowed', true]);
++ const before = bot.calls.length;
++ assert.match(JSON.parse(await t.call('robot_steps', { robot: '../admin', steps: ['x'] }, { requester: `user:${ME}` })).error, /robot id/);
++ assert.match(JSON.parse(await t.call('robot_steps', { robot: OPEN, steps: [] }, { requester: `user:${ME}` })).error, /one or more lines/);
++ assert.strictEqual(bot.calls.length, before, 'neither reached Bot');
++ });
++
++ await check('off without a Bot URL or a client secret', async () => {
++ assert.strictEqual(createRobots({ config: { ...config, services: { ...config.services, botUrl: '' } } }).enabled, false);
++ assert.strictEqual(createRobots({ config: { ...config, oauth: { clientId: 'actor', clientSecret: '' } } }).enabled, false);
++ const t = createOpenVibeTools({ config: { ...config, services: { ...config.services, botUrl: '' } }, fetchImpl: fakeBot().fetchImpl });
++ assert.ok(!(await t.definitions({ requester: `user:${ME}` })).some((d) => d.function.name === 'robot_steps'));
++ });
++
++ done();
++})();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 9f4501f..9d22770 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -106,6 +106,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'server/auth/keys.js': 'no request itself; openvibe-sdk/auth fetches the JWKS from the configured Network',
'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/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',
'server/net/webhook-post.js': 'a task\'s webhook URL (plan T17), the one caller-chosen address: https on 443/8443 to public addresses only, checked at creation and at send time through openvibe-shared/egress safeLookup, never a redirect (test/webhooks.test.js)',