@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.
- package/package.json +4 -1
- package/src/transports/vault.ts +19 -1
- package/src/_parked/interactive-spawn.test.ts +0 -324
- package/src/_parked/interactive-spawn.ts +0 -701
- package/src/agent-defs.test.ts +0 -1504
- package/src/agent-mcp-config.test.ts +0 -115
- package/src/agents.test.ts +0 -360
- package/src/auth.test.ts +0 -46
- package/src/backends/attached-queue.test.ts +0 -376
- package/src/backends/programmatic.test.ts +0 -1715
- package/src/backends/registry.test.ts +0 -1494
- package/src/backends/stream-json.test.ts +0 -570
- package/src/channel-backend-wiring.test.ts +0 -237
- package/src/credentials.test.ts +0 -274
- package/src/cron.test.ts +0 -342
- package/src/daemon-agent-def-api.test.ts +0 -166
- package/src/daemon-agent-defs-api.test.ts +0 -953
- package/src/daemon-agent-env-api.test.ts +0 -338
- package/src/daemon-attached-queue-store.test.ts +0 -65
- package/src/daemon-config-api.test.ts +0 -962
- package/src/daemon-jobs-api.test.ts +0 -271
- package/src/daemon-vault-chat.test.ts +0 -250
- package/src/daemon.test.ts +0 -746
- package/src/def-vaults.test.ts +0 -136
- package/src/delivery-state.test.ts +0 -110
- package/src/effective-env.test.ts +0 -114
- package/src/grants.test.ts +0 -638
- package/src/hub-jwt.test.ts +0 -161
- package/src/jobs.test.ts +0 -245
- package/src/mcp-http.test.ts +0 -265
- package/src/mint-token.test.ts +0 -152
- package/src/module-manifest.test.ts +0 -158
- package/src/programmatic-wiring.test.ts +0 -838
- package/src/registry.test.ts +0 -227
- package/src/resolve-port.test.ts +0 -64
- package/src/routing.test.ts +0 -184
- package/src/runner.test.ts +0 -506
- package/src/sandbox/config.test.ts +0 -150
- package/src/sandbox/egress.test.ts +0 -113
- package/src/sandbox/live-seatbelt.test.ts +0 -277
- package/src/sandbox/mounts.test.ts +0 -154
- package/src/sandbox/sandbox.test.ts +0 -168
- package/src/services-manifest.test.ts +0 -106
- package/src/spa-serve.test.ts +0 -116
- package/src/spawn-agent-cli.test.ts +0 -172
- package/src/spawn-agent.test.ts +0 -1218
- package/src/spawn-deps.test.ts +0 -54
- package/src/terminal-assets.test.ts +0 -50
- package/src/terminal.test.ts +0 -530
- package/src/transports/http-ui.test.ts +0 -455
- package/src/transports/telegram.test.ts +0 -174
- package/src/transports/vault.test.ts +0 -2012
- package/src/ui-kit.test.ts +0 -178
- 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
|
-
});
|