@openparachute/agent 0.2.3-rc.2 → 0.2.3-rc.3

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 (54) hide show
  1. package/package.json +4 -1
  2. package/src/transports/vault.ts +19 -1
  3. package/src/_parked/interactive-spawn.test.ts +0 -324
  4. package/src/_parked/interactive-spawn.ts +0 -701
  5. package/src/agent-defs.test.ts +0 -1504
  6. package/src/agent-mcp-config.test.ts +0 -115
  7. package/src/agents.test.ts +0 -360
  8. package/src/auth.test.ts +0 -46
  9. package/src/backends/attached-queue.test.ts +0 -376
  10. package/src/backends/programmatic.test.ts +0 -1715
  11. package/src/backends/registry.test.ts +0 -1494
  12. package/src/backends/stream-json.test.ts +0 -570
  13. package/src/channel-backend-wiring.test.ts +0 -237
  14. package/src/credentials.test.ts +0 -274
  15. package/src/cron.test.ts +0 -342
  16. package/src/daemon-agent-def-api.test.ts +0 -166
  17. package/src/daemon-agent-defs-api.test.ts +0 -953
  18. package/src/daemon-agent-env-api.test.ts +0 -338
  19. package/src/daemon-attached-queue-store.test.ts +0 -65
  20. package/src/daemon-config-api.test.ts +0 -962
  21. package/src/daemon-jobs-api.test.ts +0 -271
  22. package/src/daemon-vault-chat.test.ts +0 -250
  23. package/src/daemon.test.ts +0 -746
  24. package/src/def-vaults.test.ts +0 -136
  25. package/src/delivery-state.test.ts +0 -110
  26. package/src/effective-env.test.ts +0 -114
  27. package/src/grants.test.ts +0 -638
  28. package/src/hub-jwt.test.ts +0 -161
  29. package/src/jobs.test.ts +0 -245
  30. package/src/mcp-http.test.ts +0 -265
  31. package/src/mint-token.test.ts +0 -152
  32. package/src/module-manifest.test.ts +0 -158
  33. package/src/programmatic-wiring.test.ts +0 -838
  34. package/src/registry.test.ts +0 -227
  35. package/src/resolve-port.test.ts +0 -64
  36. package/src/routing.test.ts +0 -184
  37. package/src/runner.test.ts +0 -506
  38. package/src/sandbox/config.test.ts +0 -150
  39. package/src/sandbox/egress.test.ts +0 -113
  40. package/src/sandbox/live-seatbelt.test.ts +0 -277
  41. package/src/sandbox/mounts.test.ts +0 -154
  42. package/src/sandbox/sandbox.test.ts +0 -168
  43. package/src/services-manifest.test.ts +0 -106
  44. package/src/spa-serve.test.ts +0 -116
  45. package/src/spawn-agent-cli.test.ts +0 -172
  46. package/src/spawn-agent.test.ts +0 -1218
  47. package/src/spawn-deps.test.ts +0 -54
  48. package/src/terminal-assets.test.ts +0 -50
  49. package/src/terminal.test.ts +0 -530
  50. package/src/transports/http-ui.test.ts +0 -455
  51. package/src/transports/telegram.test.ts +0 -174
  52. package/src/transports/vault.test.ts +0 -2012
  53. package/src/ui-kit.test.ts +0 -178
  54. 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
- });