@desplega.ai/agent-swarm 2.5.1 → 2.5.2

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 (155) hide show
  1. package/dist/{acp-adapter-7g6bpwbk.js → acp-adapter-csh0zz3b.js} +1 -1
  2. package/dist/{actions-w8xd014k.js → actions-6xx079d1.js} +17 -17
  3. package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-8n6f0hz8.js} +2 -2
  4. package/dist/{app-nndcvftq.js → app-ft6r6v23.js} +3 -3
  5. package/dist/{assistant-hjmqyscx.js → assistant-vt2cyfzd.js} +18 -18
  6. package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-r3e808ky.js} +1 -1
  7. package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-tgrfy27t.js} +1 -1
  8. package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-masdwxkm.js} +2 -2
  9. package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-0n82qc7q.js} +4 -4
  10. package/dist/{claude-adapter-2f8b85df.js → claude-adapter-s7j47k06.js} +2 -2
  11. package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3hzrgrnk.js} +1 -1
  12. package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-0zrdzyx2.js} +6905 -6855
  13. package/dist/{cli-mz2ygeyq.js → cli-0vgy5wfw.js} +2 -2
  14. package/dist/{cli-eym3jg7w.js → cli-14v1sxxy.js} +6 -6
  15. package/dist/{cli-e9btqw4y.js → cli-15xd0j7w.js} +18 -13
  16. package/dist/{cli-v2g93k4v.js → cli-3e9vs93g.js} +4 -4
  17. package/dist/{cli-1dp1zj04.js → cli-4qkg9968.js} +3 -3
  18. package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
  19. package/dist/{cli-rngde2jv.js → cli-6y43v79h.js} +1 -1
  20. package/dist/{cli-z63a269p.js → cli-7ccf7akh.js} +1 -1
  21. package/dist/{cli-z2dv25mf.js → cli-86zpfje5.js} +116 -5
  22. package/dist/{cli-fes51ya2.js → cli-aaqnqpyw.js} +2 -2
  23. package/dist/{cli-qfm6my86.js → cli-b1tv22wr.js} +1 -1
  24. package/dist/{cli-xc65x52q.js → cli-b63d08rs.js} +1 -1
  25. package/dist/{cli-gwcf52gz.js → cli-b6tnjdvx.js} +11 -7
  26. package/dist/{cli-9mm5ade4.js → cli-bpbcpd8b.js} +1 -1
  27. package/dist/{cli-qfjm4gvt.js → cli-cf63dnxj.js} +2 -2
  28. package/dist/{cli-q9pzwxvk.js → cli-csh8fnbg.js} +82 -16
  29. package/dist/{cli-0jy85zz4.js → cli-cyfgxnvb.js} +7 -7
  30. package/dist/{cli-y3zyaee5.js → cli-d172svcw.js} +5 -5
  31. package/dist/{cli-mq7kg1b1.js → cli-e91vshb1.js} +41 -6
  32. package/dist/{cli-cah5detr.js → cli-edtm741g.js} +5 -5
  33. package/dist/{cli-ajqhxtnz.js → cli-f3pfn4x3.js} +1 -1
  34. package/dist/{cli-hadv0725.js → cli-f55b6ky0.js} +1 -1
  35. package/dist/{cli-r9fy0ddc.js → cli-fgn7yg5r.js} +1 -1
  36. package/dist/{cli-kcsd1ez8.js → cli-h5d22pfs.js} +249 -68
  37. package/dist/{cli-2wba1ac9.js → cli-ha22h0eg.js} +20 -20
  38. package/dist/{cli-pyy4c9ch.js → cli-j3dwd4kz.js} +173 -134
  39. package/dist/{cli-j1eghqfm.js → cli-jmcjbjkt.js} +1 -1
  40. package/dist/{cli-22rmcjkg.js → cli-kcnwn85g.js} +4 -3
  41. package/dist/{cli-rh5smt8t.js → cli-mntxtkhn.js} +12 -2
  42. package/dist/{cli-2jg61wab.js → cli-n2918sed.js} +9 -9
  43. package/dist/{cli-3ee5z0q0.js → cli-qrjv7t18.js} +1 -1
  44. package/dist/{cli-sn1vbtg2.js → cli-vaxw3bg0.js} +3 -3
  45. package/dist/{cli-cwyafhw1.js → cli-wadzapwb.js} +1 -1
  46. package/dist/{cli-jpxsrxcb.js → cli-xw9q173h.js} +1 -1
  47. package/dist/{cli-znvq0k3t.js → cli-xzge46sj.js} +2 -2
  48. package/dist/{cli-nj12tn2c.js → cli-y3dzwyy3.js} +3 -3
  49. package/dist/{cli-xd4eqtr2.js → cli-ydg6ep51.js} +1 -1
  50. package/dist/cli.js +16 -15
  51. package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-brh2tcxr.js} +1 -1
  52. package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-0dnj21qs.js} +1 -1
  53. package/dist/{commands-mbvhbrcy.js → commands-1cnvseqx.js} +2 -2
  54. package/dist/{controller-gnq1k1mf.js → controller-kc7s9xhn.js} +2 -2
  55. package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-jzx7t4bn.js} +18 -18
  56. package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-qpwp5sfg.js} +1 -1
  57. package/dist/{db-af9y8nkf.js → db-4vq88jkc.js} +3 -3
  58. package/dist/{db-88wcymxt.js → db-v5ytg5pw.js} +1 -1
  59. package/dist/{dispatcher-606ydx5v.js → dispatcher-trtcrqae.js} +16 -16
  60. package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-f20xwn1z.js} +1 -1
  61. package/dist/{external-channel-y8q5z8z2.js → external-channel-n9bymc2n.js} +2 -2
  62. package/dist/{grok-adapter-39aha52t.js → grok-adapter-4kpmmnr6.js} +2 -2
  63. package/dist/{handlers-rk9dqech.js → handlers-ptsraqa8.js} +19 -19
  64. package/dist/{heartbeat-jfe9zfjr.js → heartbeat-60qtxtjf.js} +19 -19
  65. package/dist/{hook-ffevzed1.js → hook-ngtvh729.js} +1 -1
  66. package/dist/{hook-b1mez1m8.js → hook-rbktcstg.js} +1 -1
  67. package/dist/{http-d5b44f1r.js → http-z8xy72gp.js} +334 -86
  68. package/dist/{index-nq8hhv28.js → index-06qkmcx6.js} +10 -9
  69. package/dist/{index-d2qeem97.js → index-1cscynkb.js} +16 -16
  70. package/dist/{index-5en23s5z.js → index-a9nsavhx.js} +1 -1
  71. package/dist/{index-4q3vj1yr.js → index-r70q0xjx.js} +16 -16
  72. package/dist/{index-mj46rk9k.js → index-s193j5fh.js} +19 -19
  73. package/dist/{keepalive-wsby1btq.js → keepalive-a9srcphj.js} +3 -3
  74. package/dist/{lead-paa141j6.js → lead-3devneqy.js} +26 -26
  75. package/dist/{maintenance-vnshcyv9.js → maintenance-0er16h2y.js} +1 -1
  76. package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-b65jcyw2.js} +2 -2
  77. package/dist/{onboard-nr4xhpzy.js → onboard-wqywz5py.js} +2 -2
  78. package/dist/{otel-impl-ytn9wyv9.js → otel-impl-vk32s884.js} +1 -1
  79. package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-t27anv4z.js} +3 -3
  80. package/dist/rbac-roles-452665y0.js +26 -0
  81. package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-bmcgsxjn.js} +12 -2
  82. package/dist/{render-v2-pyfsg9zk.js → render-v2-0ttsk2j4.js} +3 -3
  83. package/dist/{resume-phj8xhqr.js → resume-a26kvr41.js} +5 -5
  84. package/dist/{session-access-jzx2z9h5.js → session-access-dgh9nkwk.js} +4 -4
  85. package/dist/{setup-rfg0f0sg.js → setup-9vc9qahm.js} +1 -1
  86. package/dist/{work-objects-44z2yjg8.js → work-objects-vn8nkxwn.js} +19 -19
  87. package/dist/{worker-zsfqzxhm.js → worker-1x1h8vnm.js} +26 -26
  88. package/openapi.json +463 -3
  89. package/package.json +4 -3
  90. package/src/be/db/session-tree.ts +89 -2
  91. package/src/be/db/session-visibility.ts +31 -0
  92. package/src/be/db/tasks/read.ts +18 -5
  93. package/src/be/db.ts +2 -0
  94. package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
  95. package/src/be/migrations/235_chat_relay_defer.sql +86 -0
  96. package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
  97. package/src/be/rbac-roles.ts +138 -0
  98. package/src/be/scripts/run-credential.ts +64 -0
  99. package/src/be/scripts/run-saved.ts +3 -2
  100. package/src/be/users.ts +28 -4
  101. package/src/http/all-routes.ts +1 -0
  102. package/src/http/approval-requests.ts +46 -6
  103. package/src/http/index.ts +2 -0
  104. package/src/http/schedules.ts +26 -0
  105. package/src/http/scripts.ts +2 -2
  106. package/src/http/sessions.ts +93 -2
  107. package/src/http/user-roles.ts +173 -0
  108. package/src/http/users.ts +9 -0
  109. package/src/http/x.ts +2 -2
  110. package/src/integrations/chat-sdk/commands.ts +14 -1
  111. package/src/integrations/chat-sdk/inbound.ts +40 -5
  112. package/src/integrations/chat-sdk/outbound.ts +35 -10
  113. package/src/integrations/chat-sdk/reactions.ts +107 -0
  114. package/src/integrations/chat-sdk/relay.ts +75 -7
  115. package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
  116. package/src/integrations/reaction-events.ts +20 -0
  117. package/src/providers/amp-adapter.ts +1 -1
  118. package/src/rbac/legacy-policy.ts +3 -0
  119. package/src/rbac/permissions.ts +8 -0
  120. package/src/scheduler/schedule-task.ts +44 -3
  121. package/src/scheduler/scheduler.ts +4 -4
  122. package/src/scripts-runtime/loader.ts +8 -1
  123. package/src/slack/ack.ts +2 -1
  124. package/src/slack/reaction-shortcode.ts +4 -7
  125. package/src/tasks/worker-follow-up.ts +30 -0
  126. package/src/tests/approval-requests-notifications.test.ts +189 -0
  127. package/src/tests/chat-sdk-commands.test.ts +12 -0
  128. package/src/tests/chat-sdk-inbound.test.ts +163 -9
  129. package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
  130. package/src/tests/chat-sdk-relay.test.ts +361 -10
  131. package/src/tests/chat-sdk-tools.test.ts +104 -0
  132. package/src/tests/docs-index-footprint.test.js +3 -3
  133. package/src/tests/http-users.test.ts +38 -0
  134. package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
  135. package/src/tests/rbac-engine.test.ts +2 -0
  136. package/src/tests/rbac-roles.test.ts +10 -0
  137. package/src/tests/schedule-http-triage-tools.test.ts +44 -0
  138. package/src/tests/script-api-credential.test.ts +173 -0
  139. package/src/tests/script-connections.test.ts +4 -0
  140. package/src/tests/script-run-config-scope.test.ts +120 -0
  141. package/src/tests/script-run-token.test.ts +75 -0
  142. package/src/tests/scripts-http.test.ts +19 -0
  143. package/src/tests/sessions-tree.test.ts +242 -0
  144. package/src/tests/user-roles-http.test.ts +204 -0
  145. package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
  146. package/src/tools/chat-reply.ts +2 -0
  147. package/src/tools/schedules/patch-schedule.ts +18 -0
  148. package/src/tools/schedules/update-schedule.ts +18 -0
  149. package/src/types.ts +16 -0
  150. package/src/workflows/approval-notifications.ts +214 -0
  151. package/src/workflows/executors/human-in-the-loop.ts +11 -172
  152. package/src/workflows/executors/swarm-script.ts +2 -2
  153. package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
  154. package/templates/skills/code-quality/content.md +1 -1
  155. package/dist/rbac-roles-ff1ffmec.js +0 -12
