@openparachute/agent 0.2.0 → 0.2.3-rc.10

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 (70) hide show
  1. package/.parachute/module.json +3 -3
  2. package/package.json +8 -1
  3. package/src/agent-defs.ts +9 -0
  4. package/src/auth.ts +182 -14
  5. package/src/backends/registry.ts +65 -27
  6. package/src/daemon.ts +311 -12
  7. package/src/def-vault-triggers.ts +317 -0
  8. package/src/preflight.ts +139 -0
  9. package/src/spawn-agent.ts +16 -0
  10. package/src/step-up.ts +316 -0
  11. package/src/terminal-ui.ts +73 -0
  12. package/src/transports/http-ui.ts +10 -8
  13. package/src/transports/vault.ts +40 -22
  14. package/src/ui-kit.ts +6 -3
  15. package/src/ui-ticket.ts +121 -0
  16. package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
  17. package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
  18. package/web/ui/dist/index.html +15 -0
  19. package/src/_parked/interactive-spawn.test.ts +0 -324
  20. package/src/_parked/interactive-spawn.ts +0 -701
  21. package/src/agent-defs.test.ts +0 -1504
  22. package/src/agent-mcp-config.test.ts +0 -115
  23. package/src/agents.test.ts +0 -360
  24. package/src/auth.test.ts +0 -46
  25. package/src/backends/attached-queue.test.ts +0 -376
  26. package/src/backends/programmatic.test.ts +0 -1715
  27. package/src/backends/registry.test.ts +0 -1494
  28. package/src/backends/stream-json.test.ts +0 -570
  29. package/src/channel-backend-wiring.test.ts +0 -237
  30. package/src/credentials.test.ts +0 -274
  31. package/src/cron.test.ts +0 -342
  32. package/src/daemon-agent-def-api.test.ts +0 -166
  33. package/src/daemon-agent-defs-api.test.ts +0 -953
  34. package/src/daemon-agent-env-api.test.ts +0 -338
  35. package/src/daemon-attached-queue-store.test.ts +0 -65
  36. package/src/daemon-config-api.test.ts +0 -962
  37. package/src/daemon-jobs-api.test.ts +0 -271
  38. package/src/daemon-vault-chat.test.ts +0 -250
  39. package/src/daemon.test.ts +0 -746
  40. package/src/def-vaults.test.ts +0 -136
  41. package/src/delivery-state.test.ts +0 -110
  42. package/src/effective-env.test.ts +0 -114
  43. package/src/grants.test.ts +0 -638
  44. package/src/hub-jwt.test.ts +0 -161
  45. package/src/jobs.test.ts +0 -245
  46. package/src/mcp-http.test.ts +0 -265
  47. package/src/mint-token.test.ts +0 -152
  48. package/src/module-manifest.test.ts +0 -158
  49. package/src/programmatic-wiring.test.ts +0 -838
  50. package/src/registry.test.ts +0 -227
  51. package/src/resolve-port.test.ts +0 -64
  52. package/src/routing.test.ts +0 -184
  53. package/src/runner.test.ts +0 -506
  54. package/src/sandbox/config.test.ts +0 -150
  55. package/src/sandbox/egress.test.ts +0 -113
  56. package/src/sandbox/live-seatbelt.test.ts +0 -277
  57. package/src/sandbox/mounts.test.ts +0 -154
  58. package/src/sandbox/sandbox.test.ts +0 -168
  59. package/src/services-manifest.test.ts +0 -106
  60. package/src/spa-serve.test.ts +0 -116
  61. package/src/spawn-agent-cli.test.ts +0 -172
  62. package/src/spawn-agent.test.ts +0 -1218
  63. package/src/spawn-deps.test.ts +0 -54
  64. package/src/terminal-assets.test.ts +0 -50
  65. package/src/terminal.test.ts +0 -530
  66. package/src/transports/http-ui.test.ts +0 -455
  67. package/src/transports/telegram.test.ts +0 -174
  68. package/src/transports/vault.test.ts +0 -2011
  69. package/src/ui-kit.test.ts +0 -178
  70. package/web/ui/tsconfig.json +0 -21
