@desplega.ai/agent-swarm 1.103.0 → 1.104.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 (122) hide show
  1. package/README.md +2 -0
  2. package/dist/{actions-7txktrdj.js → actions-2expzt47.js} +6 -6
  3. package/dist/{app-xm3xsdye.js → app-dr8hwx7z.js} +3 -3
  4. package/dist/{assistant-qpv63wk6.js → assistant-9rhgsw7v.js} +9 -10
  5. package/dist/{boot-reembed-chxhrwv8.js → boot-reembed-3q1aywnz.js} +5 -4
  6. package/dist/{boot-reembed-9cxqka3e.js → boot-reembed-9kt97g1z.js} +4 -3
  7. package/dist/{boot-scrub-logs-8qtfkcz8.js → boot-scrub-logs-vskq108e.js} +2 -2
  8. package/dist/{cli-fedn86nz.js → cli-0jw6j755.js} +8 -3
  9. package/dist/{cli-yeemwevj.js → cli-23zk7xqy.js} +20 -16
  10. package/dist/{cli-v9283nma.js → cli-2g9jf9ek.js} +1 -1
  11. package/dist/{cli-rvae030h.js → cli-2hap19dv.js} +3 -3
  12. package/dist/{cli-wjyyv518.js → cli-3j01d33e.js} +9 -14
  13. package/dist/{cli-8hyv6c2v.js → cli-54h5daha.js} +41 -23
  14. package/dist/{cli-fygaw191.js → cli-5pf0b84v.js} +2 -2
  15. package/dist/{cli-m8f6qzck.js → cli-7v4y3hyd.js} +1 -1
  16. package/dist/{cli-fwxdt7kt.js → cli-d4z1csms.js} +30 -5
  17. package/dist/{cli-jt9d9fjw.js → cli-e7bwjntj.js} +1 -1
  18. package/dist/{cli-s14sb64w.js → cli-etvgz5x8.js} +5 -7
  19. package/dist/{cli-2qynerth.js → cli-gj2n2ec1.js} +1 -1
  20. package/dist/{cli-dz3d6zjg.js → cli-nzc0h54v.js} +121 -2
  21. package/dist/{cli-8w95v19d.js → cli-p4cj4vhc.js} +351 -24
  22. package/dist/{cli-xmtsan9k.js → cli-q5tshxd6.js} +1 -1
  23. package/dist/{http-zts4haq7.js → cli-qvzgncd8.js} +83966 -76422
  24. package/dist/{cli-4fnp2tc3.js → cli-s42f54ja.js} +1 -1
  25. package/dist/{cli-krvxq8bc.js → cli-t9v14cnd.js} +23 -4
  26. package/dist/{cli-gzepjz54.js → cli-v1syqkcb.js} +900 -352
  27. package/dist/{cli-ksq38x4n.js → cli-w1nfskkg.js} +2 -2
  28. package/dist/{cli-ns0r7mkr.js → cli-y9p3e94h.js} +3 -3
  29. package/dist/{cli-85pf4z38.js → cli-yjt8a36k.js} +3 -3
  30. package/dist/{cli-yfwwjeft.js → cli-yvektv79.js} +1 -1
  31. package/dist/{cli-kk51f3tf.js → cli-zaxksbgj.js} +5 -5
  32. package/dist/{cli-rttgde5f.js → cli-zghef0em.js} +1 -1
  33. package/dist/cli.js +10 -10
  34. package/dist/{commands-aqn63mcw.js → commands-wfrkef57.js} +2 -2
  35. package/dist/{db-azrvwsnj.js → db-c42pxt7h.js} +2 -2
  36. package/dist/{handlers-hf61har3.js → handlers-n36zkfvx.js} +10 -12
  37. package/dist/{hook-306p3yz4.js → hook-gw3vhv6s.js} +7 -5
  38. package/dist/http-59ygpn2h.js +17263 -0
  39. package/dist/{index-h2yqjyk0.js → index-881c1baf.js} +9 -8
  40. package/dist/{index-29vg51x6.js → index-jca7jdpt.js} +23 -10
  41. package/dist/{index-bfh9hgek.js → index-xf6fp4y1.js} +5 -5
  42. package/dist/{index-3c651yfk.js → index-xz8vaa5c.js} +14 -11
  43. package/dist/{keepalive-a0dg5pds.js → keepalive-mj4302vw.js} +4 -4
  44. package/dist/{lead-7mcxedkc.js → lead-qm0xjvkv.js} +19 -19
  45. package/dist/{maintenance-mrzqd53t.js → maintenance-xppxbe4t.js} +5 -4
  46. package/dist/{onboard-4dkpv9wa.js → onboard-ejx6yxh8.js} +2 -2
  47. package/dist/{otel-impl-jt7gpyp9.js → otel-impl-zs5exkpk.js} +1 -1
  48. package/dist/{pricing-refresh-xqnzh8dt.js → pricing-refresh-nqyf8bkb.js} +4 -4
  49. package/dist/{seed-pricing-56tjm1fj.js → seed-pricing-w3x550cj.js} +3 -3
  50. package/dist/{setup-tbwk8j10.js → setup-7729ktfj.js} +2 -2
  51. package/dist/{worker-e03za92t.js → worker-4ttemwhw.js} +19 -19
  52. package/openapi.json +90 -4
  53. package/package.json +3 -3
  54. package/src/be/memory/constants.ts +6 -0
  55. package/src/be/memory/providers/sqlite-store.ts +468 -71
  56. package/src/be/memory/raters/retrieval.ts +4 -2
  57. package/src/be/memory/reranker.ts +5 -2
  58. package/src/be/memory/retrieval-store.ts +2 -0
  59. package/src/be/memory/types.ts +46 -0
  60. package/src/be/migrations/099_memory_structured_key_versioning.sql +41 -0
  61. package/src/be/migrations/100_memory_retrieval_source.sql +8 -0
  62. package/src/be/migrations/101_script_connections.sql +67 -0
  63. package/src/be/script-connections.ts +744 -0
  64. package/src/be/script-credential-broker.ts +38 -0
  65. package/src/be/scripts/typecheck.ts +23 -2
  66. package/src/be/seed-scripts/catalog/boot-triage.inline.ts +16 -4
  67. package/src/be/seed-scripts/catalog/boot-triage.ts +16 -4
  68. package/src/commands/runner.ts +8 -6
  69. package/src/heartbeat/heartbeat.ts +42 -1
  70. package/src/heartbeat/index.ts +7 -1
  71. package/src/http/memory.ts +127 -14
  72. package/src/http/scripts.ts +15 -14
  73. package/src/scripts-runtime/api-client.ts +100 -0
  74. package/src/scripts-runtime/api-types.ts +45 -0
  75. package/src/scripts-runtime/credential-broker/README.md +40 -0
  76. package/src/scripts-runtime/credential-broker/broker.ts +51 -0
  77. package/src/scripts-runtime/credential-broker/default-bindings.ts +19 -0
  78. package/src/scripts-runtime/credential-broker/fetch-patch.ts +102 -0
  79. package/src/scripts-runtime/credential-broker/index.ts +17 -0
  80. package/src/scripts-runtime/credential-broker/store.ts +72 -0
  81. package/src/scripts-runtime/credential-broker/types.ts +54 -0
  82. package/src/scripts-runtime/ctx.ts +11 -1
  83. package/src/scripts-runtime/egress-secrets.ts +14 -76
  84. package/src/scripts-runtime/eval-harness.ts +1 -1
  85. package/src/scripts-runtime/executors/types.ts +5 -5
  86. package/src/scripts-runtime/loader.ts +6 -1
  87. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  88. package/src/scripts-runtime/types/stdlib.d.ts +11 -0
  89. package/src/scripts-runtime/types/swarm-sdk.d.ts +11 -0
  90. package/src/server.ts +6 -0
  91. package/src/tests/credential-broker.test.ts +281 -0
  92. package/src/tests/heartbeat.test.ts +239 -0
  93. package/src/tests/memory-edit.test.ts +177 -0
  94. package/src/tests/memory-hybrid.test.ts +212 -0
  95. package/src/tests/runner-repo-autostash.test.ts +39 -8
  96. package/src/tests/script-connections.test.ts +360 -0
  97. package/src/tests/scripts-http.test.ts +16 -10
  98. package/src/tests/scripts-runtime-secret-egress.test.ts +26 -5
  99. package/src/tests/swarm-config-reserved-keys.test.ts +23 -0
  100. package/src/tests/update-schedule-mcp-tool.test.ts +83 -4
  101. package/src/tools/credential-bindings/index.ts +1 -0
  102. package/src/tools/credential-bindings/tool.ts +238 -0
  103. package/src/tools/memory-edit.ts +148 -0
  104. package/src/tools/memory-search.ts +17 -10
  105. package/src/tools/schedules/delete-schedule.ts +18 -8
  106. package/src/tools/schedules/update-schedule.ts +4 -9
  107. package/src/tools/script-connections/index.ts +1 -0
  108. package/src/tools/script-connections/tool.ts +167 -0
  109. package/src/tools/swarm-config/delete-config.ts +5 -0
  110. package/src/tools/swarm-config/set-config.ts +23 -1
  111. package/src/tools/tool-config.ts +6 -1
  112. package/src/types.ts +6 -0
  113. package/src/workflows/executors/swarm-script.ts +2 -0
  114. package/templates/community/dora-metrics/PLAYBOOK.md +294 -0
  115. package/templates/community/dora-metrics/README.md +39 -0
  116. package/templates/community/dora-metrics/lead-prompt.md +56 -0
  117. package/templates/community/dora-metrics/report.mjs +393 -0
  118. package/templates/community/dora-metrics/run.sh +113 -0
  119. package/templates/schedules/weekly-dora-metrics/config.json +13 -0
  120. package/templates/schedules/weekly-dora-metrics/content.md +61 -0
  121. package/dist/cli-2307phk1.js +0 -24223
  122. package/dist/cli-2deg0cen.js +0 -120
