@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
@@ -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,13 +31,19 @@ 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";
39
+ import { scrubSecrets } from "../utils/secret-scrubber";
23
40
  import { cancelApprovalRequest } from "../workflows/approval-cancel";
24
- import { createApprovalFollowUpTask } from "../workflows/approval-notifications";
41
+ import {
42
+ createApprovalFollowUpTask,
43
+ dispatchApprovalNotifications,
44
+ } from "../workflows/approval-notifications";
25
45
  import { workflowEventBus } from "../workflows/event-bus";
46
+ import { ensureConfigAdmin, runConfigWriteSideEffects } from "./config";
26
47
  import { bodyTask, route } from "./route-def";
27
48
  import { jsonError } from "./utils";
28
49
 
@@ -30,7 +51,7 @@ import { jsonError } from "./utils";
30
51
 
31
52
  const QuestionSchema = z.object({
32
53
  id: z.string(),
33
- type: z.enum(["approval", "text", "single-select", "multi-select", "boolean"]),
54
+ type: z.enum(["approval", "text", "single-select", "multi-select", "boolean", "config"]),
34
55
  label: z.string(),
35
56
  required: z.boolean().optional(),
36
57
  description: z.string().optional(),
@@ -48,8 +69,14 @@ const QuestionSchema = z.object({
48
69
  minSelections: z.number().int().min(0).optional(),
49
70
  maxSelections: z.number().int().min(1).optional(),
50
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
+ ),
51
75
  });
52
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
+
53
80
  export type ApprovalQuestion = z.infer<typeof QuestionSchema>;
54
81
 
55
82
  // ─── Response Schemas ────────────────────────────────────────────────────────
@@ -129,6 +156,12 @@ const ApprovalRequestSchema = z.object({
129
156
  createdBy: z.string().optional(),
130
157
  createdAt: z.string(),
131
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
+ ),
132
165
  });
133
166
 
134
167
  /** Slim list row (`?fields=slim`) — see `ApprovalRequestSummary`. */
@@ -139,6 +172,7 @@ const ApprovalRequestSummarySchema = ApprovalRequestSchema.omit({
139
172
  approvals: true,
140
173
  resolutionReason: true,
141
174
  notificationChannels: true,
175
+ configState: true,
142
176
  }).extend({ questionCount: z.number().int() });
143
177
 
144
178
  /**
@@ -148,11 +182,16 @@ const ApprovalRequestSummarySchema = ApprovalRequestSchema.omit({
148
182
  */
149
183
  async function toApprovalRequestResponse(
150
184
  request: ApprovalRequest,
185
+ options: { withConfigState?: boolean } = {},
151
186
  ): Promise<z.infer<typeof ApprovalRequestSchema>> {
152
187
  const approvers = request.approvers as ApproversShape;
153
188
  const approvals = request.approvals ?? null;
189
+ const configState = options.withConfigState
190
+ ? await readConfigStates(request.questions as ApprovalQuestion[])
191
+ : undefined;
154
192
  return {
155
193
  ...request,
194
+ ...(configState ? { configState } : {}),
156
195
  questions: request.questions as ApprovalQuestion[],
157
196
  approvers,
158
197
  responses: request.responses as Record<string, unknown> | null,
@@ -184,6 +223,28 @@ async function withApprovalProgress(
184
223
  );
185
224
  }
186
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
+
187
248
  export async function getWorkflowApprovalUnavailableReason(
188
249
  request: ApprovalRequest,
189
250
  ): Promise<string | null> {
@@ -237,6 +298,15 @@ function hasRequiredResponse(question: ApprovalQuestion, response: unknown): boo
237
298
  }
238
299
  case "boolean":
239
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
+ }
240
310
  }
241
311
  }
242
312
 
