agent-yes 1.175.0 → 1.176.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 (47) hide show
  1. package/agent-yes.config.schema.json +2 -2
  2. package/default.config.yaml +45 -0
  3. package/dist/SUPPORTED_CLIS-BC4fOfBC.js +8 -0
  4. package/dist/{SUPPORTED_CLIS-DOs4OKwx.js → SUPPORTED_CLIS-TMmgX_p6.js} +2 -2
  5. package/dist/agentShare-DRfghQz8.js +231 -0
  6. package/dist/bash-yes.js +10 -0
  7. package/dist/cli.js +8 -8
  8. package/dist/cmd-yes.js +10 -0
  9. package/dist/{e2e-jb0Hp43q.js → e2e-BeKjLhmO.js} +1 -1
  10. package/dist/{forkNested-DhJxa4q4.js → forkNested-C8q7E6mp.js} +1 -1
  11. package/dist/index.js +2 -2
  12. package/dist/{notifyDaemon-EQPDGq-K.js → notifyDaemon-C08fJ2Ai.js} +5 -5
  13. package/dist/{openBrowser-ChR4llYa.js → openBrowser-BO5RVYwL.js} +1 -1
  14. package/dist/powershell-yes.js +10 -0
  15. package/dist/{remotes-BQMr4_En.js → remotes-BIHNxn1a.js} +3 -3
  16. package/dist/{remotes-CC-4GuJb.js → remotes-DsTnQhyx.js} +3 -3
  17. package/dist/{rustBinary-B5pGAxJj.js → rustBinary-DZTU07SA.js} +2 -2
  18. package/dist/{schedule-2gVJxKiN.js → schedule-BQtFOk0z.js} +5 -5
  19. package/dist/{serve-3WOXuTLt.js → serve-B5PQdiOb.js} +143 -76
  20. package/dist/{setup-CJQPRwIb.js → setup-BCqFNJoB.js} +3 -3
  21. package/dist/{share-QregR8a_.js → share-29YbzpbU.js} +6 -6
  22. package/dist/share-nvqy64A2.js +4 -0
  23. package/dist/{spawnGate-BFhva-2F.js → spawnGate-C5nBiUZQ.js} +1 -1
  24. package/dist/{spawnGate-IJDByl2U.js → spawnGate-Clp9xBPX.js} +2 -2
  25. package/dist/{subcommands-CpA7qj-z.js → subcommands-BbSJ_nTU.js} +736 -714
  26. package/dist/subcommands-BokdtqqC.js +9 -0
  27. package/dist/{tray-DXr7iK3E.js → tray-DKwVRBwr.js} +1 -1
  28. package/dist/{ts-DRUpMU-r.js → ts-Ce7a44Rj.js} +3 -3
  29. package/dist/{versionChecker-0OpVlsRu.js → versionChecker-C7C7QAPu.js} +2 -2
  30. package/dist/{webrtcLink-B7REGtK2.js → webrtcLink-BG0Xc4-W.js} +2 -2
  31. package/dist/{webrtcRemote-Bx-eD_0I.js → webrtcRemote-BLEbZnbY.js} +3 -3
  32. package/dist/{workspaceConfig-BgqK-31W.js → workspaceConfig-DKd6UvVm.js} +1 -1
  33. package/lab/ui/index.html +495 -291
  34. package/lab/ui/qrcode.js +2297 -0
  35. package/lab/ui/sw.js +2 -1
  36. package/package.json +8 -2
  37. package/scripts/build-rgui.ts +78 -0
  38. package/ts/agentShare.lifecycle.spec.ts +139 -0
  39. package/ts/agentShare.spec.ts +238 -0
  40. package/ts/agentShare.ts +330 -0
  41. package/ts/forkNested.spec.ts +39 -2
  42. package/ts/index.ts +5 -1
  43. package/ts/serve.ts +119 -5
  44. package/ts/share.ts +10 -3
  45. package/ts/subcommands.ts +29 -0
  46. package/dist/SUPPORTED_CLIS-7XJNuNWe.js +0 -8
  47. package/dist/subcommands-CkrKWSp8.js +0 -9
