@desplega.ai/agent-swarm 1.129.0 → 1.131.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (226) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-kfv2dawe.js → actions-w53mbns9.js} +11 -11
  3. package/dist/{anthropic-messages-3xgme8my.js → anthropic-messages-rjmd2e7n.js} +5 -5
  4. package/dist/{app-q8vjk6e8.js → app-m9gwqsqv.js} +8 -8
  5. package/dist/{artifact-hg4t1nx7.js → artifact-jysj3wrg.js} +2 -2
  6. package/dist/{assistant-g037r345.js → assistant-bp65m6f3.js} +13 -13
  7. package/dist/{azure-openai-responses-xa4sh9f6.js → azure-openai-responses-c0yjmbfd.js} +8 -5
  8. package/dist/{boot-reembed-18pzsrxa.js → boot-reembed-62p0at2k.js} +6 -6
  9. package/dist/{boot-reembed-wpks2cj8.js → boot-reembed-xmfp6wdh.js} +7 -7
  10. package/dist/{boot-scrub-logs-n7szd57r.js → boot-scrub-logs-rr8q2dms.js} +5 -5
  11. package/dist/{claude-adapter-qz4r0ajt.js → claude-adapter-2y47mrkd.js} +9 -9
  12. package/dist/{cli-mhjnbgb6.js → cli-0rcyq6d9.js} +450 -186
  13. package/dist/{cli-ttkmehy0.js → cli-2q72qz2s.js} +274 -25
  14. package/dist/{cli-gztgc3hz.js → cli-2wadx7p2.js} +2 -2
  15. package/dist/cli-3fj60etf.js +153 -0
  16. package/dist/{cli-7ecgm5v0.js → cli-3kf51j15.js} +1 -1
  17. package/dist/{cli-vgexpfsr.js → cli-57khth04.js} +6 -3
  18. package/dist/{cli-2mbe7vk0.js → cli-5dsbcjjw.js} +1 -1
  19. package/dist/{cli-etr62zy6.js → cli-5kx36evn.js} +2 -2
  20. package/dist/{cli-v2tr6amf.js → cli-5n6e5yfn.js} +30 -8
  21. package/dist/{cli-hx02s9zq.js → cli-5v4rw6yd.js} +2 -2
  22. package/dist/{cli-v37yr6mf.js → cli-5ze9rv97.js} +4 -4
  23. package/dist/{cli-ch7t5v7n.js → cli-6map995v.js} +9 -4
  24. package/dist/{cli-h0457a67.js → cli-70xqj0z3.js} +3 -3
  25. package/dist/{cli-kk4f9r17.js → cli-72q8m8gk.js} +8 -6
  26. package/dist/{cli-7padk69a.js → cli-78xwvkhj.js} +2 -2
  27. package/dist/{cli-dxtxacy7.js → cli-9dr5qzc0.js} +45 -8
  28. package/dist/{cli-17zxdgsq.js → cli-a40rnf71.js} +6 -6
  29. package/dist/{cli-t53vnnxz.js → cli-a4jzdpa3.js} +2514 -1055
  30. package/dist/{cli-w10fkrf1.js → cli-akdvwc9v.js} +354 -115
  31. package/dist/{cli-1qkk5er0.js → cli-bmze986d.js} +1 -1
  32. package/dist/{cli-ga12dc1d.js → cli-bqw0mt1n.js} +3 -3
  33. package/dist/{cli-0sf92efp.js → cli-cdeg08wx.js} +1 -1
  34. package/dist/{cli-a8ynhkyj.js → cli-cvgjwnh1.js} +17 -15
  35. package/dist/{cli-9fxg3v5m.js → cli-d2tfkcga.js} +4 -4
  36. package/dist/{cli-nhcd7x43.js → cli-d3x3x1c9.js} +2 -2
  37. package/dist/{cli-6nh2qxvh.js → cli-darcwax3.js} +1 -1
  38. package/dist/{cli-yjbgmysx.js → cli-e7ckcg4v.js} +12 -12
  39. package/dist/{cli-4n2gcjpd.js → cli-efxfnq6q.js} +4 -3
  40. package/dist/{cli-z9xqqhfk.js → cli-er3a4bb0.js} +1 -1
  41. package/dist/{cli-xg2eezyv.js → cli-f1am9szv.js} +1 -1
  42. package/dist/{cli-xy4cwsm9.js → cli-fywms69x.js} +1 -1
  43. package/dist/{cli-33jj76y8.js → cli-g6b8x1h8.js} +1 -1
  44. package/dist/{cli-epsvdckx.js → cli-gm9cbhbq.js} +20 -10
  45. package/dist/{cli-rkndnn90.js → cli-k9qkf1hw.js} +6 -4
  46. package/dist/{cli-zejvt395.js → cli-kh3ba2zf.js} +1 -1
  47. package/dist/{cli-gb25p1h2.js → cli-nm5gtp1x.js} +2 -2
  48. package/dist/{cli-akxnpx17.js → cli-pm6xbh84.js} +1829 -1629
  49. package/dist/{cli-2k0ztbfp.js → cli-psh3c4va.js} +1 -1
  50. package/dist/{cli-39vgrsep.js → cli-qv61hp37.js} +2 -2
  51. package/dist/{cli-hv2fndhm.js → cli-r6kqqbj0.js} +6 -4
  52. package/dist/{cli-1y1cm570.js → cli-rzbpmxdr.js} +1 -1
  53. package/dist/{cli-mmrk00vp.js → cli-t1xze5qh.js} +1 -1
  54. package/dist/{cli-g252a9ky.js → cli-t55yw3a1.js} +2 -2
  55. package/dist/{cli-7bz8dzqa.js → cli-t76vw5db.js} +1184 -1522
  56. package/dist/{cli-4n4mdv21.js → cli-xpxde7rm.js} +7 -3
  57. package/dist/{cli-fpaa5k2q.js → cli-yw8vghx2.js} +54 -18
  58. package/dist/cli-zsspvvm2.js +490 -0
  59. package/dist/cli.js +27 -25
  60. package/dist/{codex-adapter-7v6t7ff6.js → codex-adapter-d24q75wk.js} +7 -7
  61. package/dist/{codex-hook-amndawyb.js → codex-hook-hq54t9ft.js} +2 -2
  62. package/dist/{codex-session-runner-e7j4khe2.js → codex-session-runner-g50vcsex.js} +7 -7
  63. package/dist/{commands-ex0k5tbf.js → commands-c3nrc5hg.js} +5 -5
  64. package/dist/{db-df7jatfc.js → db-nt7fg3tw.js} +5 -5
  65. package/dist/{google-generative-ai-y42hnw9p.js → google-generative-ai-hysrd8qx.js} +7 -5
  66. package/dist/{google-vertex-4sg66gb6.js → google-vertex-p5arrga0.js} +7 -5
  67. package/dist/{handlers-1j2cfqt2.js → handlers-fkr6swat.js} +13 -13
  68. package/dist/{hook-nx9a0mj3.js → hook-77wpvnjc.js} +7 -7
  69. package/dist/{hook-w6yaf6sy.js → hook-g019yehy.js} +8 -8
  70. package/dist/{http-4tz5h8wg.js → http-jewj0syk.js} +2993 -716
  71. package/dist/{index-0bpkzb09.js → index-070v4eyb.js} +3 -3
  72. package/dist/{index-0s67qv7h.js → index-6gm5he8h.js} +11 -11
  73. package/dist/{index-35gtv6gj.js → index-7n2xpbd4.js} +310 -40
  74. package/dist/{index-ker3gccb.js → index-9znhar3x.js} +4 -4
  75. package/dist/{index-5n2c8yay.js → index-hase6zbe.js} +2 -2
  76. package/dist/{index-kcf3tm9h.js → index-hfvggd0z.js} +12 -12
  77. package/dist/{index-7c0jqna0.js → index-hp1kdbjj.js} +13 -13
  78. package/dist/{keepalive-3j4a9168.js → keepalive-j0fjvqfd.js} +7 -7
  79. package/dist/{lead-6kpevymb.js → lead-1n8x30xe.js} +33 -33
  80. package/dist/{maintenance-m1k82ean.js → maintenance-gvev11sa.js} +7 -7
  81. package/dist/{mistral-conversations-bjkdfb6z.js → mistral-conversations-81fwav0j.js} +6 -6
  82. package/dist/{oauth-refresh-sweep-881k8e4q.js → oauth-refresh-sweep-s9ecsvh0.js} +6 -6
  83. package/dist/{onboard-c0ykffjn.js → onboard-pvm5b38w.js} +2 -2
  84. package/dist/{openai-codex-responses-xetseqvt.js → openai-codex-responses-5g27pd6f.js} +43 -23
  85. package/dist/{openai-completions-5r2g5axa.js → openai-completions-3vkmfz2a.js} +55 -11
  86. package/dist/{openai-responses-ce67hd23.js → openai-responses-dg4t7j7a.js} +8 -5
  87. package/dist/{openrouter-images-pzfp22jb.js → openrouter-images-g3gm8931.js} +1 -1
  88. package/dist/{otel-impl-sqagjerv.js → otel-impl-p6jvgd9c.js} +6 -4
  89. package/dist/{otel-3mp81ppd.js → otel-nqa12wf3.js} +2 -2
  90. package/dist/{pi-mono-adapter-k584k16g.js → pi-mono-adapter-t091s27g.js} +17117 -7517
  91. package/dist/{pricing-refresh-y82whbf9.js → pricing-refresh-k49rcyed.js} +7 -7
  92. package/dist/{rbac-roles-nvkqmzyd.js → rbac-roles-30pspvy7.js} +7 -7
  93. package/dist/{rbac-roles-4ht8gegc.js → rbac-roles-cr80g96t.js} +6 -6
  94. package/dist/{render-v2-wvqdgvvp.js → render-v2-xm457ng1.js} +8 -8
  95. package/dist/{seed-pricing-fy3rxhde.js → seed-pricing-yhpv35fr.js} +6 -6
  96. package/dist/{setup-ak5cnc6f.js → setup-nytnmwwr.js} +2 -2
  97. package/dist/{worker-d8769q8a.js → worker-msytr2cw.js} +33 -33
  98. package/openapi.json +34039 -11036
  99. package/package.json +8 -6
  100. package/src/apps/definition.ts +368 -26
  101. package/src/apps/row-store.ts +66 -10
  102. package/src/apps/schema-migrate.ts +95 -1
  103. package/src/apps/store.ts +13 -4
  104. package/src/apps/sync-run-as.ts +18 -0
  105. package/src/apps/sync.ts +934 -0
  106. package/src/apps/version.ts +16 -2
  107. package/src/assets/key.ts +8 -1
  108. package/src/be/asset-key-audit.ts +3 -1
  109. package/src/be/db-queries/mcp-oauth.ts +297 -24
  110. package/src/be/db.ts +42 -1
  111. package/src/be/migrations/129_asset_keys_apps_scripts.sql +161 -0
  112. package/src/be/scripts/db.ts +5 -2
  113. package/src/be/scripts/typecheck.ts +1 -0
  114. package/src/be/seed-scripts/catalog/app-sync-run.ts +32 -0
  115. package/src/be/seed-scripts/catalog/github-issues-pull.ts +102 -0
  116. package/src/be/seed-scripts/index.ts +18 -0
  117. package/src/be/seed-skills/index.ts +7 -0
  118. package/src/http/active-sessions.ts +26 -18
  119. package/src/http/agents.ts +94 -27
  120. package/src/http/api-keys.ts +91 -17
  121. package/src/http/approval-requests.ts +87 -10
  122. package/src/http/apps.ts +361 -53
  123. package/src/http/assets.ts +165 -31
  124. package/src/http/budgets.ts +7 -5
  125. package/src/http/codex-oauth-keep-warm.ts +21 -3
  126. package/src/http/config.ts +46 -16
  127. package/src/http/context.ts +33 -6
  128. package/src/http/ecosystem.ts +20 -6
  129. package/src/http/events.ts +24 -9
  130. package/src/http/favorites.ts +20 -6
  131. package/src/http/fs.ts +60 -17
  132. package/src/http/heartbeat.ts +18 -4
  133. package/src/http/inbox-state.ts +12 -6
  134. package/src/http/integrations.ts +25 -5
  135. package/src/http/kv.ts +36 -15
  136. package/src/http/mcp-bridge.ts +5 -1
  137. package/src/http/mcp-oauth.ts +332 -17
  138. package/src/http/mcp-servers.ts +56 -18
  139. package/src/http/memory.ts +282 -29
  140. package/src/http/metrics.ts +80 -17
  141. package/src/http/oauth-callback.ts +10 -4
  142. package/src/http/oauth-generic.ts +5 -1
  143. package/src/http/oauth-locks.ts +6 -3
  144. package/src/http/openapi.ts +57 -9
  145. package/src/http/page-proxy.ts +25 -5
  146. package/src/http/pages-public.ts +37 -20
  147. package/src/http/pages.ts +93 -28
  148. package/src/http/poll.ts +89 -5
  149. package/src/http/pricing.ts +9 -6
  150. package/src/http/prompt-templates.ts +85 -21
  151. package/src/http/route-def.ts +67 -4
  152. package/src/http/schedules.ts +63 -14
  153. package/src/http/script-connection-proxy.ts +30 -4
  154. package/src/http/script-connections.ts +393 -43
  155. package/src/http/script-runs.ts +73 -19
  156. package/src/http/scripts.ts +257 -39
  157. package/src/http/session-data.ts +87 -14
  158. package/src/http/sessions.ts +80 -5
  159. package/src/http/skills.ts +115 -35
  160. package/src/http/stats.ts +94 -13
  161. package/src/http/task-templates.ts +6 -4
  162. package/src/http/tasks.ts +230 -61
  163. package/src/http/trackers/jira.ts +90 -30
  164. package/src/http/trackers/linear.ts +46 -12
  165. package/src/http/users.ts +162 -25
  166. package/src/http/utils.ts +12 -2
  167. package/src/http/webhooks.ts +30 -16
  168. package/src/http/workflow-events.ts +18 -5
  169. package/src/http/workflows.ts +128 -39
  170. package/src/http/x.ts +37 -6
  171. package/src/oauth/ensure-mcp-token.ts +16 -1
  172. package/src/oauth/mcp-wrapper.ts +281 -26
  173. package/src/providers/pi-mono-adapter.ts +1 -1
  174. package/src/scripts-runtime/credential-broker/README.md +1 -1
  175. package/src/scripts-runtime/credential-broker/fetch-patch.ts +12 -2
  176. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  177. package/src/scripts-runtime/stdlib/fetch.ts +6 -0
  178. package/src/scripts-runtime/types/stdlib.d.ts +1 -0
  179. package/src/scripts-runtime/types/swarm-sdk.d.ts +1 -0
  180. package/src/server.ts +2 -0
  181. package/src/tests/apps-rbac.test.ts +110 -0
  182. package/src/tests/apps-spike2.test.ts +4 -1
  183. package/src/tests/apps-sync-engine.test.ts +2540 -0
  184. package/src/tests/apps-sync.test.ts +1627 -0
  185. package/src/tests/asset-key-api.test.ts +48 -2
  186. package/src/tests/asset-key-invariants.test.ts +92 -0
  187. package/src/tests/asset-key-migration.test.ts +174 -8
  188. package/src/tests/asset-key-normalization.test.ts +2 -0
  189. package/src/tests/credential-broker.test.ts +27 -2
  190. package/src/tests/mcp-oauth-dcr-reuse.test.ts +743 -0
  191. package/src/tests/mcp-oauth-ensure-token.test.ts +45 -0
  192. package/src/tests/mcp-oauth-manual-client.test.ts +135 -2
  193. package/src/tests/mcp-oauth-queries.test.ts +153 -0
  194. package/src/tests/mcp-oauth-wrapper.test.ts +432 -0
  195. package/src/tests/memory-http-recall-gating.test.ts +2 -0
  196. package/src/tests/openapi-response-contract.test.ts +138 -0
  197. package/src/tests/rbac-admission-e2e.test.ts +68 -0
  198. package/src/tests/script-connections.test.ts +3 -0
  199. package/src/tests/scripts-runtime-fetch.test.ts +121 -0
  200. package/src/tests/scripts-runtime-secret-egress.test.ts +4 -2
  201. package/src/tests/seed-scripts.test.ts +2 -2
  202. package/src/tests/swarm-tool-result-gate.test.ts +32 -0
  203. package/src/tests/task-working-dir.test.ts +8 -4
  204. package/src/tests/whoami-route.test.ts +127 -0
  205. package/src/tests/workflow-engine-v2.test.ts +80 -7
  206. package/src/tests/workflow-swarm-script.test.ts +104 -0
  207. package/src/tools/app-get.ts +10 -2
  208. package/src/tools/app-rollback.ts +1 -0
  209. package/src/tools/app-sync.ts +112 -0
  210. package/src/tools/script-common.ts +20 -0
  211. package/src/tools/tool-config.ts +2 -1
  212. package/src/tools/utils.ts +11 -0
  213. package/src/tools/workflows/create-workflow.ts +4 -1
  214. package/src/types.ts +1394 -1206
  215. package/src/utils/template.ts +8 -1
  216. package/src/utils/zod-openapi.ts +11 -0
  217. package/src/workflows/engine.ts +27 -4
  218. package/src/workflows/executors/swarm-script.ts +28 -0
  219. package/src/workflows/retry-poller.ts +19 -2
  220. package/templates/skills/apps/content.md +94 -5
  221. package/templates/skills/asset-namespaces/content.md +2 -2
  222. package/templates/skills/scheduled-task-resilience/SKILL.md +102 -0
  223. package/templates/skills/scheduled-task-resilience/config.json +14 -0
  224. package/templates/skills/scheduled-task-resilience/content.md +97 -0
  225. package/dist/cli-7wmybrt5.js +0 -115
  226. package/dist/cli-d91pvmq1.js +0 -14