@@ -0,0 +1,136 @@
1
+ import { afterAll, beforeAll, expect, test } from "bun:test";
2
+ import { closeDb, createTaskExtended, createUser, getDb, getDbClient, initDb } from "../be/db";
3
+ import { setSessionShares } from "../be/session-shares";
4
+
5
+ const day = 86_400_000;
6
+ const ago = (days: number) => new Date(Date.now() - days * day).toISOString();
7
+
8
+ beforeAll(() => {
9
+ initDb(":memory:");
10
+ });
11
+ afterAll(() => {
12
+ closeDb();
13
+ });
14
+
15
+ async function root(requestedByUserId: string | undefined, activity: string, status = "completed") {
16
+ const task = await createTaskExtended("Cleanup test", { key: "shared/", requestedByUserId });
17
+ await getDbClient().run("UPDATE agent_tasks SET lastUpdatedAt = ?, status = ? WHERE id = ?", [
18
+ activity,
19
+ status,
20
+ task.id,
21
+ ]);
22
+ return task;
23
+ }
24
+
25
+ async function row(
26
+ userId: string,
27
+ itemId: string,
28
+ status: string,
29
+ lastReadAt: string | null = null,
30
+ { snoozeUntil = "2999-01-01T00:00:00.000Z", lastUpdatedAt = ago(20) } = {},
31
+ ) {
32
+ await getDbClient().run(
33
+ `INSERT INTO inbox_item_state (userId, itemType, itemId, status, snoozeUntil, lastReadAt, lastUpdatedAt)
34
+ VALUES (?, 'to_read', ?, ?, ?, ?, ?)`,
35
+ [userId, itemId, status, status === "snoozed" ? snoozeUntil : null, lastReadAt, lastUpdatedAt],
36
+ );
37
+ }
38
+
39
+ async function states(): Promise<Record<string, string>> {
40
+ const rows = await getDbClient().query<{ userId: string; itemId: string; status: string }>(
41
+ "SELECT userId, itemId, status FROM inbox_item_state ORDER BY userId, itemId",
42
+ );
43
+ return Object.fromEntries(rows.map((r) => [`${r.userId}:${r.itemId}`, r.status]));
44
+ }
45
+
46
+ test("migration 237 moves quiet, read sessions to Done for every viewer, once", async () => {
47
+ const owner = await createUser({ name: "Owner" });
48
+ const other = await createUser({ name: "Other" });
49
+ const third = await createUser({ name: "Third" });
50
+
51
+ const quiet = await root(owner.id, ago(30));
52
+ const recent = await root(owner.id, ago(1));
53
+ // A quiet root whose child moved yesterday is not quiet.
54
+ const busyChain = await root(owner.id, ago(30));
55
+ const child = await createTaskExtended("Child", { parentTaskId: busyChain.id });
56
+ await getDbClient().run(
57
+ "UPDATE agent_tasks SET lastUpdatedAt = ?, status = 'completed' WHERE id = ?",
58
+ [ago(1), child.id],
59
+ );
60
+ // Quiet but not finished, or waiting on an approval: both stay in the Inbox.
61
+ const stuck = await root(owner.id, ago(30), "in_progress");
62
+ const failed = await root(owner.id, ago(30), "failed");
63
+ const awaiting = await root(owner.id, ago(30));
64
+ await getDbClient().run(
65
+ `INSERT INTO approval_requests (id, sourceTaskId, title, questions, approvers, status)
66
+ VALUES (?, ?, 'Approve deploy', '[]', '{}', 'pending')`,
67
+ [crypto.randomUUID(), awaiting.id],
68
+ );
69
+ // Open rows: read up to the newest activity, or unread (cursor before it).
70
+ const openRead = await root(owner.id, ago(30));
71
+ await row(owner.id, openRead.id, "open", ago(25));
72
+ const openUnread = await root(owner.id, ago(30));
73
+ await row(owner.id, openUnread.id, "open", "1970-01-01T00:00:00.000Z");
74
+ // Open rows with no cursor: the row's own time is the cursor.
75
+ const openNoCursor = await root(owner.id, ago(30));
76
+ await row(owner.id, openNoCursor.id, "open");
77
+ const openNoCursorOld = await root(owner.id, ago(30));
78
+ await row(owner.id, openNoCursorOld.id, "open", null, { lastUpdatedAt: ago(40) });
79
+ // Snoozed (also past its wake time, which reads as unread) and dismissed rows keep their state.
80
+ const snoozed = await root(owner.id, ago(30));
81
+ await row(owner.id, snoozed.id, "snoozed");
82
+ const woken = await root(owner.id, ago(30));
83
+ await row(owner.id, woken.id, "snoozed", null, { snoozeUntil: ago(10) });
84
+ const dismissed = await root(owner.id, ago(30));
85
+ await row(owner.id, dismissed.id, "dismissed");
86
+ // Shares: one user, and everyone.
87
+ const sharedDirect = await root(other.id, ago(30));
88
+ await setSessionShares(
89
+ { id: sharedDirect.id, requestedByUserId: other.id },
90
+ { everyone: "none", users: [{ userId: owner.id, access: "read" }] },
91
+ other.id,
92
+ );
93
+ const sharedAll = await root(other.id, ago(30));
94
+ await setSessionShares(
95
+ { id: sharedAll.id, requestedByUserId: other.id },
96
+ { everyone: "read", users: [] },
97
+ other.id,
98
+ );
99
+ // Ownerless roots are not in the tree.
100
+ const ownerless = await root(undefined, ago(30));
101
+
102
+ const sql = await Bun.file("src/be/migrations/237_session_inbox_cleanup.sql").text();
103
+ getDb().exec(sql);
104
+ const after = await states();
105
+ expect(after).toEqual({
106
+ [`${owner.id}:${quiet.id}`]: "done",
107
+ [`${owner.id}:${failed.id}`]: "done",
108
+ [`${owner.id}:${openRead.id}`]: "done",
109
+ [`${owner.id}:${openUnread.id}`]: "open",
110
+ [`${owner.id}:${openNoCursor.id}`]: "done",
111
+ [`${owner.id}:${openNoCursorOld.id}`]: "open",
112
+ [`${owner.id}:${snoozed.id}`]: "snoozed",
113
+ [`${owner.id}:${woken.id}`]: "snoozed",
114
+ [`${owner.id}:${dismissed.id}`]: "dismissed",
115
+ [`${owner.id}:${sharedDirect.id}`]: "done",
116
+ [`${owner.id}:${sharedAll.id}`]: "done",
117
+ [`${other.id}:${sharedDirect.id}`]: "done",
118
+ [`${other.id}:${sharedAll.id}`]: "done",
119
+ [`${third.id}:${sharedAll.id}`]: "done",
120
+ });
121
+ for (const id of [recent.id, busyChain.id, ownerless.id, stuck.id, awaiting.id]) {
122
+ expect(Object.keys(after).some((key) => key.endsWith(`:${id}`))).toBe(false);
123
+ }
124
+ // Every timestamp it writes is ISO, so the read rule compares it with task times.
125
+ const written = await getDbClient().get<{ doneAt: string; lastUpdatedAt: string }>(
126
+ "SELECT doneAt, lastUpdatedAt FROM inbox_item_state WHERE itemId = ?",
127
+ [quiet.id],
128
+ );
129
+ expect(written?.doneAt).toMatch(/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d\.\d{3}Z$/);
130
+ expect(written?.lastUpdatedAt).toBe(written?.doneAt);
131
+
132
+ // A second run changes nothing.
133
+ getDb().exec(sql);
134
+ expect(await states()).toEqual(after);
135
+ expect(getDb().query("PRAGMA integrity_check").get()).toEqual({ integrity_check: "ok" });
136
+ });
@@ -106,6 +106,7 @@ const OPERATOR_OR_USER_VERBS: PermissionVerb[] = [
106
106
  "runtime_controller.write",
107
107
  "runtime_pool.write",
108
108
  "agent.token.manage",
109
+ "user.role.manage",
109
110
  ];