@@ -0,0 +1,330 @@
1
+ // Single-agent, view-only shares (Option X from docs/agent-sharing.md).
2
+ //
3
+ // A scoped share stands up its OWN e2ee WebRTC room (via startShare, minted
4
+ // fresh — never the persisted master fleet room) that exposes exactly ONE agent,
5
+ // read-only. The room's host bridge wraps the full `ay serve` API handler in a
6
+ // `scopedFetch` that DEFAULT-DENIES and permits only read paths, each verified to
7
+ // resolve to the shared `agent_id` (the stable per-process id, not the reusable
8
+ // pid). Read-only is enforced here on the host — the browser hiding controls is
9
+ // only UX (design principle #1: host-enforced capability, not client-side hiding).
10
+ //
11
+ // Link format: agent-yes.com/w/#room:grantSecret — the pid is NOT in the URL; the
12
+ // scope lives in this host-side share record. Shares are ephemeral (no disk
13
+ // persistence): a daemon restart drops them, and a restarted agent mints a fresh
14
+ // agent_id, so the holder re-shares (a deliberate NON-GOAL per the design).
15
+ import { listRecords, resolveOne, type CommonOpts } from "./subcommands.ts";
16
+ import { startShare } from "./share.ts";
17
+
18
+ export type SharePerm = "r" | "rw";
19
+
20
+ export interface ScopedShare {
21
+ shareId: string;
22
+ agentId: string;
23
+ perm: SharePerm;
24
+ room: string;
25
+ link: string;
26
+ label: string; // human hint: cli · cwd basename
27
+ createdAt: number;
28
+ expiresAt: number;
29
+ close: () => void;
30
+ }
31
+
32
+ // Bound concurrent shares: each is its own signaling WS + WebRTC host peer with a
33
+ // process.exit self-heal on repeated native peer-setup failure (see ts/share.ts),
34
+ // so a fleet of rooms multiplies that risk and the signaling-DO cost. A handful is
35
+ // plenty for the intended "show someone this one agent" use.
36
+ export const MAX_SHARES = 8;
37
+ export const DEFAULT_SHARE_TTL_MS = 24 * 60 * 60 * 1000; // 24h — design "short expiration"
38
+
39
+ const shares = new Map<string, ScopedShare>();
40
+
41
+ function lsOpts(all = false): CommonOpts {
42
+ return { all, active: false, json: true, latest: true, cwdScope: null };
43
+ }
44
+
45
+ export function listShares(): Omit<ScopedShare, "close">[] {
46
+ return [...shares.values()]
47
+ .sort((a, b) => b.createdAt - a.createdAt)
48
+ .map(({ close: _close, ...s }) => s);
49
+ }
50
+
51
+ export function revokeShare(shareId: string): boolean {
52
+ const s = shares.get(shareId);
53
+ if (!s) return false;
54
+ try {
55
+ s.close();
56
+ } catch {
57
+ /* already closed */
58
+ }
59
+ shares.delete(shareId);
60
+ return true;
61
+ }
62
+
63
+ export function revokeAllShares(): void {
64
+ for (const id of [...shares.keys()]) revokeShare(id);
65
+ }
66
+
67
+ /** Resolve a keyword to a live agent and mint a fresh view-only share room for it. */
68
+ export async function createScopedShare(opts: {
69
+ agent: string; // any keyword: pid, agent_id, cwd/cli/prompt substring
70
+ perm?: SharePerm;
71
+ localFetch: (req: Request) => Promise<Response>;
72
+ apiToken: string;
73
+ sighost?: string;
74
+ ttlMs?: number;
75
+ }): Promise<Omit<ScopedShare, "close">> {
76
+ if (shares.size >= MAX_SHARES) {
77
+ throw new Error(`too many active shares (max ${MAX_SHARES}) — revoke one first`);
78
+ }
79
+ const record = await resolveOne(opts.agent, lsOpts(true)); // throws if none match
80
+ const agentId = record.agent_id;
81
+ if (!agentId) {
82
+ // Only agents registered with a stable id can be scoped safely; pid alone is
83
+ // reused and unsafe as the security key.
84
+ throw new Error(`agent ${record.pid} has no stable agent_id — cannot share (restart it)`);
85
+ }
86
+ const label = `${record.cli}${record.cwd ? " · " + record.cwd.split("/").filter(Boolean).pop() : ""}`;
87
+
88
+ const scoped = scopedFetch(agentId, opts.localFetch, opts.perm ?? "r");
89
+ const { room, link, close } = await startShare({
90
+ localFetch: scoped,
91
+ apiToken: opts.apiToken,
92
+ sighost: opts.sighost,
93
+ // no `url` → startShare mints a fresh, unpersisted room (never the master room)
94
+ });
95
+
96
+ const shareId = "s" + Math.abs(hashStr(room + agentId + link)).toString(36);
97
+ const createdAt = Date.now();
98
+ const ttl = opts.ttlMs ?? DEFAULT_SHARE_TTL_MS;
99
+ const expiresAt = createdAt + ttl;
100
+
101
+ const stop = () => close();
102
+ const expiryTimer = setTimeout(() => revokeShare(shareId), ttl);
103
+ expiryTimer.unref?.();
104
+
105
+ const share: ScopedShare = {
106
+ shareId,
107
+ agentId,
108
+ perm: opts.perm ?? "r",
109
+ room,
110
+ link,
111
+ label,
112
+ createdAt,
113
+ expiresAt,
114
+ close: () => {
115
+ clearTimeout(expiryTimer);
116
+ stop();
117
+ },
118
+ };
119
+ shares.set(shareId, share);
120
+ const { close: _c, ...pub } = share;
121
+ return pub;
122
+ }
123
+
124
+ function hashStr(s: string): number {
125
+ let h = 0;
126
+ for (let i = 0; i < s.length; i++) h = (Math.imul(31, h) + s.charCodeAt(i)) | 0;
127
+ return h;
128
+ }
129
+
130
+ function forbidden(msg = "read-only share"): Response {
131
+ return new Response(msg, { status: 403 });
132
+ }
133
+
134
+ // Wrap the master API handler so a scoped viewer can ONLY read, and only THIS
135
+ // agent. Default-deny: an endpoint not explicitly allowed below is 403.
136
+ export function scopedFetch(
137
+ agentId: string,
138
+ inner: (req: Request) => Promise<Response>,
139
+ perm: SharePerm = "r",
140
+ ): (req: Request) => Promise<Response> {
141
+ return async (req: Request): Promise<Response> => {
142
+ const url = new URL(req.url);
143
+ const p = url.pathname;
144
+ const method = req.method;
145
+
146
+ // Static host metadata, no agent data.
147
+ if (method === "GET" && p === "/api/version") return inner(req);
148
+ if (method === "GET" && p === "/api/whoami")
149
+ return withShareFlag(await inner(req), agentId, perm);
150
+
151
+ // Agent list: force keyword=agentId (cheap server-side narrowing) then
152
+ // post-filter by EXACT agent_id — matchKeyword can fuzzy-match a sibling whose
153
+ // cwd/prompt contains the hex, so the keyword hint is not a boundary on its own.
154
+ if (method === "GET" && (p === "/api/ls" || p === "/api/ls/subscribe")) {
155
+ const scopedUrl = new URL(url);
156
+ scopedUrl.searchParams.set("keyword", agentId);
157
+ scopedUrl.searchParams.delete("all"); // never widen to other/exited agents
158
+ const scopedReq = new Request(scopedUrl.toString(), req);
159
+ const res = await inner(scopedReq);
160
+ return p === "/api/ls" ? filterLsJson(res, agentId) : filterLsSse(res, agentId);
161
+ }
162
+
163
+ // Per-agent reads — verify the target resolves to OUR agent_id (403 otherwise).
164
+ const m =
165
+ /^\/api\/(read|tail|status|size)\/(.+)$/.exec(p) && method === "GET"
166
+ ? /^\/api\/(read|tail|status|size)\/(.+)$/.exec(p)
167
+ : null;
168
+ if (m) {
169
+ const kw = decodeURIComponent(m[2]!);
170
+ if (!(await targetIsAgent(kw, agentId))) return forbidden("agent not shared");
171
+ return inner(req);
172
+ }
173
+
174
+ // Steer (rw): a read-WRITE share may drive THIS agent — send input, resize its
175
+ // PTY, and self-report presence. It still may NOT control it (kill/restart/
176
+ // spawn are machine-admin, excluded from a per-agent share) nor touch any other
177
+ // agent. Treat `send` as command-injection-equivalent — the warning shown when
178
+ // minting an rw link is the real safety gate (docs/agent-sharing.md).
179
+ if (perm.includes("w")) {
180
+ // POST /api/send body {keyword, msg, code} — verify the target is us.
181
+ if (method === "POST" && p === "/api/send") {
182
+ let kw: unknown;
183
+ try {
184
+ kw = JSON.parse(await req.clone().text())?.keyword;
185
+ } catch {
186
+ return forbidden("bad body");
187
+ }
188
+ if (kw == null || !(await targetIsAgent(String(kw), agentId)))
189
+ return forbidden("agent not shared");
190
+ return inner(req);
191
+ }
192
+ // POST /api/resize/:kw — verify the target is us.
193
+ const rz = method === "POST" ? /^\/api\/resize\/(.+)$/.exec(p) : null;
194
+ if (rz) {
195
+ const kw = decodeURIComponent(rz[1]!);
196
+ if (!(await targetIsAgent(kw, agentId))) return forbidden("agent not shared");
197
+ return inner(req);
198
+ }
199
+ // POST /api/presence — cosmetic "who's watching" self-report, no agent control.
200
+ if (method === "POST" && p === "/api/presence") return inner(req);
201
+ }
202
+
203
+ // Everything else (kill, restart, spawn, notes, spawn-config, share*, …, and —
204
+ // for a view-only share — send/resize/presence too) is denied.
205
+ return forbidden();
206
+ };
207
+ }
208
+
209
+ async function targetIsAgent(keyword: string, agentId: string): Promise<boolean> {
210
+ try {
211
+ const rec = await resolveOne(keyword, lsOpts(true));
212
+ return rec.agent_id === agentId;
213
+ } catch {
214
+ return false;
215
+ }
216
+ }
217
+
218
+ // Add a self-describing read-only capability to /api/whoami so the viewer console
219
+ // can enter read-only UI. The host stays the real boundary (writes are 403'd
220
+ // regardless); this is a UX hint the browser can trust because it comes from the
221
+ // host over the same e2ee channel.
222
+ async function withShareFlag(res: Response, agentId: string, perm: SharePerm): Promise<Response> {
223
+ if (!res.ok) return res;
224
+ let body: unknown;
225
+ try {
226
+ body = await res.json();
227
+ } catch {
228
+ return res;
229
+ }
230
+ const obj = body && typeof body === "object" ? (body as Record<string, unknown>) : {};
231
+ return Response.json({ ...obj, share: { perm, agent_id: agentId, readonly: perm === "r" } });
232
+ }
233
+
234
+ async function filterLsJson(res: Response, agentId: string): Promise<Response> {
235
+ if (!res.ok) return res;
236
+ let arr: unknown;
237
+ try {
238
+ arr = await res.json();
239
+ } catch {
240
+ return res;
241
+ }
242
+ const kept = Array.isArray(arr)
243
+ ? arr.filter(
244
+ (r) => r && typeof r === "object" && (r as { agent_id?: string }).agent_id === agentId,
245
+ )
246
+ : [];
247
+ return Response.json(kept, { status: res.status });
248
+ }
249
+
250
+ // Filter the /api/ls/subscribe SSE so only the shared agent's deltas cross the
251
+ // channel. Each event is `{full?, upsert:[records], remove:[pids]}`; keep only
252
+ // upserts whose agent_id matches, and only removes for pids we actually forwarded.
253
+ function filterLsSse(res: Response, agentId: string): Response {
254
+ if (!res.ok || !res.body) return res;
255
+ const reader = res.body.getReader();
256
+ const dec = new TextDecoder();
257
+ const enc = new TextEncoder();
258
+ const forwarded = new Set<number>();
259
+ let buf = "";
260
+
261
+ const stream = new ReadableStream({
262
+ async pull(ctrl) {
263
+ for (;;) {
264
+ const { done, value } = await reader.read();
265
+ if (done) {
266
+ ctrl.close();
267
+ return;
268
+ }
269
+ buf += dec.decode(value, { stream: true });
270
+ // Emit each complete SSE event (blank-line separated).
271
+ let sep: number;
272
+ let emittedSomething = false;
273
+ while ((sep = buf.indexOf("\n\n")) !== -1) {
274
+ const rawEvent = buf.slice(0, sep);
275
+ buf = buf.slice(sep + 2);
276
+ const out = transformEvent(rawEvent, agentId, forwarded);
277
+ if (out !== null) {
278
+ ctrl.enqueue(enc.encode(out + "\n\n"));
279
+ emittedSomething = true;
280
+ }
281
+ }
282
+ if (emittedSomething) return; // yield to the consumer; resume on next pull
283
+ }
284
+ },
285
+ cancel() {
286
+ reader.cancel().catch(() => {});
287
+ },
288
+ });
289
+ return new Response(stream, {
290
+ status: res.status,
291
+ headers: {
292
+ "Content-Type": "text/event-stream",
293
+ "Cache-Control": "no-cache",
294
+ Connection: "keep-alive",
295
+ },
296
+ });
297
+ }
298
+
299
+ // Returns the rewritten SSE event text, or null to drop it entirely. Passes
300
+ // through comment lines (": ping" heartbeats) and non-data frames unchanged.
301
+ function transformEvent(rawEvent: string, agentId: string, forwarded: Set<number>): string | null {
302
+ const trimmed = rawEvent.replace(/\r/g, "");
303
+ if (!trimmed.trim()) return null;
304
+ if (!/^data:/m.test(trimmed)) return trimmed; // comments/heartbeats pass through
305
+ // Reassemble the `data:` payload (SSE allows multiple data: lines per event).
306
+ const dataLines = trimmed
307
+ .split("\n")
308
+ .filter((l) => l.startsWith("data:"))
309
+ .map((l) => l.slice(5).replace(/^ /, ""));
310
+ const payload = dataLines.join("\n");
311
+ let obj: {
312
+ full?: boolean;
313
+ upsert?: { pid: number; agent_id?: string }[];
314
+ remove?: number[];
315
+ };
316
+ try {
317
+ obj = JSON.parse(payload);
318
+ } catch {
319
+ return null; // malformed — drop rather than leak
320
+ }
321
+ const upsert = (obj.upsert ?? []).filter((r) => r && r.agent_id === agentId);
322
+ for (const r of upsert) forwarded.add(r.pid);
323
+ const remove = (obj.remove ?? []).filter((pid) => forwarded.has(pid));
324
+ for (const pid of remove) forwarded.delete(pid);
325
+ // On the first snapshot always emit (even if empty) so the viewer knows it's
326
+ // connected; later ticks only when something relevant changed.
327
+ if (!obj.full && upsert.length === 0 && remove.length === 0) return null;
328
+ const next = obj.full ? { full: true, upsert, remove } : { upsert, remove };
329
+ return "data: " + JSON.stringify(next);
330
+ }
@@ -1,5 +1,8 @@
1
- import { describe, expect, it } from "vitest";
2
- import { buildSpawnTutorial, shouldForkNested } from "./forkNested";
1
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
2
+ import { tmpdir } from "os";
3
+ import path from "path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
5
+ import { buildSpawnTutorial, shouldForkNested, waitForFifo } from "./forkNested";
3
6
 
