@desplega.ai/agent-swarm 2.5.1 → 2.5.3

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 (197) hide show
  1. package/dist/{acp-adapter-7g6bpwbk.js → acp-adapter-gr2fcyyj.js} +3 -3
  2. package/dist/{actions-w8xd014k.js → actions-wv766ghf.js} +18 -18
  3. package/dist/{amp-adapter-z4qvhacj.js → amp-adapter-speb906t.js} +3 -3
  4. package/dist/{app-nndcvftq.js → app-ag29n14f.js} +3 -3
  5. package/dist/{assistant-hjmqyscx.js → assistant-3x8bz3eg.js} +19 -19
  6. package/dist/{boot-reembed-r8rhf9h8.js → boot-reembed-5b3d2eea.js} +1 -1
  7. package/dist/{boot-reembed-vwcj2y5d.js → boot-reembed-jsmfrpzf.js} +1 -1
  8. package/dist/{boot-scrub-sweep-fr9q36ne.js → boot-scrub-sweep-s73gerxy.js} +2 -2
  9. package/dist/{boot-seeds-9jqwnegq.js → boot-seeds-5zmax2wr.js} +4 -4
  10. package/dist/{claude-adapter-2f8b85df.js → claude-adapter-hswdnm3t.js} +3 -3
  11. package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-cvbzj1h6.js} +2 -2
  12. package/dist/{claude-managed-setup-c3hp36ph.js → claude-managed-setup-3k8m3hhc.js} +2 -2
  13. package/dist/{claude-sdk-session-05kat8rv.js → claude-sdk-session-fkbyk66x.js} +6905 -6855
  14. package/dist/{cli-y3zyaee5.js → cli-1fc85mvt.js} +5 -5
  15. package/dist/{cli-v2g93k4v.js → cli-210xd1f4.js} +4 -4
  16. package/dist/{cli-sn1vbtg2.js → cli-25j01dpj.js} +3 -3
  17. package/dist/{cli-n6kmqngw.js → cli-35gtk1p6.js} +1 -1
  18. package/dist/{cli-r9fy0ddc.js → cli-3ngdk5te.js} +1 -1
  19. package/dist/{cli-z63a269p.js → cli-40jsnrjm.js} +2 -2
  20. package/dist/{cli-ba8ne1es.js → cli-4rvr7zfx.js} +10 -0
  21. package/dist/{cli-qfm6my86.js → cli-502eqv93.js} +1 -1
  22. package/dist/{cli-xd4eqtr2.js → cli-6n8axjv1.js} +1 -1
  23. package/dist/{cli-hadv0725.js → cli-8s5wx3nz.js} +2 -2
  24. package/dist/{cli-z2dv25mf.js → cli-99xgw26r.js} +116 -5
  25. package/dist/{cli-jpxsrxcb.js → cli-9s05422w.js} +1 -1
  26. package/dist/{cli-kcsd1ez8.js → cli-aecdcr77.js} +343 -325
  27. package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
  28. package/dist/{cli-e9btqw4y.js → cli-ce0ak9ms.js} +18 -13
  29. package/dist/{cli-nj12tn2c.js → cli-e2ar4bp1.js} +71 -20
  30. package/dist/{cli-gwcf52gz.js → cli-e8jyq7d8.js} +11 -7
  31. package/dist/{cli-rngde2jv.js → cli-egjggehm.js} +1 -1
  32. package/dist/{cli-qfjm4gvt.js → cli-ewm3pwbq.js} +2 -2
  33. package/dist/{cli-9mm5ade4.js → cli-fsdprwrg.js} +1 -1
  34. package/dist/{cli-3ee5z0q0.js → cli-fzcn3d87.js} +1 -1
  35. package/dist/{cli-eym3jg7w.js → cli-gpsz7g88.js} +6 -6
  36. package/dist/{cli-cwyafhw1.js → cli-hcpj2xqz.js} +1 -1
  37. package/dist/{cli-j1eghqfm.js → cli-hhxjnjzs.js} +1 -1
  38. package/dist/{cli-pyy4c9ch.js → cli-jjznk3f7.js} +881 -232
  39. package/dist/{cli-q9pzwxvk.js → cli-kxawjrxg.js} +222 -37
  40. package/dist/{cli-rh5smt8t.js → cli-mz9afxxf.js} +14 -3
  41. package/dist/{cli-mz2ygeyq.js → cli-n45mr7zh.js} +2 -2
  42. package/dist/{cli-fes51ya2.js → cli-p0rzjkya.js} +2 -2
  43. package/dist/{cli-3qv8naq8.js → cli-p6zs9h4y.js} +827 -107
  44. package/dist/{cli-mq7kg1b1.js → cli-p9rafdrb.js} +41 -6
  45. package/dist/{cli-cah5detr.js → cli-qgf8emrn.js} +5 -5
  46. package/dist/{cli-2wba1ac9.js → cli-qzme5949.js} +26 -26
  47. package/dist/{cli-znvq0k3t.js → cli-s49hpn37.js} +2 -2
  48. package/dist/{cli-xc65x52q.js → cli-s53vp709.js} +1 -1
  49. package/dist/{cli-0jy85zz4.js → cli-svrj71pg.js} +18 -16
  50. package/dist/{cli-1dp1zj04.js → cli-vwn81jbs.js} +3 -3
  51. package/dist/{cli-ajqhxtnz.js → cli-w1zwctj2.js} +1 -1
  52. package/dist/{cli-22rmcjkg.js → cli-y561h5a9.js} +7 -6
  53. package/dist/{cli-2jg61wab.js → cli-z21whrrg.js} +15 -15
  54. package/dist/cli.js +19 -18
  55. package/dist/{codex-adapter-p4ptwar5.js → codex-adapter-n8ka66vf.js} +2 -2
  56. package/dist/{codex-session-runner-964c7dnk.js → codex-session-runner-fsebts37.js} +1 -1
  57. package/dist/{commands-mbvhbrcy.js → commands-t04rt2pc.js} +2 -2
  58. package/dist/{controller-gnq1k1mf.js → controller-rgna04r2.js} +2 -2
  59. package/dist/{controllers-connect-6dkm71tg.js → controllers-connect-bqm1rtft.js} +20 -20
  60. package/dist/{cursor-adapter-yq5xgfcg.js → cursor-adapter-swr2k95y.js} +1 -1
  61. package/dist/{db-88wcymxt.js → db-24ck09xy.js} +1 -1
  62. package/dist/{db-af9y8nkf.js → db-a4m7haw0.js} +3 -3
  63. package/dist/{dispatcher-606ydx5v.js → dispatcher-nwf9ss0q.js} +17 -17
  64. package/dist/{dsh-adapter-tvhc4k2d.js → dsh-adapter-kpdnhjst.js} +2 -2
  65. package/dist/{external-channel-y8q5z8z2.js → external-channel-j1e2p34p.js} +2 -2
  66. package/dist/{grok-adapter-39aha52t.js → grok-adapter-qtjz0whk.js} +4 -4
  67. package/dist/{handlers-rk9dqech.js → handlers-19xr6dnz.js} +20 -20
  68. package/dist/{heartbeat-jfe9zfjr.js → heartbeat-vtesda5d.js} +20 -20
  69. package/dist/{hook-b1mez1m8.js → hook-8wqaszda.js} +1 -1
  70. package/dist/{hook-ffevzed1.js → hook-b2hhj6rd.js} +1 -1
  71. package/dist/{http-d5b44f1r.js → http-mxcqhhcs.js} +3020 -2463
  72. package/dist/{index-5en23s5z.js → index-1r8wfpzs.js} +1 -1
  73. package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
  74. package/dist/{index-mj46rk9k.js → index-kw2mmat7.js} +20 -20
  75. package/dist/{index-nq8hhv28.js → index-n1vbvta9.js} +19 -10
  76. package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
  77. package/dist/{index-d2qeem97.js → index-spwxjgay.js} +17 -17
  78. package/dist/{index-4q3vj1yr.js → index-srg522m6.js} +17 -17
  79. package/dist/{keepalive-wsby1btq.js → keepalive-dsd8rtgx.js} +3 -3
  80. package/dist/{lead-paa141j6.js → lead-zyerk1x2.js} +30 -30
  81. package/dist/{maintenance-vnshcyv9.js → maintenance-jqmqwnet.js} +1 -1
  82. package/dist/{oauth-refresh-sweep-d6sqn5np.js → oauth-refresh-sweep-q2c3bnvm.js} +2 -2
  83. package/dist/{onboard-nr4xhpzy.js → onboard-w4yd7s8t.js} +2 -2
  84. package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-9tppvxar.js} +2 -2
  85. package/dist/{otel-impl-ytn9wyv9.js → otel-impl-f36byzbd.js} +1 -1
  86. package/dist/{pricing-refresh-pva04zee.js → pricing-refresh-xvak5p4v.js} +3 -3
  87. package/dist/{rbac-roles-7rc1q3g4.js → rbac-roles-682k2mt5.js} +12 -2
  88. package/dist/rbac-roles-gfn30fb8.js +26 -0
  89. package/dist/{render-v2-pyfsg9zk.js → render-v2-hgytz8nn.js} +3 -3
  90. package/dist/{resume-phj8xhqr.js → resume-2dvq348g.js} +7 -5
  91. package/dist/{session-access-jzx2z9h5.js → session-access-7f21ekq9.js} +4 -4
  92. package/dist/{setup-rfg0f0sg.js → setup-epffxtbk.js} +1 -1
  93. package/dist/{work-objects-44z2yjg8.js → work-objects-xz1a5rqk.js} +20 -20
  94. package/dist/{worker-zsfqzxhm.js → worker-42t8emjt.js} +30 -30
  95. package/openapi.json +3163 -2114
  96. package/package.json +7 -6
  97. package/src/be/approval-config-question.ts +389 -0
  98. package/src/be/automation-preflight.ts +37 -3
  99. package/src/be/db/session-tree.ts +158 -13
  100. package/src/be/db/session-visibility.ts +31 -0
  101. package/src/be/db/skill-search.ts +87 -0
  102. package/src/be/db/tasks/read.ts +47 -7
  103. package/src/be/db/workflow-runs.ts +54 -7
  104. package/src/be/db.ts +27 -5
  105. package/src/be/migrations/233_script_run_session_tokens.sql +8 -0
  106. package/src/be/migrations/235_chat_relay_defer.sql +86 -0
  107. package/src/be/migrations/237_session_inbox_cleanup.sql +75 -0
  108. package/src/be/migrations/239_skills_fts.sql +40 -0
  109. package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
  110. package/src/be/migrations/241_skills_meta_fts.sql +39 -0
  111. package/src/be/rbac-roles.ts +138 -0
  112. package/src/be/scripts/run-credential.ts +64 -0
  113. package/src/be/scripts/run-saved.ts +3 -2
  114. package/src/be/swarm-config-guard.ts +23 -0
  115. package/src/be/users.ts +28 -4
  116. package/src/extensions/catalog.generated.json +4 -4
  117. package/src/http/all-routes.ts +1 -0
  118. package/src/http/approval-requests.ts +254 -43
  119. package/src/http/config.ts +22 -37
  120. package/src/http/index.ts +2 -0
  121. package/src/http/schedules.ts +26 -0
  122. package/src/http/scripts.ts +2 -2
  123. package/src/http/sessions.ts +118 -2
  124. package/src/http/user-roles.ts +173 -0
  125. package/src/http/users.ts +9 -0
  126. package/src/http/x.ts +2 -2
  127. package/src/integrations/chat-sdk/commands.ts +14 -1
  128. package/src/integrations/chat-sdk/inbound.ts +42 -6
  129. package/src/integrations/chat-sdk/outbound.ts +35 -10
  130. package/src/integrations/chat-sdk/reactions.ts +107 -0
  131. package/src/integrations/chat-sdk/relay.ts +75 -7
  132. package/src/integrations/chat-sdk/thread-buffer.ts +2 -0
  133. package/src/integrations/reaction-events.ts +20 -0
  134. package/src/providers/amp-adapter.ts +1 -1
  135. package/src/providers/dsh-adapter.ts +1 -1
  136. package/src/rbac/legacy-policy.ts +3 -0
  137. package/src/rbac/permissions.ts +8 -0
  138. package/src/scheduler/schedule-task.ts +44 -3
  139. package/src/scheduler/scheduler.ts +4 -4
  140. package/src/scripts-runtime/loader.ts +8 -1
  141. package/src/slack/ack.ts +2 -1
  142. package/src/slack/reaction-shortcode.ts +4 -7
  143. package/src/tasks/worker-follow-up.ts +30 -0
  144. package/src/tests/approval-config-question.test.ts +702 -0
  145. package/src/tests/approval-requests-notifications.test.ts +189 -0
  146. package/src/tests/chat-sdk-commands.test.ts +12 -0
  147. package/src/tests/chat-sdk-inbound.test.ts +183 -9
  148. package/src/tests/chat-sdk-outbound-files.test.ts +9 -2
  149. package/src/tests/chat-sdk-relay.test.ts +361 -10
  150. package/src/tests/chat-sdk-tools.test.ts +104 -0
  151. package/src/tests/docs-index-footprint.test.js +3 -3
  152. package/src/tests/dsh-adapter.test.ts +1 -1
  153. package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
  154. package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
  155. package/src/tests/http-users.test.ts +38 -0
  156. package/src/tests/list-endpoint-slimming.test.ts +33 -0
  157. package/src/tests/migration-237-session-inbox-cleanup.test.ts +136 -0
  158. package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
  159. package/src/tests/rbac-engine.test.ts +2 -0
  160. package/src/tests/rbac-roles.test.ts +10 -0
  161. package/src/tests/schedule-http-triage-tools.test.ts +44 -0
  162. package/src/tests/script-api-credential.test.ts +173 -0
  163. package/src/tests/script-connections.test.ts +10 -0
  164. package/src/tests/script-run-config-scope.test.ts +120 -0
  165. package/src/tests/script-run-token.test.ts +75 -0
  166. package/src/tests/scripts-http.test.ts +19 -0
  167. package/src/tests/sessions-tree.test.ts +410 -1
  168. package/src/tests/skill-search-long-content.test.ts +193 -0
  169. package/src/tests/skill-search.test.ts +173 -22
  170. package/src/tests/user-roles-http.test.ts +204 -0
  171. package/src/tests/workflow-hitl-dynamic-questions.test.ts +1 -1
  172. package/src/tests/workflow-sub-workflow.test.ts +538 -1
  173. package/src/tools/chat-reply.ts +2 -0
  174. package/src/tools/request-human-input.ts +27 -5
  175. package/src/tools/schedules/patch-schedule.ts +18 -0
  176. package/src/tools/schedules/update-schedule.ts +18 -0
  177. package/src/types.ts +23 -0
  178. package/src/workflows/approval-notifications.ts +221 -0
  179. package/src/workflows/checkpoint.ts +69 -35
  180. package/src/workflows/engine.ts +234 -87
  181. package/src/workflows/executors/base.ts +6 -0
  182. package/src/workflows/executors/human-in-the-loop.ts +36 -173
  183. package/src/workflows/executors/sub-workflow.ts +45 -16
  184. package/src/workflows/executors/swarm-script.ts +2 -2
  185. package/src/workflows/recovery.ts +63 -1
  186. package/src/workflows/resume.ts +60 -21
  187. package/src/workflows/retry-poller.ts +25 -4
  188. package/templates/extensions/comms-adherence-gate/README.md +38 -13
  189. package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
  190. package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
  191. package/templates/schedules/weekly-harness-upgrade-check/content.md +1 -1
  192. package/templates/skills/code-quality/content.md +1 -1
  193. package/templates/skills/turso-interaction/content.md +1 -1
  194. package/templates/skills/workflow-iterate/content.md +8 -1
  195. package/dist/rbac-roles-ff1ffmec.js +0 -12
  196. /package/dist/{cli-stxdrqs2.js → cli-avdvgcay.js} +0 -0
  197. /package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-tqsge1ay.js} +0 -0
