agent-yes 1.237.0 → 1.238.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (28) hide show
  1. package/dist/{SUPPORTED_CLIS-C0RwjYrz.js → SUPPORTED_CLIS-CtgC_d_l.js} +3 -3
  2. package/dist/{SUPPORTED_CLIS-CDrJz9Xo.js → SUPPORTED_CLIS-NpYW9OaR.js} +2 -2
  3. package/dist/{agentShare-Cl_J3dfx.js → agentShare-549Zwi_B.js} +2 -2
  4. package/dist/{callback-CFbkvqTh.js → callback-B-ojDckO.js} +2 -2
  5. package/dist/{callback-D0_AeyN1.js → callback-BNJMOVlW.js} +3 -3
  6. package/dist/cli.js +4 -4
  7. package/dist/index.js +2 -2
  8. package/dist/{notifyDaemon-D_8TRVHt.js → notifyDaemon-CjBVeWRg.js} +2 -2
  9. package/dist/{rustBinary-Bb9qJZ5w.js → rustBinary-DSorJMBV.js} +2 -2
  10. package/dist/{schedule-B8Erb3y6.js → schedule-Bw3SL5Hv.js} +4 -4
  11. package/dist/{serve-QejbNTr-.js → serve-BLxmap1e.js} +217 -37
  12. package/dist/{setup-Dzi5Cc3g.js → setup-DlFVw_1n.js} +2 -2
  13. package/dist/{subcommands-ymJWO2iv.js → subcommands-A0uOpmco.js} +1 -1
  14. package/dist/{subcommands-DFr-5FOL.js → subcommands-BdBCxZVu.js} +20 -10
  15. package/dist/{terminal-vNeFNLT8.js → terminal-w7drEaJc.js} +2 -2
  16. package/dist/{ts-Dd1FK7sR.js → ts-EvHuyNA9.js} +2 -2
  17. package/dist/{versionChecker-XfEBINhH.js → versionChecker-DNLNn_SL.js} +2 -2
  18. package/dist/widget-Coav85Lx.js +172 -0
  19. package/dist/widget.js +162 -0
  20. package/dist/widgets.js +2 -1
  21. package/dist/{ws-Dun8gGiV.js → ws-3prv2in5.js} +2 -2
  22. package/package.json +5 -1
  23. package/ts/serve.ts +234 -19
  24. package/ts/subcommands.ts +12 -0
  25. package/ts/termToken.spec.ts +25 -3
  26. package/ts/termToken.ts +35 -8
  27. package/ts/widget.ts +207 -0
  28. package/ts/widgets.ts +2 -0
