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,60 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { formatCwdConflictWarning, shQuote } from "./cwdConflictWarn.ts";
3
+
4
+ describe("cwdConflictWarn.formatCwdConflictWarning", () => {
5
+ const cwd = "/code/proj/tree/main";
6
+
7
+ it("returns null when no peers occupy the cwd", () => {
8
+ expect(formatCwdConflictWarning([], cwd, "ay claude")).toBeNull();
9
+ });
10
+
11
+ it("warns and suggests a git-worktree command for a single peer", () => {
12
+ const msg = formatCwdConflictWarning([{ pid: 111, cli: "claude" }], cwd, "ay claude -- fix");
13
+ expect(msg).toContain("1 agent is already running in this directory (/code/proj/tree/main)");
14
+ expect(msg).toContain("111 claude");
15
+ // uses `git worktree add` (not `git clone .`) — worktree name = dir + branch
16
+ expect(msg).toContain("git worktree add -b main-work ../main-work && cd ../main-work");
17
+ expect(msg).not.toContain("git clone");
18
+ // original command is appended so the suggestion is copy-pasteable
19
+ expect(msg).toContain("&& ay claude -- fix");
20
+ expect(msg).toContain("AGENT_YES_NO_CWD_WARN=1");
21
+ });
22
+
23
+ it("pluralizes and lists at most 3 peers with an overflow count", () => {
24
+ const peers = [1, 2, 3, 4, 5].map((n) => ({ pid: n, cli: "codex" }));
25
+ const msg = formatCwdConflictWarning(peers, cwd, "ay codex")!;
26
+ expect(msg).toContain("5 agents are already running");
27
+ expect(msg).toContain("1 codex");
28
+ expect(msg).toContain("3 codex");
29
+ expect(msg).not.toContain("4 codex"); // capped at 3 listed
30
+ expect(msg).toContain("…and 2 more");
31
+ });
32
+
33
+ it("falls back to a placeholder when cwd has no basename or command is empty", () => {
34
+ const msg = formatCwdConflictWarning([{ pid: 9, cli: "codex" }], "/", " ")!;
35
+ expect(msg).toContain("../agent-work"); // basename('/') is empty → 'agent'
36
+ expect(msg).toContain("&& ay <cli> …"); // empty origCmd → placeholder
37
+ });
38
+ });
39
+
40
+ describe("cwdConflictWarn.shQuote", () => {
41
+ it("passes safe tokens through unquoted", () => {
42
+ expect(shQuote("ay")).toBe("ay");
43
+ expect(shQuote("claude")).toBe("claude");
44
+ expect(shQuote("--")).toBe("--");
45
+ expect(shQuote("../main-work")).toBe("../main-work");
46
+ expect(shQuote("main-work_2.3")).toBe("main-work_2.3");
47
+ });
48
+
49
+ it("single-quotes tokens with spaces or shell metacharacters", () => {
50
+ expect(shQuote("fix the bug")).toBe("'fix the bug'");
51
+ expect(shQuote("a;rm -rf b")).toBe("'a;rm -rf b'");
52
+ expect(shQuote("$HOME")).toBe("'$HOME'");
53
+ expect(shQuote("")).toBe("''");
54
+ });
55
+
56
+ it("escapes embedded single quotes safely", () => {
57
+ // POSIX close-quote / escaped-quote / reopen-quote dance
58
+ expect(shQuote("it's")).toBe(`'it'\\''s'`);
59
+ });
60
+ });
@@ -0,0 +1,74 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { detectCwdPassthrough } from "./cwdPassthroughHint.ts";
3
+
4
+ // argv shape is [exec, script, ...userArgs]; the script's basename is the
5
+ // program name the user typed (cy/ay/claude-yes/…).
6
+ const argv = (script: string, ...user: string[]) => ["/usr/bin/bun", script, ...user];
7
+
8
+ describe("detectCwdPassthrough", () => {
9
+ it("returns null when --cwd is absent", () => {
10
+ expect(detectCwdPassthrough(argv("/x/dist/cy.js", "claude", "-p", "hi"))).toBeNull();
11
+ });
12
+
13
+ it("detects `--cwd DIR` and rebuilds the command without it", () => {
14
+ const dep = detectCwdPassthrough(
15
+ argv("/x/dist/cy.js", "claude", "--cwd", "/ws/app", "-p", "fix"),
16
+ );
17
+ expect(dep).not.toBeNull();
18
+ expect(dep!.dir).toBe("/ws/app");
19
+ expect(dep!.suggestion).toBe("cd /ws/app && cy claude -p fix");
20
+ });
21
+
22
+ it("detects `--cwd=DIR` form", () => {
23
+ const dep = detectCwdPassthrough(argv("/x/dist/agent-yes.js", "codex", "--cwd=/tmp/x"));
24
+ expect(dep!.dir).toBe("/tmp/x");
25
+ expect(dep!.suggestion).toBe("cd /tmp/x && agent-yes codex");
26
+ });
27
+
28
+ it("keeps a home-relative dir bare so the shell still expands ~", () => {
29
+ const dep = detectCwdPassthrough(argv("/x/dist/cy.js", "--cwd", "~/ws/product"));
30
+ expect(dep!.suggestion).toBe("cd ~/ws/product && cy");
31
+ });
32
+
33
+ it("preserves the prompt after `--` and quotes tokens with spaces", () => {
34
+ // The shell splits `-- solve all todos` into one argv token per word.
35
+ const dep = detectCwdPassthrough(
36
+ argv("/x/dist/claude-yes.js", "--cwd", "/ws/app", "--", "solve", "all", "todos"),
37
+ );
38
+ // A single already-quoted token keeping its space is re-quoted for display.
39
+ const dep2 = detectCwdPassthrough(
40
+ argv("/x/dist/claude-yes.js", "--cwd", "/ws/app", "--", "a b"),
41
+ );
42
+ expect(dep!.suggestion).toBe("cd /ws/app && claude-yes -- solve all todos");
43
+ expect(dep2!.suggestion).toBe("cd /ws/app && claude-yes -- 'a b'");
44
+ });
45
+
46
+ it("quotes a dir containing spaces", () => {
47
+ const dep = detectCwdPassthrough(argv("/x/dist/cy.js", "--cwd", "/my ws/app"));
48
+ expect(dep!.suggestion).toBe("cd '/my ws/app' && cy");
49
+ });
50
+
51
+ it("handles `--cwd` given as the last token with no value", () => {
52
+ const dep = detectCwdPassthrough(argv("/x/dist/cy.js", "claude", "--cwd"));
53
+ expect(dep).not.toBeNull();
54
+ expect(dep!.dir).toBeUndefined();
55
+ expect(dep!.suggestion).toBe("cd <dir> && cy claude");
56
+ });
57
+
58
+ it("says the flag is passed through, not handled", () => {
59
+ const dep = detectCwdPassthrough(argv("/x/dist/cy.js", "--cwd", "/ws"));
60
+ expect(dep!.message).toContain("--cwd is not an agent-yes flag");
61
+ expect(dep!.message).toContain("cd /ws && cy");
62
+ });
63
+
64
+ it("ignores a --cwd that appears after `--`", () => {
65
+ // Past the separator the token belongs to the CLI or the prompt. It is
66
+ // forwarded verbatim either way, so there is nothing to suggest.
67
+ expect(
68
+ detectCwdPassthrough(argv("/x/dist/cy.js", "claude", "--", "--cwd", "/ws/app")),
69
+ ).toBeNull();
70
+ expect(
71
+ detectCwdPassthrough(argv("/x/dist/cy.js", "claude", "--", "run with --cwd=/ws")),
72
+ ).toBeNull();
73
+ });
74
+ });
@@ -0,0 +1,235 @@
1
+ import { describe, it, expect } from "bun:test";
2
+ import {
3
+ V,
4
+ PROTO,
5
+ MARKER,
6
+ FLAG_CONFIRM,
7
+ validateS,
8
+ deriveAuthToken,
9
+ deriveDirKeys,
10
+ computeTranscriptHash,
11
+ seal,
12
+ open,
13
+ packEnvelope,
14
+ unpackEnvelope,
15
+ parseSecret,
16
+ randomHex,
17
+ } from "../lab/ui/e2e.js";
18
+
19
+ const S = "a".repeat(64); // a valid 64-hex secret for tests
20
+ const S2 = "b".repeat(64);
21
+ const TH = new Uint8Array(32).fill(7); // a fake transcript hash
22
+ const TH2 = new Uint8Array(32).fill(9);
23
+
24
+ // Minimal but realistic SDP fragments carrying the lines the binding reads.
25
+ function sdp(fp: string, setup: string, ufrag: string): string {
26
+ return [
27
+ "v=0",
28
+ "o=- 1 1 IN IP4 0.0.0.0",
29
+ "s=-",
30
+ "t=0 0",
31
+ "m=application 9 UDP/DTLS/SCTP webrtc-datachannel",
32
+ `a=ice-ufrag:${ufrag}`,
33
+ `a=setup:${setup}`,
34
+ `a=fingerprint:${fp}`,
35
+ ].join("\r\n");
36
+ }
37
+ const FP_A = "sha-256 AA:BB:CC:DD:EE:FF:00:11:22:33:44:55:66:77:88:99";
38
+ const FP_B = "sha-256 11:22:33:44:55:66:77:88:99:AA:BB:CC:DD:EE:FF:00";
39
+
40
+ describe("e2e version constants", () => {
41
+ it("are internally consistent", () => {
42
+ expect(V).toBe(1);
43
+ expect(PROTO).toBe("ay-e2e-1");
44
+ expect(MARKER).toBe("e1.");
45
+ expect(FLAG_CONFIRM).toBe(0x01);
46
+ });
47
+ });
48
+
49
+ describe("validateS", () => {
50
+ it("accepts a 64-hex secret", () => {
51
+ expect(validateS(S)).toBe(S);
52
+ });
53
+ it("rejects bad input without echoing it", () => {
54
+ for (const bad of ["", "xyz", S.toUpperCase(), "e1." + S, S + "0", 123 as unknown as string]) {
55
+ try {
56
+ validateS(bad);
57
+ throw new Error("should have thrown for " + bad);
58
+ } catch (e) {
59
+ expect((e as Error).message).toBe("invalid share token");
60
+ expect((e as Error).message).not.toContain(String(bad).slice(0, 8) || "∅");
61
+ }
62
+ }
63
+ });
64
+ });
65
+
66
+ describe("deriveAuthToken", () => {
67
+ it("is deterministic and 64-hex", async () => {
68
+ const a = await deriveAuthToken(S, "room1", "s.agent-yes.com");
69
+ const b = await deriveAuthToken(S, "room1", "s.agent-yes.com");
70
+ expect(a).toBe(b);
71
+ expect(a).toMatch(/^[0-9a-f]{64}$/);
72
+ });
73
+ it("is not equal to S (one-way) and binds room + sighost", async () => {
74
+ const base = await deriveAuthToken(S, "room1", "s.agent-yes.com");
75
+ expect(base).not.toBe(S);
76
+ expect(await deriveAuthToken(S, "room2", "s.agent-yes.com")).not.toBe(base);
77
+ expect(await deriveAuthToken(S, "room1", "other.host")).not.toBe(base);
78
+ expect(await deriveAuthToken(S2, "room1", "s.agent-yes.com")).not.toBe(base);
79
+ });
80
+ });
81
+
82
+ describe("seal / open round trip", () => {
83
+ it("round-trips a sealed envelope", async () => {
84
+ const { keyH2C } = await deriveDirKeys(S, TH);
85
+ const send = { sendCtr: 0n };
86
+ const recv = { lastSeen: -1n };
87
+ const frame = await seal(keyH2C, send, 0, TH, packEnvelope({ t: "req", id: "x", path: "/p" }));
88
+ expect(frame).toBeInstanceOf(ArrayBuffer);
89
+ expect(new Uint8Array(frame)[0]).toBe(0x01); // VER
90
+ const { plaintext, counter, flags } = await open(keyH2C, frame, TH, recv);
91
+ expect(counter).toBe(0n);
92
+ expect(flags).toBe(0);
93
+ expect(unpackEnvelope(plaintext)).toEqual({ t: "req", id: "x", path: "/p" });
94
+ });
95
+
96
+ it("increments the counter and never repeats a nonce, even concurrently", async () => {
97
+ const { keyH2C } = await deriveDirKeys(S, TH);
98
+ const send = { sendCtr: 0n };
99
+ const [f0, f1] = await Promise.all([
100
+ seal(keyH2C, send, 0, TH, packEnvelope({ n: 0 })),
101
+ seal(keyH2C, send, 0, TH, packEnvelope({ n: 1 })),
102
+ ]);
103
+ const nonce = (f: ArrayBuffer) => new Uint8Array(f).slice(2, 14).join(",");
104
+ expect(nonce(f0)).not.toBe(nonce(f1));
105
+ expect(send.sendCtr).toBe(2n);
106
+ });
107
+
108
+ it("carries the confirmation FLAG through", async () => {
109
+ const { keyC2H } = await deriveDirKeys(S, TH);
110
+ const frame = await seal(
111
+ keyC2H,
112
+ { sendCtr: 0n },
113
+ FLAG_CONFIRM,
114
+ TH,
115
+ packEnvelope({ t: "confirm" }),
116
+ );
117
+ const { flags } = await open(keyC2H, frame, TH, { lastSeen: -1n });
118
+ expect(flags & FLAG_CONFIRM).toBe(FLAG_CONFIRM);
119
+ });
120
+ });
121
+
122
+ describe("open is fail-closed", () => {
123
+ it("rejects a tampered ciphertext bit", async () => {
124
+ const { keyH2C } = await deriveDirKeys(S, TH);
125
+ const frame = await seal(keyH2C, { sendCtr: 0n }, 0, TH, packEnvelope({ t: "req" }));
126
+ const bytes = new Uint8Array(frame);
127
+ const last = bytes.length - 1;
128
+ bytes[last] = (bytes[last] ?? 0) ^ 0x01; // flip a tag bit
129
+ await expect(open(keyH2C, bytes, TH, { lastSeen: -1n })).rejects.toThrow();
130
+ });
131
+ it("rejects a tampered header (VER/FLAGS/NONCE are authenticated)", async () => {
132
+ const { keyH2C } = await deriveDirKeys(S, TH);
133
+ const frame = await seal(keyH2C, { sendCtr: 0n }, 0, TH, packEnvelope({ t: "req" }));
134
+ const bytes = new Uint8Array(frame);
135
+ bytes[1] = (bytes[1] ?? 0) ^ 0x02; // flip a FLAGS bit
136
+ await expect(open(keyH2C, bytes, TH, { lastSeen: -1n })).rejects.toThrow();
137
+ });
138
+ it("rejects a bad version byte", async () => {
139
+ const { keyH2C } = await deriveDirKeys(S, TH);
140
+ const frame = await seal(keyH2C, { sendCtr: 0n }, 0, TH, packEnvelope({ t: "req" }));
141
+ const bytes = new Uint8Array(frame);
142
+ bytes[0] = 0x02;
143
+ await expect(open(keyH2C, bytes, TH, { lastSeen: -1n })).rejects.toThrow();
144
+ });
145
+ it("rejects a frame bound to a different transcript (per-frame binding)", async () => {
146
+ const { keyH2C } = await deriveDirKeys(S, TH);
147
+ const frame = await seal(keyH2C, { sendCtr: 0n }, 0, TH, packEnvelope({ t: "req" }));
148
+ await expect(open(keyH2C, frame, TH2, { lastSeen: -1n })).rejects.toThrow();
149
+ });
150
+ it("rejects the wrong direction key", async () => {
151
+ const { keyH2C, keyC2H } = await deriveDirKeys(S, TH);
152
+ const frame = await seal(keyH2C, { sendCtr: 0n }, 0, TH, packEnvelope({ t: "req" }));
153
+ await expect(open(keyC2H, frame, TH, { lastSeen: -1n })).rejects.toThrow();
154
+ });
155
+ it("rejects a first frame whose counter is not 0", async () => {
156
+ const { keyH2C } = await deriveDirKeys(S, TH);
157
+ const frame = await seal(keyH2C, { sendCtr: 5n }, 0, TH, packEnvelope({ t: "confirm" }));
158
+ await expect(open(keyH2C, frame, TH, { lastSeen: -1n })).rejects.toThrow(/counter-0/);
159
+ });
160
+ it("rejects a frame from a different session (per-session keys)", async () => {
161
+ const a = await deriveDirKeys(S, TH);
162
+ const b = await deriveDirKeys(S, TH2);
163
+ const frame = await seal(a.keyH2C, { sendCtr: 0n }, 0, TH, packEnvelope({ t: "req" }));
164
+ // different key AND different AAD → fails
165
+ await expect(open(b.keyH2C, frame, TH2, { lastSeen: -1n })).rejects.toThrow();
166
+ });
167
+ });
168
+
169
+ describe("anti-replay (mandatory)", () => {
170
+ it("rejects a replayed frame and an out-of-order frame", async () => {
171
+ const { keyH2C } = await deriveDirKeys(S, TH);
172
+ const send = { sendCtr: 0n };
173
+ const recv = { lastSeen: -1n };
174
+ const f0 = await seal(keyH2C, send, 0, TH, packEnvelope({ n: 0 }));
175
+ const f1 = await seal(keyH2C, send, 0, TH, packEnvelope({ n: 1 }));
176
+ await open(keyH2C, f0, TH, recv); // counter 0 ok
177
+ await open(keyH2C, f1, TH, recv); // counter 1 ok
178
+ await expect(open(keyH2C, f1, TH, recv)).rejects.toThrow(/replay/); // replay counter 1
179
+ await expect(open(keyH2C, f0, TH, recv)).rejects.toThrow(/replay/); // reorder back to 0
180
+ });
181
+ });
182
+
183
+ describe("computeTranscriptHash", () => {
184
+ it("matches across ends with offer/answer swapped", async () => {
185
+ const local = sdp(FP_A, "actpass", "ufragLOCAL");
186
+ const remote = sdp(FP_B, "active", "ufragREMOTE");
187
+ // host: offer=local, answer=remote ; client: offer=remote(as seen), answer=local
188
+ const host = await computeTranscriptHash(local, remote);
189
+ const client = await computeTranscriptHash(local, remote);
190
+ expect(Buffer.from(host).toString("hex")).toBe(Buffer.from(client).toString("hex"));
191
+ // a different fingerprint changes the hash
192
+ const other = await computeTranscriptHash(
193
+ sdp(FP_A, "actpass", "ufragLOCAL"),
194
+ sdp(FP_A, "active", "x"),
195
+ );
196
+ expect(Buffer.from(other).toString("hex")).not.toBe(Buffer.from(host).toString("hex"));
197
+ });
198
+ it("fails closed on a missing fingerprint", async () => {
199
+ const noFp = "v=0\r\na=setup:active\r\na=ice-ufrag:u";
200
+ await expect(computeTranscriptHash(sdp(FP_A, "actpass", "u"), noFp)).rejects.toThrow(
201
+ /fingerprint/,
202
+ );
203
+ });
204
+ it("fails closed on a non-sha-256 fingerprint (downgrade)", async () => {
205
+ const sha1 = sdp("sha-1 AA:BB:CC", "active", "u");
206
+ await expect(computeTranscriptHash(sdp(FP_A, "actpass", "u"), sha1)).rejects.toThrow(/sha-256/);
207
+ });
208
+ });
209
+
210
+ describe("parseSecret marker grammar (strict, fail-closed)", () => {
211
+ it("parses v2 encrypted links", () => {
212
+ expect(parseSecret("e1." + S)).toEqual({ s: S, v2: true });
213
+ });
214
+ it("treats a bare 64-hex token and a pid as legacy (caller gates)", () => {
215
+ expect(parseSecret(S)).toEqual({ s: S, v2: false });
216
+ expect(parseSecret("12345")).toEqual({ s: "12345", v2: false });
217
+ });
218
+ it("rejects an unknown version marker (update required)", () => {
219
+ expect(() => parseSecret("e2." + S)).toThrow(/update required/);
220
+ expect(() => parseSecret("e10." + S)).toThrow(/update required/);
221
+ });
222
+ it("rejects marker-shaped-but-malformed tokens (never legacy)", () => {
223
+ expect(() => parseSecret("e1." + "z".repeat(64))).toThrow(/malformed/);
224
+ expect(() => parseSecret("e1." + "a".repeat(63))).toThrow(/malformed/);
225
+ expect(() => parseSecret("e1" + S)).toThrow(/malformed/); // marker-like, no dot
226
+ expect(() => parseSecret("E1." + S)).toThrow(/malformed/); // wrong case is not v2
227
+ });
228
+ });
229
+
230
+ describe("randomHex", () => {
231
+ it("returns n bytes of hex and varies", () => {
232
+ expect(randomHex(16)).toMatch(/^[0-9a-f]{32}$/);
233
+ expect(randomHex(16)).not.toBe(randomHex(16));
234
+ });
235
+ });
@@ -0,0 +1,240 @@
1
+ import { appendFileSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs";
2
+ import { tmpdir } from "os";
3
+ import path from "path";
4
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
5
+ import {
6
+ buildSpawnTutorial,
7
+ confirmAgentStarted,
8
+ predictedLogPath,
9
+ shouldForkNested,
10
+ waitForRegistration,
11
+ } from "./forkNested";
12
+
13
+ describe("shouldForkNested", () => {
14
+ it("forks when nested (AGENT_YES_PID set) and stdout is not a TTY", () => {
15
+ expect(shouldForkNested({ isTTY: false, ayPid: "1234", attach: false })).toBe(true);
16
+ });
17
+
18
+ it("does NOT fork on an interactive TTY (a human running it directly)", () => {
19
+ expect(shouldForkNested({ isTTY: true, ayPid: "1234", attach: false })).toBe(false);
20
+ });
21
+
22
+ it("does NOT fork when not nested — a human piping output has no AGENT_YES_PID", () => {
23
+ expect(shouldForkNested({ isTTY: false, ayPid: undefined, attach: false })).toBe(false);
24
+ expect(shouldForkNested({ isTTY: false, ayPid: "", attach: false })).toBe(false);
25
+ expect(shouldForkNested({ isTTY: false, ayPid: " ", attach: false })).toBe(false);
26
+ });
27
+
28
+ it("does NOT fork when attach opts out, regardless of context", () => {
29
+ expect(shouldForkNested({ isTTY: false, ayPid: "1234", attach: true })).toBe(false);
30
+ });
31
+ });
32
+
33
+ describe("waitForRegistration", () => {
34
+ let home: string;
35
+ let prevHome: string | undefined;
36
+
37
+ const appendPid = (record: Record<string, unknown>) =>
38
+ appendFileSync(path.join(home, "pids.jsonl"), JSON.stringify(record) + "\n");
39
+
40
+ beforeEach(() => {
41
+ home = mkdtempSync(path.join(tmpdir(), "ay-forknested-"));
42
+ prevHome = process.env.AGENT_YES_HOME;
43
+ process.env.AGENT_YES_HOME = home;
44
+ });
45
+
46
+ afterEach(() => {
47
+ if (prevHome === undefined) delete process.env.AGENT_YES_HOME;
48
+ else process.env.AGENT_YES_HOME = prevHome;
49
+ rmSync(home, { recursive: true, force: true });
50
+ });
51
+
52
+ it("resolves true once the pid registry has a live record for this pid", async () => {
53
+ // Register the pid a poll-tick after the wait starts, like a real spawn.
54
+ setTimeout(
55
+ () =>
56
+ appendPid({
57
+ pid: 111,
58
+ cli: "claude",
59
+ prompt: null,
60
+ cwd: "/tmp",
61
+ log_file: null,
62
+ status: "active",
63
+ exit_code: null,
64
+ exit_reason: null,
65
+ started_at: Date.now(),
66
+ }),
67
+ 60,
68
+ );
69
+ await expect(waitForRegistration(111, 2000)).resolves.toBe(true);
70
+ });
71
+
72
+ it("times out false when the agent never registers", async () => {
73
+ await expect(waitForRegistration(222, 120)).resolves.toBe(false);
74
+ });
75
+
76
+ it("does NOT count a record whose status is already exited", async () => {
77
+ appendPid({
78
+ pid: 333,
79
+ cli: "claude",
80
+ prompt: null,
81
+ cwd: "/tmp",
82
+ log_file: null,
83
+ status: "exited",
84
+ exit_code: 1,
85
+ exit_reason: "crash",
86
+ started_at: Date.now(),
87
+ });
88
+ await expect(waitForRegistration(333, 120)).resolves.toBe(false);
89
+ });
90
+
91
+ it("fails fast when aborted() reports the child already died", async () => {
92
+ const start = Date.now();
93
+ await expect(waitForRegistration(444, 5000, () => true)).resolves.toBe(false);
94
+ expect(Date.now() - start).toBeLessThan(1000); // no full-timeout wait
95
+ });
96
+ });
97
+
98
+ describe("confirmAgentStarted", () => {
99
+ let home: string;
100
+ let proj: string;
101
+ let prevHome: string | undefined;
102
+
103
+ const appendPid = (record: Record<string, unknown>) =>
104
+ appendFileSync(path.join(home, "pids.jsonl"), JSON.stringify(record) + "\n");
105
+
106
+ const live = (pid: number, over: Record<string, unknown> = {}) => ({
107
+ pid,
108
+ cli: "claude",
109
+ prompt: null,
110
+ cwd: proj,
111
+ log_file: null,
112
+ status: "active",
113
+ exit_code: null,
114
+ exit_reason: null,
115
+ started_at: Date.now(),
116
+ ...over,
117
+ });
118
+
119
+ /** Write the raw log the wrapper produces once the CLI is really up. */
120
+ const writeRawLog = (pid: number, body = "\x1b[?25l hello") => {
121
+ mkdirSync(path.join(proj, ".agent-yes"), { recursive: true });
122
+ writeFileSync(predictedLogPath(proj, pid), body);
123
+ };
124
+
125
+ beforeEach(() => {
126
+ home = mkdtempSync(path.join(tmpdir(), "ay-confirm-home-"));
127
+ proj = mkdtempSync(path.join(tmpdir(), "ay-confirm-proj-"));
128
+ prevHome = process.env.AGENT_YES_HOME;
129
+ process.env.AGENT_YES_HOME = home;
130
+ });
131
+
132
+ afterEach(() => {
133
+ if (prevHome === undefined) delete process.env.AGENT_YES_HOME;
134
+ else process.env.AGENT_YES_HOME = prevHome;
135
+ rmSync(home, { recursive: true, force: true });
136
+ rmSync(proj, { recursive: true, force: true });
137
+ });
138
+
139
+ it("fails when the agent flips to exited inside the grace window — the #387 race", async () => {
140
+ appendPid(live(501));
141
+ setTimeout(
142
+ () => appendPid(live(501, { status: "exited", exit_code: 1, exit_reason: "fatal" })),
143
+ 60,
144
+ );
145
+ const res = await confirmAgentStarted(501, 2000);
146
+ expect(res.ok).toBe(false);
147
+ if (!res.ok) {
148
+ expect(res.reason).toContain("exited during startup");
149
+ expect(res.reason).toContain("fatal");
150
+ expect(res.reason).toContain("ay tail 501");
151
+ }
152
+ });
153
+
154
+ it("puts the agent's own error output in the failure message", async () => {
155
+ const errLog = path.join(proj, "boom.log");
156
+ writeFileSync(errLog, "error: unknown option '--cwd'\n");
157
+ appendPid(
158
+ live(502, { status: "exited", exit_code: 1, exit_reason: "fatal", log_file: errLog }),
159
+ );
160
+ const res = await confirmAgentStarted(502, 300);
161
+ expect(res.ok).toBe(false);
162
+ if (!res.ok) expect(res.reason).toContain("error: unknown option '--cwd'");
163
+ });
164
+
165
+ it("reports death even when the agent produced output before dying", async () => {
166
+ writeRawLog(503);
167
+ appendPid(live(503, { status: "exited", exit_code: 1, exit_reason: "fatal" }));
168
+ // Liveness must never outrank the failure check within a tick.
169
+ await expect(confirmAgentStarted(503, 300)).resolves.toMatchObject({ ok: false });
170
+ });
171
+
172
+ it("returns early once the raw log proves the CLI is up", async () => {
173
+ appendPid(live(504));
174
+ writeRawLog(504);
175
+ const start = Date.now();
176
+ await expect(confirmAgentStarted(504, 5000)).resolves.toEqual({ ok: true });
177
+ expect(Date.now() - start).toBeLessThan(1000); // did not pay the whole grace
178
+ });
179
+
180
+ it("succeeds at the deadline when the agent is alive but quiet", async () => {
181
+ appendPid(live(505));
182
+ await expect(confirmAgentStarted(505, 250)).resolves.toEqual({ ok: true });
183
+ });
184
+
185
+ it("fails when aborted() reports the wrapper already died", async () => {
186
+ appendPid(live(506));
187
+ const res = await confirmAgentStarted(506, 5000, () => true);
188
+ expect(res.ok).toBe(false);
189
+ });
190
+
191
+ it("fails when the record vanished — compaction drops dead+exited agents", async () => {
192
+ const res = await confirmAgentStarted(507, 300);
193
+ expect(res.ok).toBe(false);
194
+ if (!res.ok) expect(res.reason).toContain("disappeared from the registry");
195
+ });
196
+ });
197
+
198
+ describe("predictedLogPath", () => {
199
+ it("matches the Rust wrapper's <cwd>/.agent-yes/<pid>.raw.log convention", () => {
200
+ expect(predictedLogPath("/home/u/proj", 4242)).toBe(
201
+ path.join("/home/u/proj", ".agent-yes", "4242.raw.log"),
202
+ );
203
+ });
204
+ });
205
+
206
+ describe("buildSpawnTutorial", () => {
207
+ it("names the cli + pid and lists the drive commands with that pid", () => {
208
+ const out = buildSpawnTutorial("claude", 4242);
209
+ expect(out).toContain("Spawned claude agent as pid 4242");
210
+ expect(out).toContain("ay tail 4242");
211
+ expect(out).toContain("ay send 4242");
212
+ expect(out).toContain("ay ls");
213
+ expect(out).toContain("ay result get 4242");
214
+ expect(out).toContain("ay exit 4242");
215
+ expect(out).toContain("ay notify watch --unread");
216
+ expect(out).toContain("Monitor (preferred");
217
+ });
218
+ });
219
+
220
+ describe("buildSpawnTutorial — the parent's half of the contract", () => {
221
+ it("offers the monitor route first, for a harness that has one", () => {
222
+ const out = buildSpawnTutorial("claude", 4242);
223
+ expect(out).toContain("ay ls --watch --json");
224
+ expect(out).toContain("ay status 4242");
225
+ expect(out).toContain("--wait-idle");
226
+ });
227
+
228
+ it("tells the parent the push is a fallback that yields to a watcher", () => {
229
+ const out = buildSpawnTutorial("claude", 4242);
230
+ expect(out).toContain("<ay-report");
231
+ expect(out).toMatch(/finishes, gets stuck, or exits/);
232
+ expect(out).toContain("stays quiet while you are actively watching");
233
+ });
234
+
235
+ it("still lists the drive commands (a parent may want to intervene early)", () => {
236
+ const out = buildSpawnTutorial("codex", 7);
237
+ expect(out).toContain("ay tail 7");
238
+ expect(out).toContain(`ay send 7 "..."`);
239
+ });
240
+ });