// `ay callback` — pure core: capability tokens + the embeddable widget snippet. // // A callback capability is a self-contained, HMAC-signed token that lets a // public web page send ONE-WAY messages to ONE agent through the daemon's // unauthenticated POST /cb/ route. It is deliberately NOT the serve token // (that is a master key: send/kill/spawn on every agent). Scope is baked into // the signed payload — target agent, expiry, capability id — so verification // holds even if the daemon's callbacks.json store is lost: an expired or // re-targeted token can never be resurrected by deleting local state. // // Format: `cb1..`, payload JSON // `{ id, agent, exp }` (exp = unix ms). Expiry is REQUIRED at mint time by // design — there is no "never" and no default: whoever embeds a snippet on a // public page must declare how long it lives. import { createHmac, timingSafeEqual } from "node:crypto"; export interface CallbackPayload { /** Short capability id — the handle for `ay callback ls` / `revoke`. */ id: string; /** Target agent_id (full) — the ONLY agent this capability can reach. */ agent: string; /** Expiry, unix epoch ms. Verification fails hard after this. */ exp: number; } export type VerifyResult = | { ok: true; payload: CallbackPayload } | { ok: false; reason: "malformed" | "badsig" | "expired" }; const CAP_PREFIX = "cb1"; const DAY_MS = 24 * 60 * 60 * 1000; export const MAX_EXPIRES_MS = 365 * DAY_MS; /** Parse a required `--expires` duration: `` (minutes, hours, * days, weeks). Rejects everything else — no bare numbers, no "never", no * zero — so the caller is forced to state a real lifetime. Capped at 365d. */ export function parseExpires(spec: string): number { const m = /^(\d+)([mhdw])$/.exec(spec.trim()); if (!m) { throw new Error( `invalid --expires "${spec}" — use m|h|d|w (e.g. 12h, 7d, 2w); ` + `an explicit lifetime is required, "never" is not supported`, ); } const n = Number(m[1]); const unit = { m: 60_000, h: 3_600_000, d: DAY_MS, w: 7 * DAY_MS }[m[2] as "m" | "h" | "d" | "w"]; const ms = n * unit; if (ms <= 0) throw new Error(`--expires must be positive (got "${spec}")`); if (ms > MAX_EXPIRES_MS) throw new Error(`--expires "${spec}" exceeds the 365d maximum — re-mint closer to use instead`); return ms; } const b64url = (buf: Buffer): string => buf.toString("base64url"); function sign(secret: string, payloadB64: string): Buffer { return createHmac("sha256", secret).update(`${CAP_PREFIX}.${payloadB64}`).digest(); } /** Mint a signed capability token for one agent with a hard expiry. */ export function mintCapability(secret: string, payload: CallbackPayload): string { const payloadB64 = b64url(Buffer.from(JSON.stringify(payload))); return `${CAP_PREFIX}.${payloadB64}.${b64url(sign(secret, payloadB64))}`; } /** Verify signature THEN expiry. Never throws; malformed/badsig are kept * distinct from expired so the public route can answer 404 vs 410. */ export function verifyCapability(secret: string, cap: string, now: number): VerifyResult { const parts = cap.split("."); if (parts.length !== 3 || parts[0] !== CAP_PREFIX) return { ok: false, reason: "malformed" }; const payloadB64 = parts[1]!; const sigB64 = parts[2]!; let sig: Buffer; try { sig = Buffer.from(sigB64, "base64url"); } catch { return { ok: false, reason: "malformed" }; } const expect = sign(secret, payloadB64); if (sig.length !== expect.length || !timingSafeEqual(sig, expect)) return { ok: false, reason: "badsig" }; let payload: CallbackPayload; try { payload = JSON.parse(Buffer.from(payloadB64, "base64url").toString("utf8")); } catch { return { ok: false, reason: "malformed" }; } if ( typeof payload?.id !== "string" || typeof payload?.agent !== "string" || typeof payload?.exp !== "number" ) return { ok: false, reason: "malformed" }; if (now >= payload.exp) return { ok: false, reason: "expired" }; return { ok: true, payload }; } /** Frame an untrusted visitor message for PTY injection. Control characters * (except newline/tab) are stripped so a visitor can never smuggle escape * sequences or a fake Enter into the agent's terminal, and the wrapper names * the message untrusted so the agent treats it as data, not instructions. * Framing text must stay pattern-inert: no error-chrome words that could trip * autoRetry/ready markers (same rule as the retry nudge, see PR #250). */ export function frameVisitorMessage(capId: string, msg: string): string { // eslint-disable-next-line no-control-regex const clean = msg.replace(/[\u0000-\u0008\u000b-\u001f\u007f]/g, "").trim(); return ( `\n` + `${clean}\n` + `` ); } /** Max visitor message body accepted by the public route. */ export const MAX_CALLBACK_MSG_BYTES = 4096; export interface SnippetOpts { /** Absolute base URL of the daemon, e.g. https://x1a2b3.agent-yes.com */ base: string; /** The full capability token. */ cap: string; /** Button/modal title shown to visitors. */ title?: string; } /** Build the self-contained embed snippet: one inline `; }