gentle-pi 2.6.4 → 3.0.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 (128) hide show
  1. package/README.md +25 -7
  2. package/assets/agents/gentle-ai-worker.md +5 -1
  3. package/assets/agents/sdd-apply.md +9 -7
  4. package/assets/agents/sdd-archive.md +42 -23
  5. package/assets/agents/sdd-proposal.md +2 -2
  6. package/assets/agents/sdd-remediate.md +4 -4
  7. package/assets/agents/sdd-research.md +20 -48
  8. package/assets/agents/sdd-tasks.md +5 -5
  9. package/assets/agents/sdd-verify.md +6 -28
  10. package/assets/chains/sdd-full.chain.md +4 -22
  11. package/assets/chains/sdd-verify.chain.md +3 -12
  12. package/assets/orchestrator-delegation.md +33 -3
  13. package/assets/orchestrator-memory.md +20 -7
  14. package/assets/orchestrator.md +5 -3
  15. package/assets/sdd-orchestrator-workflow.md +25 -58
  16. package/assets/support/sdd-status-contract.md +9 -12
  17. package/docs/gentle-shell.md +41 -19
  18. package/docs/readme-reference.md +175 -36
  19. package/extensions/codegraph-tools.ts +2 -0
  20. package/extensions/gentle-agents.ts +289 -361
  21. package/extensions/gentle-ai.ts +588 -117
  22. package/extensions/gentle-shell.ts +123 -97
  23. package/extensions/pi-pretty.ts +63 -14
  24. package/extensions/quiet-tools.ts +1 -2
  25. package/extensions/startup-banner.ts +10 -9
  26. package/lib/agent-home.ts +8 -0
  27. package/lib/agent-profile-pin.ts +336 -0
  28. package/lib/agent-profiles.ts +28 -8
  29. package/lib/agents-config.ts +24 -2
  30. package/lib/agents-history.ts +3 -97
  31. package/lib/agents-keys.ts +27 -0
  32. package/lib/agents-protocol.ts +2 -15
  33. package/lib/agents-runner.ts +74 -113
  34. package/lib/agents-session-transport.ts +691 -0
  35. package/lib/command-palette-catalog.ts +87 -0
  36. package/lib/command-palette.ts +346 -0
  37. package/lib/native-choice-list.ts +5 -0
  38. package/lib/native-review-cli.ts +19 -97
  39. package/lib/review-publication-gate.ts +11 -1
  40. package/lib/review-repository.ts +1 -1
  41. package/lib/review-snapshot.ts +1 -0
  42. package/lib/review-transaction.ts +4 -2
  43. package/lib/sdd-preflight.ts +2 -1
  44. package/lib/sdd-research-capabilities.ts +18 -152
  45. package/lib/sdd-status.ts +7 -779
  46. package/lib/session-change-capture.ts +88 -0
  47. package/lib/session-changes.ts +147 -0
  48. package/lib/shell-bar.ts +24 -10
  49. package/lib/shell-card.ts +8 -12
  50. package/lib/shell-changes-view.ts +2 -1
  51. package/lib/shell-changes.ts +5 -2
  52. package/lib/shell-prompt.ts +25 -8
  53. package/lib/shell-sidebar-banner.ts +2 -2
  54. package/lib/shell-sidebar-layout.ts +5 -2
  55. package/lib/windows-session-transport.ts +877 -0
  56. package/package.json +3 -3
  57. package/runtime/native-review-cli.mjs +18 -96
  58. package/runtime/windows-session-transport.ps1 +791 -0
  59. package/scripts/gentle-ai-installer.mjs +10 -10
  60. package/scripts/test-packed-runner.mjs +1668 -20
  61. package/scripts/verify-package-files.mjs +2 -3
  62. package/tests/agent-home.test.ts +52 -0
  63. package/tests/agent-profiles.test.ts +30 -1
  64. package/tests/agents-config.test.ts +44 -0
  65. package/tests/agents-history.test.ts +12 -24
  66. package/tests/agents-runner.test.ts +321 -58
  67. package/tests/agents-session-transport-process.test.ts +249 -0
  68. package/tests/agents-session-transport.test.ts +823 -0
  69. package/tests/artifact-language.test.ts +10 -7
  70. package/tests/command-palette.test.ts +378 -0
  71. package/tests/delegated-key-learnings-contract.test.ts +2 -2
  72. package/tests/fixtures/agents-session-transport-process.mjs +108 -0
  73. package/tests/fixtures/legacy/sdd-research-v2.5.0.md +54 -0
  74. package/tests/fixtures/windows-session-bootstrap.ps1 +129 -0
  75. package/tests/fixtures/windows-session-compile.ps1 +110 -0
  76. package/tests/gentle-agents.test.ts +883 -356
  77. package/tests/gentle-ai-binary.test.ts +1 -1
  78. package/tests/gentle-ai-installer.test.ts +47 -47
  79. package/tests/gentle-ai.test.ts +472 -4
  80. package/tests/gentle-shell.test.ts +338 -205
  81. package/tests/native-choice-list.test.ts +13 -0
  82. package/tests/native-review-capability-contract.test.ts +15 -1
  83. package/tests/native-review-cli.test.ts +0 -33
  84. package/tests/odd-routing-contract.test.ts +208 -0
  85. package/tests/orchestrator-budget.test.ts +17 -2
  86. package/tests/package-manifest.test.ts +119 -31
  87. package/tests/persona-single-channel.test.ts +3 -3
  88. package/tests/pi-pretty.test.ts +45 -0
  89. package/tests/profile-pin.test.ts +370 -0
  90. package/tests/quiet-tool-rendering.test.ts +32 -5
  91. package/tests/review-contract-prompt.test.ts +9 -0
  92. package/tests/review-controller.test.ts +0 -44
  93. package/tests/review-session-standing-permission-ipc.test.ts +427 -13
  94. package/tests/runtime-harness.mjs +4 -4
  95. package/tests/sdd-agent-tools.test.ts +15 -36
  96. package/tests/sdd-archive-replay.test.ts +82 -0
  97. package/tests/sdd-classical-continuation.test.ts +74 -0
  98. package/tests/sdd-execution-routing-contract.test.ts +18 -2
  99. package/tests/sdd-managed-runtime-settlement.test.ts +42 -330
  100. package/tests/sdd-native-managed-uptake.test.ts +11 -21
  101. package/tests/sdd-no-attempts-contract.test.ts +15 -0
  102. package/tests/sdd-odd-integration.test.ts +33 -0
  103. package/tests/sdd-optional-research.test.ts +124 -0
  104. package/tests/sdd-planning-routing-contract.test.ts +1 -1
  105. package/tests/sdd-preflight-rpc-input.test.ts +125 -0
  106. package/tests/sdd-preflight.test.ts +1 -1
  107. package/tests/sdd-research-capabilities.test.ts +20 -162
  108. package/tests/sdd-selection-transport.test.ts +180 -88
  109. package/tests/sdd-status.test.ts +5 -778
  110. package/tests/sdd-task-truth.test.ts +43 -0
  111. package/tests/session-change-capture.test.ts +86 -0
  112. package/tests/session-changes-shell.test.ts +38 -0
  113. package/tests/session-changes.test.ts +114 -0
  114. package/tests/shell-bar.test.ts +35 -0
  115. package/tests/shell-card.test.ts +8 -6
  116. package/tests/shell-changes.test.ts +8 -0
  117. package/tests/shell-prompt.test.ts +41 -7
  118. package/tests/shell-sidebar-banner.test.ts +4 -4
  119. package/tests/shell-sidebar-layout.test.ts +97 -13
  120. package/tests/startup-banner.test.ts +55 -2
  121. package/tests/windows-hidden-processes.test.ts +303 -0
  122. package/tests/windows-session-bootstrap.test.ts +1772 -0
  123. package/tests/windows-session-compile.test.ts +170 -0
  124. package/tests/windows-session-transport.test.ts +754 -0
  125. package/assets/agents/sdd-sync.md +0 -146
  126. package/lib/openspec-guardrails.ts +0 -99
  127. package/tests/native-sdd-attempt-authority.test.ts +0 -240
  128. package/tests/openspec-guardrails.test.ts +0 -71
