UNPKG

@adonis-agora/agent

Version:

Governed, durable-backed AI agent (chat + tool-calling + governance) for AdonisJS — part of the Agora ecosystem.

648 lines • 29.8 kB
import { createHmac, randomBytes } from 'node:crypto'; import { calculateJwkThumbprint } from 'jose'; import { AgentIdentity, ConversationClient, ConversationError, completeDirectSignIn, connectMcp, discover, mediatedSignIn, newMessageId, OAuthError, PoppyAgent, PoppyError, pollDeviceSignInOnce, ResourceAuthError, StateStore, startDirectSignIn, submitMediatedCode, } from '../poppy-client/index.js'; import { DEFAULT_POPPY_CONNECTIONS_TEXTS, normalizeDomain, } from './config.js'; import { Sealer } from './crypto.js'; /** A Company outside the allow-list. The model is told plainly, and nothing is fetched. */ export class PoppyCompanyNotAllowedError extends Error { domain; constructor(domain) { super(`${domain} is not a company this assistant may connect to`); this.domain = domain; this.name = 'PoppyCompanyNotAllowedError'; } } const DEFAULT_READ_SCOPES = ['poppy:read']; /** * This app's agent acting on other Poppy Companies for its users: the agent identity, the * connections (sealed at rest), the connect flows and the calls the tools make. * * @experimental Tracks Personal Agent Protocol Draft 0.1 — will change as the spec evolves. */ export class PoppyConnections { #names = new Map(); texts; path; baseUrl; insecureDev; #config; #store; #sealer; #secret; #locks = new Map(); #onConnected; #log; #identity = null; constructor(options) { const { config } = options; if (config.insecureDev === true && options.production) { throw new Error('[@adonis-agora/agent] poppy-connections: `insecureDev` is for development only and cannot be on in production.'); } this.#config = config; this.#store = options.store; this.#sealer = new Sealer(options.secret); this.#secret = options.secret; this.#onConnected = options.onConnected; this.#log = options.log ?? (() => { }); this.insecureDev = config.insecureDev === true; this.texts = { ...DEFAULT_POPPY_CONNECTIONS_TEXTS, ...config.texts }; this.path = (config.path ?? 'poppy-agent').replace(/^\/+|\/+$/g, ''); this.baseUrl = config.baseUrl.replace(/\/+$/, ''); } /** How long a connect link works, in ms (`connectTtlSeconds`, default 15 minutes). */ get connectTtlMs() { return (this.#config.connectTtlSeconds ?? 900) * 1000; } /** The agent's display name (its `client_name`). */ get identityName() { return this.#config.identity.name; } /** The app's agent identity (`client_id` = `{baseUrl}/{path}/agent.json`). */ async identity() { if (this.#identity === null) { this.#identity = new AgentIdentity({ baseUrl: `${this.baseUrl}/${this.path}`, clientName: this.#config.identity.name, ...(this.#config.identity.logoUri !== undefined ? { logoUri: this.#config.identity.logoUri } : {}), keys: await normalizeKeys(this.#config.identity.keys), }, { insecureDev: this.insecureDev }); } return this.#identity; } ownerOf(actor) { return this.#config.owner?.(actor) ?? actor.tenantRef ?? actor.id; } /** The policy for `domain`, or `null` when it is not allow-listed (default: nothing is). */ policy(domain) { const d = normalizeDomain(domain); const companies = this.#config.companies; if (companies === undefined) return null; if (typeof companies === 'function') return companies(d) ? {} : null; if (Array.isArray(companies)) { return companies.map(normalizeDomain).includes(d) ? {} : null; } for (const [key, policy] of Object.entries(companies)) { if (normalizeDomain(key) === d) return policy; } return null; } /** The allow-listed Companies (when the allow-list is a list or a map). */ allowedCompanies() { const companies = this.#config.companies; if (companies === undefined || typeof companies === 'function') return []; if (Array.isArray(companies)) return companies.map((d) => ({ domain: normalizeDomain(d) })); return Object.entries(companies).map(([d, p]) => ({ domain: normalizeDomain(d), ...(p.label !== undefined ? { label: p.label } : {}), })); } /** * The allow-listed domain the model meant by `company`. A model often names a company rather than * its domain ("MeuProntoo", "meuprontoo.com" for `localhost:3841`): when `company` is not itself * allowed, it resolves to the ONE allowed company whose label, or the organization of the * owner's connection to it, it names. Ambiguous or unknown → `company` as given (then refused). */ async resolveCompany(owner, company) { const given = normalizeDomain(company); if (this.policy(given) !== null) return given; const key = (s) => s.toLowerCase().replace(/[^a-z0-9]/g, ''); const wanted = new Set([key(given), key(given.split('.')[0] ?? '')].filter(Boolean)); const connected = await this.#store.listConnections(owner); const allowed = this.allowedCompanies(); const names = await Promise.all(allowed.map(async (c) => [ c.label, connected.find((x) => x.domain === c.domain)?.organization, await this.organizationOf(c.domain), ] .filter((n) => typeof n === 'string' && n !== '') .map(key))); const matches = allowed.filter((_, i) => names[i].some((n) => wanted.has(n))); return matches.length === 1 ? matches[0].domain : given; } /** * The organization an allow-listed company names itself in its `poppy.json` (cached ten * minutes), or `undefined` when it cannot be reached — no owner, no sign-in needed. */ async organizationOf(domain) { const d = normalizeDomain(domain); if (this.policy(d) === null) return undefined; const cached = this.#names.get(d); if (cached && cached.until > Date.now()) return cached.name; let name; try { const found = await discover(d, { insecureDev: this.insecureDev, ...(this.#config.fetch !== undefined ? { fetch: this.#config.fetch } : {}), }); name = found.document.organization.name; } catch { name = undefined; } this.#names.set(d, { name, until: Date.now() + (name ? 600_000 : 30_000) }); return name; } // ── state ────────────────────────────────────────────────────────────────── /** Serialize everything one owner does: Account Tokens rotate, and a lost rotation is a lost login. */ async #exclusive(owner, fn) { const previous = this.#locks.get(owner) ?? Promise.resolve(); const run = previous.catch(() => { }).then(fn); const tail = run.catch(() => { }); this.#locks.set(owner, tail); try { return await run; } finally { if (this.#locks.get(owner) === tail) this.#locks.delete(owner); } } /** A `PoppyAgent` over this owner's connections; every save seals them back to the store. */ async #agent(owner) { const rows = await this.#store.listConnections(owner); const byIssuer = new Map(rows.map((row) => [row.issuer, row])); const domains = new Map(rows.map((row) => [row.issuer, row.domain])); const companies = {}; for (const row of rows) companies[row.issuer] = this.#sealer.open(row.state); const data = { version: 1, profiles: { [owner]: { companies } } }; const store = StateStore.memory(data, async (current) => { const now = Date.now(); for (const [issuer, state] of Object.entries(current.profiles[owner]?.companies ?? {})) { const existing = byIssuer.get(issuer); const domain = domains.get(issuer) ?? existing?.domain ?? state.domains[0] ?? issuer; const status = state.accountToken ? 'active' : existing?.status === 'active' || existing?.status === 'revoked' ? 'revoked' : 'pending'; const record = { owner, issuer, domain, organization: existing?.organization ?? domain, scope: state.accountToken?.scope ?? '', status, state: this.#sealer.seal(state), createdAt: existing?.createdAt ?? now, updatedAt: now, }; await this.#store.saveConnection(record); byIssuer.set(issuer, record); } }); const identity = await this.identity(); const agent = new PoppyAgent({ store, identity, profile: owner, insecureDev: this.insecureDev, ...(this.#config.fetch !== undefined ? { fetch: this.#config.fetch } : {}), log: (line) => this.#log(`poppy-connections: ${line}`), }); return { agent, domains }; } /** Discover `domain` (allow-listed) and load this owner's state there. */ async #company(owner, domain) { const d = normalizeDomain(domain); const policy = this.policy(d); if (policy === null) throw new PoppyCompanyNotAllowedError(d); const { agent, domains } = await this.#agent(owner); // The requested domain (with its port) is what rediscovers the Company later. const discovered = await agent.company(d); domains.set(discovered.issuer, d); const row = await this.#store.getConnection(owner, discovered.issuer); const organization = discovered.document.organization.name; if (row !== null && (row.domain !== d || row.organization !== organization)) { await this.#store.saveConnection({ ...row, domain: d, organization }); } return { client: discovered, policy }; } /** Run `fn` with this owner's client at `domain`, serialized per owner. */ withCompany(owner, domain, fn) { return this.#exclusive(owner, async () => { const { client, policy } = await this.#company(owner, domain); return fn(client, policy); }); } // ── connections ──────────────────────────────────────────────────────────── async list(owner) { const rows = await this.#store.listConnections(owner); return rows.map((row) => ({ domain: row.domain, organization: row.organization, status: row.status, scope: row.scope, connectedSince: row.status === 'active' ? row.updatedAt : null, })); } /** The connection at `domain`, without contacting it. */ async connection(owner, domain) { const d = normalizeDomain(domain); return (await this.list(owner)).find((c) => c.domain === d) ?? null; } /** Sign out at the Company (revokes the Account Token, §4.9) and mark the connection revoked. */ async disconnect(owner, domain) { return this.withCompany(owner, domain, async (client) => { let revoked = false; try { revoked = await client.signOut(); } catch (error) { this.#log(`poppy-connections: revocation at ${client.issuer} failed: ${error.message}`); client.state.accountToken = undefined; client.state.tokens = {}; await client.save(); } const row = await this.#store.getConnection(owner, client.issuer); if (row !== null) await this.#store.saveConnection({ ...row, status: 'revoked', scope: '' }); return revoked || row !== null; }); } // ── connect flows ────────────────────────────────────────────────────────── /** * Start a connect: the link the person opens (web or WhatsApp — it is a URL), bound to the parked * `connect` call so completing it resumes the conversation. `id` makes a replayed call land on the * same link. */ async startConnect(input) { const owner = this.ownerOf(input.actor); const id = input.id ?? randomBytes(16).toString('base64url'); const existing = await this.#store.getPending(id); if (existing !== null) { return { id, url: this.connectUrl(id), organization: existing.organization, scope: existing.scope, }; } return this.withCompany(owner, input.domain, async (client, policy) => { const scope = input.scope ?? policy.scopes?.join(' ') ?? offeredScopes(client).join(' '); const now = Date.now(); await this.#store.createPending({ id, owner, domain: normalizeDomain(input.domain), issuer: client.issuer, organization: client.document.organization.name, scope, actor: { id: input.actor.id, ...(input.actor.roles !== undefined ? { roles: input.actor.roles } : {}), ...(input.actor.tenantRef !== undefined ? { tenantRef: input.actor.tenantRef } : {}), }, threadId: input.threadId ?? null, runId: input.runId ?? null, toolCallId: input.toolCallId ?? null, flow: null, status: 'open', expiresAt: now + this.connectTtlMs, createdAt: now, }); return { id, url: this.connectUrl(id), organization: client.document.organization.name, scope, }; }); } /** The connect started by one tool call (`runId`, `toolCallId`), if any. */ pendingFor(runId, toolCallId) { return this.#store.getPending(this.connectIdFor(`${runId}:${toolCallId}`)); } /** * The connect id of one tool call (`runId:toolCallId`): a replayed call reaches the same link. * Keyed with the app secret — the link is a bearer capability, so it must not be derivable. */ connectIdFor(key) { return createHmac('sha256', this.#secret) .update(`poppy-connect:${key}`) .digest('base64url') .slice(0, 32); } connectUrl(id) { return `${this.baseUrl}/${this.path}/connect/${id}`; } async #openPending(id) { const pending = await this.#store.getPending(id); if (pending === null || pending.expiresAt <= Date.now()) return null; return pending; } async page(id) { const pending = await this.#openPending(id); if (pending === null) return { kind: 'expired' }; if (pending.status === 'connected') return { kind: 'connected', organization: pending.organization }; return this.withCompany(pending.owner, pending.domain, async (client) => { const auth = client.document.auth; return { kind: 'open', id, organization: pending.organization, direct: Boolean(auth?.direct && client.metadata.authorization_endpoint), device: Boolean(auth?.device && client.metadata.device_authorization_endpoint), mediated: auth?.mediated?.fields?.map((f) => ({ name: f.name, label: f.label, secret: f.secret, })) ?? null, }; }); } /** Direct Sign-In (§4.5): the authorization URL to send the browser to. */ async directUrl(id) { const pending = await this.#openPending(id); if (pending === null || pending.status !== 'open') return null; return this.withCompany(pending.owner, pending.domain, async (client) => { const scope = withinType(client, 'direct', pending.scope); const signIn = await startDirectSignIn(client, scope); // `state` carries the connect id: the callback finds its sign-in without a session. const state = `${id}.${signIn.state}`; const url = new URL(signIn.url); url.searchParams.set('state', state); await this.#store.updatePending(id, { flow: this.#sealer.seal({ kind: 'direct', signIn: { ...signIn, state, url: url.toString() }, }), }); return url.toString(); }); } /** The Direct Sign-In redirect back to `redirect_uri`. */ async directCallback(params) { const id = (params.get('state') ?? '').split('.')[0] ?? ''; const pending = await this.#openPending(id); if (pending === null || pending.flow === null) return { ok: false, organization: null }; const flow = this.#sealer.open(pending.flow); if (flow.kind !== 'direct') return { ok: false, organization: pending.organization }; try { await this.withCompany(pending.owner, pending.domain, (client) => completeDirectSignIn(client, flow.signIn, params)); } catch (error) { this.#log(`poppy-connections: Direct Sign-In failed: ${error.message}`); return { ok: false, organization: pending.organization }; } await this.#connected(pending); return { ok: true, organization: pending.organization }; } /** * Mediated Sign-In (§4.7) from this app's own page: the credentials go from the person's browser * to this server and straight to the Company — never stored, logged, or shown to the model. */ async mediated(id, credentials) { const pending = await this.#openPending(id); if (pending === null || pending.status !== 'open') return { status: 'expired' }; const outcome = await this.withCompany(pending.owner, pending.domain, async (client) => { const scope = withinType(client, 'mediated', pending.scope); const result = await mediatedSignIn(client, scope, credentials); return { result, scope }; }); return this.#mediatedOutcome(pending, outcome.result, outcome.scope); } async mediatedCode(id, code) { const pending = await this.#openPending(id); if (pending === null || pending.flow === null || pending.status !== 'open') return { status: 'expired' }; const flow = this.#sealer.open(pending.flow); if (flow.kind !== 'mediated') return { status: 'failed' }; const result = await this.withCompany(pending.owner, pending.domain, (client) => submitMediatedCode(client, flow.scope, flow.signInId, code)); return this.#mediatedOutcome(pending, result, flow.scope); } async #mediatedOutcome(pending, result, scope) { if (result.status === 'complete') { await this.#connected(pending); return { status: 'connected' }; } if (result.status === 'code_required') { const sentTo = result.sentTo ?? ''; await this.#store.updatePending(pending.id, { flow: this.#sealer.seal({ kind: 'mediated', signInId: result.signInId, sentTo, scope, }), }); return { status: 'code_required', sentTo }; } return { status: result.status }; } /** Device Sign-In (§4.6): the code and the Company page where the person approves it. */ async deviceStart(id) { const pending = await this.#openPending(id); if (pending === null || pending.status !== 'open') return null; return this.withCompany(pending.owner, pending.domain, async (client) => { const scope = withinType(client, 'device', pending.scope); const d = await client.tokens.deviceAuthorization(scope); const flow = { kind: 'device', deviceCode: d.device_code, userCode: d.user_code, verificationUri: d.verification_uri, ...(d.verification_uri_complete !== undefined ? { verificationUriComplete: d.verification_uri_complete } : {}), scope, }; await this.#store.updatePending(id, { flow: this.#sealer.seal(flow) }); return { userCode: d.user_code, verificationUri: d.verification_uri_complete ?? d.verification_uri, }; }); } /** The device code already started for this connect, if any. */ async deviceInfo(id) { const pending = await this.#openPending(id); if (pending === null || pending.flow === null) return null; const flow = this.#sealer.open(pending.flow); if (flow.kind !== 'device') return null; return { userCode: flow.userCode, verificationUri: flow.verificationUriComplete ?? flow.verificationUri, }; } /** One device-code exchange, when the person says they approved. */ async deviceCheck(id) { const pending = await this.#openPending(id); if (pending === null || pending.flow === null) return 'expired'; if (pending.status === 'connected') return 'connected'; const flow = this.#sealer.open(pending.flow); if (flow.kind !== 'device') return 'failed'; try { const result = await this.withCompany(pending.owner, pending.domain, (client) => pollDeviceSignInOnce(client, flow.deviceCode, flow.scope)); if (result === 'pending') return 'pending'; } catch (error) { this.#log(`poppy-connections: Device Sign-In failed: ${error.message}`); return 'failed'; } await this.#connected(pending); return 'connected'; } async #connected(pending) { await this.#store.updatePending(pending.id, { status: 'connected', flow: null }); const row = await this.#store.getConnection(pending.owner, pending.issuer); if (row !== null && row.organization !== pending.organization) { await this.#store.saveConnection({ ...row, organization: pending.organization }); } try { await this.#onConnected?.({ ...pending, status: 'connected' }); } catch (error) { this.#log(`poppy-connections: resuming the conversation failed: ${error.message}`); } } // ── the Company's APIs and agent ────────────────────────────────────────── /** The Company's MCP server, with a Bearer token for it (§6). Closed by the caller. */ mcp(owner, domain) { return this.withCompany(owner, domain, (client) => connectMcp(client)); } /** * One exchange with the Company's agent (§7): `act: false` narrows the token to the read scopes * (`policy.readScopes`, default `poppy:read`), so a question can never make the Company act; * `act: true` uses the whole grant — the tools gate that behind the person's confirmation. */ ask(owner, domain, text, options) { return this.withCompany(owner, domain, async (client, policy) => { let scope; if (!options.act) { const granted = new Set((client.accountToken?.scope ?? '').split(' ').filter(Boolean)); const read = (policy.readScopes ?? DEFAULT_READ_SCOPES).filter((s) => granted.has(s)); if (client.accountToken && read.length === 0) { return { conversationId: '', replies: [], authorization: { error: 'no_read_scope' }, status: 'idle', }; } scope = read.length > 0 ? read.join(' ') : undefined; } const conv = new ConversationClient(client, undefined, { scope }); const message = { id: newMessageId(), sender: 'agent', text }; const open = Object.entries(client.state.conversations) .filter(([, c]) => !c.closed && c.endpoint === conv.endpoint && c.parentConversationId === undefined) .sort(([, a], [, b]) => b.createdAt - a.createdAt)[0]?.[0]; let conversationId; try { if (open === undefined) throw new ConversationError('conversation_not_found'); await conv.send(open, message); conversationId = open; } catch (error) { if (!(error instanceof ConversationError)) throw error; if (open !== undefined) client.state.conversations[open] = { ...client.state.conversations[open], closed: true }; conversationId = (await conv.start(message)).conversation_id; } const deadline = Date.now() + (this.#config.replyTimeoutMs ?? 90_000); const ac = new AbortController(); const timer = setTimeout(() => ac.abort(), Math.max(1000, deadline - Date.now())); const reply = { conversationId, replies: [], status: 'working' }; try { for await (const item of conv.follow(conversationId, { untilSettled: true, signal: ac.signal, })) { if (item.kind !== 'event') continue; const ev = item.event; if (ev.type === 'message' && ev.message?.role === 'company') { reply.replies.push({ text: ev.message.text ?? '', ...(ev.message.data !== undefined ? { data: ev.message.data } : {}), }); } else if (ev.type === 'authorization') { reply.authorization = { error: String(ev.error ?? 'insufficient_scope'), ...(typeof ev.scope === 'string' ? { scope: ev.scope } : {}), }; } else if (ev.type === 'state' && typeof ev.status === 'string') { reply.status = ev.status; } } } finally { clearTimeout(timer); } if (ac.signal.aborted && reply.status === 'working') reply.status = 'timeout'; await client.save(); return reply; }); } } /** The scopes a Company offers for signing in, broadest list first. */ function offeredScopes(client) { const auth = client.document.auth; return auth?.direct?.scopes ?? auth?.device?.scopes ?? auth?.mediated?.scopes ?? []; } /** `scope` narrowed to what one sign-in type can grant (falls back to all it offers). */ function withinType(client, type, scope) { const offered = client.document.auth?.[type]?.scopes ?? []; const wanted = scope.split(' ').filter((s) => offered.includes(s)); return (wanted.length > 0 ? wanted : offered).join(' '); } /** Private EC JWKs (or poppy-client `StoredKey`s, or a JSON string of either) → `StoredKey[]`. */ export async function normalizeKeys(input) { const list = (typeof input === 'string' ? JSON.parse(input) : input); if (!Array.isArray(list) || list.length === 0) { throw new Error('[@adonis-agora/agent] poppy-connections: `identity.keys` needs at least one private key'); } return Promise.all(list.map(async (raw) => { const value = raw; if (value.privateJwk && value.publicJwk && typeof value.kid === 'string') return value; const jwk = value; if (jwk.kty !== 'EC' || jwk.crv !== 'P-256' || typeof jwk.d !== 'string' || typeof jwk.x !== 'string' || typeof jwk.y !== 'string') { throw new Error('[@adonis-agora/agent] poppy-connections: identity keys must be private EC P-256 JWKs'); } const publicJwk = { kty: jwk.kty, crv: jwk.crv, x: jwk.x, y: jwk.y }; const kid = jwk.kid ?? (await calculateJwkThumbprint(publicJwk, 'sha256')); return { kid, privateJwk: { ...jwk, kid, alg: 'ES256', use: 'sig' }, publicJwk: { ...publicJwk, kid, alg: 'ES256', use: 'sig' }, }; })); } export { OAuthError, PoppyError, ResourceAuthError }; //# sourceMappingURL=service.js.map