import fs from "node:fs/promises"; import crypto from "node:crypto"; import { fileURLToPath } from "node:url"; const EXPECTED_HASH_RE = /expected:\s*([0-9a-f]{64})/i; export const SIGNABLE_FIELDS = Object.freeze([ "agent_id", "target_agent_id", "timestamp", "nonce", "input", "output", "alert_threshold", ]); function normalizeA2spaPayload(payload) { if (!payload || typeof payload !== "object" || Array.isArray(payload)) { throw new TypeError("payload must be an object"); } const normalized = {}; for (const field of SIGNABLE_FIELDS) { if (Object.prototype.hasOwnProperty.call(payload, field)) { const value = payload[field]; if (value === undefined) { throw new TypeError(`A2SPA signable field cannot be undefined: ${field}`); } normalized[field] = value; } } if (!Object.prototype.hasOwnProperty.call(normalized, "alert_threshold")) { normalized.alert_threshold = 10; } return normalized; } function pythonCompatibleJsonString(value) { return JSON.stringify(value).replace(/[\u007f-\uffff]/g, (character) => { return "\\u" + character.charCodeAt(0).toString(16).padStart(4, "0"); }); } export function canonicalJsonPythonStyle(value) { if (value === null) return "null"; if (Array.isArray(value)) { return `[${value.map((item) => canonicalJsonPythonStyle(item)).join(", ")}]`; } if (typeof value === "object") { const keys = Object.keys(value).sort(); return `{${keys .map((key) => `${pythonCompatibleJsonString(key)}: ${canonicalJsonPythonStyle(value[key])}`) .join(", ")}}`; } if (typeof value === "string") { return pythonCompatibleJsonString(value); } if (typeof value === "number") { if (!Number.isFinite(value)) { throw new TypeError("A2SPA canonicalization only supports finite JSON numbers"); } return String(value); } if (typeof value === "boolean") { return value ? "true" : "false"; } throw new TypeError(`Unsupported payload value type: ${typeof value}`); } export function canonicalizeA2spaValue(value) { return canonicalJsonPythonStyle(value); } export function canonicalizeA2spaPayload(payload) { return canonicalJsonPythonStyle(normalizeA2spaPayload(payload)); } export function prepareA2spaSignedBytes(payload) { return Buffer.from(canonicalizeA2spaPayload(payload), "utf8"); } export const prepareSignedBytes = prepareA2spaSignedBytes; export function computeA2spaPayloadHash(payload) { return crypto.createHash("sha256").update(prepareA2spaSignedBytes(payload)).digest("hex"); } export const computePayloadHash = computeA2spaPayloadHash; export function finalizeA2spaPayload(payloadBeforeHash) { const finalized = { ...payloadBeforeHash }; delete finalized.hash; delete finalized.signature; finalized.hash = computeA2spaPayloadHash(payloadBeforeHash); return finalized; } export const finalizePayload = finalizeA2spaPayload; export function buildA2spaRequestBody(payload, signature) { return { payload, signature }; } export const buildRequestBody = buildA2spaRequestBody; export function extractExpectedHash(errorText) { if (!errorText) return null; const match = String(errorText).match(EXPECTED_HASH_RE); return match ? match[1] : null; } export function buildDebugSnapshot(payloadBeforeHash, signature = null, finalPayload = null, serverErrorText = null) { const canonicalString = canonicalizeA2spaPayload(payloadBeforeHash); const signedBytes = prepareA2spaSignedBytes(payloadBeforeHash); const requestPayload = finalPayload ?? finalizeA2spaPayload(payloadBeforeHash); const snapshot = { payload_before_hash: payloadBeforeHash, canonical_string: canonicalString, canonical_bytes_hex: signedBytes.toString("hex"), client_computed_hash: computeA2spaPayloadHash(payloadBeforeHash), final_request_body: signature ? buildA2spaRequestBody(requestPayload, signature) : { payload: requestPayload }, server_expected_hash: extractExpectedHash(serverErrorText), }; if (serverErrorText !== null && serverErrorText !== undefined) { snapshot.server_error = serverErrorText; } return snapshot; } export function signA2spaPayload(payload, privateKeyPem) { const signer = crypto.createSign("RSA-SHA256"); signer.update(prepareA2spaSignedBytes(payload)); signer.end(); return signer.sign(privateKeyPem, "hex"); } export const signPayload = signA2spaPayload; export class A2SPAClient { constructor({ apiBase, apiKey, agentId, privateKeyPath, timeoutMs = 20000 }) { this.apiBase = apiBase.replace(/\/$/, ""); this.apiKey = apiKey; this.agentId = agentId; this.privateKeyPath = privateKeyPath; this.timeoutMs = timeoutMs; this.privateKeyPem = null; } static fromEnv() { return new A2SPAClient({ apiBase: process.env.A2SPA_API_BASE || "https://aimodularity.com/A2SPA", apiKey: process.env.A2SPA_API_KEY, agentId: process.env.A2SPA_AGENT_ID, privateKeyPath: process.env.A2SPA_PRIVATE_KEY_PATH, }); } async loadPrivateKeyPem() { if (!this.privateKeyPem) { this.privateKeyPem = await fs.readFile(this.privateKeyPath, "utf8"); } return this.privateKeyPem; } buildPayloadFields(targetAgentId, input, output, alertThreshold = 10, extra = {}) { const payload = { agent_id: this.agentId, target_agent_id: targetAgentId, timestamp: new Date().toISOString(), nonce: crypto.randomUUID(), input, output, alert_threshold: alertThreshold, ...extra, }; return payload; } buildPayload(targetAgentId, input, output, alertThreshold = 10, extra = {}) { return finalizeA2spaPayload( this.buildPayloadFields(targetAgentId, input, output, alertThreshold, extra), ); } async buildSignedRequest(targetAgentId, input, output, alertThreshold = 10, extra = {}) { const payloadBeforeHash = this.buildPayloadFields(targetAgentId, input, output, alertThreshold, extra); const payload = finalizeA2spaPayload(payloadBeforeHash); const signature = signA2spaPayload(payloadBeforeHash, await this.loadPrivateKeyPem()); return { payloadBeforeHash, payload, signature, requestBody: buildA2spaRequestBody(payload, signature), }; } async postRequestBody(requestBody) { return fetch(`${this.apiBase}/api/verify_payload`, { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": this.apiKey, }, body: JSON.stringify(requestBody), }); } async sendPayload(targetAgentId, input, output, alertThreshold = 10, extra = {}, debug = false) { const signedRequest = await this.buildSignedRequest(targetAgentId, input, output, alertThreshold, extra); const response = await this.postRequestBody(signedRequest.requestBody); if (!response.ok) { const errorText = await response.text(); if (debug) { console.error( JSON.stringify( buildDebugSnapshot( signedRequest.payloadBeforeHash, signedRequest.signature, signedRequest.payload, errorText, ), null, 2, ), ); } throw new Error(errorText); } return response.json(); } async fetchLogs(agentId = this.agentId) { const params = new URLSearchParams({ agent_id: agentId }); const response = await fetch(`${this.apiBase}/api/logs_for_agent?${params.toString()}`, { headers: { "x-api-key": this.apiKey }, }); if (!response.ok) { throw new Error(await response.text()); } const body = await response.json(); return body.logs || []; } async fetchInbox(agentId = this.agentId, after = "", limit = 50) { const params = new URLSearchParams({ agent_id: agentId, limit: String(limit) }); if (after) params.set("after", after); const response = await fetch(`${this.apiBase}/api/inbox_for_agent?${params.toString()}`, { headers: { "x-api-key": this.apiKey }, }); if (!response.ok) { throw new Error(await response.text()); } const body = await response.json(); return body.messages || []; } async runAndSend(targetAgentId, input, runner, alertThreshold = 10) { const output = await runner(input); return this.sendPayload(targetAgentId, input, output, alertThreshold); } } async function existingAgent(input) { return { status: "ready", summary: `Processed: ${input.message ?? "no message"}`, }; } async function main() { const client = A2SPAClient.fromEnv(); const input = { message: "Hello from my existing Node agent" }; const output = await existingAgent(input); const signedRequest = await client.buildSignedRequest( process.env.A2SPA_TARGET_AGENT_ID, input, output, ); const firstResponse = await client.postRequestBody(signedRequest.requestBody); console.log(`First request: ${firstResponse.status} ${await firstResponse.text()}`); const secondResponse = await client.postRequestBody(signedRequest.requestBody); console.log(`Second request: ${secondResponse.status} ${await secondResponse.text()}`); } if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { main().catch((error) => { console.error(error); process.exit(1); }); }