agent-yes 1.287.0 → 1.288.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 (111) hide show
  1. package/dist/{SUPPORTED_CLIS-DKdFELZL.js → SUPPORTED_CLIS-BHowMgMY.js} +2 -2
  2. package/dist/{SUPPORTED_CLIS-5YPYQ8Iw.js → SUPPORTED_CLIS-ChhJyq4h.js} +3 -3
  3. package/dist/{agentShare-B525ly6v.js → agentShare-T3RskvDT.js} +2 -2
  4. package/dist/{callback-C-awM4qn.js → callback-CuohbCJA.js} +3 -3
  5. package/dist/{callback-BLTW4Mgp.js → callback-DeaETuHt.js} +2 -2
  6. package/dist/cli.js +5 -5
  7. package/dist/{cmdPs-BRtmnpsM.js → cmdPs-Bm4JG7hi.js} +2 -2
  8. package/dist/index.js +2 -2
  9. package/dist/{notifyDaemon-DshQvO-4.js → notifyDaemon-BORqPeAl.js} +2 -2
  10. package/dist/{parentPingLoop-Bjw6hKoZ.js → parentPingLoop-COePRYr9.js} +2 -2
  11. package/dist/{rustBinary-C57KWEtX.js → rustBinary-BmXzqj5z.js} +2 -2
  12. package/dist/{rustBinary-BXhp8yBp.js → rustBinary-D12k7iOD.js} +2 -2
  13. package/dist/{schedule-Boa0nE0V.js → schedule-DLoUmyJh.js} +4 -4
  14. package/dist/{serve-7mcHebep.js → serve-DaDlOtss.js} +17 -17
  15. package/dist/{setup-DXRL-Mdo.js → setup-Cqy8nRrq.js} +2 -2
  16. package/dist/{subcommands-BftthBK7.js → subcommands-BfiOyfz7.js} +15 -15
  17. package/dist/{subcommands-BQJ-Uqtl.js → subcommands-DKTa3E6O.js} +1 -1
  18. package/dist/{terminal-DrYf3HP0.js → terminal-C0tnGnrZ.js} +2 -2
  19. package/dist/trayApp-BrAMlCY7.js +5 -0
  20. package/dist/{trayApp-DG2fQnhx.js → trayApp-DvL9qW6V.js} +2 -2
  21. package/dist/{ts-LDnoqCV2.js → ts-DJLp2_bv.js} +3 -3
  22. package/dist/{versionChecker-CGmyt2qE.js → versionChecker-CkO7wYyV.js} +2 -2
  23. package/dist/{widget-B2SluyG1.js → widget-CJqpScCG.js} +3 -3
  24. package/dist/{ws-DWA90-uE.js → ws-DGENGNrr.js} +2 -2
  25. package/dist/{ws-CTOnVXg8.js → ws-DzCKvzWD.js} +2 -2
  26. package/lab/ui/index.html +95 -67
  27. package/package.json +2 -1
  28. package/ts/JsonlStore.bun.spec.ts +204 -0
  29. package/ts/ReadyManager.bun.spec.ts +72 -0
  30. package/ts/agentNice.bun.spec.ts +24 -0
  31. package/ts/agentPermissions.bun.spec.ts +117 -0
  32. package/ts/agentShare.bun.spec.ts +240 -0
  33. package/ts/agentTree.bun.spec.ts +92 -0
  34. package/ts/agentYesHome.bun.spec.ts +25 -0
  35. package/ts/askApi.bun.spec.ts +358 -0
  36. package/ts/askCli.bun.spec.ts +304 -0
  37. package/ts/autoRetry.bun.spec.ts +123 -0
  38. package/ts/badges.bun.spec.ts +187 -0
  39. package/ts/buildRustArgs.bun.spec.ts +351 -0
  40. package/ts/callbackCore.bun.spec.ts +139 -0
  41. package/ts/channels/hlc.bun.spec.ts +56 -0
  42. package/ts/channels/link.bun.spec.ts +72 -0
  43. package/ts/channels/op.bun.spec.ts +78 -0
  44. package/ts/channels/store.browser.bun.spec.ts +60 -0
  45. package/ts/channels/store.bun.spec.ts +145 -0
  46. package/ts/channels/store.node.bun.spec.ts +76 -0
  47. package/ts/channels/trust.bun.spec.ts +86 -0
  48. package/ts/channels.bun.spec.ts +212 -0
  49. package/ts/cli-idle.bun.spec.ts +20 -0
  50. package/ts/cmdDsh.bun.spec.ts +90 -0
  51. package/ts/configLoader.bun.spec.ts +273 -0
  52. package/ts/cwdConflictWarn.bun.spec.ts +60 -0
  53. package/ts/cwdPassthroughHint.bun.spec.ts +74 -0
  54. package/ts/e2e-crypto.bun.spec.ts +235 -0
  55. package/ts/forkNested.bun.spec.ts +240 -0
  56. package/ts/histStore.bun.spec.ts +355 -0
  57. package/ts/identity.bun.spec.ts +190 -0
  58. package/ts/idleWaiter.bun.spec.ts +91 -0
  59. package/ts/initMsg.bun.spec.ts +132 -0
  60. package/ts/invokedCli.bun.spec.ts +30 -0
  61. package/ts/ipcLock.bun.spec.ts +107 -0
  62. package/ts/logger.bun.spec.ts +27 -0
  63. package/ts/lsWatch.bun.spec.ts +61 -0
  64. package/ts/messageLog.bun.spec.ts +145 -0
  65. package/ts/needsInput.bun.spec.ts +157 -0
  66. package/ts/nodeRuntime.bun.spec.ts +99 -0
  67. package/ts/notifyDaemon.bun.spec.ts +297 -0
  68. package/ts/notifyInbox.bun.spec.ts +277 -0
  69. package/ts/notifyRouter.bun.spec.ts +351 -0
  70. package/ts/notifyStore.bun.spec.ts +403 -0
  71. package/ts/ownerHeartbeat.bun.spec.ts +175 -0
  72. package/ts/parentLink.bun.spec.ts +64 -0
  73. package/ts/parentPing.bun.spec.ts +176 -0
  74. package/ts/parentPingLoop.bun.spec.ts +79 -0
  75. package/ts/parentPingSend.bun.spec.ts +195 -0
  76. package/ts/parentWatching.bun.spec.ts +61 -0
  77. package/ts/pidStore.bun.spec.ts +372 -0
  78. package/ts/procStartTime.bun.spec.ts +117 -0
  79. package/ts/reaper.bun.spec.ts +86 -0
  80. package/ts/removeControlCharacters.bun.spec.ts +94 -0
  81. package/ts/resolveBinary.bun.spec.ts +133 -0
  82. package/ts/resultEnvelope.bun.spec.ts +46 -0
  83. package/ts/runningLock.bun.spec.ts +485 -0
  84. package/ts/rustBinary.bun.spec.ts +81 -0
  85. package/ts/schedule.bun.spec.ts +30 -0
  86. package/ts/serve.bun.spec.ts +166 -0
  87. package/ts/serveLock.bun.spec.ts +220 -0
  88. package/ts/session-integration.bun.spec.ts +93 -0
  89. package/ts/setup.bun.spec.ts +42 -0
  90. package/ts/share.bun.spec.ts +36 -0
  91. package/ts/sizeNego.bun.spec.ts +56 -0
  92. package/ts/statusText.bun.spec.ts +17 -0
  93. package/ts/strayServe.bun.spec.ts +50 -0
  94. package/ts/subcommandMirror.bun.spec.ts +76 -0
  95. package/ts/systemPathLink.bun.spec.ts +79 -0
  96. package/ts/termToken.bun.spec.ts +74 -0
  97. package/ts/terminal.bun.spec.ts +50 -0
  98. package/ts/terminalReply.bun.spec.ts +36 -0
  99. package/ts/titleScanner.bun.spec.ts +65 -0
  100. package/ts/todoAutomation.bun.spec.ts +253 -0
  101. package/ts/todoBlock.bun.spec.ts +73 -0
  102. package/ts/todoDigest.bun.spec.ts +184 -0
  103. package/ts/todoIdentity.bun.spec.ts +85 -0
  104. package/ts/todoLifecycle.bun.spec.ts +79 -0
  105. package/ts/todoParse.bun.spec.ts +98 -0
  106. package/ts/todoRoot.bun.spec.ts +117 -0
  107. package/ts/todoStore.bun.spec.ts +727 -0
  108. package/ts/tryCatch.bun.spec.ts +275 -0
  109. package/ts/webrtcLink.bun.spec.ts +45 -0
  110. package/ts/workspaceConfig.bun.spec.ts +319 -0
  111. package/dist/trayApp-1AQ6X2V9.js +0 -5