@@ -1,5 +1,6 @@
1
1
  import * as oauth from "oauth4webapi";
2
2
  import { isEnvFlagEnabled } from "../utils/env-flag";
3
+ import { registerVolatileSecret, scrubSecrets } from "../utils/secret-scrubber";
3
4
 
4
5
  /**
5
6
  * MCP OAuth 2.1 wrapper.
@@ -54,6 +55,15 @@ function isPrivateIPv6(host: string): boolean {
54
55
  return false;
55
56
  }
56
57
 
58
+ // Deliberately does NOT match "invalid_client_metadata" (a distinct DCR-only
59
+ // error) — the `\b` boundary fails on the following underscore. Shared by
60
+ // every MCP OAuth call site (explicit /refresh route, automatic
61
+ // ensureMcpToken refresh, callback token exchange) that needs to decide
62
+ // whether a provider has disowned a stored DCR client.
63
+ export function isInvalidClientError(message: string): boolean {
64
+ return /invalid_client\b/i.test(message);
65
+ }
66
+
57
67
  export interface SsrfGuardOptions {
58
68
  /** Allow loopback and RFC1918 hosts (dev / self-hosting). Opt-in only. */
59
69
  allowPrivateHosts?: boolean;
@@ -119,9 +129,23 @@ function defaultSsrfOptions(): SsrfGuardOptions {
119
129
  };