4
7
  describe("shouldForkNested", () => {
5
8
  it("forks when nested (AGENT_YES_PID set) and stdout is not a TTY", () => {
@@ -21,6 +24,40 @@ describe("shouldForkNested", () => {
21
24
  });
22
25
  });
23
26
 
27
+ describe("waitForFifo", () => {
28
+ let home: string;
29
+ let prevHome: string | undefined;
30
+
31
+ beforeEach(() => {
32
+ home = mkdtempSync(path.join(tmpdir(), "ay-forknested-"));
33
+ mkdirSync(path.join(home, "fifo"), { recursive: true });
34
+ prevHome = process.env.AGENT_YES_HOME;
35
+ process.env.AGENT_YES_HOME = home;
36
+ });
37
+
38
+ afterEach(() => {
39
+ if (prevHome === undefined) delete process.env.AGENT_YES_HOME;
40
+ else process.env.AGENT_YES_HOME = prevHome;
41
+ rmSync(home, { recursive: true, force: true });
42
+ });
43
+
44
+ it("resolves true as soon as the wrapper's stdin FIFO is registered", async () => {
45
+ // Register the endpoint a poll-tick after the wait starts, like a real spawn.
46
+ setTimeout(() => writeFileSync(path.join(home, "fifo", "111.stdin"), ""), 60);
47
+ await expect(waitForFifo(111, 2000)).resolves.toBe(true);
48
+ });
49
+
50
+ it("times out false when the child never registers", async () => {
51
+ await expect(waitForFifo(222, 120)).resolves.toBe(false);
52
+ });
53
+
54
+ it("fails fast when aborted() reports the child already died", async () => {
55
+ const start = Date.now();
56
+ await expect(waitForFifo(333, 5000, () => true)).resolves.toBe(false);
57
+ expect(Date.now() - start).toBeLessThan(1000); // no full-timeout wait
58
+ });
59
+ });
60
+
24
61
  describe("buildSpawnTutorial", () => {
25
62
  it("names the cli + pid and lists the drive commands with that pid", () => {
26
63
  const out = buildSpawnTutorial("claude", 4242);
package/ts/index.ts CHANGED
@@ -65,7 +65,7 @@ export type AgentCliConfig = {
65
65
  working?: RegExp[]; // regex matcher for working status
66
66
  updateAvailable?: RegExp[]; // regex matcher for update available banners
67
67
  exitCommands?: string[]; // commands to exit the cli gracefully
68
- promptArg?: (string & {}) | "first-arg" | "last-arg"; // argument name to pass the prompt, e.g. --prompt, or first-arg for positional arg
68
+ promptArg?: (string & {}) | "first-arg" | "last-arg" | "typed"; // argument name to pass the prompt, e.g. --prompt, first-arg for positional arg, or "typed" to type it into an interactive session after ready (shells)
69
69
 
70
70
  // handle special format
71
71
  noEOL?: boolean; // if true, do not split lines by \n when handling inputs, e.g. for codex, which uses cursor-move csi code instead of \n to move lines
@@ -361,6 +361,10 @@ export default async function agentYes({
361
361
  } else if (cliConf.promptArg.startsWith("--")) {
362
362
  cliArgs = [cliConf.promptArg, prompt, ...cliArgs];
363
363
  prompt = undefined; // clear prompt to avoid sending later
364
+ } else if (cliConf.promptArg === "typed") {
365
+ // Shell mode (bash/cmd/powershell): don't pass an argv (that would
366
+ // run-and-exit). Leave `prompt` set so it's typed into the interactive
367
+ // session at onStart, keeping the shell alive afterwards.
364
368
  } else {
365
369
  logger.warn(`Unknown promptArg format: ${cliConf.promptArg}`);
366
370
  }
package/ts/serve.ts CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  listRecords,
17
17
  readNotes,
18
18
  readPtysize,
19
+ recentReadEdges,
19
20
  renderRawLog,
20
21
  resolveOne,
21
22
  snapshotStatus,
@@ -1118,6 +1119,16 @@ export async function cmdServe(rest: string[]): Promise<number> {
1118
1119
  const oscTitleRe = /\x1b\][02];([^\x07\x1b]*)(?:\x07|\x1b\\)/g;
1119
1120
  let title: string | null = null;
1120
1121
  for (let m; (m = oscTitleRe.exec(text)); ) if (m[1]!.trim()) title = m[1]!.trim();
1122
+ // Defense-in-depth: this title is remote-controlled text (an agent sets its
1123
+ // own terminal title) that the web console renders. The console escapes it,
1124
+ // but strip C0/C1 control bytes and cap the length at the SOURCE too, so a
1125
+ // hostile title can't smuggle control characters into any current/future
1126
+ // sink. Quotes are left intact (legitimate titles contain them) and handled
1127
+ // by the console's HTML escaper.
1128
+ if (title) {
1129
+ // eslint-disable-next-line no-control-regex
1130
+ title = title.replace(/[\x00-\x1f\x7f-\x9f]/g, "").slice(0, 256).trim() || null;
1131
+ }
1121
1132
  titleCache.set(logFile, { size, mtimeMs, title });
1122
1133
  return title;
1123
1134
  } finally {
@@ -1578,6 +1589,17 @@ export async function cmdServe(rest: string[]): Promise<number> {
1578
1589
  });
1579
1590
  }
1580
1591
 
1592
+ // GET /api/edges — recent inter-agent relationship edges for the /rgui wire
1593
+ // view. Currently the read/tail edges (agent `by` read agent `target` within
1594
+ // the last minute, from ~/.agent-yes/reads.jsonl). Directional, ephemeral.
1595
+ if (req.method === "GET" && p === "/api/edges") {
1596
+ try {
1597
+ return Response.json({ reads: await recentReadEdges() });
1598
+ } catch (e) {
1599
+ return new Response((e as Error).message, { status: 500 });
1600
+ }
1601
+ }
1602
+
1581
1603
  // GET /api/whoami — this host's device label (user@host), so a remote
1582
1604
  // console can tag each agent with the machine it came from. Unlike codehost,
1583
1605
  // `ay serve --share` carries no per-agent device id; the viewer fetches this
@@ -1697,6 +1719,9 @@ export async function cmdServe(rest: string[]): Promise<number> {
1697
1719
  return new Response(`pid ${record.pid}: no log_file`, { status: 404 });
1698
1720
  const logPath = record.log_file;
1699
1721
 
1722
+ // Assigned inside start(); called by BOTH the stream's cancel() and the
1723
+ // req.signal abort listener, so client-disconnect teardown can't leak.
1724
+ let cleanup = () => {};
1700
1725
  const stream = new ReadableStream({
1701
1726
  async start(ctrl) {
1702
1727
  const enc = new TextEncoder();
@@ -1769,9 +1794,21 @@ export async function cmdServe(rest: string[]): Promise<number> {
1769
1794
  } catch {
1770
1795
  /* fs.watch unsupported — the fallback poll below still works */
1771
1796
  }
1772
- const poller = setInterval(() => void flush(), 60);
1773
-
1774
- req.signal.addEventListener("abort", () => {
1797
+ // When fs.watch is live it already gives instant echo, so the poll is
1798
+ // only a safety net → 500ms. Without a watcher it IS the primary path
1799
+ // → keep it at 60ms for low typing-echo latency. This matters when many
1800
+ // /api/tail streams are open at once (the /rgui viewer opens one per
1801
+ // node): N × a 60ms poll each was needless load on the event loop.
1802
+ const poller = setInterval(() => void flush(), watcher ? 500 : 60);
1803
+
1804
+ // Tear down on client disconnect via BOTH the req.signal 'abort'
1805
+ // listener and the stream's cancel() — whichever the runtime fires
1806
+ // (Bun uses abort here; other runtimes/paths may only cancel). `closed`
1807
+ // makes it idempotent so it runs exactly once and never leaks the
1808
+ // heartbeat/poller/fs-watcher/fd, however many /api/tail streams churn
1809
+ // (the /rgui viewer opens+drops one per node while panning/zooming).
1810
+ cleanup = () => {
1811
+ if (closed) return;
1775
1812
  closed = true;
1776
1813
  clearInterval(heartbeat);
1777
1814
  clearInterval(poller);
@@ -1786,7 +1823,11 @@ export async function cmdServe(rest: string[]): Promise<number> {
1786
1823
  } catch {
1787
1824
  /* already closed */
1788
1825
  }
1789
- });
1826
+ };
1827
+ req.signal.addEventListener("abort", cleanup);
1828
+ },
1829
+ cancel() {
1830
+ cleanup();
1790
1831
  },
1791
1832
  });
1792
1833
 
@@ -2334,6 +2375,54 @@ export async function cmdServe(rest: string[]): Promise<number> {
2334
2375
  }
2335
2376
  }
2336
2377
 
2378
+ // ---- Single-agent view-only shares (docs/agent-sharing.md, Option X) ------
2379
+ // Mint / list / revoke scoped share rooms. Reachable by whoever already holds
2380
+ // full control of this host (the local token or the master fleet room) — a
2381
+ // scoped viewer can't reach these (its scopedFetch 403s /api/share*).
2382
+
2383
+ // POST /api/share body {agent, perm?} → mint a fresh view-only room for ONE
2384
+ // agent and return its share link.
2385
+ if (req.method === "POST" && p === "/api/share") {
2386
+ let body: { agent?: string; perm?: "r" | "rw" };
2387
+ try {
2388
+ body = (await req.json()) as typeof body;
2389
+ } catch {
2390
+ return new Response("invalid JSON body", { status: 400 });
2391
+ }
2392
+ if (!body.agent) return new Response("agent required", { status: 400 });
2393
+ const perm = body.perm ?? "r";
2394
+ if (perm !== "r" && perm !== "rw")
2395
+ return new Response(`invalid perm ${perm} (want r or rw)`, { status: 400 });
2396
+ try {
2397
+ const { createScopedShare } = await import("./agentShare.ts");
2398
+ const share = await createScopedShare({
2399
+ agent: body.agent,
2400
+ perm,
2401
+ localFetch: apiFetch,
2402
+ apiToken: token,
2403
+ });
2404
+ return Response.json(share);
2405
+ } catch (e) {
2406
+ const msg = (e as Error).message;
2407
+ const status = /too many active shares/.test(msg) ? 409 : /no agent matched|no stable/.test(msg) ? 404 : 500;
2408
+ return new Response(msg, { status });
2409
+ }
2410
+ }
2411
+
2412
+ // GET /api/shares → active scoped shares (for the manage/revoke UI).
2413
+ if (req.method === "GET" && p === "/api/shares") {
2414
+ const { listShares } = await import("./agentShare.ts");
2415
+ return Response.json(listShares());
2416
+ }
2417
+
2418
+ // DELETE /api/share/:shareId → revoke (close the room).
2419
+ const revokeM = /^\/api\/share\/([^/]+)$/.exec(p);
2420
+ if (req.method === "DELETE" && revokeM) {
2421
+ const { revokeShare } = await import("./agentShare.ts");
2422
+ const ok = revokeShare(decodeURIComponent(revokeM[1]!));
2423
+ return new Response(ok ? "revoked" : "no such share", { status: ok ? 200 : 404 });
2424
+ }
2425
+
2337
2426
  return new Response("Not Found", { status: 404 });
2338
2427
  };
2339
2428
 
@@ -2342,10 +2431,31 @@ export async function cmdServe(rest: string[]): Promise<number> {
2342
2431
  // (the page holds no secrets); the page carries the token via the #k= link
2343
2432
  // and sends it on every /api call.
2344
2433
  const uiDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "lab", "ui");
2434
+ // Defense-in-depth CSP for the console document served by --http (mirrors the
2435
+ // one in lab/ui/cf/worker.ts — keep them in sync). The console renders remote
2436
+ // host-supplied agent metadata, so we constrain where an injection could send
2437
+ // data even though output is escaped. connect-src allows any wss: so custom
2438
+ // signaling hosts still work; 'self' covers same-origin /api + EventSource.
2439
+ const CONSOLE_CSP = [
2440
+ "default-src 'self'",
2441
+ "base-uri 'none'",
2442
+ "object-src 'none'",
2443
+ "frame-ancestors 'none'",
2444
+ "form-action 'self'",
2445
+ "img-src 'self' data:",
2446
+ "font-src 'self' data:",
2447
+ "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
2448
+ "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net",
2449
+ "connect-src 'self' https://s.agent-yes.com https://agent-yes.com wss:",
2450
+ "worker-src 'self'",
2451
+ "manifest-src 'self'",
2452
+ ].join("; ");
2345
2453
  const serveUiFile = async (name: string, type: string): Promise<Response> => {
2346
2454
  try {
2347
2455
  const buf = await readFile(path.join(uiDir, name));
2348
- return new Response(buf, { headers: { "Content-Type": type } });
2456
+ const headers: Record<string, string> = { "Content-Type": type };
2457
+ if (type.includes("text/html")) headers["Content-Security-Policy"] = CONSOLE_CSP;
2458
+ return new Response(buf, { headers });
2349
2459
  } catch {
2350
2460
  return new Response("UI assets not found in this install — use the /api endpoints", {
2351
2461
  status: 404,
@@ -2362,6 +2472,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
2362
2472
  return serveUiFile("console-logic.js", "text/javascript; charset=utf-8");
2363
2473
  if (req.method === "GET" && p === "/e2e.js")
2364
2474
  return serveUiFile("e2e.js", "text/javascript; charset=utf-8");
2475
+ if (req.method === "GET" && p === "/qrcode.js")
2476
+ return serveUiFile("qrcode.js", "text/javascript; charset=utf-8");
2365
2477
  if (req.method === "GET" && p === "/favicon.ico") return new Response(null, { status: 204 });
2366
2478
  return apiFetch(req);
2367
2479
  };
@@ -2534,6 +2646,8 @@ export async function cmdServe(rest: string[]): Promise<number> {
2534
2646
  const shutdown = (resolve: () => void) => {
2535
2647
  if (heartbeat) clearInterval(heartbeat);
2536
2648
  closeShare?.();
2649
+ // Close any scoped single-agent share rooms so viewers get an immediate drop.
2650
+ void import("./agentShare.ts").then((m) => m.revokeAllShares()).catch(() => {});
2537
2651
  server?.stop();
2538
2652
  resolve();
2539
2653
  };
package/ts/share.ts CHANGED
@@ -77,6 +77,13 @@ const STUN: IceServer[] = [{ urls: "stun:stun.l.google.com:19302" }];
77
77
  // TURN) to enable; without them we use STUN only, exactly as before. Cached
78
78
  // until just before expiry; STUN-only fallback on any error so sharing never
79
79
  // breaks because TURN is misconfigured or unreachable.
80
+ //
81
+ // Exposure note: these short-lived (1h) relay credentials are handed to every
82
+ // browser peer that joins a room (they ride in the offer, see startPeer), so any
83
+ // holder of a valid share link can use them to relay for the credential's
84
+ // lifetime. That is acceptable — joining a room already requires the share
85
+ // secret — but it means a share link also grants ~1h of Cloudflare TURN relay
86
+ // capacity. Keep the TTL short; do not widen it.
80
87
  let iceCache: { servers: IceServer[]; exp: number } | null = null;
81
88
  async function getIceServers(): Promise<IceServer[]> {
82
89
  const keyId = process.env.CF_TURN_KEY_ID;
@@ -146,7 +153,7 @@ export async function loadOrCreateShareRoom(sighost = DEFAULT_SIGHOST): Promise<
146
153
  } catch {
147
154
  /* not yet minted */
148
155
  }
149
- const room = "r" + randomBytes(3).toString("hex");
156
+ const room = "r" + randomBytes(6).toString("hex");
150
157
  const s = randomBytes(32).toString("hex");
151
158
  const url = `webrtc://${room}:${MARKER}${s}@${sighost}`;
152
159
  await mkdir(path.dirname(shareRoomPath()), { recursive: true });
@@ -284,7 +291,7 @@ export async function startShare(
284
291
  const initial = opts.url
285
292
  ? parseShareUrl(opts.url)
286
293
  : {
287
- room: "r" + randomBytes(3).toString("hex"),
294
+ room: "r" + randomBytes(6).toString("hex"),
288
295
  token: `${MARKER}${randomBytes(32).toString("hex")}`,
289
296
  host: sighost,
290
297
  };
@@ -322,7 +329,7 @@ export async function startShare(
322
329
  const rotate = async (): Promise<boolean> => {
323
330
  if (!opts.onRotate || closed || rotateCount >= 5) return false;
324
331
  rotateCount++;
325
- room = "r" + randomBytes(3).toString("hex");
332
+ room = "r" + randomBytes(6).toString("hex");
326
333
  token = `${MARKER}${randomBytes(32).toString("hex")}`;
327
334
  S = parseSecret(token).s;
328
335
  authToken = await deriveAuthToken(S, room, host);