diff --git a/index.html b/index.html index bd69078..c842d2a 100644 --- a/index.html +++ b/index.html @@ -7,7 +7,7 @@ name="description" content="amerc is an agent mercenary layer for embedding, bringing, and hosting agents across vertical software boundaries." /> - + diff --git a/package.json b/package.json index 3f2ef0d..b74ecc8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "amerc-site", - "version": "0.20.0-suite", + "version": "0.21.1-pool", "private": true, "type": "module", "scripts": { diff --git a/server/amerc-api.mjs b/server/amerc-api.mjs index d8501f2..8662443 100644 --- a/server/amerc-api.mjs +++ b/server/amerc-api.mjs @@ -43,8 +43,40 @@ db.exec(` CREATE TABLE IF NOT EXISTS boards ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, data TEXT DEFAULT '', links TEXT DEFAULT '[]', updated_by TEXT DEFAULT '', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL); + + -- agent platform: classes -> instances -> sessions + CREATE TABLE IF NOT EXISTS agent_classes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, name TEXT NOT NULL, owner TEXT NOT NULL, + tags TEXT DEFAULT '', description TEXT DEFAULT '', kind TEXT DEFAULT 'codex', command TEXT DEFAULT '', + visibility TEXT NOT NULL DEFAULT 'public', created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS agent_instances ( + id INTEGER PRIMARY KEY AUTOINCREMENT, class_id INTEGER NOT NULL, owner TEXT NOT NULL, name TEXT DEFAULT '', + accesskey TEXT NOT NULL, visibility TEXT NOT NULL DEFAULT 'public', kind TEXT DEFAULT 'persistent', + status TEXT NOT NULL DEFAULT 'pending', broker TEXT DEFAULT '', tui TEXT DEFAULT '', + last_heartbeat INTEGER, created_at INTEGER NOT NULL); + CREATE TABLE IF NOT EXISTS agent_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, instance_id INTEGER NOT NULL, class_id INTEGER, party TEXT DEFAULT '', + connector TEXT DEFAULT 'ws', status TEXT NOT NULL DEFAULT 'active', started_at INTEGER NOT NULL, ended_at INTEGER); + CREATE TABLE IF NOT EXISTS class_subscriptions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, class_id INTEGER NOT NULL, party TEXT NOT NULL, token TEXT NOT NULL, + created_at INTEGER NOT NULL); `); +// instance lifecycle thresholds +const INSTANCE_LOST_MS = 30 * 1000; // no heartbeat -> lost +const INSTANCE_DOWN_MS = 5 * 60 * 1000; // lost too long -> auto shut down +function sweepInstances() { + const now = Date.now(); + for (const inst of db.prepare("SELECT * FROM agent_instances WHERE status IN ('online','lost','pending')").all()) { + const age = now - (inst.last_heartbeat || inst.created_at); + if (age > INSTANCE_DOWN_MS) db.prepare("UPDATE agent_instances SET status='down' WHERE id=?").run(inst.id); + else if (age > INSTANCE_LOST_MS && inst.status === 'online') db.prepare("UPDATE agent_instances SET status='lost' WHERE id=?").run(inst.id); + } + // close sessions whose instance is down + db.prepare("UPDATE agent_sessions SET status='closed', ended_at=? WHERE status='active' AND instance_id IN (SELECT id FROM agent_instances WHERE status='down')").run(now); +} +setInterval(sweepInstances, 10 * 1000).unref?.(); + // ---- crypto ----------------------------------------------------------------- function hashPassword(pw) { const salt = crypto.randomBytes(16); @@ -146,6 +178,13 @@ const server = http.createServer(async (req, res) => { return send(res, 200, { ok: true, user: publicUser(u) }, { 'Set-Cookie': sessionCookie(signToken({ uid: u.id })) }); } if (path === '/auth/logout' && method === 'POST') return send(res, 200, { ok: true }, { 'Set-Cookie': clearCookie }); + // SSO bridge for nginx auth_request (Gitea reverse-proxy auth). Always 200; sets user header when logged in. + if (path === '/auth/verify' && method === 'GET') { + const idn = identify(req); + const h = {}; + if (idn?.kind === 'user') { h['X-Amerc-User'] = idn.user.handle; h['X-Amerc-Email'] = idn.user.email; } + return send(res, 200, { ok: true, user: idn?.kind === 'user' ? publicUser(idn.user) : null }, h); + } if (path === '/auth/me' && method === 'GET') { const id = identify(req); return send(res, 200, { ok: true, user: id?.kind === 'user' ? publicUser(id.user) : null, agent: id?.kind === 'agent' ? id.name : null }); } if (path === '/auth/password' && method === 'POST') { const id = identify(req); if (id?.kind !== 'user') return send(res, 401, { ok: false, error: 'login required' }); @@ -262,6 +301,131 @@ const server = http.createServer(async (req, res) => { db.prepare('UPDATE boards SET name=?,data=?,links=?,updated_by=?,updated_at=? WHERE id=?').run(b.name ?? bd.name, b.data ?? bd.data, b.links != null ? JSON.stringify(b.links) : bd.links, who(id), Date.now(), bd.id); return send(res, 200, { ok: true }); } if ((m = path.match(/^\/boards\/(\d+)$/)) && method === 'DELETE') { if (!id) return needAuth(); db.prepare('DELETE FROM boards WHERE id=?').run(Number(m[1])); return send(res, 200, { ok: true }); } + // ---------- AGENT PLATFORM: classes -> instances -> sessions ---------- + const isAdmin = id?.kind === 'user' && id.user.role === 'admin'; + const owns = (row) => id && (row.owner === who(id) || isAdmin); + const liveStatus = (inst) => { + if (inst.status === 'down') return 'down'; + const age = Date.now() - (inst.last_heartbeat || inst.created_at); + if (age > INSTANCE_DOWN_MS) return 'down'; + if (age > INSTANCE_LOST_MS && inst.status !== 'pending') return 'lost'; + return inst.status; + }; + const instView = (inst, key = false) => ({ id: inst.id, class_id: inst.class_id, owner: inst.owner, name: inst.name, visibility: inst.visibility, kind: inst.kind, status: liveStatus(inst), broker: inst.broker, last_heartbeat: inst.last_heartbeat, created_at: inst.created_at, ...(key ? { accesskey: inst.accesskey } : {}) }); + const slugify = (s) => String(s || 'agent').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'agent'; + + // public stats for the tavern frontpage + if (path === '/agents/stats' && method === 'GET') { + const classes = db.prepare("SELECT COUNT(*) n FROM agent_classes WHERE visibility='public'").get().n; + const insts = db.prepare('SELECT * FROM agent_instances').all(); + const online = insts.filter((i) => liveStatus(i) === 'online').length; + const sessions = db.prepare("SELECT COUNT(*) n FROM agent_sessions WHERE status='active'").get().n; + const owners = db.prepare('SELECT COUNT(DISTINCT owner) n FROM agent_classes').get().n; + const tagRows = db.prepare("SELECT tags FROM agent_classes WHERE visibility='public'").all(); + const tags = {}; tagRows.forEach((r) => (r.tags || '').split(',').map((t) => t.trim()).filter(Boolean).forEach((t) => (tags[t] = (tags[t] || 0) + 1))); + return send(res, 200, { ok: true, classes, instances: insts.length, online, sessions, owners, tags }); + } + + // browse classes (public + own private) + if (path === '/agents/classes' && method === 'GET') { + const tag = url.searchParams.get('tag'); const q = (url.searchParams.get('q') || '').toLowerCase(); const mine = url.searchParams.get('mine'); + let rows = db.prepare('SELECT * FROM agent_classes ORDER BY updated_at DESC').all(); + const me = id ? who(id) : null; + rows = rows.filter((c) => c.visibility === 'public' || (me && c.owner === me) || isAdmin); + if (mine && me) rows = rows.filter((c) => c.owner === me); + if (tag) rows = rows.filter((c) => (c.tags || '').split(',').map((t) => t.trim()).includes(tag)); + if (q) rows = rows.filter((c) => (c.name + ' ' + c.tags + ' ' + c.description).toLowerCase().includes(q)); + const out = rows.map((c) => { + const insts = db.prepare('SELECT * FROM agent_instances WHERE class_id=?').all(c.id); + return { ...c, tags: (c.tags || '').split(',').map((t) => t.trim()).filter(Boolean), instanceCount: insts.length, onlineCount: insts.filter((i) => liveStatus(i) === 'online').length }; + }); + return send(res, 200, { ok: true, classes: out }); + } + if (path === '/agents/classes' && method === 'POST') { + if (!id) return needAuth(); + const b = await readBody(req) || {}; const now = Date.now(); + const tags = Array.isArray(b.tags) ? b.tags.join(',') : (b.tags || ''); + let slug = slugify(b.slug || b.name); let n = 1; + while (db.prepare('SELECT id FROM agent_classes WHERE slug=?').get(slug)) slug = `${slugify(b.name)}-${++n}`; + const info = db.prepare('INSERT INTO agent_classes(slug,name,owner,tags,description,kind,command,visibility,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?)') + .run(slug, b.name || 'Agent', who(id), tags, b.description || '', b.kind || 'codex', b.command || '', b.visibility === 'private' ? 'private' : 'public', now, now); + return send(res, 200, { ok: true, id: Number(info.lastInsertRowid), slug }); + } + if ((m = path.match(/^\/agents\/classes\/(\d+)$/)) && method === 'GET') { + const c = db.prepare('SELECT * FROM agent_classes WHERE id=?').get(Number(m[1])); if (!c) return send(res, 404, { ok: false, error: 'not found' }); + const me = id ? who(id) : null; + if (c.visibility !== 'public' && !(me && c.owner === me) && !isAdmin) return send(res, 403, { ok: false, error: 'private class' }); + let insts = db.prepare('SELECT * FROM agent_instances WHERE class_id=?').all(c.id); + insts = insts.filter((i) => i.visibility === 'public' || owns(i)).map((i) => instView(i, owns(i))); + return send(res, 200, { ok: true, class: { ...c, tags: (c.tags || '').split(',').map((t) => t.trim()).filter(Boolean) }, instances: insts }); + } + if ((m = path.match(/^\/agents\/classes\/(\d+)$/)) && (method === 'PATCH' || method === 'DELETE')) { + const c = db.prepare('SELECT * FROM agent_classes WHERE id=?').get(Number(m[1])); if (!c) return send(res, 404, { ok: false, error: 'not found' }); + if (!owns(c)) return send(res, 403, { ok: false, error: 'not owner' }); + if (method === 'DELETE') { db.prepare('DELETE FROM agent_classes WHERE id=?').run(c.id); db.prepare('DELETE FROM agent_instances WHERE class_id=?').run(c.id); return send(res, 200, { ok: true }); } + const b = await readBody(req) || {}; const tags = Array.isArray(b.tags) ? b.tags.join(',') : (b.tags ?? c.tags); + db.prepare('UPDATE agent_classes SET name=?,tags=?,description=?,kind=?,command=?,visibility=?,updated_at=? WHERE id=?') + .run(b.name ?? c.name, tags, b.description ?? c.description, b.kind ?? c.kind, b.command ?? c.command, b.visibility ?? c.visibility, Date.now(), c.id); + return send(res, 200, { ok: true }); + } + // subscription token for a class + if ((m = path.match(/^\/agents\/classes\/(\d+)\/subscribe$/)) && method === 'POST') { + if (!id) return needAuth(); const c = db.prepare('SELECT * FROM agent_classes WHERE id=?').get(Number(m[1])); if (!c) return send(res, 404, { ok: false, error: 'not found' }); + const me = who(id); + let sub = db.prepare('SELECT * FROM class_subscriptions WHERE class_id=? AND party=?').get(c.id, me); + if (!sub) { const tok = 'sub_' + crypto.randomBytes(18).toString('base64url'); db.prepare('INSERT INTO class_subscriptions(class_id,party,token,created_at) VALUES(?,?,?,?)').run(c.id, me, tok, Date.now()); sub = { token: tok }; } + return send(res, 200, { ok: true, token: sub.token, classSlug: c.slug }); + } + + // instances + if (path === '/agents/instances' && method === 'GET') { + const classId = url.searchParams.get('classId'); const me = id ? who(id) : null; + let rows = classId ? db.prepare('SELECT * FROM agent_instances WHERE class_id=?').all(Number(classId)) : db.prepare('SELECT * FROM agent_instances').all(); + rows = rows.filter((i) => i.visibility === 'public' || (me && i.owner === me) || isAdmin); + return send(res, 200, { ok: true, instances: rows.map((i) => instView(i, owns(i))) }); + } + if (path === '/agents/instances' && method === 'POST') { + if (!id) return needAuth(); const b = await readBody(req) || {}; + const c = db.prepare('SELECT * FROM agent_classes WHERE id=?').get(Number(b.classId)); if (!c) return send(res, 404, { ok: false, error: 'class not found' }); + const accesskey = 'ik_' + crypto.randomBytes(20).toString('base64url'); + const info = db.prepare('INSERT INTO agent_instances(class_id,owner,name,accesskey,visibility,kind,status,created_at,last_heartbeat) VALUES(?,?,?,?,?,?,?,?,?)') + .run(c.id, who(id), b.name || `${c.name} instance`, accesskey, b.visibility === 'private' ? 'private' : 'public', b.kind || 'persistent', 'pending', Date.now(), Date.now()); + return send(res, 200, { ok: true, id: Number(info.lastInsertRowid), accesskey, classSlug: c.slug, relayWs: `wss://amerc.ai/webagent/ws?role=broker&token=${accesskey}` }); + } + if ((m = path.match(/^\/agents\/instances\/(\d+)$/)) && method === 'GET') { + const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' }); + const akey = url.searchParams.get('accesskey'); const authed = owns(inst) || (akey && akey === inst.accesskey); + if (inst.visibility !== 'public' && !authed) return send(res, 403, { ok: false, error: 'private instance' }); + return send(res, 200, { ok: true, instance: instView(inst, authed), tui: authed ? inst.tui : undefined }); + } + // heartbeat (broker, by accesskey — no session needed) + if ((m = path.match(/^\/agents\/instances\/(\d+)\/heartbeat$/)) && method === 'POST') { + const b = await readBody(req, MAX_UPLOAD) || {}; const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' }); + if (b.accesskey !== inst.accesskey) return send(res, 401, { ok: false, error: 'bad accesskey' }); + const status = b.status === 'down' ? 'down' : 'online'; + db.prepare('UPDATE agent_instances SET status=?,last_heartbeat=?,broker=?,tui=? WHERE id=?').run(status, Date.now(), b.broker || inst.broker, b.tui != null ? String(b.tui).slice(0, 20000) : inst.tui, inst.id); + return send(res, 200, { ok: true, status }); + } + if ((m = path.match(/^\/agents\/instances\/(\d+)$/)) && method === 'DELETE') { + const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' }); + const b = await readBody(req) || {}; if (!owns(inst) && b.accesskey !== inst.accesskey) return send(res, 403, { ok: false, error: 'not authorized' }); + db.prepare("UPDATE agent_instances SET status='down' WHERE id=?").run(inst.id); + db.prepare("UPDATE agent_sessions SET status='closed', ended_at=? WHERE instance_id=? AND status='active'").run(Date.now(), inst.id); + return send(res, 200, { ok: true }); + } + // start a session against an instance + if ((m = path.match(/^\/agents\/instances\/(\d+)\/sessions$/)) && method === 'POST') { + const inst = db.prepare('SELECT * FROM agent_instances WHERE id=?').get(Number(m[1])); if (!inst) return send(res, 404, { ok: false, error: 'not found' }); + const b = await readBody(req) || {}; const authed = owns(inst) || (b.accesskey && b.accesskey === inst.accesskey) || (inst.visibility === 'public' && id); + if (!authed) return send(res, 403, { ok: false, error: 'not authorized — need login (public) or accesskey (private)' }); + const info = db.prepare('INSERT INTO agent_sessions(instance_id,class_id,party,connector,status,started_at) VALUES(?,?,?,?,?,?)') + .run(inst.id, inst.class_id, id ? who(id) : 'anon', b.connector || 'ws', 'active', Date.now()); + return send(res, 200, { ok: true, sessionId: Number(info.lastInsertRowid), relayToken: inst.accesskey, relayWs: `wss://amerc.ai/webagent/ws?role=iframe&token=${inst.accesskey}`, chatboxJs: `https://webagent.amerc.ai/client.js?token=${inst.accesskey}` }); + } + if ((m = path.match(/^\/agents\/sessions\/(\d+)\/close$/)) && method === 'POST') { + db.prepare("UPDATE agent_sessions SET status='closed', ended_at=? WHERE id=?").run(Date.now(), Number(m[1])); return send(res, 200, { ok: true }); + } + return send(res, 404, { ok: false, error: 'not found' }); } catch (err) { return send(res, 500, { ok: false, error: 'server error', detail: String(err && err.message || err) }); diff --git a/server/platform-tests.mjs b/server/platform-tests.mjs new file mode 100644 index 0000000..00b165b --- /dev/null +++ b/server/platform-tests.mjs @@ -0,0 +1,116 @@ +// amerc platform test suite. Spins up the API on a temp DB and exercises every surface. +// Run: node --experimental-sqlite server/platform-tests.mjs +import { spawn } from 'node:child_process'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const PORT = 5310, BASE = `http://127.0.0.1:${PORT}/api`; +const dir = mkdtempSync(join(tmpdir(), 'amerc-test-')); +const srv = spawn(process.execPath, ['--experimental-sqlite', new URL('./amerc-api.mjs', import.meta.url).pathname], { + env: { ...process.env, PORT: String(PORT), AMERC_DB: join(dir, 't.db'), AMERC_FILES: join(dir, 'disk'), AMERC_SECRET: 'test-secret' }, + stdio: 'ignore', +}); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +let pass = 0, fail = 0; const fails = []; +function ok(cond, name) { if (cond) { pass++; } else { fail++; fails.push(name); console.log(' ✗ ' + name); } } +function eq(a, b, name) { ok(JSON.stringify(a) === JSON.stringify(b), `${name} (got ${JSON.stringify(a)}, want ${JSON.stringify(b)})`); } + +let cookies = {}; +function setCookie(res) { const sc = res.headers.get('set-cookie'); if (sc) { const [kv] = sc.split(';'); const [k, v] = kv.split('='); cookies[k] = v; } } +const cookieHdr = () => Object.entries(cookies).map(([k, v]) => `${k}=${v}`).join('; '); +async function req(path, { method = 'GET', body, cookie = true, bearer, raw } = {}) { + const headers = {}; + if (body) headers['Content-Type'] = 'application/json'; + if (cookie && cookieHdr()) headers.Cookie = cookieHdr(); + if (bearer) headers.Authorization = `Bearer ${bearer}`; + const res = await fetch(BASE + path, { method, headers, body: body ? JSON.stringify(body) : undefined }); + setCookie(res); + if (raw) return res; + let j = {}; try { j = await res.json(); } catch {} + return { status: res.status, ...j }; +} + +async function main() { + // wait for server + for (let i = 0; i < 30; i++) { try { const r = await fetch(BASE + '/health'); if (r.ok) break; } catch {} await sleep(150); } + + console.log('\n== auth + SSO =='); + let r = await req('/health'); ok(r.ok && r.version === 2, 'health v2'); + r = await req('/auth/signup', { method: 'POST', body: { email: 'Admin@x.io', password: 'secret123', handle: 'admin' } }); + ok(r.ok && r.user.role === 'admin', 'first signup -> admin'); eq(r.user.email, 'admin@x.io', 'email lowercased'); + r = await req('/auth/me'); ok(r.user && r.user.handle === 'admin', 'me via cookie'); + r = await req('/auth/verify', { raw: true }); ok(r.status === 200 && r.headers.get('x-amerc-user') === 'admin', 'SSO verify header'); + r = await req('/auth/signup', { method: 'POST', body: { email: 'm@x.io', password: 'short' } }); ok(r.status === 400, 'reject short password'); + const adminCookies = { ...cookies }; + // member + cookies = {}; r = await req('/auth/signup', { method: 'POST', body: { email: 'mercA@x.io', password: 'hunter22', handle: 'mercA' } }); + ok(r.user.role === 'member', 'second signup -> member'); + r = await req('/admin/users'); ok(r.status === 403, 'member blocked from admin'); + r = await req('/auth/password', { method: 'POST', body: { currentPassword: 'WRONG', newPassword: 'newpass1' } }); ok(r.status === 401, 'pw change wrong current'); + r = await req('/auth/password', { method: 'POST', body: { currentPassword: 'hunter22', newPassword: 'newpass1' } }); ok(r.ok, 'pw change ok'); + r = await req('/auth/login', { method: 'POST', body: { email: 'mercA@x.io', password: 'newpass1' } }); ok(r.ok, 'login with new pw'); + const memberCookies = { ...cookies }; + + console.log('== admin + agent keys =='); + cookies = adminCookies; + r = await req('/admin/users'); ok(r.ok && r.users.length === 2, 'admin lists users'); + r = await req('/admin/keys', { method: 'POST', body: { name: 'fleet' } }); const agentKey = r.key; ok(r.ok && agentKey?.startsWith('amk_'), 'mint agent key'); + cookies = {}; r = await req('/auth/me', { bearer: agentKey }); ok(r.agent === 'fleet', 'agent identity via Bearer'); + + console.log('== docs / netdisk / boards (agent + user) =='); + cookies = {}; r = await req('/docs', { method: 'POST', bearer: agentKey, body: { title: 'Agent Doc', body: '# hi' } }); const docId = r.id; ok(r.ok, 'agent writes doc'); + cookies = adminCookies; r = await req(`/docs/${docId}`); ok(r.doc.updated_by === 'agent:fleet', 'doc authored by agent'); + r = await req('/files', { method: 'POST', body: { name: 'n.md', kind: 'text', text: 'hello', mime: 'text/markdown' } }); const fileId = r.id; ok(r.ok, 'create text file'); + r = await req(`/files/${fileId}/raw`, { raw: true }); ok((await r.text()) === 'hello', 'download text file'); + r = await req('/boards', { method: 'POST', body: { name: 'B', data: '{}', links: [{ fileId }] } }); ok(r.ok, 'create board'); + cookies = {}; r = await req('/docs'); ok(r.status === 401, 'docs need auth'); + + console.log('== agent platform: classes =='); + cookies = adminCookies; + r = await req('/agents/classes', { method: 'POST', body: { name: 'Backend Coder', tags: ['backend', 'model'], description: 'api', visibility: 'public' } }); + const classId = r.id; ok(r.ok && r.slug === 'backend-coder', 'publish class + slug'); + await req('/agents/classes', { method: 'POST', body: { name: 'Frontend Pixie', tags: ['frontend'] } }); + cookies = memberCookies; await req('/agents/classes', { method: 'POST', body: { name: 'Secret Bot', tags: ['x'], visibility: 'private' } }); + cookies = {}; r = await req('/agents/classes'); ok(r.classes.length === 2, 'browse: only public classes anon'); + r = await req('/agents/classes?tag=backend'); eq(r.classes.map((c) => c.name), ['Backend Coder'], 'filter by tag'); + cookies = memberCookies; r = await req('/agents/classes?mine=1'); ok(r.classes.length === 1 && r.classes[0].name === 'Secret Bot', 'mine shows own private'); + cookies = adminCookies; r = await req('/agents/classes'); ok(r.classes.length === 3, 'admin sees private too'); + r = await req(`/agents/classes/${classId}/subscribe`, { method: 'POST' }); ok(r.token?.startsWith('sub_'), 'subscription token'); + + console.log('== agent platform: instances + lifecycle =='); + cookies = adminCookies; + r = await req('/agents/instances', { method: 'POST', body: { classId, visibility: 'public' } }); + const instId = r.id, accesskey = r.accesskey; ok(r.ok && accesskey?.startsWith('ik_'), 'create instance + accesskey'); + ok(r.relayWs.includes(accesskey), 'instance returns relay WS'); + r = await req(`/agents/instances/${instId}`); eq(r.instance.status, 'pending', 'instance starts pending'); + r = await req(`/agents/instances/${instId}/heartbeat`, { method: 'POST', cookie: false, body: { accesskey, broker: 'kebab', tui: 'booting' } }); eq(r.status, 'online', 'heartbeat -> online'); + r = await req(`/agents/instances/${instId}`); eq(r.instance.status, 'online', 'instance online'); + r = await req(`/agents/instances/${instId}/heartbeat`, { method: 'POST', cookie: false, body: { accesskey: 'WRONG' } }); ok(r.status === 401, 'bad accesskey rejected'); + // stats reflect it + r = await req('/agents/stats'); ok(r.classes === 2 && r.instances === 1 && r.online === 1, 'stats classes/instances/online'); + // session + r = await req(`/agents/instances/${instId}/sessions`, { method: 'POST', body: { connector: 'chatbox' } }); + ok(r.ok && r.relayToken === accesskey && r.chatboxJs.includes(accesskey), 'start session -> relay token + chatbox js'); + r = await req('/agents/stats'); ok(r.sessions === 1, 'stats sessions'); + // private instance needs accesskey for a non-owner + cookies = adminCookies; r = await req('/agents/instances', { method: 'POST', body: { classId, visibility: 'private' } }); + const privId = r.id, privKey = r.accesskey; + cookies = memberCookies; r = await req(`/agents/instances/${privId}`); ok(r.status === 403, 'private instance hidden from non-owner'); + r = await req(`/agents/instances/${privId}?accesskey=${privKey}`); ok(r.ok, 'private instance visible with accesskey'); + // shutdown + cookies = adminCookies; r = await req(`/agents/instances/${instId}`, { method: 'DELETE', body: { accesskey } }); ok(r.ok, 'shutdown instance'); + r = await req(`/agents/instances/${instId}`); eq(r.instance.status, 'down', 'instance down after shutdown'); + + console.log('== webagent broker login =='); + cookies = {}; + r = await req('/webagent/login', { method: 'POST', body: { username: 'admin@x.io', password: 'secret123' } }); ok(r.ok && r.token?.startsWith('wa_'), 'webagent login (amerc user)'); + r = await req('/webagent/login', { method: 'POST', body: { username: 'admin@x.io', password: 'nope' } }); ok(r.status === 401, 'webagent login bad pw'); + + console.log(`\n${fail ? '❌' : '✅'} ${pass} passed, ${fail} failed`); + if (fail) console.log('FAILED: ' + fails.join(' | ')); + srv.kill(); rmSync(dir, { recursive: true, force: true }); + process.exit(fail ? 1 : 0); +} +main().catch((e) => { console.error(e); srv.kill(); process.exit(1); }); diff --git a/src/AgentPlatform.jsx b/src/AgentPlatform.jsx new file mode 100644 index 0000000..8f91b32 --- /dev/null +++ b/src/AgentPlatform.jsx @@ -0,0 +1,233 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { api } from './api.js'; + +const STATUS_COLOR = { online: '#36f0b0', pending: '#f2b85f', lost: '#ff9f5a', down: '#ff7a8c' }; + +// ---- Browse Agents: all public classes, filter by tag -------------------- +export function AgentBrowse({ auth, setRoute }) { + const [classes, setClasses] = useState([]); + const [tags, setTags] = useState({}); + const [tag, setTag] = useState(''); + const [q, setQ] = useState(''); + const [open, setOpen] = useState(null); + const [err, setErr] = useState(''); + + const load = useCallback(async () => { + setErr(''); + try { + const [c, s] = await Promise.all([api(`/agents/classes${tag ? `?tag=${encodeURIComponent(tag)}` : ''}`), api('/agents/stats')]); + setClasses((c.classes || []).filter((x) => !q || (x.name + x.tags.join() + x.description).toLowerCase().includes(q.toLowerCase()))); + setTags(s.tags || {}); + } catch (e) { setErr(e.message); } + }, [tag, q]); + useEffect(() => { load(); }, [load]); + + return ( +
+
+

Browse Agents

+ setQ(e.target.value)} /> +
+
+ + {Object.entries(tags).sort((a, b) => b[1] - a[1]).map(([t, n]) => ( + + ))} +
+ {err &&

{err}

} +
+ {classes.map((c) => ( + + ))} + {!classes.length &&

No agent classes yet. Open My Booth to publish one.

} +
+ {open && { setOpen(null); load(); }} />} +
+ ); +} + +function ClassModal({ id, auth, onClose }) { + const [data, setData] = useState(null); + const [sub, setSub] = useState(null); + const [use, setUse] = useState(null); // instance to use + const [err, setErr] = useState(''); + const load = useCallback(() => api(`/agents/classes/${id}`).then(setData).catch((e) => setErr(e.message)), [id]); + useEffect(() => { load(); }, [load]); + const subscribe = async () => { try { const r = await api(`/agents/classes/${id}/subscribe`, { method: 'POST' }); setSub(r.token); } catch (e) { setErr(e.message); } }; + if (!data) return null; + const c = data.class; + return ( +
+
e.stopPropagation()}> +
+