@@ -0,0 +1,360 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { createAgent, deleteSwarmConfig, getDb, upsertSwarmConfig } from "../be/db";
4
+ import {
5
+ getScriptApiConnectionDescriptors,
6
+ upsertCredentialBinding,
7
+ upsertScriptConnection,
8
+ } from "../be/script-connections";
9
+ import { buildScriptCredentialBindings } from "../be/script-credential-broker";
10
+ import { typecheckScript } from "../be/scripts/typecheck";
11
+ import { runScript } from "../scripts-runtime/loader";
12
+ import { registerScriptConnectionsTool } from "../tools/script-connections";
13
+
14
+ const createdBindingIds: string[] = [];
15
+ const createdConnectionIds: string[] = [];
16
+ const createdConfigIds: string[] = [];
17
+ const originalFetch = globalThis.fetch;
18
+ const savedEnv = { ...process.env };
19
+ const resources = { memoryMb: 2048, cpuTimeSec: 20, maxStdoutBytes: 1_048_576 };
20
+
21
+ type RegisteredTool = {
22
+ handler: (args: unknown, extra: unknown) => Promise<unknown>;
23
+ };
24
+
25
+ function scriptConnectionsTool() {
26
+ const server = new McpServer({ name: "script-connections-test", version: "1.0.0" });
27
+ registerScriptConnectionsTool(server);
28
+ const registered = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
29
+ ._registeredTools;
30
+ const tool = registered["script-connections"];
31
+ if (!tool) throw new Error("script-connections tool not registered");
32
+ return tool;
33
+ }
34
+
35
+ function meta(agentId: string) {
36
+ return {
37
+ sessionId: "script-connections-test-session",
38
+ requestInfo: { headers: { "x-agent-id": agentId } },
39
+ };
40
+ }
41
+
42
+ const openapiSpec = JSON.stringify({
43
+ openapi: "3.1.0",
44
+ info: { title: "Vendor", version: "1.0.0" },
45
+ paths: {
46
+ "/repos/{owner}/{repo}": {
47
+ get: {
48
+ operationId: "getRepo",
49
+ parameters: [
50
+ { name: "owner", in: "path", required: true, schema: { type: "string" } },
51
+ { name: "repo", in: "path", required: true, schema: { type: "string" } },
52
+ { name: "include", in: "query", schema: { type: "string" } },
53
+ ],
54
+ responses: {
55
+ "200": {
56
+ description: "repo",
57
+ content: {
58
+ "application/json": {
59
+ schema: {
60
+ type: "object",
61
+ required: ["full_name"],
62
+ properties: {
63
+ full_name: { type: "string" },
64
+ private: { type: "boolean" },
65
+ },
66
+ },
67
+ },
68
+ },
69
+ },
70
+ },
71
+ },
72
+ },
73
+ "/repos": {
74
+ post: {
75
+ operationId: "createRepo",
76
+ requestBody: {
77
+ required: true,
78
+ content: {
79
+ "application/json": {
80
+ schema: {
81
+ type: "object",
82
+ required: ["name"],
83
+ properties: {
84
+ name: { type: "string" },
85
+ private: { type: "boolean" },
86
+ },
87
+ },
88
+ },
89
+ },
90
+ },
91
+ responses: {
92
+ "201": {
93
+ description: "created repo",
94
+ content: {
95
+ "application/json": {
96
+ schema: {
97
+ type: "object",
98
+ required: ["full_name"],
99
+ properties: {
100
+ full_name: { type: "string" },
101
+ },
102
+ },
103
+ },
104
+ },
105
+ },
106
+ },
107
+ },
108
+ },
109
+ },
110
+ });
111
+
112
+ beforeEach(() => {
113
+ process.env.AGENT_SWARM_API_KEY = "script-connections-test-key";
114
+ delete process.env.API_KEY;
115
+ process.env.MCP_BASE_URL = "http://localhost:3013";
116
+ });
117
+
118
+ afterEach(() => {
119
+ globalThis.fetch = originalFetch;
120
+ for (const key of Object.keys(process.env)) {
121
+ if (!(key in savedEnv)) delete process.env[key];
122
+ }
123
+ for (const [key, value] of Object.entries(savedEnv)) {
124
+ if (value === undefined) delete process.env[key];
125
+ else process.env[key] = value;
126
+ }
127
+ const db = getDb();
128
+ for (const id of createdConnectionIds.splice(0)) {
129
+ db.run("DELETE FROM script_connections WHERE id = ?", id);
130
+ }
131
+ for (const id of createdBindingIds.splice(0)) {
132
+ db.run("DELETE FROM script_credential_bindings WHERE id = ?", id);
133
+ }
134
+ for (const id of createdConfigIds.splice(0)) {
135
+ deleteSwarmConfig(id);
136
+ }
137
+ });
138
+
139
+ describe("script connections", () => {
140
+ test("relational credential bindings are resolved before legacy JSON config", () => {
141
+ const binding = upsertCredentialBinding({
142
+ configKey: "REL_VENDOR_KEY",
143
+ allowedHosts: ["api.vendor.test"],
144
+ headerTemplate: "Authorization: Bearer [REDACTED:REL_VENDOR_KEY]",
145
+ });
146
+ createdBindingIds.push(binding.id);
147
+ const secretConfig = upsertSwarmConfig({
148
+ scope: "global",
149
+ key: "REL_VENDOR_KEY",
150
+ value: "rel-secret",
151
+ isSecret: true,
152
+ });
153
+ createdConfigIds.push(secretConfig.id);
154
+
155
+ const egressSecrets = buildScriptCredentialBindings({});
156
+
157
+ expect(egressSecrets).toContainEqual(
158
+ expect.objectContaining({
159
+ configKey: "REL_VENDOR_KEY",
160
+ allowedHosts: ["api.vendor.test"],
161
+ value: "rel-secret",
162
+ }),
163
+ );
164
+ });
165
+
166
+ test("credential binding upsert is idempotent by binding identity", () => {
167
+ const first = upsertCredentialBinding({
168
+ configKey: "IDEMPOTENT_VENDOR_KEY",
169
+ allowedHosts: ["old.vendor.test"],
170
+ headerTemplate: "Authorization: Bearer [REDACTED:IDEMPOTENT_VENDOR_KEY]",
171
+ });
172
+ createdBindingIds.push(first.id);
173
+
174
+ const second = upsertCredentialBinding({
175
+ configKey: "IDEMPOTENT_VENDOR_KEY",
176
+ allowedHosts: ["new.vendor.test"],
177
+ headerTemplate: "Authorization: Bearer [REDACTED:IDEMPOTENT_VENDOR_KEY]",
178
+ });
179
+
180
+ expect(second.id).toBe(first.id);
181
+ expect(second.allowedHosts).toEqual(["new.vendor.test"]);
182
+ });
183
+
184
+ test("OpenAPI connections generate full ctx.api method, args, and response types", () => {
185
+ const binding = upsertCredentialBinding({
186
+ configKey: "TYPE_VENDOR_KEY",
187
+ allowedHosts: ["api.vendor.test"],
188
+ headerTemplate: "Authorization: Bearer [REDACTED:TYPE_VENDOR_KEY]",
189
+ });
190
+ createdBindingIds.push(binding.id);
191
+ const connection = upsertScriptConnection({
192
+ slug: "vendorApi",
193
+ kind: "openapi",
194
+ baseUrl: "https://api.vendor.test",
195
+ credentialBindingId: binding.id,
196
+ openapiSpecJson: openapiSpec,
197
+ });
198
+ createdConnectionIds.push(connection.id);
199
+
200
+ expect(connection.generationError).toBeNull();
201
+ const source = `
202
+ import type { ScriptMain } from "swarm-sdk";
203
+ const main: ScriptMain = async (_args, ctx) => {
204
+ const repo = await ctx.api.vendorApi.getRepo({
205
+ path: { owner: "desplega-ai", repo: "agent-swarm" },
206
+ query: { include: "stats" },
207
+ });
208
+ const created = await ctx.api.vendorApi.createRepo({
209
+ body: { name: "agent-swarm", private: false },
210
+ });
211
+ const name: string = repo.full_name;
212
+ const createdName: string = created.full_name;
213
+ const isPrivate: boolean | undefined = repo.private;
214
+ return { name, createdName, isPrivate };
215
+ };
216
+ export default main;
217
+ `;
218
+
219
+ expect(typecheckScript(source)).toEqual({ ok: true });
220
+ const descriptor = getScriptApiConnectionDescriptors().find(
221
+ (candidate) => candidate.slug === "vendorApi",
222
+ );
223
+ const getRepo = descriptor?.operations.find((operation) => operation.name === "getRepo");
224
+ expect(getRepo?.parameters).toContainEqual({
225
+ name: "owner",
226
+ in: "path",
227
+ required: true,
228
+ schema: { type: "string" },
229
+ });
230
+ expect(getRepo?.responseSchema).toEqual({
231
+ type: "object",
232
+ required: ["full_name"],
233
+ properties: {
234
+ full_name: { type: "string" },
235
+ private: { type: "boolean" },
236
+ },
237
+ });
238
+ const createRepo = descriptor?.operations.find((operation) => operation.name === "createRepo");
239
+ expect(createRepo?.requestBodySchema).toEqual({
240
+ type: "object",
241
+ required: ["name"],
242
+ properties: {
243
+ name: { type: "string" },
244
+ private: { type: "boolean" },
245
+ },
246
+ });
247
+ });
248
+
249
+ test("script-connections tool registers OpenAPI connections from an agent header without FK failures", async () => {
250
+ const suffix = crypto.randomUUID().replace(/-/g, "");
251
+ const slug = `agentHeaderVendor${suffix}`;
252
+ const configKey = `AGENT_HEADER_VENDOR_KEY_${suffix}`;
253
+ const lead = createAgent({
254
+ name: `script-connections-lead-${crypto.randomUUID()}`,
255
+ isLead: true,
256
+ status: "idle",
257
+ });
258
+ const tool = scriptConnectionsTool();
259
+
260
+ const result = (await tool.handler(
261
+ {
262
+ action: "upsert-openapi",
263
+ slug,
264
+ displayName: "Agent Header Vendor",
265
+ baseUrl: "https://api.vendor.test",
266
+ allowedHosts: ["api.vendor.test"],
267
+ configKey,
268
+ openapiSpecJson: openapiSpec,
269
+ },
270
+ meta(lead.id),
271
+ )) as {
272
+ structuredContent: { success: boolean; message: string; connections: Array<{ id: string }> };
273
+ };
274
+
275
+ expect(result.structuredContent.success).toBe(true);
276
+
277
+ const db = getDb();
278
+ const connectionRow = db
279
+ .prepare<
280
+ {
281
+ id: string;
282
+ credential_binding_id: string | null;
283
+ created_by: string | null;
284
+ updated_by: string | null;
285
+ },
286
+ [string]
287
+ >(
288
+ "SELECT id, credential_binding_id, created_by, updated_by FROM script_connections WHERE slug = ?",
289
+ )
290
+ .get(slug);
291
+ expect(connectionRow).toBeDefined();
292
+ expect(connectionRow?.created_by).toBeNull();
293
+ expect(connectionRow?.updated_by).toBeNull();
294
+ createdConnectionIds.push(connectionRow!.id);
295
+
296
+ const bindingRow = db
297
+ .prepare<{ id: string; created_by: string | null; updated_by: string | null }, [string]>(
298
+ "SELECT id, created_by, updated_by FROM script_credential_bindings WHERE config_key = ?",
299
+ )
300
+ .get(configKey);
301
+ expect(bindingRow).toBeDefined();
302
+ expect(bindingRow?.id).toBe(connectionRow?.credential_binding_id);
303
+ expect(bindingRow?.created_by).toBeNull();
304
+ expect(bindingRow?.updated_by).toBeNull();
305
+ createdBindingIds.push(bindingRow!.id);
306
+ });
307
+
308
+ test("ctx.api runtime emits plain fetch with credential placeholders", async () => {
309
+ let observed: { url: string; authorization: string | null } | null = null;
310
+ const server = Bun.serve({
311
+ port: 0,
312
+ fetch(req) {
313
+ observed = {
314
+ url: req.url,
315
+ authorization: req.headers.get("authorization"),
316
+ };
317
+ return Response.json({ full_name: "desplega-ai/agent-swarm", private: false });
318
+ },
319
+ });
320
+ const binding = upsertCredentialBinding({
321
+ configKey: "RUNTIME_VENDOR_KEY",
322
+ allowedHosts: [`127.0.0.1:${server.port}`],
323
+ headerTemplate: "Authorization: Bearer [REDACTED:RUNTIME_VENDOR_KEY]",
324
+ });
325
+ createdBindingIds.push(binding.id);
326
+ const connection = upsertScriptConnection({
327
+ slug: "runtimeVendor",
328
+ kind: "openapi",
329
+ baseUrl: `http://127.0.0.1:${server.port}`,
330
+ credentialBindingId: binding.id,
331
+ openapiSpecJson: openapiSpec,
332
+ });
333
+ createdConnectionIds.push(connection.id);
334
+
335
+ try {
336
+ const output = await runScript({
337
+ agentId: "agent-1",
338
+ resources,
339
+ apiConnections: getScriptApiConnectionDescriptors(),
340
+ source: `
341
+ export default async (_args, ctx) => {
342
+ return await ctx.api.runtimeVendor.getRepo({
343
+ path: { owner: "desplega-ai", repo: "agent-swarm" },
344
+ query: { include: "stats" },
345
+ });
346
+ };
347
+ `,
348
+ });
349
+
350
+ expect(output.error).toBeUndefined();
351
+ expect(output.result).toEqual({ full_name: "desplega-ai/agent-swarm", private: false });
352
+ expect(observed).toEqual({
353
+ url: `http://127.0.0.1:${server.port}/repos/desplega-ai/agent-swarm?include=stats`,
354
+ authorization: "Bearer [REDACTED:RUNTIME_VENDOR_KEY]",
355
+ });
356
+ } finally {
357
+ server.stop(true);
358
+ }
359
+ });
360
+ });
@@ -316,18 +316,13 @@ describe("/api/scripts HTTP", () => {
316
316
  expect((await res.json()).error).toBe("typecheck_failed");
317
317
  });