@@ -260,7 +330,12 @@ const createRoute = route({
260
330
  tags: ["ApprovalRequests"],
261
331
  body: z.object({
262
332
  title: z.string().min(1),
263
- 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
+ ),
264
339
  approvers: z.object({
265
340
  users: z.array(z.string()).optional(),
266
341
  roles: z.array(z.string()).optional(),
@@ -288,7 +363,10 @@ const createRoute = route({
288
363
  target: z.string(),
289
364
  }),
290
365
  )
291
- .optional(),
366
+ .optional()
367
+ .describe(
368
+ "Where to announce the request. A Slack target (channel or user ID) gets a card with the title, the questions and a link to the dashboard page that answers it. Slack targets need a lead agent caller (403 otherwise). Email is not supported yet and is skipped.",
369
+ ),
292
370
  }),
293
371
  responses: {
294
372
  201: {
@@ -296,6 +374,7 @@ const createRoute = route({
296
374
  schema: z.object({ approvalRequest: ApprovalRequestSchema }),
297
375
  },
298
376
  400: { description: "Validation error" },
377
+ 403: { description: "Slack notifications require a lead agent" },
299
378
  },
300
379
  auth: { apiKey: true },
301
380
  });
@@ -315,6 +394,8 @@ const getByIdRoute = route({
315
394
  404: { description: "Not found" },
316
395
  },
317
396
  auth: { apiKey: true },
397
+ description:
398
+ "Includes `configState` when the request has `config` questions: the live state of each target key, read now.",
318
399
  });
319
400
 
320
401
  const respondRoute = route({
@@ -323,7 +404,7 @@ const respondRoute = route({
323
404
  pattern: ["api", "approval-requests", null, "respond"],
324
405
  summary: "Submit a response to an approval request",
325
406
  description:
326
- "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.',
327
408
  tags: ["ApprovalRequests"],
328
409
  params: z.object({ id: z.string().uuid() }),
329
410
  body: z.object({
@@ -479,11 +560,47 @@ export async function handleApprovalRequests(
479
560
  return true;
480
561
  }
481
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
+
482
599
  // Determine status from responses: if any approval question has approved: false → rejected
483
600
  let status: "approved" | "rejected" = "approved";
484
601
  for (const q of questions) {
485
602
  if (q.type === "approval") {
486
- const answer = parsed.body.responses[q.id] as { approved?: boolean } | undefined;
603
+ const answer = safeResponses[q.id] as { approved?: boolean } | undefined;
487
604
  if (answer && answer.approved === false) {
488
605
  status = "rejected";
489
606
  break;
@@ -492,38 +609,89 @@ export async function handleApprovalRequests(
492
609
  }
493
610
 
494
611
  const responderId = responderKey(responder.responder);
495
- const vote: ApprovalVote = {
496
- responder: responderId,
497
- approved: status === "approved",
498
- responses: parsed.body.responses,
499
- ...(parsed.body.respondedBy ? { claimedRespondedBy: parsed.body.respondedBy } : {}),
500
- respondedAt: new Date().toISOString(),
501
- };
612
+ const respondedAt = new Date().toISOString();
613
+ const configWrites: { result: ConfigResult; value: string }[] = [];
502
614
  // One write transaction (BEGIN IMMEDIATE) so concurrent answers to an
503
615
  // `all` / `{ min: N }` request append in turn and never drop each other.
504
- const outcome = await getDbClient().transaction(async () => {
505
- const current = await getApprovalRequestById(parsed.params.id);
506
- if (!current || current.status !== "pending") return { kind: "unavailable" as const };
507
- const prior = current.approvals ?? [];
508
- if (prior.some((v) => v.responder === responderId)) return { kind: "duplicate" as const };
509
- const votes = [...prior, vote];
510
- if (status === "rejected" || (await approvalQuorumMet(approvers, votes))) {
511
- const resolved = await resolveApprovalRequest(
512
- parsed.params.id,
513
- { status, responses: parsed.body.responses, approvals: votes, resolvedBy: responderId },
514
- { requireActionableWorkflow: true },
515
- );
516
- return resolved
517
- ? { 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 }
518
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
+ );
519
693
  }
520
- const recorded = (await recordApprovalVotes(parsed.params.id, votes))
521
- ? await getApprovalRequestById(parsed.params.id)
522
- : null;
523
- return recorded
524
- ? { kind: "recorded" as const, request: recorded }
525
- : { kind: "unavailable" as const };
526
- });
694
+ }
527
695
 
528
696
  if (outcome.kind === "duplicate") {
529
697
  jsonError(res, "You already answered this approval request", 409);
@@ -568,7 +736,7 @@ export async function handleApprovalRequests(
568
736
  await createApprovalFollowUpTask(updated, "hitl.follow_up");
569
737
 
570
738
  respondRoute.respond(res, 200, {
571
- approvalRequest: await toApprovalRequestResponse(updated),
739
+ approvalRequest: await toApprovalRequestResponse(updated, { withConfigState: true }),
572
740
  });
573
741
  return true;
574
742
  }
@@ -578,7 +746,7 @@ export async function handleApprovalRequests(
578
746
  const parsed = await cancelRoute.parse(req, res, pathSegments, queryParams);
579
747
  if (!parsed) return true;
580
748
 
581
- const { principal, resolvedBy } = await approvalCancelPrincipal(req);
749
+ const { principal, resolvedBy } = await approvalCallerPrincipal(req);
582
750
  const result = await cancelApprovalRequest({
583
751
  id: parsed.params.id,
584
752
  reason: parsed.body.reason,
@@ -609,7 +777,7 @@ export async function handleApprovalRequests(
609
777
  }
610
778
 
611
779
  getByIdRoute.respond(res, 200, {
612
- approvalRequest: await toApprovalRequestResponse(request),
780
+ approvalRequest: await toApprovalRequestResponse(request, { withConfigState: true }),
613
781
  });
614
782
  return true;
615
783
  }
@@ -619,6 +787,26 @@ export async function handleApprovalRequests(
619
787
  const parsed = await createRoute.parse(req, res, pathSegments, queryParams);
620
788
  if (!parsed) return true;
621
789
 
790
+ // Posting to Slack is lead-only everywhere else (slack-post); a caller that
791
+ // may not post must not reach an arbitrary channel through this route.
792
+ if (parsed.body.notifications?.some((notification) => notification.channel === "slack")) {
793
+ const { principal } = await approvalCallerPrincipal(req);
794
+ const decision = can({ principal, verb: "integration.slack.post", source: "http" });
795
+ if (!decision.allow) {
796
+ jsonError(res, SLACK_NOTIFICATION_REFUSED, 403);
797
+ return true;
798
+ }
799
+ }
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
+
622
810
  const id = crypto.randomUUID();
623
811
  // Prefer a trusted authenticated user (never client-controlled); else fall
624
812
  // back to the ownership-validated sourceTaskId the request body carries.
@@ -632,7 +820,7 @@ export async function handleApprovalRequests(
632
820
  const request = await createApprovalRequest({
633
821
  id,
634
822
  title: parsed.body.title,
635
- questions: parsed.body.questions,
823
+ questions,
636
824
  approvers: parsed.body.approvers,
637
825
  workflowRunId: parsed.body.workflowRunId,
638
826
  workflowRunStepId: parsed.body.workflowRunStepId,
@@ -642,6 +830,24 @@ export async function handleApprovalRequests(
642
830
  createdBy,
643
831
  });
644
832
 
833
+ // Fire-and-forget: Slack must not delay or fail the 201. Workflow HITL
834
+ // requests are created by the executor directly, so they never post here.
835
+ const notifications = parsed.body.notifications;
836
+ if (notifications?.length && request.status === "pending") {
837
+ dispatchApprovalNotifications({
838
+ requestId: request.id,
839
+ title: request.title,
840
+ questions,
841
+ timeoutSeconds: parsed.body.timeoutSeconds,
842
+ notifications,
843
+ }).catch((err) => {
844
+ console.error(
845
+ "[approval-requests] Unexpected error dispatching notifications:",
846
+ scrubSecrets(err instanceof Error ? err.message : String(err)),
847
+ );
848
+ });
849
+ }
850
+
645
851
  createRoute.respond(res, 201, {
646
852
  approvalRequest: await toApprovalRequestResponse(request),
647
853
  });
@@ -668,7 +874,9 @@ export async function handleApprovalRequests(
668
874
 
669
875
  const requests = await listApprovalRequests(filters);
670
876
  listRoute.respond(res, 200, {
671
- approvalRequests: await Promise.all(requests.map(toApprovalRequestResponse)),
877
+ approvalRequests: await Promise.all(
878
+ requests.map((request) => toApprovalRequestResponse(request)),
879
+ ),
672
880
  });
673
881
  return true;
674
882
  }
@@ -676,11 +884,14 @@ export async function handleApprovalRequests(
676
884
  return false;
677
885
  }
678
886
 
887
+ const SLACK_NOTIFICATION_REFUSED =
888
+ "Slack notifications require lead privileges (integration.slack.post). Create the request without them, or ask the lead to post the link.";
889
+
679
890
  /**
680
- * The caller of the cancel route. The shared API key with an `X-Agent-ID`
681
- * header identifies that agent, as on the create route.
891
+ * The caller of the create and cancel routes. The shared API key with an
892
+ * `X-Agent-ID` header identifies that agent.
682
893
  */
683
- async function approvalCancelPrincipal(
894
+ async function approvalCallerPrincipal(
684
895
  req: IncomingMessage,
685
896
  ): Promise<{ principal: RbacPrincipal; resolvedBy: string | null }> {
686
897
  const auth = getRequestAuth(req);
@@ -21,10 +21,7 @@ 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";
29
26
  import { can, isRbacEnabled, type PermissionVerb } from "../rbac";
30
27
  import { SwarmConfigSchema } from "../types";
@@ -145,6 +142,25 @@ export async function ensureConfigAdmin(
145
142
  );
146
143
  }
147
144
 
145
+ /**
146
+ * What a committed config write triggers beyond the row itself. Shared by
147
+ * `PUT /api/config` and HITL `config` answers.
148
+ */
149
+ export async function runConfigWriteSideEffects(
150
+ scope: "global" | "agent" | "repo",
151
+ key: string,
152
+ value: string,
153
+ ): Promise<void> {
154
+ if (scope !== "global") return;
155
+ if (key === "SWARM_ORG_WEBSITE") await fillOrgLogoFromWebsite(value);
156
+ await syncRankingAfterConfigChange(key, value);
157
+ // Auto-reload integrations when a global-scoped config changes so callers
158
+ // (CLI, automation, dashboard) don't have to remember to hit /reload.
159
+ // Debounced to coalesce sequential single-key upserts from the dashboard
160
+ // batch save (no bulk endpoint exists).
161
+ scheduleIntegrationsReload();
162
+ }
163
+
148
164
  /**
149
165
  * The general form of `ensureConfigAdmin`: operators and users pass, guests
150
166
  * get a plain 403, and an agent principal (session identity first, then the
@@ -484,28 +500,7 @@ export async function handleConfig(
484
500
  if (!(await ensureConfigAdmin(req, res, "config.write.any"))) return true;
485
501
  const { scope, scopeId, key, value, isSecret, envPath, description, exportToEnv } = parsed.body;
486
502
 
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);
503
+ const validationError = validateConfigEntry({ scope, scopeId, key, value, exportToEnv });
509
504
  if (validationError) {
510
505
  jsonError(res, validationError, 400);
511
506
  return true;
@@ -523,17 +518,7 @@ export async function handleConfig(
523
518
  description: description || null,
524
519
  exportToEnv,
525
520
  });
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
- }
521
+ await runConfigWriteSideEffects(scope, key, String(value));
537
522
  const result = includeSecrets || !config.isSecret ? config : maskSecrets([config])[0]!;
538
523
  upsertConfig.respond(res, 200, result);
539
524
  } catch (error) {
package/src/http/index.ts CHANGED
@@ -139,6 +139,7 @@ import { handleTasks } from "./tasks";
139
139
  import { handleTelemetryUi } from "./telemetry-ui";
140
140
  import { handleTrackers } from "./trackers";
141
141
  import { handleUsedBy } from "./used-by";
142
+ import { handleUserRoles } from "./user-roles";
142
143
  import { handleUsers } from "./users";
143
144
  import {
144
145
  getPathSegments,
@@ -404,6 +405,7 @@ const httpServer = createHttpServer(async (req, res) => {
404
405
  () => handleRuntimePools(req, res, pathSegments, queryParams, myAgentId),
405
406
  () => handleExtensions(req, res, pathSegments, queryParams, myAgentId),
406
407
  () => handleFavorites(req, res, pathSegments, queryParams, myAgentId),
408
+ () => handleUserRoles(req, res, pathSegments, queryParams),
407
409
  () => handleUsers(req, res, pathSegments, queryParams),
408
410
  () => handleSessions(req, res, pathSegments, queryParams, myAgentId),
409
411
  () => handleInboxState(req, res, pathSegments, queryParams),
@@ -19,6 +19,7 @@ import {
19
19
  import { explicitModelErrorForAgent } from "../be/model-validation";
20
20
  import { mergeScheduleTiming, validateRecurringTiming } from "../be/schedules/validate";
21
21
  import { getScript } from "../be/scripts/db";
22
+ import { can } from "../rbac";
22
23
  import { calculateNextRun, dispatchScheduleTarget } from "../scheduler/scheduler";
23
24
  import {
24
25
  AgentTaskSchema,
@@ -30,6 +31,7 @@ import {
30
31
  splitLegacyModelAlias,
31
32
  } from "../types";
32
33
  import { resolveHttpFavoriteOwner } from "./favorite-owner";
34
+ import { agentFirstPrincipal } from "./request-principal";
33
35
  import { route } from "./route-def";
34
36
  import { jsonError } from "./utils";
35
37
 
@@ -219,6 +221,7 @@ const updateSchedule = route({
219
221
  responses: {
220
222
  200: { description: "Schedule updated", schema: ScheduledTaskSchema.nullable() },
221
223
  400: { description: "Validation error" },
224
+ 403: { description: "Deferral owned by another agent" },
222
225
  404: { description: "Schedule not found" },
223
226
  409: { description: "Duplicate name" },
224
227
  },
@@ -237,6 +240,7 @@ const patchSchedule = route({
237
240
  responses: {
238
241
  200: { description: "Schedule patched", schema: ScheduledTaskSchema.nullable() },
239
242
  400: { description: "Validation error" },
243
+ 403: { description: "Deferral owned by another agent" },
240
244
  404: { description: "Schedule not found" },
241
245
  409: { description: "Duplicate name" },
242
246
  },
@@ -559,6 +563,28 @@ export async function handleSchedules(
559
563
  jsonError(res, "Schedule not found", 404);
560
564
  return true;
561
565
  }
566
+ // A deferral continues its owner's task, in that task's chat thread when
567
+ // it has one (src/scheduler/schedule-task.ts). An agent may change it only
568
+ // as its owner or a lead, as in the patch-schedule and update-schedule tools.
569
+ if (existing.taskType === "deferred") {
570
+ const principal = await agentFirstPrincipal(req, myAgentId);
571
+ if (
572
+ principal.kind === "agent" &&
573
+ !can({
574
+ principal,
575
+ verb: "schedule.deferral.update.any",
576
+ resource: { kind: "owned", ownerAgentId: existing.createdByAgentId },
577
+ source: "http",
578
+ }).allow
579
+ ) {
580
+ jsonError(
581
+ res,
582
+ `Schedule "${existing.name}" defers another agent's task. Only that agent or a lead can change it.`,
583
+ 403,
584
+ );
585
+ return true;
586
+ }
587
+ }
562
588
  // A move to another agent re-judges the stored model against the new harness.
563
589
  const agentChanged =
564
590
  parsed.body.targetAgentId !== undefined &&
@@ -32,6 +32,7 @@ import {
32
32
  } from "../be/scripts/db";
33
33
  import { searchScripts } from "../be/scripts/embeddings";
34
34
  import { extractArgsJsonSchema } from "../be/scripts/extract-schema";
35
+ import { runScriptWithRunCredential } from "../be/scripts/run-credential";
35
36
  import { refuseSourceWithSecrets } from "../be/scripts/source-secrets";
36
37
  import {
37
38
  scriptSdkTypesWithGeneratedApis,
@@ -40,7 +41,6 @@ import {
40
41
  } from "../be/scripts/typecheck";
41
42
  import { can } from "../rbac";
42
43
  import { extractScriptSignature, type ScriptSignature } from "../scripts-runtime/extract-signature";
43
- import { runScript } from "../scripts-runtime/loader";
44
44
  import { checkQuickJSCompat, quickJSCompatWarnings } from "../scripts-runtime/quickjs-compat";
45
45
  import {
46
46
  ScriptApiAuthModeSchema,
@@ -782,7 +782,7 @@ export async function handleScripts(
782
782
  ? await touchScratchScriptLastUsed(namedScript.id)
783
783
  : null;
784
784
  const credentials = await buildScriptCredentialBindingsWithFailures({ agentId: agent.id });
785
- const output = await runScript({
785
+ const output = await runScriptWithRunCredential({
786
786
  source: source as string,
787
787
  args: parsed.body.args,
788
788
  fsMode,