@nebutra/agent-runtime 0.2.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 (179) hide show
  1. package/.turbo/turbo-build.log +115 -0
  2. package/.turbo/turbo-test.log +44 -0
  3. package/.turbo/turbo-typecheck.log +4 -0
  4. package/CHANGELOG.md +253 -0
  5. package/LICENSE +676 -0
  6. package/README.md +50 -0
  7. package/dist/adapters/dispatcher-sse.d.ts +68 -0
  8. package/dist/adapters/dispatcher-sse.js +11 -0
  9. package/dist/adapters/dispatcher-sse.js.map +1 -0
  10. package/dist/adapters/index.d.ts +12 -0
  11. package/dist/adapters/index.js +21 -0
  12. package/dist/adapters/index.js.map +1 -0
  13. package/dist/adapters/mcp-catalog.d.ts +58 -0
  14. package/dist/adapters/mcp-catalog.js +9 -0
  15. package/dist/adapters/mcp-catalog.js.map +1 -0
  16. package/dist/adapters/prisma-rollout.d.ts +60 -0
  17. package/dist/adapters/prisma-rollout.js +7 -0
  18. package/dist/adapters/prisma-rollout.js.map +1 -0
  19. package/dist/chunk-24ZXP7FI.js +93 -0
  20. package/dist/chunk-24ZXP7FI.js.map +1 -0
  21. package/dist/chunk-2DA6Q6TN.js +126 -0
  22. package/dist/chunk-2DA6Q6TN.js.map +1 -0
  23. package/dist/chunk-37BBB2P2.js +73 -0
  24. package/dist/chunk-37BBB2P2.js.map +1 -0
  25. package/dist/chunk-57W3AR43.js +52 -0
  26. package/dist/chunk-57W3AR43.js.map +1 -0
  27. package/dist/chunk-5N4644PB.js +67 -0
  28. package/dist/chunk-5N4644PB.js.map +1 -0
  29. package/dist/chunk-5YS7WAPS.js +177 -0
  30. package/dist/chunk-5YS7WAPS.js.map +1 -0
  31. package/dist/chunk-6EGG2OZC.js +13 -0
  32. package/dist/chunk-6EGG2OZC.js.map +1 -0
  33. package/dist/chunk-7BUOF367.js +126 -0
  34. package/dist/chunk-7BUOF367.js.map +1 -0
  35. package/dist/chunk-BJBBR3QA.js +121 -0
  36. package/dist/chunk-BJBBR3QA.js.map +1 -0
  37. package/dist/chunk-CGRCUKGT.js +73 -0
  38. package/dist/chunk-CGRCUKGT.js.map +1 -0
  39. package/dist/chunk-FUG5DT2C.js +75 -0
  40. package/dist/chunk-FUG5DT2C.js.map +1 -0
  41. package/dist/chunk-LO24VOA3.js +199 -0
  42. package/dist/chunk-LO24VOA3.js.map +1 -0
  43. package/dist/chunk-MUF7ZZTO.js +57 -0
  44. package/dist/chunk-MUF7ZZTO.js.map +1 -0
  45. package/dist/chunk-NN7DATXA.js +46 -0
  46. package/dist/chunk-NN7DATXA.js.map +1 -0
  47. package/dist/chunk-PGGWSUTM.js +33 -0
  48. package/dist/chunk-PGGWSUTM.js.map +1 -0
  49. package/dist/chunk-RDKYDMXT.js +135 -0
  50. package/dist/chunk-RDKYDMXT.js.map +1 -0
  51. package/dist/chunk-YYFPDBJG.js +63 -0
  52. package/dist/chunk-YYFPDBJG.js.map +1 -0
  53. package/dist/chunk-ZMYX5VBU.js +135 -0
  54. package/dist/chunk-ZMYX5VBU.js.map +1 -0
  55. package/dist/chunk-ZTSKS42I.js +131 -0
  56. package/dist/chunk-ZTSKS42I.js.map +1 -0
  57. package/dist/commands.d.ts +74 -0
  58. package/dist/commands.js +10 -0
  59. package/dist/commands.js.map +1 -0
  60. package/dist/definitions.d.ts +94 -0
  61. package/dist/definitions.js +15 -0
  62. package/dist/definitions.js.map +1 -0
  63. package/dist/dispatcher.d.ts +50 -0
  64. package/dist/dispatcher.js +8 -0
  65. package/dist/dispatcher.js.map +1 -0
  66. package/dist/durable-turn.d.ts +58 -0
  67. package/dist/durable-turn.js +9 -0
  68. package/dist/durable-turn.js.map +1 -0
  69. package/dist/hook-pipeline.d.ts +114 -0
  70. package/dist/hook-pipeline.js +13 -0
  71. package/dist/hook-pipeline.js.map +1 -0
  72. package/dist/index.d.ts +1874 -0
  73. package/dist/index.js +3117 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/loop.d.ts +78 -0
  76. package/dist/loop.js +9 -0
  77. package/dist/loop.js.map +1 -0
  78. package/dist/mcp-bridge.d.ts +48 -0
  79. package/dist/mcp-bridge.js +8 -0
  80. package/dist/mcp-bridge.js.map +1 -0
  81. package/dist/model.d.ts +154 -0
  82. package/dist/model.js +9 -0
  83. package/dist/model.js.map +1 -0
  84. package/dist/policy.d.ts +130 -0
  85. package/dist/policy.js +23 -0
  86. package/dist/policy.js.map +1 -0
  87. package/dist/protocol.d.ts +170 -0
  88. package/dist/protocol.js +15 -0
  89. package/dist/protocol.js.map +1 -0
  90. package/dist/rollout-store-persistent.d.ts +48 -0
  91. package/dist/rollout-store-persistent.js +9 -0
  92. package/dist/rollout-store-persistent.js.map +1 -0
  93. package/dist/rollout.d.ts +82 -0
  94. package/dist/rollout.js +15 -0
  95. package/dist/rollout.js.map +1 -0
  96. package/dist/sandbox.d.ts +65 -0
  97. package/dist/sandbox.js +15 -0
  98. package/dist/sandbox.js.map +1 -0
  99. package/dist/skills.d.ts +93 -0
  100. package/dist/skills.js +10 -0
  101. package/dist/skills.js.map +1 -0
  102. package/dist/subagents.d.ts +129 -0
  103. package/dist/subagents.js +21 -0
  104. package/dist/subagents.js.map +1 -0
  105. package/dist/tools.d.ts +77 -0
  106. package/dist/tools.js +9 -0
  107. package/dist/tools.js.map +1 -0
  108. package/package.json +74 -0
  109. package/src/adapters/dispatcher-sse.test.ts +218 -0
  110. package/src/adapters/dispatcher-sse.ts +222 -0
  111. package/src/adapters/index.ts +18 -0
  112. package/src/adapters/mcp-catalog.test.ts +213 -0
  113. package/src/adapters/mcp-catalog.ts +188 -0
  114. package/src/adapters/prisma-rollout.test.ts +153 -0
  115. package/src/adapters/prisma-rollout.ts +104 -0
  116. package/src/agent-runtime.test.ts +176 -0
  117. package/src/artifact-stream.test.ts +330 -0
  118. package/src/artifact-stream.ts +453 -0
  119. package/src/channel-gateway.test.ts +432 -0
  120. package/src/channel-gateway.ts +357 -0
  121. package/src/code-review.test.ts +501 -0
  122. package/src/code-review.ts +495 -0
  123. package/src/command-suggestions.test.ts +251 -0
  124. package/src/command-suggestions.ts +338 -0
  125. package/src/commands.test.ts +184 -0
  126. package/src/commands.ts +140 -0
  127. package/src/commit-message.test.ts +249 -0
  128. package/src/commit-message.ts +180 -0
  129. package/src/context-compaction.test.ts +522 -0
  130. package/src/context-compaction.ts +434 -0
  131. package/src/definitions.test.ts +78 -0
  132. package/src/definitions.ts +190 -0
  133. package/src/deployment-status.test.ts +215 -0
  134. package/src/deployment-status.ts +227 -0
  135. package/src/design-context.test.ts +195 -0
  136. package/src/design-context.ts +198 -0
  137. package/src/dispatcher.test.ts +234 -0
  138. package/src/dispatcher.ts +189 -0
  139. package/src/durable-turn.test.ts +209 -0
  140. package/src/durable-turn.ts +135 -0
  141. package/src/edit-planner.test.ts +204 -0
  142. package/src/edit-planner.ts +325 -0
  143. package/src/fuzzy-match.test.ts +311 -0
  144. package/src/fuzzy-match.ts +444 -0
  145. package/src/hook-pipeline.test.ts +279 -0
  146. package/src/hook-pipeline.ts +373 -0
  147. package/src/inbound-admission.test.ts +394 -0
  148. package/src/inbound-admission.ts +246 -0
  149. package/src/index.ts +47 -0
  150. package/src/loop.test.ts +161 -0
  151. package/src/loop.ts +211 -0
  152. package/src/mcp-bridge.test.ts +165 -0
  153. package/src/mcp-bridge.ts +76 -0
  154. package/src/memory-provider.test.ts +232 -0
  155. package/src/memory-provider.ts +257 -0
  156. package/src/model.ts +168 -0
  157. package/src/permission-ruleset.test.ts +301 -0
  158. package/src/permission-ruleset.ts +200 -0
  159. package/src/policy.ts +151 -0
  160. package/src/project-repo.test.ts +232 -0
  161. package/src/project-repo.ts +311 -0
  162. package/src/protocol.ts +159 -0
  163. package/src/rollout-store-persistent.test.ts +217 -0
  164. package/src/rollout-store-persistent.ts +166 -0
  165. package/src/rollout.ts +150 -0
  166. package/src/sandbox.ts +113 -0
  167. package/src/session-share.test.ts +360 -0
  168. package/src/session-share.ts +310 -0
  169. package/src/skill-distillation.test.ts +177 -0
  170. package/src/skill-distillation.ts +369 -0
  171. package/src/skills.test.ts +277 -0
  172. package/src/skills.ts +255 -0
  173. package/src/subagents.test.ts +290 -0
  174. package/src/subagents.ts +332 -0
  175. package/src/tools.ts +126 -0
  176. package/src/workbench.test.ts +0 -0
  177. package/src/workbench.ts +0 -0
  178. package/tsconfig.json +12 -0
  179. package/tsup.config.ts +33 -0
