openvibers/openvibe.actor Commits

Hand it to me reaches the person wherever they are: hand_to_person sends one Network notification (Your agent needs you, the step, the link to take the desk over), best effort (#146)

OP OpenVibers committed 17 days ago · 1c7b127499 · 5 files, +118 −2

Changes

ddiff --git a/server/agents/tools.js b/server/agents/tools.js
iindex afa56d1..dbf1714 100644
---- a/server/agents/tools.js
++++ b/server/agents/tools.js
@@@ -2,6 +2,7 @@
const crypto = require('crypto');
const looks = require('./looks');
++const { createNotifier } = require('../notify/client');
/**
* What the OpenVibe runtime can do, as model tools — every one of them an OpenVibe service's public API, called the
@@@ -53,8 +54,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, computers = null, vision = null }) {
++function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console, robots = null, computers = null, vision = null, notifier = null }) {
const { searchUrl, toolsUrl } = config.services;
++ // Desks plan D4: "hand it to me" reaches the person through Network's notifications (server/notify/client.js).
++ const notify = notifier || createNotifier({ config, fetchImpl, log });
const bot = robots || createRobots({ config, fetchImpl });
const pcs = computers || createComputers({ config, fetchImpl });
let catalog = null;
@@@ -237,6 +240,9 @@ function createOpenVibeTools({ config, fetchImpl = globalThis.fetch, now = () =>
const granted = waitForPerson ? waitForPerson(PERSON_WAIT_MS) : 0;
if (!granted) return { done: false, note: 'This task has already waited for the person as long as it may: tell them in your answer what is left for them to do, with the link.', watch };
if (emit) await emit({ step: 'text', text: `Needs you: ${what} Open your agent's desk, take it over, do it, then let go: ${watch}` });
++ // Wherever the person is (Network's bell, and a push when they allowed it), with the link to the desk. Best
++ // effort, never awaited by the wait below.
++ if (notify.enabled) notify.needsYou({ subject: person, what, url: watch }).catch(() => {});
const started = Date.now();
const end = started + granted;
let took = false;
ddiff --git a/server/notify/client.js b/server/notify/client.js
nnew file mode 100644
iindex 0000000..538ebbc
---- /dev/null
++++ b/server/notify/client.js
@@@ -0,0 +1,66 @@
++'use strict';
++/**
++ * OpenVibe.Network notifications for the person a task is for (Desks plan D4, "hand it to me"): when the agent needs
++ * them for a step on its desk, they hear it wherever they are (the bell on every OpenVibe site, and a push when they
++ * allowed it), with the link to take the desk over. The task's owner (a usr_ subject) becomes Network's user id
++ * (GET /internal/identity/resolve, capability identity.subject.resolve), then one notification is pushed as app actor
++ * (POST /internal/notifications/push, capability network.notifications.push). Each with a token of its own, audience
++ * openvibe.network.
++ *
++ * Best effort: off without a client secret, never retried (Network keeps no idempotency record for a push, and a person
++ * told twice is worse than one told once), and a failure never stops a task. → { sent, reason? }
++ */
++const { serviceAuth } = require('openvibe-contracts');
++
++const SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function createNotifier({ config, fetchImpl = globalThis.fetch, tokens: tokensOverride = null, log = console }) {
++ const base = config.networkInternalUrl;
++ const enabled = !!(base && (tokensOverride || (config.oauth && config.oauth.clientSecret)));
++ const tokenFor = (scope) => tokensOverride || serviceAuth.createTokenClient({
++ tokenUrl: `${base}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network', scope, fetchImpl,
++ });
++ const resolveTokens = enabled ? tokenFor('identity.subject.resolve') : null;
++ const pushTokens = enabled ? tokenFor('network.notifications.push') : null;
++
++ async function networkUserId(subject) {
++ const res = await fetchImpl(`${base}/internal/identity/resolve?subject_id=${encodeURIComponent(subject)}`, {
++ headers: { Accept: 'application/json', ...(await resolveTokens.authHeaders()) }, signal: AbortSignal.timeout(10_000),
++ });
++ if (res.status === 404) return null;
++ if (!res.ok) throw new Error(`Network answered ${res.status} to resolve`);
++ const who = await res.json();
++ return who && !who.deleted && who.network_user_id != null ? who.network_user_id : null;
++ }
++
++ /** The agent needs its person for one step on its desk: `what`, the task's `title` and the desk's `url`. */
++ async function needsYou({ subject, what, title = null, url }) {
++ if (!enabled) return { sent: false, reason: 'off' };
++ if (!SUBJECT.test(String(subject || '')) || !/^https:\/\//.test(String(url || ''))) return { sent: false, reason: 'bad_input' };
++ try {
++ const userId = await networkUserId(subject);
++ if (userId == null) return { sent: false, reason: 'unknown_subject' };
++ const message = `${String(what || 'One step only you can do.').slice(0, 300)}${title ? ` (${String(title).slice(0, 120)})` : ''}`;
++ const res = await fetchImpl(`${base}/internal/notifications/push`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await pushTokens.authHeaders()) },
++ body: JSON.stringify({
++ user_id: userId, type: 'AGENT_NEEDS_YOU', category: 'service', priority: 'high', icon: '🙋',
++ title: 'Your agent needs you', message, url, service: 'actor',
++ }),
++ signal: AbortSignal.timeout(10_000),
++ });
++ if (!res.ok) return { sent: false, reason: `network_${res.status}` };
++ const out = await res.json().catch(() => null);
++ return { sent: true, ...(out && out.skipped ? { skipped: true } : {}) };
++ } catch (err) {
++ log.warn && log.warn(`[notify] could not tell the person: ${(err && err.message) || err}`);
++ return { sent: false, reason: 'error' };
++ }
++ }
++
++ return { enabled, needsYou };
++}
++
++module.exports = { createNotifier };
ddiff --git a/test/desk-handoff.test.js b/test/desk-handoff.test.js
iindex 5491860..cce32d3 100644
---- a/test/desk-handoff.test.js
++++ b/test/desk-handoff.test.js
@@@ -40,7 +40,10 @@ function botWith(turns) {
};
return { asked, fetchImpl };
}
--const toolsFor = (bot) => createOpenVibeTools({ config, fetchImpl: bot.fetchImpl, robots: createRobots({ config, fetchImpl: bot.fetchImpl, tokens }), computers: createComputers({ config, fetchImpl: bot.fetchImpl, tokens }) });
++// Network notifications (Desks plan D4): recorded, never sent from these tests.
++const told = [];
++const notifier = { enabled: true, needsYou: async (n) => { told.push(n); return { sent: true }; } };
++const toolsFor = (bot) => createOpenVibeTools({ config, fetchImpl: bot.fetchImpl, robots: createRobots({ config, fetchImpl: bot.fetchImpl, tokens }), computers: createComputers({ config, fetchImpl: bot.fetchImpl, tokens }), notifier });
(async () => {
await check('hand_to_person: the person takes the desk and lets go, and the agent goes on (looking again first)', async () => {
@@@ -66,6 +69,7 @@ const toolsFor = (bot) => createOpenVibeTools({ config, fetchImpl: bot.fetchImpl
assert.strictEqual(said[0].step, 'text');
assert.match(said[0].text, new RegExp(`^Needs you: Sign in to your Google account in the browser\\. .*https://openvibe\\.bot/machines/${PC}/screen\\?desk=${run.name}$`));
assert.ok(bot.asked.every((d) => d === run.name), 'it watched the task\'s own desk');
++ assert.deepStrictEqual(told.at(-1), { subject: ME, what: 'Sign in to your Google account in the browser.', url: out.watch }, 'the person is told wherever they are, with the link');
});
await check('hand_to_person: nobody takes it, the clock may not wait, or there is nothing to do', async () => {
ddiff --git a/test/notify-client.test.js b/test/notify-client.test.js
nnew file mode 100644
iindex 0000000..46694cc
---- /dev/null
++++ b/test/notify-client.test.js
@@@ -0,0 +1,39 @@
++'use strict';
++// Network notifications for "hand it to me" (Desks plan D4, server/notify/client.js): the task's owner is resolved to
++// Network's user id, then one notification is pushed as app actor with the link to the desk; never retried, never thrown.
++const assert = require('assert');
++const { createNotifier } = require('../server/notify/client');
++const { check, done } = require('./helpers/boot');
++
++const ME = 'usr_01M4W5JBFEG3QG9ER8W1A2K0RX';
++const config = { networkInternalUrl: 'http://network.test', oauth: { clientId: 'actor', clientSecret: 'actor-secret' } };
++const tokens = { authHeaders: async () => ({ Authorization: 'Bearer t' }) };
++const reply = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
++
++(async () => {
++ await check('resolve, then one push as app actor with the desk link; a person Network does not know is not told', async () => {
++ const calls = [];
++ const fetchImpl = async (url, init = {}) => {
++ calls.push({ url: String(url), method: init.method || 'GET', body: init.body ? JSON.parse(init.body) : null });
++ if (String(url).includes('/internal/identity/resolve')) return String(url).includes(ME) ? reply(200, { network_user_id: 42 }) : reply(404, { code: 'identity.subject_not_found' });
++ return reply(200, { ok: true });
++ };
++ const n = createNotifier({ config, fetchImpl, tokens, log: { warn() {} } });
++ const url = 'https://openvibe.bot/machines/dev_01M4W5JBFDG2TJ8C4FRDMCWBZ9/screen?desk=desk-0123456789';
++ assert.deepStrictEqual(await n.needsYou({ subject: ME, what: 'Sign in to the airline.', url }), { sent: true });
++ assert.strictEqual(calls[0].url, `http://network.test/internal/identity/resolve?subject_id=${ME}`);
++ assert.deepStrictEqual(calls[1].body, { user_id: 42, type: 'AGENT_NEEDS_YOU', category: 'service', priority: 'high', icon: '🙋', title: 'Your agent needs you', message: 'Sign in to the airline.', url, service: 'actor' });
++ assert.deepStrictEqual(await n.needsYou({ subject: 'usr_01M4W5JBFEG3QG9ER8W1A2K0RY', what: 'x', url }), { sent: false, reason: 'unknown_subject' });
++ assert.strictEqual(calls.filter((c) => c.method === 'POST').length, 1, 'only the known person was told');
++ assert.deepStrictEqual(await n.needsYou({ subject: ME, what: 'x', url: 'http://evil.example/' }), { sent: false, reason: 'bad_input' }, 'only an https link');
++ });
++
++ await check('a failure is an answer, never a throw; off without a client secret', async () => {
++ const n = createNotifier({ config, fetchImpl: async () => { throw new Error('down'); }, tokens, log: { warn() {} } });
++ assert.deepStrictEqual(await n.needsYou({ subject: ME, what: 'x', url: 'https://openvibe.bot/x' }), { sent: false, reason: 'error' });
++ const off = createNotifier({ config: { networkInternalUrl: 'http://network.test', oauth: { clientId: 'actor', clientSecret: '' } } });
++ assert.strictEqual(off.enabled, false);
++ assert.deepStrictEqual(await off.needsYou({ subject: ME, what: 'x', url: 'https://openvibe.bot/x' }), { sent: false, reason: 'off' });
++ });
++ done();
++})();
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 4127233..a7d67b3 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -109,6 +109,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'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/chat/client.js': 'the configured OpenVibe.Chat base (ACTOR_CHAT_URL, loopback) with Actor\'s own service token; the room and the message it answers come from a signed chat.message.created, never from the task (test/chat-asks.test.js)',
++ 'server/notify/client.js': 'the configured OpenVibe.Network internal base (OV_NETWORK_INTERNAL_URL, loopback) with Actor\'s own service tokens: identity resolve and one notification push; the subject is the task\'s owner and the link is the desk page Actor built, never a URL from the task (test/notify-client.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/identity/projects.js': 'Network\'s internal membership read (configured networkInternalUrl) with Actor\'s own service token (network.project.member.read); the project and subject ids are path-encoded and come from a parsed OVRN and a checked usr_ id (test/resource-control.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',