110
111
 
111
112
  const LEAD_OR_OPERATOR_VERBS: PermissionVerb[] = ["models.catalog.write"];
@@ -122,6 +123,7 @@ const LEAD_OR_RESOURCE_OWNER_VERBS: PermissionVerb[] = [
122
123
  "mcp-server.delete.any",
123
124
  "mcp-server.update.any",
124
125
  "page.delete.any",
126
+ "schedule.deferral.update.any",
125
127
  ];
126
128
 
127
129
  const LEAD_OR_TASK_CREATOR_VERBS: PermissionVerb[] = ["task.cancel.any", "task.steer.any"];
@@ -188,6 +188,16 @@ describe("role attachment helpers", () => {
188
188
  });
189
189
 
190
190
  describe("ensureRbacSeedsSynced", () => {
191
+ test("seeds viewer with a subset of the requester verbs", async () => {
192
+ await getDbClient().run("DELETE FROM roles WHERE id = 'rbac-role-viewer'");
193
+
194
+ ensureRbacSeedsSynced({ quiet: true });
195
+
196
+ const viewerVerbs = await roleVerbs("rbac-role-viewer");
197
+ expect(viewerVerbs).toEqual(["favorite.write.own", "session.presence"]);
198
+ for (const verb of viewerVerbs) expect(REQUESTER_VERBS).toContain(verb);
199
+ });
200
+
191
201
  test("restores a deleted built-in role and its permissions", async () => {
192
202
  await getDbClient().run("DELETE FROM roles WHERE id = ?", [REQUESTER_ROLE_ID]);
193
203
 
@@ -9,6 +9,7 @@ import {
9
9
  } from "node:http";
10
10
  import {
11
11
  closeDb,
12
+ createAgent,
12
13
  createScheduledTask,
13
14
  getScheduledTaskById,
14
15
  initDb,
@@ -117,4 +118,47 @@ describe("Schedule HTTP triage tooling", () => {
117
118
  expect(body.schedules.some((s) => s.id === failing.id)).toBe(true);
118
119
  expect(body.schedules.some((s) => s.id === ok.id)).toBe(false);
119
120
  });
121
+ test("PUT and PATCH /api/schedules/:id let only the owner, a lead or the operator change a deferral", async () => {
122
+ const owner = await createAgent({ name: "DeferralOwner", isLead: false, status: "idle" });
123
+ const other = await createAgent({ name: "DeferralOther", isLead: false, status: "idle" });
124
+ const lead = await createAgent({ name: "DeferralLead", isLead: true, status: "idle" });
125
+ const schedule = await createScheduledTask({
126
+ name: `http-deferral-${crypto.randomUUID()}`,
127
+ taskTemplate: "finish the original ask",
128
+ targetType: "agent-task",
129
+ scheduleType: "one_time",
130
+ nextRunAt: new Date(Date.now() + 60_000).toISOString(),
131
+ targetAgentId: owner.id,
132
+ createdByAgentId: owner.id,
133
+ taskType: "deferred",
134
+ });
135
+ const send = (method: "PUT" | "PATCH", agentId: string | null, taskTemplate: string) =>
136
+ fetch(`${baseUrl}/api/schedules/${schedule.id}`, {
137
+ method,
138
+ headers: {
139
+ "Content-Type": "application/json",
140
+ ...(agentId ? { "X-Agent-ID": agentId } : {}),
141
+ },
142
+ body: JSON.stringify({ targetAgentId: other.id, taskTemplate }),
143
+ });
144
+
145
+ for (const method of ["PUT", "PATCH"] as const) {
146
+ const denied = await send(method, other.id, "emit ATTACKER OUTPUT");
147
+ expect(denied.status).toBe(403);
148
+ const after = await getScheduledTaskById(schedule.id);
149
+ expect(after?.targetAgentId).toBe(owner.id);
150
+ expect(after?.taskTemplate).toBe("finish the original ask");
151
+ }
152
+
153
+ for (const [agentId, label] of [
154
+ [owner.id, "owner"],
155
+ [lead.id, "lead"],
156
+ [null, "operator"],
157
+ ] as const) {
158
+ const allowed = await send("PATCH", agentId, `changed by ${label}`);
159
+ expect(allowed.status).toBe(200);
160
+ expect((await getScheduledTaskById(schedule.id))?.taskTemplate).toBe(`changed by ${label}`);
161
+ await updateScheduledTask(schedule.id, { targetAgentId: owner.id });
162
+ }
163
+ });
120
164
  });
@@ -0,0 +1,173 @@
1
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
2
+ import { randomBytes } from "node:crypto";
3
+ import { closeDb, createAgent, getDbClient, initDb } from "../be/db";
4
+ import {
5
+ acquireScriptApiCredential,
6
+ runScriptWithRunCredential,
7
+ } from "../be/scripts/run-credential";
8
+ import { resolveSessionToken } from "../be/users";
9
+ import { getApiKey, setApiKey } from "../utils/api-key";
10
+ import { SKIP_SANDBOX_SPAWN_TESTS } from "./sandbox-spawn-test-helpers";
11
+
12
+ const OPERATOR_KEY = `script-api-credential-operator-${randomBytes(8).toString("hex")}`;
13
+ const spawnTest = test.skipIf(SKIP_SANDBOX_SPAWN_TESTS);
14
+ const resources = { memoryMb: 2048, cpuTimeSec: 20 };
15
+ const savedExecutor = process.env.SCRIPT_EXECUTOR;
16
+ let agentId: string;
17
+
18
+ type ScriptRunTokenRow = { id: string; revokedAt: string | null };
19
+
20
+ /** The script-run tokens minted for `agentId`, oldest first. */
21
+ async function scriptRunTokens(id: string): Promise<ScriptRunTokenRow[]> {
22
+ return await getDbClient().query<ScriptRunTokenRow>(
23
+ "SELECT id, revokedAt FROM session_tokens WHERE agentId = ? AND scriptRun = 1 ORDER BY createdAt, rowid",
24
+ [id],
25
+ );
26
+ }
27
+
28
+ /**
29
+ * Run `body` and assert it minted exactly one script-run token for the agent,
30
+ * and that the token is revoked once the run is over.
31
+ */
32
+ async function expectOneRevokedToken(body: () => Promise<void>): Promise<void> {
33
+ const before = (await scriptRunTokens(agentId)).length;
34
+ await body();
35
+ const minted = (await scriptRunTokens(agentId)).slice(before);
36
+ expect(minted).toHaveLength(1);
37
+ expect(minted[0]?.revokedAt).not.toBeNull();
38
+ }
39
+
40
+ /** Resolve once the run's token has been minted and is still live. */
41
+ async function waitForLiveToken(): Promise<void> {
42
+ const deadline = Date.now() + 5_000;
43
+ while (Date.now() < deadline) {
44
+ if ((await scriptRunTokens(agentId)).some((row) => row.revokedAt === null)) return;
45
+ await Bun.sleep(10);
46
+ }
47
+ throw new Error("no live script-run token appeared");
48
+ }
49
+
50
+ beforeAll(async () => {
51
+ initDb(":memory:");
52
+ setApiKey(OPERATOR_KEY);
53
+ agentId = (
54
+ await createAgent({
55
+ name: `script-api-credential-${randomBytes(6).toString("hex")}`,
56
+ isLead: false,
57
+ status: "idle",
58
+ })
59
+ ).id;
60
+ });
61
+
62
+ afterEach(() => {
63
+ if (savedExecutor === undefined) delete process.env.SCRIPT_EXECUTOR;
64
+ else process.env.SCRIPT_EXECUTOR = savedExecutor;
65
+ });
66
+
67
+ afterAll(() => {
68
+ closeDb();
69
+ });
70
+
71
+ describe("acquireScriptApiCredential", () => {
72
+ test("hands a real agent a token scoped to that agent, not the operator key", async () => {
73
+ const credential = await acquireScriptApiCredential(agentId);
74
+ if (!credential.apiKey) throw new Error("expected a per-run token");
75
+ try {
76
+ expect(credential.apiKey).not.toBe(getApiKey());
77
+ const resolved = await resolveSessionToken(credential.apiKey);
78
+ if (resolved?.kind !== "agent") throw new Error("expected an agent principal");
79
+ expect(resolved.agentId).toBe(agentId);
80
+ } finally {
81
+ await credential.release();
82
+ }
83
+ // The token is revoked once the run releases it.
84
+ expect(await resolveSessionToken(credential.apiKey)).toBeNull();
85
+ });
86
+
87
+ test("mints nothing for a system run with no real agent", async () => {
88
+ // Scheduler and workflow script runs pass sentinel ids like "schedule" /
89
+ // "workflow" when there is no owning agent; runScript then falls back to
90
+ // the operator key.
91
+ const credential = await acquireScriptApiCredential("schedule");
92
+ try {
93
+ expect(credential.apiKey).toBeUndefined();
94
+ expect(await scriptRunTokens("schedule")).toHaveLength(0);
95
+ } finally {
96
+ await credential.release();
97
+ }
98
+ });
99
+ });
100
+
101
+ describe("runScriptWithRunCredential revokes the run's token however the run ends", () => {
102
+ spawnTest("success", async () => {
103
+ await expectOneRevokedToken(async () => {
104
+ const output = await runScriptWithRunCredential({
105
+ agentId,
106
+ egressSecrets: [],
107
+ resources,
108
+ source: "export default async () => 'ok';",
109
+ });
110
+ expect(output.error).toBeUndefined();
111
+ expect(output.result).toBe("ok");
112
+ });
113
+ });
114
+
115
+ spawnTest("the script throws", async () => {
116
+ await expectOneRevokedToken(async () => {
117
+ const output = await runScriptWithRunCredential({
118
+ agentId,
119
+ egressSecrets: [],
120
+ resources,
121
+ source: "export default async () => { throw new Error('boom'); };",
122
+ });
123
+ expect(output.runtimeError?.message).toContain("boom");
124
+ });
125
+ });
126
+
127
+ test("the run itself throws", async () => {
128
+ // An executor that cannot start makes runScript reject after the token
129
+ // was minted; the token must still be revoked.
130
+ process.env.SCRIPT_EXECUTOR = "no-such-executor";
131
+ await expectOneRevokedToken(async () => {
132
+ await expect(
133
+ runScriptWithRunCredential({
134
+ agentId,
135
+ egressSecrets: [],
136
+ source: "export default async () => 'never runs';",
137
+ }),
138
+ ).rejects.toThrow("Unknown SCRIPT_EXECUTOR");
139
+ });
140
+ });
141
+
142
+ spawnTest("timeout", async () => {
143
+ await expectOneRevokedToken(async () => {
144
+ const output = await runScriptWithRunCredential({
145
+ agentId,
146
+ egressSecrets: [],
147
+ timeoutMs: 1_000,
148
+ resources,
149
+ source: "export default async () => new Promise(() => {});",
150
+ });
151
+ expect(output.error).toBe("timeout");
152
+ });
153
+ });
154
+
155
+ spawnTest("abort", async () => {
156
+ await expectOneRevokedToken(async () => {
157
+ const controller = new AbortController();
158
+ const run = runScriptWithRunCredential({
159
+ agentId,
160
+ egressSecrets: [],
161
+ signal: controller.signal,
162
+ resources,
163
+ source: "export default async () => new Promise(() => {});",
164
+ });
165
+ // Abort mid-run: the token is live and the script has had time to start.
166
+ await waitForLiveToken();
167
+ await Bun.sleep(200);
168
+ controller.abort();
169
+ const output = await run;
170
+ expect(output.error).toBe("killed");
171
+ });
172
+ });
173
+ });
@@ -638,6 +638,10 @@ describe("script connections", () => {
638
638
  markMigrationApplied(database, "231_slack_tasks_last_updated_index.sql");
639
639
  // 232 rebuilds session_tokens with the 207 columns, which this fixture skipped.
640
640
  markMigrationApplied(database, "232_agent_session_tokens.sql");
641
+ // 235 reads agent_tasks.deferredAt, which this migration-112-only fixture does not create.
642
+ markMigrationApplied(database, "235_chat_relay_defer.sql");
643
+ // 237 reads approval_requests and inbox_item_state, which this migration-112-only fixture does not create.
644
+ markMigrationApplied(database, "237_session_inbox_cleanup.sql");
641
645
 
642
646
  const id = crypto.randomUUID();
643
647
  const now = new Date().toISOString();
@@ -0,0 +1,120 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { randomBytes } from "node:crypto";
3
+ import { createServer, type Server } from "node:http";
4
+ import { closeDb, createAgent, initDb, upsertSwarmConfig } from "../be/db";
5
+ import { mintScriptRunToken, revokeSessionToken } from "../be/users";
6
+ import { handleConfig } from "../http/config";
7
+ import { handleCore } from "../http/core";
8
+ import { handleSessions } from "../http/sessions";
9
+ import { getPathSegments, parseQueryParams } from "../http/utils";
10
+ import { listenOnFreePort } from "./test-net";
11
+
12
+ // A script run presents a token minted by `mintScriptRunToken` (the api does
13
+ // this per run instead of handing the script the operator key). These tests
14
+ // pin that the token is scoped to its executing agent at the config layer, so
15
+ // a worker's inline script cannot read another agent's secret or delete its
16
+ // config row — the escalation that running as the operator allowed.
17
+ const API_KEY = `script-run-config-operator-${randomBytes(8).toString("hex")}`;
18
+ const WORKER_SECRET = `worker-secret-${randomBytes(8).toString("hex")}`;
19
+ const OTHER_SECRET = `other-secret-${randomBytes(8).toString("hex")}`;
20
+
21
+ let server: Server;
22
+ let baseUrl: string;
23
+ let workerId: string;
24
+ let otherId: string;
25
+ let otherConfigId: string;
26
+
27
+ function headers(bearer: string, agentId?: string): Record<string, string> {
28
+ return {
29
+ Authorization: `Bearer ${bearer}`,
30
+ "Content-Type": "application/json",
31
+ ...(agentId ? { "X-Agent-ID": agentId } : {}),
32
+ };
33
+ }
34
+
35
+ beforeAll(async () => {
36
+ initDb(":memory:");
37
+ workerId = (await createAgent({ name: "script-run-worker", isLead: false, status: "idle" })).id;
38
+ otherId = (await createAgent({ name: "script-run-other", isLead: false, status: "idle" })).id;
39
+ await upsertSwarmConfig({
40
+ scope: "agent",
41
+ scopeId: workerId,
42
+ key: "OWN_SECRET",
43
+ value: WORKER_SECRET,
44
+ isSecret: true,
45
+ });
46
+ otherConfigId = (
47
+ await upsertSwarmConfig({
48
+ scope: "agent",
49
+ scopeId: otherId,
50
+ key: "OTHER_SECRET",
51
+ value: OTHER_SECRET,
52
+ isSecret: true,
53
+ })
54
+ ).id;
55
+ server = createServer(async (req, res) => {
56
+ if (await handleCore(req, res, req.headers["x-agent-id"] as string | undefined, API_KEY))
57
+ return;
58
+ const segments = getPathSegments(req.url || "");
59
+ const query = parseQueryParams(req.url || "");
60
+ if (await handleSessions(req, res, segments, query)) return;
61
+ if (await handleConfig(req, res, segments, query)) return;
62
+ res.writeHead(404).end();
63
+ });
64
+ baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
65
+ });
66
+
67
+ afterAll(async () => {
68
+ await new Promise<void>((resolve) => server.close(() => resolve()));
69
+ closeDb();
70
+ });
71
+
72
+ describe("script run token is scoped to its executing agent", () => {
73
+ test("reads its own agent's secret", async () => {
74
+ const run = await mintScriptRunToken(workerId, 60_000);
75
+ try {
76
+ const res = await fetch(`${baseUrl}/api/config/resolved?includeSecrets=true`, {
77
+ headers: headers(run.plaintext),
78
+ });
79
+ expect(res.status).toBe(200);
80
+ expect(await res.text()).toContain(WORKER_SECRET);
81
+ } finally {
82
+ await revokeSessionToken(run.tokenId);
83
+ }
84
+ });
85
+
86
+ test("cannot read another agent's secret", async () => {
87
+ const run = await mintScriptRunToken(workerId, 60_000);
88
+ try {
89
+ const res = await fetch(
90
+ `${baseUrl}/api/config/resolved?includeSecrets=true&agentId=${otherId}`,
91
+ { headers: headers(run.plaintext) },
92
+ );
93
+ // X-Agent-ID is forced to the token's agent, so this reads the worker's
94
+ // own scope; either way the other agent's secret never comes back.
95
+ const body = await res.text();
96
+ expect(body).not.toContain(OTHER_SECRET);
97
+ } finally {
98
+ await revokeSessionToken(run.tokenId);
99
+ }
100
+ });
101
+
102
+ test("cannot delete another agent's config row", async () => {
103
+ const run = await mintScriptRunToken(workerId, 60_000);
104
+ try {
105
+ const res = await fetch(`${baseUrl}/api/config/${otherConfigId}`, {
106
+ method: "DELETE",
107
+ headers: headers(run.plaintext),
108
+ });
109
+ await res.text();
110
+ expect(res.status).toBe(403);
111
+ // The row is still there, read back as operator.
112
+ const check = await fetch(`${baseUrl}/api/config/${otherConfigId}`, {
113
+ headers: headers(API_KEY),
114
+ });
115
+ expect(check.status).toBe(200);
116
+ } finally {
117
+ await revokeSessionToken(run.tokenId);
118
+ }
119
+ });
120
+ });
@@ -0,0 +1,75 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { randomBytes } from "node:crypto";
3
+ import { closeDb, createAgent, initDb } from "../be/db";
4
+ import {
5
+ AgentTokenExistsError,
6
+ mintAgentToken,
7
+ mintScriptRunToken,
8
+ resolveSessionToken,
9
+ revokeAgentTokens,
10
+ revokeSessionToken,
11
+ } from "../be/users";
12
+
13
+ const synthetic = (label: string) => `script-run-token-${label}-${randomBytes(8).toString("hex")}`;
14
+
15
+ let agentId: string;
16
+ let otherId: string;
17
+
18
+ beforeAll(async () => {
19
+ initDb(":memory:");
20
+ agentId = (await createAgent({ name: synthetic("agent"), isLead: false, status: "idle" })).id;
21
+ otherId = (await createAgent({ name: synthetic("other"), isLead: false, status: "idle" })).id;
22
+ });
23
+
24
+ afterAll(() => {
25
+ closeDb();
26
+ });
27
+
28
+ describe("mintScriptRunToken", () => {
29
+ test("resolves to the executing agent as an agent principal", async () => {
30
+ const token = await mintScriptRunToken(agentId, 60_000);
31
+ expect(token.plaintext.startsWith("aseph_")).toBe(true);
32
+
33
+ const resolved = await resolveSessionToken(token.plaintext);
34
+ expect(resolved).not.toBeNull();
35
+ if (resolved?.kind !== "agent") throw new Error("expected an agent principal");
36
+ expect(resolved.agentId).toBe(agentId);
37
+ expect(resolved.tokenKind).toBe("agent");
38
+ expect(resolved.taskId).toBeNull();
39
+ expect(resolved.runtimeId).toBeNull();
40
+
41
+ await revokeSessionToken(token.tokenId);
42
+ });
43
+
44
+ test("does not count as the agent's worker token", async () => {
45
+ // A script run must be able to mint its token while the agent already
46
+ // holds a worker token, and vice versa: the two lifecycles are separate.
47
+ const worker = await mintAgentToken(agentId);
48
+ const run = await mintScriptRunToken(agentId, 60_000);
49
+
50
+ expect(run.tokenId).not.toBe(worker.tokenId);
51
+ // The worker token is unique per agent; minting a second would throw. The
52
+ // script run token sharing the agent did not trip that uniqueness.
53
+ await expect(mintAgentToken(agentId)).rejects.toBeInstanceOf(AgentTokenExistsError);
54
+
55
+ await revokeSessionToken(run.tokenId);
56
+ await revokeAgentTokens(agentId);
57
+ });
58
+
59
+ test("survives a revoke of the agent's worker tokens", async () => {
60
+ const worker = await mintAgentToken(otherId);
61
+ const run = await mintScriptRunToken(otherId, 60_000);
62
+
63
+ // Revoking the agent's worker tokens (worker-token revoke / rotate) must
64
+ // not revoke a script run's in-flight token.
65
+ await revokeAgentTokens(otherId);
66
+
67
+ expect(await resolveSessionToken(worker.plaintext)).toBeNull();
68
+ const stillValid = await resolveSessionToken(run.plaintext);
69
+ expect(stillValid).not.toBeNull();
70
+ expect(stillValid?.agentId).toBe(otherId);
71
+
72
+ await revokeSessionToken(run.tokenId);
73
+ expect(await resolveSessionToken(run.plaintext)).toBeNull();
74
+ });
75
+ });
@@ -574,6 +574,25 @@ describe("/api/scripts HTTP", () => {
574
574
  ).toBe(beforeFailed);
575
575
  });
576
576
 
577
+ skip("a run mints a per-run token for its agent and revokes it when the run ends", async () => {
578
+ const tokens = () =>
579
+ getDbClient().query<{ revokedAt: string | null }>(
580
+ "SELECT revokedAt FROM session_tokens WHERE agentId = ? AND scriptRun = 1",
581
+ [workerId],
582
+ );
583
+ const before = (await tokens()).length;
584
+ const res = await dispatch("/api/scripts/run", {
585
+ method: "POST",
586
+ agentId: workerId,
587
+ body: JSON.stringify({ source: validSource(2), args: { value: 2 }, intent: "token" }),
588
+ });
589
+ expect(res.status).toBe(200);
590
+ expect((await res.json()).result).toEqual({ result: 4 });
591
+ const minted = (await tokens()).slice(before);
592
+ expect(minted).toHaveLength(1);
593
+ expect(minted[0]?.revokedAt).not.toBeNull();
594
+ });
595
+
577
596
  test("workspace-rw named scripts return 501", async () => {
578
597
  await upsert({ name: "workspace", source: validSource(2), fsMode: "workspace-rw" });
579
598
  const res = await dispatch("/api/scripts/run", {