@@ -0,0 +1,117 @@
1
+ import { afterEach, describe, expect, it } from "bun:test";
2
+ import { derivePermissions, permissionBadge } from "./agentPermissions.ts";
3
+ import { allowsSkipPermissions } from "./workspaceConfig.ts";
4
+
5
+ // Issue #236. The pid record is the only place the answer to "was this agent
6
+ // allowed to act without confirmation?" survives — the global index carries no
7
+ // argv, and the wrapper's own flags never reach the CLI's args at all.
8
+
9
+ describe("derivePermissions", () => {
10
+ const YES = ["--dangerously-skip-permissions"];
11
+
12
+ it("detects the configured yolo flag", () => {
13
+ expect(derivePermissions({ cliArgs: ["--print", ...YES], yesArgs: YES }).skip_permissions).toBe(
14
+ true,
15
+ );
16
+ });
17
+
18
+ it("detects a dangerous flag the config never declared", () => {
19
+ // A CLI with no yesArgs must still not hide a flag that disables its gate —
20
+ // the check is the union of configured and known-dangerous, not just config.
21
+ expect(derivePermissions({ cliArgs: ["--yolo"] }).skip_permissions).toBe(true);
22
+ expect(
23
+ derivePermissions({ cliArgs: ["--dangerously-bypass-approvals-and-sandbox"] })
24
+ .skip_permissions,
25
+ ).toBe(true);
26
+ });
27
+
28
+ it("matches the flag part of an --flag=value form", () => {
29
+ expect(derivePermissions({ cliArgs: ["--full-auto=true"] }).skip_permissions).toBe(true);
30
+ });
31
+
32
+ it("does not flag ordinary args", () => {
33
+ expect(
34
+ derivePermissions({ cliArgs: ["--model", "opus", "--continue"], yesArgs: YES })
35
+ .skip_permissions,
36
+ ).toBe(false);
37
+ expect(derivePermissions({ cliArgs: [] }).skip_permissions).toBe(false);
38
+ });
39
+
40
+ it("records the wrapper flags independently of the CLI's", () => {
41
+ const p = derivePermissions({ cliArgs: [], robust: true, autoContinue: true });
42
+ expect(p).toEqual({ skip_permissions: false, robust: true, auto_continue: true });
43
+ });
44
+
45
+ it("defaults every flag to false rather than undefined", () => {
46
+ // The record is serialized to JSON and read by both runtimes; an absent
47
+ // boolean would read as "unknown" where we mean "no".
48
+ expect(derivePermissions({ cliArgs: [] })).toEqual({
49
+ skip_permissions: false,
50
+ robust: false,
51
+ auto_continue: false,
52
+ });
53
+ });
54
+
55
+ it("ignores an empty yesArgs entry rather than matching everything", () => {
56
+ // A config with a stray "" must not make every arg look like a yolo flag.
57
+ expect(derivePermissions({ cliArgs: ["--model"], yesArgs: [""] }).skip_permissions).toBe(false);
58
+ });
59
+ });
60
+
61
+ describe("permissionBadge", () => {
62
+ it("reports skip only when the CLI gate is off", () => {
63
+ expect(permissionBadge({ skip_permissions: true, robust: false, auto_continue: false })).toBe(
64
+ "skip",
65
+ );
66
+ expect(permissionBadge({ skip_permissions: false, robust: false, auto_continue: false })).toBe(
67
+ "prompt",
68
+ );
69
+ });
70
+
71
+ it("never lets the wrapper flags soften the label", () => {
72
+ // robust/auto_continue change how the agent RECOVERS, not what it may do.
73
+ expect(permissionBadge({ skip_permissions: true, robust: true, auto_continue: true })).toBe(
74
+ "skip",
75
+ );
76
+ });
77
+
78
+ it("has nothing to show for a record with no stamp (pre-#236 agent)", () => {
79
+ expect(permissionBadge(null)).toBeNull();
80
+ expect(permissionBadge(undefined)).toBeNull();
81
+ });
82
+ });
83
+
84
+ describe("allowsSkipPermissions", () => {
85
+ const original = process.env.AGENT_YES_ALLOW_SKIP_PERMISSIONS;
86
+ afterEach(() => {
87
+ if (original === undefined) delete process.env.AGENT_YES_ALLOW_SKIP_PERMISSIONS;
88
+ else process.env.AGENT_YES_ALLOW_SKIP_PERMISSIONS = original;
89
+ });
90
+
91
+ it("is permissive by default — restricting is an opt-in", () => {
92
+ delete process.env.AGENT_YES_ALLOW_SKIP_PERMISSIONS;
93
+ expect(allowsSkipPermissions()).toBe(true);
94
+ });
95
+
96
+ it("is restricted by an explicit falsy env value", () => {
97
+ for (const v of ["0", "false", "no", "off", "FALSE"]) {
98
+ process.env.AGENT_YES_ALLOW_SKIP_PERMISSIONS = v;
99
+ expect(allowsSkipPermissions()).toBe(false);
100
+ }
101
+ });
102
+
103
+ it("stays permissive for any other value, including garbage", () => {
104
+ // Failing closed here would lock spawns out of a host over a typo; the
105
+ // enforcement point is an explicit opt-in, so an unparseable value must not
106
+ // silently become a restriction.
107
+ for (const v of ["1", "true", "yes", "on", "banana"]) {
108
+ process.env.AGENT_YES_ALLOW_SKIP_PERMISSIONS = v;
109
+ expect(allowsSkipPermissions()).toBe(true);
110
+ }
111
+ });
112
+
113
+ it("treats an empty env value as unset (falls through to config)", () => {
114
+ process.env.AGENT_YES_ALLOW_SKIP_PERMISSIONS = " ";
115
+ expect(allowsSkipPermissions()).toBe(true);
116
+ });
117
+ });
@@ -0,0 +1,240 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import { scopedFetch } from "./agentShare.ts";
3
+
4
+ // The security core of single-agent view-only sharing: scopedFetch wraps the full
5
+ // `ay serve` API and must DEFAULT-DENY, exposing only reads scoped to ONE agent_id.
6
+ // These drive the real filter with a mock inner handler (no WebRTC / no registry).
7
+
8
+ const SHARED = "aaaaaaaaaaaa"; // the shared agent's stable id
9
+ const OTHER = "bbbbbbbbbbbb"; // a sibling on the same host — must never leak
10
+
11
+ function rec(pid: number, agent_id: string) {
12
+ return { pid, agent_id, cli: "claude", cwd: "/x", status: "running", title: "t" };
13
+ }
14
+
15
+ // A stand-in for the master apiFetch. Records what it was asked and answers a few
16
+ // endpoints; the scoped wrapper is what we're testing.
17
+ function makeInner(seen: { url?: string }) {
18
+ return async (req: Request): Promise<Response> => {
19
+ const url = new URL(req.url);
20
+ seen.url = req.method + " " + url.pathname + url.search;
21
+ const p = url.pathname;
22
+ if (p === "/api/ls") {
23
+ // The real handler honours ?keyword=; emulate that so we can assert the
24
+ // wrapper both narrows via keyword AND post-filters by exact agent_id.
25
+ const kw = url.searchParams.get("keyword");
26
+ const all = [rec(1, SHARED), rec(2, OTHER)];
27
+ const out = kw ? all.filter((r) => r.agent_id === kw || r.cwd.includes(kw)) : all;
28
+ return Response.json(out);
29
+ }
30
+ if (p === "/api/whoami") return Response.json({ host: "me@box" });
31
+ if (p === "/api/version") return new Response("1.2.3");
32
+ if (p === "/api/ls/subscribe") {
33
+ const enc = new TextEncoder();
34
+ const body = new ReadableStream({
35
+ start(ctrl) {
36
+ // Initial full snapshot with BOTH agents, then a delta touching each.
37
+ ctrl.enqueue(
38
+ enc.encode(
39
+ `data: ${JSON.stringify({ full: true, upsert: [rec(1, SHARED), rec(2, OTHER)], remove: [] })}\n\n`,
40
+ ),
41
+ );
42
+ ctrl.enqueue(enc.encode(`: ping\n\n`));
43
+ ctrl.enqueue(
44
+ enc.encode(`data: ${JSON.stringify({ upsert: [rec(2, OTHER)], remove: [] })}\n\n`),
45
+ );
46
+ ctrl.enqueue(
47
+ enc.encode(`data: ${JSON.stringify({ upsert: [rec(1, SHARED)], remove: [] })}\n\n`),
48
+ );
49
+ ctrl.close();
50
+ },
51
+ });
52
+ return new Response(body, { headers: { "Content-Type": "text/event-stream" } });
53
+ }
54
+ // read/tail/status/size land here only if the wrapper allowed them through.
55
+ return new Response("inner-ok");
56
+ };
57
+ }
58
+
59
+ const mk = (seen: { url?: string } = {}) => scopedFetch(SHARED, makeInner(seen), "r");
60
+
61
+ async function sse(res: Response): Promise<string> {
62
+ return await res.text();
63
+ }
64
+
65
+ describe("scopedFetch — default-deny writes", () => {
66
+ for (const [method, path] of [
67
+ ["POST", "/api/send"],
68
+ ["POST", "/api/resize/1"],
69
+ ["POST", "/api/kill"],
70
+ ["POST", "/api/restart"],
71
+ ["POST", "/api/spawn"],
72
+ ["POST", "/api/presence"],
73
+ ["GET", "/api/presence"],
74
+ ["POST", "/api/share"],
75
+ ["GET", "/api/notes"],
76
+ ["GET", "/api/spawn-config"],
77
+ ] as const) {
78
+ it(`403s ${method} ${path}`, async () => {
79
+ const res = await mk()(new Request("http://ay.local" + path, { method }));
80
+ expect(res.status).toBe(403);
81
+ });
82
+ }
83
+ });
84
+
85
+ describe("scopedFetch — /api/ls is narrowed and post-filtered", () => {
86
+ it("injects keyword=agentId and returns ONLY the shared agent", async () => {
87
+ const seen: { url?: string } = {};
88
+ const res = await mk(seen)(new Request("http://ay.local/api/ls"));
89
+ expect(seen.url).toContain("keyword=" + SHARED); // narrowed server-side
90
+ const out = (await res.json()) as { agent_id: string }[];
91
+ expect(out).toHaveLength(1);
92
+ expect(out[0]!.agent_id).toBe(SHARED);
93
+ });
94
+
95
+ it("post-filters even if the inner handler over-matches (fuzzy keyword)", async () => {
96
+ // Inner that ignores keyword and returns everyone — the wrapper must still cut
97
+ // it down to the shared agent by EXACT agent_id.
98
+ const leaky = scopedFetch(
99
+ SHARED,
100
+ async () => Response.json([rec(1, SHARED), rec(2, OTHER)]),
101
+ "r",
102
+ );
103
+ const out = (await (await leaky(new Request("http://ay.local/api/ls"))).json()) as {
104
+ agent_id: string;
105
+ }[];
106
+ expect(out.map((r) => r.agent_id)).toEqual([SHARED]);
107
+ });
108
+ });
109
+
110
+ describe("scopedFetch — /api/ls/subscribe SSE is filtered", () => {
111
+ it("keeps only the shared agent across snapshot + deltas", async () => {
112
+ const res = await mk()(new Request("http://ay.local/api/ls/subscribe"));
113
+ const text = await sse(res);
114
+ expect(text).toContain('"full":true');
115
+ expect(text).toContain(SHARED);
116
+ expect(text).not.toContain(OTHER); // sibling never crosses the channel
117
+ expect(text).toContain(": ping"); // heartbeat passes through
118
+ // The delta that touched only OTHER must be dropped entirely.
119
+ const dataEvents = text.split("\n\n").filter((e) => e.startsWith("data:"));
120
+ // full snapshot (shared only) + the shared-agent delta = 2 data events.
121
+ expect(dataEvents).toHaveLength(2);
122
+ });
123
+ });
124
+
125
+ describe("scopedFetch — /api/ls/subscribe remove + teardown", () => {
126
+ // Inner whose stream announces both agents, then removes them: only the removal
127
+ // of a pid we actually FORWARDED may cross; a never-forwarded sibling pid is
128
+ // information (its existence) and must be dropped.
129
+ function removalInner(): (req: Request) => Promise<Response> {
130
+ const enc = new TextEncoder();
131
+ return async () =>
132
+ new Response(
133
+ new ReadableStream({
134
+ start(ctrl) {
135
+ ctrl.enqueue(
136
+ enc.encode(
137
+ `data: ${JSON.stringify({ full: true, upsert: [rec(1, SHARED), rec(2, OTHER)], remove: [] })}\n\n`,
138
+ ),
139
+ );
140
+ ctrl.enqueue(enc.encode(`data: ${JSON.stringify({ upsert: [], remove: [2] })}\n\n`));
141
+ ctrl.enqueue(enc.encode(`data: ${JSON.stringify({ upsert: [], remove: [1] })}\n\n`));
142
+ ctrl.close();
143
+ },
144
+ }),
145
+ { headers: { "Content-Type": "text/event-stream" } },
146
+ );
147
+ }
148
+
149
+ it("forwards removes only for pids it forwarded (sibling removals stay hidden)", async () => {
150
+ const res = await scopedFetch(
151
+ SHARED,
152
+ removalInner(),
153
+ "r",
154
+ )(new Request("http://ay.local/api/ls/subscribe"));
155
+ const events = (await sse(res))
156
+ .split("\n\n")
157
+ .filter((e) => e.startsWith("data:"))
158
+ .map((e) => JSON.parse(e.slice(5)) as { upsert: unknown[]; remove: number[] });
159
+ // Snapshot (shared only) + the remove of OUR pid; OTHER's removal is dropped.
160
+ expect(events).toHaveLength(2);
161
+ expect(events[0]!.upsert).toHaveLength(1);
162
+ expect(events[1]!.remove).toEqual([1]);
163
+ });
164
+
165
+ it("cancelling the filtered stream tears down the upstream reader", async () => {
166
+ const res = await mk()(new Request("http://ay.local/api/ls/subscribe"));
167
+ await res.body!.cancel(); // viewer went away — must not leak the inner stream
168
+ expect(res.status).toBe(200);
169
+ });
170
+ });
171
+
172
+ describe("scopedFetch — read metadata", () => {
173
+ it("passes /api/version through untouched", async () => {
174
+ const res = await mk()(new Request("http://ay.local/api/version"));
175
+ expect(await res.text()).toBe("1.2.3");
176
+ });
177
+
178
+ it("advertises read-only capability on /api/whoami", async () => {
179
+ const res = await mk()(new Request("http://ay.local/api/whoami"));
180
+ const who = (await res.json()) as {
181
+ host: string;
182
+ share: { readonly: boolean; agent_id: string; perm: string };
183
+ };
184
+ expect(who.host).toBe("me@box");
185
+ expect(who.share.readonly).toBe(true);
186
+ expect(who.share.agent_id).toBe(SHARED);
187
+ expect(who.share.perm).toBe("r");
188
+ });
189
+
190
+ it("403s a read of an agent that is not the shared one (unknown keyword)", async () => {
191
+ // resolveOne throws for an unknown keyword → targetIsAgent false → 403. Uses a
192
+ // keyword that cannot resolve on this machine, so it never leaks a real agent.
193
+ const res = await mk()(new Request("http://ay.local/api/read/zzzzzzzzzzzz-nope"));
194
+ expect(res.status).toBe(403);
195
+ });
196
+ });
197
+
198
+ describe("scopedFetch — read-write (rw / steer) share", () => {
199
+ const mkRw = () => scopedFetch(SHARED, makeInner({}), "rw");
200
+
201
+ it("advertises perm=rw and readonly=false on /api/whoami", async () => {
202
+ const res = await mkRw()(new Request("http://ay.local/api/whoami"));
203
+ const who = (await res.json()) as { share: { readonly: boolean; perm: string } };
204
+ expect(who.share.perm).toBe("rw");
205
+ expect(who.share.readonly).toBe(false);
206
+ });
207
+
208
+ it("still DENIES control (kill / restart / spawn) — rw is steer, not control", async () => {
209
+ for (const path of ["/api/kill", "/api/restart", "/api/spawn"]) {
210
+ const res = await mkRw()(new Request("http://ay.local" + path, { method: "POST" }));
211
+ expect(res.status).toBe(403);
212
+ }
213
+ });
214
+
215
+ it("DENIES send/resize aimed at an agent that isn't the shared one", async () => {
216
+ // keyword can't resolve on this machine → targetIsAgent false → 403, so an rw
217
+ // holder can only steer THE shared agent, never a sibling.
218
+ const send = await mkRw()(
219
+ new Request("http://ay.local/api/send", {
220
+ method: "POST",
221
+ body: JSON.stringify({ keyword: "zzzzzzzzzzzz-nope", msg: "x" }),
222
+ }),
223
+ );
224
+ expect(send.status).toBe(403);
225
+ const resize = await mkRw()(
226
+ new Request("http://ay.local/api/resize/zzzzzzzzzzzz-nope", { method: "POST" }),
227
+ );
228
+ expect(resize.status).toBe(403);
229
+ });
230
+
231
+ it("still denies send on a VIEW-ONLY (r) share", async () => {
232
+ const res = await mk()(
233
+ new Request("http://ay.local/api/send", {
234
+ method: "POST",
235
+ body: JSON.stringify({ keyword: "1", msg: "x" }),
236
+ }),
237
+ );
238
+ expect(res.status).toBe(403);
239
+ });
240
+ });
@@ -0,0 +1,92 @@
1
+ import { expect, test } from "bun:test";
2
+ import { buildAgentForest, flattenForest, foldLayers } from "./agentTree.ts";
3
+ import type { LayerNode } from "./agentTree.ts";
4
+ import type { GlobalPidRecord } from "./globalPidIndex.ts";
5
+
6
+ const mk = (pid: number, wrapper_pid: number, parent_pid?: number): GlobalPidRecord =>
7
+ ({ pid, wrapper_pid, parent_pid }) as GlobalPidRecord;
8
+
9
+ const pidsOf = (recs: GlobalPidRecord[]) =>
10
+ flattenForest(buildAgentForest(recs))
11
+ .map((r) => r.record.pid)
12
+ .sort((a, b) => a - b);
13
+
14
+ test("links child under parent via parent_pid === wrapper_pid", () => {
15
+ const rows = flattenForest(buildAgentForest([mk(1, 10), mk(2, 20, 10)]));
16
+ expect(rows.map((r) => r.record.pid)).toEqual([1, 2]);
17
+ expect(rows[1]!.depth).toBe(1); // child indented under root
18
+ });
19
+
20
+ test("orphan (parent not present) renders at top level, never vanishes", () => {
21
+ const rows = flattenForest(buildAgentForest([mk(1, 10), mk(2, 20, 10), mk(3, 30, 999)]));
22
+ expect(pidsOf([mk(1, 10), mk(2, 20, 10), mk(3, 30, 999)])).toEqual([1, 2, 3]);
23
+ expect(rows.find((r) => r.record.pid === 3)!.depth).toBe(0); // orphan is a root
24
+ });
25
+
26
+ // Regression: a parent_pid cycle (only via pid reuse across a reboot) used to
27
+ // drop every node in the cycle, since none became a root — they'd disappear from
28
+ // `ay ls` entirely. Each node must still render exactly once.
29
+ test("2-node parent_pid cycle still renders both nodes", () => {
30
+ expect(pidsOf([mk(1, 10, 20), mk(2, 20, 10)])).toEqual([1, 2]);
31
+ });
32
+
33
+ test("3-node parent_pid cycle still renders all nodes", () => {
34
+ expect(pidsOf([mk(1, 10, 30), mk(2, 20, 10), mk(3, 30, 20)])).toEqual([1, 2, 3]);
35
+ });
36
+
37
+ test("self-parent is treated as a root, not dropped or self-nested", () => {
38
+ expect(pidsOf([mk(1, 10, 10)])).toEqual([1]);
39
+ });
40
+
41
+ // foldLayers: the VSCode-explorer-style collapse used by the console to nest
42
+ // rooms > peers > agents and fold single-child chains onto one row.
43
+ const ln = (label: string, children: LayerNode[] = []): LayerNode => ({
44
+ label,
45
+ kind: "node",
46
+ children,
47
+ });
48
+
49
+ test("foldLayers collapses a single-child chain onto one row", () => {
50
+ const rows = foldLayers([ln("a", [ln("b", [ln("c")])])]);
51
+ expect(rows).toHaveLength(1);
52
+ expect(rows[0]!.segments.map((s) => s.label)).toEqual(["a", "b", "c"]);
53
+ expect(rows[0]!.depth).toBe(0);
54
+ expect(rows[0]!.prefix).toBe("");
55
+ });
56
+
57
+ test("foldLayers branches a multi-child node into indented ├─/└─ rows", () => {
58
+ const rows = foldLayers([ln("root", [ln("x"), ln("y")])]);
59
+ expect(rows.map((r) => r.segments[0]!.label)).toEqual(["root", "x", "y"]);
60
+ expect(rows[0]!.depth).toBe(0);
61
+ expect(rows[1]!).toMatchObject({ depth: 1, prefix: "├─ " });
62
+ expect(rows[2]!).toMatchObject({ depth: 1, prefix: "└─ " }); // last child
63
+ });
64
+
65
+ test("foldLayers builds nested prefixes with both ancestor connectors", () => {
66
+ // root → {a (not last), b (last)}, each with two children, so depth-2 rows
67
+ // exercise both ancestor connectors: "│ " under a, " " under the last child b.
68
+ const rows = foldLayers([
69
+ ln("root", [ln("a", [ln("a1"), ln("a2")]), ln("b", [ln("b1"), ln("b2")])]),
70
+ ]);
71
+ const at = (label: string) => rows.find((r) => r.segments[0]!.label === label)!;
72
+ expect(at("a1")).toMatchObject({ depth: 2, prefix: "│ ├─ " });
73
+ expect(at("a2").prefix).toBe("│ └─ ");
74
+ expect(at("b1").prefix).toBe(" ├─ ");
75
+ expect(at("b2").prefix).toBe(" └─ ");
76
+ });
77
+
78
+ test("foldLayers renders a node shared by two parents only once (visited guard)", () => {
79
+ // Diamond: root → {a, b}, both pointing at the same leaf c. The visited guard
80
+ // folds c into whichever branch reaches it first (a), so it isn't duplicated.
81
+ const c = ln("c");
82
+ const rows = foldLayers([ln("root", [ln("a", [c]), ln("b", [c])])]);
83
+ const labelsOf = (i: number) => rows[i]!.segments.map((s) => s.label);
84
+ expect(labelsOf(0)).toEqual(["root"]);
85
+ expect(labelsOf(1)).toEqual(["a", "c"]); // c folded into a's chain
86
+ expect(rows[1]!.prefix).toBe("├─ ");
87
+ expect(labelsOf(2)).toEqual(["b"]); // b's duplicate c is skipped, not re-rendered
88
+ expect(rows[2]!.prefix).toBe("└─ ");
89
+ expect(rows.flatMap((r) => r.segments.map((s) => s.label)).filter((l) => l === "c")).toHaveLength(
90
+ 1,
91
+ );
92
+ });
@@ -0,0 +1,25 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import { homedir } from "os";
3
+ import path from "path";
4
+ import { agentYesHome } from "./agentYesHome.ts";
5
+
6
+ describe("agentYesHome", () => {
7
+ let original: string | undefined;
8
+ beforeEach(() => {
9
+ original = process.env.AGENT_YES_HOME;
10
+ });
11
+ afterEach(() => {
12
+ if (original === undefined) delete process.env.AGENT_YES_HOME;
13
+ else process.env.AGENT_YES_HOME = original;
14
+ });
15
+
16
+ it("uses $AGENT_YES_HOME when set", () => {
17
+ process.env.AGENT_YES_HOME = "/custom/ay-home";
18
+ expect(agentYesHome()).toBe("/custom/ay-home");
19
+ });
20
+
21
+ it("falls back to ~/.agent-yes when unset", () => {
22
+ delete process.env.AGENT_YES_HOME;
23
+ expect(agentYesHome()).toBe(path.join(homedir(), ".agent-yes"));
24
+ });
25
+ });