@desplega.ai/agent-swarm 2.5.2 → 2.5.4

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 (236) hide show
  1. package/dist/{acp-adapter-csh0zz3b.js → acp-adapter-ek38cwsv.js} +5 -5
  2. package/dist/{actions-6xx079d1.js → actions-92mepq03.js} +19 -19
  3. package/dist/{amp-adapter-8n6f0hz8.js → amp-adapter-9gaxg015.js} +4 -4
  4. package/dist/{app-ft6r6v23.js → app-ctn8gsgx.js} +3 -3
  5. package/dist/{assistant-vt2cyfzd.js → assistant-zyr5kz47.js} +21 -21
  6. package/dist/{boot-reembed-tgrfy27t.js → boot-reembed-dwzfttdr.js} +1 -1
  7. package/dist/{boot-reembed-r3e808ky.js → boot-reembed-w75maga2.js} +1 -1
  8. package/dist/{boot-scrub-sweep-masdwxkm.js → boot-scrub-sweep-e9kq43kf.js} +3 -3
  9. package/dist/{boot-seeds-0n82qc7q.js → boot-seeds-x28v3fhb.js} +4 -4
  10. package/dist/{claude-adapter-s7j47k06.js → claude-adapter-f0afqnne.js} +5 -5
  11. package/dist/{claude-managed-adapter-gt6pa35d.js → claude-managed-adapter-x81a7r65.js} +3 -3
  12. package/dist/{claude-managed-setup-3hzrgrnk.js → claude-managed-setup-bqzwjd9x.js} +2 -2
  13. package/dist/{claude-sdk-session-0zrdzyx2.js → claude-sdk-session-7b7whkaq.js} +9307 -9254
  14. package/dist/{cli-a5ebc95n.js → cli-0shx9j93.js} +16 -13
  15. package/dist/{cli-4qkg9968.js → cli-19202kew.js} +4 -4
  16. package/dist/{cli-cyfgxnvb.js → cli-1r5ad7j5.js} +19 -17
  17. package/dist/{cli-jmcjbjkt.js → cli-25n3mv5d.js} +9 -7
  18. package/dist/cli-2655k16q.js +140 -0
  19. package/dist/{cli-wadzapwb.js → cli-277c31dw.js} +1 -1
  20. package/dist/{cli-n6kmqngw.js → cli-4b2z6n0m.js} +29 -7
  21. package/dist/{cli-7ccf7akh.js → cli-5bnsc0e1.js} +9 -7
  22. package/dist/{cli-qrjv7t18.js → cli-621ce7kw.js} +1 -1
  23. package/dist/{cli-xzge46sj.js → cli-64mh9mf3.js} +20 -6
  24. package/dist/{cli-b6tnjdvx.js → cli-6a1xg1p7.js} +6 -2
  25. package/dist/{cli-ydg6ep51.js → cli-6n8axjv1.js} +1 -1
  26. package/dist/{cli-e91vshb1.js → cli-7p4jxgtt.js} +5 -5
  27. package/dist/{cli-f3pfn4x3.js → cli-9j129brq.js} +9 -3
  28. package/dist/{cli-xw9q173h.js → cli-9s05422w.js} +1 -1
  29. package/dist/{cli-15xd0j7w.js → cli-abs8ndtr.js} +12 -12
  30. package/dist/{cli-h5d22pfs.js → cli-asq5ydm4.js} +219 -309
  31. package/dist/{cli-w9etnnxk.js → cli-bf8ma3f1.js} +120 -50
  32. package/dist/{cli-0vgy5wfw.js → cli-bvpgr9ny.js} +2 -2
  33. package/dist/{cli-3e9vs93g.js → cli-c61mbdds.js} +4 -4
  34. package/dist/{cli-e5bxnjps.js → cli-c952c7ac.js} +5 -5
  35. package/dist/{cli-p859hcc8.js → cli-dew6x255.js} +1 -1
  36. package/dist/{cli-tx00c0h6.js → cli-dqygeeh5.js} +17 -1
  37. package/dist/{cli-gpv60278.js → cli-e42vss7b.js} +2 -2
  38. package/dist/{cli-b63d08rs.js → cli-eftzyh9y.js} +1 -1
  39. package/dist/{cli-fgn7yg5r.js → cli-ethgy7ha.js} +1 -1
  40. package/dist/{cli-86zpfje5.js → cli-ff1tm9n9.js} +1 -1
  41. package/dist/{cli-cf63dnxj.js → cli-g9vd9ece.js} +37 -12
  42. package/dist/{cli-j3dwd4kz.js → cli-gbfxp48k.js} +770 -126
  43. package/dist/{cli-n2918sed.js → cli-gn5af0x5.js} +21 -21
  44. package/dist/{cli-d172svcw.js → cli-gwhjmh8g.js} +4 -4
  45. package/dist/{cli-stxdrqs2.js → cli-hh5we352.js} +24 -5
  46. package/dist/{cli-0ebey9hg.js → cli-j6rktc91.js} +1 -1
  47. package/dist/{cli-csh8fnbg.js → cli-jb30dzvk.js} +473 -58
  48. package/dist/{cli-vaxw3bg0.js → cli-jbnn4vxb.js} +3 -3
  49. package/dist/{cli-ha22h0eg.js → cli-k4tjsdx9.js} +45 -40
  50. package/dist/{cli-f55b6ky0.js → cli-m4ecdxvz.js} +40 -13
  51. package/dist/{cli-mntxtkhn.js → cli-mz9afxxf.js} +2 -1
  52. package/dist/{cli-ta8bd7th.js → cli-pzatvw94.js} +1 -1
  53. package/dist/{cli-3qv8naq8.js → cli-rr7zx19j.js} +1591 -353
  54. package/dist/{cli-b1tv22wr.js → cli-s682zgqz.js} +1 -1
  55. package/dist/{cli-bpbcpd8b.js → cli-sbwsxvtd.js} +1 -1
  56. package/dist/{cli-0xk505e8.js → cli-sxgtf5fw.js} +1 -1
  57. package/dist/{cli-6y43v79h.js → cli-tsma7p8v.js} +1 -1
  58. package/dist/{cli-edtm741g.js → cli-wghjkr0e.js} +5 -5
  59. package/dist/{cli-aaqnqpyw.js → cli-wx2nj19m.js} +30 -11
  60. package/dist/{cli-y3dzwyy3.js → cli-x0f2v69c.js} +180 -60
  61. package/dist/{cli-kcnwn85g.js → cli-xtrz6bzs.js} +7 -7
  62. package/dist/{cli-14v1sxxy.js → cli-xzfq4at3.js} +7 -7
  63. package/dist/cli.js +20 -20
  64. package/dist/{codex-adapter-brh2tcxr.js → codex-adapter-wch12q99.js} +3 -3
  65. package/dist/{codex-hook-e2he7aca.js → codex-hook-brpnycn5.js} +1 -1
  66. package/dist/{codex-session-runner-0dnj21qs.js → codex-session-runner-41pfcr95.js} +2 -2
  67. package/dist/{commands-1cnvseqx.js → commands-9849dkj6.js} +2 -2
  68. package/dist/{controller-kc7s9xhn.js → controller-ja0sd95p.js} +29 -3
  69. package/dist/{controllers-connect-jzx7t4bn.js → controllers-connect-xp05dj6x.js} +20 -20
  70. package/dist/{cursor-adapter-qpwp5sfg.js → cursor-adapter-623qxqew.js} +1 -2
  71. package/dist/{db-4vq88jkc.js → db-47vj40dy.js} +3 -3
  72. package/dist/{db-v5ytg5pw.js → db-w759qmx0.js} +2 -2
  73. package/dist/{dispatcher-trtcrqae.js → dispatcher-7b8e4z2j.js} +17 -17
  74. package/dist/{docker-nfy2gc8t.js → docker-s84hjs91.js} +2 -2
  75. package/dist/{dsh-adapter-f20xwn1z.js → dsh-adapter-2ezw3xea.js} +2 -3
  76. package/dist/{external-channel-n9bymc2n.js → external-channel-e2s028da.js} +2 -2
  77. package/dist/{grok-adapter-4kpmmnr6.js → grok-adapter-ys7gaqee.js} +6 -6
  78. package/dist/{handlers-ptsraqa8.js → handlers-wy1dr6ja.js} +20 -20
  79. package/dist/{heartbeat-60qtxtjf.js → heartbeat-w3mntghj.js} +20 -20
  80. package/dist/{hook-rbktcstg.js → hook-3jjywnpb.js} +9 -3
  81. package/dist/{hook-ngtvh729.js → hook-g7kvv6x9.js} +3 -2
  82. package/dist/{http-z8xy72gp.js → http-tkp7d4kb.js} +2984 -2483
  83. package/dist/{index-r70q0xjx.js → index-1ef1myq2.js} +17 -17
  84. package/dist/{index-a9nsavhx.js → index-3n8m9ea2.js} +1 -1
  85. package/dist/{index-cbxkmfc5.js → index-9pn1pfkf.js} +1 -1
  86. package/dist/{index-s193j5fh.js → index-nmm79qgr.js} +20 -20
  87. package/dist/{index-6srwvd5b.js → index-p4xfz37z.js} +1 -1
  88. package/dist/{index-06qkmcx6.js → index-q7m26kvs.js} +33 -23
  89. package/dist/{index-1cscynkb.js → index-xdaahthb.js} +17 -17
  90. package/dist/{keepalive-a9srcphj.js → keepalive-v4ta8wgg.js} +3 -3
  91. package/dist/{lead-3devneqy.js → lead-sawnyqxk.js} +35 -35
  92. package/dist/{maintenance-0er16h2y.js → maintenance-84n1ex21.js} +1 -1
  93. package/dist/{oauth-refresh-sweep-b65jcyw2.js → oauth-refresh-sweep-vjnnandw.js} +2 -2
  94. package/dist/{onboard-wqywz5py.js → onboard-sfmpe4np.js} +2 -2
  95. package/dist/{opencode-adapter-e56086m1.js → opencode-adapter-jmpst4q4.js} +4 -4
  96. package/dist/{otel-impl-vk32s884.js → otel-impl-v6cc0zve.js} +1 -1
  97. package/dist/{pi-mono-adapter-tzps8f38.js → pi-mono-adapter-fk8fkcf6.js} +33 -6
  98. package/dist/{pricing-refresh-t27anv4z.js → pricing-refresh-fsk1e1zc.js} +3 -3
  99. package/dist/{rbac-roles-452665y0.js → rbac-roles-prmkdnkw.js} +2 -2
  100. package/dist/{rbac-roles-bmcgsxjn.js → rbac-roles-zxmxqa39.js} +1 -1
  101. package/dist/{render-v2-0ttsk2j4.js → render-v2-pjwxvnkw.js} +3 -3
  102. package/dist/{resume-a26kvr41.js → resume-frdtgfa0.js} +9 -5
  103. package/dist/{session-access-dgh9nkwk.js → session-access-fpez4y5q.js} +4 -4
  104. package/dist/{setup-9vc9qahm.js → setup-mq6m3m8q.js} +1 -1
  105. package/dist/{work-objects-vn8nkxwn.js → work-objects-kfahhjsq.js} +20 -20
  106. package/dist/{worker-1x1h8vnm.js → worker-v5e5rxj3.js} +35 -35
  107. package/openapi.json +3078 -2424
  108. package/package.json +7 -7
  109. package/plugin/commands/create-pr.md +2 -2
  110. package/plugin/pi-skills/create-pr/SKILL.md +2 -2
  111. package/src/be/approval-config-question.ts +389 -0
  112. package/src/be/automation-preflight.ts +37 -3
  113. package/src/be/db/approvals.ts +48 -0
  114. package/src/be/db/session-tree.ts +73 -15
  115. package/src/be/db/skill-search.ts +87 -0
  116. package/src/be/db/tasks/read.ts +29 -2
  117. package/src/be/db/tasks/write.ts +15 -5
  118. package/src/be/db/workflow-idempotency.ts +183 -0
  119. package/src/be/db/workflow-runs.ts +366 -8
  120. package/src/be/db.ts +92 -22
  121. package/src/be/migrations/239_skills_fts.sql +40 -0
  122. package/src/be/migrations/240_workflow_run_parent_step_attempt.sql +37 -0
  123. package/src/be/migrations/241_skills_meta_fts.sql +39 -0
  124. package/src/be/migrations/242_workflow_idempotency_keys.sql +40 -0
  125. package/src/be/sealed-json.ts +1 -0
  126. package/src/be/seed-scripts/catalog/compound-insights.inline.ts +2 -2
  127. package/src/be/seed-scripts/catalog/compound-insights.ts +2 -2
  128. package/src/be/seed-skills/bundled-files.generated.json +1 -1
  129. package/src/be/sql-connections.ts +7 -1
  130. package/src/be/swarm-config-guard.ts +25 -0
  131. package/src/be/users.ts +9 -0
  132. package/src/commands/controller.ts +33 -0
  133. package/src/commands/runner.ts +6 -2
  134. package/src/extensions/apply-task-create.ts +57 -11
  135. package/src/extensions/catalog.generated.json +4 -4
  136. package/src/extensions/contract-types.generated.ts +1 -1
  137. package/src/extensions/contract.ts +25 -0
  138. package/src/extensions/dispatcher.ts +4 -0
  139. package/src/extensions/post-bridge.ts +8 -0
  140. package/src/hooks/context-bound-note.ts +196 -0
  141. package/src/hooks/hook.ts +12 -0
  142. package/src/http/approval-requests.ts +209 -38
  143. package/src/http/config.ts +70 -53
  144. package/src/http/idempotency-key.ts +117 -0
  145. package/src/http/sessions.ts +27 -2
  146. package/src/http/workflows.ts +129 -24
  147. package/src/integrations/chat-sdk/inbound.ts +5 -4
  148. package/src/prompts/context-bound-note.ts +48 -0
  149. package/src/prompts/session-templates.ts +25 -1
  150. package/src/providers/amp-adapter.ts +30 -8
  151. package/src/providers/claude-adapter.ts +29 -4
  152. package/src/providers/claude-managed-adapter.ts +26 -0
  153. package/src/providers/claude-sdk-session.ts +10 -0
  154. package/src/providers/claude-session-events.ts +13 -0
  155. package/src/providers/codex-adapter.ts +12 -0
  156. package/src/providers/dsh-adapter.ts +1 -1
  157. package/src/providers/opencode-adapter.ts +18 -0
  158. package/src/providers/pi-mono-adapter.ts +35 -0
  159. package/src/runtime-providers/docker.ts +6 -2
  160. package/src/scripts-runtime/types/swarm-extension.d.ts +35 -0
  161. package/src/tests/amp-adapter.test.ts +97 -0
  162. package/src/tests/approval-config-question.test.ts +702 -0
  163. package/src/tests/approval-requests.test.ts +10 -7
  164. package/src/tests/approval-sweeps.test.ts +12 -6
  165. package/src/tests/base-prompt.test.ts +1 -1
  166. package/src/tests/chat-sdk-inbound.test.ts +20 -0
  167. package/src/tests/claude-hook-output.test.ts +79 -1
  168. package/src/tests/claude-managed-adapter.test.ts +275 -0
  169. package/src/tests/codex-adapter.test.ts +163 -0
  170. package/src/tests/context-bound-note.test.ts +269 -0
  171. package/src/tests/dsh-adapter.test.ts +1 -1
  172. package/src/tests/entrypoint-gitlab-credential-helper.test.ts +273 -0
  173. package/src/tests/extensions-example-comms-adherence-gate.test.ts +321 -12
  174. package/src/tests/extensions-pre-task.test.ts +273 -0
  175. package/src/tests/fetch-resolved-env.test.ts +17 -0
  176. package/src/tests/fixtures/extensions/record-blocks/hooks.ts +10 -0
  177. package/src/tests/fixtures/extensions/record-blocks/manifest.json +7 -0
  178. package/src/tests/fixtures/fake-amp.ts +22 -8
  179. package/src/tests/fixtures/held-template-render.ts +42 -0
  180. package/src/tests/fixtures/workflow-idempotency-trigger.child.ts +73 -0
  181. package/src/tests/list-endpoint-slimming.test.ts +33 -0
  182. package/src/tests/migration-240-workflow-run-parent-step-attempt.test.ts +55 -0
  183. package/src/tests/openapi-route-headers.test.ts +2 -0
  184. package/src/tests/opencode-adapter.test.ts +134 -0
  185. package/src/tests/pi-mono-adapter.test.ts +105 -0
  186. package/src/tests/prompt-template-session.test.ts +4 -3
  187. package/src/tests/runner-push-delivery.test.ts +25 -0
  188. package/src/tests/runtime-controller-liveness.test.ts +133 -0
  189. package/src/tests/runtime-providers-docker.test.ts +2 -0
  190. package/src/tests/script-connections.test.ts +9 -0
  191. package/src/tests/script-run-config-scope.test.ts +91 -3
  192. package/src/tests/session-logs.test.ts +70 -0
  193. package/src/tests/sessions-tree.test.ts +205 -1
  194. package/src/tests/skill-search-long-content.test.ts +193 -0
  195. package/src/tests/skill-search.test.ts +173 -22
  196. package/src/tests/workflow-cascade-cancel.test.ts +1169 -0
  197. package/src/tests/workflow-foreach-supersede-retry.test.ts +488 -0
  198. package/src/tests/workflow-idempotency.test.ts +915 -0
  199. package/src/tests/workflow-sub-workflow.test.ts +538 -1
  200. package/src/tools/request-human-input.ts +27 -5
  201. package/src/tools/workflows/cancel-workflow-run.ts +1 -1
  202. package/src/types.ts +7 -0
  203. package/src/utils/context-window.ts +8 -6
  204. package/src/workflows/approval-cancel.ts +15 -13
  205. package/src/workflows/approval-notifications.ts +15 -4
  206. package/src/workflows/checkpoint.ts +72 -35
  207. package/src/workflows/engine.ts +262 -93
  208. package/src/workflows/executors/base.ts +6 -0
  209. package/src/workflows/executors/foreach.ts +2 -2
  210. package/src/workflows/executors/human-in-the-loop.ts +25 -1
  211. package/src/workflows/executors/sub-workflow.ts +45 -16
  212. package/src/workflows/foreach-driver.ts +10 -3
  213. package/src/workflows/foreach-join.ts +29 -6
  214. package/src/workflows/recovery.ts +68 -1
  215. package/src/workflows/resume.ts +228 -77
  216. package/src/workflows/retry-poller.ts +74 -7
  217. package/src/workflows/task-step-routing.ts +29 -7
  218. package/templates/ai-toolbox.manifest.json +8 -8
  219. package/templates/community/open-prompt-showreel/PLAYBOOK.md +5 -6
  220. package/templates/community/open-prompt-showreel/lead-prompt.md +1 -1
  221. package/templates/community/open-prompt-showreel/skills/open-prompt-showreel/SKILL.md +4 -4
  222. package/templates/extensions/comms-adherence-gate/README.md +38 -13
  223. package/templates/extensions/comms-adherence-gate/hooks.ts +272 -69
  224. package/templates/extensions/comms-adherence-gate/manifest.json +2 -2
  225. package/templates/skills/agent-browser/SKILL.md +4 -4
  226. package/templates/skills/agent-browser/content.md +4 -4
  227. package/templates/skills/code-quality/config.json +1 -1
  228. package/templates/skills/code-quality/content.md +2 -2
  229. package/templates/skills/delegate-work/content.md +29 -25
  230. package/templates/skills/delegate-work/files/scripts/codex-exec.sh +5 -5
  231. package/templates/skills/qa/content.md +2 -2
  232. package/templates/skills/researching/content.md +2 -0
  233. package/templates/skills/turso-interaction/content.md +1 -1
  234. package/templates/skills/work-on-task/SKILL.md +1 -1
  235. package/templates/skills/work-on-task/content.md +1 -1
  236. package/templates/skills/workflow-iterate/content.md +8 -1
