@openparachute/agent 0.2.2 → 0.2.3-rc.11

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 (74) hide show
  1. package/.parachute/module.json +3 -3
  2. package/package.json +4 -1
  3. package/src/agent-defs.ts +9 -0
  4. package/src/auth.ts +182 -14
  5. package/src/backends/programmatic.ts +35 -2
  6. package/src/backends/registry.ts +159 -40
  7. package/src/backends/types.ts +44 -0
  8. package/src/daemon.ts +317 -12
  9. package/src/def-vault-triggers.ts +317 -0
  10. package/src/preflight.ts +139 -0
  11. package/src/spawn-agent.ts +16 -0
  12. package/src/step-up.ts +316 -0
  13. package/src/terminal-ui.ts +73 -0
  14. package/src/transports/http-ui.ts +10 -8
  15. package/src/transports/vault.ts +48 -27
  16. package/src/ui-kit.ts +6 -3
  17. package/src/ui-ticket.ts +121 -0
  18. package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
  19. package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
  20. package/web/ui/dist/index.html +2 -2
  21. package/src/_parked/interactive-spawn.test.ts +0 -324
  22. package/src/_parked/interactive-spawn.ts +0 -701
  23. package/src/agent-defs.test.ts +0 -1504
  24. package/src/agent-mcp-config.test.ts +0 -115
  25. package/src/agents.test.ts +0 -360
  26. package/src/auth.test.ts +0 -46
  27. package/src/backends/attached-queue.test.ts +0 -376
  28. package/src/backends/programmatic.test.ts +0 -1715
  29. package/src/backends/registry.test.ts +0 -1494
  30. package/src/backends/stream-json.test.ts +0 -570
  31. package/src/channel-backend-wiring.test.ts +0 -237
  32. package/src/credentials.test.ts +0 -274
  33. package/src/cron.test.ts +0 -342
  34. package/src/daemon-agent-def-api.test.ts +0 -166
  35. package/src/daemon-agent-defs-api.test.ts +0 -953
  36. package/src/daemon-agent-env-api.test.ts +0 -338
  37. package/src/daemon-attached-queue-store.test.ts +0 -65
  38. package/src/daemon-config-api.test.ts +0 -962
  39. package/src/daemon-jobs-api.test.ts +0 -271
  40. package/src/daemon-vault-chat.test.ts +0 -250
  41. package/src/daemon.test.ts +0 -746
  42. package/src/def-vaults.test.ts +0 -136
  43. package/src/delivery-state.test.ts +0 -110
  44. package/src/effective-env.test.ts +0 -114
  45. package/src/grants.test.ts +0 -638
  46. package/src/hub-jwt.test.ts +0 -161
  47. package/src/jobs.test.ts +0 -245
  48. package/src/mcp-http.test.ts +0 -265
  49. package/src/mint-token.test.ts +0 -152
  50. package/src/module-manifest.test.ts +0 -158
  51. package/src/programmatic-wiring.test.ts +0 -838
  52. package/src/registry.test.ts +0 -227
  53. package/src/resolve-port.test.ts +0 -64
  54. package/src/routing.test.ts +0 -184
  55. package/src/runner.test.ts +0 -506
  56. package/src/sandbox/config.test.ts +0 -150
  57. package/src/sandbox/egress.test.ts +0 -113
  58. package/src/sandbox/live-seatbelt.test.ts +0 -277
  59. package/src/sandbox/mounts.test.ts +0 -154
  60. package/src/sandbox/sandbox.test.ts +0 -168
  61. package/src/services-manifest.test.ts +0 -106
  62. package/src/spa-serve.test.ts +0 -116
  63. package/src/spawn-agent-cli.test.ts +0 -172
  64. package/src/spawn-agent.test.ts +0 -1218
  65. package/src/spawn-deps.test.ts +0 -54
  66. package/src/terminal-assets.test.ts +0 -50
  67. package/src/terminal.test.ts +0 -530
  68. package/src/transports/http-ui.test.ts +0 -455
  69. package/src/transports/telegram.test.ts +0 -174
  70. package/src/transports/vault.test.ts +0 -2011
  71. package/src/ui-kit.test.ts +0 -178
  72. package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
  73. package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
  74. package/web/ui/tsconfig.json +0 -21
