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.
- package/dist/{SUPPORTED_CLIS-C0RwjYrz.js → SUPPORTED_CLIS-CtgC_d_l.js} +3 -3
- package/dist/{SUPPORTED_CLIS-CDrJz9Xo.js → SUPPORTED_CLIS-NpYW9OaR.js} +2 -2
- package/dist/{agentShare-Cl_J3dfx.js → agentShare-549Zwi_B.js} +2 -2
- package/dist/{callback-CFbkvqTh.js → callback-B-ojDckO.js} +2 -2
- package/dist/{callback-D0_AeyN1.js → callback-BNJMOVlW.js} +3 -3
- package/dist/cli.js +4 -4
- package/dist/index.js +2 -2
- package/dist/{notifyDaemon-D_8TRVHt.js → notifyDaemon-CjBVeWRg.js} +2 -2
- package/dist/{rustBinary-Bb9qJZ5w.js → rustBinary-DSorJMBV.js} +2 -2
- package/dist/{schedule-B8Erb3y6.js → schedule-Bw3SL5Hv.js} +4 -4
- package/dist/{serve-QejbNTr-.js → serve-BLxmap1e.js} +217 -37
- package/dist/{setup-Dzi5Cc3g.js → setup-DlFVw_1n.js} +2 -2
- package/dist/{subcommands-ymJWO2iv.js → subcommands-A0uOpmco.js} +1 -1
- package/dist/{subcommands-DFr-5FOL.js → subcommands-BdBCxZVu.js} +20 -10
- package/dist/{terminal-vNeFNLT8.js → terminal-w7drEaJc.js} +2 -2
- package/dist/{ts-Dd1FK7sR.js → ts-EvHuyNA9.js} +2 -2
- package/dist/{versionChecker-XfEBINhH.js → versionChecker-DNLNn_SL.js} +2 -2
- package/dist/widget-Coav85Lx.js +172 -0
- package/dist/widget.js +162 -0
- package/dist/widgets.js +2 -1
- package/dist/{ws-Dun8gGiV.js → ws-3prv2in5.js} +2 -2
- package/package.json +5 -1
- package/ts/serve.ts +234 -19
- package/ts/subcommands.ts +12 -0
- package/ts/termToken.spec.ts +25 -3
- package/ts/termToken.ts +35 -8
- package/ts/widget.ts +207 -0
- 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
|
|
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 (
|
|
297
|
-
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
-
|
|
323
|
-
opts: { ttlSec: number; canSend
|
|
324
|
-
): Promise<{ token: string; pid: string; exp: number;
|
|
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
|
|
331
|
-
|
|
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
|
|
334
|
-
return { token, pid, exp,
|
|
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` +
|
package/ts/termToken.spec.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
24
|
+
/** Subject the token is bound to: an agent pid (terminal) or a viewer id (widget). */
|
|
22
25
|
pid: string;
|
|
23
|
-
/**
|
|
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
|
-
/**
|
|
44
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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";
|