@@ -1,5 +1,20 @@
1
1
  import type { IncomingMessage, ServerResponse } from "node:http";
2
2
  import { z } from "zod";
3
+ import {
4
+ applyConfigAnswer,
5
+ type ConfigAnswer,
6
+ ConfigQuestionSettingsSchema,
7
+ type ConfigResult,
8
+ type ConfigState,
9
+ ConfigStateSchema,
10
+ isConfigQuestion,
11
+ keptConfigResult,
12
+ normalizeApprovalQuestions,
13
+ parseConfigAnswer,
14
+ readConfigState,
15
+ type StoredConfigQuestion,
16
+ skippedConfigResult,
17
+ } from "../be/approval-config-question";
3
18
  import { resolveTaskAuditUserId } from "../be/audit-user";
4
19
  import {
5
20
  type ApprovalRequest,
@@ -16,8 +31,9 @@ import {
16
31
  recordApprovalVotes,
17
32
  resolveApprovalRequest,
18
33
  } from "../be/db";
34
+ import { getUserGrant } from "../be/rbac-roles";
19
35
  import { findUserById } from "../be/users";
20
- import { can, type RbacPrincipal } from "../rbac";
36
+ import { can, isRbacEnabled, type RbacPrincipal } from "../rbac";
21
37
  import type { User } from "../types";
22
38
  import { getRequestAuth } from "../utils/request-auth-context";
23
39
  import { scrubSecrets } from "../utils/secret-scrubber";
@@ -27,6 +43,7 @@ import {
27
43
  dispatchApprovalNotifications,
28
44
  } from "../workflows/approval-notifications";
29
45
  import { workflowEventBus } from "../workflows/event-bus";
46
+ import { ensureConfigAdmin, runConfigWriteSideEffects } from "./config";
30
47
  import { bodyTask, route } from "./route-def";
31
48
  import { jsonError } from "./utils";
32
49
 
@@ -34,7 +51,7 @@ import { jsonError } from "./utils";
34
51
 
35
52
  const QuestionSchema = z.object({
36
53
  id: z.string(),
37
- type: z.enum(["approval", "text", "single-select", "multi-select", "boolean"]),
54
+ type: z.enum(["approval", "text", "single-select", "multi-select", "boolean", "config"]),
38
55
  label: z.string(),
39
56
  required: z.boolean().optional(),
40
57
  description: z.string().optional(),
@@ -52,8 +69,14 @@ const QuestionSchema = z.object({
52
69
  minSelections: z.number().int().min(0).optional(),
53
70
  maxSelections: z.number().int().min(1).optional(),
54
71
  defaultValue: z.boolean().optional(),
72
+ config: ConfigQuestionSettingsSchema.optional().describe(
73
+ "Config question settings. Answers are written to swarm_config and replaced by a result that never holds a secret value.",
74
+ ),
55
75
  });
56
76
 
77
+ /** Create input: `label` may be omitted on a config question (it defaults to the key). */
78
+ const QuestionInputSchema = QuestionSchema.extend({ label: z.string().optional() });
79
+
57
80
  export type ApprovalQuestion = z.infer<typeof QuestionSchema>;
58
81
 
59
82
  // ─── Response Schemas ────────────────────────────────────────────────────────
@@ -133,6 +156,12 @@ const ApprovalRequestSchema = z.object({
133
156
  createdBy: z.string().optional(),
134
157
  createdAt: z.string(),
135
158
  updatedAt: z.string(),
159
+ configState: z
160
+ .record(z.string(), ConfigStateSchema)
161
+ .optional()
162
+ .describe(
163
+ "Live state of each config question's key, by question id. Read at request time and never stored. `value` appears only for non-secret keys.",
164
+ ),
136
165
  });
137
166
 
138
167
  /** Slim list row (`?fields=slim`) — see `ApprovalRequestSummary`. */
@@ -143,6 +172,7 @@ const ApprovalRequestSummarySchema = ApprovalRequestSchema.omit({
143
172
  approvals: true,
144
173
  resolutionReason: true,
145
174
  notificationChannels: true,
175
+ configState: true,
146
176
  }).extend({ questionCount: z.number().int() });
147
177
 
148
178
  /**
@@ -152,11 +182,16 @@ const ApprovalRequestSummarySchema = ApprovalRequestSchema.omit({
152
182
  */
153
183
  async function toApprovalRequestResponse(
154
184
  request: ApprovalRequest,
185
+ options: { withConfigState?: boolean } = {},
155
186
  ): Promise<z.infer<typeof ApprovalRequestSchema>> {
156
187
  const approvers = request.approvers as ApproversShape;
157
188
  const approvals = request.approvals ?? null;
189
+ const configState = options.withConfigState
190
+ ? await readConfigStates(request.questions as ApprovalQuestion[])
191
+ : undefined;
158
192
  return {
159
193
  ...request,
194
+ ...(configState ? { configState } : {}),
160
195
  questions: request.questions as ApprovalQuestion[],
161
196
  approvers,
162
197
  responses: request.responses as Record<string, unknown> | null,
@@ -188,6 +223,28 @@ async function withApprovalProgress(
188
223
  );
189
224
  }
190
225
 
226
+ /** Live state per config question id, or undefined when there are none. */
227
+ async function readConfigStates(
228
+ questions: ApprovalQuestion[],
229
+ ): Promise<Record<string, ConfigState> | undefined> {
230
+ const configQuestions = questions.filter(isConfigQuestion);
231
+ if (configQuestions.length === 0) return undefined;
232
+ const entries = await Promise.all(
233
+ configQuestions.map(async (q) => [q.id, await readConfigState(q.config)] as const),
234
+ );
235
+ return Object.fromEntries(entries);
236
+ }
237
+
238
+ /** Thrown inside the respond transaction to roll back config writes. */
239
+ class ConfigRespondAbort extends Error {
240
+ constructor(
241
+ readonly kind: "unavailable" | "conflict",
242
+ message: string = kind,
243
+ ) {
244
+ super(message);
245
+ }
246
+ }
247
+
191
248
  export async function getWorkflowApprovalUnavailableReason(
192
249
  request: ApprovalRequest,
193
250
  ): Promise<string | null> {
@@ -241,6 +298,15 @@ function hasRequiredResponse(question: ApprovalQuestion, response: unknown): boo
241
298
  }
242
299
  case "boolean":
243
300
  return typeof response === "boolean";
301
+ case "config": {
302
+ // Shape only. The respond handler checks `keep` against the live row.
303
+ if (typeof response !== "object" || response === null) return false;
304
+ const answer = response as { action?: unknown; value?: unknown };
305
+ if (answer.action === "keep") return true;
306
+ return (
307
+ answer.action === "set" && typeof answer.value === "string" && answer.value.trim() !== ""
308
+ );
309
+ }
244
310
  }
245
311
  }
246
312
 
@@ -264,7 +330,12 @@ const createRoute = route({
264
330
  tags: ["ApprovalRequests"],
265
331
  body: z.object({
266
332
  title: z.string().min(1),
267
- questions: z.array(QuestionSchema).min(1),
333
+ questions: z
334
+ .array(QuestionInputSchema)
335
+ .min(1)
336
+ .describe(
337
+ 'A request with a `config` question needs `approvers.policy: "any"`. The answer is written to swarm_config; the request stores only `{ key, scope, scopeId, isSecret, status, updatedAt }` (plus `value` for non-secret keys).',
338
+ ),
268
339
  approvers: z.object({
269
340
  users: z.array(z.string()).optional(),
270
341
  roles: z.array(z.string()).optional(),
@@ -323,6 +394,8 @@ const getByIdRoute = route({
323
394
  404: { description: "Not found" },
324
395
  },
325
396
  auth: { apiKey: true },
397
+ description:
398
+ "Includes `configState` when the request has `config` questions: the live state of each target key, read now.",
326
399
  });
327
400
 
328
401
  const respondRoute = route({
@@ -331,7 +404,7 @@ const respondRoute = route({
331
404
  pattern: ["api", "approval-requests", null, "respond"],
332
405
  summary: "Submit a response to an approval request",
333
406
  description:
334
- "Only a person may answer: a user token, a page session signed for a user, or the shared key with no agent identity (recorded as `operator`). The responder is taken from the credential. When the request lists `approvers.users` or `approvers.roles`, a user must match one of them. A rejection resolves the request at once; approvals resolve it when the `any`, `all` or `{ min: N }` policy is met, and until then it stays pending with the answer recorded in `approvals`.",
407
+ 'Only a person may answer: a user token, a page session signed for a user, or the shared key with no agent identity (recorded as `operator`). The responder is taken from the credential. When the request lists `approvers.users` or `approvers.roles`, a user must match one of them. A rejection resolves the request at once; approvals resolve it when the `any`, `all` or `{ min: N }` policy is met, and until then it stays pending with the answer recorded in `approvals`. A `config` question takes `{ action: "set", value }`, `{ action: "keep" }` (key must exist) or `{ action: "skip" }` (optional questions only); `set` also needs the config write permission. The value is written only when the request is approved, and the stored answer is the result, never a secret value.',
335
408
  tags: ["ApprovalRequests"],
336
409
  params: z.object({ id: z.string().uuid() }),
337
410
  body: z.object({
@@ -487,11 +560,47 @@ export async function handleApprovalRequests(
487
560
  return true;
488
561
  }
489
562
 
563
+ // Config answers (D6) are checked here and replaced by their results (D7)
564
+ // before the vote exists, so no plaintext reaches any stored or sent copy.
565
+ const configQuestions = questions.filter(isConfigQuestion);
566
+ const configAnswers = new Map<
567
+ string,
568
+ { question: StoredConfigQuestion; answer: ConfigAnswer }
569
+ >();
570
+ for (const question of configQuestions) {
571
+ const state = await readConfigState(question.config);
572
+ const answer = parseConfigAnswer(question, parsed.body.responses[question.id], state);
573
+ if (!answer.ok) {
574
+ jsonError(res, answer.error, 400);
575
+ return true;
576
+ }
577
+ configAnswers.set(question.id, { question, answer: answer.answer });
578
+ }
579
+ if (configAnswers.size > 0 && approvers.policy !== "any") {
580
+ jsonError(res, 'Config questions need approvers.policy "any"', 409);
581
+ return true;
582
+ }
583
+ if ([...configAnswers.values()].some(({ answer }) => answer.action === "set")) {
584
+ if (!(await ensureConfigAdmin(req, res, "config.write.any"))) return true;
585
+ // Admission only checked `approval.respond`; `ensureConfigAdmin` passes
586
+ // every user, so check the config write grant here as PUT /api/config would.
587
+ const auth = getRequestAuth(req);
588
+ if (auth?.kind === "user" && isRbacEnabled()) {
589
+ const grant = await getUserGrant(auth.userId);
590
+ if (!grant.grantsAll && !grant.verbs.has("config.write.any")) {
591
+ jsonError(res, "Forbidden: setting a config value needs 'config.write.any'", 403);
592
+ return true;
593
+ }
594
+ }
595
+ }
596
+ const safeResponses: Record<string, unknown> = { ...parsed.body.responses };
597
+ for (const id of configAnswers.keys()) delete safeResponses[id];
598
+
490
599
  // Determine status from responses: if any approval question has approved: false → rejected
491
600
  let status: "approved" | "rejected" = "approved";
492
601
  for (const q of questions) {
493
602
  if (q.type === "approval") {
494
- const answer = parsed.body.responses[q.id] as { approved?: boolean } | undefined;
603
+ const answer = safeResponses[q.id] as { approved?: boolean } | undefined;
495
604
  if (answer && answer.approved === false) {
496
605
  status = "rejected";
497
606
  break;
@@ -500,38 +609,89 @@ export async function handleApprovalRequests(
500
609
  }
501
610
 
502
611
  const responderId = responderKey(responder.responder);
503
- const vote: ApprovalVote = {
504
- responder: responderId,
505
- approved: status === "approved",
506
- responses: parsed.body.responses,
507
- ...(parsed.body.respondedBy ? { claimedRespondedBy: parsed.body.respondedBy } : {}),
508
- respondedAt: new Date().toISOString(),
509
- };
612
+ const respondedAt = new Date().toISOString();
613
+ const configWrites: { result: ConfigResult; value: string }[] = [];
510
614
  // One write transaction (BEGIN IMMEDIATE) so concurrent answers to an
511
615
  // `all` / `{ min: N }` request append in turn and never drop each other.
512
- const outcome = await getDbClient().transaction(async () => {
513
- const current = await getApprovalRequestById(parsed.params.id);
514
- if (!current || current.status !== "pending") return { kind: "unavailable" as const };
515
- const prior = current.approvals ?? [];
516
- if (prior.some((v) => v.responder === responderId)) return { kind: "duplicate" as const };
517
- const votes = [...prior, vote];
518
- if (status === "rejected" || (await approvalQuorumMet(approvers, votes))) {
519
- const resolved = await resolveApprovalRequest(
520
- parsed.params.id,
521
- { status, responses: parsed.body.responses, approvals: votes, resolvedBy: responderId },
522
- { requireActionableWorkflow: true },
523
- );
524
- return resolved
525
- ? { kind: "resolved" as const, request: resolved }
616
+ // Config writes share it, so a lost race writes nothing.
617
+ const outcome = await getDbClient()
618
+ .transaction(async () => {
619
+ const current = await getApprovalRequestById(parsed.params.id);
620
+ if (!current || current.status !== "pending") return { kind: "unavailable" as const };
621
+ const prior = current.approvals ?? [];
622
+ if (prior.some((v) => v.responder === responderId)) return { kind: "duplicate" as const };
623
+
624
+ // Config values are written only when the request resolves approved (D8).
625
+ const responses = { ...safeResponses };
626
+ for (const [id, { question, answer }] of configAnswers) {
627
+ if (status === "rejected" || answer.action === "skip") {
628
+ responses[id] = skippedConfigResult(question);
629
+ } else if (answer.action === "keep") {
630
+ const kept = await keptConfigResult(question);
631
+ if (!kept) {
632
+ throw new ConfigRespondAbort(
633
+ "conflict",
634
+ `config question "${id}": the key was removed before the answer was saved`,
635
+ );
636
+ }
637
+ responses[id] = kept;
638
+ } else {
639
+ const written = await applyConfigAnswer(question, answer.value);
640
+ configWrites.push({ result: written, value: answer.value });
641
+ responses[id] = written;
642
+ }
643
+ }
644
+
645
+ const vote: ApprovalVote = {
646
+ responder: responderId,
647
+ approved: status === "approved",
648
+ responses,
649
+ ...(parsed.body.respondedBy ? { claimedRespondedBy: parsed.body.respondedBy } : {}),
650
+ respondedAt,
651
+ };
652
+ const votes = [...prior, vote];
653
+ if (status === "rejected" || (await approvalQuorumMet(approvers, votes))) {
654
+ const resolved = await resolveApprovalRequest(
655
+ parsed.params.id,
656
+ { status, responses, approvals: votes, resolvedBy: responderId },
657
+ { requireActionableWorkflow: true },
658
+ );
659
+ if (!resolved && configWrites.length > 0) throw new ConfigRespondAbort("unavailable");
660
+ return resolved
661
+ ? { kind: "resolved" as const, request: resolved }
662
+ : { kind: "unavailable" as const };
663
+ }
664
+ const recorded = (await recordApprovalVotes(parsed.params.id, votes))
665
+ ? await getApprovalRequestById(parsed.params.id)
666
+ : null;
667
+ return recorded
668
+ ? { kind: "recorded" as const, request: recorded }
526
669
  : { kind: "unavailable" as const };
670
+ })
671
+ .catch((error: unknown) => {
672
+ if (!(error instanceof ConfigRespondAbort)) throw error;
673
+ // Rolled back: nothing was written.
674
+ configWrites.length = 0;
675
+ return error.kind === "conflict"
676
+ ? { kind: "conflict" as const, message: error.message }
677
+ : { kind: "unavailable" as const };
678
+ });
679
+
680
+ if (outcome.kind === "conflict") {
681
+ jsonError(res, outcome.message, 409);
682
+ return true;
683
+ }
684
+ // The transaction has committed: run what PUT /api/config runs after a write.
685
+ for (const { result, value } of configWrites) {
686
+ try {
687
+ await runConfigWriteSideEffects(result.scope, result.key, value);
688
+ } catch (err) {
689
+ console.error(
690
+ "[approval-requests] Config side effects failed:",
691
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
692
+ );
527
693
  }
528
- const recorded = (await recordApprovalVotes(parsed.params.id, votes))
529
- ? await getApprovalRequestById(parsed.params.id)
530
- : null;
531
- return recorded
532
- ? { kind: "recorded" as const, request: recorded }
533
- : { kind: "unavailable" as const };
534
- });
694
+ }
535
695
 
536
696
  if (outcome.kind === "duplicate") {
537
697
  jsonError(res, "You already answered this approval request", 409);
@@ -576,7 +736,7 @@ export async function handleApprovalRequests(
576
736
  await createApprovalFollowUpTask(updated, "hitl.follow_up");
577
737
 
578
738
  respondRoute.respond(res, 200, {
579
- approvalRequest: await toApprovalRequestResponse(updated),
739
+ approvalRequest: await toApprovalRequestResponse(updated, { withConfigState: true }),
580
740
  });
581
741
  return true;
582
742
  }
@@ -617,7 +777,7 @@ export async function handleApprovalRequests(
617
777
  }
618
778
 
619
779
  getByIdRoute.respond(res, 200, {
620
- approvalRequest: await toApprovalRequestResponse(request),
780
+ approvalRequest: await toApprovalRequestResponse(request, { withConfigState: true }),
621
781
  });
622
782
  return true;
623
783
  }
@@ -638,6 +798,15 @@ export async function handleApprovalRequests(
638
798
  }
639
799
  }
640
800
 
801
+ const normalized = await normalizeApprovalQuestions(parsed.body.questions, {
802
+ approvers: parsed.body.approvers,
803
+ });
804
+ if (!normalized.ok) {
805
+ jsonError(res, normalized.error, 400);
806
+ return true;
807
+ }
808
+ const questions = normalized.questions;
809
+
641
810
  const id = crypto.randomUUID();
642
811
  // Prefer a trusted authenticated user (never client-controlled); else fall
643
812
  // back to the ownership-validated sourceTaskId the request body carries.
@@ -651,7 +820,7 @@ export async function handleApprovalRequests(
651
820
  const request = await createApprovalRequest({
652
821
  id,
653
822
  title: parsed.body.title,
654
- questions: parsed.body.questions,
823
+ questions,
655
824
  approvers: parsed.body.approvers,
656
825
  workflowRunId: parsed.body.workflowRunId,
657
826
  workflowRunStepId: parsed.body.workflowRunStepId,
@@ -668,7 +837,7 @@ export async function handleApprovalRequests(
668
837
  dispatchApprovalNotifications({
669
838
  requestId: request.id,
670
839
  title: request.title,
671
- questions: parsed.body.questions,
840
+ questions,
672
841
  timeoutSeconds: parsed.body.timeoutSeconds,
673
842
  notifications,
674
843
  }).catch((err) => {
@@ -705,7 +874,9 @@ export async function handleApprovalRequests(
705
874
 
706
875
  const requests = await listApprovalRequests(filters);
707
876
  listRoute.respond(res, 200, {
708
- approvalRequests: await Promise.all(requests.map(toApprovalRequestResponse)),
877
+ approvalRequests: await Promise.all(
878
+ requests.map((request) => toApprovalRequestResponse(request)),
879
+ ),
709
880
  });
710
881
  return true;
711
882
  }
@@ -21,13 +21,11 @@ import {
21
21
  internalConfigKeyError,
22
22
  isApiOnlyConfigKey,
23
23
  isInternalConfigKey,
24
- isReservedConfigKey,
25
- reservedKeyError,
26
- validateConfigValue,
27
- validateExportToEnv,
24
+ validateConfigEntry,
28
25
  } from "../be/swarm-config-guard";
26
+ import { isScriptRunToken } from "../be/users";
29
27
  import { can, isRbacEnabled, type PermissionVerb } from "../rbac";
30
- import { SwarmConfigSchema } from "../types";
28
+ import { type SwarmConfig, SwarmConfigSchema } from "../types";
31
29
  import { getRequestAuth, type HttpRequestAuth } from "../utils/request-auth-context";
32
30
  import { registerVolatileSecret } from "../utils/secret-scrubber";
33
31
  import { reloadGlobalConfigsAndIntegrations, scheduleIntegrationsReload } from "./core";
@@ -114,6 +112,49 @@ async function resolveSecretsRead(
114
112
  };
115
113
  }
116
114
 
115
+ /**
116
+ * Ids of the rows a script run's token reads unmasked on its own
117
+ * `/api/config/resolved` (its agent over global, no repo secrets). The run
118
+ * already holds these values in `ctx.swarm.config`. `undefined` for any other
119
+ * caller.
120
+ */
121
+ async function scriptRunHeldConfigIds(req: IncomingMessage): Promise<Set<string> | undefined> {
122
+ const auth = getRequestAuth(req);
123
+ if (auth?.kind !== "agent" || auth.tokenKind !== "agent") return undefined;
124
+ if (!(await isScriptRunToken(auth.tokenId))) return undefined;
125
+ const held = stripApiOnlyKeys(
126
+ await getResolvedConfig(auth.agentId, undefined, { includeRepoSecrets: false }),
127
+ );
128
+ return new Set(held.map((config) => config.id));
129
+ }
130
+
131
+ /**
132
+ * Mask a raw list or by-id read for this caller. A script run's token gets
133
+ * the rows it holds (see {@link scriptRunHeldConfigIds}) unmasked, so
134
+ * `config_list` and `ctx.swarm.config` agree; every other secret row stays
135
+ * masked. Unmasked secret values are registered with the scrubber.
136
+ */
137
+ async function maskConfigRead(
138
+ req: IncomingMessage,
139
+ configs: SwarmConfig[],
140
+ includeSecrets: boolean,
141
+ ): Promise<{ result: SwarmConfig[]; secretsNote: string }> {
142
+ const { effectiveIncludeSecrets, secretsNote } = await resolveSecretsRead(req, includeSecrets);
143
+ const held =
144
+ includeSecrets && !effectiveIncludeSecrets ? await scriptRunHeldConfigIds(req) : undefined;
145
+ const reveal = (config: SwarmConfig) => effectiveIncludeSecrets || !!held?.has(config.id);
146
+ const result = configs.map((config) =>
147
+ config.isSecret && !reveal(config) ? maskSecrets([config])[0]! : config,
148
+ );
149
+ for (const config of result) {
150
+ if (config.isSecret && config.value && reveal(config)) {
151
+ registerVolatileSecret(config.value, `config:${config.key}`);
152
+ }
153
+ }
154
+ const anyMasked = result.some((config) => config.isSecret && !reveal(config));
155
+ return { result, secretsNote: held && !anyMasked ? "" : secretsNote };
156
+ }
157
+
117
158
  /**
118
159
  * Gate a config write/delete over HTTP (DES-445 follow-up).
119
160
  *
@@ -145,6 +186,25 @@ export async function ensureConfigAdmin(
145
186
  );
146
187
  }
147
188
 
189
+ /**
190
+ * What a committed config write triggers beyond the row itself. Shared by
191
+ * `PUT /api/config` and HITL `config` answers.
192
+ */
193
+ export async function runConfigWriteSideEffects(
194
+ scope: "global" | "agent" | "repo",
195
+ key: string,
196
+ value: string,
197
+ ): Promise<void> {
198
+ if (scope !== "global") return;
199
+ if (key === "SWARM_ORG_WEBSITE") await fillOrgLogoFromWebsite(value);
200
+ await syncRankingAfterConfigChange(key, value);
201
+ // Auto-reload integrations when a global-scoped config changes so callers
202
+ // (CLI, automation, dashboard) don't have to remember to hit /reload.
203
+ // Debounced to coalesce sequential single-key upserts from the dashboard
204
+ // batch save (no bulk endpoint exists).
205
+ scheduleIntegrationsReload();
206
+ }
207
+
148
208
  /**
149
209
  * The general form of `ensureConfigAdmin`: operators and users pass, guests
150
210
  * get a plain 403, and an agent principal (session identity first, then the
@@ -439,13 +499,9 @@ export async function handleConfig(
439
499
  jsonError(res, "Config not found", 404);
440
500
  return true;
441
501
  }
442
- const { effectiveIncludeSecrets, secretsNote } = await resolveSecretsRead(req, includeSecrets);
443
- const singleResult = effectiveIncludeSecrets ? config : maskSecrets([config])[0]!;
444
- if (effectiveIncludeSecrets && singleResult.isSecret && singleResult.value) {
445
- registerVolatileSecret(singleResult.value, `config:${singleResult.key}`);
446
- }
502
+ const { result, secretsNote } = await maskConfigRead(req, [config], includeSecrets);
447
503
  getConfigById.respond(res, 200, {
448
- ...singleResult,
504
+ ...result[0]!,
449
505
  ...(secretsNote ? { message: `Found 1 config(s).${secretsNote}` } : {}),
450
506
  });
451
507
  return true;
@@ -455,7 +511,6 @@ export async function handleConfig(
455
511
  const parsed = await listConfig.parse(req, res, pathSegments, queryParams);
456
512
  if (!parsed) return true;
457
513
  const includeSecrets = parsed.query.includeSecrets === "true";
458
- const { effectiveIncludeSecrets, secretsNote } = await resolveSecretsRead(req, includeSecrets);
459
514
  const configs = stripApiOnlyKeys(
460
515
  await getSwarmConfigs({
461
516
  scope: parsed.query.scope || undefined,
@@ -463,14 +518,7 @@ export async function handleConfig(
463
518
  key: parsed.query.key,
464
519
  }),
465
520
  );
466
- const listResult = effectiveIncludeSecrets ? configs : maskSecrets(configs);
467
- if (effectiveIncludeSecrets) {
468
- for (const c of listResult) {
469
- if (c.isSecret && c.value) {
470
- registerVolatileSecret(c.value, `config:${c.key}`);
471
- }
472
- }
473
- }
521
+ const { result: listResult, secretsNote } = await maskConfigRead(req, configs, includeSecrets);
474
522
  listConfig.respond(res, 200, {
475
523
  configs: listResult,
476
524
  ...(secretsNote ? { message: `Found ${listResult.length} config(s).${secretsNote}` } : {}),
@@ -484,28 +532,7 @@ export async function handleConfig(
484
532
  if (!(await ensureConfigAdmin(req, res, "config.write.any"))) return true;
485
533
  const { scope, scopeId, key, value, isSecret, envPath, description, exportToEnv } = parsed.body;
486
534
 
487
- if (scope === "global" && scopeId) {
488
- jsonError(res, "Global scope must not have scopeId", 400);
489
- return true;
490
- }
491
-
492
- if ((scope === "agent" || scope === "repo") && !scopeId) {
493
- jsonError(res, "Agent/repo scope requires scopeId", 400);
494
- return true;
495
- }
496
-
497
- if (isReservedConfigKey(key)) {
498
- jsonError(res, reservedKeyError(key).message, 400);
499
- return true;
500
- }
501
-
502
- if (isInternalConfigKey(key)) {
503
- jsonError(res, internalConfigKeyError(key).message, 400);
504
- return true;
505
- }
506
-
507
- const validationError =
508
- validateConfigValue(key, value) ?? validateExportToEnv(key, exportToEnv);
535
+ const validationError = validateConfigEntry({ scope, scopeId, key, value, exportToEnv });
509
536
  if (validationError) {
510
537
  jsonError(res, validationError, 400);
511
538
  return true;
@@ -523,17 +550,7 @@ export async function handleConfig(
523
550
  description: description || null,
524
551
  exportToEnv,
525
552
  });
526
- // Auto-reload integrations when a global-scoped config changes so callers
527
- // (CLI, automation, dashboard) don't have to remember to hit /reload.
528
- // Debounced to coalesce sequential single-key upserts from the dashboard
529
- // batch save (no bulk endpoint exists).
530
- if (scope === "global" && key === "SWARM_ORG_WEBSITE") {
531
- await fillOrgLogoFromWebsite(String(value));
532
- }
533
- if (scope === "global") await syncRankingAfterConfigChange(key, String(value));
534
- if (scope === "global") {
535
- scheduleIntegrationsReload();
536
- }
553
+ await runConfigWriteSideEffects(scope, key, String(value));
537
554
  const result = includeSecrets || !config.isSecret ? config : maskSecrets([config])[0]!;
538
555
  upsertConfig.respond(res, 200, result);
539
556
  } catch (error) {