@@ -0,0 +1,702 @@
1
+ /**
2
+ * HITL `config` questions: create-time rules on every path, the answer path
3
+ * that writes swarm_config, and a sentinel regression test proving a secret's
4
+ * plaintext never lands anywhere except the encrypted swarm_config row.
5
+ */
6
+ import { afterAll, beforeAll, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
7
+ import { unlink } from "node:fs/promises";
8
+ import { createServer, type Server } from "node:http";
9
+
10
+ const slackPayloads: unknown[] = [];
11
+ const postMessage = mock(async (args: Record<string, unknown>) => {
12
+ slackPayloads.push(args);
13
+ return { ts: "1700000000.000100" };
14
+ });
15
+ const conversationsInfo = mock(async (_args: { channel: string }) => ({
16
+ channel: { is_ext_shared: false },
17
+ }));
18
+ mock.module("../slack/app", () => ({
19
+ getSlackApp: () => ({
20
+ client: { chat: { postMessage }, conversations: { info: conversationsInfo } },
21
+ }),
22
+ }));
23
+
24
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
25
+ import { decryptSecret, getEncryptionKey } from "../be/crypto";
26
+ import * as db from "../be/db";
27
+ import {
28
+ closeDb,
29
+ createAgent,
30
+ createTaskExtended,
31
+ createUser,
32
+ createWorkflow,
33
+ getApprovalRequestById,
34
+ getDbClient,
35
+ getWorkflowRun,
36
+ getWorkflowRunStepsByRunId,
37
+ initDb,
38
+ upsertSwarmConfig,
39
+ } from "../be/db";
40
+ import { isSealedJson, openSealedJson } from "../be/sealed-json";
41
+ import { type IdentityActor, mintToken } from "../be/users";
42
+ import { handleApprovalRequests } from "../http/approval-requests";
43
+ import { resolveHttpRequestAuth } from "../http/auth";
44
+ import * as core from "../http/core";
45
+ import { resetExternalChannelStateForTesting } from "../slack/external-channel";
46
+ import { registerRequestHumanInputTool } from "../tools/request-human-input";
47
+ import type { WorkflowDefinition } from "../types";
48
+ import { setRequestAuth } from "../utils/request-auth-context";
49
+ import { startWorkflowExecution } from "../workflows/engine";
50
+ import { InProcessEventBus, workflowEventBus } from "../workflows/event-bus";
51
+ import type { ExecutorDependencies } from "../workflows/executors/base";
52
+ import { HumanInTheLoopExecutor } from "../workflows/executors/human-in-the-loop";
53
+ import { ExecutorRegistry } from "../workflows/executors/registry";
54
+ import { setupWorkflowResumeListener } from "../workflows/resume";
55
+ import { listenOnFreePort } from "./test-net";
56
+
57
+ const TEST_DB_PATH = "./test-approval-config-question.sqlite";
58
+ const API_KEY = "approval-config-question-test-key";
59
+
60
+ let server: Server;
61
+ let baseUrl = "";
62
+ let workerId = "";
63
+ let leadId = "";
64
+ let toolHandler: (args: unknown, extra: unknown) => Promise<Record<string, any>>;
65
+ const resolvedEvents: unknown[] = [];
66
+ const reloadSpy = spyOn(core, "scheduleIntegrationsReload").mockImplementation(() => {});
67
+
68
+ const uniqueKey = (prefix: string) =>
69
+ `${prefix}_${crypto.randomUUID().replaceAll("-", "").slice(0, 10).toUpperCase()}`;
70
+ const operator = { Authorization: `Bearer ${API_KEY}` };
71
+
72
+ async function api(
73
+ method: string,
74
+ path: string,
75
+ body?: unknown,
76
+ headers: Record<string, string> = operator,
77
+ ): Promise<{ status: number; text: string; json: Record<string, any> }> {
78
+ const res = await fetch(`${baseUrl}${path}`, {
79
+ method,
80
+ headers: { "Content-Type": "application/json", ...headers },
81
+ body: body === undefined ? undefined : JSON.stringify(body),
82
+ });
83
+ const text = await res.text();
84
+ return { status: res.status, text, json: JSON.parse(text) as Record<string, any> };
85
+ }
86
+
87
+ function createRequest(body: Record<string, unknown>) {
88
+ // Slack notifications need a lead caller.
89
+ const headers = body.notifications ? { ...operator, "X-Agent-ID": leadId } : operator;
90
+ return api(
91
+ "POST",
92
+ "/api/approval-requests",
93
+ { title: "Set up Bitbucket", approvers: { policy: "any" }, ...body },
94
+ headers,
95
+ );
96
+ }
97
+
98
+ async function createConfigRequest(
99
+ config: Record<string, unknown>,
100
+ extra: Record<string, unknown> = {},
101
+ questionExtra: Record<string, unknown> = {},
102
+ ): Promise<string> {
103
+ const res = await createRequest({
104
+ questions: [{ id: "cfg", type: "config", config, ...questionExtra }],
105
+ ...extra,
106
+ });
107
+ expect(res.status).toBe(201);
108
+ return res.json.approvalRequest.id as string;
109
+ }
110
+
111
+ function respond(id: string, responses: Record<string, unknown>, headers = operator) {
112
+ return api("POST", `/api/approval-requests/${id}/respond`, { responses }, headers);
113
+ }
114
+
115
+ async function configRow(key: string) {
116
+ return getDbClient().get<{
117
+ value: string;
118
+ isSecret: number;
119
+ encrypted: number;
120
+ lastUpdatedAt: string;
121
+ envPath: string | null;
122
+ description: string | null;
123
+ }>("SELECT * FROM swarm_config WHERE key = ?", [key]);
124
+ }
125
+
126
+ /**
127
+ * Every value in every column of every table, cast to text, plus the opened
128
+ * form of sealed replay columns. Returns `table.column` for each hit.
129
+ */
130
+ async function scanDatabase(needles: string[]): Promise<{ hits: string[]; columns: number }> {
131
+ const client = getDbClient();
132
+ const tables = await client.query<{ name: string }>(
133
+ "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
134
+ );
135
+ const hits = new Set<string>();
136
+ let columns = 0;
137
+ for (const { name: table } of tables) {
138
+ let cols: { name: string }[];
139
+ try {
140
+ cols = await client.query<{ name: string }>("SELECT name FROM pragma_table_info(?)", [table]);
141
+ } catch {
142
+ continue;
143
+ }
144
+ for (const { name: column } of cols) {
145
+ let rows: { v: string | null }[];
146
+ try {
147
+ rows = await client.query<{ v: string | null }>(
148
+ `SELECT CAST("${column}" AS TEXT) AS v FROM "${table}" WHERE "${column}" IS NOT NULL`,
149
+ );
150
+ } catch {
151
+ continue;
152
+ }
153
+ columns++;
154
+ for (const { v } of rows) {
155
+ if (!v) continue;
156
+ const views = [v];
157
+ if (isSealedJson(v)) views.push(JSON.stringify(openSealedJson(v)));
158
+ if (views.some((view) => needles.some((needle) => view.includes(needle)))) {
159
+ hits.add(`${table}.${column}`);
160
+ }
161
+ }
162
+ }
163
+ }
164
+ return { hits: [...hits].sort(), columns };
165
+ }
166
+
167
+ async function followUpTasks(sourceTaskId: string) {
168
+ return getDbClient().query<Record<string, unknown>>(
169
+ "SELECT * FROM agent_tasks WHERE parentTaskId = ? AND taskType = 'hitl-follow-up'",
170
+ [sourceTaskId],
171
+ );
172
+ }
173
+
174
+ async function sourceTask(): Promise<string> {
175
+ const task = await createTaskExtended("configure bitbucket", {
176
+ agentId: workerId,
177
+ source: "mcp",
178
+ });
179
+ return task.id;
180
+ }
181
+
182
+ function callTool(args: Record<string, unknown>, agentId = workerId) {
183
+ return toolHandler(args, {
184
+ sessionId: "approval-config-question",
185
+ requestInfo: { headers: { "x-agent-id": agentId } },
186
+ });
187
+ }
188
+
189
+ beforeAll(async () => {
190
+ for (const suffix of ["", "-wal", "-shm"]) await unlink(TEST_DB_PATH + suffix).catch(() => {});
191
+ initDb(TEST_DB_PATH);
192
+ workerId = crypto.randomUUID();
193
+ await createAgent({ id: workerId, name: "config-worker", isLead: false, status: "idle" });
194
+ leadId = crypto.randomUUID();
195
+ await createAgent({ id: leadId, name: "config-lead", isLead: true, status: "idle" });
196
+
197
+ const mcp = new McpServer({ name: "approval-config-question", version: "1.0.0" });
198
+ registerRequestHumanInputTool(mcp);
199
+ toolHandler = (mcp as unknown as { _registeredTools: Record<string, { handler: any }> })
200
+ ._registeredTools["request-human-input"]!.handler;
201
+
202
+ workflowEventBus.on("approval.resolved", (payload) => resolvedEvents.push(payload));
203
+
204
+ server = createServer(async (req, res) => {
205
+ const auth = await resolveHttpRequestAuth(req, API_KEY);
206
+ if (!auth) {
207
+ res.writeHead(401, { "Content-Type": "application/json" });
208
+ res.end(JSON.stringify({ error: "Unauthorized" }));
209
+ return;
210
+ }
211
+ setRequestAuth(req, auth);
212
+ const url = new URL(req.url ?? "/", "http://localhost");
213
+ const handled = await handleApprovalRequests(
214
+ req,
215
+ res,
216
+ url.pathname.split("/").filter(Boolean),
217
+ url.searchParams,
218
+ );
219
+ if (!handled) {
220
+ res.writeHead(404, { "Content-Type": "application/json" });
221
+ res.end(JSON.stringify({ error: "Not found" }));
222
+ }
223
+ });
224
+ baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
225
+ });
226
+
227
+ afterAll(async () => {
228
+ reloadSpy.mockRestore();
229
+ await new Promise<void>((resolve) => server.close(() => resolve()));
230
+ closeDb();
231
+ for (const suffix of ["", "-wal", "-shm"]) await unlink(TEST_DB_PATH + suffix).catch(() => {});
232
+ });
233
+
234
+ beforeEach(() => {
235
+ resetExternalChannelStateForTesting();
236
+ });
237
+
238
+ describe("create", () => {
239
+ test("tool: a secret question defaults isSecret to true and label to the key", async () => {
240
+ const key = uniqueKey("BITBUCKET_TOKEN");
241
+ const out = await callTool({
242
+ title: "Bitbucket",
243
+ questions: [{ id: "bb", type: "config", config: { key, scope: "global" } }],
244
+ });
245
+ expect(out.isError).not.toBe(true);
246
+ const stored = await getApprovalRequestById(out.structuredContent.requestId);
247
+ const [question] = stored!.questions as Record<string, any>[];
248
+ expect(question!.label).toBe(key);
249
+ expect(question!.required).toBe(true);
250
+ expect(question!.config.isSecret).toBe(true);
251
+ });
252
+
253
+ test("tool: agent scope defaults scopeId to the calling agent", async () => {
254
+ const key = uniqueKey("AGENT_SCOPED");
255
+ const out = await callTool({
256
+ title: "Agent key",
257
+ questions: [{ id: "a", type: "config", config: { key, scope: "agent", isSecret: false } }],
258
+ });
259
+ expect(out.isError).not.toBe(true);
260
+ const stored = await getApprovalRequestById(out.structuredContent.requestId);
261
+ expect((stored!.questions as Record<string, any>[])[0]!.config.scopeId).toBe(workerId);
262
+ });
263
+
264
+ test("tool: reserved and internal keys are refused", async () => {
265
+ for (const key of ["API_KEY", "runtime_owner_id"]) {
266
+ const out = await callTool({
267
+ title: "Bad",
268
+ questions: [{ id: "k", type: "config", config: { key, scope: "global" } }],
269
+ });
270
+ expect(out.isError).toBe(true);
271
+ expect(JSON.stringify(out.content)).toContain(key);
272
+ }
273
+ });
274
+
275
+ test("tool: options on a secret are refused", async () => {
276
+ const out = await callTool({
277
+ title: "Bad",
278
+ questions: [
279
+ {
280
+ id: "k",
281
+ type: "config",
282
+ config: {
283
+ key: uniqueKey("SECRET"),
284
+ scope: "global",
285
+ options: [{ value: "a", label: "A" }],
286
+ },
287
+ },
288
+ ],
289
+ });
290
+ expect(out.isError).toBe(true);
291
+ });
292
+
293
+ test("tool: isSecret false over an existing secret row stores true", async () => {
294
+ const key = uniqueKey("ALREADY_SECRET");
295
+ await upsertSwarmConfig({ scope: "global", key, value: "x", isSecret: true });
296
+ const out = await callTool({
297
+ title: "Plain?",
298
+ questions: [{ id: "k", type: "config", config: { key, scope: "global", isSecret: false } }],
299
+ });
300
+ expect(out.isError).not.toBe(true);
301
+ const stored = await getApprovalRequestById(out.structuredContent.requestId);
302
+ expect((stored!.questions as Record<string, any>[])[0]!.config.isSecret).toBe(true);
303
+ });
304
+
305
+ test("REST: valid question is 201; reserved key and non-any policy are 400", async () => {
306
+ const ok = await createRequest({
307
+ questions: [{ id: "cfg", type: "config", config: { key: uniqueKey("OK"), scope: "global" } }],
308
+ });
309
+ expect(ok.status).toBe(201);
310
+ expect(ok.json.approvalRequest.questions[0].label).toMatch(/^OK_/);
311
+
312
+ const reserved = await createRequest({
313
+ questions: [{ id: "cfg", type: "config", config: { key: "API_KEY", scope: "global" } }],
314
+ });
315
+ expect(reserved.status).toBe(400);
316
+ expect(reserved.text).toContain("API_KEY");
317
+ expect(reserved.text).toContain("reserved");
318
+
319
+ const all = await createRequest({
320
+ approvers: { policy: "all" },
321
+ questions: [
322
+ { id: "cfg", type: "config", config: { key: uniqueKey("ALL"), scope: "global" } },
323
+ ],
324
+ });
325
+ expect(all.status).toBe(400);
326
+ expect(all.text).toContain("approvers.policy");
327
+ });
328
+
329
+ test("REST: a non-config question still needs a label", async () => {
330
+ const res = await createRequest({ questions: [{ id: "t", type: "text" }] });
331
+ expect(res.status).toBe(400);
332
+ expect(res.text).toContain("label is required");
333
+ });
334
+
335
+ test("executor: a bad config question fails the node", async () => {
336
+ const registry = makeRegistry();
337
+ const workflow = await createWorkflow({
338
+ name: `hitl-config-bad-${crypto.randomUUID()}`,
339
+ definition: {
340
+ nodes: [
341
+ {
342
+ id: "card",
343
+ type: "human-in-the-loop",
344
+ config: {
345
+ title: "Bad",
346
+ questions: [{ id: "k", type: "config", config: { key: "API_KEY", scope: "global" } }],
347
+ approvers: { policy: "any" },
348
+ },
349
+ },
350
+ ],
351
+ } satisfies WorkflowDefinition,
352
+ });
353
+ const runId = await startWorkflowExecution(workflow, {}, registry);
354
+ const run = await getWorkflowRun(runId);
355
+ expect(run!.status).toBe("failed");
356
+ const [step] = await getWorkflowRunStepsByRunId(runId);
357
+ expect(step!.error).toContain("reserved");
358
+ });
359
+ });
360
+
361
+ describe("respond", () => {
362
+ test("keep on an existing row writes nothing and reports its updatedAt", async () => {
363
+ const key = uniqueKey("KEEP_ME");
364
+ const row = await upsertSwarmConfig({ scope: "global", key, value: "old", isSecret: true });
365
+ const id = await createConfigRequest({ key, scope: "global" });
366
+
367
+ const get = await api("GET", `/api/approval-requests/${id}`);
368
+ expect(get.json.approvalRequest.configState.cfg).toEqual({
369
+ exists: true,
370
+ isSecret: true,
371
+ updatedAt: row.lastUpdatedAt,
372
+ });
373
+
374
+ const res = await respond(id, { cfg: { action: "keep" } });
375
+ expect(res.status).toBe(200);
376
+ expect(res.json.approvalRequest.responses.cfg).toEqual({
377
+ key,
378
+ scope: "global",
379
+ scopeId: null,
380
+ isSecret: true,
381
+ status: "kept",
382
+ updatedAt: row.lastUpdatedAt,
383
+ });
384
+ expect((await configRow(key))!.lastUpdatedAt).toBe(row.lastUpdatedAt);
385
+ });
386
+
387
+ test("keep with no row is 400", async () => {
388
+ const id = await createConfigRequest({ key: uniqueKey("MISSING"), scope: "global" });
389
+ const res = await respond(id, { cfg: { action: "keep" } });
390
+ expect(res.status).toBe(400);
391
+ expect(res.text).toContain("not set");
392
+ });
393
+
394
+ test("skip is 400 on a required question and skipped on an optional one", async () => {
395
+ const required = await createConfigRequest({ key: uniqueKey("REQ"), scope: "global" });
396
+ expect((await respond(required, { cfg: { action: "skip" } })).status).toBe(400);
397
+
398
+ const key = uniqueKey("OPT");
399
+ const optional = await createConfigRequest({ key, scope: "global" }, {}, { required: false });
400
+ const res = await respond(optional, { cfg: { action: "skip" } });
401
+ expect(res.status).toBe(200);
402
+ expect(res.json.approvalRequest.responses.cfg.status).toBe("skipped");
403
+ expect(await configRow(key)).toBeNull();
404
+ });
405
+
406
+ test("a rejection writes nothing and skips the config question", async () => {
407
+ const key = uniqueKey("REJECTED");
408
+ const res = await createRequest({
409
+ questions: [
410
+ { id: "ok", type: "approval", label: "Go?", required: true },
411
+ { id: "cfg", type: "config", config: { key, scope: "global" } },
412
+ ],
413
+ });
414
+ const value = `hitl-rejected-${crypto.randomUUID()}`;
415
+ const answered = await respond(res.json.approvalRequest.id, {
416
+ ok: { approved: false },
417
+ cfg: { action: "set", value },
418
+ });
419
+ expect(answered.status).toBe(200);
420
+ expect(answered.json.approvalRequest.status).toBe("rejected");
421
+ expect(answered.json.approvalRequest.responses.cfg.status).toBe("skipped");
422
+ expect(answered.text).not.toContain(value);
423
+ expect(await configRow(key)).toBeNull();
424
+ });
425
+
426
+ test("a secret that fails the key's validator is 400 without echoing the value", async () => {
427
+ const id = await createConfigRequest({ key: "DB_RETENTION_TICK_BUDGET_MS", scope: "global" });
428
+ const value = `hitl-invalid-${crypto.randomUUID()}`;
429
+ const res = await respond(id, { cfg: { action: "set", value } });
430
+ expect(res.status).toBe(400);
431
+ expect(res.text).toContain("DB_RETENTION_TICK_BUDGET_MS validation");
432
+ expect(res.text).not.toContain(value);
433
+ expect(await configRow("DB_RETENTION_TICK_BUDGET_MS")).toBeNull();
434
+ });
435
+
436
+ test("a plain key with options returns its value and shows it in configState", async () => {
437
+ const key = uniqueKey("REGION");
438
+ const id = await createConfigRequest({
439
+ key,
440
+ scope: "global",
441
+ isSecret: false,
442
+ options: [
443
+ { value: "eu", label: "Europe" },
444
+ { value: "us", label: "United States" },
445
+ ],
446
+ defaultValue: "eu",
447
+ });
448
+ expect((await respond(id, { cfg: { action: "set", value: "mars" } })).status).toBe(400);
449
+
450
+ reloadSpy.mockClear();
451
+ const res = await respond(id, { cfg: { action: "set", value: "us" } });
452
+ expect(res.status).toBe(200);
453
+ expect(res.json.approvalRequest.responses.cfg).toMatchObject({
454
+ key,
455
+ isSecret: false,
456
+ status: "set",
457
+ value: "us",
458
+ });
459
+ expect(res.json.approvalRequest.configState.cfg).toMatchObject({
460
+ exists: true,
461
+ isSecret: false,
462
+ value: "us",
463
+ });
464
+ const row = await configRow(key);
465
+ expect(row).toMatchObject({ value: "us", isSecret: 0, encrypted: 0 });
466
+ // The global write schedules one integrations reload, after commit.
467
+ expect(reloadSpy).toHaveBeenCalledTimes(1);
468
+ });
469
+
470
+ test("set keeps the row's envPath and description", async () => {
471
+ const key = uniqueKey("CARRY");
472
+ await upsertSwarmConfig({
473
+ scope: "global",
474
+ key,
475
+ value: "old",
476
+ isSecret: false,
477
+ description: "kept description",
478
+ });
479
+ const id = await createConfigRequest({ key, scope: "global", isSecret: false });
480
+ expect((await respond(id, { cfg: { action: "set", value: "new" } })).status).toBe(200);
481
+ expect(await configRow(key)).toMatchObject({ value: "new", description: "kept description" });
482
+ });
483
+
484
+ test("an API-only key is treated as secret even when stored plain", async () => {
485
+ const key = "TRANSCRIPTION_API_KEY";
486
+ const stored = `hitl-api-only-${crypto.randomUUID()}`;
487
+ await upsertSwarmConfig({ scope: "global", key, value: stored, isSecret: false });
488
+ const created = await createRequest({
489
+ questions: [{ id: "cfg", type: "config", config: { key, scope: "global", isSecret: false } }],
490
+ });
491
+ expect(created.status).toBe(201);
492
+ expect(created.json.approvalRequest.questions[0].config.isSecret).toBe(true);
493
+ const id = created.json.approvalRequest.id as string;
494
+
495
+ const get = await api("GET", `/api/approval-requests/${id}`);
496
+ expect(get.json.approvalRequest.configState.cfg.isSecret).toBe(true);
497
+ expect(get.text).not.toContain(stored);
498
+
499
+ const kept = await respond(id, { cfg: { action: "keep" } });
500
+ expect(kept.status).toBe(200);
501
+ expect(kept.json.approvalRequest.responses.cfg).not.toHaveProperty("value");
502
+ expect(kept.text).not.toContain(stored);
503
+ });
504
+
505
+ test("a user without config.write.any cannot set, one with it can", async () => {
506
+ const key = uniqueKey("USER_GRANT");
507
+ const id = await createConfigRequest({ key, scope: "global" });
508
+ const roleId = `approver-${crypto.randomUUID()}`;
509
+ await getDbClient().run(
510
+ "INSERT INTO roles (id, name, description, isBuiltin, grantsAll) VALUES (?, ?, '', 0, 0)",
511
+ [roleId, roleId],
512
+ );
513
+ await getDbClient().run("INSERT INTO role_permissions (roleId, verb) VALUES (?, ?)", [
514
+ roleId,
515
+ "approval.respond",
516
+ ]);
517
+ const approver = await createUser({ name: "Approver only" });
518
+ // New users start as admin (rbac-roles insert trigger); narrow this one.
519
+ await getDbClient().run("DELETE FROM principal_roles WHERE principalId = ?", [approver.id]);
520
+ await getDbClient().run(
521
+ "INSERT INTO principal_roles (principalType, principalId, roleId) VALUES ('user', ?, ?)",
522
+ [approver.id, roleId],
523
+ );
524
+ const actor: IdentityActor = { kind: "operator", id: "approval-config-question-test" };
525
+ const approverToken = (await mintToken(approver.id, "approver", actor)).plaintext;
526
+
527
+ const denied = await respond(
528
+ id,
529
+ { cfg: { action: "set", value: "nope" } },
530
+ { Authorization: `Bearer ${approverToken}` },
531
+ );
532
+ expect(denied.status).toBe(403);
533
+ expect(await configRow(key)).toBeNull();
534
+ expect((await getApprovalRequestById(id))!.status).toBe("pending");
535
+
536
+ const admin = await createUser({ name: "Config admin" });
537
+ const adminToken = (await mintToken(admin.id, "admin", actor)).plaintext;
538
+ const allowed = await respond(
539
+ id,
540
+ { cfg: { action: "set", value: "yes" } },
541
+ { Authorization: `Bearer ${adminToken}` },
542
+ );
543
+ expect(allowed.status).toBe(200);
544
+ expect(await configRow(key)).not.toBeNull();
545
+ });
546
+
547
+ test("an agent cannot answer a config question", async () => {
548
+ const key = uniqueKey("AGENT_DENIED");
549
+ const id = await createConfigRequest({ key, scope: "global" });
550
+ const res = await respond(
551
+ id,
552
+ { cfg: { action: "set", value: "nope" } },
553
+ { ...operator, "X-Agent-ID": workerId },
554
+ );
555
+ expect(res.status).toBe(403);
556
+ expect(await configRow(key)).toBeNull();
557
+ expect((await getApprovalRequestById(id))!.status).toBe("pending");
558
+ });
559
+ });
560
+
561
+ function makeRegistry(): ExecutorRegistry {
562
+ const deps: ExecutorDependencies = {
563
+ db: db as typeof import("../be/db"),
564
+ eventBus: new InProcessEventBus(),
565
+ interpolate: (t: string) => t,
566
+ };
567
+ const registry = new ExecutorRegistry();
568
+ registry.register(new HumanInTheLoopExecutor(deps));
569
+ return registry;
570
+ }
571
+
572
+ async function waitFor(check: () => Promise<boolean>): Promise<void> {
573
+ for (let i = 0; i < 200; i++) {
574
+ if (await check()) return;
575
+ await Bun.sleep(10);
576
+ }
577
+ throw new Error("timed out waiting");
578
+ }
579
+
580
+ describe("no plaintext egress", () => {
581
+ test("standalone: the secret reaches only the encrypted swarm_config row", async () => {
582
+ const SENTINEL = `hitl-sentinel-${crypto.randomUUID()}`;
583
+ const key = uniqueKey("BITBUCKET_TOKEN");
584
+ const redaction = `[REDACTED:config:${key}]`;
585
+ const taskId = await sourceTask();
586
+ slackPayloads.length = 0;
587
+ resolvedEvents.length = 0;
588
+
589
+ const id = await createConfigRequest(
590
+ { key, scope: "global" },
591
+ { sourceTaskId: taskId, notifications: [{ channel: "slack", target: "C_CONFIG" }] },
592
+ );
593
+ await waitFor(async () => slackPayloads.length > 0);
594
+
595
+ const res = await respond(id, { cfg: { action: "set", value: SENTINEL } });
596
+ expect(res.status).toBe(200);
597
+ expect(res.text).not.toContain(SENTINEL);
598
+ expect(res.json.approvalRequest.responses.cfg).toMatchObject({
599
+ key,
600
+ isSecret: true,
601
+ status: "set",
602
+ });
603
+ expect(res.json.approvalRequest.responses.cfg.value).toBeUndefined();
604
+ expect(res.json.approvalRequest.configState.cfg).not.toHaveProperty("value");
605
+
606
+ // 1. Opened responses and the vote.
607
+ const stored = await getApprovalRequestById(id);
608
+ const storedJson = JSON.stringify(stored);
609
+ expect(storedJson).not.toContain(SENTINEL);
610
+ expect(storedJson).not.toContain(redaction);
611
+
612
+ // 2. Slack card and the approval.resolved payload (the extension bridge
613
+ // and workflow resume both consume this event).
614
+ expect(JSON.stringify(slackPayloads)).not.toContain(SENTINEL);
615
+ expect(JSON.stringify(slackPayloads)).toContain("(set on dashboard)");
616
+ expect(resolvedEvents.length).toBe(1);
617
+ expect(JSON.stringify(resolvedEvents)).not.toContain(SENTINEL);
618
+ expect(JSON.stringify(resolvedEvents)).not.toContain(redaction);
619
+
620
+ // 3. The follow-up task carries the result line only.
621
+ const followUps = await followUpTasks(taskId);
622
+ expect(followUps.length).toBe(1);
623
+ const followUpJson = JSON.stringify(followUps);
624
+ expect(followUpJson).not.toContain(SENTINEL);
625
+ expect(followUpJson).toContain(`${key} (global, secret): set, updated `);
626
+
627
+ // 4. Every column of every table, sealed columns opened.
628
+ const scan = await scanDatabase([SENTINEL, redaction]);
629
+ expect(scan.columns).toBeGreaterThan(100);
630
+ expect(scan.hits).toEqual([]);
631
+
632
+ // 5. Stored encrypted, decrypts to the answer.
633
+ const row = await configRow(key);
634
+ expect(row).toMatchObject({ isSecret: 1, encrypted: 1 });
635
+ expect(row!.value).not.toBe(SENTINEL);
636
+ expect(decryptSecret(row!.value, getEncryptionKey())).toBe(SENTINEL);
637
+ });
638
+
639
+ test("positive control: the same checks find a text answer", async () => {
640
+ const SENTINEL2 = `hitl-control-${crypto.randomUUID()}`;
641
+ const taskId = await sourceTask();
642
+ const res = await createRequest({
643
+ sourceTaskId: taskId,
644
+ questions: [{ id: "note", type: "text", label: "Note", required: true }],
645
+ });
646
+ const answered = await respond(res.json.approvalRequest.id, { note: SENTINEL2 });
647
+ expect(answered.status).toBe(200);
648
+
649
+ const stored = await getApprovalRequestById(res.json.approvalRequest.id);
650
+ expect(JSON.stringify(stored!.responses)).toContain(SENTINEL2);
651
+ expect(JSON.stringify(await followUpTasks(taskId))).toContain(SENTINEL2);
652
+ const scan = await scanDatabase([SENTINEL2]);
653
+ expect(scan.hits).toContain("approval_requests.responses");
654
+ expect(scan.hits).toContain("agent_tasks.task");
655
+ });
656
+
657
+ test("workflow: step output and run context carry only the result", async () => {
658
+ const SENTINEL = `hitl-sentinel-wf-${crypto.randomUUID()}`;
659
+ const key = uniqueKey("WF_TOKEN");
660
+ const registry = makeRegistry();
661
+ const unsubscribe = setupWorkflowResumeListener(workflowEventBus, registry);
662
+ try {
663
+ const workflow = await createWorkflow({
664
+ name: `hitl-config-${crypto.randomUUID()}`,
665
+ definition: {
666
+ nodes: [
667
+ {
668
+ id: "card",
669
+ type: "human-in-the-loop",
670
+ config: {
671
+ title: "Token",
672
+ questions: [{ id: "cfg", type: "config", config: { key, scope: "global" } }],
673
+ approvers: { policy: "any" },
674
+ },
675
+ },
676
+ ],
677
+ } satisfies WorkflowDefinition,
678
+ });
679
+ const runId = await startWorkflowExecution(workflow, {}, registry);
680
+ const [waiting] = await getWorkflowRunStepsByRunId(runId);
681
+ expect(waiting!.status).toBe("waiting");
682
+ const request = await db.getApprovalRequestByStepId(waiting!.id);
683
+
684
+ const res = await respond(request!.id, { cfg: { action: "set", value: SENTINEL } });
685
+ expect(res.status).toBe(200);
686
+ await waitFor(async () => (await getWorkflowRun(runId))!.status === "completed");
687
+
688
+ const [step] = await getWorkflowRunStepsByRunId(runId);
689
+ const output = step!.output as { responses: Record<string, unknown> };
690
+ expect(output.responses.cfg).toEqual(res.json.approvalRequest.responses.cfg);
691
+ expect(JSON.stringify(step)).not.toContain(SENTINEL);
692
+ expect(JSON.stringify(await getWorkflowRun(runId))).not.toContain(SENTINEL);
693
+
694
+ const scan = await scanDatabase([SENTINEL, `[REDACTED:config:${key}]`]);
695
+ expect(scan.hits).toEqual([]);
696
+ const row = await configRow(key);
697
+ expect(decryptSecret(row!.value, getEncryptionKey())).toBe(SENTINEL);
698
+ } finally {
699
+ unsubscribe();
700
+ }
701
+ });
702
+ });