120
130
  }
121
131
 
122
- async function safeFetch(url: string, init?: RequestInit): Promise<Response> {
132
+ // Every safeFetch call is bounded so a provider that accepts the connection
133
+ // and never responds can't hang indefinitely. This matters most for the
134
+ // calls made under `withAuthorizeFlowLock` (metadata discovery + DCR POST in
135
+ // mcp-oauth.ts) — an unbounded fetch there would block every subsequent
136
+ // /authorize for that connector+user behind the lock. 15s is generous for
137
+ // OAuth metadata/DCR/token-endpoint round trips while still bounding the
138
+ // lock hold time; token exchange, refresh, and revocation share the same
139
+ // default since none of them should legitimately take longer.
140
+ const DEFAULT_FETCH_TIMEOUT_MS = 15_000;
141
+
142
+ async function safeFetch(
143
+ url: string,
144
+ init?: RequestInit,
145
+ timeoutMs: number = DEFAULT_FETCH_TIMEOUT_MS,
146
+ ): Promise<Response> {
123
147
  assertUrlSafe(url, defaultSsrfOptions());
124
- return fetch(url, init);
148
+ return fetch(url, { ...init, signal: init?.signal ?? AbortSignal.timeout(timeoutMs) });
125
149
  }
126
150
 
127
151
  // ─── Protected Resource Metadata (RFC 9728) ──────────────────────────────────
@@ -225,6 +249,179 @@ export async function discoverAuthorizationServerMetadata(
225
249
  throw lastError ?? new Error(`Authorization server metadata not found at ${issuer}`);
226
250
  }
227
251
 
252
+ // ─── Token endpoint client authentication (RFC 6749 §2.3.1 / RFC 7591 §2) ────
253
+
254
+ /**
255
+ * The three `token_endpoint_auth_method` values agent-swarm knows how to
256
+ * apply at the token endpoint. Other RFC 7591 values (e.g. `private_key_jwt`)
257
+ * are not supported by any MCP provider we've integrated with; they fall
258
+ * back to the RFC 7591 §2 default below.
259
+ */
260
+ export type TokenEndpointAuthMethod = "client_secret_basic" | "client_secret_post" | "none";
261
+
262
+ const KNOWN_TOKEN_ENDPOINT_AUTH_METHODS: readonly TokenEndpointAuthMethod[] = [
263
+ "client_secret_basic",
264
+ "client_secret_post",
265
+ "none",
266
+ ];
267
+
268
+ /** RFC 7591 §2: "client_secret_basic" is the default when unspecified. */
269
+ export const DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD: TokenEndpointAuthMethod = "client_secret_basic";
270
+
271
+ /** Coerce a value we read back from a provider (or our own storage) to a known method, defaulting per RFC 7591 §2. */
272
+ export function normalizeTokenEndpointAuthMethod(
273
+ value: string | null | undefined,
274
+ ): TokenEndpointAuthMethod {
275
+ return (KNOWN_TOKEN_ENDPOINT_AUTH_METHODS as readonly string[]).includes(value ?? "")
276
+ ? (value as TokenEndpointAuthMethod)
277
+ : DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD;
278
+ }
279
+
280
+ export class UnsupportedTokenEndpointAuthMethodError extends Error {
281
+ constructor(value: string, source: string) {
282
+ super(
283
+ `Authorization server ${source} token_endpoint_auth_method "${value}", which agent-swarm cannot perform. ` +
284
+ `Supported methods: ${KNOWN_TOKEN_ENDPOINT_AUTH_METHODS.join(", ")}. ` +
285
+ `Register a client manually via POST /api/mcp-oauth/:id/manual-client with a supported method.`,
286
+ );
287
+ this.name = "UnsupportedTokenEndpointAuthMethodError";
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Resolve a method the authorization server stated EXPLICITLY. An omitted
293
+ * value falls back to the RFC 7591 §2 default, but a present-and-unsupported
294
+ * value (e.g. `private_key_jwt`) is an error rather than a silent downgrade:
295
+ * coercing it to Basic would send credentials in a scheme the AS did not
296
+ * select, contradicting "server wins" and failing later as invalid_client.
297
+ */
298
+ export function resolveAdvertisedTokenEndpointAuthMethod(
299
+ value: string | null | undefined,
300
+ source: string,
301
+ ): TokenEndpointAuthMethod {
302
+ // An empty string is treated as "not stated", not as an unsupported method.
303
+ // A provider that serializes the field empty has told us nothing, and the
304
+ // DCR caller falls back to the method it requested (which the AS advertised)
305
+ // rather than erroring. Failing here would break such a provider outright
306
+ // for no safety gain, since we still authenticate the way it advertised.
307
+ if (value == null || value === "") return DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD;
308
+ if (!(KNOWN_TOKEN_ENDPOINT_AUTH_METHODS as readonly string[]).includes(value)) {
309
+ throw new UnsupportedTokenEndpointAuthMethodError(value, source);
310
+ }
311
+ return value as TokenEndpointAuthMethod;
312
+ }
313
+
314
+ /**
315
+ * Read the method from a persisted client. Older rows predate this field and
316
+ * used client_secret_post, so preserve that known-working behavior.
317
+ */
318
+ export function authMethodForStoredClient(
319
+ value: string | null | undefined,
320
+ ): TokenEndpointAuthMethod {
321
+ if (value == null || value === "") return "client_secret_post";
322
+ return normalizeTokenEndpointAuthMethod(value);
323
+ }
324
+
325
+ /**
326
+ * Pick the method to REQUEST during DCR from the AS's advertised
327
+ * `token_endpoint_auth_methods_supported`. Prefers the RFC-preferred Basic
328
+ * form, then body-post, then public (`none`); falls back to the RFC 7591 §2
329
+ * default when the AS didn't advertise anything we recognize.
330
+ */
331
+ export function selectDcrTokenEndpointAuthMethod(
332
+ supportedMethods: string[] | undefined,
333
+ ): TokenEndpointAuthMethod {
334
+ // An empty advertised list is degenerate rather than a claim to support
335
+ // nothing, so it takes the RFC 8414 omitted-value default. Only a non-empty
336
+ // list containing nothing we can perform is a genuine incompatibility.
337
+ if (!supportedMethods || supportedMethods.length === 0) {
338
+ return DEFAULT_TOKEN_ENDPOINT_AUTH_METHOD;
339
+ }
340
+ for (const method of KNOWN_TOKEN_ENDPOINT_AUTH_METHODS) {
341
+ if (supportedMethods.includes(method)) return method;
342
+ }
343
+ // The AS advertised a list and none of it is something we can perform.
344
+ // Defaulting to Basic here would register a client we can never authenticate.
345
+ throw new UnsupportedTokenEndpointAuthMethodError(supportedMethods.join(", "), "advertises only");
346
+ }
347
+
348
+ /**
349
+ * Serialize one credential component per `application/x-www-form-urlencoded`,
350
+ * which RFC 6749 §2.3.1 requires before Base64-encoding the Basic header.
351
+ * `encodeURIComponent` is NOT that algorithm: it emits `%20` for a space where
352
+ * form encoding emits `+`, and it leaves `!'()*` unescaped.
353
+ */
354
+ function formUrlEncodeComponent(value: string): string {
355
+ return new URLSearchParams({ v: value }).toString().slice(2);
356
+ }
357
+
358
+ /**
359
+ * Redact every credential representation we actually transmitted from an
360
+ * upstream error body before it can be logged, persisted, or reflected into a
361
+ * redirect.
362
+ *
363
+ * `scrubSecrets` alone is not enough here: `registerVolatileSecret` ignores
364
+ * values shorter than its minimum length, and RFC 7591 sets no floor on a
365
+ * `client_secret`. A provider echoing a short secret, or the form-encoded
366
+ * form of a longer one, would otherwise pass straight through.
367
+ *
368
+ * Every value is redacted in BOTH its raw and its form-encoded shape. Anything
369
+ * we put in a `URLSearchParams` body travels percent-encoded (a token like
370
+ * `a+b/c=` is sent as `a%2Bb%2Fc%3D`), so a provider that echoes its received
371
+ * body back would otherwise leak the encoded form. Doing it here rather than
372
+ * at each call site means a new sensitive field cannot forget one shape.
373
+ */
374
+ function redactSentCredentials(text: string, sent: Array<string | null | undefined>): string {
375
+ let out = scrubSecrets(text);
376
+ for (const value of sent) {
377
+ if (!value) continue;
378
+ for (const shape of new Set([value, formUrlEncodeComponent(value)])) {
379
+ out = out.split(shape).join("[REDACTED]");
380
+ }
381
+ }
382
+ return out;
383
+ }
384
+
385
+ /**
386
+ * Apply the client's registered auth method to a token-endpoint request.
387
+ * Mutates `body` and `headers` in place so callers can build the rest of the
388
+ * grant-specific params around it.
389
+ *
390
+ * - client_secret_basic → Authorization: Basic header; no creds in body.
391
+ * - client_secret_post → client_id/client_secret in the body; no header.
392
+ * - none (public client) → client_id only in the body; no secret anywhere.
393
+ *
394
+ * Returns every credential representation it put on the wire so the caller can
395
+ * redact exactly what it sent, whatever the encoding or length.
396
+ */
397
+ function applyClientAuthentication(
398
+ method: TokenEndpointAuthMethod,
399
+ clientId: string,
400
+ clientSecret: string | null | undefined,
401
+ body: URLSearchParams,
402
+ headers: Record<string, string>,
403
+ ): string[] {
404
+ if (method === "client_secret_basic" && clientSecret) {
405
+ const encodedSecret = formUrlEncodeComponent(clientSecret);
406
+ const credentials = `${formUrlEncodeComponent(clientId)}:${encodedSecret}`;
407
+ const encoded = Buffer.from(credentials).toString("base64");
408
+ // The Base64 blob is itself a credential. Register it so a provider that
409
+ // echoes the Authorization header back in an error body gets scrubbed.
410
+ registerVolatileSecret(encoded, "mcp_oauth_basic_credential");
411
+ headers.Authorization = `Basic ${encoded}`;
412
+ return [clientSecret, encodedSecret, encoded, credentials];
413
+ }
414
+
415
+ // A public client must identify itself in the body, whatever method was
416
+ // recorded for a client with no secret.
417
+ body.set("client_id", clientId);
418
+ if (method === "client_secret_post" && clientSecret) {
419
+ body.set("client_secret", clientSecret);
420
+ return [clientSecret, formUrlEncodeComponent(clientSecret)];
421
+ }
422
+ return [];
423
+ }
424
+
228
425
  // ─── Dynamic Client Registration (RFC 7591) ──────────────────────────────────
229
426
 
230
427
  export interface DcrRequest {
@@ -329,6 +526,7 @@ export interface ExchangeCodeInput {
329
526
  tokenUrl: string;
330
527
  clientId: string;
331
528
  clientSecret?: string | null;
529
+ tokenEndpointAuthMethod?: string | null;
332
530
  redirectUri: string;
333
531
  code: string;
334
532
  codeVerifier: string;
@@ -344,27 +542,48 @@ export interface TokenResponse {
344
542
  }
345
543
 
346
544
  export async function exchangeCodeForTokens(input: ExchangeCodeInput): Promise<TokenResponse> {
545
+ // A provider can echo a submitted credential back verbatim in an error body.
546
+ // The thrown message is logged by the callback AND reflected into the
547
+ // dashboard redirect as error_description, so redact before it can escape.
548
+ if (input.clientSecret) registerVolatileSecret(input.clientSecret, "mcp_oauth_client_secret");
549
+
550
+ const method = normalizeTokenEndpointAuthMethod(input.tokenEndpointAuthMethod);
347
551
  const body = new URLSearchParams({
348
552
  grant_type: "authorization_code",
349
553
  code: input.code,
350
554
  redirect_uri: input.redirectUri,
351
- client_id: input.clientId,
352
555
  code_verifier: input.codeVerifier,
353
556
  resource: input.resource,
354
557
  });
355
- if (input.clientSecret) body.set("client_secret", input.clientSecret);
558
+ const headers: Record<string, string> = {
559
+ "Content-Type": "application/x-www-form-urlencoded",
560
+ Accept: "application/json",
561
+ };
562
+ const sentCredentials = applyClientAuthentication(
563
+ method,
564
+ input.clientId,
565
+ input.clientSecret,
566
+ body,
567
+ headers,
568
+ );
356
569
 
357
570
  const res = await safeFetch(input.tokenUrl, {
358
571
  method: "POST",
359
- headers: {
360
- "Content-Type": "application/x-www-form-urlencoded",
361
- Accept: "application/json",
362
- },
572
+ headers,
363
573
  body: body.toString(),
364
574
  });
365
575
  if (!res.ok) {
366
576
  const text = await res.text().catch(() => "");
367
- throw new Error(`Token exchange failed (${res.status}): ${text}`);
577
+ // The code and verifier are single-use but the exchange just failed, so
578
+ // treat them as live. Both callback sinks (console.error and the dashboard
579
+ // redirect's error_description) receive this message verbatim.
580
+ throw new Error(
581
+ `Token exchange failed (${res.status}): ${redactSentCredentials(text, [
582
+ ...sentCredentials,
583
+ input.code,
584
+ input.codeVerifier,
585
+ ])}`,
586
+ );
368
587
  }
369
588
  return (await res.json()) as TokenResponse;
370
589
  }
@@ -373,32 +592,53 @@ export interface RefreshTokenInput {
373
592
  tokenUrl: string;
374
593
  clientId: string;
375
594
  clientSecret?: string | null;
595
+ tokenEndpointAuthMethod?: string | null;
376
596
  refreshToken: string;
377
597
  resource: string;
378
598
  scopes?: string[];
379
599
  }
380
600
 
381
601
  export async function refreshMcpToken(input: RefreshTokenInput): Promise<TokenResponse> {
602
+ // A misbehaving/compromised provider can echo a submitted credential back
603
+ // verbatim in an error body (e.g. "invalid client_secret: <value>"). Both
604
+ // credentials we send are registered as volatile secrets so the scrub pass
605
+ // below redacts any echo BEFORE the message is persisted to
606
+ // lastErrorMessage or returned by the status endpoint.
607
+ if (input.clientSecret) registerVolatileSecret(input.clientSecret, "mcp_oauth_client_secret");
608
+ registerVolatileSecret(input.refreshToken, "mcp_oauth_refresh_token");
609
+
610
+ const method = normalizeTokenEndpointAuthMethod(input.tokenEndpointAuthMethod);
382
611
  const body = new URLSearchParams({
383
612
  grant_type: "refresh_token",
384
613
  refresh_token: input.refreshToken,
385
- client_id: input.clientId,
386
614
  resource: input.resource,
387
615
  });
388
- if (input.clientSecret) body.set("client_secret", input.clientSecret);
389
616
  if (input.scopes && input.scopes.length > 0) body.set("scope", input.scopes.join(" "));
617
+ const headers: Record<string, string> = {
618
+ "Content-Type": "application/x-www-form-urlencoded",
619
+ Accept: "application/json",
620
+ };
621
+ const sentCredentials = applyClientAuthentication(
622
+ method,
623
+ input.clientId,
624
+ input.clientSecret,
625
+ body,
626
+ headers,
627
+ );
390
628
 
391
629
  const res = await safeFetch(input.tokenUrl, {
392
630
  method: "POST",
393
- headers: {
394
- "Content-Type": "application/x-www-form-urlencoded",
395
- Accept: "application/json",
396
- },
631
+ headers,
397
632
  body: body.toString(),
398
633
  });
399
634
  if (!res.ok) {
400
635
  const text = await res.text().catch(() => "");
401
- throw new Error(`Token refresh failed (${res.status}): ${text}`);
636
+ throw new Error(
637
+ `Token refresh failed (${res.status}): ${redactSentCredentials(text, [
638
+ ...sentCredentials,
639
+ input.refreshToken,
640
+ ])}`,
641
+ );
402
642
  }
403
643
  return (await res.json()) as TokenResponse;
404
644
  }
@@ -409,28 +649,43 @@ export interface RevokeInput {
409
649
  tokenTypeHint?: "access_token" | "refresh_token";
410
650
  clientId: string;
411
651
  clientSecret?: string | null;
652
+ tokenEndpointAuthMethod?: string | null;
412
653
  }
413
654
 
414
655
  export async function revokeMcpToken(input: RevokeInput): Promise<void> {
415
- const body = new URLSearchParams({
416
- token: input.token,
417
- client_id: input.clientId,
418
- });
656
+ // Same exposure as the exchange path: the caller logs this error verbatim.
657
+ if (input.clientSecret) registerVolatileSecret(input.clientSecret, "mcp_oauth_client_secret");
658
+ registerVolatileSecret(input.token, "mcp_oauth_revoked_token");
659
+
660
+ const method = normalizeTokenEndpointAuthMethod(input.tokenEndpointAuthMethod);
661
+ const body = new URLSearchParams({ token: input.token });
419
662
  if (input.tokenTypeHint) body.set("token_type_hint", input.tokenTypeHint);
420
- if (input.clientSecret) body.set("client_secret", input.clientSecret);
663
+ const headers: Record<string, string> = {
664
+ "Content-Type": "application/x-www-form-urlencoded",
665
+ Accept: "application/json",
666
+ };
667
+ const sentCredentials = applyClientAuthentication(
668
+ method,
669
+ input.clientId,
670
+ input.clientSecret,
671
+ body,
672
+ headers,
673
+ );
421
674
 
422
675
  const res = await safeFetch(input.revocationUrl, {
423
676
  method: "POST",
424
- headers: {
425
- "Content-Type": "application/x-www-form-urlencoded",
426
- Accept: "application/json",
427
- },
677
+ headers,
428
678
  body: body.toString(),
429
679
  });
430
680
  if (!res.ok && res.status !== 200 && res.status !== 204) {
431
681
  // RFC 7009: 200 even for already-revoked. Treat any non-2xx as informational only.
432
682
  const text = await res.text().catch(() => "");
433
- throw new Error(`Token revocation failed (${res.status}): ${text}`);
683
+ throw new Error(
684
+ `Token revocation failed (${res.status}): ${redactSentCredentials(text, [
685
+ ...sentCredentials,
686
+ input.token,
687
+ ])}`,
688
+ );
434
689
  }
435
690
  }
436
691
 
@@ -363,7 +363,7 @@ export async function createPiRuntimeAuth(
363
363
  for (const [envKey, provider] of PI_RUNTIME_API_KEYS) {
364
364
  const apiKey = env[envKey];
365
365
  if (apiKey) {
366
- await modelRuntime.setRuntimeApiKey(provider, apiKey, { allowNetwork: false });
366
+ await modelRuntime.setRuntimeApiKey(provider, apiKey);
367
367
  }
368
368
  }
369
369
 
@@ -26,7 +26,7 @@ Example binding document:
26
26
  }
27
27
  ```
28
28
 
29
- Scripts still opt in per request by sending a header value that contains the placeholder from `headerTemplate`. Query-string bindings work the same way: configure `queryTemplate` as `param=[REDACTED:CONFIG_KEY]`, then send a URL with that placeholder-bearing query value. The broker does not auto-add headers or query parameters in this draft.
29
+ A header whose value still contains an unresolved `[REDACTED:...]` placeholder after substitution is dropped before egress — the literal is useless to any provider, and omitting it lets optional-auth APIs (e.g. GitHub public repos) serve unauthenticated requests on installs without the credential. Scripts still opt in per request by sending a header value that contains the placeholder from `headerTemplate`. Query-string bindings work the same way: configure `queryTemplate` as `param=[REDACTED:CONFIG_KEY]`, then send a URL with that placeholder-bearing query value. The broker does not auto-add headers or query parameters in this draft.
30
30
 
31
31
  ## Management
32
32
 
@@ -83,8 +83,10 @@ export function patchFetchWithCredentialBroker(
83
83
  bindings: ResolvedCredentialBinding[],
84
84
  failedBindings: FailedCredentialBinding[] = [],
85
85
  ): void {
86
- if (bindings.length === 0 && failedBindings.length === 0) return;
87
-
86
+ // Always install: even with zero resolved bindings (fresh install, identity
87
+ // without the credential) the wrapper must strip unresolved placeholder
88
+ // headers below, or the literal placeholder rides to the provider as a
89
+ // guaranteed 401 and optional-auth APIs stop serving public data.
88
90
  const originalFetch = globalThis.fetch;
89
91
 
90
92
  globalThis.fetch = function patchedFetch(
@@ -112,6 +114,14 @@ export function patchFetchWithCredentialBroker(
112
114
  headersModified = true;
113
115
  }
114
116
  }
117
+ // A placeholder still present after substitution means the credential is
118
+ // not configured (or not allowed) for this identity and host: drop the
119
+ // header instead of sending the literal, so optional-auth providers can
120
+ // still serve public data unauthenticated.
121
+ if (value.includes("[REDACTED:")) {
122
+ headersModified = true;
123
+ continue;
124
+ }
115
125
  newHeaders.set(key, value);
116
126
  }
117
127
 
@@ -148,6 +148,7 @@ export const SDK_TOOL_NAME_MAP = {
148
148
  app_patch: "app-patch",
149
149
  app_query: "app-query",
150
150
  app_rollback: "app-rollback",
151
+ app_sync: "app-sync",
151
152
  app_upsert: "app-upsert",
152
153
  page_create: "create_page",
153
154
  page_delete: "delete-page", // destructive
@@ -11,10 +11,14 @@ export async function runtimeFetch(
11
11
  let lastError: unknown;
12
12
 
13
13
  for (let attempt = 0; attempt < retries; attempt++) {
14
+ if (signal?.aborted) {
15
+ throw signal.reason ?? new DOMException("This operation was aborted", "AbortError");
16
+ }
14
17
  const controller = new AbortController();
15
18
  const timeout = setTimeout(() => controller.abort(), timeoutMs);
16
19
  const onAbort = () => controller.abort();
17
20
  signal?.addEventListener("abort", onAbort, { once: true });
21
+ if (signal?.aborted) controller.abort();
18
22
 
19
23
  try {
20
24
  const res = await fetch(input, { ...init, signal: controller.signal });
@@ -24,6 +28,8 @@ export async function runtimeFetch(
24
28
  }
25
29
  return res;
26
30
  } catch (error) {
31
+ // Caller abort is terminal — only the controller's own timeout abort retries.
32
+ if (signal?.aborted) throw signal.reason ?? error;
27
33
  lastError = error;
28
34
  if (attempt === retries - 1) break;
29
35
  } finally {
@@ -455,6 +455,7 @@ declare module "swarm-sdk" {
455
455
  migration?: Record<string, unknown>;
456
456
  forceElementBreak?: string[];
457
457
  }): Promise<unknown>;
458
+ app_sync(args: { appId: string; model?: string; source?: string }): Promise<unknown>;
458
459
  app_upsert(args: {
459
460
  name: string;
460
461
  description?: string;
@@ -437,6 +437,7 @@ declare module "swarm-sdk" {
437
437
  migration?: Record<string, unknown>;
438
438
  forceElementBreak?: string[];
439
439
  }): Promise<unknown>;
440
+ app_sync(args: { appId: string; model?: string; source?: string }): Promise<unknown>;
440
441
  app_upsert(args: {
441
442
  name: string;
442
443
  description?: string;
package/src/server.ts CHANGED
@@ -16,6 +16,7 @@ import { registerAppListTool } from "./tools/app-list";
16
16
  import { registerAppPatchTool } from "./tools/app-patch";
17
17
  import { registerAppQueryTool } from "./tools/app-query";
18
18
  import { registerAppRollbackTool } from "./tools/app-rollback";
19
+ import { registerAppSyncTool } from "./tools/app-sync";
19
20
  import { registerAppUpsertTool } from "./tools/app-upsert";
20
21
  import { registerCancelTaskTool } from "./tools/cancel-task";
21
22
  import { registerContextDiffTool } from "./tools/context-diff";
@@ -477,6 +478,7 @@ export function createServer(opts: { scriptsOnly?: boolean; fullSurface?: boolea
477
478
  registerAppPatchTool(server);
478
479
  registerAppQueryTool(server);
479
480
  registerAppRollbackTool(server);
481
+ registerAppSyncTool(server);
480
482
  registerAppUpsertTool(server);
481
483
  registerCreatePageTool(server);
482
484
  registerDeletePageTool(server);
@@ -8,6 +8,7 @@ import {
8
8
  } from "node:http";
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { closeDb, createAgent, createUser, getDb, getTaskById, initDb } from "../be/db";
11
+ import { upsertScriptByName } from "../be/scripts/db";
11
12
  import { type IdentityActor, mintToken } from "../be/users";
12
13
  import { handleApps } from "../http/apps";
13
14
  import { resolveHttpRequestAuth } from "../http/auth";
@@ -340,3 +341,112 @@ describe("app viewer identity", () => {
340
341
  expect(getTaskById(agentAction.body.taskId)?.requestedByUserId).toBeUndefined();
341
342
  });
342
343
  });
344
+
345
+ describe("user writers and the script ownership gate", () => {
346
+ async function saveScript(scope: "agent" | "global", ownerId: string | null): Promise<string> {
347
+ const result = await upsertScriptByName({
348
+ name: `apps_rbac_${scope}_${crypto.randomUUID().replaceAll("-", "")}`,
349
+ scope,
350
+ scopeId: ownerId,
351
+ agentId: ownerId,
352
+ source: "export default function run() { return { ok: true }; }",
353
+ description: "Ownership-gate fixture",
354
+ intent: "Prove the user-writer ownership gate",
355
+ signatureJson: JSON.stringify({ args: { type: "object" }, result: { type: "object" } }),
356
+ typeChecked: true,
357
+ embeddingMode: "skip",
358
+ });
359
+ return result.script.id;
360
+ }
361
+
362
+ function withSource(scriptId: string) {
363
+ return {
364
+ ...definition,
365
+ models: {
366
+ note: {
367
+ columns: {
368
+ title: { kind: "string" },
369
+ issueKey: { kind: "string" },
370
+ body: { kind: "string", source: { of: "gh", field: "body" } },
371
+ },
372
+ sources: { gh: { connector: "script", scriptId, joinKey: "issueKey" } },
373
+ },
374
+ },
375
+ };
376
+ }
377
+
378
+ test("a web user cannot wire agent-scoped or catalog scripts; stored paths stay editable", async () => {
379
+ const ownerId = crypto.randomUUID();
380
+ createAgent({ id: ownerId, name: "apps-rbac-script-owner", isLead: false, status: "idle" });
381
+ const foreignId = await saveScript("agent", ownerId);
382
+ const globalId = await saveScript("global", null);
383
+ type Issues = { issues?: Array<{ path: string; message: string }> };
384
+
385
+ // A user write carries no agent id — it must NOT pass like the operator.
386
+ const asSource = await request<Issues>("/api/apps", "user", {
387
+ method: "POST",
388
+ body: JSON.stringify({ name: "User foreign source", definition: withSource(foreignId) }),
389
+ });
390
+ expect(asSource.status).toBe(400);
391
+ expect(
392
+ asSource.body.issues?.some(
393
+ (issue) =>
394
+ issue.path === "models.note.sources.gh.scriptId" &&
395
+ issue.message.includes("agent-scoped to another agent"),
396
+ ),
397
+ ).toBe(true);
398
+
399
+ const asAction = await request<Issues>("/api/apps", "user", {
400
+ method: "POST",
401
+ body: JSON.stringify({
402
+ name: "User foreign action",
403
+ definition: {
404
+ ...definition,
405
+ actions: { steal: { kind: "script", scriptId: foreignId } },
406
+ },
407
+ }),
408
+ });
409
+ expect(asAction.status).toBe(400);
410
+ expect(
411
+ asAction.body.issues?.some(
412
+ (issue) =>
413
+ issue.path === "actions.steal.scriptId" &&
414
+ issue.message.includes("agent-scoped to another agent"),
415
+ ),
416
+ ).toBe(true);
417
+
418
+ // Owner-less global (catalog) scripts sync with the LEAD's credentials —
419
+ // a user may not wire those as sources either.
420
+ const globalApp = await request<Issues>("/api/apps", "user", {
421
+ method: "POST",
422
+ body: JSON.stringify({ name: "User global source", definition: withSource(globalId) }),
423
+ });
424
+ expect(globalApp.status).toBe(400);
425
+ expect(
426
+ globalApp.body.issues?.some(
427
+ (issue) =>
428
+ issue.path === "models.note.sources.gh.scriptId" &&
429
+ issue.message.includes("only that agent or the operator"),
430
+ ),
431
+ ).toBe(true);
432
+
433
+ // An operator-authored app carrying the foreign script at a stored path
434
+ // stays editable for users — grandfathering is path-exact, not skipped.
435
+ const operatorApp = await request<{ app: { id: string } }>("/api/apps", "operator", {
436
+ method: "POST",
437
+ body: JSON.stringify({
438
+ name: "Operator foreign action",
439
+ definition: { ...definition, actions: { run: { kind: "script", scriptId: foreignId } } },
440
+ }),
441
+ });
442
+ expect(operatorApp.status).toBe(201);
443
+
444
+ const edited = await request<Issues>(`/api/apps/${operatorApp.body.app.id}`, "user", {
445
+ method: "PATCH",
446
+ body: JSON.stringify({
447
+ definition: { models: { note: { columns: { extra: { kind: "string" } } } } },
448
+ }),
449
+ });
450
+ expect(edited.status).toBe(200);
451
+ });
452
+ });
@@ -1064,7 +1064,7 @@ describe("custom app actions", () => {
1064
1064
  );
1065
1065
  expect(started.status).toBe(200);
1066
1066
  expect(started.body).toMatchObject({ ok: true, status: "pending" });
1067
- const observed = await request<{ id: string; agentId: string; task: string }>(
1067
+ const observed = await request<{ id: string; agentId: string; task: string; key: string }>(
1068
1068
  `/api/tasks/${started.body.taskId}`,
1069
1069
  );
1070
1070
  expect(observed.status).toBe(200);
@@ -1072,6 +1072,9 @@ describe("custom app actions", () => {
1072
1072
  expect(observed.body.agentId).toBe(LEAD_ID);
1073
1073
  expect(observed.body.task).toContain(`[App action] app=${appId}`);
1074
1074
  expect(observed.body.task).toContain('input={"idea":"42"}');
1075
+ // The app-namespaced asset key lets a swarm-tasks source pull these back
1076
+ // via config.assetKey.
1077
+ expect(observed.body.key).toBe(`shared/app:${appId}/action:investigate/`);
1075
1078
  });
1076
1079
  });
1077
1080