@openparachute/agent 0.2.2 → 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 (72) hide show
  1. package/.parachute/module.json +3 -3
  2. package/package.json +4 -1
  3. package/src/agent-defs.ts +9 -0
  4. package/src/auth.ts +182 -14
  5. package/src/backends/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 +2 -2
  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/dist/assets/index-C-iWdFFV.css +0 -1
  71. package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
  72. package/web/ui/tsconfig.json +0 -21
@@ -1,115 +0,0 @@
1
- import { describe, test, expect } from "bun:test";
2
- import {
3
- buildAgentMcpServers,
4
- buildAgentMcpConfigJson,
5
- channelEntryKey,
6
- vaultEntryKey,
7
- } from "./agent-mcp-config.ts";
8
-
9
- describe("channelEntryKey — the per-channel MCP server name (channel→agent rename)", () => {
10
- test("is `agent-<name>` (matches mcp-http.ts buildServer + launch-session.sh)", () => {
11
- // The entry-key + per-channel HTTP-MCP server name moved channel-<name> →
12
- // agent-<name> with the module identity. The channel NAME slug (the domain)
13
- // is preserved; only the `agent-` prefix is the renamed wire surface.
14
- expect(channelEntryKey("eng")).toBe("agent-eng");
15
- expect(channelEntryKey("aaron-dev")).toBe("agent-aaron-dev");
16
- });
17
-
18
- test(".mcp.json mcpServers is keyed by `agent-<name>`", () => {
19
- const parsed = JSON.parse(
20
- buildAgentMcpConfigJson({
21
- channelUrl: "http://127.0.0.1:1941",
22
- channels: [{ channel: "eng", token: "T" }],
23
- }),
24
- ) as { mcpServers: Record<string, unknown> };
25
- expect(Object.keys(parsed.mcpServers)).toEqual(["agent-eng"]);
26
- expect(parsed.mcpServers["agent-eng"]).toBeDefined();
27
- });
28
- });
29
-
30
- describe("buildAgentMcpServers — N-entry strict config", () => {
31
- test("one entry per channel with its own URL + Bearer", () => {
32
- const servers = buildAgentMcpServers({
33
- channelUrl: "http://127.0.0.1:1941",
34
- channels: [
35
- { channel: "aaron-dev", token: "TOK-A" },
36
- { channel: "ops", token: "TOK-B" },
37
- ],
38
- });
39
- expect(servers[channelEntryKey("aaron-dev")]).toEqual({
40
- type: "http",
41
- url: "http://127.0.0.1:1941/mcp/aaron-dev",
42
- headers: { Authorization: "Bearer TOK-A" },
43
- });
44
- expect(servers[channelEntryKey("ops")]).toEqual({
45
- type: "http",
46
- url: "http://127.0.0.1:1941/mcp/ops",
47
- headers: { Authorization: "Bearer TOK-B" },
48
- });
49
- expect(Object.keys(servers)).toHaveLength(2);
50
- });
51
-
52
- test("adds a vault entry with its OWN token (one token per aud)", () => {
53
- const servers = buildAgentMcpServers({
54
- channelUrl: "http://127.0.0.1:1941",
55
- channels: [{ channel: "ch", token: "CH-TOK" }],
56
- vault: { url: "http://127.0.0.1:1940", entry: { name: "default", token: "VAULT-TOK" } },
57
- });
58
- expect(servers[vaultEntryKey("default")]).toEqual({
59
- type: "http",
60
- url: "http://127.0.0.1:1940/vault/default/mcp",
61
- headers: { Authorization: "Bearer VAULT-TOK" },
62
- });
63
- // The channel token and the vault token are DIFFERENT (separate auds).
64
- expect(servers[channelEntryKey("ch")]!.headers!.Authorization).toBe("Bearer CH-TOK");
65
- expect(servers[vaultEntryKey("default")]!.headers!.Authorization).toBe("Bearer VAULT-TOK");
66
- });
67
-
68
- test("adds otherMcps; an entry without a token gets no Authorization header", () => {
69
- const servers = buildAgentMcpServers({
70
- channelUrl: "http://127.0.0.1:1941",
71
- channels: [{ channel: "ch", token: "T" }],
72
- otherMcps: [
73
- { name: "extra", url: "https://mcp.example.com/mcp", token: "X" },
74
- { name: "open", url: "https://open.example.com/mcp" },
75
- ],
76
- });
77
- expect(servers.extra!.headers).toEqual({ Authorization: "Bearer X" });
78
- expect(servers.open!.headers).toBeUndefined();
79
- });
80
-
81
- test("strips a trailing slash on the base URL", () => {
82
- const servers = buildAgentMcpServers({
83
- channelUrl: "http://127.0.0.1:1941/",
84
- channels: [{ channel: "ch", token: "T" }],
85
- });
86
- expect(servers[channelEntryKey("ch")]!.url).toBe("http://127.0.0.1:1941/mcp/ch");
87
- });
88
- });
89
-
90
- describe("buildAgentMcpConfigJson", () => {
91
- test("emits two-space-indented JSON with the mcpServers wrapper", () => {
92
- const json = buildAgentMcpConfigJson({
93
- channelUrl: "http://127.0.0.1:1941",
94
- channels: [{ channel: "ch", token: "T" }],
95
- });
96
- const parsed = JSON.parse(json);
97
- expect(parsed.mcpServers).toBeDefined();
98
- expect(parsed.mcpServers[channelEntryKey("ch")].type).toBe("http");
99
- // Two-space indent (matches runner/vault emission convention).
100
- expect(json).toContain('\n "mcpServers"');
101
- });
102
-
103
- test("round-trips: parse(emit(x)) carries every entry's token", () => {
104
- const input = {
105
- channelUrl: "http://127.0.0.1:1941",
106
- channels: [{ channel: "a", token: "TA" }],
107
- vault: { url: "http://127.0.0.1:1940", entry: { name: "default", token: "TV" } },
108
- };
109
- const parsed = JSON.parse(buildAgentMcpConfigJson(input)) as {
110
- mcpServers: Record<string, { headers?: { Authorization: string } }>;
111
- };
112
- expect(parsed.mcpServers[channelEntryKey("a")]!.headers!.Authorization).toBe("Bearer TA");
113
- expect(parsed.mcpServers[vaultEntryKey("default")]!.headers!.Authorization).toBe("Bearer TV");
114
- });
115
- });
@@ -1,360 +0,0 @@
1
- /**
2
- * Tests for the web agent-management layer (`src/agents.ts`) + the daemon's
3
- * `/agents` page and `/api/agents` routes (`src/daemon.ts`), POST-interactive-retire.
4
- *
5
- * The interactive (tmux) backend was retired 2026-06-19 (design
6
- * 2026-06-19-retire-interactive-backend.md) — its tmux spawner + session admin moved
7
- * to `src/_parked/interactive-spawn.ts` and are tested by
8
- * `src/_parked/interactive-spawn.test.ts`. The daemon no longer has an `agentOps`
9
- * seam; the spawn/list/restart/delete routes are programmatic-only (channel agents
10
- * are vault-native). Programmatic + channel routing is covered in
11
- * `programmatic-wiring.test.ts` / `channel-backend-wiring.test.ts`; here we cover
12
- * `buildSpecFromBody` + the auth gates / shapes of the daemon routes.
13
- */
14
-
15
- import { describe, test, expect, mock } from "bun:test";
16
- // Re-export the REAL error class + helper in the mock below so this process-wide
17
- // `mock.module` doesn't break hub-jwt.test.ts's assertions on the genuine shapes.
18
- import { HubJwtError, looksLikeJwt } from "@openparachute/scope-guard";
19
-
20
- const ADMIN_TOKEN = "test-admin-token"; // agent:admin (the operator gate)
21
- const READ_TOKEN = "test-read-token"; // agent:read only (insufficient)
22
- mock.module("./hub-jwt.ts", () => ({
23
- AGENT_AUDIENCE: "agent",
24
- CHANNEL_AUDIENCE: "channel",
25
- async validateHubJwt(token: string) {
26
- const base = { sub: "test", aud: "agent", jti: undefined, clientId: undefined, vaultScope: undefined };
27
- if (token === ADMIN_TOKEN) return { ...base, scopes: ["agent:read", "agent:send", "agent:admin"] };
28
- if (token === READ_TOKEN) return { ...base, scopes: ["agent:read"] };
29
- throw new HubJwtError("issuer", "invalid token");
30
- },
31
- HubJwtError,
32
- looksLikeJwt,
33
- resetJwksCache() {},
34
- resetRevocationCache() {},
35
- }));
36
-
37
- import { buildSpecFromBody, SpawnRequestError } from "./agents.ts";
38
- import { createFetchHandler } from "./daemon.ts";
39
- import { ClientRegistry } from "./routing.ts";
40
- import { HttpUiTransport } from "./transports/http-ui.ts";
41
- import type { Channel } from "./registry.ts";
42
-
43
- const adminAuth = { authorization: "Bearer " + ADMIN_TOKEN } as const;
44
- const readAuth = { authorization: "Bearer " + READ_TOKEN } as const;
45
-
46
- // ===========================================================================
47
- // buildSpecFromBody — body → validated AgentSpec (valid + every error)
48
- // ===========================================================================
49
- describe("buildSpecFromBody", () => {
50
- test("minimal valid body (one channel, defaults to write + programmatic backend)", () => {
51
- const spec = buildSpecFromBody({ name: "aaron", channels: ["aaron"] });
52
- // backend defaults to "programmatic" for a new request (the interactive default
53
- // was retired 2026-06-19).
54
- expect(spec).toEqual({ name: "aaron", channels: ["aaron"], backend: "programmatic" });
55
- });
56
-
57
- test("rejects a missing/empty name", () => {
58
- expect(() => buildSpecFromBody({ channels: ["c"] })).toThrow(/name/);
59
- expect(() => buildSpecFromBody({ name: "", channels: ["c"] })).toThrow(/name/);
60
- });
61
-
62
- test("rejects a non-slug name", () => {
63
- expect(() => buildSpecFromBody({ name: "../escape", channels: ["c"] })).toThrow(/slug/);
64
- });
65
-
66
- test("rejects missing / empty channels", () => {
67
- expect(() => buildSpecFromBody({ name: "a" })).toThrow(/channels/);
68
- expect(() => buildSpecFromBody({ name: "a", channels: [] })).toThrow(/channels/);
69
- });
70
-
71
- test("scoped channel object form { name, access } is honored", () => {
72
- const spec = buildSpecFromBody({ name: "a", channels: [{ name: "ops", access: "read" }] });
73
- expect(spec.channels).toEqual([{ name: "ops", access: "read" }]);
74
- });
75
-
76
- test("a vault binding with tag-scope is parsed", () => {
77
- const spec = buildSpecFromBody({
78
- name: "a",
79
- channels: ["c"],
80
- vault: { name: "default", access: "write", tags: ["#agent/message"] },
81
- });
82
- expect(spec.vault).toEqual({ name: "default", access: "write", tags: ["#agent/message"] });
83
- });
84
-
85
- test("rejects a bad filesystem / network value", () => {
86
- expect(() => buildSpecFromBody({ name: "a", channels: ["c"], filesystem: "weird" })).toThrow(/filesystem/);
87
- expect(() => buildSpecFromBody({ name: "a", channels: ["c"], network: "weird" })).toThrow(/network/);
88
- });
89
-
90
- test("rejects a non-string workspace", () => {
91
- expect(() => buildSpecFromBody({ name: "a", channels: ["c"], workspace: 42 })).toThrow(/workspace must be a string/);
92
- });
93
-
94
- // Backend selection post-retire: omitted → programmatic; "attached" (and the legacy
95
- // value "channel") is vault-native (rejected with the deflect message); "interactive"
96
- // is retired (rejected); any other value is rejected.
97
- test("omitted backend → programmatic (the new-request default)", () => {
98
- expect(buildSpecFromBody({ name: "a", channels: ["c"] }).backend).toBe("programmatic");
99
- expect(buildSpecFromBody({ name: "a", channels: ["c"], backend: null }).backend).toBe("programmatic");
100
- });
101
- test("explicit backend:\"programmatic\" is honored", () => {
102
- expect(buildSpecFromBody({ name: "a", channels: ["c"], backend: "programmatic" }).backend).toBe("programmatic");
103
- });
104
- test("backend:\"interactive\" is REJECTED (retired)", () => {
105
- expect(() => buildSpecFromBody({ name: "a", channels: ["c"], backend: "interactive" })).toThrow(/retired/);
106
- });
107
- test("backend:\"attached\" is REJECTED via this endpoint (vault-native)", () => {
108
- expect(() => buildSpecFromBody({ name: "a", channels: ["c"], backend: "attached" })).toThrow(/vault-native/);
109
- });
110
- test("the legacy backend:\"channel\" is ALSO deflected as vault-native (dual-read)", () => {
111
- expect(() => buildSpecFromBody({ name: "a", channels: ["c"], backend: "channel" })).toThrow(/vault-native/);
112
- });
113
- test("rejects an invalid backend value", () => {
114
- expect(() => buildSpecFromBody({ name: "a", channels: ["c"], backend: "weird" })).toThrow(/backend/);
115
- });
116
-
117
- // Per-channel system prompt (design 2026-06-16-channel-system-prompt.md).
118
- test("systemPrompt parsed + default mode is append", () => {
119
- const spec = buildSpecFromBody({ name: "a", channels: ["c"], systemPrompt: "You are the eng bot." });
120
- expect(spec.systemPrompt).toBe("You are the eng bot.");
121
- expect(spec.systemPromptMode).toBe("append");
122
- });
123
- test("explicit systemPromptMode:\"replace\" is honored", () => {
124
- const spec = buildSpecFromBody({
125
- name: "a",
126
- channels: ["c"],
127
- systemPrompt: "Full custom persona.",
128
- systemPromptMode: "replace",
129
- });
130
- expect(spec.systemPrompt).toBe("Full custom persona.");
131
- expect(spec.systemPromptMode).toBe("replace");
132
- });
133
- test("absent systemPrompt → both fields undefined", () => {
134
- const spec = buildSpecFromBody({ name: "a", channels: ["c"] });
135
- expect(spec.systemPrompt).toBeUndefined();
136
- expect(spec.systemPromptMode).toBeUndefined();
137
- });
138
- test("blank / whitespace-only systemPrompt is treated as unset (no flag)", () => {
139
- const spec = buildSpecFromBody({ name: "a", channels: ["c"], systemPrompt: " \n " });
140
- expect(spec.systemPrompt).toBeUndefined();
141
- expect(spec.systemPromptMode).toBeUndefined();
142
- });
143
- test("an orphan systemPromptMode with no prompt is dropped (no-op)", () => {
144
- const spec = buildSpecFromBody({ name: "a", channels: ["c"], systemPromptMode: "replace" });
145
- expect(spec.systemPrompt).toBeUndefined();
146
- expect(spec.systemPromptMode).toBeUndefined();
147
- });
148
- test("rejects an invalid systemPromptMode value", () => {
149
- expect(() =>
150
- buildSpecFromBody({ name: "a", channels: ["c"], systemPrompt: "x", systemPromptMode: "merge" }),
151
- ).toThrow(/systemPromptMode/);
152
- });
153
- test("rejects a non-string systemPrompt", () => {
154
- expect(() => buildSpecFromBody({ name: "a", channels: ["c"], systemPrompt: 42 })).toThrow(/systemPrompt must be a string/);
155
- });
156
- test("systemPrompt is trimmed", () => {
157
- const spec = buildSpecFromBody({ name: "a", channels: ["c"], systemPrompt: " hi " });
158
- expect(spec.systemPrompt).toBe("hi");
159
- });
160
- });
161
-
162
- // ===========================================================================
163
- // The daemon routes (real handler, mocked JWT). No interactive `agentOps` seam.
164
- // ===========================================================================
165
- function buildServer() {
166
- const registry = new ClientRegistry();
167
- const transport = new HttpUiTransport({ channel: "ui1" });
168
- const channels = new Map<string, Channel>([
169
- ["ui1", { name: "ui1", transport, entry: { name: "ui1", transport: "http-ui" } }],
170
- ]);
171
- void transport.start({ channel: "ui1", emit: () => {}, emitPermissionVerdict: () => {} });
172
- const srv = Bun.serve({
173
- port: 0,
174
- hostname: "127.0.0.1",
175
- idleTimeout: 0,
176
- fetch: createFetchHandler(channels, registry),
177
- });
178
- return { srv, base: `http://127.0.0.1:${srv.port}` };
179
- }
180
-
181
- describe("GET /agents — retired into the SPA (Phase 4c)", () => {
182
- test("302 redirects to the SPA app root", async () => {
183
- const { srv, base } = buildServer();
184
- try {
185
- const res = await fetch(`${base}/agents`, { redirect: "manual" });
186
- expect(res.status).toBe(302);
187
- expect(res.headers.get("location")).toBe("app/");
188
- } finally {
189
- srv.stop(true);
190
- }
191
- });
192
- });
193
-
194
- describe("/api/agents — operator-gated on agent:admin", () => {
195
- test("GET with no token → 401", async () => {
196
- const { srv, base } = buildServer();
197
- try {
198
- expect((await fetch(`${base}/api/agents`)).status).toBe(401);
199
- } finally {
200
- srv.stop(true);
201
- }
202
- });
203
-
204
- test("GET with agent:read (insufficient) → 403", async () => {
205
- const { srv, base } = buildServer();
206
- try {
207
- expect((await fetch(`${base}/api/agents`, { headers: readAuth })).status).toBe(403);
208
- } finally {
209
- srv.stop(true);
210
- }
211
- });
212
-
213
- test("GET with agent:admin → 200 + an (empty) agent list", async () => {
214
- const { srv, base } = buildServer();
215
- try {
216
- const res = await fetch(`${base}/api/agents`, { headers: adminAuth });
217
- expect(res.status).toBe(200);
218
- const body = (await res.json()) as { agents: unknown[] };
219
- expect(Array.isArray(body.agents)).toBe(true);
220
- // No interactive tmux sessions are merged in anymore — the list is the
221
- // registered programmatic + channel agents (none registered here).
222
- expect(body.agents).toEqual([]);
223
- } finally {
224
- srv.stop(true);
225
- }
226
- });
227
-
228
- test("POST with no token → 401", async () => {
229
- const { srv, base } = buildServer();
230
- try {
231
- const res = await fetch(`${base}/api/agents`, {
232
- method: "POST",
233
- headers: { "content-type": "application/json" },
234
- body: JSON.stringify({ name: "x", channels: ["x"] }),
235
- });
236
- expect(res.status).toBe(401);
237
- } finally {
238
- srv.stop(true);
239
- }
240
- });
241
-
242
- test("POST with admin token + bad spec → 400", async () => {
243
- const { srv, base } = buildServer();
244
- try {
245
- const res = await fetch(`${base}/api/agents`, {
246
- method: "POST",
247
- headers: { ...adminAuth, "content-type": "application/json" },
248
- body: JSON.stringify({ name: "aaron" }), // no channels
249
- });
250
- expect(res.status).toBe(400);
251
- } finally {
252
- srv.stop(true);
253
- }
254
- });
255
-
256
- test("POST with backend:\"interactive\" → 400 (retired)", async () => {
257
- const { srv, base } = buildServer();
258
- try {
259
- const res = await fetch(`${base}/api/agents`, {
260
- method: "POST",
261
- headers: { ...adminAuth, "content-type": "application/json" },
262
- body: JSON.stringify({ name: "aaron", channels: ["aaron"], backend: "interactive" }),
263
- });
264
- expect(res.status).toBe(400);
265
- expect(((await res.json()) as { error: string }).error).toContain("retired");
266
- } finally {
267
- srv.stop(true);
268
- }
269
- });
270
- });
271
-
272
- describe("GET /api/vaults", () => {
273
- test("no token → 401", async () => {
274
- const { srv, base } = buildServer();
275
- try {
276
- expect((await fetch(`${base}/api/vaults`)).status).toBe(401);
277
- } finally {
278
- srv.stop(true);
279
- }
280
- });
281
-
282
- test("admin token → 200 with a vaults array", async () => {
283
- const { srv, base } = buildServer();
284
- try {
285
- const res = await fetch(`${base}/api/vaults`, { headers: adminAuth });
286
- expect(res.status).toBe(200);
287
- const body = (await res.json()) as { vaults: unknown };
288
- expect(Array.isArray(body.vaults)).toBe(true);
289
- } finally {
290
- srv.stop(true);
291
- }
292
- });
293
- });
294
-
295
- describe("DELETE /api/agents/:name", () => {
296
- test("no token → 401", async () => {
297
- const { srv, base } = buildServer();
298
- try {
299
- expect((await fetch(`${base}/api/agents/aaron`, { method: "DELETE" })).status).toBe(401);
300
- } finally {
301
- srv.stop(true);
302
- }
303
- });
304
-
305
- test("agent:read (insufficient) → 403", async () => {
306
- const { srv, base } = buildServer();
307
- try {
308
- expect((await fetch(`${base}/api/agents/aaron`, { method: "DELETE", headers: readAuth })).status).toBe(403);
309
- } finally {
310
- srv.stop(true);
311
- }
312
- });
313
-
314
- test("admin token, no live agent → 200 idempotent no-op { killed: false }", async () => {
315
- const { srv, base } = buildServer();
316
- try {
317
- const res = await fetch(`${base}/api/agents/aaron`, { method: "DELETE", headers: adminAuth });
318
- expect(res.status).toBe(200);
319
- const body = (await res.json()) as { ok: boolean; name: string; killed: boolean };
320
- expect(body).toEqual({ ok: true, name: "aaron", killed: false });
321
- } finally {
322
- srv.stop(true);
323
- }
324
- });
325
- });
326
-
327
- describe("POST /api/agents/:name/restart — per-session restart (agent:admin)", () => {
328
- test("no token → 401", async () => {
329
- const { srv, base } = buildServer();
330
- try {
331
- expect((await fetch(`${base}/api/agents/aaron/restart`, { method: "POST" })).status).toBe(401);
332
- } finally {
333
- srv.stop(true);
334
- }
335
- });
336
-
337
- test("agent:read (insufficient) → 403", async () => {
338
- const { srv, base } = buildServer();
339
- try {
340
- expect((await fetch(`${base}/api/agents/aaron/restart`, { method: "POST", headers: readAuth })).status).toBe(403);
341
- } finally {
342
- srv.stop(true);
343
- }
344
- });
345
-
346
- test("admin token, no programmatic agent by that name → 404", async () => {
347
- const { srv, base } = buildServer();
348
- try {
349
- const res = await fetch(`${base}/api/agents/aaron/restart`, { method: "POST", headers: adminAuth });
350
- expect(res.status).toBe(404);
351
- } finally {
352
- srv.stop(true);
353
- }
354
- });
355
- });
356
-
357
- // Keep a direct reference so the SpawnRequestError import is exercised.
358
- test("SpawnRequestError carries its message", () => {
359
- expect(new SpawnRequestError("boom").message).toBe("boom");
360
- });
package/src/auth.test.ts DELETED
@@ -1,46 +0,0 @@
1
- /**
2
- * Unit tests for the dual-accept scope helpers (channel→agent rename, back-compat
3
- * rule 1). `grantsScope` is the single chokepoint every scope gate routes through;
4
- * an isolated test locks the legacy-alias logic so a future edit can't silently
5
- * drop pre-rename `channel:*` acceptance (the class of bug the mcp-http write-gate
6
- * fix caught). Pure functions — no mocks, no JWKS.
7
- */
8
- import { describe, test, expect } from "bun:test";
9
- import { grantsScope, legacyScopeAlias } from "./auth.ts";
10
-
11
- describe("legacyScopeAlias", () => {
12
- test("maps an agent:<verb> scope to its pre-rename channel:<verb> form", () => {
13
- expect(legacyScopeAlias("agent:read")).toBe("channel:read");
14
- expect(legacyScopeAlias("agent:write")).toBe("channel:write");
15
- expect(legacyScopeAlias("agent:send")).toBe("channel:send");
16
- expect(legacyScopeAlias("agent:admin")).toBe("channel:admin");
17
- });
18
-
19
- test("returns undefined for a scope with no agent: prefix (no legacy alias)", () => {
20
- expect(legacyScopeAlias("vault:read")).toBeUndefined();
21
- expect(legacyScopeAlias("channel:read")).toBeUndefined();
22
- expect(legacyScopeAlias("agentfoo")).toBeUndefined();
23
- });
24
- });
25
-
26
- describe("grantsScope — dual-accept (new agent:* OR legacy channel:*)", () => {
27
- test("grants when the new agent:<verb> scope is present", () => {
28
- expect(grantsScope(["agent:write"], "agent:write")).toBe(true);
29
- });
30
-
31
- test("grants when only the legacy channel:<verb> scope is present (pre-rename token)", () => {
32
- expect(grantsScope(["channel:write"], "agent:write")).toBe(true);
33
- expect(grantsScope(["channel:read", "channel:send"], "agent:send")).toBe(true);
34
- });
35
-
36
- test("denies when neither the new nor the legacy scope is present", () => {
37
- expect(grantsScope(["agent:read"], "agent:write")).toBe(false);
38
- expect(grantsScope(["vault:write"], "agent:write")).toBe(false);
39
- expect(grantsScope([], "agent:read")).toBe(false);
40
- });
41
-
42
- test("does NOT cross-grant a different verb via the alias", () => {
43
- // channel:read must not satisfy agent:write — the alias is per-verb only.
44
- expect(grantsScope(["channel:read"], "agent:write")).toBe(false);
45
- });
46
- });