318
318
 
319
- test("global upsert is lead-only and writes audit events", async () => {
320
- const denied = await upsert(
321
- { name: "global-denied", scope: "global", source: validSource(2) },
322
- workerId,
323
- );
324
- expect(denied.status).toBe(403);
325
-
319
+ test("non-lead agents can upsert and delete global scripts", async () => {
326
320
  const allowed = await upsert(
327
- { name: "global-ok", scope: "global", source: validSource(2) },
328
- leadId,
321
+ { name: "global-worker-ok", scope: "global", source: validSource(2) },
322
+ workerId,
329
323
  );
330
324
  expect(allowed.status).toBe(200);
325
+ expect(getScript({ name: "global-worker-ok", scope: "global", scopeId: null })).toBeTruthy();
331
326
 
332
327
  const event = getDb()
333
328
  .prepare<{ data: string }, []>(
@@ -335,7 +330,18 @@ describe("/api/scripts HTTP", () => {
335
330
  )
336
331
  .get();
337
332
  expect(event).toBeTruthy();
338
- expect(JSON.parse(event!.data).isNew).toBe(true);
333
+ expect(JSON.parse(event!.data)).toMatchObject({
334
+ isNew: true,
335
+ changedByAgentId: workerId,
336
+ });
337
+
338
+ const del = await dispatch("/api/scripts/global-worker-ok?scope=global", {
339
+ method: "DELETE",
340
+ agentId: workerId,
341
+ });
342
+ expect(del.status).toBe(200);
343
+ expect(await del.json()).toEqual({ deleted: true });
344
+ expect(getScript({ name: "global-worker-ok", scope: "global", scopeId: null })).toBeNull();
339
345
  });
340
346
 
341
347
  test("global upsert promotion marks isPromotion when caller has agent script with same name", async () => {
@@ -54,8 +54,13 @@ describe("egress-substitution", () => {
54
54
  const secrets = buildEgressSecrets();
55
55
  expect(secrets).toEqual([
56
56
  {
57
+ configKey: "GITHUB_TOKEN",
57
58
  placeholder: "[REDACTED:GITHUB_TOKEN]",
58
- hosts: ["api.github.com"],
59
+ allowedHosts: ["api.github.com"],
60
+ headerTemplate: "Authorization: Bearer [REDACTED:GITHUB_TOKEN]",
61
+ scope: "global",
62
+ scopeId: null,
63
+ active: true,
59
64
  value: "ghp_test1234567890abcdef",
60
65
  },
61
66
  ]);
@@ -89,7 +94,11 @@ describe("egress-substitution", () => {
89
94
  patchFetchWithEgressSubstitution([
90
95
  {
91
96
  placeholder: "[REDACTED:GITHUB_TOKEN]",
92
- hosts: ["api.github.com"],
97
+ allowedHosts: ["api.github.com"],
98
+ headerTemplate: "Authorization: Bearer [REDACTED:GITHUB_TOKEN]",
99
+ scope: "global",
100
+ scopeId: null,
101
+ active: true,
93
102
  value: "ghp_real_secret_value_123",
94
103
  },
95
104
  ]);
@@ -111,7 +120,11 @@ describe("egress-substitution", () => {
111
120
  patchFetchWithEgressSubstitution([
112
121
  {
113
122
  placeholder: "[REDACTED:GITHUB_TOKEN]",
114
- hosts: ["api.github.com"],
123
+ allowedHosts: ["api.github.com"],
124
+ headerTemplate: "Authorization: Bearer [REDACTED:GITHUB_TOKEN]",
125
+ scope: "global",
126
+ scopeId: null,
127
+ active: true,
115
128
  value: "ghp_real_secret_value_123",
116
129
  },
117
130
  ]);
@@ -133,7 +146,11 @@ describe("egress-substitution", () => {
133
146
  patchFetchWithEgressSubstitution([
134
147
  {
135
148
  placeholder: "[REDACTED:GITHUB_TOKEN]",
136
- hosts: ["api.github.com"],
149
+ allowedHosts: ["api.github.com"],
150
+ headerTemplate: "Authorization: Bearer [REDACTED:GITHUB_TOKEN]",
151
+ scope: "global",
152
+ scopeId: null,
153
+ active: true,
137
154
  value: "ghp_real_secret_value_123",
138
155
  },
139
156
  ]);
@@ -155,7 +172,11 @@ describe("egress-substitution", () => {
155
172
  patchFetchWithEgressSubstitution([
156
173
  {
157
174
  placeholder: "[REDACTED:GITHUB_TOKEN]",
158
- hosts: ["api.github.com"],
175
+ allowedHosts: ["api.github.com"],
176
+ headerTemplate: "Authorization: Bearer [REDACTED:GITHUB_TOKEN]",
177
+ scope: "global",
178
+ scopeId: null,
179
+ active: true,
159
180
  value: "ghp_real_secret_value_123",
160
181
  },
161
182
  ]);
@@ -3,6 +3,7 @@ import { unlink } from "node:fs/promises";
3
3
  import { createServer as createHttpServer, type Server } from "node:http";
4
4
  import {
5
5
  closeDb,
6
+ createAgent,
6
7
  deleteSwarmConfig,
7
8
  getDb,
8
9
  getSwarmConfigById,
@@ -13,6 +14,7 @@ import {
13
14
  import { isReservedConfigKey, reservedKeyError } from "../be/swarm-config-guard";
14
15
  import { handleConfig } from "../http/config";
15
16
  import { getPathSegments, parseQueryParams } from "../http/utils";
17
+ import { CREDENTIAL_BINDINGS_CONFIG_KEY } from "../scripts-runtime/credential-broker";
16
18
  import { registerDeleteConfigTool } from "../tools/swarm-config/delete-config";
17
19
  import { registerListConfigTool } from "../tools/swarm-config/list-config";
18
20
  import { registerSetConfigTool } from "../tools/swarm-config/set-config";
@@ -262,6 +264,27 @@ describe("swarm-config reserved keys guard", () => {
262
264
 
263
265
  expect(result.structuredContent.success).toBe(true);
264
266
  });
267
+
268
+ test("rejects SCRIPT_CREDENTIAL_BINDINGS from non-lead set-config callers", async () => {
269
+ const worker = createAgent({
270
+ name: "config-worker",
271
+ isLead: false,
272
+ status: "idle",
273
+ capabilities: [],
274
+ });
275
+ const handler = mcpServer.handlers.get("set-config");
276
+ const result = (await handler!(
277
+ {
278
+ scope: "global",
279
+ key: CREDENTIAL_BINDINGS_CONFIG_KEY,
280
+ value: JSON.stringify({ bindings: [] }),
281
+ },
282
+ makeRequestInfo(worker.id),
283
+ )) as { structuredContent: { success: boolean; message: string } };
284
+
285
+ expect(result.structuredContent.success).toBe(false);
286
+ expect(result.structuredContent.message).toContain("Only the lead can manage");
287
+ });
265
288
  });
266
289
 
267
290
  // ─── MCP tool: delete-config ──────────────────────────────────────────────
@@ -11,7 +11,15 @@ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
11
  import { unlink } from "node:fs/promises";
12
12
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
13
13
  import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
14
- import { closeDb, createAgent, createScheduledTask, getScheduledTaskById, initDb } from "../be/db";
14
+ import {
15
+ closeDb,
16
+ createAgent,
17
+ createScheduledTask,
18
+ getDb,
19
+ getScheduledTaskById,
20
+ initDb,
21
+ } from "../be/db";
22
+ import { registerDeleteScheduleTool } from "../tools/schedules/delete-schedule";
15
23
  import { registerUpdateScheduleTool } from "../tools/schedules/update-schedule";
16
24
 
17
25
  const TEST_DB_PATH = "./test-update-schedule-mcp-tool.sqlite";
@@ -23,24 +31,34 @@ type RegisteredTool = {
23
31
  function buildServer(): McpServer {
24
32
  const server = new McpServer({ name: "update-schedule-mcp-test", version: "1.0.0" });
25
33
  registerUpdateScheduleTool(server);
34
+ registerDeleteScheduleTool(server);
26
35
  return server;
27
36
  }
28
37
 
29
- function callUpdateSchedule(
38
+ function callTool(
30
39
  server: McpServer,
40
+ toolName: string,
31
41
  args: Record<string, unknown>,
32
42
  callerAgentId: string,
33
43
  ): Promise<CallToolResult> {
34
44
  const tools = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
35
45
  ._registeredTools;
36
- const tool = tools["update-schedule"];
37
- if (!tool) throw new Error("update-schedule not registered");
46
+ const tool = tools[toolName];
47
+ if (!tool) throw new Error(`${toolName} not registered`);
38
48
  return tool.handler(args, {
39
49
  sessionId: "test-session",
40
50
  requestInfo: { headers: { "x-agent-id": callerAgentId } },
41
51
  });
42
52
  }
43
53
 
54
+ function callUpdateSchedule(
55
+ server: McpServer,
56
+ args: Record<string, unknown>,
57
+ callerAgentId: string,
58
+ ): Promise<CallToolResult> {
59
+ return callTool(server, "update-schedule", args, callerAgentId);
60
+ }
61
+
44
62
  type ScheduleOutput = {
45
63
  success: boolean;
46
64
  message: string;
@@ -57,6 +75,7 @@ function structured(result: CallToolResult): ScheduleOutput {
57
75
  }
58
76
 
59
77
  let creatorId: string;
78
+ let otherAgentId: string;
60
79
 
61
80
  beforeAll(async () => {
62
81
  for (const suffix of ["", "-wal", "-shm"]) {
@@ -70,7 +89,13 @@ beforeAll(async () => {
70
89
  isLead: false,
71
90
  status: "idle",
72
91
  });
92
+ const otherAgent = createAgent({
93
+ name: "update-schedule-mcp-other",
94
+ isLead: false,
95
+ status: "idle",
96
+ });
73
97
  creatorId = creator.id;
98
+ otherAgentId = otherAgent.id;
74
99
  });
75
100
 
76
101
  afterAll(async () => {
@@ -83,6 +108,60 @@ afterAll(async () => {
83
108
  });
84
109
 
85
110
  describe("update-schedule MCP tool", () => {
111
+ test("non-lead, non-creator agent can update a schedule", async () => {
112
+ const server = buildServer();
113
+ const schedule = createScheduledTask({
114
+ name: `mcp-noncreator-update-${Date.now()}`,
115
+ intervalMs: 60000,
116
+ taskTemplate: "owned by creator",
117
+ createdByAgentId: creatorId,
118
+ timezone: "UTC",
119
+ });
120
+
121
+ const result = await callUpdateSchedule(
122
+ server,
123
+ { scheduleId: schedule.id, intervalMs: 120000 },
124
+ otherAgentId,
125
+ );
126
+ const sc = structured(result);
127
+
128
+ expect(sc.success).toBe(true);
129
+ expect(sc.schedule?.intervalMs).toBe(120000);
130
+ expect(getScheduledTaskById(schedule.id)?.intervalMs).toBe(120000);
131
+ });
132
+
133
+ test("non-lead, non-creator agent can delete a schedule and writes an audit event", async () => {
134
+ const server = buildServer();
135
+ const schedule = createScheduledTask({
136
+ name: `mcp-noncreator-delete-${Date.now()}`,
137
+ intervalMs: 60000,
138
+ taskTemplate: "owned by creator",
139
+ createdByAgentId: creatorId,
140
+ timezone: "UTC",
141
+ });
142
+
143
+ const result = await callTool(
144
+ server,
145
+ "delete-schedule",
146
+ { scheduleId: schedule.id },
147
+ otherAgentId,
148
+ );
149
+ const sc = result.structuredContent as { success: boolean };
150
+
151
+ expect(sc.success).toBe(true);
152
+ expect(getScheduledTaskById(schedule.id)).toBeNull();
153
+ const event = getDb()
154
+ .prepare<{ data: string }, []>(
155
+ "SELECT data FROM events WHERE event = 'schedule.deleted' ORDER BY createdAt DESC LIMIT 1",
156
+ )
157
+ .get();
158
+ expect(JSON.parse(event!.data)).toMatchObject({
159
+ scheduleId: schedule.id,
160
+ deletedByAgentId: otherAgentId,
161
+ createdByAgentId: creatorId,
162
+ });
163
+ });
164
+
86
165
  test("regression: { cronExpression: null, intervalMs: null, enabled: false } returns success:false and leaves DB row unchanged", async () => {
87
166
  const server = buildServer();
88
167
  const schedule = createScheduledTask({
@@ -0,0 +1 @@
1
+ export { registerCredentialBindingsTool } from "./tool";