// `ay term` — embed a LIVE, read-only agent-session terminal in any web page. // // The terminal sibling of `ay ch`. `ay term embed ` prints a \n` ); } function cmdTermEmbed(args: string[]): number { const { flags, positional } = parseFlags(args, { host: "value", origin: "value", token: "value", placeholder: "bool", interactive: "bool", }); const pid = positional[0]; if (!pid || positional.length > 1) throw new Error( "usage: ay term embed [--host H] [--origin URL] [--token TOK | --placeholder] [--interactive]", ); if (flags.token && flags.placeholder) throw new Error("--token and --placeholder are mutually exclusive"); const host = typeof flags.host === "string" ? flags.host : "agent-yes.com"; const origin = typeof flags.origin === "string" ? flags.origin : undefined; const interactive = flags.interactive === true; const mode: TermEmbedMode = flags.placeholder ? { kind: "placeholder", origin, interactive } : typeof flags.token === "string" ? { kind: "live", token: flags.token, origin, interactive } : { kind: "discover", origin, interactive }; // stdout: only the snippet (safe to pipe/paste). stderr: the guidance. process.stdout.write(buildTermEmbedSnippet(host, pid, mode)); if (mode.kind === "discover") { process.stderr.write( `\n Token-free snippet — the widget reads the serve token from the page (#k= hash,\n` + ` window.AY_TERM_TOKEN, or the console's localStorage["ay.localToken"]). Ideal when the\n` + ` page is served by the daemon itself (ay serve --http) with a #k= link.\n`, ); } else if (mode.kind === "placeholder") { process.stderr.write( `\n Placeholder — the token is NOT in the snippet; set window.AY_TERM_TOKEN at runtime\n` + ` (server-rendered / env), so a committed file carries no secret.\n`, ); } else { process.stderr.write( `\n ⚠ This snippet embeds a LIVE serve token — full-fleet READ+WRITE for this daemon.\n` + ` Anyone who reads the page source gets it. Do NOT commit it to a public or\n` + ` deploy-bound file. Prefer --placeholder (runtime-injected) or the token-free\n` + ` default. Scoped read-only embed tokens are future work (need per-author signing).\n`, ); } if (interactive) { process.stderr.write( `\n ⚠ INTERACTIVE: keystrokes in the widget go to the agent's stdin. This needs a token\n` + ` that grants /api/send for #${pid} (the widget reverts to read-only on a 403). Prefer a\n` + ` scoped, short-TTL, single-session interactive token (future: ay term mint --interactive)\n` + ` over the master token — the master token is full-fleet RCE.\n`, ); } else { process.stderr.write( `\n Read-only mirror: it renders the agent's PTY but never types into it. Add --interactive\n` + ` (with a send-granting token) to let viewers type to the agent.\n`, ); } process.stderr.write(` terminal.js must load from https://${host}/w/terminal.js.\n`); if (!origin) { process.stderr.write( ` Same-origin: /api/tail sends no CORS headers, so the page must be served by the\n` + ` daemon (ay serve --http). For an arbitrary-origin page, pass --origin \n` + ` (the daemon must be reachable + allow it); the WebRTC --share transport for any\n` + ` origin is a later phase.\n`, ); } return 0; } /** Parse a TTL like "900", "30s", "15m", "2h" → seconds. */ function parseTtlSec(s: string): number { const m = /^(\d+)(s|m|h)?$/.exec(s.trim()); if (!m) throw new Error(`bad --ttl "${s}" (use e.g. 900, 30s, 15m, 2h)`); const mult = m[2] === "h" ? 3600 : m[2] === "m" ? 60 : 1; return Number(m[1]) * mult; } async function cmdTermMint(args: string[]): Promise { const { flags, positional } = parseFlags(args, { ttl: "value", ro: "bool", interactive: "bool", json: "bool", }); const pid = positional[0]; if (!pid || positional.length > 1) throw new Error( "usage: ay term mint [--ttl 15m] [--ro | --interactive] [--json]", ); if (flags.ro && flags.interactive) throw new Error("--ro and --interactive are mutually exclusive"); const ttlSec = parseTtlSec(typeof flags.ttl === "string" ? flags.ttl : "15m"); const canSend = flags.interactive === true; // default read-only // Sign locally off ~/.agent-yes/.serve-token — no daemon round-trip. const { mintScopedTermToken } = await import("./serve.ts"); const r = await mintScopedTermToken(pid, { ttlSec, canSend }); if (flags.json === true) { process.stdout.write(JSON.stringify(r) + "\n"); return 0; } process.stdout.write(r.token + "\n"); const mins = Math.round(ttlSec / 60); process.stderr.write( `\n Scoped ${canSend ? "INTERACTIVE (read+write)" : "read-only"} token for #${r.pid}, ` + `expires in ~${mins}m (${new Date(r.exp * 1000).toISOString()}).\n` + ` It grants ONLY ${canSend ? "read+write of" : "read of"} #${r.pid} — no other agent, no spawn.\n` + ` Safe to embed in a page (this is NOT the master token):\n` + ` ay term embed ${r.pid} --token ${canSend ? "--interactive " : ""}--origin \n` + ` Short TTL is the backstop (serverless — no central revoke); re-mint when it lapses.\n`, ); return 0; } function termHelp(): number { process.stdout.write( `ay term — embed a live, read-only agent terminal in a web page\n\n` + `Usage:\n` + ` ay term embed [--host H] [--origin URL] [--token TOK | --placeholder] [--interactive]\n` + ` ay term mint [--ttl 15m] [--ro | --interactive] [--json]\n\n` + ` embed print a