@@ -0,0 +1,360 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ constantTimeEquals,
4
+ type IdMint,
5
+ InMemoryShareStore,
6
+ SessionShare,
7
+ ShareDisabledError,
8
+ type ShareRecord,
9
+ type ShareStore,
10
+ } from "./session-share.js";
11
+
12
+ /**
13
+ * Deterministic id/secret mint for tests. Real callers inject a CSPRNG-backed
14
+ * implementation; the module never generates entropy itself.
15
+ */
16
+ class SeqMint implements IdMint {
17
+ #i = 0;
18
+ #s = 0;
19
+ shareId(): string {
20
+ this.#i += 1;
21
+ return `share_${this.#i}`;
22
+ }
23
+ secret(): string {
24
+ this.#s += 1;
25
+ return `secret_${this.#s}`;
26
+ }
27
+ }
28
+
29
+ /** Records every emitted sync event for assertion. */
30
+ class RecordingSink {
31
+ readonly events: {
32
+ type: "share.created" | "share.revoked";
33
+ tenantId: string;
34
+ sessionId: string;
35
+ share: ShareRecord | null;
36
+ }[] = [];
37
+ async emit(event: {
38
+ type: "share.created" | "share.revoked";
39
+ tenantId: string;
40
+ sessionId: string;
41
+ share: ShareRecord | null;
42
+ }): Promise<void> {
43
+ this.events.push(event);
44
+ }
45
+ }
46
+
47
+ /** A sink whose remote propagation always fails. */
48
+ class FailingSink {
49
+ calls = 0;
50
+ async emit(): Promise<void> {
51
+ this.calls += 1;
52
+ throw new Error("remote sync unreachable");
53
+ }
54
+ }
55
+
56
+ const url = (shareId: string): string => `https://viewer.example/s/${shareId}`;
57
+
58
+ function make(opts?: {
59
+ store?: ShareStore;
60
+ sink?: { emit: (e: never) => Promise<void> } | RecordingSink | FailingSink;
61
+ disabled?: boolean;
62
+ }) {
63
+ const store = opts?.store ?? new InMemoryShareStore();
64
+ const sink = opts?.sink ?? new RecordingSink();
65
+ const mint = new SeqMint();
66
+ const subject = new SessionShare({
67
+ store,
68
+ mint,
69
+ urlBuilder: url,
70
+ sink: sink as { emit: (e: never) => Promise<void> },
71
+ ...(opts?.disabled === undefined ? {} : { disabled: opts.disabled }),
72
+ });
73
+ return { subject, store, sink, mint };
74
+ }
75
+
76
+ const T = "org_A";
77
+ const S = "session_1";
78
+
79
+ describe("SessionShare.share", () => {
80
+ it("mints id+secret, builds url, persists a read-only record, emits share.created", async () => {
81
+ const { subject, store, sink } = make();
82
+ const recSink = sink as RecordingSink;
83
+
84
+ const { url: shareUrl } = await subject.share(T, S);
85
+
86
+ expect(shareUrl).toBe("https://viewer.example/s/share_1");
87
+ const rec = await store.getBySession(T, S);
88
+ expect(rec).not.toBeNull();
89
+ expect(rec).toMatchObject({
90
+ id: "share_1",
91
+ sessionId: S,
92
+ tenantId: T,
93
+ url: "https://viewer.example/s/share_1",
94
+ secret: "secret_1",
95
+ readonly: true,
96
+ });
97
+ expect(typeof rec?.createdAt).toBe("string");
98
+ expect(rec?.revokedAt).toBeUndefined();
99
+ expect(recSink.events).toHaveLength(1);
100
+ expect(recSink.events[0]).toMatchObject({
101
+ type: "share.created",
102
+ tenantId: T,
103
+ sessionId: S,
104
+ });
105
+ expect(recSink.events[0]?.share?.id).toBe("share_1");
106
+ });
107
+
108
+ it("is idempotent: re-sharing returns the same url, mints no new secret, emits no duplicate", async () => {
109
+ const { subject, store, sink } = make();
110
+ const recSink = sink as RecordingSink;
111
+
112
+ const first = await subject.share(T, S);
113
+ const second = await subject.share(T, S);
114
+
115
+ expect(second.url).toBe(first.url);
116
+ const rec = await store.getBySession(T, S);
117
+ expect(rec?.secret).toBe("secret_1"); // not re-minted
118
+ expect(rec?.id).toBe("share_1");
119
+ expect(recSink.events.filter((e) => e.type === "share.created")).toHaveLength(1);
120
+ });
121
+
122
+ it("re-shares after a revoke (mints a fresh share)", async () => {
123
+ const { subject, store, sink } = make();
124
+ const recSink = sink as RecordingSink;
125
+
126
+ await subject.share(T, S);
127
+ await subject.unshare(T, S);
128
+ const again = await subject.share(T, S);
129
+
130
+ expect(again.url).toBe("https://viewer.example/s/share_2");
131
+ const rec = await store.getBySession(T, S);
132
+ expect(rec?.id).toBe("share_2");
133
+ expect(rec?.secret).toBe("secret_2");
134
+ expect(rec?.revokedAt).toBeUndefined();
135
+ expect(recSink.events.filter((e) => e.type === "share.created")).toHaveLength(2);
136
+ });
137
+
138
+ it("honors the disabled kill-switch and throws ShareDisabledError", async () => {
139
+ const { subject, store } = make({ disabled: true });
140
+ await expect(subject.share(T, S)).rejects.toBeInstanceOf(ShareDisabledError);
141
+ expect(await store.getBySession(T, S)).toBeNull();
142
+ });
143
+
144
+ it("fails closed on empty tenantId", async () => {
145
+ const { subject } = make();
146
+ await expect(subject.share("", S)).rejects.toThrow();
147
+ });
148
+
149
+ it("fails closed on empty sessionId", async () => {
150
+ const { subject } = make();
151
+ await expect(subject.share(T, "")).rejects.toThrow();
152
+ });
153
+
154
+ it("does not lose the persisted share when the sync sink fails", async () => {
155
+ const failing = new FailingSink();
156
+ const { subject, store } = make({ sink: failing });
157
+
158
+ const { url: shareUrl } = await subject.share(T, S);
159
+
160
+ expect(failing.calls).toBe(1);
161
+ const rec = await store.getBySession(T, S);
162
+ expect(rec).not.toBeNull();
163
+ expect(rec?.url).toBe(shareUrl);
164
+ expect(rec?.id).toBe("share_1");
165
+ });
166
+ });
167
+
168
+ describe("SessionShare.unshare", () => {
169
+ it("revokes, persists revokedAt, emits share.revoked with share:null", async () => {
170
+ const { subject, store, sink } = make();
171
+ const recSink = sink as RecordingSink;
172
+
173
+ await subject.share(T, S);
174
+ await subject.unshare(T, S);
175
+
176
+ const rec = await store.getBySession(T, S);
177
+ expect(rec?.revokedAt).toBeTypeOf("string");
178
+ const revoked = recSink.events.filter((e) => e.type === "share.revoked");
179
+ expect(revoked).toHaveLength(1);
180
+ expect(revoked[0]?.share).toBeNull();
181
+ expect(revoked[0]).toMatchObject({ tenantId: T, sessionId: S });
182
+ });
183
+
184
+ it("is idempotent: unshare with no existing share is a no-op (no emit)", async () => {
185
+ const { subject, sink } = make();
186
+ const recSink = sink as RecordingSink;
187
+ await subject.unshare(T, S);
188
+ expect(recSink.events).toHaveLength(0);
189
+ });
190
+
191
+ it("is idempotent: unshare twice does not double-emit", async () => {
192
+ const { subject, sink } = make();
193
+ const recSink = sink as RecordingSink;
194
+ await subject.share(T, S);
195
+ await subject.unshare(T, S);
196
+ await subject.unshare(T, S);
197
+ expect(recSink.events.filter((e) => e.type === "share.revoked")).toHaveLength(1);
198
+ });
199
+
200
+ it("fails closed on empty tenantId", async () => {
201
+ const { subject } = make();
202
+ await expect(subject.unshare("", S)).rejects.toThrow();
203
+ });
204
+
205
+ it("does not lose the persisted revoke when the sync sink fails", async () => {
206
+ const recording = new RecordingSink();
207
+ const { subject, store } = make({ sink: recording });
208
+ await subject.share(T, S);
209
+
210
+ // Swap to a failing sink for the unshare path.
211
+ const failing = new FailingSink();
212
+ const subject2 = new SessionShare({
213
+ store,
214
+ mint: new SeqMint(),
215
+ urlBuilder: url,
216
+ sink: failing as unknown as { emit: (e: never) => Promise<void> },
217
+ });
218
+ await subject2.unshare(T, S);
219
+
220
+ expect(failing.calls).toBe(1);
221
+ const rec = await store.getBySession(T, S);
222
+ expect(rec?.revokedAt).toBeTypeOf("string");
223
+ });
224
+ });
225
+
226
+ describe("SessionShare.verifyViewer (public, secret-gated, no tenant)", () => {
227
+ it("returns ok with the record for a valid id+secret", async () => {
228
+ const { subject, store } = make();
229
+ await subject.share(T, S);
230
+ const rec = await store.getBySession(T, S);
231
+
232
+ const result = await subject.verifyViewer(rec!.id, rec!.secret);
233
+ expect(result.ok).toBe(true);
234
+ if (result.ok) {
235
+ expect(result.record.id).toBe(rec!.id);
236
+ expect(result.record.readonly).toBe(true);
237
+ }
238
+ });
239
+
240
+ it("returns not-found for an unknown share id", async () => {
241
+ const { subject } = make();
242
+ const result = await subject.verifyViewer("share_nope", "secret_1");
243
+ expect(result).toEqual({ ok: false, reason: "not-found" });
244
+ });
245
+
246
+ it("returns revoked for a revoked share even with the correct secret", async () => {
247
+ const { subject, store } = make();
248
+ await subject.share(T, S);
249
+ const rec = await store.getBySession(T, S);
250
+ await subject.unshare(T, S);
251
+
252
+ const result = await subject.verifyViewer(rec!.id, rec!.secret);
253
+ expect(result).toEqual({ ok: false, reason: "revoked" });
254
+ });
255
+
256
+ it("returns bad-secret for a wrong secret of equal length (no early exit)", async () => {
257
+ const { subject, store } = make();
258
+ await subject.share(T, S);
259
+ const rec = await store.getBySession(T, S);
260
+
261
+ const wrongSameLen = "x".repeat(rec!.secret.length);
262
+ const result = await subject.verifyViewer(rec!.id, wrongSameLen);
263
+ expect(result).toEqual({ ok: false, reason: "bad-secret" });
264
+ });
265
+
266
+ it("returns bad-secret for a wrong secret of different length", async () => {
267
+ const { subject, store } = make();
268
+ await subject.share(T, S);
269
+ const rec = await store.getBySession(T, S);
270
+
271
+ const result = await subject.verifyViewer(rec!.id, `${rec!.secret}EXTRA`);
272
+ expect(result).toEqual({ ok: false, reason: "bad-secret" });
273
+ });
274
+
275
+ it("fails closed on empty shareId", async () => {
276
+ const { subject } = make();
277
+ await expect(subject.verifyViewer("", "secret_1")).rejects.toThrow();
278
+ });
279
+ });
280
+
281
+ describe("constantTimeEquals helper", () => {
282
+ it("returns true for identical strings", () => {
283
+ expect(constantTimeEquals("abcdef", "abcdef")).toBe(true);
284
+ });
285
+
286
+ it("returns false for equal-length differing strings", () => {
287
+ expect(constantTimeEquals("abcdef", "abcXef")).toBe(false);
288
+ });
289
+
290
+ it("returns false for different-length strings", () => {
291
+ expect(constantTimeEquals("abc", "abcdef")).toBe(false);
292
+ });
293
+
294
+ it("scans the full string (a late mismatch is still detected)", () => {
295
+ const a = `${"a".repeat(64)}X`;
296
+ const b = `${"a".repeat(64)}Y`;
297
+ expect(constantTimeEquals(a, b)).toBe(false);
298
+ });
299
+
300
+ it("an early mismatch does not short-circuit to a false positive", () => {
301
+ expect(constantTimeEquals("Xbcdef", "abcdef")).toBe(false);
302
+ expect(constantTimeEquals("", "")).toBe(true);
303
+ });
304
+ });
305
+
306
+ describe("InMemoryShareStore tenant isolation", () => {
307
+ it("getBySession is tenant-keyed: another tenant cannot see the share", async () => {
308
+ const store = new InMemoryShareStore();
309
+ const a = make({ store });
310
+ await a.subject.share("org_A", "shared_session");
311
+
312
+ expect(await store.getBySession("org_A", "shared_session")).not.toBeNull();
313
+ expect(await store.getBySession("org_B", "shared_session")).toBeNull();
314
+ });
315
+
316
+ it("getById is the public viewer path (tenant-agnostic, secret-gated)", async () => {
317
+ const store = new InMemoryShareStore();
318
+ const a = make({ store });
319
+ await a.subject.share("org_A", "s");
320
+ const rec = await store.getBySession("org_A", "s");
321
+
322
+ const byId = await store.getById(rec!.id);
323
+ expect(byId?.id).toBe(rec!.id);
324
+ expect(await store.getById("missing")).toBeNull();
325
+ });
326
+
327
+ it("fails closed on empty tenantId / sessionId in getBySession", async () => {
328
+ const store = new InMemoryShareStore();
329
+ await expect(store.getBySession("", "s")).rejects.toThrow();
330
+ await expect(store.getBySession("t", "")).rejects.toThrow();
331
+ });
332
+
333
+ it("fails closed on empty shareId in getById", async () => {
334
+ const store = new InMemoryShareStore();
335
+ await expect(store.getById("")).rejects.toThrow();
336
+ });
337
+ });
338
+
339
+ describe("immutability", () => {
340
+ it("does not mutate a record handed back from the store across operations", async () => {
341
+ const { subject, store } = make();
342
+ await subject.share(T, S);
343
+ const snapshot = await store.getBySession(T, S);
344
+ const frozenCopy = { ...snapshot } as ShareRecord;
345
+
346
+ await subject.unshare(T, S);
347
+
348
+ // The previously-read snapshot object must not have been mutated in place.
349
+ expect(snapshot).toEqual(frozenCopy);
350
+ expect(snapshot?.revokedAt).toBeUndefined();
351
+ });
352
+
353
+ it("returned share result is a fresh object, not the stored record", async () => {
354
+ const { subject, store } = make();
355
+ const result = await subject.share(T, S);
356
+ const rec = await store.getBySession(T, S);
357
+ expect(result).not.toBe(rec);
358
+ expect(Object.keys(result)).toEqual(["url"]);
359
+ });
360
+ });
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Shareable-session model — a faithful re-expression of a coding agent's
3
+ * "share this session with a viewer" subsystem into Sailor's grammar:
4
+ * TypeScript, multi-tenant, no datastore lock-in, no transport/crypto vendor
5
+ * lock-in.
6
+ *
7
+ * ── Mental model ────────────────────────────────────────────────────────────
8
+ * The session OWNER keeps editing through the normal session API. Sharing only
9
+ * grants a *read-only viewer* an observation window. A {@link ShareRecord} is
10
+ * therefore always `readonly: true` — there is no writable share.
11
+ *
12
+ * ── Two access planes ───────────────────────────────────────────────────────
13
+ * 1. OWNER plane ({@link SessionShare.share} / {@link SessionShare.unshare}):
14
+ * tenant-scoped. `tenantId` is mandatory, Zod-validated, and fails closed
15
+ * on empty input. Cross-tenant reads are impossible by construction because
16
+ * {@link ShareStore.getBySession} is tenant-keyed.
17
+ *
18
+ * 2. VIEWER plane ({@link SessionShare.verifyViewer}): PUBLIC. The viewer does
19
+ * NOT have a tenantId — they only hold a link containing the share id plus
20
+ * an unguessable secret. Security rests ENTIRELY on the secret being
21
+ * high-entropy and compared in (length-independent) constant time. This is
22
+ * why {@link ShareStore.getById} is tenant-agnostic: it is the public
23
+ * lookup, gated by the secret rather than by tenancy. The secret never
24
+ * reaches the owner caller path (it travels via the record + sync sink to
25
+ * wherever the viewer URL is rendered).
26
+ *
27
+ * ── Threat model ────────────────────────────────────────────────────────────
28
+ * • A viewer link leaking == full read access to that one session until the
29
+ * owner revokes. Mitigation: revoke is immediate and `verifyViewer` denies
30
+ * revoked shares; the secret must be CSPRNG-grade (enforced by the injected
31
+ * {@link IdMint}, NOT by this module — the module never generates entropy).
32
+ * • Secret guessing / timing oracle. Mitigation: {@link constantTimeEquals}
33
+ * never early-returns on the first mismatched byte and folds length
34
+ * differences into the same negative result.
35
+ *
36
+ * ── Sink-failure policy (DOCUMENTED CHOICE) ─────────────────────────────────
37
+ * Local persistence is the source of truth. `share`/`unshare` persist to the
38
+ * {@link ShareStore} FIRST, then best-effort emit to the {@link SyncSink}. If
39
+ * `sink.emit` rejects, the local store change STILL STANDS and the sink error
40
+ * is SWALLOWED (not rethrown) so a flaky remote can never roll back or hide a
41
+ * share/revoke the owner already committed. The owner call resolves normally;
42
+ * sync convergence is the remote's eventual-consistency concern, not the
43
+ * caller's. (Tested: a failing sink does not lose the persisted share/revoke.)
44
+ */
45
+
46
+ import { z } from "zod";
47
+
48
+ /** A share is always read-only for viewers; the owner edits via the session. */
49
+ export interface ShareRecord {
50
+ readonly id: string;
51
+ readonly sessionId: string;
52
+ readonly tenantId: string;
53
+ readonly url: string;
54
+ readonly secret: string;
55
+ readonly readonly: true;
56
+ readonly createdAt: string;
57
+ readonly revokedAt?: string | undefined;
58
+ }
59
+
60
+ /** Emitted to the sync sink whenever share state changes. */
61
+ export interface ShareSyncEvent {
62
+ readonly type: "share.created" | "share.revoked";
63
+ readonly tenantId: string;
64
+ readonly sessionId: string;
65
+ readonly share: ShareRecord | null;
66
+ }
67
+
68
+ /**
69
+ * Caller-injected id/secret source. Tests pass a deterministic stub. Real
70
+ * callers MUST back `secret()` with a CSPRNG (≥128 bits) — this module
71
+ * consumes the port and never implements crypto itself.
72
+ */
73
+ export interface IdMint {
74
+ shareId(): string;
75
+ secret(): string;
76
+ }
77
+
78
+ /** Derives the public viewer URL from a share id. */
79
+ export type UrlBuilder = (shareId: string) => string;
80
+
81
+ /**
82
+ * Best-effort propagation seam for share state changes. The real impl pushes to
83
+ * a remote; tests use a recorder. A rejecting `emit` MUST NOT corrupt local
84
+ * state — see the module-level sink-failure policy.
85
+ */
86
+ export interface SyncSink {
87
+ emit(event: ShareSyncEvent): Promise<void>;
88
+ }
89
+
90
+ /**
91
+ * Storage seam. `getBySession` is tenant-keyed and tenant-validated (owner
92
+ * plane). `getById` is the PUBLIC viewer lookup: the viewer has no tenantId, so
93
+ * it is tenant-agnostic and secret-gated by the caller, never tenant-gated.
94
+ */
95
+ export interface ShareStore {
96
+ put(rec: ShareRecord): Promise<void>;
97
+ getBySession(tenantId: string, sessionId: string): Promise<ShareRecord | null>;
98
+ getById(shareId: string): Promise<ShareRecord | null>;
99
+ }
100
+
101
+ /** Raised when sharing is globally disabled by the kill-switch. */
102
+ export class ShareDisabledError extends Error {
103
+ constructor(message = "session sharing is disabled") {
104
+ super(message);
105
+ this.name = "ShareDisabledError";
106
+ }
107
+ }
108
+
109
+ const tenantIdSchema = z.string().min(1, "tenantId is required");
110
+ const sessionIdSchema = z.string().min(1, "sessionId is required");
111
+ const shareIdSchema = z.string().min(1, "shareId is required");
112
+
113
+ const ownerKeySchema = z.object({
114
+ tenantId: tenantIdSchema,
115
+ sessionId: sessionIdSchema,
116
+ });
117
+
118
+ /**
119
+ * Length-independent, full-scan equality. Never short-circuits on the first
120
+ * differing character; a length mismatch is folded into the same negative
121
+ * result by comparing against a fixed-length view so the loop count does not
122
+ * leak the secret length. Pure and unit-tested in isolation.
123
+ */
124
+ export function constantTimeEquals(a: string, b: string): boolean {
125
+ const len = Math.max(a.length, b.length);
126
+ let diff = a.length ^ b.length;
127
+ for (let i = 0; i < len; i += 1) {
128
+ const ca = i < a.length ? a.charCodeAt(i) : 0;
129
+ const cb = i < b.length ? b.charCodeAt(i) : 0;
130
+ diff |= ca ^ cb;
131
+ }
132
+ return diff === 0;
133
+ }
134
+
135
+ /** Deep-frozen defensive copy so stored records can never be mutated in place. */
136
+ function freezeRecord(rec: ShareRecord): ShareRecord {
137
+ return Object.freeze({ ...rec });
138
+ }
139
+
140
+ /**
141
+ * In-memory reference {@link ShareStore}. Mirrors the package's
142
+ * store/port convention (interface + in-memory ref impl, like
143
+ * `InMemoryRolloutStore`). Production swaps in a Postgres / `@nebutra/db`
144
+ * adapter satisfying the same interface — no infra change.
145
+ *
146
+ * Tenancy is structural: the by-session key embeds `tenantId`, so a different
147
+ * tenant resolves to a different key and gets `null`. A by-id index serves the
148
+ * public viewer path.
149
+ */
150
+ export class InMemoryShareStore implements ShareStore {
151
+ readonly #bySession = new Map<string, ShareRecord>();
152
+ readonly #byId = new Map<string, ShareRecord>();
153
+
154
+ #key(tenantId: string, sessionId: string): string {
155
+ return `${tenantId}::${sessionId}`;
156
+ }
157
+
158
+ async put(rec: ShareRecord): Promise<void> {
159
+ const validated = ownerKeySchema.parse({
160
+ tenantId: rec.tenantId,
161
+ sessionId: rec.sessionId,
162
+ });
163
+ const stored = freezeRecord(rec);
164
+ this.#bySession.set(this.#key(validated.tenantId, validated.sessionId), stored);
165
+ this.#byId.set(stored.id, stored);
166
+ }
167
+
168
+ async getBySession(tenantId: string, sessionId: string): Promise<ShareRecord | null> {
169
+ const validated = ownerKeySchema.parse({ tenantId, sessionId });
170
+ return this.#bySession.get(this.#key(validated.tenantId, validated.sessionId)) ?? null;
171
+ }
172
+
173
+ async getById(shareId: string): Promise<ShareRecord | null> {
174
+ const id = shareIdSchema.parse(shareId);
175
+ return this.#byId.get(id) ?? null;
176
+ }
177
+ }
178
+
179
+ /** Result of a public viewer access attempt. */
180
+ export type ViewerVerdict =
181
+ | { readonly ok: true; readonly record: ShareRecord }
182
+ | { readonly ok: false; readonly reason: "not-found" | "revoked" | "bad-secret" };
183
+
184
+ export interface SessionShareOptions {
185
+ readonly store: ShareStore;
186
+ readonly mint: IdMint;
187
+ readonly urlBuilder: UrlBuilder;
188
+ readonly sink: SyncSink;
189
+ /** Kill-switch: when true, every `share()` throws {@link ShareDisabledError}. */
190
+ readonly disabled?: boolean | undefined;
191
+ }
192
+
193
+ /** Tenant-scoped owner API + public viewer verification. */
194
+ export class SessionShare {
195
+ readonly #store: ShareStore;
196
+ readonly #mint: IdMint;
197
+ readonly #urlBuilder: UrlBuilder;
198
+ readonly #sink: SyncSink;
199
+ readonly #disabled: boolean;
200
+
201
+ constructor(opts: SessionShareOptions) {
202
+ this.#store = opts.store;
203
+ this.#mint = opts.mint;
204
+ this.#urlBuilder = opts.urlBuilder;
205
+ this.#sink = opts.sink;
206
+ this.#disabled = opts.disabled ?? false;
207
+ }
208
+
209
+ /**
210
+ * Best-effort sync emit. Local state is already persisted before this runs;
211
+ * a rejecting sink is swallowed so the committed share/revoke is never lost
212
+ * or rolled back (see module-level sink-failure policy).
213
+ */
214
+ async #emitBestEffort(event: ShareSyncEvent): Promise<void> {
215
+ try {
216
+ await this.#sink.emit(event);
217
+ } catch {
218
+ // Intentionally swallowed: persistence is the source of truth.
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Share a session read-only. Fails closed on empty tenant/session. Honors the
224
+ * kill-switch. Idempotent: if a live (non-revoked) share exists, returns its
225
+ * url WITHOUT minting a new secret or emitting a duplicate `share.created`.
226
+ * Returns only the url — the secret never reaches the owner caller path.
227
+ */
228
+ async share(tenantId: string, sessionId: string): Promise<{ url: string }> {
229
+ const key = ownerKeySchema.parse({ tenantId, sessionId });
230
+ if (this.#disabled) {
231
+ throw new ShareDisabledError();
232
+ }
233
+
234
+ const existing = await this.#store.getBySession(key.tenantId, key.sessionId);
235
+ if (existing && existing.revokedAt === undefined) {
236
+ return { url: existing.url };
237
+ }
238
+
239
+ const id = this.#mint.shareId();
240
+ const record: ShareRecord = {
241
+ id,
242
+ sessionId: key.sessionId,
243
+ tenantId: key.tenantId,
244
+ url: this.#urlBuilder(id),
245
+ secret: this.#mint.secret(),
246
+ readonly: true,
247
+ createdAt: new Date().toISOString(),
248
+ };
249
+
250
+ await this.#store.put(record);
251
+ await this.#emitBestEffort({
252
+ type: "share.created",
253
+ tenantId: key.tenantId,
254
+ sessionId: key.sessionId,
255
+ share: record,
256
+ });
257
+
258
+ return { url: record.url };
259
+ }
260
+
261
+ /**
262
+ * Revoke a session's share. Fails closed on empty tenant/session. Idempotent:
263
+ * a no-op (no emit) when no live share exists. On revoke, persists `revokedAt`
264
+ * and emits `share.revoked` with `share: null`.
265
+ */
266
+ async unshare(tenantId: string, sessionId: string): Promise<void> {
267
+ const key = ownerKeySchema.parse({ tenantId, sessionId });
268
+
269
+ const existing = await this.#store.getBySession(key.tenantId, key.sessionId);
270
+ if (!existing || existing.revokedAt !== undefined) {
271
+ return;
272
+ }
273
+
274
+ const revoked: ShareRecord = {
275
+ ...existing,
276
+ revokedAt: new Date().toISOString(),
277
+ };
278
+
279
+ await this.#store.put(revoked);
280
+ await this.#emitBestEffort({
281
+ type: "share.revoked",
282
+ tenantId: key.tenantId,
283
+ sessionId: key.sessionId,
284
+ share: null,
285
+ });
286
+ }
287
+
288
+ /**
289
+ * PUBLIC viewer access. NO tenantId — the viewer has none; security rests
290
+ * entirely on the unguessable secret, compared in length-independent
291
+ * constant time. Unknown id → not-found; revoked share → revoked (even with
292
+ * the right secret); wrong secret → bad-secret. Fails closed on empty id.
293
+ */
294
+ async verifyViewer(shareId: string, presentedSecret: string): Promise<ViewerVerdict> {
295
+ const id = shareIdSchema.parse(shareId);
296
+ const secret = z.string().parse(presentedSecret);
297
+
298
+ const record = await this.#store.getById(id);
299
+ if (!record) {
300
+ return { ok: false, reason: "not-found" };
301
+ }
302
+ if (record.revokedAt !== undefined) {
303
+ return { ok: false, reason: "revoked" };
304
+ }
305
+ if (!constantTimeEquals(record.secret, secret)) {
306
+ return { ok: false, reason: "bad-secret" };
307
+ }
308
+ return { ok: true, record };
309
+ }
310
+ }