openvibers/openvibe.network Commits

Relay AG-UI runs to Dock cards (#821)

OP OpenVibers committed 2 days ago · c5e252e23f · 3 files, +72 −5

* Relay owner AG-UI runs for Dock cards

* Relay Actor runs for live chat cards

* Allow resumable run headers in frame preflight

* Review AG-UI relay outbound call

Changes

ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 9213a773..f59b7568 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -814,8 +814,8 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
};
}
const dockJson = express.json({ limit: '8kb' });
-- router.options(['/agent-view', '/agent-view/*', '/agent/*', '/agents/*', '/codes-agents/*', '/tasks/*', '/actor', '/actor/*', '/codes-tasks', '/codes-tasks/*'], (req, res) => {
-- res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Authorization, Content-Type', 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE', 'Access-Control-Max-Age': '600' });
++ router.options(['/agent-view', '/agent-view/*', '/agent/*', '/agents/*', '/codes-agents/*', '/tasks/*', '/actor', '/actor/*', '/codes-tasks', '/codes-tasks/*', '/codes-runs/*', '/agent-runs/*'], (req, res) => {
++ res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Authorization, Content-Type, Last-Event-ID', 'Access-Control-Allow-Methods': 'GET, POST, PATCH, DELETE', 'Access-Control-Max-Age': '600' });
res.status(204).end();
});
// OpenVibe.Codes: the person's agent stream, its last still, and the four controls.
@@@ -869,6 +869,44 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
}));
router.post('/codes-tasks', dockJson, dockRelay('codes', 'POST', '/api/v1/tasks'));
router.post('/codes-tasks/:id/stop', dockJson, dockRelay('codes', 'POST', (req) => (TASK_ID.test(String(req.params.id || '')) ? `/api/v1/tasks/${req.params.id}/stop` : null)));
++ // A task card follows its AG-UI run through Network. Codes authorizes the bearer on both reads; the
++ // stream forwards only SSE and its id so a reconnect can resume at the last event.
++ const RUN_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}(?:~[1-9][0-9]*)?$/;
++ router.get('/codes-runs/:id', dockRelay('codes', 'GET', (req) => RUN_ID.test(String(req.params.id || ''))
++ ? `/api/v1/runs/${encodeURIComponent(req.params.id)}` : null, { maxBytes: 512 * 1024 }));
++ router.get('/agent-runs/actor/:id', dockRelay('actor', 'GET', (req) => RUN_ID.test(String(req.params.id || ''))
++ ? `/api/v1/runs/${encodeURIComponent(req.params.id)}` : null, { maxBytes: 512 * 1024 }));
++ const runEvents = (service) => async (req, res) => {
++ res.set({ 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'private, no-store', 'Vary': 'Authorization' });
++ const auth = dockAuth(req);
++ const id = String(req.params.id || '');
++ if (!auth) return res.status(401).json({ error: 'sign in to follow the run' });
++ if (!RUN_ID.test(id)) return res.status(404).json({ error: 'not found' });
++ const last = String(req.get('last-event-id') || '0');
++ if (!/^\d{1,16}$/.test(last)) return res.status(400).json({ error: 'invalid event id' });
++ const abort = new AbortController();
++ res.on('close', () => abort.abort());
++ try {
++ const upstream = await fetch(new URL(`/api/v1/runs/${encodeURIComponent(id)}/events`, svcUrl(service, service === 'codes' ? 'http://127.0.0.1:4900' : 'http://127.0.0.1:4950')).href, {
++ headers: { Authorization: auth, Accept: 'text/event-stream', 'Last-Event-ID': last }, redirect: 'manual', signal: abort.signal
++ });
++ if (!upstream.ok || !String(upstream.headers.get('content-type') || '').startsWith('text/event-stream')) {
++ return res.status(upstream.status).json({ error: 'run stream unavailable' });
++ }
++ res.status(200).set({ 'Content-Type': 'text/event-stream', 'X-Accel-Buffering': 'no' });
++ res.flushHeaders();
++ for await (const chunk of upstream.body) {
++ if (abort.signal.aborted) break;
++ if (!res.write(chunk)) await new Promise((resolve) => {
++ const done = () => { res.off('drain', done); res.off('close', done); resolve(); };
++ res.once('drain', done); res.once('close', done);
++ });
++ }
++ res.end();
++ } catch { if (!res.headersSent) res.status(502).json({ error: 'run stream unavailable' }); else res.end(); }
++ };
++ router.get('/codes-runs/:id/events', runEvents('codes'));
++ router.get('/agent-runs/actor/:id/events', runEvents('actor'));
// GET /api/frame/suggest?q=&owner=&limit= — the navbar's magnifier (openvibe-shared search.js): OpenVibe.Search's
// suggestions, asked through Network because every site's connect-src already lets the navbar reach
ddiff --git a/test/frame-codes-tasks.test.js b/test/frame-codes-tasks.test.js
iindex 05ee00d3..fb1cc976 100644
---- a/test/frame-codes-tasks.test.js
++++ b/test/frame-codes-tasks.test.js
@@@ -19,7 +19,7 @@ const TASK = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const u = new URL(req.url, 'http://x');
-- asked.push({ method: req.method, url: req.url, auth: req.headers.authorization || '', cookie: req.headers.cookie || '', body: Buffer.concat(chunks).toString('utf8') });
++ asked.push({ method: req.method, url: req.url, auth: req.headers.authorization || '', cookie: req.headers.cookie || '', last: req.headers['last-event-id'] || '', body: Buffer.concat(chunks).toString('utf8') });
if (down) { req.socket.destroy(); return; }
const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
if (u.pathname === '/api/v1/me/tasks' && req.method === 'GET') {
@@@ -31,12 +31,17 @@ const TASK = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
return json(201, { id: TASK, url: `https://openvibe.codes/tasks/${TASK}` });
}
if (u.pathname === `/api/v1/tasks/${TASK}/stop` && req.method === 'POST') return json(200, { ok: true });
++ if (u.pathname === `/api/v1/runs/${TASK}` && req.method === 'GET') return json(200, { id: TASK, title: 'A task', plan: [] });
++ if (u.pathname === `/api/v1/runs/${TASK}/events` && req.method === 'GET') {
++ res.writeHead(200, { 'Content-Type': 'text/event-stream' });
++ return res.end('id: 3\nevent: STEP_STARTED\ndata: {"type":"STEP_STARTED","stepName":"Read files"}\n\n');
++ }
res.writeHead(404, { 'Content-Type': 'application/json' });
return res.end('{"error":"no route"}');
});
});
await new Promise((r) => codes.listen(0, '127.0.0.1', r));
-- const svc = await createFrameService(getDb(), { services: { codes: { internalUrl: `http://127.0.0.1:${codes.address().port}` } } }, null);
++ const svc = await createFrameService(getDb(), { services: { codes: { internalUrl: `http://127.0.0.1:${codes.address().port}` }, actor: { internalUrl: `http://127.0.0.1:${codes.address().port}` } } }, null);
const app = express();
app.set('trust proxy', 'loopback');
app.use('/api/frame', svc.router);
@@@ -95,9 +100,33 @@ const TASK = 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3';
assert.strictEqual(r.status, 404);
assert.strictEqual(asked.length, n, 'a bad id never goes upstream');
++ // The card's snapshot and resumable AG-UI stream carry the same owner token.
++ ({ r, d } = await get(`/codes-runs/${TASK}`, { Authorization: TOKEN }));
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(d.title, 'A task');
++ const events = await fetch(`${base}/codes-runs/${TASK}/events`, { headers: { Authorization: TOKEN, 'Last-Event-ID': '2' } });
++ assert.strictEqual(events.status, 200);
++ assert.match(await events.text(), /id: 3\nevent: STEP_STARTED/);
++ hit = asked.find((x) => x.url === `/api/v1/runs/${TASK}/events`);
++ assert.strictEqual(hit.auth, TOKEN);
++ assert.strictEqual(hit.last, '2');
++ assert.strictEqual(hit.cookie, '');
++ ({ r } = await get('/codes-runs/nope', { Authorization: TOKEN }));
++ assert.strictEqual(r.status, 404);
++ ({ r } = await get(`/codes-runs/${TASK}`));
++ assert.strictEqual(r.status, 401);
++ ({ r, d } = await get(`/agent-runs/actor/${TASK}`, { Authorization: TOKEN }));
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(d.id, TASK);
++ const actorEvents = await fetch(`${base}/agent-runs/actor/${TASK}/events`, { headers: { Authorization: TOKEN } });
++ assert.match(await actorEvents.text(), /STEP_STARTED/);
++
// Preflights end here.
const pre = await fetch(`${base}/codes-tasks`, { method: 'OPTIONS' });
assert.strictEqual(pre.status, 204);
++ const streamPre = await fetch(`${base}/codes-runs/${TASK}/events`, { method: 'OPTIONS', headers: { Origin: 'https://openvibe.chat', 'Access-Control-Request-Headers': 'authorization,last-event-id' } });
++ assert.strictEqual(streamPre.status, 204);
++ assert.match(streamPre.headers.get('access-control-allow-headers'), /Last-Event-ID/);
// Codes down: a 502 that says so.
down = true;
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 38954001..4f22ea07 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -131,7 +131,7 @@ const REVIEWED = {
'server/coins/routes.js fetch': [1, 'Billing internal URL from config (the person\'s Vibes with their own token, 2 s timeout)'],
'server/today/receipts.js fetch': [1, 'Mail\'s internal URL from config (OV_MAIL_INTERNAL_URL, loopback 5250): the person\'s own token to the fixed path /api/v1/me/mail/receipts, 2.5 s timeout; never a request value names a host'],
'server/wallet/routes.js fetch': [2, 'Billing internal URL from config (OV_BILLING_INTERNAL_URL, loopback 4600) only: the person\'s own token for /api/me/activity and every /api/me read and write, and — only while /api/me/activity is missing — the marked temporary journal fallback, a billing.balance.read service read scoped to the signed-in person\'s own usr_ id; the paths are fixed, a charge id must be a vch_ ULID and is percent-encoded, and no request value ever names a host. The second call site is Network\'s own loopback (config.internalUrl, else 127.0.0.1:<port>): the fixed path POST /internal/subject-assertions, which signs the person\'s own token into a 60-second subject assertion; the person\'s token is in the body, never in the URL, and no request value names a host'],
-- 'server/frame/service.js fetch': [7, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links); Community internal URL from config for the menu composer relay (POST /api/frame/status, fixed path, the person\'s Bearer only); Billing and Inventory internal URLs from config for the wallet relays (/api/frame/wallet/…, fixed paths, a usr_/itd_ id must match its pattern, the person\'s Bearer only, no redirects, 8 s); Codes and Actor internal URLs from config for the Dock relays (/api/frame/agent-view…, /agent/timeline, /agent/approvals/…, /agent/kill-switch, /tasks/…, /actor…, fixed paths, product and id matched against patterns and percent-encoded, the person\'s Bearer only, no cookie, no redirects, 15 s)'],
++ 'server/frame/service.js fetch': [8, 'The composer\'s pictures and posts go to OpenVibe.Community\'s fixed paths (POST /api/v1/spaces/status/attachments, POST /api/v1/posts) on its internal URL. Live and AI internal URLs from config (navbar ranking copy); https://<open site host>/privacy, hosts from the site registry only, no redirects, 5 s (legal links); Community internal URL from config for the menu composer relay (POST /api/frame/status, fixed path, the person\'s Bearer only); Billing and Inventory internal URLs from config for the wallet relays (/api/frame/wallet/…, fixed paths, a usr_/itd_ id must match its pattern, the person\'s Bearer only, no redirects, 8 s); Codes and Actor internal URLs from config for the Dock relays (/api/frame/agent-view…, /agent/timeline, /agent/approvals/…, /agent/kill-switch, /tasks/…, /actor…, fixed paths, product and id matched against patterns and percent-encoded, the person\'s Bearer only, no cookie, no redirects, 15 s); the AG-UI run stream relay uses a fixed /api/v1/runs/:id/events path, a validated task id, the trusted service URL, the person\'s Bearer, and no redirects'],
'server/frame/menu-cards.js fetch': [2, 'https://<open site host>/.well-known/openvibe-menu.json, hosts from the site registry only, no redirects, 5 s, 64 KB (menu cards)'],
'server/stats/open.js fetch': [1, 'open stats (/stats): fixed first-party loopback URLs only — Live (OV_LIVE_INTERNAL_URL, its public home numbers and service-token analytics totals) and Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; the request only picks 7, 30 or 90 days'],
'server/admin/service-proxies.js fetch': [1, 'the admin panel proxies: Live\'s and Chat\'s internal URLs from config, fixed path prefixes, staff only'],