package/ts/serve.ts CHANGED
@@ -184,7 +184,7 @@ async function loadOrCreateToken(tokenFlag?: string): Promise<string> {
184
184
 
185
185
  // Read the serve token WITHOUT creating one — `ay serve status` must be a pure
186
186
  // read (creating a token as a side effect of asking "is it running?" is wrong).
187
- async function loadTokenReadOnly(): Promise<string | null> {
187
+ export async function loadTokenReadOnly(): Promise<string | null> {
188
188
  try {
189
189
  return (await readFile(tokenPath(), "utf-8")).trim();
190
190
  } catch {
@@ -241,7 +241,12 @@ function authorizeRequest(req: Request, expectedToken: string): AuthResult | nul
241
241
  // a cross-origin report page can reach the daemon — the token is still required to
242
242
  // get a 200, and no cookies are involved, so `*` leaks nothing.
243
243
  function isTermCorsPath(p: string): boolean {
244
- return /^\/api\/(tail|size)\/.+$/.test(p) || p === "/api/send";
244
+ return (
245
+ /^\/api\/(tail|size)\/.+$/.test(p) ||
246
+ p === "/api/send" ||
247
+ /^\/api\/resize\/.+$/.test(p) ||
248
+ p.startsWith("/api/widget/")
249
+ );
245
250
  }
246
251
 
247
252
  function withTermCors(req: Request, res: Response | undefined): Response | undefined {
@@ -291,24 +296,34 @@ async function scopedGate(
291
296
  p: string,
292
297
  ): Promise<Response | null> {
293
298
  const forbid = (m: string) => new Response(m, { status: 403 });
299
+ const needs = (cap: string) => (scope.caps.includes(cap) ? null : forbid(`scoped token lacks '${cap}'`));
294
300
  const m = /^\/api\/(?:tail|size)\/(.+)$/.exec(p);
295
301
  if (method === "GET" && m) {
296
- return (await scopedKeywordOk(decodeURIComponent(m[1]!), scope))
297
- ? null
298
- : forbid("scoped token: not bound to this agent");
302
+ return (
303
+ needs("tail") ??
304
+ ((await scopedKeywordOk(decodeURIComponent(m[1]!), scope))
305
+ ? null
306
+ : forbid("scoped token: not bound to this agent"))
307
+ );
299
308
  }
300
309
  if (method === "POST" && p === "/api/send") {
301
- if (!scope.canSend) return forbid("scoped token is read-only");
302
- return null; // pid binding enforced in the /api/send handler (it has the body)
310
+ return needs("send"); // pid binding enforced in the /api/send handler (it has the body)
303
311
  }
304
312
  // Interactive viewers may renegotiate the agent's PTY size (drag-to-resize) —
305
- // send-capable scope only, bound pid. The keyword is in the path, so bind here.
313
+ // needs the resize cap, bound pid. The keyword is in the path, so bind here.
306
314
  const rz = /^\/api\/resize\/(.+)$/.exec(p);
307
315
  if (method === "POST" && rz) {
308
- if (!scope.canSend) return forbid("scoped token is read-only");
309
- return (await scopedKeywordOk(decodeURIComponent(rz[1]!), scope))
310
- ? null
311
- : forbid("scoped token: not bound to this agent");
316
+ return (
317
+ needs("resize") ??
318
+ ((await scopedKeywordOk(decodeURIComponent(rz[1]!), scope))
319
+ ? null
320
+ : forbid("scoped token: not bound to this agent"))
321
+ );
322
+ }
323
+ // Widget sensor routes: require the 'read' cap; the widget-route handlers do the
324
+ // fine-grained viewer-binding (and the 'screenshot' cap check for that kind).
325
+ if (p.startsWith("/api/widget/")) {
326
+ return needs("read");
312
327
  }
313
328
  return forbid("scoped token: route not permitted");
314
329
  }
@@ -318,20 +333,65 @@ async function scopedGate(
318
333
  * master token file, resolve the keyword to a concrete pid, and sign. Used by
319
334
  * `ay term mint`. Throws if there's no serve token or the agent can't be resolved.
320
335
  */
336
+ const TERMINAL_CAPS = new Set(["tail", "size", "send", "resize"]);
337
+
321
338
  export async function mintScopedTermToken(
322
- keyword: string,
323
- opts: { ttlSec: number; canSend: boolean },
324
- ): Promise<{ token: string; pid: string; exp: number; canSend: boolean }> {
339
+ target: string,
340
+ opts: { ttlSec: number; caps?: string[]; canSend?: boolean },
341
+ ): Promise<{ token: string; sub: string; pid: string; exp: number; caps: string[] }> {
325
342
  const master = await loadTokenReadOnly();
326
343
  if (!master)
327
344
  throw new Error(
328
345
  "no serve token at ~/.agent-yes/.serve-token — run `ay serve` once on this host to create it",
329
346
  );
330
- const rec = await resolveOne(keyword, defaultOpts({ all: true }));
331
- const pid = String(rec.pid);
347
+ const caps = opts.caps ?? (opts.canSend ? ["tail", "size", "send", "resize"] : ["tail", "size"]);
348
+ // A terminal-cap token binds to a concrete pid (resolve it); a widget-only token
349
+ // (read/screenshot) binds to the target verbatim (a viewer id, or "*" for any).
350
+ let sub = target;
351
+ if (target !== "*" && caps.some((c) => TERMINAL_CAPS.has(c))) {
352
+ const rec = await resolveOne(target, defaultOpts({ all: true }));
353
+ sub = String(rec.pid);
354
+ }
332
355
  const exp = Math.floor(Date.now() / 1000) + Math.max(1, Math.floor(opts.ttlSec));
333
- const token = mintTermToken(master, { pid, canSend: opts.canSend, exp });
334
- return { token, pid, exp, canSend: opts.canSend };
356
+ const token = mintTermToken(master, { pid: sub, caps, exp });
357
+ return { token, sub, pid: sub, exp, caps };
358
+ }
359
+
360
+ // ── Widget sensor broker (`ay widget`) ──────────────────────────────────────
361
+ // The daemon is a STATELESS broker between the CLI (`ay widget read …`) and an
362
+ // in-page AyWidget: it correlates a command with its result and forwards the
363
+ // payload, but stores no page data. A widget registers on mount, holds an SSE
364
+ // poll for commands, and POSTs results; a CLI read enqueues a command and awaits
365
+ // the matching result. All in-memory — one daemon per process.
366
+ interface WidgetViewer {
367
+ id: string;
368
+ url: string;
369
+ title: string;
370
+ caps: string[]; // what the page author opted into (selection/dom/screenshot)
371
+ lastSeen: number;
372
+ }
373
+ const widgetViewers = new Map<string, WidgetViewer>();
374
+ const widgetPushers = new Map<string, (cmd: unknown) => void>(); // viewerId → active poll push
375
+ const widgetWaiters = new Map<string, (r: { ok: boolean; data?: unknown; error?: string }) => void>();
376
+ let widgetCmdSeq = 0;
377
+ const WIDGET_TTL_MS = 30_000; // a viewer with no poll heartbeat this long is offline
378
+
379
+ function widgetNewId(): string {
380
+ return "v_" + randomBytes(4).toString("hex");
381
+ }
382
+ function widgetLive(): WidgetViewer[] {
383
+ const now = Date.now();
384
+ for (const [id, v] of widgetViewers) if (now - v.lastSeen >= WIDGET_TTL_MS) widgetViewers.delete(id);
385
+ return [...widgetViewers.values()];
386
+ }
387
+ /** Resolve a `<viewer>` selector: exact id, then id-prefix / url / title substring. */
388
+ function widgetResolve(sel: string): string | null {
389
+ if (widgetViewers.has(sel) && Date.now() - widgetViewers.get(sel)!.lastSeen < WIDGET_TTL_MS)
390
+ return sel;
391
+ const hit = widgetLive().find(
392
+ (v) => v.id.startsWith(sel) || v.url.includes(sel) || v.title.includes(sel),
393
+ );
394
+ return hit?.id ?? null;
335
395
  }
336
396
 
337
397
  const defaultOpts = (overrides: Partial<CommonOpts> = {}): CommonOpts => ({
@@ -2869,6 +2929,161 @@ export async function cmdServe(rest: string[]): Promise<number> {
2869
2929
  }
2870
2930
  }
2871
2931
 
2932
+ // ── Widget sensor broker routes (`ay widget`) ───────────────────────────
2933
+ // A scoped token binds to one viewer id (scope.pid); "*" means any. Enforce
2934
+ // that binding on the per-viewer routes (scopedGate already required 'read').
2935
+ const scopedSub = authResult.kind === "scoped" ? authResult.scope.pid : null;
2936
+ const boundOk = (vid: string) => !scopedSub || scopedSub === "*" || scopedSub === vid;
2937
+
2938
+ // POST /api/widget/register {id?, url, title, caps} → { viewerId }
2939
+ if (req.method === "POST" && p === "/api/widget/register") {
2940
+ let b: { id?: string; url?: string; title?: string; caps?: string[] };
2941
+ try {
2942
+ b = (await req.json()) as typeof b;
2943
+ } catch {
2944
+ return new Response("invalid JSON body", { status: 400 });
2945
+ }
2946
+ // A scoped token pins the id to its subject (so a page can't register as
2947
+ // another viewer); "*"/master lets the widget pick (its provided id or a new one).
2948
+ const id =
2949
+ scopedSub && scopedSub !== "*"
2950
+ ? scopedSub
2951
+ : typeof b.id === "string" && b.id
2952
+ ? b.id
2953
+ : widgetNewId();
2954
+ widgetViewers.set(id, {
2955
+ id,
2956
+ url: String(b.url ?? ""),
2957
+ title: String(b.title ?? ""),
2958
+ caps: Array.isArray(b.caps) ? b.caps.filter((c) => typeof c === "string") : [],
2959
+ lastSeen: Date.now(),
2960
+ });
2961
+ return Response.json({ viewerId: id });
2962
+ }
2963
+
2964
+ // GET /api/widget/list → live viewers (id, url, title, caps, age)
2965
+ if (req.method === "GET" && p === "/api/widget/list") {
2966
+ const now = Date.now();
2967
+ return Response.json(
2968
+ widgetLive().map((v) => ({
2969
+ id: v.id,
2970
+ url: v.url,
2971
+ title: v.title,
2972
+ caps: v.caps,
2973
+ age: Math.round((now - v.lastSeen) / 1000),
2974
+ })),
2975
+ );
2976
+ }
2977
+
2978
+ // GET /api/widget/poll/:viewerId (SSE) — the widget's command channel + heartbeat
2979
+ const pollM = /^\/api\/widget\/poll\/(.+)$/.exec(p);
2980
+ if (req.method === "GET" && pollM) {
2981
+ const vid = decodeURIComponent(pollM[1]!);
2982
+ if (!boundOk(vid)) return new Response("token not bound to this viewer", { status: 403 });
2983
+ const enc = new TextEncoder();
2984
+ let hb: ReturnType<typeof setInterval> | null = null;
2985
+ const stream = new ReadableStream({
2986
+ start(controller) {
2987
+ const push = (obj: unknown) => {
2988
+ try {
2989
+ controller.enqueue(enc.encode(`data: ${JSON.stringify(obj)}\n\n`));
2990
+ } catch {
2991
+ /* stream closed */
2992
+ }
2993
+ };
2994
+ widgetPushers.set(vid, push);
2995
+ const touch = () => {
2996
+ const v = widgetViewers.get(vid);
2997
+ if (v) v.lastSeen = Date.now();
2998
+ };
2999
+ touch();
3000
+ hb = setInterval(() => {
3001
+ try {
3002
+ controller.enqueue(enc.encode(": ping\n\n"));
3003
+ touch();
3004
+ } catch {
3005
+ /* closed */
3006
+ }
3007
+ }, 15_000);
3008
+ const cleanup = () => {
3009
+ if (hb) clearInterval(hb);
3010
+ if (widgetPushers.get(vid) === push) widgetPushers.delete(vid);
3011
+ };
3012
+ req.signal.addEventListener("abort", cleanup);
3013
+ },
3014
+ cancel() {
3015
+ if (hb) clearInterval(hb);
3016
+ },
3017
+ });
3018
+ return new Response(stream, {
3019
+ headers: {
3020
+ "Content-Type": "text/event-stream",
3021
+ "Cache-Control": "no-cache",
3022
+ Connection: "keep-alive",
3023
+ },
3024
+ });
3025
+ }
3026
+
3027
+ // POST /api/widget/result {cmdId, ok, data?, error?} — widget → CLI result
3028
+ if (req.method === "POST" && p === "/api/widget/result") {
3029
+ let b: { cmdId?: string; ok?: boolean; data?: unknown; error?: string };
3030
+ try {
3031
+ b = (await req.json()) as typeof b;
3032
+ } catch {
3033
+ return new Response("invalid JSON body", { status: 400 });
3034
+ }
3035
+ const resolve = b.cmdId ? widgetWaiters.get(b.cmdId) : undefined;
3036
+ if (resolve) {
3037
+ widgetWaiters.delete(b.cmdId!);
3038
+ resolve({ ok: b.ok === true, data: b.data, error: b.error });
3039
+ }
3040
+ return Response.json({ ok: true });
3041
+ }
3042
+
3043
+ // POST /api/widget/read {viewer, kind, args?} — CLI issues a read to a viewer
3044
+ if (req.method === "POST" && p === "/api/widget/read") {
3045
+ let b: { viewer?: string; kind?: string; args?: unknown };
3046
+ try {
3047
+ b = (await req.json()) as typeof b;
3048
+ } catch {
3049
+ return new Response("invalid JSON body", { status: 400 });
3050
+ }
3051
+ if (!b.viewer || !b.kind) return new Response("missing viewer/kind", { status: 400 });
3052
+ // A scoped token needs the 'screenshot' cap for that kind (scopedGate already
3053
+ // required 'read'); the master token is unrestricted.
3054
+ if (
3055
+ authResult.kind === "scoped" &&
3056
+ b.kind === "screenshot" &&
3057
+ !authResult.scope.caps.includes("screenshot")
3058
+ )
3059
+ return new Response("scoped token lacks 'screenshot'", { status: 403 });
3060
+ const vid = widgetResolve(b.viewer);
3061
+ if (!vid) return new Response(`no online viewer matching "${b.viewer}"`, { status: 404 });
3062
+ if (!boundOk(vid)) return new Response("token not bound to this viewer", { status: 403 });
3063
+ const push = widgetPushers.get(vid);
3064
+ if (!push) return new Response("viewer offline", { status: 409 });
3065
+ const cmdId = `c${++widgetCmdSeq}_${Date.now()}`;
3066
+ const result = await new Promise<{ ok: boolean; data?: unknown; error?: string }>((resolve) => {
3067
+ const timer = setTimeout(() => {
3068
+ widgetWaiters.delete(cmdId);
3069
+ resolve({ ok: false, error: "timeout" });
3070
+ }, 10_000);
3071
+ widgetWaiters.set(cmdId, (r) => {
3072
+ clearTimeout(timer);
3073
+ resolve(r);
3074
+ });
3075
+ push({ cmdId, kind: b.kind, args: b.args ?? {} });
3076
+ });
3077
+ const v = widgetViewers.get(vid);
3078
+ return Response.json({
3079
+ viewer: vid,
3080
+ url: v?.url ?? "",
3081
+ ts: Math.floor(Date.now() / 1000),
3082
+ kind: b.kind,
3083
+ ...(result.ok ? { data: result.data } : { error: result.error ?? "failed" }),
3084
+ });
3085
+ }
3086
+
2872
3087
  // GET /api/tail/:keyword — SSE streaming
2873
3088
  const tailM = /^\/api\/tail\/(.+)$/.exec(p);
2874
3089
  if (req.method === "GET" && tailM) {
package/ts/subcommands.ts CHANGED
@@ -366,6 +366,8 @@ const SUBCOMMANDS = new Set([
366
366
  "ch",
367
367
  "channels",
368
368
  "term",
369
+ "widget",
370
+ "mint",
369
371
  "serve",
370
372
  "schedule",
371
373
  "remote",
@@ -491,6 +493,14 @@ export async function runSubcommand(argv: string[]): Promise<number | null> {
491
493
  const { cmdTerm } = await import("./terminal.ts");
492
494
  return cmdTerm(rest);
493
495
  }
496
+ case "widget": {
497
+ const { cmdWidget } = await import("./widget.ts");
498
+ return cmdWidget(rest);
499
+ }
500
+ case "mint": {
501
+ const { cmdMint } = await import("./widget.ts");
502
+ return cmdMint(rest);
503
+ }
494
504
  case "serve": {
495
505
  const { cmdServe } = await import("./serve.ts");
496
506
  return cmdServe(rest);
@@ -628,6 +638,8 @@ export async function cmdHelp(managerCommands = true): Promise<number> {
628
638
  ` ay msgs [keyword] [--in|--out] inter-agent message log (sent + received)\n` +
629
639
  ` ay ch mk|join|send|read|tail <topic> local-first E2E channels: AI ↔ humans on a topic (ay ch help)\n` +
630
640
  ` ay term embed <pid> <script> to embed a live read-only agent terminal in a page (ay term help)\n` +
641
+ ` ay widget ls | read selection|dom read an opted-in page widget's context (selection/DOM) (ay widget help)\n` +
642
+ ` ay mint <target> --caps ... mint a scoped, short-TTL capability token (embeddable in a page)\n` +
631
643
  ` ay key <keyword> <key...> send raw keystrokes (down/up/enter/esc/…) — drives menus\n` +
632
644
  ` ay select <keyword> <N> pick option N of a needs_input selection menu\n` +
633
645
  ` ay attach <keyword> interactive attach (detach: Ctrl-\\)\n` +
@@ -9,12 +9,34 @@ describe("termToken", () => {
9
9
  const tok = mintTermToken(MASTER, { pid: "4242", canSend: false, exp: now + 900 });
10
10
  expect(isTermToken(tok)).toBe(true);
11
11
  const scope = verifyTermToken(MASTER, tok, now);
12
- expect(scope).toEqual({ pid: "4242", canSend: false, exp: now + 900 });
12
+ expect(scope).toEqual({ pid: "4242", canSend: false, caps: ["tail", "size"], exp: now + 900 });
13
13
  });
14
14
 
15
- it("round-trips an interactive token (canSend true)", () => {
15
+ it("round-trips an interactive token (canSend true → send/resize caps)", () => {
16
16
  const tok = mintTermToken(MASTER, { pid: "7", canSend: true, exp: now + 60 });
17
- expect(verifyTermToken(MASTER, tok, now)?.canSend).toBe(true);
17
+ const scope = verifyTermToken(MASTER, tok, now);
18
+ expect(scope?.canSend).toBe(true);
19
+ expect(scope?.caps).toEqual(["tail", "size", "send", "resize"]);
20
+ });
21
+
22
+ it("carries an explicit caps array (widget read/screenshot)", () => {
23
+ const tok = mintTermToken(MASTER, { pid: "v_ab12", caps: ["read", "screenshot"], exp: now + 60 });
24
+ const scope = verifyTermToken(MASTER, tok, now);
25
+ expect(scope?.caps).toEqual(["read", "screenshot"]);
26
+ expect(scope?.canSend).toBe(false); // no "send" cap
27
+ expect(scope?.pid).toBe("v_ab12");
28
+ });
29
+
30
+ it("verifies a legacy {p,w,x} token (pre-caps format) into derived caps", () => {
31
+ // hand-build a legacy payload to prove backward-compat with live phase-2 tokens
32
+ const legacy = Buffer.from(JSON.stringify({ p: "9", w: 1, x: now + 60 })).toString("base64url");
33
+ const body = `ayt1.${legacy}`;
34
+ const { createHmac, createHash } = require("crypto");
35
+ const key = createHash("sha256").update("ay/term/token/v1\n" + MASTER).digest();
36
+ const sig = createHmac("sha256", key).update(body).digest().toString("base64url");
37
+ const scope = verifyTermToken(MASTER, `${body}.${sig}`, now);
38
+ expect(scope?.pid).toBe("9");
39
+ expect(scope?.caps).toEqual(["tail", "size", "send", "resize"]);
18
40
  });
19
41
 
20
42
  it("rejects a token signed with a different master (forgery)", () => {
package/ts/termToken.ts CHANGED
@@ -17,17 +17,29 @@
17
17
  // delivers short-TTL + read-only + single-session, which is what embeds need.)
18
18
  import { createHash, createHmac, timingSafeEqual } from "crypto";
19
19
 
20
+ /** Capability vocabulary a scoped token can carry (see scopedGate in serve.ts). */
21
+ export type Cap = "tail" | "size" | "send" | "resize" | "read" | "screenshot";
22
+
20
23
  export interface TermScope {
21
- /** The agent pid this token is bound to (resolved to a concrete pid at mint time). */
24
+ /** Subject the token is bound to: an agent pid (terminal) or a viewer id (widget). */
22
25
  pid: string;
23
- /** Interactive: may also write to this pid's stdin (/api/send). Read-only when false. */
26
+ /** Convenience mirror of caps.includes("send") — interactive terminal write. */
24
27
  canSend: boolean;
28
+ /** Full capability list this token grants for its subject. */
29
+ caps: string[];
25
30
  /** Expiry, epoch seconds. */
26
31
  exp: number;
27
32
  }
28
33
 
29
34
  const PREFIX = "ayt1";
30
35
 
36
+ const CAPS_READONLY: Cap[] = ["tail", "size"];
37
+ const CAPS_INTERACTIVE: Cap[] = ["tail", "size", "send", "resize"];
38
+ /** Derive caps for a legacy `{w}` token (pre-caps format): w=1 ⇒ interactive. */
39
+ function legacyCaps(w: unknown): string[] {
40
+ return w === 1 ? [...CAPS_INTERACTIVE] : [...CAPS_READONLY];
41
+ }
42
+
31
43
  /** Dedicated HMAC key derived from the master token (so the token isn't signed with the raw master). */
32
44
  function keyFor(masterToken: string): Buffer {
33
45
  return createHash("sha256")
@@ -40,10 +52,19 @@ export function isTermToken(tok: unknown): tok is string {
40
52
  return typeof tok === "string" && tok.startsWith(PREFIX + ".");
41
53
  }
42
54
 
43
- /** Mint a scoped token bound to `scope.pid`, signed with the master token. */
44
- export function mintTermToken(masterToken: string, scope: TermScope): string {
55
+ /**
56
+ * Mint a scoped token bound to `scope.pid` (the subject), signed with the master
57
+ * token. `caps` is authoritative; if omitted it derives from `canSend` (terminal
58
+ * back-compat). Emits the caps-format payload `{s, c, x}` (verified alongside the
59
+ * legacy `{p, w, x}`).
60
+ */
61
+ export function mintTermToken(
62
+ masterToken: string,
63
+ scope: { pid: string; exp: number; caps?: string[]; canSend?: boolean },
64
+ ): string {
65
+ const caps = scope.caps ?? (scope.canSend ? [...CAPS_INTERACTIVE] : [...CAPS_READONLY]);
45
66
  const payload = Buffer.from(
46
- JSON.stringify({ p: scope.pid, w: scope.canSend ? 1 : 0, x: Math.floor(scope.exp) }),
67
+ JSON.stringify({ s: scope.pid, c: caps, x: Math.floor(scope.exp) }),
47
68
  ).toString("base64url");
48
69
  const body = `${PREFIX}.${payload}`;
49
70
  const sig = createHmac("sha256", keyFor(masterToken)).update(body).digest().toString("base64url");
@@ -73,13 +94,19 @@ export function verifyTermToken(
73
94
  }
74
95
  // constant-time compare; length check first (timingSafeEqual throws on mismatch)
75
96
  if (got.length !== expected.length || !timingSafeEqual(got, expected)) return null;
76
- let obj: { p?: unknown; w?: unknown; x?: unknown };
97
+ let obj: { s?: unknown; p?: unknown; w?: unknown; c?: unknown; x?: unknown };
77
98
  try {
78
99
  obj = JSON.parse(Buffer.from(parts[1]!, "base64url").toString("utf-8"));
79
100
  } catch {
80
101
  return null;
81
102
  }
82
- if (typeof obj.p !== "string" || !obj.p) return null;
103
+ // subject: new tokens use `s`, legacy tokens use `p`
104
+ const sub = typeof obj.s === "string" && obj.s ? obj.s : obj.p;
105
+ if (typeof sub !== "string" || !sub) return null;
83
106
  if (typeof obj.x !== "number" || !Number.isFinite(obj.x) || obj.x <= nowSec) return null; // expired
84
- return { pid: obj.p, canSend: obj.w === 1, exp: obj.x };
107
+ // caps: new tokens carry `c`; legacy tokens derive from `w`
108
+ const caps = Array.isArray(obj.c)
109
+ ? (obj.c.filter((x) => typeof x === "string") as string[])
110
+ : legacyCaps(obj.w);
111
+ return { pid: sub, canSend: caps.includes("send"), caps, exp: obj.x };
85
112
  }
package/ts/widget.ts ADDED
@@ -0,0 +1,207 @@
1
+ // `ay widget` — read what a viewer is looking at, through an opted-in page widget.
2
+ //
3
+ // Agent-side CLI over the daemon's widget broker (serve.ts /api/widget/*). The
4
+ // page embeds `new AyWidget({sensors:[…]})` (widget/browser.ts); this reads it:
5
+ // ay widget ls # online viewers
6
+ // ay widget read selection <viewer> # what the viewer has selected
7
+ // ay widget read dom <viewer> --selector <css>
8
+ // Complements rechrome (rech drives any page; this reads an author-opted-in page,
9
+ // cross-origin / WebRTC-remote, no extension). Verb `read` matches `ay read`/`tail`.
10
+
11
+ type FlagSpec = Record<string, "bool" | "value">;
12
+
13
+ function parseFlags(
14
+ args: string[],
15
+ known: FlagSpec,
16
+ ): { flags: Record<string, string | boolean>; positional: string[] } {
17
+ const flags: Record<string, string | boolean> = {};
18
+ const positional: string[] = [];
19
+ for (let i = 0; i < args.length; i++) {
20
+ const a = args[i]!;
21
+ if (!a.startsWith("-") || a === "-") {
22
+ positional.push(a);
23
+ continue;
24
+ }
25
+ const eq = a.indexOf("=");
26
+ const name = eq === -1 ? a.replace(/^--?/, "") : a.slice(a.startsWith("--") ? 2 : 1, eq);
27
+ const kind = known[name];
28
+ if (!kind) throw new Error(`unknown flag ${a}`);
29
+ if (kind === "bool") {
30
+ if (eq !== -1) throw new Error(`--${name} takes no value`);
31
+ flags[name] = true;
32
+ } else {
33
+ const v = eq !== -1 ? a.slice(eq + 1) : args[++i];
34
+ if (v === undefined) throw new Error(`${a} requires a value`);
35
+ flags[name] = v;
36
+ }
37
+ }
38
+ return { flags, positional };
39
+ }
40
+
41
+ /** Resolve the local daemon base URL + token (flags override discovery). */
42
+ async function daemonTarget(
43
+ flags: Record<string, string | boolean>,
44
+ ): Promise<{ base: string; token: string }> {
45
+ const { resolveLocalServeUrl, loadTokenReadOnly } = await import("./serve.ts");
46
+ const base = typeof flags.base === "string" ? flags.base : await resolveLocalServeUrl();
47
+ if (!base)
48
+ throw new Error(
49
+ "no running ay serve daemon found — start `ay serve` or pass --base <url> (e.g. http://127.0.0.1:PORT)",
50
+ );
51
+ const token = typeof flags.token === "string" ? flags.token : ((await loadTokenReadOnly()) ?? "");
52
+ if (!token) throw new Error("no serve token — pass --token or run `ay serve` once");
53
+ return { base: base.replace(/\/+$/, ""), token };
54
+ }
55
+
56
+ function withTok(url: string, token: string): string {
57
+ return `${url}${url.includes("?") ? "&" : "?"}token=${encodeURIComponent(token)}`;
58
+ }
59
+
60
+ async function cmdWidgetLs(args: string[]): Promise<number> {
61
+ const { flags } = parseFlags(args, { json: "bool", base: "value", token: "value" });
62
+ const { base, token } = await daemonTarget(flags);
63
+ const res = await fetch(withTok(`${base}/api/widget/list`, token), {
64
+ headers: { accept: "application/json" },
65
+ });
66
+ if (!res.ok) throw new Error(`widget list failed: ${res.status} ${await res.text()}`);
67
+ const list = (await res.json()) as Array<{
68
+ id: string;
69
+ url: string;
70
+ title: string;
71
+ caps: string[];
72
+ age: number;
73
+ }>;
74
+ if (flags.json) {
75
+ process.stdout.write(JSON.stringify(list, null, 2) + "\n");
76
+ return 0;
77
+ }
78
+ if (!list.length) {
79
+ process.stderr.write("no online widgets (a page must embed AyWidget and start() it)\n");
80
+ return 0;
81
+ }
82
+ for (const v of list) {
83
+ process.stdout.write(
84
+ `${v.id.padEnd(12)} ${(v.title || "(untitled)").slice(0, 28).padEnd(28)} ` +
85
+ `caps:${v.caps.join(",") || "-"} ${v.age}s ${v.url}\n`,
86
+ );
87
+ }
88
+ return 0;
89
+ }
90
+
91
+ async function cmdWidgetRead(args: string[]): Promise<number> {
92
+ const { flags, positional } = parseFlags(args, {
93
+ selector: "value",
94
+ all: "bool",
95
+ text: "bool",
96
+ html: "bool",
97
+ json: "bool",
98
+ base: "value",
99
+ token: "value",
100
+ });
101
+ const kind = positional[0];
102
+ const viewer = positional[1];
103
+ if (!kind || !viewer)
104
+ throw new Error("usage: ay widget read <selection|dom> <viewer> [--selector <css>] [--all] [--text|--html]");
105
+ const cmdArgs: Record<string, unknown> = {};
106
+ if (kind === "dom") {
107
+ if (typeof flags.selector !== "string") throw new Error("dom read needs --selector <css>");
108
+ cmdArgs.selector = flags.selector;
109
+ cmdArgs.all = flags.all === true;
110
+ }
111
+ const { base, token } = await daemonTarget(flags);
112
+ const res = await fetch(withTok(`${base}/api/widget/read`, token), {
113
+ method: "POST",
114
+ headers: { "content-type": "application/json" },
115
+ body: JSON.stringify({ viewer, kind, args: cmdArgs }),
116
+ });
117
+ const body = (await res.json().catch(() => ({}))) as any;
118
+ if (!res.ok) throw new Error(`widget read failed: ${res.status} ${JSON.stringify(body)}`);
119
+ if (body.error) {
120
+ process.stderr.write(`read ${kind} @ ${body.viewer ?? viewer}: ${body.error}\n`);
121
+ return 1;
122
+ }
123
+ // Plain-text shortcut for selection unless --json/--html asked for the envelope.
124
+ if (kind === "selection" && !flags.json) {
125
+ process.stdout.write((flags.html ? body.data?.html : body.data?.text) ?? "");
126
+ process.stdout.write("\n");
127
+ return 0;
128
+ }
129
+ process.stdout.write(JSON.stringify(flags.json ? body : body.data, null, 2) + "\n");
130
+ return 0;
131
+ }
132
+
133
+ function widgetHelp(): number {
134
+ process.stdout.write(
135
+ `ay widget — read an opted-in page widget's context (selection / DOM)\n\n` +
136
+ `Usage:\n` +
137
+ ` ay widget ls [--json]\n` +
138
+ ` ay widget read selection <viewer> [--text|--html|--json]\n` +
139
+ ` ay widget read dom <viewer> --selector <css> [--all] [--json]\n\n` +
140
+ ` <viewer> = an id, or a url/title substring (see \`ay widget ls\`).\n` +
141
+ ` Common flags: --base <daemon-url> --token <tok> (default: the local daemon).\n` +
142
+ ` The page must embed \`new AyWidget({sensors:[...]}).start()\` and the token must\n` +
143
+ ` carry the 'read' cap (ay mint <viewer> --caps read) — or use the master token.\n`,
144
+ );
145
+ return 0;
146
+ }
147
+
148
+ export async function cmdWidget(args: string[]): Promise<number> {
149
+ const sub = args[0];
150
+ const rest = args.slice(1);
151
+ switch (sub) {
152
+ case "ls":
153
+ case "list":
154
+ return cmdWidgetLs(rest);
155
+ case "read":
156
+ return cmdWidgetRead(rest);
157
+ case undefined:
158
+ case "help":
159
+ case "--help":
160
+ case "-h":
161
+ return widgetHelp();
162
+ default:
163
+ process.stderr.write(`ay widget: unknown subcommand "${sub}"\n\n`);
164
+ widgetHelp();
165
+ return 1;
166
+ }
167
+ }
168
+
169
+ // ── `ay mint` — the general scoped-token minter (ay term mint is an alias) ────
170
+ function parseTtlSec(s: string): number {
171
+ const m = /^(\d+)(s|m|h)?$/.exec(s.trim());
172
+ if (!m) throw new Error(`bad --ttl "${s}" (use e.g. 900, 30s, 15m, 2h)`);
173
+ const mult = m[2] === "h" ? 3600 : m[2] === "m" ? 60 : 1;
174
+ return Number(m[1]) * mult;
175
+ }
176
+
177
+ const KNOWN_CAPS = new Set(["tail", "size", "send", "resize", "read", "screenshot"]);
178
+
179
+ export async function cmdMint(args: string[]): Promise<number> {
180
+ const { flags, positional } = parseFlags(args, {
181
+ caps: "value",
182
+ ttl: "value",
183
+ json: "bool",
184
+ });
185
+ const target = positional[0];
186
+ if (!target || positional.length > 1)
187
+ throw new Error("usage: ay mint <pid|viewer|url|*> --caps tail,send,read,screenshot [--ttl 15m] [--json]");
188
+ if (typeof flags.caps !== "string")
189
+ throw new Error("--caps is required, e.g. --caps read or --caps tail,send");
190
+ const caps = flags.caps.split(",").map((c) => c.trim()).filter(Boolean);
191
+ const bad = caps.filter((c) => !KNOWN_CAPS.has(c));
192
+ if (bad.length) throw new Error(`unknown cap(s): ${bad.join(", ")} (valid: ${[...KNOWN_CAPS].join(", ")})`);
193
+ const ttlSec = parseTtlSec(typeof flags.ttl === "string" ? flags.ttl : "15m");
194
+ const { mintScopedTermToken } = await import("./serve.ts");
195
+ const r = await mintScopedTermToken(target, { ttlSec, caps });
196
+ if (flags.json === true) {
197
+ process.stdout.write(JSON.stringify(r) + "\n");
198
+ return 0;
199
+ }
200
+ process.stdout.write(r.token + "\n");
201
+ process.stderr.write(
202
+ `\n Scoped token for ${r.sub === "*" ? "ANY subject" : r.sub}, caps [${r.caps.join(", ")}], ` +
203
+ `expires ${new Date(r.exp * 1000).toISOString()}.\n` +
204
+ ` It grants ONLY those caps for that subject — safe to embed in a page (not the master token).\n`,
205
+ );
206
+ return 0;
207
+ }
package/ts/widgets.ts CHANGED
@@ -12,3 +12,5 @@ export { AyTerminal } from "./terminal/browser.ts";
12
12
  export type { AyTerminalInfo } from "./terminal/browser.ts";
13
13
  export { AyChannel } from "./channels/browser.ts";
14
14
  export type { AyChannelInfo } from "./channels/browser.ts";
15
+ export { AyWidget } from "./widget/browser.ts";
16
+ export type { AyWidgetInfo } from "./widget/browser.ts";