@@ -0,0 +1,823 @@
1
+ import assert from "node:assert/strict";
2
+ import { spawn } from "node:child_process";
3
+ import { createHash } from "node:crypto";
4
+ import { once } from "node:events";
5
+ import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
6
+ import { createConnection, createServer, Socket } from "node:net";
7
+ import { join } from "node:path";
8
+ import { tmpdir } from "node:os";
9
+ import nodeTest, { type TestContext } from "node:test";
10
+ const test = process.platform === "win32" ? nodeTest.skip : nodeTest;
11
+ import { ActiveSessionClient, ActiveSessionClientError, ActiveSessionListener, FrameDecoder, MAX_FRAME_BYTES, SessionPresenceError, SessionPresenceRegistry, TransportProtocolError, encodeAckFrame, encodeNotificationFrame, isSafeRuntimeParent, type PresenceRecord, type ReceivedNotification } from "../lib/agents-session-transport.ts";
12
+
13
+ async function registry(t: TestContext, beforeCandidateOpen?: () => Promise<void>) {
14
+ const home = await mkdtemp(join(tmpdir(), "g-"));
15
+ await chmod(home, 0o755);
16
+ await mkdir(join(home, "gentle-agents"), { mode: 0o755 });
17
+ t.after(() => rm(home, { recursive: true, force: true }));
18
+ return SessionPresenceRegistry.create(home, beforeCandidateOpen);
19
+ }
20
+
21
+ const mode = async (path: string) => (await lstat(path)).mode & 0o777;
22
+ const boundedSubprocess = (command: string, args: string[], timeoutMs: number) => new Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string; timedOut: boolean }>((resolveChild, rejectChild) => {
23
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
24
+ let stdout = "", stderr = "", timedOut = false;
25
+ const timer = setTimeout(() => { timedOut = true; child.kill("SIGKILL"); }, timeoutMs);
26
+ child.stdout.on("data", (chunk: Buffer) => { stdout += chunk; });
27
+ child.stderr.on("data", (chunk: Buffer) => { stderr += chunk; });
28
+ child.once("error", (error) => { clearTimeout(timer); rejectChild(error); });
29
+ child.once("close", (code, signal) => { clearTimeout(timer); resolveChild({ code, signal, stdout, stderr, timedOut }); });
30
+ });
31
+ const rejected = async (operation: Promise<unknown>, code: string, unsafe = "") => {
32
+ await assert.rejects(operation, (error: unknown) => {
33
+ assert.ok(error instanceof SessionPresenceError);
34
+ assert.equal(error.code, code);
35
+ assert.equal(unsafe ? error.message.includes(unsafe) : false, false);
36
+ return true;
37
+ });
38
+ };
39
+
40
+ test("preserves both shared parent modes while creating private transport directories", async (t) => {
41
+ const transport = await registry(t);
42
+ assert.equal(await mode(transport.paths.root), 0o700);
43
+ assert.equal(await mode(transport.paths.presence), 0o700);
44
+ assert.equal(await mode(transport.paths.sockets), 0o700);
45
+ assert.equal(await mode(join(transport.paths.root, "..", "..")), 0o755, "agentHome remains host-owned");
46
+ assert.equal(await mode(join(transport.paths.root, "..")), 0o755, "gentle-agents remains host-owned");
47
+ });
48
+
49
+ test("accepts only private-owned or sticky world-writable runtime parents", () => {
50
+ const current = process.getuid!(), foreign = current === 1 ? 2 : 1;
51
+ const parent = (mode: number, uid: number, directory = true, symlink = false) => ({ mode, uid, isDirectory: () => directory, isSymbolicLink: () => symlink });
52
+ assert.equal(isSafeRuntimeParent(parent(0o700, current)), true, "private owned parent is safe");
53
+ assert.equal(isSafeRuntimeParent(parent(0o777, current)), false, "non-sticky world-writable owned parent is unsafe");
54
+ assert.equal(isSafeRuntimeParent(parent(0o770, current)), false, "group-writable owned parent is unsafe");
55
+ assert.equal(isSafeRuntimeParent(parent(0o1777, current)), true, "current-user sticky parent is safe");
56
+ assert.equal(isSafeRuntimeParent(parent(0o1777, 0)), true, "root-owned sticky parent is safe");
57
+ assert.equal(isSafeRuntimeParent(parent(0o1777, foreign)), false, "foreign-owned sticky parent is unsafe");
58
+ assert.equal(isSafeRuntimeParent(parent(0o755, foreign)), false, "foreign read-only parent is rejected conservatively");
59
+ assert.equal(isSafeRuntimeParent(parent(0o1777, foreign, true, true)), false, "symlinked parent is unsafe");
60
+ assert.equal(isSafeRuntimeParent(parent(0o1777, foreign, false)), false, "non-directory parent is unsafe");
61
+ });
62
+
63
+ test("binds deep ASCII and Unicode profiles to isolated bounded socket paths", async (t) => {
64
+ const base = await mkdtemp(join(tmpdir(), "g-deep-"));
65
+ t.after(() => rm(base, { recursive: true, force: true }));
66
+ const profile = async (name: string) => {
67
+ const home = join(base, ...Array.from({ length: 4 }, () => name.repeat(32)));
68
+ await mkdir(join(home, "gentle-agents"), { recursive: true, mode: 0o755 });
69
+ return SessionPresenceRegistry.create(home);
70
+ };
71
+ const ascii = await profile("ascii-"), unicode = await profile("é-");
72
+ const asciiRecord = await ascii.record("ascii"), unicodeRecord = await unicode.record("unicode");
73
+ for (const record of [asciiRecord, unicodeRecord]) assert.ok(Buffer.byteLength(record.endpoint) <= 100, "socket endpoint stays within the conservative Unix limit");
74
+ assert.notEqual(ascii.paths.sockets, unicode.paths.sockets, "profile hash isolates runtime socket partitions");
75
+ assert.equal(ascii.paths.presence.includes(base), true, "presence records remain in the private profile transport directory");
76
+ assert.equal(asciiRecord.endpoint.includes(base), false, "socket endpoint is independent of the profile path");
77
+ const instance = new ActiveSessionListener(unicode, "unicode", async () => {});
78
+ await instance.start();
79
+ assert.equal((await lstat(instance.record!.endpoint)).isSocket(), true, "the bounded Unicode-profile endpoint is usable");
80
+ await instance.close();
81
+ });
82
+
83
+ test("publishes immutable v1 activations with private modes and exact discovery", async (t) => {
84
+ const transport = await registry(t);
85
+ const record = await transport.record("123e4567-e89b-12d3-a456-426614174000", 1);
86
+ assert.deepEqual(Reflect.ownKeys(record), ["version", "sessionId", "endpoint", "createdAt"]);
87
+ assert.equal(record.version, 1);
88
+ await transport.publish(record);
89
+ await rejected(transport.publish(record), "busy");
90
+ assert.deepEqual((await readdir(transport.paths.presence)).filter((name) => name.startsWith(".")), []);
91
+ assert.deepEqual(await transport.resolve(record.sessionId), record);
92
+ assert.equal(await mode(transport.paths.root), 0o700);
93
+ assert.equal(await mode(transport.paths.presence), 0o700);
94
+ assert.equal(await mode(transport.paths.sockets), 0o700);
95
+ assert.equal(await mode(transport.presencePath(record)), 0o600);
96
+ assert.equal(record.endpoint.startsWith(`${transport.paths.sockets}/`), true);
97
+ });
98
+
99
+ test("lists only advertised peer IDs without mutating the private registry", async (t) => {
100
+ let candidateOpens = 0;
101
+ const transport = await registry(t, async () => { candidateOpens += 1; });
102
+ const snapshot = async () => {
103
+ const entries = await readdir(transport.paths.presence);
104
+ const files = await Promise.all(entries.map(async (name) => {
105
+ const path = join(transport.paths.presence, name), stat = await lstat(path);
106
+ return [name, stat.mode & 0o777, createHash("sha256").update(await readFile(path)).digest("hex")];
107
+ }));
108
+ return JSON.stringify({ root: await mode(transport.paths.root), presence: await mode(transport.paths.presence), sockets: await mode(transport.paths.sockets), files: files.sort() });
109
+ };
110
+ assert.deepEqual(await transport.list("self"), []);
111
+ const self = await raw(t, transport, "self");
112
+ assert.deepEqual(await transport.list(self.sessionId), [], "the one active record is excluded when it belongs to this host");
113
+ assert.deepEqual(await transport.list(), [{ sessionId: "self", reachability: "unknown" }], "one peer exposes only its public ID and unknown reachability");
114
+ const alphaOld = await raw(t, transport, "alpha");
115
+ const alphaNew = await raw(t, transport, "alpha");
116
+ const missing = await transport.record("missing", 10); await transport.publish(missing);
117
+ const malformed = await transport.record("malformed", 11); await writeFile(transport.presencePath(malformed), "{");
118
+ const symlinked = await transport.record("symlinked", 12); await symlink("missing", symlinked.endpoint); await transport.publish(symlinked);
119
+ const before = await snapshot();
120
+ assert.deepEqual(await transport.list(self.sessionId), [
121
+ { sessionId: "alpha", reachability: "unknown" },
122
+ ]);
123
+ assert.equal(candidateOpens, 8, "listing reads candidate records but never connects or repairs them");
124
+ assert.equal(await snapshot(), before, "listing leaves presence files, directory modes, and socket entries unchanged");
125
+ assert.equal(alphaNew.createdAt >= alphaOld.createdAt, true);
126
+ for (let index = 0; index < 65; index++) await transport.publish(await transport.record(`overflow-${index}`, index));
127
+ await rejected(transport.list(), "busy");
128
+ });
129
+
130
+ test("keeps same-session activations distinct and old cleanup preserves the newest", async (t) => {
131
+ const transport = await registry(t);
132
+ const old = await transport.record("same-session", 1);
133
+ const newest = await transport.record("same-session", 2);
134
+ await transport.publish(old);
135
+ await transport.publish(newest);
136
+ assert.equal((await readdir(transport.paths.presence)).length, 2);
137
+ assert.deepEqual(await transport.resolve(old.sessionId), newest, "stale activation remains until later liveness cleanup");
138
+ const concurrent = await transport.record("same-session", 3);
139
+ await Promise.all([transport.publish(concurrent), ...Array.from({ length: 12 }, () => transport.removeOwn(old))]);
140
+ assert.deepEqual(await transport.resolve(old.sessionId), concurrent);
141
+ await transport.removeOwn(newest);
142
+ assert.deepEqual(await transport.resolve(old.sessionId), concurrent);
143
+ await transport.removeOwn(concurrent);
144
+ await transport.removeOwn(concurrent);
145
+ await rejected(transport.resolve(old.sessionId), "not_found");
146
+ });
147
+
148
+ test("breaks equal activation timestamps deterministically by capability token", async (t) => {
149
+ const transport = await registry(t);
150
+ const first = await transport.record("tie", 3);
151
+ const second = await transport.record("tie", 3);
152
+ await Promise.all([transport.publish(first), transport.publish(second)]);
153
+ assert.deepEqual(await transport.resolve("tie"), first.endpoint > second.endpoint ? first : second);
154
+ });
155
+
156
+ test("isolates two sessions and bounds registry discovery at 65 entries", async (t) => {
157
+ const transport = await registry(t);
158
+ const left = await transport.record("left", 1);
159
+ const right = await transport.record("right", 2);
160
+ await Promise.all([transport.publish(left), transport.publish(right)]);
161
+ assert.deepEqual(await transport.resolve("left"), left);
162
+ assert.deepEqual(await transport.resolve("right"), right);
163
+ for (let index = 0; index < 65; index++) await transport.publish(await transport.record("capped", index));
164
+ await rejected(transport.resolve("capped"), "busy");
165
+ });
166
+
167
+ test("rejects unsafe IDs, v1 schema violations, prototypes, and content-leaking errors", async (t) => {
168
+ const transport = await registry(t);
169
+ for (const id of ["", "/absolute", "a/b", "line\nbreak", "x".repeat(129)]) await rejected(transport.record(id), "invalid_session", id);
170
+ const record = await transport.record("schema", 1);
171
+ const values: unknown[] = [
172
+ { ...record, version: 2 }, { sessionId: record.sessionId, endpoint: record.endpoint, createdAt: 1 },
173
+ Object.assign(Object.create({ inherited: true }), record),
174
+ ];
175
+ const nullRecord = Object.assign(Object.create(null), record) as PresenceRecord;
176
+ await transport.publish(nullRecord);
177
+ await transport.removeOwn(nullRecord);
178
+ const symbol = { ...record } as Record<PropertyKey, unknown>;
179
+ symbol[Symbol("extra")] = true;
180
+ Object.defineProperty(symbol, "hidden", { value: true });
181
+ values.push(symbol);
182
+ for (const value of values) await rejected(transport.publish(value as PresenceRecord), "invalid_presence");
183
+ await rejected(transport.publish({ ...record, endpoint: join(transport.paths.root, "forged.sock") }), "unsafe_path", "forged.sock");
184
+ });
185
+
186
+ test("continues past malformed matching activations in either entry order", async (t) => {
187
+ for (const malformedFirst of [true, false]) {
188
+ const transport = await registry(t);
189
+ const old = await transport.record("mixed", 1);
190
+ const newest = await transport.record("mixed", 2);
191
+ const malformed = await transport.record("mixed", 3);
192
+ if (malformedFirst) await writeFile(transport.presencePath(malformed), "{");
193
+ await transport.publish(old);
194
+ await transport.publish(newest);
195
+ if (!malformedFirst) await writeFile(transport.presencePath(malformed), "{");
196
+ assert.deepEqual(await transport.resolve("mixed"), newest);
197
+ }
198
+ });
199
+
200
+ test("continues past a matching activation that vanishes after directory enumeration", async (t) => {
201
+ let vanished = "";
202
+ const transport = await registry(t, async () => { await rm(vanished, { force: true }); });
203
+ const valid = await transport.record("vanishing", 1);
204
+ const missing = await transport.record("vanishing", 2);
205
+ vanished = transport.presencePath(missing);
206
+ await transport.publish(valid);
207
+ await transport.publish(missing);
208
+ assert.deepEqual(await transport.resolve("vanishing"), valid);
209
+ });
210
+
211
+ test("maps hook exceptions to a fixed I/O error even with a valid activation", async (t) => {
212
+ for (const error of [new SessionPresenceError("invalid_presence", "SECRET/PATH"), new Error("OTHER/SECRET")]) {
213
+ const transport = await registry(t, async () => { throw error; });
214
+ const valid = await transport.record("hook-error", 1);
215
+ await transport.publish(valid);
216
+ await rejected(transport.resolve("hook-error"), "io_error", "SECRET");
217
+ }
218
+ });
219
+
220
+ test("retains a fixed candidate error when no valid activation exists", async (t) => {
221
+ const transport = await registry(t);
222
+ const malformed = await transport.record("broken", 1);
223
+ await writeFile(transport.presencePath(malformed), "{");
224
+ await rejected(transport.resolve("broken"), "invalid_presence", malformed.endpoint);
225
+ });
226
+
227
+ test("retains unsafe-path priority over malformed candidates independent of entry order", async (t) => {
228
+ for (const malformedFirst of [true, false]) {
229
+ const transport = await registry(t);
230
+ const malformed = await transport.record("priority", 1);
231
+ const unsafe = await transport.record("priority", 2);
232
+ if (malformedFirst) await writeFile(transport.presencePath(malformed), "{");
233
+ await symlink("missing", transport.presencePath(unsafe));
234
+ if (!malformedFirst) await writeFile(transport.presencePath(malformed), "{");
235
+ await rejected(transport.resolve("priority"), "unsafe_path");
236
+ }
237
+ });
238
+
239
+ test("descriptor reads reject symlinks, malformed or oversized candidates, and fixed errors", async (t) => {
240
+ const transport = await registry(t);
241
+ const record = await transport.record("recipient", 1);
242
+ const file = transport.presencePath(record);
243
+ for (const text of ["{", JSON.stringify({ ...record, extra: true }), JSON.stringify({ ...record, sessionId: "other" }), "x".repeat(8193)]) {
244
+ await writeFile(file, text, { mode: 0o600 });
245
+ await rejected(transport.resolve(record.sessionId), "invalid_presence");
246
+ await rm(file, { force: true });
247
+ }
248
+ await symlink("missing", file);
249
+ await rejected(transport.resolve(record.sessionId), "unsafe_path");
250
+ });
251
+
252
+ test("a matching FIFO cannot block presence discovery", async (t) => {
253
+ const transport = await registry(t);
254
+ const record = await transport.record("fifo");
255
+ const fifo = transport.presencePath(record);
256
+ const created = await boundedSubprocess("mkfifo", [fifo], 1000);
257
+ assert.equal(created.timedOut, false, `mkfifo timed out: ${created.stderr}`);
258
+ assert.equal(created.code, 0, `mkfifo failed: ${created.stderr}`);
259
+ const moduleUrl = new URL("../lib/agents-session-transport.ts", import.meta.url).href;
260
+ const script = `import { SessionPresenceRegistry } from ${JSON.stringify(moduleUrl)};\nconst registry = await SessionPresenceRegistry.create(${JSON.stringify(join(transport.paths.root, "..", ".."))});\ntry { await registry.resolve("fifo"); console.log("resolved"); } catch (error) { console.log(\`rejected:\${error.code}\`); }`;
261
+ const child = await boundedSubprocess(process.execPath, ["--experimental-strip-types", "--input-type=module", "--eval", script], 1000);
262
+ assert.equal(child.timedOut, false, `presence discovery blocked: ${child.stderr}`);
263
+ assert.equal(child.signal, null, `presence discovery was killed: ${child.stderr}`);
264
+ assert.equal(child.code, 0, `presence discovery failed: ${child.stderr}`);
265
+ assert.match(child.stdout, /^rejected:invalid_presence\s*$/);
266
+ });
267
+
268
+ test("rejects a symlinked dedicated transport directory", async (t) => {
269
+ const home = await mkdtemp(join(tmpdir(), "g-"));
270
+ t.after(() => rm(home, { recursive: true, force: true }));
271
+ await symlink("missing", join(home, "gentle-agents"));
272
+ await rejected(SessionPresenceRegistry.create(home), "unsafe_path");
273
+ });
274
+
275
+ const protocolRejected = (operation: () => unknown, code: string) => assert.throws(operation, (error: unknown) => error instanceof TransportProtocolError && error.code === code);
276
+ const notification = (message = "hello") => ({ version: 1 as const, kind: "notification" as const, id: "message-1", senderSessionId: "sender", recipientSessionId: "recipient", message });
277
+ const decode = (bytes: Buffer, split = bytes.length) => {
278
+ const decoder = new FrameDecoder();
279
+ decoder.push(bytes.subarray(0, split));
280
+ decoder.push(bytes.subarray(split));
281
+ return decoder.finish();
282
+ };
283
+
284
+ test("encodes and incrementally decodes every notification and ack split boundary", () => {
285
+ const frame = notification();
286
+ const bytes = encodeNotificationFrame(frame);
287
+ for (let split = 0; split <= bytes.length; split++) assert.deepEqual(decode(bytes, split), frame);
288
+ const ack = { version: 1 as const, kind: "ack" as const, id: frame.id, accepted: false, error: "rejected" as const };
289
+ const encodedAck = encodeAckFrame(ack);
290
+ for (let split = 0; split <= encodedAck.length; split++) assert.deepEqual(decode(encodedAck, split), ack);
291
+ const accepted = { version: 1 as const, kind: "ack" as const, id: frame.id, accepted: true };
292
+ assert.deepEqual(decode(encodeAckFrame(accepted)), accepted);
293
+ });
294
+
295
+ test("enforces payload and bounded escaped raw-frame limits", () => {
296
+ for (const message of ["a".repeat(8192), "é".repeat(4096), "\0".repeat(8192)]) assert.deepEqual(decode(encodeNotificationFrame(notification(message))), notification(message));
297
+ protocolRejected(() => encodeNotificationFrame(notification("a".repeat(8193))), "invalid_frame");
298
+ const decoder = new FrameDecoder();
299
+ protocolRejected(() => decoder.push(Buffer.alloc(MAX_FRAME_BYTES + 1)), "oversized");
300
+ });
301
+
302
+ test("rejects malformed, trailing, incomplete, invalid UTF8, and terminal protocol input", () => {
303
+ for (const [value, code] of [[Buffer.from("{\n"), "invalid_frame"], [Buffer.from("{}\n"), "invalid_frame"], [Buffer.from(`${JSON.stringify(notification())}\nextra`), "trailing_frame"], [Buffer.from(`${JSON.stringify(notification())}\n{}\n`), "trailing_frame"], [Buffer.from([0xff, 10]), "invalid_utf8"]] as const) {
304
+ const decoder = new FrameDecoder();
305
+ protocolRejected(() => decoder.push(value), code);
306
+ }
307
+ const incomplete = new FrameDecoder();
308
+ incomplete.push(Buffer.from(JSON.stringify(notification())));
309
+ protocolRejected(() => incomplete.finish(), "malformed");
310
+ const complete = new FrameDecoder();
311
+ complete.push(encodeNotificationFrame(notification()));
312
+ complete.finish();
313
+ protocolRejected(() => complete.finish(), "trailing_frame");
314
+ protocolRejected(() => complete.push(Buffer.from("x")), "trailing_frame");
315
+ });
316
+
317
+ test("rejects strict programmatic fields and ack conditional violations", () => {
318
+ const bad: unknown[] = [{ ...notification(), version: 2 }, { ...notification(), kind: "unknown" }, { ...notification(), extra: true }, { ...notification(), id: 3 }, Object.assign(Object.create({ inherited: true }), notification())];
319
+ const symbol = { ...notification() } as Record<PropertyKey, unknown>;
320
+ symbol[Symbol("x")] = true;
321
+ Object.defineProperty(symbol, "hidden", { value: true });
322
+ bad.push(symbol);
323
+ for (const value of bad) protocolRejected(() => encodeNotificationFrame(value as never), "invalid_frame");
324
+ for (const ack of [{ version: 1, kind: "ack", id: "message-1", accepted: true, error: "rejected" }, { version: 1, kind: "ack", id: "message-1", accepted: false }, { version: 1, kind: "ack", id: "message-1", accepted: false, error: "forged" }]) protocolRejected(() => encodeAckFrame(ack as never), "invalid_frame");
325
+ });
326
+
327
+ async function listener(t: TestContext, callback: (value: ReceivedNotification) => Promise<void> = async (_value: ReceivedNotification) => {}, options?: object) {
328
+ const instance = new ActiveSessionListener(await registry(t), "recipient", callback, options as never);
329
+ assert.equal(instance.record, undefined);
330
+ await instance.start();
331
+ return instance;
332
+ }
333
+
334
+ async function request(path: string, frame: Buffer) {
335
+ const socket = createConnection(path), chunks: Buffer[] = [];
336
+ socket.on("data", (chunk) => chunks.push(chunk));
337
+ await once(socket, "connect");
338
+ socket.write(frame.subarray(0, 3));
339
+ socket.write(frame.subarray(3));
340
+ await once(socket, "end");
341
+ return chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : undefined;
342
+ }
343
+
344
+ const wait = async <T>(label: string, operation: Promise<T>) => {
345
+ let timer: ReturnType<typeof setTimeout> | undefined;
346
+ try {
347
+ return await Promise.race([operation, new Promise<never>((_, reject) => { timer = setTimeout(() => reject(new Error(`timed out waiting for ${label}`)), 1000); })]);
348
+ } finally { if (timer) clearTimeout(timer); }
349
+ };
350
+ const poll = async (label: string, condition: () => boolean) => {
351
+ const deadline = Date.now() + 1000;
352
+ while (!condition()) {
353
+ if (Date.now() >= deadline) throw new Error(`timed out waiting for ${label}`);
354
+ await new Promise<void>((resolve) => setImmediate(resolve));
355
+ }
356
+ };
357
+ const deferred = <T = void>() => {
358
+ let resolve!: (value: T | PromiseLike<T>) => void, reject!: (reason?: unknown) => void;
359
+ const promise = new Promise<T>((next, fail) => { resolve = next; reject = fail; });
360
+ return { promise, resolve, reject };
361
+ };
362
+ const fakeScheduler = () => {
363
+ let next = 0;
364
+ const tasks = new Map<number, () => void>();
365
+ const delays: number[] = [];
366
+ return {
367
+ scheduler: { setTimeout: (task: () => void, delay: number) => { const handle = next++; delays.push(delay); tasks.set(handle, task); return handle; }, clearTimeout: (handle: number) => tasks.delete(handle) },
368
+ get size() { return tasks.size; },
369
+ get delays() { return delays; },
370
+ runAll() { for (const [handle, task] of [...tasks]) { tasks.delete(handle); task(); } },
371
+ };
372
+ };
373
+
374
+ test("listens before publishing presence and acknowledges one valid chunked notification", async (t) => {
375
+ const received: unknown[] = [];
376
+ const instance = await listener(t, async (value) => { received.push(value); });
377
+ assert.ok(instance.record);
378
+ assert.deepEqual(await instance.registry.resolve("recipient"), instance.record);
379
+ assert.equal(await mode(instance.record.endpoint), 0o600);
380
+ const frame = { ...notification("ready"), recipientSessionId: "recipient" };
381
+ assert.deepEqual(await request(instance.record.endpoint, encodeNotificationFrame(frame)), { version: 1, kind: "ack", id: frame.id, accepted: true });
382
+ assert.deepEqual(received, [{ id: frame.id, senderSessionId: frame.senderSessionId, message: frame.message }]);
383
+ await instance.close();
384
+ });
385
+
386
+ test("rejects malformed or foreign notifications without invoking the callback", async (t) => {
387
+ let calls = 0;
388
+ const instance = await listener(t, async () => { calls++; });
389
+ const foreign = { ...notification(), recipientSessionId: "other" };
390
+ assert.equal((await request(instance.record!.endpoint, encodeNotificationFrame(foreign))).accepted, false);
391
+ assert.equal(await request(instance.record!.endpoint, Buffer.from("{\n")), undefined);
392
+ assert.equal(calls, 0);
393
+ await instance.close();
394
+ });
395
+
396
+ test("caps held partial connections and rejects callback failures", async (t) => {
397
+ const instance = await listener(t, async () => { throw new Error("no"); });
398
+ t.after(() => instance.close());
399
+ const held = await Promise.all(Array.from({ length: 8 }, async () => { const socket = createConnection(instance.record!.endpoint); await once(socket, "connect"); return socket; }));
400
+ await poll("held connections", () => instance.activeConnections === 8);
401
+ assert.equal(instance.activeConnections, 8);
402
+ const ninth = createConnection(instance.record!.endpoint);
403
+ await once(ninth, "close");
404
+ assert.equal(instance.activeConnections, 8);
405
+ await Promise.all(held.map(async (socket) => { socket.destroy(); await once(socket, "close"); }));
406
+ const reply = await request(instance.record!.endpoint, encodeNotificationFrame({ ...notification(), recipientSessionId: "recipient" }));
407
+ assert.equal(reply.accepted, false);
408
+ await instance.close();
409
+ });
410
+
411
+ test("accepts during publication and becomes active only after presence succeeds", async (t) => {
412
+ const transport = await registry(t);
413
+ let callbackStarted!: () => void, release: (() => void) | undefined, socket: ReturnType<typeof createConnection> | undefined;
414
+ let instance: ActiveSessionListener | undefined;
415
+ const callbackReady = new Promise<void>((resolve) => { callbackStarted = resolve; });
416
+ const callbackRelease = new Promise<void>((resolve) => { release = resolve; });
417
+ t.after(async () => {
418
+ release?.();
419
+ socket?.destroy();
420
+ if (instance) await wait("listener close", instance.close());
421
+ });
422
+ instance = new ActiveSessionListener(transport, "recipient", async () => { callbackStarted(); await callbackRelease; });
423
+ const original = transport.publish.bind(transport);
424
+ (transport as unknown as { publish: (record: PresenceRecord) => Promise<void> }).publish = async (record) => {
425
+ socket = createConnection(record.endpoint);
426
+ const chunks: Buffer[] = [];
427
+ socket.on("data", (chunk) => chunks.push(chunk));
428
+ socket.on("error", () => {});
429
+ await wait("publication connection", once(socket, "connect"));
430
+ socket.end(encodeNotificationFrame({ ...notification(), recipientSessionId: "recipient" }));
431
+ const outcome = await wait("publication callback", Promise.race([callbackReady.then(() => "callback"), once(socket, "close").then(() => "closed")]));
432
+ assert.equal(outcome, "callback");
433
+ assert.equal(instance!.status, "accepting");
434
+ assert.equal(instance!.activeConnections, 1);
435
+ release!();
436
+ await wait("publication response", once(socket, "end"));
437
+ assert.equal(JSON.parse(Buffer.concat(chunks).toString()).accepted, true);
438
+ await original(record);
439
+ };
440
+ await wait("listener start", instance.start());
441
+ assert.equal(instance.status, "active");
442
+ });
443
+
444
+ test("rolls back publication failures without listener residue", async (t) => {
445
+ const transport = await registry(t);
446
+ let attempted: PresenceRecord | undefined;
447
+ const failed = new ActiveSessionListener(transport, "failed", async () => {});
448
+ t.after(async () => { await wait("failed listener close", failed.close()); });
449
+ (transport as unknown as { publish: (record: PresenceRecord) => Promise<void> }).publish = async (record) => { attempted = record; throw new Error("fail"); };
450
+ await wait("failed listener start", assert.rejects(failed.start()));
451
+ assert.ok(attempted);
452
+ assert.equal(failed.status, "idle");
453
+ assert.equal(failed.record, undefined);
454
+ assert.equal(failed.activeConnections, 0);
455
+ await assert.rejects(lstat(attempted.endpoint));
456
+ });
457
+
458
+ test("contains runtime server errors in one fixed cleanup", async (t) => {
459
+ const instance = await listener(t);
460
+ t.after(async () => { await wait("failed listener close", instance.close()); });
461
+ const endpoint = instance.record!.endpoint;
462
+ const controlled = instance as unknown as { server: { close: (callback?: () => void) => unknown; emit: (event: string, error: unknown) => boolean; on: (event: string, listener: () => void) => unknown } };
463
+ const close = controlled.server.close.bind(controlled.server);
464
+ let closes = 0;
465
+ controlled.server.close = (callback) => { closes++; return close(callback); };
466
+ controlled.server.on("error", () => {});
467
+ controlled.server.emit("error", new Error("SECRET server error"));
468
+ controlled.server.emit("error", new Error("SECOND error"));
469
+ await poll("listener failure", () => Boolean(instance.failure));
470
+ await wait("server cleanup", instance.closed);
471
+ assert.equal(instance.failure?.code, "io_error");
472
+ assert.equal(instance.failure?.message, "listener failed");
473
+ assert.equal(instance.status, "failed");
474
+ assert.equal(closes, 1);
475
+ assert.equal(instance.activeConnections, 0);
476
+ await assert.rejects(lstat(endpoint));
477
+ await rejected(instance.registry.resolve("recipient"), "not_found");
478
+ });
479
+
480
+ const clientRejected = (operation: Promise<unknown>, code: string) => assert.rejects(operation, (error: unknown) => error instanceof ActiveSessionClientError && error.code === code);
481
+ async function raw(t: TestContext, transport: SessionPresenceRegistry, id: string, reply?: Buffer | null, createdAt?: number) {
482
+ const record = await transport.record(id, createdAt), server = createServer((socket) => { socket.on("error", () => {}); socket.on("data", () => { if (reply !== null) socket.end(reply); }); });
483
+ await new Promise<void>((resolveListen, rejectListen) => { server.once("error", rejectListen); server.listen(record.endpoint, resolveListen); });
484
+ await transport.publish(record);
485
+ t.after(async () => { await transport.removeOwn(record); await new Promise<void>((resolveClose) => server.close(() => resolveClose())); });
486
+ return record;
487
+ }
488
+
489
+ test("client delivers one exact notification from its fixed active sender", async (t) => {
490
+ const transport = await registry(t), received: unknown[] = [];
491
+ const sender = new ActiveSessionListener(transport, "sender", async () => {}), recipient = new ActiveSessionListener(transport, "recipient", async (value) => { received.push(value); });
492
+ t.after(() => Promise.all([sender.close(), recipient.close()]));
493
+ await Promise.all([sender.start(), recipient.start()]);
494
+ const client = new ActiveSessionClient(transport, "sender");
495
+ assert.deepEqual(await wait("client accepted", client.sendNotification("recipient", "exact", { id: "outbound-1" })), { id: "outbound-1", accepted: true });
496
+ assert.deepEqual(received, [{ id: "outbound-1", senderSessionId: "sender", message: "exact" }]);
497
+ await clientRejected(client.sendNotification("sender", "self"), "self");
498
+ });
499
+
500
+ test("client pins a selected activation and never connects to its replacement", async (t) => {
501
+ const transport = await registry(t);
502
+ const old = await raw(t, transport, "recipient", undefined, 1);
503
+ const replacement = await raw(t, transport, "recipient", undefined, 2);
504
+ let connects = 0;
505
+ const client = new ActiveSessionClient(transport, "sender", {
506
+ connect: (endpoint) => {
507
+ connects += 1;
508
+ return createConnection(endpoint);
509
+ },
510
+ });
511
+ await clientRejected(client.sendNotification("recipient", "selected", { expectedActivation: old } as never), "stale");
512
+ assert.equal(connects, 0, "a changed registry activation is rejected before opening a socket");
513
+ await clientRejected(client.sendNotification("recipient", "host changed", { expectedActivation: replacement, beforeConnect: () => false }), "stale");
514
+ assert.equal(connects, 0, "a stale host guard is checked after resolution and before connecting");
515
+ assert.deepEqual(await transport.resolve("recipient"), replacement, "the replacement remains intact for a later explicit send");
516
+ });
517
+
518
+ test("client bounds admission and aborts or closes each pending send once", async (t) => {
519
+ const transport = await registry(t), record = await raw(t, transport, "held", null);
520
+ const client = new ActiveSessionClient(transport, "sender"), pre = new AbortController(); pre.abort();
521
+ await clientRejected(client.sendNotification("missing", "x"), "not_found");
522
+ await clientRejected(client.sendNotification("held", "x", { signal: pre.signal }), "aborted");
523
+ const pending = Array.from({ length: 8 }, (_, index) => client.sendNotification(record.sessionId, "x", { id: `held-${index}` }));
524
+ await poll("client admission", () => client.pendingCount === 8);
525
+ await clientRejected(client.sendNotification(record.sessionId, "x"), "busy");
526
+ client.close();
527
+ await Promise.all(pending.map((operation) => clientRejected(operation, "closed")));
528
+ assert.equal(client.pendingCount, 0); assert.equal(client.closed, true);
529
+ await clientRejected(client.sendNotification(record.sessionId, "x"), "closed");
530
+ });
531
+
532
+ test("client enforces bounded deadlines, aborts awaiting ack, and rejects hostile replies", async (t) => {
533
+ const transport = await registry(t), record = await transport.record("seam"); await transport.publish(record);
534
+ t.after(() => transport.removeOwn(record));
535
+ const clock = fakeScheduler(), socket = new Socket(); (socket as unknown as { write: () => boolean }).write = () => true;
536
+ const timeout = new ActiveSessionClient(transport, "sender", { scheduler: clock.scheduler, connect: () => socket, connectDeadlineMs: 1, ackDeadlineMs: 1 });
537
+ const connecting = timeout.sendNotification("seam", "x"); await poll("connect timer", () => clock.size === 1); clock.runAll(); await clientRejected(connecting, "connect_timeout");
538
+ const ackSocket = new Socket(); (ackSocket as unknown as { write: () => boolean }).write = () => true;
539
+ const awaiting = new ActiveSessionClient(transport, "sender", { scheduler: clock.scheduler, connect: () => ackSocket, connectDeadlineMs: 1, ackDeadlineMs: 1 });
540
+ const ack = awaiting.sendNotification("seam", "x"); await poll("ack connect", () => clock.size === 1); ackSocket.emit("connect"); await poll("ack timer", () => clock.size === 1); clock.runAll(); await clientRejected(ack, "ack_timeout");
541
+ const abortSocket = new Socket(); (abortSocket as unknown as { write: () => boolean }).write = () => true;
542
+ const abort = new AbortController(), aborting = new ActiveSessionClient(transport, "sender", { connect: () => abortSocket });
543
+ const aborted = aborting.sendNotification("seam", "x", { signal: abort.signal }); await poll("abort socket", () => abortSocket.listenerCount("error") > 0); abortSocket.emit("connect"); abort.abort(); await clientRejected(aborted, "aborted"); abortSocket.emit("error", new Error("late"));
544
+ for (const [id, reply, code] of [["wrong", encodeAckFrame({ version: 1, kind: "ack", id: "other", accepted: true }), "invalid_ack"], ["malformed", Buffer.from("{\n"), "invalid_ack"], ["trailing", Buffer.concat([encodeAckFrame({ version: 1, kind: "ack", id: "trailing", accepted: true }), Buffer.from("x")]), "invalid_ack"], ["drop", undefined, "invalid_ack"], ["negative", encodeAckFrame({ version: 1, kind: "ack", id: "negative", accepted: false, error: "rejected" }), "remote_rejected"]] as const) {
545
+ await raw(t, transport, id, reply); await clientRejected(new ActiveSessionClient(transport, "sender").sendNotification(id, "x", { id }), code);
546
+ }
547
+ });
548
+
549
+ test("client removes only definitively stale presence and leaves timeout or replacement records", async (t) => {
550
+ const transport = await registry(t), stale = await transport.record("stale", 1); await transport.publish(stale);
551
+ await clientRejected(new ActiveSessionClient(transport, "sender").sendNotification("stale", "x"), "io_error"); await rejected(transport.resolve("stale"), "not_found");
552
+ const old = await transport.record("replaced", 1), newer = await transport.record("replaced", 2); await transport.publish(old);
553
+ const socket = new Socket(), client = new ActiveSessionClient(transport, "sender", { connect: () => socket }), resolve = transport.resolve.bind(transport);
554
+ (transport as unknown as { resolve: () => Promise<PresenceRecord> }).resolve = async () => old;
555
+ const replacing = client.sendNotification("replaced", "x"); await poll("replacement connect", () => socket.listenerCount("error") > 0); await transport.publish(newer); socket.emit("error", Object.assign(new Error("refused"), { code: "ECONNREFUSED" }));
556
+ await clientRejected(replacing, "io_error"); (transport as unknown as { resolve: typeof resolve }).resolve = resolve; assert.deepEqual(await transport.resolve("replaced"), newer);
557
+ const held = await raw(t, transport, "timeout", null), clock = fakeScheduler();
558
+ const timed = new ActiveSessionClient(transport, "sender", { scheduler: clock.scheduler, ackDeadlineMs: 1 }).sendNotification("timeout", "x"); await poll("timeout timer", () => clock.delays.length === 2); clock.runAll(); await clientRejected(timed, "ack_timeout"); assert.deepEqual(await transport.resolve(held.sessionId), held);
559
+ });
560
+
561
+ test("client serializes definitive stale cleanup before its result", async (t) => {
562
+ const transport = await registry(t), record = await transport.record("stale-race"); await transport.publish(record);
563
+ const gate = deferred<void>(), clock = fakeScheduler(), socket = new Socket(); let removals = 0, settled = false;
564
+ (transport as unknown as { removeOwn: () => Promise<void> }).removeOwn = async () => { removals++; await gate.promise; };
565
+ const pending = new ActiveSessionClient(transport, "sender", { scheduler: clock.scheduler, connect: () => socket }).sendNotification("stale-race", "x").finally(() => { settled = true; });
566
+ await poll("stale listener", () => socket.listenerCount("error") > 0 && clock.size === 1);
567
+ socket.emit("error", Object.assign(new Error("missing"), { code: "ENOENT" })); socket.emit("error", Object.assign(new Error("again"), { code: "ECONNREFUSED" }));
568
+ assert.equal(removals, 1); assert.equal(clock.size, 0); await new Promise<void>((resolve) => setImmediate(resolve)); assert.equal(settled, false);
569
+ gate.resolve(); await clientRejected(pending, "io_error");
570
+ });
571
+
572
+ test("client ignores coded stale errors after abort or close", async (t) => {
573
+ const transport = await registry(t), record = await transport.record("late-stale"); await transport.publish(record);
574
+ let removals = 0; (transport as unknown as { removeOwn: () => Promise<void> }).removeOwn = async () => { removals++; };
575
+ for (const action of ["abort", "close"] as const) {
576
+ const socket = new Socket(), controller = new AbortController(), client = new ActiveSessionClient(transport, "sender", { connect: () => socket });
577
+ const pending = client.sendNotification("late-stale", "x", { signal: controller.signal }); await poll(`${action} listener`, () => socket.listenerCount("error") > 0);
578
+ if (action === "abort") controller.abort(); else client.close(); await clientRejected(pending, action === "abort" ? "aborted" : "closed"); socket.emit("error", Object.assign(new Error("late"), { code: "ENOENT" }));
579
+ }
580
+ assert.equal(removals, 0);
581
+ });
582
+
583
+ test("settles partial EOF, socket error, and listener close without leaking admission", async (t) => {
584
+ let calls = 0;
585
+ const instance = await listener(t, async () => { calls++; });
586
+ const clients: ReturnType<typeof createConnection>[] = [];
587
+ t.after(async () => {
588
+ for (const socket of clients) socket.destroy();
589
+ await wait("listener close", instance.close());
590
+ });
591
+ const partial = createConnection(instance.record!.endpoint);
592
+ clients.push(partial);
593
+ partial.on("error", () => {});
594
+ await wait("partial connection", once(partial, "connect"));
595
+ partial.end(Buffer.from('{"version":1'));
596
+ await wait("partial close", once(partial, "close"));
597
+ assert.equal(instance.activeConnections, 0);
598
+ const errored = createConnection(instance.record!.endpoint);
599
+ clients.push(errored);
600
+ errored.on("error", () => {});
601
+ await wait("error connection", once(errored, "connect"));
602
+ const serverSocket = [...instance.sockets][0];
603
+ serverSocket.destroy(new Error("socket failure"));
604
+ await wait("error close", once(errored, "close"));
605
+ assert.equal(instance.activeConnections, 0);
606
+ const held = await Promise.all(Array.from({ length: 2 }, async () => {
607
+ const socket = createConnection(instance.record!.endpoint);
608
+ clients.push(socket);
609
+ socket.on("error", () => {});
610
+ await wait("held connection", once(socket, "connect"));
611
+ return socket;
612
+ }));
613
+ await poll("held connections", () => instance.activeConnections === 2);
614
+ const heldCloses = held.map((socket) => wait("held close", once(socket, "close")));
615
+ await wait("listener close", instance.close());
616
+ await Promise.all(heldCloses);
617
+ assert.equal(instance.activeConnections, 0);
618
+ assert.equal(calls, 0);
619
+ });
620
+
621
+ test("bounds callback deadlines and ignores late settlement", async (t) => {
622
+ const clock = fakeScheduler(), pending = new Map<string, ReturnType<typeof deferred<void>>>(), unhandled: unknown[] = [];
623
+ const onUnhandled = (error: unknown) => unhandled.push(error);
624
+ process.on("unhandledRejection", onUnhandled);
625
+ t.after(() => process.off("unhandledRejection", onUnhandled));
626
+ const instance = await listener(t, async (value) => {
627
+ assert.equal(Object.isFrozen(value), true);
628
+ const next = deferred();
629
+ pending.set(value.id, next);
630
+ await next.promise;
631
+ }, { callbackDeadlineMs: 1, scheduler: clock.scheduler });
632
+ t.after(() => instance.close());
633
+ const replies = ["late-resolve", "late-reject"].map((id) => request(instance.record!.endpoint, encodeNotificationFrame({ ...notification(), id, recipientSessionId: "recipient" })));
634
+ await poll("callbacks", () => pending.size === 2);
635
+ assert.deepEqual(clock.delays, [1, 1]);
636
+ clock.runAll();
637
+ assert.deepEqual(await Promise.all(replies.map((reply) => wait("timeout reply", reply))), [
638
+ { version: 1, kind: "ack", id: "late-resolve", accepted: false, error: "timeout" },
639
+ { version: 1, kind: "ack", id: "late-reject", accepted: false, error: "timeout" },
640
+ ]);
641
+ pending.get("late-resolve")!.resolve();
642
+ pending.get("late-reject")!.reject(new Error("late"));
643
+ await new Promise<void>((resolve) => setImmediate(resolve));
644
+ assert.equal(instance.activeConnections, 0);
645
+ assert.deepEqual(unhandled, []);
646
+ for (const deadline of [0, 2001, 1.5]) assert.throws(() => new ActiveSessionListener(instance.registry, "recipient", async () => {}, { callbackDeadlineMs: deadline } as never));
647
+ });
648
+
649
+ test("rejects active close and concurrent duplicates without a second callback", async (t) => {
650
+ let calls = 0;
651
+ const gate = deferred(), instance = await listener(t, async () => { calls++; await gate.promise; });
652
+ t.after(() => instance.close());
653
+ const frame = encodeNotificationFrame({ ...notification(), id: "duplicate", recipientSessionId: "recipient" });
654
+ const first = request(instance.record!.endpoint, frame);
655
+ await poll("first callback", () => instance.activeConnections === 1);
656
+ assert.deepEqual(await wait("duplicate reply", request(instance.record!.endpoint, frame)), { version: 1, kind: "ack", id: "duplicate", accepted: false, error: "duplicate" });
657
+ assert.equal(calls, 1);
658
+ assert.equal(instance.activeConnections, 1);
659
+ await instance.close();
660
+ assert.equal(await wait("closed request", first), undefined);
661
+ assert.equal(instance.activeConnections, 0);
662
+ gate.resolve();
663
+ });
664
+
665
+ test("retains rejected and timed-out IDs and evicts only the oldest of 64", async (t) => {
666
+ let calls = 0;
667
+ const clock = fakeScheduler(), timeout = deferred();
668
+ const instance = await listener(t, async (value) => { calls++; if (value.id === "reject") throw new Error("no"); if (value.id === "timeout") await timeout.promise; }, { callbackDeadlineMs: 1, scheduler: clock.scheduler });
669
+ t.after(() => instance.close());
670
+ for (const id of ["reject", "timeout"]) {
671
+ const reply = request(instance.record!.endpoint, encodeNotificationFrame({ ...notification(), id, recipientSessionId: "recipient" }));
672
+ if (id === "timeout") { await poll("timeout callback", () => clock.size === 1); clock.runAll(); assert.equal((await reply).error, "timeout"); } else assert.equal((await reply).error, "rejected");
673
+ assert.equal((await request(instance.record!.endpoint, encodeNotificationFrame({ ...notification(), id, recipientSessionId: "recipient" }))).error, "duplicate");
674
+ }
675
+ const lru = await listener(t, async () => { calls++; });
676
+ t.after(() => lru.close());
677
+ for (let id = 0; id < 65; id++) assert.equal((await request(lru.record!.endpoint, encodeNotificationFrame({ ...notification(), id: `evict-${id}`, recipientSessionId: "recipient" }))).accepted, true);
678
+ assert.equal((await request(lru.record!.endpoint, encodeNotificationFrame({ ...notification(), id: "evict-1", recipientSessionId: "recipient" }))).error, "duplicate");
679
+ assert.equal((await request(lru.record!.endpoint, encodeNotificationFrame({ ...notification(), id: "evict-0", recipientSessionId: "recipient" }))).accepted, true);
680
+ assert.equal(calls, 68);
681
+ });
682
+
683
+ type EndpointStat = Pick<Awaited<ReturnType<typeof lstat>>, "dev" | "ino" | "uid">;
684
+ const sameEndpointIdentity = (left: EndpointStat | undefined, right: EndpointStat | undefined) => Boolean(left && right && left.dev === right.dev && left.ino === right.ino && left.uid === right.uid);
685
+ const endpointCleanupDiagnostic = (kind: "socket" | "file" | "symlink", original: EndpointStat, replacement: EndpointStat | undefined, after: EndpointStat | undefined, endpointAbsentBeforeReplacement: boolean) => JSON.stringify({
686
+ kind,
687
+ originalIdentityEqualsReplacement: sameEndpointIdentity(original, replacement),
688
+ endpointAbsentBeforeReplacement,
689
+ replacementSurvived: after !== undefined,
690
+ afterIdentityMatchesReplacement: sameEndpointIdentity(after, replacement),
691
+ });
692
+ async function preservesReplacementEndpoint(t: TestContext, kind: "socket" | "file" | "symlink") {
693
+ let replacement: ReturnType<typeof createServer> | undefined;
694
+ let endpoint = "", replacementStat: Awaited<ReturnType<typeof lstat>> | undefined;
695
+ let endpointAbsentBeforeReplacement = false, closeFailed = false;
696
+ const instance = await listener(t, async () => {}, { beforeEndpointCleanup: async () => {
697
+ try { await lstat(endpoint); } catch (error) { endpointAbsentBeforeReplacement = (error as NodeJS.ErrnoException).code === "ENOENT"; }
698
+ if (kind === "socket") {
699
+ const candidate = createServer();
700
+ try {
701
+ await new Promise<void>((resolve, reject) => { candidate.once("error", reject); candidate.listen(endpoint, resolve); });
702
+ candidate.unref();
703
+ replacement = candidate;
704
+ replacementStat = await lstat(endpoint);
705
+ } catch { /* diagnostics below retain only fixed fields */ }
706
+ } else {
707
+ try {
708
+ if (kind === "file") await writeFile(endpoint, "replacement"); else await symlink("replacement", endpoint);
709
+ replacementStat = await lstat(endpoint);
710
+ } catch { /* diagnostics below retain only fixed fields */ }
711
+ }
712
+ } });
713
+ endpoint = instance.record!.endpoint;
714
+ const original = await lstat(endpoint);
715
+ try { await instance.close(); } catch { closeFailed = true; }
716
+ let after: Awaited<ReturnType<typeof lstat>> | undefined;
717
+ try { after = await lstat(endpoint); } catch { /* replacementSurvived remains false */ }
718
+ t.diagnostic(endpointCleanupDiagnostic(kind, original, replacementStat, after, endpointAbsentBeforeReplacement));
719
+ try {
720
+ assert.equal(closeFailed, false);
721
+ assert.equal(original.isSocket(), true, "the listener's original endpoint is a socket");
722
+ assert.ok(replacementStat, "the cleanup seam created a replacement");
723
+ assert.ok(after, "the replacement survives listener cleanup");
724
+ assert.equal(kind === "socket" ? after.isSocket() : kind === "file" ? after.isFile() : after.isSymbolicLink(), true);
725
+ await rejected(instance.registry.resolve("recipient"), "not_found");
726
+ } finally {
727
+ if (replacement) await new Promise<void>((resolve) => replacement!.close(() => resolve()));
728
+ }
729
+ }
730
+
731
+ test("preserves a replacement socket endpoint through listener cleanup", (t) => preservesReplacementEndpoint(t, "socket"));
732
+ test("preserves a replacement file endpoint through listener cleanup", (t) => preservesReplacementEndpoint(t, "file"));
733
+ test("preserves a replacement symlink endpoint through listener cleanup", (t) => preservesReplacementEndpoint(t, "symlink"));
734
+ test("removes an untouched own socket endpoint", async (t) => {
735
+ const instance = await listener(t);
736
+ const endpoint = instance.record!.endpoint;
737
+ await instance.close();
738
+ await assert.rejects(lstat(endpoint));
739
+ await rejected(instance.registry.resolve("recipient"), "not_found");
740
+ });
741
+
742
+ test("waits for an invalidated delayed startup before closing", async (t) => {
743
+ const transport = await registry(t), candidate = await transport.record("recipient"), gate = deferred<PresenceRecord>();
744
+ let records = 0, closed = false;
745
+ (transport as unknown as { record: (sessionID: string) => Promise<PresenceRecord> }).record = async () => { records++; return gate.promise; };
746
+ const instance = new ActiveSessionListener(transport, "recipient", async () => {});
747
+ const start = instance.start(), closing = instance.close().then(() => { closed = true; });
748
+ t.after(async () => { gate.resolve(candidate); await Promise.allSettled([start, closing]); await instance.close(); });
749
+ await poll("delayed record", () => records === 1);
750
+ await new Promise<void>((resolve) => setImmediate(resolve));
751
+ assert.equal(closed, false);
752
+ gate.resolve(candidate);
753
+ await assert.rejects(wait("invalidated start", start), (error: unknown) => error instanceof SessionPresenceError && error.code === "io_error");
754
+ await wait("delayed close", closing);
755
+ assert.equal(instance.status, "closed");
756
+ assert.equal(instance.record, undefined);
757
+ await assert.rejects(lstat(candidate.endpoint));
758
+ await rejected(transport.resolve("recipient"), "not_found");
759
+ });
760
+
761
+ test("rolls back a delayed publish after a runtime server failure", async (t) => {
762
+ const transport = await registry(t), gate = deferred<void>();
763
+ const publish = transport.publish.bind(transport);
764
+ let attempted: PresenceRecord | undefined, publishes = 0;
765
+ (transport as unknown as { publish: (record: PresenceRecord) => Promise<void> }).publish = async (record) => { attempted = record; publishes++; await gate.promise; await publish(record); };
766
+ const instance = new ActiveSessionListener(transport, "recipient", async () => {});
767
+ const start = instance.start();
768
+ t.after(async () => { gate.resolve(); await Promise.allSettled([start]); await instance.close(); });
769
+ await poll("delayed publish", () => Boolean(attempted));
770
+ const controlled = instance as unknown as { server: { emit: (event: string, error: unknown) => boolean } };
771
+ controlled.server.emit("error", new Error("server failed"));
772
+ await poll("startup failure", () => Boolean(instance.failure));
773
+ gate.resolve();
774
+ await assert.rejects(wait("failed delayed publish", start), (error: unknown) => error instanceof SessionPresenceError && error.code === "io_error");
775
+ await wait("failed cleanup", instance.closed);
776
+ assert.equal(publishes, 1);
777
+ assert.equal(instance.status, "failed");
778
+ assert.equal(instance.record, undefined);
779
+ await assert.rejects(lstat(attempted!.endpoint));
780
+ await rejected(transport.resolve("recipient"), "not_found");
781
+ });
782
+
783
+ test("shares concurrent starts through one record, listen, and publish", async (t) => {
784
+ const transport = await registry(t);
785
+ const record = transport.record.bind(transport), publish = transport.publish.bind(transport);
786
+ let records = 0, publishes = 0;
787
+ (transport as unknown as { record: (sessionID: string) => Promise<PresenceRecord> }).record = async (sessionID) => { records++; return record(sessionID); };
788
+ (transport as unknown as { publish: (value: PresenceRecord) => Promise<void> }).publish = async (value) => { publishes++; await publish(value); };
789
+ const instance = new ActiveSessionListener(transport, "recipient", async () => {});
790
+ t.after(() => instance.close());
791
+ await wait("concurrent starts", Promise.all([instance.start(), instance.start()]));
792
+ assert.equal(records, 1);
793
+ assert.equal(publishes, 1);
794
+ assert.equal(instance.status, "active");
795
+ const endpoint = instance.record!.endpoint;
796
+ await instance.close();
797
+ await assert.rejects(lstat(endpoint));
798
+ await rejected(transport.resolve("recipient"), "not_found");
799
+ });
800
+
801
+ test("shares concurrent close through delayed final cleanup", async (t) => {
802
+ const gate = deferred<void>();
803
+ let cleanupCalls = 0, firstDone = false, secondDone = false;
804
+ const instance = await listener(t, async () => {}, { beforeEndpointCleanup: async () => { cleanupCalls++; await gate.promise; } });
805
+ const held = createConnection(instance.record!.endpoint);
806
+ held.on("error", () => {});
807
+ await wait("held connection", once(held, "connect"));
808
+ t.after(async () => { gate.resolve(); held.destroy(); await instance.close(); });
809
+ const endpoint = instance.record!.endpoint;
810
+ const first = instance.close().then(() => { firstDone = true; });
811
+ const second = instance.close().then(() => { secondDone = true; });
812
+ await poll("delayed cleanup", () => cleanupCalls === 1);
813
+ await new Promise<void>((resolve) => setImmediate(resolve));
814
+ assert.equal(firstDone, false);
815
+ assert.equal(secondDone, false);
816
+ assert.equal(instance.activeConnections, 0);
817
+ gate.resolve();
818
+ await Promise.all([wait("first close", first), wait("second close", second)]);
819
+ assert.equal(cleanupCalls, 1);
820
+ assert.equal(instance.status, "closed");
821
+ await assert.rejects(lstat(endpoint));
822
+ await rejected(instance.registry.resolve("recipient"), "not_found");
823
+ });