@@ -1,161 +0,0 @@
1
- /**
2
- * Tests for the agent-side hub-JWT adapter. These exercise the parts that
3
- * DON'T need a live hub / JWKS endpoint: hub-origin resolution (env precedence →
4
- * expose-state self-heal → loopback fallback), the audience constants, and the
5
- * re-exported pure helpers (`looksLikeJwt`). Real signature/issuer/audience
6
- * validation is scope-guard's own tested surface — we don't re-test it here and
7
- * we never need a real JWKS.
8
- *
9
- * Audience constants (channel→agent rename, rule 1): the daemon now mints/
10
- * validates `aud: "agent"` (`AGENT_AUDIENCE`); the pre-rename `aud: "channel"`
11
- * (`CHANNEL_AUDIENCE`, deprecated) still validates during the dual-accept window
12
- * via `ACCEPTED_AUDIENCES`. We assert both constants here. The dual-ACCEPT itself
13
- * (a `channel`-aud token still validating) lives in `validateHubJwt`, which needs
14
- * a live JWKS to exercise — that's scope-guard's tested surface, not re-tested here.
15
- *
16
- * The self-heal reads `<PARACHUTE_HOME>/expose-state.json`. Every case here
17
- * points `PARACHUTE_HOME` at a fresh temp dir so the operator's real
18
- * `~/.parachute/expose-state.json` can't leak into the loopback assertions.
19
- */
20
- import { describe, test, expect, afterEach, beforeEach } from "bun:test";
21
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
22
- import { tmpdir } from "node:os";
23
- import { join } from "node:path";
24
- import {
25
- getHubOrigin,
26
- AGENT_AUDIENCE,
27
- CHANNEL_AUDIENCE,
28
- ACCEPTED_AUDIENCES,
29
- looksLikeJwt,
30
- HubJwtError,
31
- } from "./hub-jwt.ts";
32
-
33
- const savedOrigin = process.env.PARACHUTE_HUB_ORIGIN;
34
- const savedHome = process.env.PARACHUTE_HOME;
35
-
36
- let home: string;
37
-
38
- beforeEach(() => {
39
- // Isolated, empty ecosystem root — no expose-state.json unless a case writes one.
40
- home = mkdtempSync(join(tmpdir(), "agent-hubjwt-"));
41
- process.env.PARACHUTE_HOME = home;
42
- });
43
-
44
- afterEach(() => {
45
- if (savedOrigin === undefined) delete process.env.PARACHUTE_HUB_ORIGIN;
46
- else process.env.PARACHUTE_HUB_ORIGIN = savedOrigin;
47
- if (savedHome === undefined) delete process.env.PARACHUTE_HOME;
48
- else process.env.PARACHUTE_HOME = savedHome;
49
- try {
50
- rmSync(home, { recursive: true, force: true });
51
- } catch {}
52
- });
53
-
54
- function writeExposeState(obj: Record<string, unknown>): void {
55
- writeFileSync(join(home, "expose-state.json"), JSON.stringify(obj));
56
- }
57
-
58
- describe("getHubOrigin — env precedence", () => {
59
- test("uses the env value when set", () => {
60
- process.env.PARACHUTE_HUB_ORIGIN = "https://hub.example.com";
61
- expect(getHubOrigin()).toBe("https://hub.example.com");
62
- });
63
-
64
- test("strips a single trailing slash for a canonical form", () => {
65
- process.env.PARACHUTE_HUB_ORIGIN = "https://hub.example.com/";
66
- expect(getHubOrigin()).toBe("https://hub.example.com");
67
- });
68
-
69
- test("env wins over expose-state (highest precedence)", () => {
70
- process.env.PARACHUTE_HUB_ORIGIN = "https://env.example.com";
71
- writeExposeState({ hubOrigin: "https://exposed.example.com" });
72
- expect(getHubOrigin()).toBe("https://env.example.com");
73
- });
74
- });
75
-
76
- describe("getHubOrigin — expose-state self-heal (agent#34)", () => {
77
- test("reads expose-state.hubOrigin when env is unset", () => {
78
- delete process.env.PARACHUTE_HUB_ORIGIN;
79
- writeExposeState({ hubOrigin: "https://exposed.example.com" });
80
- expect(getHubOrigin()).toBe("https://exposed.example.com");
81
- });
82
-
83
- test("reads expose-state.hubOrigin when env is empty", () => {
84
- process.env.PARACHUTE_HUB_ORIGIN = "";
85
- writeExposeState({ hubOrigin: "https://exposed.example.com" });
86
- expect(getHubOrigin()).toBe("https://exposed.example.com");
87
- });
88
-
89
- test("synthesizes https://<canonicalFqdn> for older state files lacking hubOrigin", () => {
90
- delete process.env.PARACHUTE_HUB_ORIGIN;
91
- writeExposeState({ canonicalFqdn: "box.taildf9ce2.ts.net" });
92
- expect(getHubOrigin()).toBe("https://box.taildf9ce2.ts.net");
93
- });
94
-
95
- test("strips a trailing slash off the expose-state origin", () => {
96
- delete process.env.PARACHUTE_HUB_ORIGIN;
97
- writeExposeState({ hubOrigin: "https://exposed.example.com/" });
98
- expect(getHubOrigin()).toBe("https://exposed.example.com");
99
- });
100
-
101
- test("never self-heals to a loopback expose-state origin", () => {
102
- delete process.env.PARACHUTE_HUB_ORIGIN;
103
- writeExposeState({ hubOrigin: "http://127.0.0.1:1939" });
104
- expect(getHubOrigin()).toBe("http://127.0.0.1:1939"); // loopback default, not a self-heal
105
- });
106
- });
107
-
108
- describe("getHubOrigin — loopback fallback", () => {
109
- test("falls back to loopback when env unset AND no expose-state file", () => {
110
- delete process.env.PARACHUTE_HUB_ORIGIN;
111
- expect(getHubOrigin()).toBe("http://127.0.0.1:1939");
112
- });
113
-
114
- test("falls back to loopback when env empty AND no expose-state file", () => {
115
- process.env.PARACHUTE_HUB_ORIGIN = "";
116
- expect(getHubOrigin()).toBe("http://127.0.0.1:1939");
117
- });
118
-
119
- test("falls back to loopback when expose-state has no usable origin", () => {
120
- delete process.env.PARACHUTE_HUB_ORIGIN;
121
- writeExposeState({ layer: "tailnet" }); // neither hubOrigin nor canonicalFqdn
122
- expect(getHubOrigin()).toBe("http://127.0.0.1:1939");
123
- });
124
-
125
- test("falls back to loopback when expose-state is malformed JSON", () => {
126
- delete process.env.PARACHUTE_HUB_ORIGIN;
127
- writeFileSync(join(home, "expose-state.json"), "{ not json");
128
- expect(getHubOrigin()).toBe("http://127.0.0.1:1939");
129
- });
130
- });
131
-
132
- describe("audience constants (channel→agent dual-accept, rule 1)", () => {
133
- test("AGENT_AUDIENCE is the literal 'agent' (what the hub mints aud as now)", () => {
134
- expect(AGENT_AUDIENCE).toBe("agent");
135
- });
136
-
137
- test("CHANNEL_AUDIENCE is the deprecated legacy literal 'channel' (pre-rename tokens)", () => {
138
- expect(CHANNEL_AUDIENCE).toBe("channel");
139
- });
140
-
141
- test("ACCEPTED_AUDIENCES carries BOTH — new 'agent' + legacy 'channel' (the dual-accept set)", () => {
142
- // The resource-server backstop: a token whose aud is neither (e.g. minted for
143
- // a vault) is rejected; both transitional forms validate until live re-mint.
144
- expect([...ACCEPTED_AUDIENCES]).toEqual(["agent", "channel"]);
145
- expect(ACCEPTED_AUDIENCES).toContain(AGENT_AUDIENCE);
146
- expect(ACCEPTED_AUDIENCES).toContain(CHANNEL_AUDIENCE);
147
- });
148
- });
149
-
150
- describe("re-exported helpers", () => {
151
- test("looksLikeJwt recognizes the eyJ prefix", () => {
152
- expect(looksLikeJwt("eyJhbGciOiJSUzI1NiJ9.payload.sig")).toBe(true);
153
- expect(looksLikeJwt("opaque-shared-secret")).toBe(false);
154
- });
155
-
156
- test("HubJwtError is the scope-guard error class", () => {
157
- const err = new HubJwtError("issuer", "bad iss");
158
- expect(err).toBeInstanceOf(Error);
159
- expect(err.code).toBe("issuer");
160
- });
161
- });
package/src/jobs.test.ts DELETED
@@ -1,245 +0,0 @@
1
- import { describe, test, expect, afterEach } from "bun:test";
2
- import { validateJob, VaultJobStore, vaultTransportFor, type Job } from "./jobs.ts";
3
- import { VaultTransport } from "./transports/vault.ts";
4
- import type { Channel } from "./registry.ts";
5
- import { TelegramTransport } from "./transports/telegram.ts";
6
-
7
- const realFetch = globalThis.fetch;
8
- afterEach(() => {
9
- globalThis.fetch = realFetch;
10
- });
11
-
12
- function makeJob(over: Partial<Job> = {}): Job {
13
- return {
14
- id: "morning",
15
- channel: "uni-dev",
16
- message: "Run the morning weave",
17
- schedule: { cron: "53 7 * * *", tz: "America/Los_Angeles" },
18
- enabled: true,
19
- createdAt: "2026-06-17T00:00:00.000Z",
20
- ...over,
21
- };
22
- }
23
-
24
- describe("validateJob (pure)", () => {
25
- const isVault = (name: string): boolean | null => {
26
- if (name === "uni-dev") return true;
27
- if (name === "tele") return false;
28
- return null;
29
- };
30
-
31
- test("a well-formed vault job validates", () => {
32
- expect(validateJob(makeJob(), isVault)).toEqual({ ok: true });
33
- });
34
- test("bad id (not a slug) rejected", () => {
35
- const r = validateJob(makeJob({ id: "has spaces" }), isVault);
36
- expect(r).toMatchObject({ ok: false });
37
- expect((r as { error: string }).error).toMatch(/slug/);
38
- });
39
- test("empty message rejected", () => {
40
- const r = validateJob(makeJob({ message: " " }), isVault);
41
- expect((r as { error: string }).error).toMatch(/message/);
42
- });
43
- test("bad cron rejected with a field-naming message", () => {
44
- const r = validateJob(makeJob({ schedule: { cron: "99 7 * * *" } }), isVault);
45
- expect((r as { error: string }).error).toMatch(/invalid schedule.cron/);
46
- });
47
- test("missing schedule.cron rejected", () => {
48
- const r = validateJob({ id: "x", channel: "uni-dev", message: "m" }, isVault);
49
- expect((r as { error: string }).error).toMatch(/schedule.cron/);
50
- });
51
- test("bad tz rejected", () => {
52
- const r = validateJob(makeJob({ schedule: { cron: "0 0 * * *", tz: "Mars/Olympus" } }), isVault);
53
- expect((r as { error: string }).error).toMatch(/timezone/);
54
- });
55
- test("unknown channel rejected", () => {
56
- const r = validateJob(makeJob({ channel: "ghost" }), isVault);
57
- expect((r as { error: string }).error).toMatch(/unknown channel/);
58
- });
59
- test("non-vault channel rejected (the inject path needs a vault transport)", () => {
60
- const r = validateJob(makeJob({ channel: "tele" }), isVault);
61
- expect((r as { error: string }).error).toMatch(/not a vault channel/);
62
- });
63
- });
64
-
65
- /** Build a live channels map with one vault channel + (optionally) a telegram one. */
66
- function channelsWithVault(): { channels: Map<string, Channel>; vault: VaultTransport } {
67
- const vault = new VaultTransport({
68
- vault: "default",
69
- vaultUrl: "http://127.0.0.1:1940",
70
- token: "write-token",
71
- });
72
- const channels = new Map<string, Channel>();
73
- channels.set("uni-dev", {
74
- name: "uni-dev",
75
- transport: vault,
76
- entry: { name: "uni-dev", transport: "vault", config: { vault: "default", token: "write-token" } },
77
- });
78
- const tele = new TelegramTransport({ token: "tg", name: "tele" });
79
- channels.set("tele", {
80
- name: "tele",
81
- transport: tele,
82
- entry: { name: "tele", transport: "telegram", config: { token: "tg" } },
83
- });
84
- return { channels, vault };
85
- }
86
-
87
- describe("vaultTransportFor", () => {
88
- test("resolves a vault channel to its transport; null for non-vault / unknown", () => {
89
- const { channels, vault } = channelsWithVault();
90
- expect(vaultTransportFor(channels, "uni-dev")).toBe(vault);
91
- expect(vaultTransportFor(channels, "tele")).toBeNull();
92
- expect(vaultTransportFor(channels, "ghost")).toBeNull();
93
- });
94
- });
95
-
96
- describe("VaultJobStore — vault-native CRUD", () => {
97
- test("listAll queries each unique vault once + maps job notes to Jobs", async () => {
98
- const { channels } = channelsWithVault();
99
- const urls: string[] = [];
100
- globalThis.fetch = (async (url: string | URL | Request) => {
101
- urls.push(String(url));
102
- return new Response(
103
- JSON.stringify([
104
- {
105
- id: "note-1",
106
- content: "Run the weave",
107
- metadata: {
108
- channel: "uni-dev",
109
- cron: "53 7 * * *",
110
- tz: "America/Los_Angeles",
111
- enabled: "true",
112
- createdAt: "2026-06-17T00:00:00Z",
113
- },
114
- },
115
- {
116
- id: "note-2",
117
- content: "Hourly ping",
118
- metadata: { channel: "uni-dev", cron: "0 * * * *", enabled: "false" },
119
- },
120
- ]),
121
- { status: 200, headers: { "content-type": "application/json" } },
122
- );
123
- }) as typeof fetch;
124
-
125
- const store = new VaultJobStore(channels);
126
- const jobs = await store.listAll();
127
- // Only ONE vault transport in the map → queried exactly once (telegram is skipped).
128
- expect(urls.filter((u) => u.includes("/api/notes")).length).toBe(1);
129
- expect(urls[0]).toContain("tag=%23agent%2Fjob");
130
- expect(jobs).toHaveLength(2);
131
- expect(jobs[0]).toMatchObject({
132
- id: "note-1",
133
- channel: "uni-dev",
134
- message: "Run the weave",
135
- schedule: { cron: "53 7 * * *", tz: "America/Los_Angeles" },
136
- enabled: true,
137
- });
138
- expect(jobs[1]!.enabled).toBe(false); // "false" string → disabled
139
- });
140
-
141
- test("listAll dedups by vault IDENTITY across SEPARATE transport instances sharing one vault (regression)", async () => {
142
- // Two vault channels, each with its OWN VaultTransport INSTANCE pointing at the
143
- // SAME vault "default" — the real shape (each channel constructs its own
144
- // transport). Instance-identity dedup misses this; vaultKey() dedup must catch it.
145
- // Caught live 2026-06-18: one job listed 3x with three channels on the default vault.
146
- const cfg = { vault: "default", vaultUrl: "http://127.0.0.1:1940", token: "write-token" };
147
- const channels = new Map<string, Channel>();
148
- for (const name of ["uni-a", "uni-b"]) {
149
- channels.set(name, {
150
- name,
151
- transport: new VaultTransport(cfg), // a DISTINCT instance per channel
152
- entry: { name, transport: "vault", config: { vault: "default", token: "write-token" } },
153
- });
154
- }
155
- const urls: string[] = [];
156
- globalThis.fetch = (async (url: string | URL | Request) => {
157
- urls.push(String(url));
158
- return new Response(
159
- JSON.stringify([
160
- { id: "Channels/uni-a/jobs/j1", content: "do it", metadata: { channel: "uni-a", cron: "0 9 * * *", enabled: "true" } },
161
- ]),
162
- { status: 200, headers: { "content-type": "application/json" } },
163
- );
164
- }) as typeof fetch;
165
-
166
- const store = new VaultJobStore(channels);
167
- const jobs = await store.listAll();
168
- // Queried the shared vault EXACTLY once (not once per channel), and the job
169
- // appears EXACTLY once (not duplicated per channel that shares the vault).
170
- expect(urls.filter((u) => u.includes("/api/notes")).length).toBe(1);
171
- expect(jobs).toHaveLength(1);
172
- expect(jobs[0]!.id).toBe("Channels/uni-a/jobs/j1");
173
- });
174
-
175
- test("upsert writes a #agent/job note via the target channel's vault", async () => {
176
- const { channels } = channelsWithVault();
177
- const calls: { url: string; init: RequestInit }[] = [];
178
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
179
- calls.push({ url: String(url), init: init ?? {} });
180
- return new Response(JSON.stringify({ id: "Channels/uni-dev/jobs/morning" }), {
181
- status: 201,
182
- headers: { "content-type": "application/json" },
183
- });
184
- }) as typeof fetch;
185
-
186
- const store = new VaultJobStore(channels);
187
- const saved = await store.upsert(makeJob());
188
- // `id` stays the operator slug; `noteId` is the vault note id for addressing.
189
- expect(saved.id).toBe("morning");
190
- expect(saved.noteId).toBe("Channels/uni-dev/jobs/morning");
191
- expect(calls).toHaveLength(1);
192
- const body = JSON.parse(String(calls[0]!.init.body));
193
- expect(body.tags).toEqual(["#agent/job"]);
194
- expect(body.path).toBe("Channels/uni-dev/jobs/morning");
195
- expect(body.metadata.jobId).toBe("morning"); // slug persisted for stable display
196
- expect(body.content).toBe("Run the morning weave");
197
- expect(body.metadata).toMatchObject({
198
- // CONTRACT: routing key under `metadata.agent` ONLY — no `channel`.
199
- agent: "uni-dev",
200
- cron: "53 7 * * *",
201
- tz: "America/Los_Angeles",
202
- enabled: "true",
203
- createdAt: "2026-06-17T00:00:00.000Z",
204
- });
205
- expect(body.metadata.channel).toBeUndefined();
206
- // nextRunAt is NEVER persisted.
207
- expect(body.metadata.nextRunAt).toBeUndefined();
208
- const headers = calls[0]!.init.headers as Record<string, string>;
209
- expect(headers.authorization).toBe("Bearer write-token");
210
- });
211
-
212
- test("upsert to a non-vault channel throws", async () => {
213
- const { channels } = channelsWithVault();
214
- const store = new VaultJobStore(channels);
215
- await expect(store.upsert(makeJob({ channel: "tele" }))).rejects.toThrow(/not a live vault channel/);
216
- });
217
-
218
- test("remove DELETEs the note by id via the channel's vault", async () => {
219
- const { channels } = channelsWithVault();
220
- const calls: { url: string; method?: string }[] = [];
221
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
222
- calls.push({ url: String(url), method: init?.method });
223
- return new Response(null, { status: 204 });
224
- }) as typeof fetch;
225
- const store = new VaultJobStore(channels);
226
- await store.remove("Channels/uni-dev/jobs/morning", "uni-dev");
227
- expect(calls).toHaveLength(1);
228
- expect(calls[0]!.method).toBe("DELETE");
229
- expect(calls[0]!.url).toContain("/api/notes/");
230
- });
231
-
232
- test("patch PATCHes bookkeeping metadata onto the note", async () => {
233
- const { channels } = channelsWithVault();
234
- const calls: { url: string; init: RequestInit }[] = [];
235
- globalThis.fetch = (async (url: string | URL | Request, init?: RequestInit) => {
236
- calls.push({ url: String(url), init: init ?? {} });
237
- return new Response(null, { status: 200 });
238
- }) as typeof fetch;
239
- const store = new VaultJobStore(channels);
240
- await store.patch("note-1", "uni-dev", { lastRunAt: "2026-06-17T11:00:00Z", lastStatus: "ok" });
241
- expect(calls[0]!.init.method).toBe("PATCH");
242
- const body = JSON.parse(String(calls[0]!.init.body));
243
- expect(body.metadata).toEqual({ lastRunAt: "2026-06-17T11:00:00Z", lastStatus: "ok" });
244
- });
245
- });
@@ -1,265 +0,0 @@
1
- /**
2
- * HTTP MCP tests — the per-channel session registry, the wake push, write-scope
3
- * enforcement on tool dispatch, and the daemon's /mcp/<channel> auth gate.
4
- *
5
- * Like daemon.test.ts these need NO live hub: the no-token path in requireScope
6
- * short-circuits before any JWKS fetch, and the registry/push/tool tests drive
7
- * the in-memory session set directly with fake servers + a fake transport.
8
- */
9
- import { describe, test, expect, afterEach, beforeAll, afterAll } from "bun:test";
10
- import {
11
- pushToChannel,
12
- pushPermissionVerdict,
13
- mcpSessionCount,
14
- assertMcpSdkStreamContract,
15
- _resetSessionsForTest,
16
- } from "./mcp-http.ts";
17
- import { createFetchHandler } from "./daemon.ts";
18
- import { ClientRegistry } from "./routing.ts";
19
- import { HttpUiTransport } from "./transports/http-ui.ts";
20
- import type { Channel } from "./registry.ts";
21
- import type { Transport, ReplyArgs } from "./transport.ts";
22
-
23
- // ---------------------------------------------------------------------------
24
- // Registry + wake — drive the session set directly via a registration shim.
25
- //
26
- // pushToChannel iterates the channel's session set and calls
27
- // server.notification. We register fake sessions by reaching the same internal
28
- // maps the way handleMcp's onsessioninitialized would. Since those maps are
29
- // module-private, we exercise them through the public surface: register via a
30
- // tiny test-only re-entry that mirrors registerSession. To keep this hermetic
31
- // without exporting internals, we register by spinning handleMcp is overkill;
32
- // instead we assert push reaches a registered session through a captured
33
- // server.notification. We use the exported _registerForTest below.
34
- // ---------------------------------------------------------------------------
35
-
36
- import { _registerSessionForTest, _unregisterSessionForTest } from "./mcp-http.ts";
37
-
38
- interface FakeServer {
39
- notes: Array<{ method: string; params: unknown }>;
40
- }
41
-
42
- function fakeSession(): { server: { notification: (n: unknown) => void }; captured: FakeServer } {
43
- const captured: FakeServer = { notes: [] };
44
- const server = {
45
- notification(n: unknown) {
46
- captured.notes.push(n as { method: string; params: unknown });
47
- },
48
- };
49
- return { server, captured };
50
- }
51
-
52
- afterEach(() => {
53
- _resetSessionsForTest();
54
- });
55
-
56
- describe("per-channel MCP session registry + wake push", () => {
57
- test("pushToChannel reaches a session on A and NOT one on B", () => {
58
- const a = fakeSession();
59
- const b = fakeSession();
60
- _registerSessionForTest("A", "sid-a", a.server as never, ["agent:read", "agent:write"]);
61
- _registerSessionForTest("B", "sid-b", b.server as never, ["agent:read"]);
62
-
63
- const delivered = pushToChannel("A", "hello A", { foo: "bar" });
64
- expect(delivered).toBe(1);
65
- expect(a.captured.notes).toHaveLength(1);
66
- expect(a.captured.notes[0]!.method).toBe("notifications/claude/agent");
67
- expect((a.captured.notes[0]!.params as { content: string }).content).toBe("hello A");
68
- // Source is stamped + caller meta merged.
69
- expect((a.captured.notes[0]!.params as { meta: Record<string, string> }).meta).toMatchObject({
70
- source: "parachute-agent",
71
- foo: "bar",
72
- });
73
- // B got nothing.
74
- expect(b.captured.notes).toHaveLength(0);
75
- });
76
-
77
- test("two sessions on the same channel both get the wake", () => {
78
- const a1 = fakeSession();
79
- const a2 = fakeSession();
80
- _registerSessionForTest("A", "sid-a1", a1.server as never, ["agent:read"]);
81
- _registerSessionForTest("A", "sid-a2", a2.server as never, ["agent:read"]);
82
- expect(mcpSessionCount("A")).toBe(2);
83
- const delivered = pushToChannel("A", "broadcast", {});
84
- expect(delivered).toBe(2);
85
- expect(a1.captured.notes).toHaveLength(1);
86
- expect(a2.captured.notes).toHaveLength(1);
87
- });
88
-
89
- test("pushPermissionVerdict pushes the permission method", () => {
90
- const a = fakeSession();
91
- _registerSessionForTest("A", "sid-a", a.server as never, ["agent:read"]);
92
- const delivered = pushPermissionVerdict("A", { request_id: "r1", behavior: "allow" });
93
- expect(delivered).toBe(1);
94
- expect(a.captured.notes[0]!.method).toBe("notifications/claude/agent/permission");
95
- expect(a.captured.notes[0]!.params).toMatchObject({ request_id: "r1", behavior: "allow" });
96
- });
97
-
98
- test("push to an unknown channel delivers to nobody (0)", () => {
99
- expect(pushToChannel("nope", "x", {})).toBe(0);
100
- expect(pushPermissionVerdict("nope", { request_id: "r", behavior: "deny" })).toBe(0);
101
- });
102
-
103
- test("a streamless session (registered, no live GET stream) is NOT counted as delivered", () => {
104
- // The bug this guards: a session that POSTed `initialize` but hasn't opened (or
105
- // has dropped) its standalone GET stream is registered, but the SDK silently
106
- // drops any notification to it. If pushToChannel counted it, the daemon would
107
- // advance the channel's delivery mark and the message would be lost.
108
- const a = fakeSession();
109
- _registerSessionForTest("A", "sid-streamless", a.server as never, ["agent:read"], {
110
- streamless: true,
111
- });
112
- expect(mcpSessionCount("A")).toBe(1); // it IS registered…
113
- expect(pushToChannel("A", "into the void", {})).toBe(0); // …but NOT deliverable
114
- expect(a.captured.notes).toHaveLength(0); // not even attempted
115
- // Permission verdicts honor the same gate.
116
- expect(pushPermissionVerdict("A", { request_id: "r", behavior: "allow" })).toBe(0);
117
- });
118
-
119
- test("pushToChannel counts only the live-stream sessions in a mixed set", () => {
120
- const live = fakeSession();
121
- const dead = fakeSession();
122
- _registerSessionForTest("A", "sid-live", live.server as never, ["agent:read"]);
123
- _registerSessionForTest("A", "sid-streamless", dead.server as never, ["agent:read"], {
124
- streamless: true,
125
- });
126
- expect(mcpSessionCount("A")).toBe(2); // both registered
127
- expect(pushToChannel("A", "hi", {})).toBe(1); // only the one with a live stream
128
- expect(live.captured.notes).toHaveLength(1);
129
- expect(dead.captured.notes).toHaveLength(0);
130
- });
131
-
132
- test("the installed MCP SDK still keys the standalone GET stream as we expect (contract guard)", () => {
133
- // If this fails, the SDK renamed the internal sessionHasLivePushStream reads —
134
- // HTTP-MCP delivery would silently break. Catch it here, not in production.
135
- expect(assertMcpSdkStreamContract()).toBe(true);
136
- });
137
-
138
- test("mcpSessionCount tracks registration + reset", () => {
139
- expect(mcpSessionCount("A")).toBe(0);
140
- const a = fakeSession();
141
- _registerSessionForTest("A", "sid-a", a.server as never, ["agent:read"]);
142
- expect(mcpSessionCount("A")).toBe(1);
143
- _resetSessionsForTest();
144
- expect(mcpSessionCount("A")).toBe(0);
145
- });
146
-
147
- test("unregister cleans up the session and drops the empty channel set (no leak)", () => {
148
- _registerSessionForTest("A", "sid-a1", fakeSession().server as never, ["agent:read"]);
149
- _registerSessionForTest("A", "sid-a2", fakeSession().server as never, ["agent:read"]);
150
- expect(mcpSessionCount("A")).toBe(2);
151
- _unregisterSessionForTest("A", "sid-a1");
152
- expect(mcpSessionCount("A")).toBe(1); // the other session survives
153
- _unregisterSessionForTest("A", "sid-a2");
154
- expect(mcpSessionCount("A")).toBe(0); // empty set removed — no orphaned channel entry
155
- // a push to the now-cleaned channel reaches nobody
156
- expect(pushToChannel("A", "hi", {})).toBe(0);
157
- });
158
- });
159
-
160
- // ---------------------------------------------------------------------------
161
- // Tool dispatch — a reply tool call routes to the channel's transport.
162
- // ---------------------------------------------------------------------------
163
-
164
- describe("tool dispatch routes to the channel's transport + enforces write scope", () => {
165
- function fakeTransport(): { transport: Transport; replies: ReplyArgs[] } {
166
- const replies: ReplyArgs[] = [];
167
- const transport: Transport = {
168
- kind: "fake",
169
- async start() {},
170
- async stop() {},
171
- async reply(args: ReplyArgs) {
172
- replies.push(args);
173
- return { sent: ["msg-1"] };
174
- },
175
- };
176
- return { transport, replies };
177
- }
178
-
179
- test("a write-scoped reply call reaches transport.reply with the channel + args", async () => {
180
- const { transport, replies } = fakeTransport();
181
- const { callReplyTool } = await import("./mcp-http.ts");
182
- const result = await callReplyTool("A", transport, ["agent:read", "agent:write"], {
183
- text: "hi there",
184
- chat_id: "42",
185
- });
186
- expect(result.isError).toBeUndefined();
187
- expect(replies).toHaveLength(1);
188
- expect(replies[0]).toMatchObject({ channel: "A", text: "hi there", meta: { chat_id: "42" } });
189
- });
190
-
191
- test("a read-only token cannot call reply (write scope enforced)", async () => {
192
- const { transport, replies } = fakeTransport();
193
- const { callReplyTool } = await import("./mcp-http.ts");
194
- const result = await callReplyTool("A", transport, ["agent:read"], { text: "blocked" });
195
- expect(result.isError).toBe(true);
196
- expect(replies).toHaveLength(0);
197
- expect((result.content[0] as { text: string }).text).toContain("agent:write");
198
- });
199
-
200
- test("DUAL-ACCEPT: a pre-rename token with the LEGACY channel:write scope can still call reply", async () => {
201
- // The write-tool gate must dual-accept (grantsScope), not raw-includes — else
202
- // a pre-rename token connects + is woken but silently can't send (channel#…).
203
- const { transport, replies } = fakeTransport();
204
- const { callReplyTool } = await import("./mcp-http.ts");
205
- const result = await callReplyTool("A", transport, ["channel:read", "channel:write"], {
206
- text: "legacy-send",
207
- });
208
- expect(result.isError).toBeUndefined();
209
- expect(replies).toHaveLength(1);
210
- expect(replies[0]).toMatchObject({ channel: "A", text: "legacy-send" });
211
- });
212
- });
213
-
214
- // ---------------------------------------------------------------------------
215
- // Daemon auth gate — POST /mcp/<channel> with no bearer → 401 (pre-JWKS).
216
- // ---------------------------------------------------------------------------
217
-
218
- describe("daemon /mcp/<channel> auth gate", () => {
219
- let server: ReturnType<typeof Bun.serve>;
220
- let base: string;
221
-
222
- beforeAll(async () => {
223
- const registry = new ClientRegistry();
224
- const transport = new HttpUiTransport({ channel: "ui1" });
225
- await transport.start({ channel: "ui1", emit: () => {}, emitPermissionVerdict: () => {} });
226
- const channels = new Map<string, Channel>([
227
- ["ui1", { name: "ui1", transport, entry: { name: "ui1", transport: "http-ui" } }],
228
- ]);
229
- server = Bun.serve({
230
- port: 0,
231
- hostname: "127.0.0.1",
232
- idleTimeout: 0,
233
- fetch: createFetchHandler(channels, registry),
234
- });
235
- base = `http://127.0.0.1:${server.port}`;
236
- });
237
-
238
- afterAll(() => {
239
- server.stop(true);
240
- });
241
-
242
- test("POST /mcp/ui1 with an initialize body and no bearer → 401", async () => {
243
- const res = await fetch(`${base}/mcp/ui1`, {
244
- method: "POST",
245
- headers: { "content-type": "application/json", accept: "application/json, text/event-stream" },
246
- body: JSON.stringify({
247
- jsonrpc: "2.0",
248
- id: 1,
249
- method: "initialize",
250
- params: { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "t", version: "0" } },
251
- }),
252
- });
253
- expect(res.status).toBe(401);
254
- expect(((await res.json()) as { error: string }).error).toBe("unauthorized");
255
- });
256
-
257
- test("POST /mcp/unknown-channel → 404 (channel miss, before auth body)", async () => {
258
- const res = await fetch(`${base}/mcp/does-not-exist`, {
259
- method: "POST",
260
- headers: { "content-type": "application/json" },
261
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "initialize", params: {} }),
262
- });
263
- expect(res.status).toBe(404);
264
- });
265
- });