@@ -1,455 +0,0 @@
1
- /**
2
- * Tier 1 unit + integration tests for the http-ui transport.
3
- *
4
- * These exercise the transport (and a daemon-shaped Bun.serve harness) WITHOUT a
5
- * live Claude session. They cover:
6
- * - inbound routing: a UI `send` reaches the bridge subscribed to that channel;
7
- * - outbound to UI: `transport.reply()` pushes a `reply` event to a connected
8
- * /ui/events SSE client;
9
- * - round-trip through the daemon HTTP server (UI send → bridge, bridge reply
10
- * → UI) with no Claude;
11
- * - channel isolation (a send on A never reaches a UI client on B);
12
- * - registry: an http-ui channel instantiates without a token;
13
- * - reply() with no connected UI client does not throw.
14
- */
15
-
16
- import { describe, test, expect, mock } from "bun:test";
17
-
18
- // Layer 2 gates the http-ui send + SSE routes on `requireScope`, which validates
19
- // a hub JWT against the hub's JWKS. The no-token path short-circuits to 401
20
- // before any JWKS fetch (asserted below). To exercise the *delivery* paths
21
- // (routing, SSE fan-out) without a live hub, stub the JWT validator so a single
22
- // sentinel token validates with the agent scopes. A request with no token (or
23
- // any other token) still hits the real no-token / shape-first reject. This keeps
24
- // the round-trip coverage genuine while staying hub-free.
25
- const VALID_TOKEN = "test-valid-token";
26
- // A token carrying ONLY agent:write (a session/bridge token) — must be
27
- // REJECTED on the UI send endpoint, which requires agent:send. Locks the
28
- // privilege separation (a session token can't post as a human).
29
- const WRITE_ONLY_TOKEN = "test-write-only-token";
30
- mock.module("../hub-jwt.ts", () => ({
31
- // New tokens carry aud "agent" (channel→agent rename); CHANNEL_AUDIENCE stays a
32
- // deprecated alias. ACCEPTED_AUDIENCES is the dual-accept set the real adapter
33
- // checks — included so the mock matches the renamed module surface.
34
- AGENT_AUDIENCE: "agent",
35
- CHANNEL_AUDIENCE: "channel",
36
- ACCEPTED_AUDIENCES: ["agent", "channel"],
37
- async validateHubJwt(token: string) {
38
- if (token === VALID_TOKEN) {
39
- return {
40
- sub: "test",
41
- scopes: ["agent:read", "agent:send", "agent:write"],
42
- aud: "agent",
43
- jti: undefined,
44
- clientId: undefined,
45
- vaultScope: undefined,
46
- };
47
- }
48
- if (token === WRITE_ONLY_TOKEN) {
49
- return {
50
- sub: "test",
51
- scopes: ["agent:write"],
52
- aud: "agent",
53
- jti: undefined,
54
- clientId: undefined,
55
- vaultScope: undefined,
56
- };
57
- }
58
- throw new HubJwtError("invalid token");
59
- },
60
- HubJwtError: class HubJwtError extends Error {},
61
- looksLikeJwt: (t: string) => t.split(".").length === 3,
62
- resetJwksCache() {},
63
- resetRevocationCache() {},
64
- }));
65
- class HubJwtError extends Error {}
66
-
67
- import { HttpUiTransport } from "./http-ui.ts";
68
- import type { TransportContext, InboundMessage } from "../transport.ts";
69
- import { ClientRegistry } from "../routing.ts";
70
- import { instantiateTransport } from "../registry.ts";
71
-
72
- /** Authorization header carrying the sentinel valid token. */
73
- const AUTH = { authorization: "Bearer " + VALID_TOKEN } as const;
74
- /** Append the sentinel token as a `?token=` query param (the SSE auth path). */
75
- function withToken(path: string): string {
76
- return path + (path.includes("?") ? "&" : "?") + "token=" + encodeURIComponent(VALID_TOKEN);
77
- }
78
-
79
- /** A test context that records emitted inbound messages + permission verdicts. */
80
- function fakeCtx(channel: string): TransportContext & {
81
- emitted: InboundMessage[];
82
- verdicts: { request_id: string; behavior: string }[];
83
- } {
84
- const emitted: InboundMessage[] = [];
85
- const verdicts: { request_id: string; behavior: string }[] = [];
86
- return {
87
- channel,
88
- emitted,
89
- verdicts,
90
- emit(msg) {
91
- emitted.push(msg);
92
- },
93
- emitPermissionVerdict(v) {
94
- verdicts.push(v);
95
- },
96
- };
97
- }
98
-
99
- /**
100
- * Read the next non-comment SSE frame from a reader. Handles both the bytes a
101
- * `fetch` response body yields and the raw string chunks a directly-read
102
- * in-process ReadableStream<string> yields (the transport enqueues strings).
103
- */
104
- async function readFrame(
105
- reader: ReadableStreamDefaultReader<Uint8Array | string>,
106
- decoder = new TextDecoder(),
107
- ): Promise<string> {
108
- while (true) {
109
- const { value, done } = await reader.read();
110
- if (done) return "";
111
- const chunk = typeof value === "string" ? value : decoder.decode(value, { stream: true });
112
- if (chunk.includes("event:")) return chunk;
113
- }
114
- }
115
-
116
- describe("HttpUiTransport — direct", () => {
117
- test("ingestHttp send (authed) → ctx.emit on its own channel", async () => {
118
- const t = new HttpUiTransport();
119
- const ctx = fakeCtx("dev");
120
- await t.start(ctx);
121
-
122
- const req = new Request("http://x/api/channels/dev/send", {
123
- method: "POST",
124
- headers: { "content-type": "application/json", ...AUTH },
125
- body: JSON.stringify({ text: "hello session" }),
126
- });
127
- const res = await t.ingestHttp(req, new URL(req.url));
128
- expect(res).not.toBeNull();
129
- expect(res!.status).toBe(200);
130
- expect(await res!.json()).toEqual({ ok: true });
131
-
132
- expect(ctx.emitted).toHaveLength(1);
133
- expect(ctx.emitted[0]!.content).toBe("hello session");
134
- expect(ctx.emitted[0]!.channel).toBe("dev");
135
- expect(ctx.emitted[0]!.source).toBe("http-ui");
136
- });
137
-
138
- test("ingestHttp send WITHOUT a token → 401, no emit (Layer 2)", async () => {
139
- const t = new HttpUiTransport();
140
- const ctx = fakeCtx("dev");
141
- await t.start(ctx);
142
- const req = new Request("http://x/api/channels/dev/send", {
143
- method: "POST",
144
- headers: { "content-type": "application/json" },
145
- body: JSON.stringify({ text: "no token" }),
146
- });
147
- const res = await t.ingestHttp(req, new URL(req.url));
148
- expect(res).not.toBeNull();
149
- expect(res!.status).toBe(401);
150
- expect(ctx.emitted).toHaveLength(0);
151
- });
152
-
153
- test("ingestHttp send with an agent:write-only (session) token → 403, no emit", async () => {
154
- // Privilege separation: a session/bridge token (agent:write) must NOT be
155
- // usable to post a human message through the UI send endpoint (agent:send).
156
- const t = new HttpUiTransport();
157
- const ctx = fakeCtx("dev");
158
- await t.start(ctx);
159
- const req = new Request("http://x/api/channels/dev/send", {
160
- method: "POST",
161
- headers: { "content-type": "application/json", authorization: "Bearer " + WRITE_ONLY_TOKEN },
162
- body: JSON.stringify({ text: "trying to send as a session" }),
163
- });
164
- const res = await t.ingestHttp(req, new URL(req.url));
165
- expect(res).not.toBeNull();
166
- expect(res!.status).toBe(403);
167
- expect(ctx.emitted).toHaveLength(0);
168
- });
169
-
170
- test("ingestHttp SSE WITHOUT a ?token= → 401 (Layer 2)", async () => {
171
- const t = new HttpUiTransport();
172
- await t.start(fakeCtx("dev"));
173
- const req = new Request("http://x/ui/events?channel=dev");
174
- const res = await t.ingestHttp(req, new URL(req.url));
175
- expect(res).not.toBeNull();
176
- expect(res!.status).toBe(401);
177
- expect(res!.headers.get("content-type")).toContain("application/json");
178
- });
179
-
180
- test("ingestHttp ignores a send for a DIFFERENT channel's path", async () => {
181
- const t = new HttpUiTransport();
182
- await t.start(fakeCtx("dev"));
183
- const req = new Request("http://x/api/channels/other/send", {
184
- method: "POST",
185
- body: JSON.stringify({ text: "nope" }),
186
- });
187
- const res = await t.ingestHttp(req, new URL(req.url));
188
- expect(res).toBeNull();
189
- });
190
-
191
- test("send (authed) with empty/missing text → 400, no emit", async () => {
192
- const t = new HttpUiTransport();
193
- const ctx = fakeCtx("dev");
194
- await t.start(ctx);
195
- const req = new Request("http://x/api/channels/dev/send", {
196
- method: "POST",
197
- headers: { ...AUTH },
198
- body: JSON.stringify({ text: "" }),
199
- });
200
- const res = await t.ingestHttp(req, new URL(req.url));
201
- expect(res!.status).toBe(400);
202
- expect(ctx.emitted).toHaveLength(0);
203
- });
204
-
205
- test("reply() with no connected UI client does not throw and returns sent:[]", async () => {
206
- const t = new HttpUiTransport();
207
- await t.start(fakeCtx("dev"));
208
- const result = await t.reply({ channel: "dev", text: "ping" });
209
- expect(result.sent).toEqual([]);
210
- });
211
-
212
- test("reply() pushes a `reply` event to a connected /ui/events SSE client", async () => {
213
- const t = new HttpUiTransport();
214
- await t.start(fakeCtx("dev"));
215
-
216
- // Open the UI SSE stream via ingestHttp (authed via ?token=).
217
- const sseReq = new Request("http://x" + withToken("/ui/events?channel=dev"));
218
- const sseRes = await t.ingestHttp(sseReq, new URL(sseReq.url));
219
- expect(sseRes).not.toBeNull();
220
- const reader = sseRes!.body!.getReader();
221
-
222
- // Drain the ": connected" comment, then reply.
223
- const result = await t.reply({ channel: "dev", text: "from session", files: ["/tmp/a.png"] });
224
- expect(result.sent).toHaveLength(1);
225
-
226
- const frame = await readFrame(reader);
227
- expect(frame).toContain("event: reply");
228
- expect(frame).toContain("from session");
229
- expect(frame).toContain("/tmp/a.png");
230
- reader.cancel().catch(() => {});
231
- });
232
-
233
- test("stop() clears UI clients", async () => {
234
- const t = new HttpUiTransport();
235
- await t.start(fakeCtx("dev"));
236
- const sseReq = new Request("http://x" + withToken("/ui/events?channel=dev"));
237
- const sseRes = await t.ingestHttp(sseReq, new URL(sseReq.url));
238
- sseRes!.body!.getReader();
239
- await t.stop();
240
- // After stop, a reply reaches nobody.
241
- const result = await t.reply({ channel: "dev", text: "x" });
242
- expect(result.sent).toEqual([]);
243
- });
244
- });
245
-
246
- describe("registry — http-ui", () => {
247
- test("an http-ui channel instantiates without a token", () => {
248
- const transport = instantiateTransport({ name: "dev", transport: "http-ui" });
249
- expect(transport.kind).toBe("http-ui");
250
- });
251
- });
252
-
253
- // ---------------------------------------------------------------------------
254
- // Daemon-shaped integration: a Bun.serve harness wiring routing + ingestHttp,
255
- // mirroring daemon.ts. No Claude.
256
- // ---------------------------------------------------------------------------
257
-
258
- describe("HttpUiTransport — through a daemon-shaped server", () => {
259
- /** Build a minimal daemon-shaped server over the given channels. */
260
- function buildServer(channelDefs: { name: string }[]) {
261
- const registry = new ClientRegistry();
262
- const channels = new Map<string, { name: string; transport: HttpUiTransport }>();
263
- for (const def of channelDefs) {
264
- const transport = new HttpUiTransport();
265
- channels.set(def.name, { name: def.name, transport });
266
- }
267
-
268
- // Start each transport with a ctx that routes into the bridge registry,
269
- // exactly like daemon.ts's contextFor.
270
- for (const ch of channels.values()) {
271
- const name = ch.name;
272
- const ctx: TransportContext = {
273
- channel: name,
274
- emit(msg) {
275
- registry.routeToChannel(name, "message", {
276
- content: msg.content,
277
- meta: msg.meta,
278
- source: msg.source,
279
- });
280
- },
281
- emitPermissionVerdict(v) {
282
- registry.routeToChannel(name, "permission_verdict", v);
283
- },
284
- };
285
- ch.transport.start(ctx);
286
- }
287
-
288
- const server = Bun.serve({
289
- port: 0,
290
- hostname: "127.0.0.1",
291
- idleTimeout: 0,
292
- async fetch(req) {
293
- const url = new URL(req.url);
294
-
295
- // Bridge SSE subscription (mirrors daemon /events).
296
- if (req.method === "GET" && url.pathname === "/events") {
297
- const channel = url.searchParams.get("channel") ?? "default";
298
- const id = crypto.randomUUID();
299
- const stream = new ReadableStream<string>({
300
- start(controller) {
301
- registry.add(id, { channel, enqueue: (p) => controller.enqueue(p) });
302
- controller.enqueue(": connected\n\n");
303
- },
304
- cancel() {
305
- registry.remove(id);
306
- },
307
- });
308
- return new Response(stream, { headers: { "content-type": "text/event-stream" } });
309
- }
310
-
311
- // Bridge reply (mirrors daemon /api/reply dispatch).
312
- if (req.method === "POST" && url.pathname === "/api/reply") {
313
- const body = (await req.json()) as { channel: string; text?: string };
314
- const ch = channels.get(body.channel);
315
- if (!ch) return new Response(JSON.stringify({ error: "unknown channel" }), { status: 400 });
316
- const r = await ch.transport.reply({ channel: body.channel, text: body.text });
317
- return new Response(JSON.stringify({ sent: r.sent }), {
318
- headers: { "content-type": "application/json" },
319
- });
320
- }
321
-
322
- // Transport-owned routes (send + /ui/events).
323
- for (const ch of channels.values()) {
324
- const res = await ch.transport.ingestHttp(req, url);
325
- if (res) return res;
326
- }
327
- return new Response(JSON.stringify({ error: "not found" }), { status: 404 });
328
- },
329
- });
330
-
331
- return { server, base: `http://127.0.0.1:${server.port}`, registry };
332
- }
333
-
334
- /** Open an SSE stream and return a reader + helpers. The http-ui `/ui/events`
335
- * route is Layer-2-gated, so append the sentinel `?token=` for those; the
336
- * bridge `/events` route in this harness is ungated (pass through as-is). */
337
- async function openSse(base: string, path: string) {
338
- const url = path.startsWith("/ui/events") ? withToken(path) : path;
339
- const res = await fetch(`${base}${url}`);
340
- const reader = res.body!.getReader();
341
- return {
342
- read: () => readFrame(reader),
343
- cancel: () => reader.cancel().catch(() => {}),
344
- };
345
- }
346
-
347
- test("inbound routing: UI send reaches the subscribed bridge", async () => {
348
- const { server, base, registry } = buildServer([{ name: "dev" }]);
349
- try {
350
- // A bridge subscribes to channel "dev".
351
- const bridge = await openSse(base, "/events?channel=dev");
352
- // Wait for registration.
353
- const start = Date.now();
354
- while (registry.size < 1 && Date.now() - start < 1000) {
355
- await new Promise((r) => setTimeout(r, 5));
356
- }
357
- expect(registry.size).toBe(1);
358
-
359
- // UI POSTs a send (authed — Layer 2).
360
- const res = await fetch(`${base}/api/channels/dev/send`, {
361
- method: "POST",
362
- headers: { "content-type": "application/json", ...AUTH },
363
- body: JSON.stringify({ text: "hi from UI" }),
364
- });
365
- expect(res.status).toBe(200);
366
- expect(await res.json()).toEqual({ ok: true });
367
-
368
- const frame = await bridge.read();
369
- expect(frame).toContain("event: message");
370
- expect(frame).toContain("hi from UI");
371
- bridge.cancel();
372
- } finally {
373
- server.stop(true);
374
- }
375
- });
376
-
377
- test("round-trip: UI send → bridge AND bridge /api/reply → UI SSE, end to end", async () => {
378
- const { server, base, registry } = buildServer([{ name: "dev" }]);
379
- try {
380
- const bridge = await openSse(base, "/events?channel=dev");
381
- const ui = await openSse(base, "/ui/events?channel=dev");
382
- const start = Date.now();
383
- while (registry.size < 1 && Date.now() - start < 1000) {
384
- await new Promise((r) => setTimeout(r, 5));
385
- }
386
-
387
- // UI → bridge (authed — Layer 2).
388
- await fetch(`${base}/api/channels/dev/send`, {
389
- method: "POST",
390
- headers: { "content-type": "application/json", ...AUTH },
391
- body: JSON.stringify({ text: "wake up" }),
392
- });
393
- const bridgeFrame = await bridge.read();
394
- expect(bridgeFrame).toContain("wake up");
395
-
396
- // bridge → UI (the session replied via the reply tool).
397
- const replyRes = await fetch(`${base}/api/reply`, {
398
- method: "POST",
399
- headers: { "content-type": "application/json" },
400
- body: JSON.stringify({ channel: "dev", text: "I am awake" }),
401
- });
402
- expect((await replyRes.json()).sent).toHaveLength(1);
403
-
404
- const uiFrame = await ui.read();
405
- expect(uiFrame).toContain("event: reply");
406
- expect(uiFrame).toContain("I am awake");
407
-
408
- bridge.cancel();
409
- ui.cancel();
410
- } finally {
411
- server.stop(true);
412
- }
413
- });
414
-
415
- test("channel isolation: a send on A reaches A's bridge but NOT a UI client on B", async () => {
416
- const { server, base } = buildServer([{ name: "A" }, { name: "B" }]);
417
- try {
418
- const bridgeA = await openSse(base, "/events?channel=A");
419
- const uiB = await openSse(base, "/ui/events?channel=B");
420
-
421
- // Send on channel A, then reply on A (authed — Layer 2).
422
- await fetch(`${base}/api/channels/A/send`, {
423
- method: "POST",
424
- headers: { "content-type": "application/json", ...AUTH },
425
- body: JSON.stringify({ text: "for-A" }),
426
- });
427
- // Close the loop: A's bridge MUST receive the inbound (not just "B didn't").
428
- const bridgeFrame = await bridgeA.read();
429
- expect(bridgeFrame).toContain("for-A");
430
-
431
- await fetch(`${base}/api/reply`, {
432
- method: "POST",
433
- headers: { "content-type": "application/json" },
434
- body: JSON.stringify({ channel: "A", text: "reply-to-A" }),
435
- });
436
-
437
- // Now reply on B so B's stream definitely has a frame, and assert it's B's
438
- // only — none of A's traffic leaked across.
439
- await fetch(`${base}/api/reply`, {
440
- method: "POST",
441
- headers: { "content-type": "application/json" },
442
- body: JSON.stringify({ channel: "B", text: "reply-to-B" }),
443
- });
444
-
445
- const frame = await uiB.read();
446
- expect(frame).toContain("reply-to-B");
447
- expect(frame).not.toContain("reply-to-A");
448
- expect(frame).not.toContain("for-A");
449
- bridgeA.cancel();
450
- uiB.cancel();
451
- } finally {
452
- server.stop(true);
453
- }
454
- });
455
- });
@@ -1,174 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
- import {
3
- isAllowedFor,
4
- chunkText,
5
- TelegramTransport,
6
- type AccessConfig,
7
- } from "./telegram.ts";
8
- import { ChannelConfigError } from "../transport.ts";
9
-
10
- // ---------------------------------------------------------------------------
11
- // Access control — these cases moved here from the daemon. The policy is now a
12
- // pure function (isAllowedFor) so it's testable without a live connection.
13
- // ---------------------------------------------------------------------------
14
-
15
- function access(partial: Partial<AccessConfig>): AccessConfig {
16
- return { dmPolicy: "allowlist", allowFrom: [], groups: {}, pending: {}, ...partial };
17
- }
18
-
19
- describe("isAllowedFor", () => {
20
- test("open policy allows anyone", () => {
21
- const a = access({ dmPolicy: "open", allowFrom: [] });
22
- expect(isAllowedFor(a, 999, 999)).toBe(true);
23
- expect(isAllowedFor(a, 1, -100)).toBe(true);
24
- });
25
-
26
- test("allowlist: user in allowFrom is allowed, others denied", () => {
27
- const a = access({ allowFrom: ["42"] });
28
- expect(isAllowedFor(a, 42, 42)).toBe(true);
29
- expect(isAllowedFor(a, 7, 7)).toBe(false);
30
- });
31
-
32
- test("allowInChats group bypass: any member of an allowlisted group gets in", () => {
33
- const a = access({ allowFrom: ["42"], allowInChats: ["-100200300"] });
34
- // A user NOT in allowFrom, posting in the allowlisted group → allowed.
35
- expect(isAllowedFor(a, 999, "-100200300")).toBe(true);
36
- // Same user in a different group → denied.
37
- expect(isAllowedFor(a, 999, "-555")).toBe(false);
38
- });
39
-
40
- test("allowInChats DM gating: requires BOTH allowFrom AND allowInChats", () => {
41
- const a = access({ allowFrom: ["42"], allowInChats: ["42"] });
42
- // user 42 DMing (chat_id === user_id) → both lists include 42 → allowed.
43
- expect(isAllowedFor(a, 42, 42)).toBe(true);
44
- // user 42 in a chat NOT in allowInChats → denied.
45
- expect(isAllowedFor(a, 42, 99)).toBe(false);
46
- // user not in allowFrom → denied even if chat is listed.
47
- expect(isAllowedFor(access({ allowFrom: ["1"], allowInChats: ["42"] }), 42, 42)).toBe(false);
48
- });
49
-
50
- test("allowInChats empty array fails closed for DMs", () => {
51
- const a = access({ allowFrom: ["42"], allowInChats: [] });
52
- expect(isAllowedFor(a, 42, 42)).toBe(false);
53
- });
54
-
55
- test("allowInChats absent → user-allowlist only (back-compat, no per-chat gating)", () => {
56
- const a = access({ allowFrom: ["42"] });
57
- expect(isAllowedFor(a, 42, 12345)).toBe(true);
58
- expect(isAllowedFor(a, 42, undefined)).toBe(true);
59
- });
60
-
61
- test("allowFrom empty + allowlist policy → fail-closed (denies everyone)", () => {
62
- const a = access({ dmPolicy: "allowlist", allowFrom: [] });
63
- expect(isAllowedFor(a, 1, 1)).toBe(false);
64
- expect(isAllowedFor(a, 42, -100)).toBe(false);
65
- });
66
- });
67
-
68
- // ---------------------------------------------------------------------------
69
- // Chunking
70
- // ---------------------------------------------------------------------------
71
-
72
- describe("chunkText", () => {
73
- test("short text → single chunk", () => {
74
- expect(chunkText("hello", 4096)).toEqual(["hello"]);
75
- });
76
-
77
- test("long text splits into <=maxLen chunks", () => {
78
- const text = "a".repeat(10000);
79
- const chunks = chunkText(text, 4096);
80
- expect(chunks.length).toBe(3);
81
- for (const c of chunks) expect(c.length).toBeLessThanOrEqual(4096);
82
- expect(chunks.join("")).toBe(text);
83
- });
84
-
85
- test("prefers newline breaks when one is available in the back half", () => {
86
- const head = "x".repeat(3000);
87
- const tail = "y".repeat(3000);
88
- const chunks = chunkText(`${head}\n${tail}`, 4096);
89
- expect(chunks[0]).toBe(head); // broke at the newline, which it stripped
90
- expect(chunks[1]).toBe(tail);
91
- });
92
- });
93
-
94
- // ---------------------------------------------------------------------------
95
- // Transport shape
96
- // ---------------------------------------------------------------------------
97
-
98
- describe("TelegramTransport", () => {
99
- test("throws ChannelConfigError when no config.token (no env fallback)", () => {
100
- // The daemon-global TELEGRAM_BOT_TOKEN fallback is gone — a telegram channel
101
- // MUST carry its own per-channel token. Even with the env var set, a config
102
- // without a token throws: the env is never read as a token source.
103
- const prev = process.env.TELEGRAM_BOT_TOKEN;
104
- try {
105
- // (1) no config token, env UNSET → throws.
106
- delete process.env.TELEGRAM_BOT_TOKEN;
107
- expect(() => new TelegramTransport({ name: "tele-x" })).toThrow(ChannelConfigError);
108
- expect(() => new TelegramTransport({ name: "tele-x" })).toThrow(
109
- /telegram channel tele-x requires a per-channel bot token/,
110
- );
111
-
112
- // (2) no config token, env SET → STILL throws (env is not a token source).
113
- process.env.TELEGRAM_BOT_TOKEN = "env-tok";
114
- expect(() => new TelegramTransport({ name: "tele-x" })).toThrow(ChannelConfigError);
115
- } finally {
116
- if (prev === undefined) delete process.env.TELEGRAM_BOT_TOKEN;
117
- else process.env.TELEGRAM_BOT_TOKEN = prev;
118
- }
119
- });
120
-
121
- test("a per-channel config.token constructs, regardless of the env", () => {
122
- const prev = process.env.TELEGRAM_BOT_TOKEN;
123
- try {
124
- // env UNSET → constructs off the per-channel token.
125
- delete process.env.TELEGRAM_BOT_TOKEN;
126
- const perChannel = new TelegramTransport({
127
- token: "per-channel-tok",
128
- stateDir: "/tmp/parachute-agent-test-precedence",
129
- });
130
- expect(perChannel.kind).toBe("telegram");
131
-
132
- // env SET to a DIFFERENT value → the per-channel token is what's used; the
133
- // env is irrelevant, construction still succeeds with the config token.
134
- process.env.TELEGRAM_BOT_TOKEN = "env-tok";
135
- const withEnvNoise = new TelegramTransport({
136
- token: "per-channel-tok",
137
- stateDir: "/tmp/parachute-agent-test-precedence",
138
- });
139
- expect(withEnvNoise.kind).toBe("telegram");
140
- } finally {
141
- if (prev === undefined) delete process.env.TELEGRAM_BOT_TOKEN;
142
- else process.env.TELEGRAM_BOT_TOKEN = prev;
143
- }
144
- });
145
-
146
- test("kind is 'telegram' and outbound methods exist", () => {
147
- const t = new TelegramTransport({ token: "tok", stateDir: "/tmp/parachute-agent-test-telegram" });
148
- expect(t.kind).toBe("telegram");
149
- expect(typeof t.reply).toBe("function");
150
- expect(typeof t.react).toBe("function");
151
- expect(typeof t.edit).toBe("function");
152
- expect(typeof t.sendPermission).toBe("function");
153
- expect(typeof t.download).toBe("function");
154
- });
155
-
156
- test("reply without a chat_id in meta errors clearly", async () => {
157
- const t = new TelegramTransport({ token: "tok", stateDir: "/tmp/parachute-agent-test-telegram" });
158
- await expect(t.reply({ channel: "telegram", text: "hi" })).rejects.toThrow(/chat_id is required/);
159
- });
160
-
161
- test("sendPermission with no allowlisted users throws ChannelConfigError (→ 400, not 500)", async () => {
162
- // Fresh state dir → no access.json → default access has empty allowFrom.
163
- const t = new TelegramTransport({ token: "tok", stateDir: "/tmp/parachute-agent-test-noperm" });
164
- await expect(
165
- t.sendPermission({
166
- channel: "telegram",
167
- request_id: "abcde",
168
- tool_name: "Bash",
169
- description: "run a command",
170
- input_preview: "ls",
171
- }),
172
- ).rejects.toBeInstanceOf(ChannelConfigError);
173
- });
174
- });