openvibers/openvibe.network Commits

Relay owner agent sheets through Network frame (#817)

OP OpenVibers committed 2 days ago · 629e94a95e · 2 files, +61 −5

* Relay owner agent sheets through Network frame

* Relay coding agent sheets through Network frame

Changes

ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex 74ffc413..9ed820df 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -780,7 +780,7 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
const dockAuth = (req) => (/^Bearer [A-Za-z0-9._~+/=-]{10,4096}$/.test(String(req.get('authorization') || '')) ? req.get('authorization') : '');
const DOCK_ID = /^[A-Za-z0-9._~:-]{1,160}$/;
const dockId = (v) => (DOCK_ID.test(String(v || '')) ? String(v) : '');
-- function dockRelay(service, method, upstream, { image = false } = {}) {
++ function dockRelay(service, method, upstream, { image = false, maxBytes = 64 * 1024 } = {}) {
const upstreamPath = typeof upstream === 'function' ? upstream : () => upstream;
const fallback = service === 'codes' ? 'http://127.0.0.1:4900' : 'http://127.0.0.1:4950';
return async (req, res) => {
@@@ -801,13 +801,14 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
}
const text = await r.text();
res.set('Content-Type', 'application/json; charset=utf-8');
-- return res.status(r.status).send(text.slice(0, 64 * 1024));
++ if (Buffer.byteLength(text) > maxBytes) return res.status(502).json({ error: 'The Dock service answer was too large' });
++ return res.status(r.status).send(text);
} catch { return res.status(502).json({ error: 'The Dock service did not answer' }); }
};
}
const dockJson = express.json({ limit: '8kb' });
-- router.options(['/agent-view', '/agent-view/*', '/agent/*', '/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', 'Access-Control-Max-Age': '600' });
++ 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' });
res.status(204).end();
});
// OpenVibe.Codes: the person's agent stream, its last still, and the four controls.
@@@ -836,6 +837,22 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
router.get('/actor', dockRelay('actor', 'GET', '/api/v1/me/actor'));
for (const what of ['pause', 'resume', 'tell']) router.post(`/actor/${what}`, dockJson, dockRelay('actor', 'POST', `/api/v1/me/actor/${what}`));
for (const what of ['answer', 'decline']) router.post(`/actor/asks/:id/${what}`, dockJson, dockRelay('actor', 'POST', (req) => (dockId(req.params.id) ? `/api/v1/me/actor/asks/${encodeURIComponent(req.params.id)}/${what}` : null)));
++ // One owner's agent sheet on any site. Actor checks ownership and capabilities; the relay accepts only its
++ // fixed ID shapes and passes the person's Bearer upstream. The larger answer includes up to 100 work records.
++ const AGENT_ID = /^agt_[0-9A-HJKMNP-TV-Z]{26}$/;
++ const MEMORY_ID = /^mem_[0-9A-HJKMNP-TV-Z]{26}$/;
++ const agentPath = (req, tail = '') => AGENT_ID.test(String(req.params.id || '')) ? `/api/v1/me/agents/${req.params.id}${tail}` : null;
++ router.get('/agents/:id/sheet', dockRelay('actor', 'GET', (req) => {
++ const path = agentPath(req);
++ return path ? `${path}?view=sheet` : null;
++ }, { maxBytes: 512 * 1024 }));
++ router.get('/codes-agents/:id/sheet', dockRelay('codes', 'GET', (req) =>
++ /^[a-z][a-z0-9-]{0,63}$/.test(String(req.params.id || '')) ? `/api/v1/me/agents/${req.params.id}/sheet` : null,
++ { maxBytes: 512 * 1024 }));
++ router.patch('/agents/:id', dockJson, dockRelay('actor', 'PATCH', (req) => agentPath(req)));
++ router.post('/agents/:id/memory', dockJson, dockRelay('actor', 'POST', (req) => agentPath(req, '/memory')));
++ router.patch('/agents/:id/memory/:mem', dockJson, dockRelay('actor', 'PATCH', (req) => MEMORY_ID.test(String(req.params.mem || '')) ? agentPath(req, `/memory/${req.params.mem}`) : null));
++ router.delete('/agents/:id/memory/:mem', dockRelay('actor', 'DELETE', (req) => MEMORY_ID.test(String(req.params.mem || '')) ? agentPath(req, `/memory/${req.params.mem}`) : null));
// OpenVibe.Codes' person tasks API (the Dock's Code tab): the latest tasks running first, one start, one stop.
// ?limit= is the list's cap (the Dock asks with 8); the stop's id is a tsk_ id, anything else is not a task.
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
ddiff --git a/test/frame-dock.test.js b/test/frame-dock.test.js
iindex a70da8c1..999c669c 100644
---- a/test/frame-dock.test.js
++++ b/test/frame-dock.test.js
@@@ -11,6 +11,8 @@ const { getDb } = require('../server/db/database');
const { createFrameService } = require('../server/frame/service');
const TOKEN = 'Bearer abcdefghijklmnop.qrstu';
++const AGENT_ID = 'agt_00000000000000000000000000';
++const MEMORY_ID = 'mem_00000000000000000000000000';
// A one-pixel JPEG: the still the Dock shows when nothing is live.
const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0xff, 0xd9]);
@@@ -22,7 +24,8 @@ const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49,
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
-- asked.push({ svc: name, method: req.method, url: req.url, auth: req.headers.authorization || '', cookie: req.headers.cookie || '', body: Buffer.concat(chunks).toString('utf8') });
++ req.body = Buffer.concat(chunks).toString('utf8');
++ asked.push({ svc: name, method: req.method, url: req.url, auth: req.headers.authorization || '', cookie: req.headers.cookie || '', body: req.body });
if (down) { res.statusCode = 503; return res.end('{"error":"down"}'); }
return handler(req, res, new URL(req.url, 'http://x'));
});
@@@ -30,6 +33,10 @@ const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49,
return server;
};
const codes = stub('codes', (req, res, u) => {
++ if (u.pathname === '/api/v1/me/agents/codex/sheet' && req.method === 'GET') {
++ res.setHeader('content-type', 'application/json');
++ return res.end(JSON.stringify({ agent: { id: 'codex', name: 'Codex' }, work: [] }));
++ }
if (u.pathname === '/api/v1/me/agent-view' && req.method === 'GET') {
res.setHeader('content-type', 'application/json');
return res.end(JSON.stringify({ agent: { id: 'ags_1', state: 'live', title: 'Ship it' } }));
@@@ -47,6 +54,10 @@ const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49,
});
const actor = stub('actor', (req, res, u) => {
res.setHeader('content-type', 'application/json');
++ if (u.pathname === `/api/v1/me/agents/${AGENT_ID}` && u.searchParams.get('view') === 'sheet') return res.end(JSON.stringify({ agent: { id: AGENT_ID, name: 'Helper' }, work: [], asks_empty: 'Requests to approve or decline will appear here.' }));
++ if (u.pathname === `/api/v1/me/agents/${AGENT_ID}` && req.method === 'PATCH') return res.end(JSON.stringify({ status: JSON.parse(req.body || '{}').status }));
++ if (u.pathname === `/api/v1/me/agents/${AGENT_ID}/memory` && req.method === 'POST') { res.statusCode = 201; return res.end(JSON.stringify({ text: JSON.parse(req.body || '{}').text })); }
++ if (u.pathname === `/api/v1/me/agents/${AGENT_ID}/memory/${MEMORY_ID}` && (req.method === 'PATCH' || req.method === 'DELETE')) { res.statusCode = req.method === 'DELETE' ? 204 : 200; return res.end(req.method === 'DELETE' ? '' : JSON.stringify({ text: JSON.parse(req.body || '{}').text })); }
if (u.pathname === '/api/v1/agent/timeline' && req.method === 'GET') return res.end(JSON.stringify({ now: null, needs_you: [], entries: [], counts: { mode: u.searchParams.get('mode') } }));
if (/^\/api\/v1\/agent\/approvals\/[a-z]+\/[A-Za-z0-9._~-]+\/decide$/.test(u.pathname) && req.method === 'POST') { res.statusCode = 201; return res.end(JSON.stringify({ product: 'actor', state: 'approved' })); }
if (u.pathname === '/api/v1/agent/kill-switch' && req.method === 'POST') return res.end(JSON.stringify({ ok: true }));
@@@ -154,6 +165,34 @@ const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49,
assert.ok(asked.some((x) => x.url === `/api/v1/me/actor/asks/ask_1/${what}` && x.method === 'POST'));
}
++ // The sheet and its owner edits use fixed paths, pass only the Bearer, and reject malformed IDs locally.
++ ({ r, d } = await get(`/agents/${AGENT_ID}/sheet`, { Authorization: TOKEN, Cookie: 'ov_token=secret' }));
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(d.agent.name, 'Helper');
++ hit = asked.at(-1);
++ assert.strictEqual(hit.url, `/api/v1/me/agents/${AGENT_ID}?view=sheet`);
++ assert.strictEqual(hit.auth, TOKEN);
++ assert.strictEqual(hit.cookie, '');
++ const sheetCalls = asked.length;
++ ({ r } = await get(`/agents/${AGENT_ID}/sheet`));
++ assert.strictEqual(r.status, 401);
++ ({ r } = await get('/agents/bad/sheet', { Authorization: TOKEN }));
++ assert.strictEqual(r.status, 404);
++ assert.strictEqual(asked.length, sheetCalls);
++ ({ r, d } = await get('/codes-agents/codex/sheet', { Authorization: TOKEN }));
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(d.agent.id, 'codex');
++ assert.strictEqual(asked.at(-1).url, '/api/v1/me/agents/codex/sheet');
++ const patch = await fetch(`${base}/agents/${AGENT_ID}`, { method: 'PATCH', headers: { Authorization: TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ status: 'paused' }) });
++ assert.strictEqual((await patch.json()).status, 'paused');
++ ({ r, d } = await send(`/agents/${AGENT_ID}/memory`, { text: 'Keep this fact' }));
++ assert.strictEqual(r.status, 201);
++ assert.strictEqual(d.text, 'Keep this fact');
++ const editMemory = await fetch(`${base}/agents/${AGENT_ID}/memory/${MEMORY_ID}`, { method: 'PATCH', headers: { Authorization: TOKEN, 'Content-Type': 'application/json' }, body: JSON.stringify({ text: 'Revised fact' }) });
++ assert.strictEqual((await editMemory.json()).text, 'Revised fact');
++ const removeMemory = await fetch(`${base}/agents/${AGENT_ID}/memory/${MEMORY_ID}`, { method: 'DELETE', headers: { Authorization: TOKEN } });
++ assert.strictEqual(removeMemory.status, 204);
++
// A preflight is answered for every path; an upstream status passes back, and a service that is not there is
// a 502, never a hang.
const opt = await fetch(`${base}/agent/timeline`, { method: 'OPTIONS' });