@openparachute/agent 0.2.2 → 0.2.3-rc.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.parachute/module.json +3 -3
  2. package/package.json +4 -1
  3. package/src/agent-defs.ts +9 -0
  4. package/src/auth.ts +182 -14
  5. package/src/backends/programmatic.ts +35 -2
  6. package/src/backends/registry.ts +159 -40
  7. package/src/backends/types.ts +44 -0
  8. package/src/daemon.ts +317 -12
  9. package/src/def-vault-triggers.ts +317 -0
  10. package/src/preflight.ts +139 -0
  11. package/src/spawn-agent.ts +16 -0
  12. package/src/step-up.ts +316 -0
  13. package/src/terminal-ui.ts +73 -0
  14. package/src/transports/http-ui.ts +10 -8
  15. package/src/transports/vault.ts +48 -27
  16. package/src/ui-kit.ts +6 -3
  17. package/src/ui-ticket.ts +121 -0
  18. package/web/ui/dist/assets/index-Dhr5Kl_d.css +1 -0
  19. package/web/ui/dist/assets/index-Di5MmFZR.js +60 -0
  20. package/web/ui/dist/index.html +2 -2
  21. package/src/_parked/interactive-spawn.test.ts +0 -324
  22. package/src/_parked/interactive-spawn.ts +0 -701
  23. package/src/agent-defs.test.ts +0 -1504
  24. package/src/agent-mcp-config.test.ts +0 -115
  25. package/src/agents.test.ts +0 -360
  26. package/src/auth.test.ts +0 -46
  27. package/src/backends/attached-queue.test.ts +0 -376
  28. package/src/backends/programmatic.test.ts +0 -1715
  29. package/src/backends/registry.test.ts +0 -1494
  30. package/src/backends/stream-json.test.ts +0 -570
  31. package/src/channel-backend-wiring.test.ts +0 -237
  32. package/src/credentials.test.ts +0 -274
  33. package/src/cron.test.ts +0 -342
  34. package/src/daemon-agent-def-api.test.ts +0 -166
  35. package/src/daemon-agent-defs-api.test.ts +0 -953
  36. package/src/daemon-agent-env-api.test.ts +0 -338
  37. package/src/daemon-attached-queue-store.test.ts +0 -65
  38. package/src/daemon-config-api.test.ts +0 -962
  39. package/src/daemon-jobs-api.test.ts +0 -271
  40. package/src/daemon-vault-chat.test.ts +0 -250
  41. package/src/daemon.test.ts +0 -746
  42. package/src/def-vaults.test.ts +0 -136
  43. package/src/delivery-state.test.ts +0 -110
  44. package/src/effective-env.test.ts +0 -114
  45. package/src/grants.test.ts +0 -638
  46. package/src/hub-jwt.test.ts +0 -161
  47. package/src/jobs.test.ts +0 -245
  48. package/src/mcp-http.test.ts +0 -265
  49. package/src/mint-token.test.ts +0 -152
  50. package/src/module-manifest.test.ts +0 -158
  51. package/src/programmatic-wiring.test.ts +0 -838
  52. package/src/registry.test.ts +0 -227
  53. package/src/resolve-port.test.ts +0 -64
  54. package/src/routing.test.ts +0 -184
  55. package/src/runner.test.ts +0 -506
  56. package/src/sandbox/config.test.ts +0 -150
  57. package/src/sandbox/egress.test.ts +0 -113
  58. package/src/sandbox/live-seatbelt.test.ts +0 -277
  59. package/src/sandbox/mounts.test.ts +0 -154
  60. package/src/sandbox/sandbox.test.ts +0 -168
  61. package/src/services-manifest.test.ts +0 -106
  62. package/src/spa-serve.test.ts +0 -116
  63. package/src/spawn-agent-cli.test.ts +0 -172
  64. package/src/spawn-agent.test.ts +0 -1218
  65. package/src/spawn-deps.test.ts +0 -54
  66. package/src/terminal-assets.test.ts +0 -50
  67. package/src/terminal.test.ts +0 -530
  68. package/src/transports/http-ui.test.ts +0 -455
  69. package/src/transports/telegram.test.ts +0 -174
  70. package/src/transports/vault.test.ts +0 -2011
  71. package/src/ui-kit.test.ts +0 -178
  72. package/web/ui/dist/assets/index-C-iWdFFV.css +0 -1
  73. package/web/ui/dist/assets/index-VFETBk0a.js +0 -60
  74. package/web/ui/tsconfig.json +0 -21
@@ -1,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
- });