{c.name}

{c.kind} by {c.owner}
+ +
+

{c.description}

+
{c.tags.map((t) => {t})}
+ {err &&

{err}

} +
+ {auth.user ? : Log in to subscribe.} + {sub && navigator.clipboard?.writeText(sub)}>{sub} ⧉} +
+

Instances

+
+ {data.instances.map((i) => ( +
+ + #{i.id} {i.name} + {i.status}{i.broker ? ` · ${i.broker}` : ''} · {i.visibility} + +
+ ))} + {!data.instances.length &&

No instances published. The owner can publish one in My Booth.

} +
+ {use && setUse(null)} />} +
+
+ ); +} + +// ---- Use an instance: chatbox + web-pty ---------------------------------- +export function UsePanel({ instance, onClose }) { + const [mode, setMode] = useState('chatbox'); + const [sess, setSess] = useState(null); + const [accesskey, setAccesskey] = useState(instance.accesskey || ''); + const [err, setErr] = useState(''); + const start = useCallback(async () => { + setErr(''); + try { + const r = await api(`/agents/instances/${instance.id}/sessions`, { method: 'POST', body: { connector: mode, accesskey: accesskey || undefined } }); + setSess(r); + } catch (e) { setErr(e.message); } + }, [instance.id, mode, accesskey]); + useEffect(() => { if (instance.accesskey) start(); }, []); // owner: auto-start + + return ( +
+
+
+ + +
+ +
+ {!sess && ( +
+ {!instance.accesskey && instance.visibility !== 'public' && ( + setAccesskey(e.target.value)} /> + )} + + {err &&

{err}

} +
+ )} + {sess && ( + <> +