@desplega.ai/agent-swarm 1.119.6 → 1.121.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 (207) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-qe5zjcbr.js → actions-mwshtkf7.js} +6 -6
  3. package/dist/{anthropic-messages-qjxzsabf.js → anthropic-messages-4gn944cn.js} +29 -13
  4. package/dist/{app-31x7k3vj.js → app-skbty91x.js} +4 -4
  5. package/dist/{assistant-4crt9dve.js → assistant-tg7dhfj8.js} +13 -13
  6. package/dist/{azure-openai-responses-ntfd05zd.js → azure-openai-responses-79zc0wyy.js} +28 -13
  7. package/dist/{boot-reembed-8g69ttz4.js → boot-reembed-ntjmeyh2.js} +4 -4
  8. package/dist/{boot-reembed-q648j733.js → boot-reembed-q5x0xpcp.js} +5 -5
  9. package/dist/{boot-scrub-logs-sfmzd2hj.js → boot-scrub-logs-5gznsfbe.js} +4 -4
  10. package/dist/{claude-adapter-9n3nzt65.js → claude-adapter-vxvcym3r.js} +3 -3
  11. package/dist/{claude-managed-adapter-pv2nbqy4.js → claude-managed-adapter-g807894s.js} +2 -2
  12. package/dist/{cli-bpvgs2y6.js → cli-072t49hf.js} +2123 -1533
  13. package/dist/{cli-g6qz2kts.js → cli-0df2g86x.js} +336 -18
  14. package/dist/cli-1728qh70.js +791 -0
  15. package/dist/{cli-vg1jxfqz.js → cli-3b1ayz83.js} +5 -5
  16. package/dist/{cli-r93nz80j.js → cli-3fxqwfh3.js} +1 -1
  17. package/dist/{cli-w6rhc30s.js → cli-3jrdwnn9.js} +20 -5
  18. package/dist/{cli-pasyk3vc.js → cli-46etyprn.js} +10 -5
  19. package/dist/{cli-433c0f4b.js → cli-4pfweq4x.js} +8 -8
  20. package/dist/{cli-2r7s8jhp.js → cli-5c4327dx.js} +7 -1
  21. package/dist/{cli-4s84mw72.js → cli-5hpvd15x.js} +2 -2
  22. package/dist/{cli-branks1w.js → cli-6s3ytc5x.js} +1 -1
  23. package/dist/{cli-da3x3btq.js → cli-7pt9sc83.js} +2 -2
  24. package/dist/{cli-2gv8ygwr.js → cli-7pzae66m.js} +2 -2
  25. package/dist/{cli-qs7nb3zn.js → cli-8wrympd7.js} +27 -1
  26. package/dist/{cli-qw1ccvxm.js → cli-95rbe11h.js} +70 -51
  27. package/dist/{cli-6rvm3jkk.js → cli-agdrxyex.js} +1 -1
  28. package/dist/{cli-wr706gez.js → cli-av2s7my4.js} +167 -66
  29. package/dist/{cli-59jy217p.js → cli-avw9ev3x.js} +55 -21
  30. package/dist/{cli-fkxxh5d4.js → cli-b2mqkg1h.js} +2 -2
  31. package/dist/{cli-1arw00dp.js → cli-ccjwgarc.js} +12 -3
  32. package/dist/{cli-pscrc6tw.js → cli-ch7t5v7n.js} +5 -0
  33. package/dist/{cli-tghre7g6.js → cli-cw70hymf.js} +1 -0
  34. package/dist/{cli-n61x2aph.js → cli-dmzv0986.js} +816 -1335
  35. package/dist/{cli-d1reabed.js → cli-g9vva2qh.js} +2 -2
  36. package/dist/{cli-ner6af7m.js → cli-gdpx70nc.js} +24048 -23715
  37. package/dist/{cli-96feg24n.js → cli-ghq3v4c9.js} +2 -2
  38. package/dist/{cli-wxbnjgce.js → cli-ky8esb8t.js} +3 -3
  39. package/dist/{cli-gdqdkr3j.js → cli-nf7sadev.js} +2 -2
  40. package/dist/{cli-h6cf69x5.js → cli-p9w7kb3h.js} +1 -1
  41. package/dist/{cli-xeatwfbe.js → cli-ry656xem.js} +24 -17
  42. package/dist/{cli-q8dway4w.js → cli-s8bdcke2.js} +1 -1
  43. package/dist/cli-s8s499xy.js +81 -0
  44. package/dist/{cli-sbkwvn5e.js → cli-srjqn7fd.js} +1 -1
  45. package/dist/cli-tv7vekye.js +367 -0
  46. package/dist/{cli-gg5bd32n.js → cli-vg3zrw6h.js} +4 -4
  47. package/dist/{cli-vx0y68jv.js → cli-vp9r4ap4.js} +5 -4
  48. package/dist/{cli-mg280wsv.js → cli-we156bmz.js} +1 -1
  49. package/dist/{cli-86fdnerz.js → cli-wwsaj97q.js} +9 -4
  50. package/dist/{cli-e0y603nn.js → cli-x0e0yvnq.js} +101 -1
  51. package/dist/{cli-mw3xpgwb.js → cli-x4apc9t3.js} +4 -4
  52. package/dist/cli.js +26 -21
  53. package/dist/{codex-adapter-6j2b38tg.js → codex-adapter-mmy741zd.js} +8 -9
  54. package/dist/{codex-session-runner-vz215wgw.js → codex-session-runner-sazmx9zt.js} +8 -9
  55. package/dist/{commands-6xr20jbe.js → commands-cxxye5e4.js} +3 -3
  56. package/dist/{db-1mmvk1z4.js → db-ab1954b7.js} +5 -3
  57. package/dist/{google-generative-ai-n1d3xt4n.js → google-generative-ai-wwa2ked5.js} +14 -18
  58. package/dist/{google-vertex-mxst23gy.js → google-vertex-71xmdd7n.js} +14 -18
  59. package/dist/{handlers-xvrknk8w.js → handlers-fmfe2343.js} +13 -13
  60. package/dist/{hook-qm7he8wn.js → hook-ygnw3cvt.js} +7 -8
  61. package/dist/{http-hj08zq67.js → http-kactcc0z.js} +1128 -536
  62. package/dist/{index-688jph6a.js → index-6erq0wvk.js} +11 -11
  63. package/dist/{index-gze4fg2w.js → index-g86j2yt5.js} +9 -9
  64. package/dist/{index-etz7d8md.js → index-gmzs6a2q.js} +10 -10
  65. package/dist/{index-wj8dfpwh.js → index-hmp82wn0.js} +12 -12
  66. package/dist/{keepalive-94wn9yxw.js → keepalive-3cvt26dw.js} +18 -14
  67. package/dist/{lead-rvcqkp11.js → lead-ysf26cjr.js} +31 -32
  68. package/dist/{maintenance-qhfc4dnp.js → maintenance-9npwptbb.js} +5 -5
  69. package/dist/{mistral-conversations-ykcg66za.js → mistral-conversations-26ya4yah.js} +17 -13
  70. package/dist/{oauth-refresh-sweep-rj8wgvsw.js → oauth-refresh-sweep-0r2v76vw.js} +23 -17
  71. package/dist/{onboard-8bv8q1wc.js → onboard-ddb1674a.js} +2 -2
  72. package/dist/{openai-codex-responses-zy0907j9.js → openai-codex-responses-6rr2ye0s.js} +78 -38
  73. package/dist/{openai-completions-xk4p56n9.js → openai-completions-zfa9nkvt.js} +130 -33
  74. package/dist/{openai-responses-4gv3ca5d.js → openai-responses-9wmdks0g.js} +38 -14
  75. package/dist/{opencode-adapter-5bwzvbwj.js → opencode-adapter-h6rkxwrk.js} +2 -2
  76. package/dist/{openrouter-images-4fsrrzes.js → openrouter-images-dgq98729.js} +10 -3
  77. package/dist/{otel-impl-1kf6fr2m.js → otel-impl-b43bkq8e.js} +4 -4
  78. package/dist/{pi-messages-zb8v76wx.js → pi-messages-nzjbbz47.js} +4 -6
  79. package/dist/{pi-mono-adapter-9sg69kg3.js → pi-mono-adapter-ke789738.js} +2328 -1281
  80. package/dist/{pricing-refresh-e84tar2s.js → pricing-refresh-2rqf114z.js} +5 -5
  81. package/dist/{rbac-roles-atytq5ch.js → rbac-roles-5wkhc8xv.js} +5 -5
  82. package/dist/{rbac-roles-pg0zvnwn.js → rbac-roles-6vtafvzb.js} +4 -4
  83. package/dist/{seed-pricing-xd6nxq7k.js → seed-pricing-23wrb873.js} +4 -4
  84. package/dist/{setup-4z389yk0.js → setup-6rny4fk6.js} +2 -2
  85. package/dist/{worker-m61r0t7m.js → worker-yapewe14.js} +31 -32
  86. package/openapi.json +2671 -1811
  87. package/package.json +10 -5
  88. package/plugin/commands/swarm-chat.md +2 -0
  89. package/plugin/pi-skills/swarm-chat/SKILL.md +2 -0
  90. package/src/be/connection-bindings-blob-migration.ts +123 -0
  91. package/src/be/db-queries/mcp-oauth.ts +409 -247
  92. package/src/be/db-queries/oauth.ts +813 -131
  93. package/src/be/db.ts +97 -6
  94. package/src/be/migrations/117_unified_oauth.sql +624 -0
  95. package/src/be/migrations/119_agent_avatar.sql +7 -0
  96. package/src/be/migrations/120_task_title.sql +3 -0
  97. package/src/be/oauth-credential-bindings.ts +48 -14
  98. package/src/be/oauth-encryption-backfill.ts +84 -0
  99. package/src/be/oauth-refresh-sweep.ts +38 -23
  100. package/src/be/script-connections.ts +1130 -163
  101. package/src/be/script-credential-broker.ts +37 -27
  102. package/src/be/scripts/typecheck.ts +3 -2
  103. package/src/be/seed-capabilities.ts +62 -0
  104. package/src/be/seed-pricing.ts +1 -1
  105. package/src/be/seed-scripts/catalog/ops-catalog-audit.inline.ts +1 -1
  106. package/src/be/seed-scripts/catalog/ops-catalog-audit.ts +1 -1
  107. package/src/be/vendored-openapi.ts +98 -0
  108. package/src/commands/runner.ts +69 -6
  109. package/src/http/agents.ts +23 -5
  110. package/src/http/all-routes.ts +1 -0
  111. package/src/http/core.ts +8 -5
  112. package/src/http/index.ts +18 -5
  113. package/src/http/mcp-bridge.ts +5 -2
  114. package/src/http/mcp-oauth.ts +103 -101
  115. package/src/http/oauth-callback.ts +332 -0
  116. package/src/http/oauth-generic.ts +14 -54
  117. package/src/http/poll.ts +5 -1
  118. package/src/http/script-connections.ts +651 -170
  119. package/src/http/scripts.ts +4 -2
  120. package/src/http/sessions.ts +1 -1
  121. package/src/http/tasks.ts +35 -0
  122. package/src/http/trackers/jira.ts +22 -34
  123. package/src/http/trackers/linear.ts +21 -36
  124. package/src/http/x.ts +6 -2
  125. package/src/jira/app.ts +12 -6
  126. package/src/jira/oauth.ts +48 -48
  127. package/src/linear/app.ts +44 -2
  128. package/src/linear/oauth.ts +12 -37
  129. package/src/oauth/app-validation.ts +27 -9
  130. package/src/oauth/ensure-mcp-token.ts +1 -0
  131. package/src/oauth/ensure-token.ts +331 -108
  132. package/src/oauth/identity-capture.ts +103 -0
  133. package/src/oauth/keepalive.ts +14 -9
  134. package/src/oauth/presets.ts +242 -0
  135. package/src/oauth/wrapper.ts +197 -76
  136. package/src/prompts/base-prompt.ts +48 -4
  137. package/src/prompts/session-templates.ts +13 -1
  138. package/src/providers/claude-managed-models.ts +8 -0
  139. package/src/rbac/legacy-policy.ts +2 -1
  140. package/src/rbac/permissions.ts +7 -3
  141. package/src/scripts-runtime/credential-broker/broker.ts +51 -8
  142. package/src/scripts-runtime/credential-broker/fetch-patch.ts +30 -3
  143. package/src/scripts-runtime/credential-broker/index.ts +1 -1
  144. package/src/scripts-runtime/credential-broker/types.ts +55 -8
  145. package/src/scripts-runtime/egress-secrets.ts +6 -2
  146. package/src/scripts-runtime/eval-harness.ts +2 -2
  147. package/src/scripts-runtime/executors/types.ts +2 -1
  148. package/src/scripts-runtime/loader.ts +3 -0
  149. package/src/scripts-runtime/types/stdlib.d.ts +11 -0
  150. package/src/server.ts +230 -126
  151. package/src/tests/agent-avatar-resolution.test.ts +123 -0
  152. package/src/tests/agents-list-model-display.test.ts +10 -0
  153. package/src/tests/base-prompt.test.ts +72 -0
  154. package/src/tests/claude-managed-adapter.test.ts +6 -0
  155. package/src/tests/connection-embedded-auth.test.ts +709 -0
  156. package/src/tests/context-window.test.ts +1 -0
  157. package/src/tests/credential-broker.test.ts +123 -125
  158. package/src/tests/db-queries-oauth.test.ts +126 -1
  159. package/src/tests/ensure-token.test.ts +44 -2
  160. package/src/tests/list-endpoint-slimming.test.ts +45 -0
  161. package/src/tests/mcp-oauth-manual-client.test.ts +20 -1
  162. package/src/tests/mcp-oauth-queries.test.ts +154 -8
  163. package/src/tests/oauth-access-token-tool.test.ts +37 -1
  164. package/src/tests/oauth-callback-flow.test.ts +534 -0
  165. package/src/tests/oauth-credential-bindings.test.ts +50 -22
  166. package/src/tests/oauth-presets.test.ts +275 -0
  167. package/src/tests/oauth-refresh-failure.test.ts +263 -0
  168. package/src/tests/oauth-refresh-sweep.test.ts +83 -3
  169. package/src/tests/oauth-wrapper.test.ts +77 -55
  170. package/src/tests/prompt-template-session.test.ts +6 -5
  171. package/src/tests/rbac-charact-misc-tools.test.ts +60 -0
  172. package/src/tests/rbac-engine.test.ts +2 -1
  173. package/src/tests/script-connections-http.test.ts +356 -11
  174. package/src/tests/script-connections.test.ts +102 -0
  175. package/src/tests/sdk-allowlist.test.ts +4 -1
  176. package/src/tests/seed-capabilities.test.ts +92 -0
  177. package/src/tests/session-costs-model-key-normalize.test.ts +1 -0
  178. package/src/tests/sessions.test.ts +46 -0
  179. package/src/tests/spec-base-url.test.ts +266 -0
  180. package/src/tests/status.test.ts +3 -4
  181. package/src/tests/swarm-config-reserved-keys.test.ts +4 -2
  182. package/src/tests/task-title-route.test.ts +106 -0
  183. package/src/tests/tool-annotations.test.ts +6 -3
  184. package/src/tests/tracker-fold.test.ts +412 -0
  185. package/src/tests/unified-oauth-migration.test.ts +455 -0
  186. package/src/tests/vendored-openapi.test.ts +263 -0
  187. package/src/tools/credential-bindings/tool.ts +232 -77
  188. package/src/tools/oauth-access-token.ts +55 -6
  189. package/src/tools/script-connections/tool.ts +73 -53
  190. package/src/tools/swarm-config/set-config.ts +24 -50
  191. package/src/tools/update-profile.ts +15 -5
  192. package/src/tracker/types.ts +12 -0
  193. package/src/types.ts +29 -0
  194. package/src/utils/context-window.ts +1 -0
  195. package/src/workflows/executors/swarm-script.ts +6 -2
  196. package/vendored-openapi/README.md +22 -0
  197. package/vendored-openapi/github.json +7759 -0
  198. package/vendored-openapi/gmail.json +393 -0
  199. package/vendored-openapi/jira.json +3396 -0
  200. package/vendored-openapi/linear.json +252 -0
  201. package/vendored-openapi/manifest.json +128 -0
  202. package/vendored-openapi/slack.json +2895 -0
  203. package/dist/cli-0pygq9r1.js +0 -429
  204. package/dist/cli-8hhms4hp.js +0 -393
  205. package/dist/cli-z9gzq07x.js +0 -27
  206. package/src/scripts-runtime/credential-broker/store.ts +0 -72
  207. package/dist/{otel-g299k41b.js → otel-63jr5v38.js} +1 -1
@@ -9,6 +9,7 @@ import {
9
9
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
10
  import { closeDb, createAgent, getDb, initDb } from "../be/db";
11
11
  import {
12
+ deleteOAuthTokens,
12
13
  getOAuthApp,
13
14
  getOAuthTokens,
14
15
  storeOAuthTokens,
@@ -41,8 +42,8 @@ type ToolResult = {
41
42
  bindings: Array<{
42
43
  configKey: string;
43
44
  authKind?: "config" | "oauth";
44
- oauthProvider?: string;
45
- tokenStatus?: "ok" | "expiring" | "missing";
45
+ oauthAuthorizationId?: string;
46
+ tokenStatus?: "ok" | "expiring" | "refresh-failed" | "revoked" | "missing";
46
47
  }>;
47
48
  };
48
49
  };
@@ -100,7 +101,6 @@ async function listen(server: Server): Promise<number> {
100
101
  function cleanupRows() {
101
102
  const db = getDb();
102
103
  db.run("DELETE FROM script_credential_bindings WHERE config_key LIKE 'PHASE2_%'");
103
- db.run("DELETE FROM oauth_tokens WHERE provider LIKE 'phase2-%'");
104
104
  db.run("DELETE FROM oauth_apps WHERE provider LIKE 'phase2-%'");
105
105
  }
106
106
 
@@ -147,24 +147,31 @@ describe("OAuth credential bindings", () => {
147
147
  .all()
148
148
  .map((column) => column.name);
149
149
  expect(columns).toContain("auth_kind");
150
- expect(columns).toContain("oauth_provider");
150
+ expect(columns).toContain("oauth_authorization_id");
151
+
152
+ upsertOAuthApp("phase2-roundtrip", testApp("phase2-roundtrip"));
153
+ storeOAuthTokens("phase2-roundtrip", {
154
+ accessToken: "roundtrip-access",
155
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
156
+ });
157
+ const authorizationId = getOAuthTokens("phase2-roundtrip")!.id;
151
158
 
152
159
  const binding = upsertCredentialBinding({
153
160
  configKey: "PHASE2_ROUNDTRIP_OAUTH",
154
161
  allowedHosts: ["api.vendor.test"],
155
162
  headerTemplate: "Authorization: Bearer [REDACTED:PHASE2_ROUNDTRIP_OAUTH]",
156
163
  authKind: "oauth",
157
- oauthProvider: "phase2-roundtrip",
164
+ oauthAuthorizationId: authorizationId,
158
165
  });
159
166
 
160
167
  expect(binding.authKind).toBe("oauth");
161
- expect(binding.oauthProvider).toBe("phase2-roundtrip");
168
+ expect(binding.oauthAuthorizationId).toBe(authorizationId);
162
169
 
163
170
  const listed = listRelationalCredentialBindings({ includeInactive: true }).find(
164
171
  (item) => item.id === binding.id,
165
172
  );
166
173
  expect(listed?.authKind).toBe("oauth");
167
- expect(listed?.oauthProvider).toBe("phase2-roundtrip");
174
+ expect(listed?.oauthAuthorizationId).toBe(authorizationId);
168
175
  });
169
176
 
170
177
  test("OAuth binding resolves through the stored access token", async () => {
@@ -179,7 +186,7 @@ describe("OAuth credential bindings", () => {
179
186
  allowedHosts: ["api.vendor.test"],
180
187
  headerTemplate: "Authorization: Bearer [REDACTED:PHASE2_RESOLVE_OAUTH]",
181
188
  authKind: "oauth",
182
- oauthProvider: "phase2-resolve",
189
+ oauthAuthorizationId: getOAuthTokens("phase2-resolve")!.id,
183
190
  });
184
191
  process.env.PHASE2_RESOLVE_OAUTH = "env-must-not-win";
185
192
 
@@ -205,7 +212,7 @@ describe("OAuth credential bindings", () => {
205
212
  allowedHosts: ["api.vendor.test"],
206
213
  headerTemplate: "Authorization: Bearer [REDACTED:PHASE2_REFRESH_OAUTH]",
207
214
  authKind: "oauth",
208
- oauthProvider: "phase2-refresh",
215
+ oauthAuthorizationId: getOAuthTokens("phase2-refresh")!.id,
209
216
  });
210
217
 
211
218
  const fetchSpy = mock((input: string | URL | Request, init?: RequestInit) => {
@@ -254,7 +261,7 @@ describe("OAuth credential bindings", () => {
254
261
  allowedHosts: ["api.vendor.test"],
255
262
  headerTemplate: "Authorization: Bearer [REDACTED:PHASE2_BASIC_OAUTH]",
256
263
  authKind: "oauth",
257
- oauthProvider: "phase2-basic",
264
+ oauthAuthorizationId: getOAuthTokens("phase2-basic")!.id,
258
265
  });
259
266
 
260
267
  const config = getOAuthProviderConfig("phase2-basic");
@@ -308,7 +315,16 @@ describe("OAuth credential bindings", () => {
308
315
  expect(url).not.toContain("scope=read%2Cwrite");
309
316
  });
310
317
 
311
- test("credential-bindings tool rejects reserved tracker OAuth providers", async () => {
318
+ test("metadata actor remains an authorization parameter without lifted extras", () => {
319
+ upsertOAuthApp("phase2-actor", {
320
+ ...testApp("phase2-actor"),
321
+ metadata: JSON.stringify({ actor: "app" }),
322
+ });
323
+
324
+ expect(getOAuthProviderConfig("phase2-actor")?.extraParams).toEqual({ actor: "app" });
325
+ });
326
+
327
+ test("credential-bindings tool now accepts tracker providers (reserved carve-out removed in step-8)", async () => {
312
328
  const result = (await credentialBindingsTool().handler(
313
329
  {
314
330
  action: "oauth-app-upsert",
@@ -322,9 +338,10 @@ describe("OAuth credential bindings", () => {
322
338
  meta(),
323
339
  )) as ToolResult;
324
340
 
325
- expect(result.structuredContent.success).toBe(false);
326
- expect(result.structuredContent.message).toContain("dedicated tracker");
327
- expect(getOAuthApp("jira")).toBeNull();
341
+ // linear/jira are ordinary oauth_apps rows now — no reserved-provider block.
342
+ expect(result.structuredContent.success).toBe(true);
343
+ expect(result.structuredContent.message).not.toContain("dedicated tracker");
344
+ expect(getOAuthApp("jira")?.clientId).toBe("jira-client");
328
345
  });
329
346
 
330
347
  test("credential-bindings tool validates OAuth app URLs in production", async () => {
@@ -376,7 +393,7 @@ describe("OAuth credential bindings", () => {
376
393
  allowedHosts: ["api.vendor.test"],
377
394
  headerTemplate: "Authorization: Bearer [REDACTED:PHASE2_BROKEN_OAUTH]",
378
395
  authKind: "oauth",
379
- oauthProvider: "phase2-broken",
396
+ oauthAuthorizationId: getOAuthTokens("phase2-broken")!.id,
380
397
  });
381
398
  upsertCredentialBinding({
382
399
  configKey: "PHASE2_HEALTHY_CONFIG",
@@ -406,15 +423,21 @@ describe("OAuth credential bindings", () => {
406
423
  delete process.env.PHASE2_HEALTHY_CONFIG;
407
424
  });
408
425
 
409
- test("missing OAuth token skips binding resolution and list reports missing", async () => {
426
+ test("revoked OAuth token skips binding resolution and list reports revoked", async () => {
410
427
  upsertOAuthApp("phase2-missing", testApp("phase2-missing"));
428
+ storeOAuthTokens("phase2-missing", {
429
+ accessToken: "soon-deleted",
430
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
431
+ });
432
+ const authorizationId = getOAuthTokens("phase2-missing")!.id;
411
433
  upsertCredentialBinding({
412
434
  configKey: "PHASE2_MISSING_OAUTH",
413
435
  allowedHosts: ["api.vendor.test"],
414
436
  headerTemplate: "Authorization: Bearer [REDACTED:PHASE2_MISSING_OAUTH]",
415
437
  authKind: "oauth",
416
- oauthProvider: "phase2-missing",
438
+ oauthAuthorizationId: authorizationId,
417
439
  });
440
+ deleteOAuthTokens("phase2-missing");
418
441
  process.env.PHASE2_MISSING_OAUTH = "env-must-not-win";
419
442
 
420
443
  const bindings = await buildScriptCredentialBindings({});
@@ -428,7 +451,9 @@ describe("OAuth credential bindings", () => {
428
451
  (binding) => binding.configKey === "PHASE2_MISSING_OAUTH",
429
452
  );
430
453
  expect(result.structuredContent.success).toBe(true);
431
- expect(listed?.tokenStatus).toBe("missing");
454
+ // Disconnect revokes the authorization in place (row kept for referential
455
+ // continuity), so the binding surfaces as `revoked`, not `missing`.
456
+ expect(listed?.tokenStatus).toBe("revoked");
432
457
  });
433
458
 
434
459
  test("generic OAuth callback exchanges code and stores tokens", async () => {
@@ -472,17 +497,20 @@ describe("OAuth credential bindings", () => {
472
497
  }
473
498
  });
474
499
 
475
- test("generic OAuth callback rejects linear", async () => {
500
+ test("generic OAuth callback no longer special-cases linear (unified routing)", async () => {
501
+ // step-4 dropped the DEDICATED_CALLBACK_PROVIDERS 409 asymmetry: every
502
+ // provider routes through the unified, state-keyed handler. An unknown
503
+ // state now reports the same invalid-state error as any other provider.
476
504
  const server = createOAuthServer();
477
505
  const port = await listen(server);
478
506
  try {
479
507
  const res = await fetch(
480
- `http://localhost:${port}/api/oauth/linear/callback?code=callback-code&state=state`,
508
+ `http://localhost:${port}/api/oauth/linear/callback?code=callback-code&state=unknown-state`,
481
509
  );
482
510
 
483
- expect(res.status).toBe(409);
511
+ expect(res.status).toBe(400);
484
512
  const body = (await res.json()) as { error: string };
485
- expect(body.error).toContain("/api/trackers/linear/callback");
513
+ expect(body.error).toContain("Invalid or expired OAuth state");
486
514
  } finally {
487
515
  server.close();
488
516
  }
@@ -0,0 +1,275 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import type { IncomingMessage, ServerResponse } from "node:http";
4
+ import { Readable } from "node:stream";
5
+ import { closeDb, createAgent, initDb } from "../be/db";
6
+ import { getOAuthApp } from "../be/db-queries/oauth";
7
+ import { handleScriptConnections } from "../http/script-connections";
8
+ import { getPathSegments, parseQueryParams } from "../http/utils";
9
+ import { assertOAuthAppUrlsSafe } from "../oauth/app-validation";
10
+ import {
11
+ getOAuthPreset,
12
+ hydrateOAuthAppFromPreset,
13
+ listOAuthPresetIds,
14
+ listOAuthPresets,
15
+ } from "../oauth/presets";
16
+ import { buildAuthorizationUrl } from "../oauth/wrapper";
17
+
18
+ const TEST_DB_PATH = "./test-oauth-presets.sqlite";
19
+
20
+ async function removeDbFiles(path: string): Promise<void> {
21
+ for (const suffix of ["", "-wal", "-shm"]) {
22
+ try {
23
+ await unlink(path + suffix);
24
+ } catch (error) {
25
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
26
+ }
27
+ }
28
+ }
29
+
30
+ type TestResponse = { status: number; text: string; json: () => Promise<unknown> };
31
+
32
+ async function dispatch(
33
+ path: string,
34
+ init: { method?: string; body?: unknown; agentId?: string } = {},
35
+ ): Promise<TestResponse> {
36
+ const req = Readable.from(
37
+ init.body === undefined ? [] : [Buffer.from(JSON.stringify(init.body))],
38
+ ) as IncomingMessage;
39
+ req.method = init.method ?? "GET";
40
+ req.url = path;
41
+ req.headers = init.agentId
42
+ ? { "x-agent-id": init.agentId, "content-type": "application/json" }
43
+ : { "content-type": "application/json" };
44
+
45
+ let status = 200;
46
+ let text = "";
47
+ const res = {
48
+ headersSent: false,
49
+ writableEnded: false,
50
+ setHeader() {},
51
+ writeHead(code: number) {
52
+ status = code;
53
+ this.headersSent = true;
54
+ return this;
55
+ },
56
+ end(chunk?: unknown) {
57
+ if (chunk !== undefined) text += String(chunk);
58
+ this.writableEnded = true;
59
+ return this;
60
+ },
61
+ } as unknown as ServerResponse;
62
+
63
+ const pathSegments = getPathSegments(req.url || "");
64
+ const queryParams = parseQueryParams(req.url || "");
65
+ if (!(await handleScriptConnections(req, res, pathSegments, queryParams, init.agentId))) {
66
+ res.writeHead(404);
67
+ res.end("Not Found");
68
+ }
69
+ return { status, text, json: async () => JSON.parse(text) };
70
+ }
71
+
72
+ let leadAgentId: string;
73
+
74
+ beforeAll(async () => {
75
+ await removeDbFiles(TEST_DB_PATH);
76
+ initDb(TEST_DB_PATH);
77
+ leadAgentId = createAgent({ name: "presets-lead", isLead: true, status: "idle" }).id;
78
+ });
79
+
80
+ afterAll(async () => {
81
+ closeDb();
82
+ await removeDbFiles(TEST_DB_PATH);
83
+ });
84
+
85
+ describe("oauth presets — pure data", () => {
86
+ test("exposes the expected curated preset ids", () => {
87
+ expect(listOAuthPresetIds().sort()).toEqual(
88
+ ["github", "google", "jira", "linear", "notion", "slack"].sort(),
89
+ );
90
+ expect(listOAuthPresets()).toHaveLength(6);
91
+ });
92
+
93
+ test("every preset carries endpoints, setup hints, and SSRF-safe URLs", () => {
94
+ for (const preset of listOAuthPresets()) {
95
+ expect(preset.authorizeUrl.startsWith("https://")).toBe(true);
96
+ expect(preset.tokenUrl.startsWith("https://")).toBe(true);
97
+ // Parse without throwing.
98
+ expect(() => new URL(preset.authorizeUrl)).not.toThrow();
99
+ expect(() => new URL(preset.tokenUrl)).not.toThrow();
100
+ if (preset.revocationUrl) expect(() => new URL(preset.revocationUrl as string)).not.toThrow();
101
+ if (preset.userinfoUrl) expect(() => new URL(preset.userinfoUrl as string)).not.toThrow();
102
+ expect(preset.setupHints.length).toBeGreaterThan(0);
103
+ // Defense in depth: the merged endpoints must survive the SSRF gate.
104
+ expect(() =>
105
+ assertOAuthAppUrlsSafe({ authorizeUrl: preset.authorizeUrl, tokenUrl: preset.tokenUrl }),
106
+ ).not.toThrow();
107
+ }
108
+ });
109
+
110
+ test("provider-specific quirks are encoded", () => {
111
+ const google = getOAuthPreset("google");
112
+ expect(google?.extraParams).toMatchObject({ access_type: "offline", prompt: "consent" });
113
+ expect(google?.scopeSeparator).toBe(" ");
114
+
115
+ const notion = getOAuthPreset("notion");
116
+ expect(notion?.tokenAuthStyle).toBe("basic");
117
+ expect(notion?.tokenBodyFormat).toBe("json");
118
+
119
+ const jira = getOAuthPreset("jira");
120
+ expect(jira?.requiresRefreshTokenRotation).toBe(true);
121
+
122
+ const linear = getOAuthPreset("linear");
123
+ expect(linear?.scopeSeparator).toBe(",");
124
+
125
+ const slack = getOAuthPreset("slack");
126
+ expect(slack?.scopeSeparator).toBe(",");
127
+ });
128
+
129
+ test("hydration marks curated-prefill and lets explicit fields win", () => {
130
+ const preset = getOAuthPreset("google");
131
+ if (!preset) throw new Error("google preset missing");
132
+
133
+ const merged = hydrateOAuthAppFromPreset(preset, {
134
+ tokenUrl: "https://custom.example.com/token",
135
+ scopes: ["custom.scope"],
136
+ extraParams: { prompt: "select_account" },
137
+ });
138
+
139
+ expect(merged.source).toBe("curated-prefill");
140
+ expect(merged.authorizeUrl).toBe(preset.authorizeUrl); // filled from preset
141
+ expect(merged.tokenUrl).toBe("https://custom.example.com/token"); // explicit wins
142
+ expect(merged.scopes).toEqual(["custom.scope"]); // explicit wins
143
+ // extraParams merge per-key: explicit prompt wins, preset access_type stays.
144
+ expect(merged.extraParams).toMatchObject({ access_type: "offline", prompt: "select_account" });
145
+ expect(merged.setupHints).toEqual(preset.setupHints);
146
+ });
147
+
148
+ test("google hydration yields offline params in the built authorization URL", async () => {
149
+ const preset = getOAuthPreset("google");
150
+ if (!preset) throw new Error("google preset missing");
151
+ const hydrated = hydrateOAuthAppFromPreset(preset, {});
152
+
153
+ // buildAuthorizationUrl now persists a PKCE pending row keyed to a real app
154
+ // (step-4), so the app must exist first.
155
+ const created = await dispatch("/api/oauth-apps", {
156
+ method: "POST",
157
+ agentId: leadAgentId,
158
+ body: { presetId: "google", clientId: "google-client", clientSecret: "google-secret" },
159
+ });
160
+ expect(created.status).toBe(200);
161
+
162
+ const { url } = await buildAuthorizationUrl(
163
+ {
164
+ provider: hydrated.provider,
165
+ clientId: "google-client",
166
+ clientSecret: "google-secret",
167
+ authorizeUrl: hydrated.authorizeUrl,
168
+ tokenUrl: hydrated.tokenUrl,
169
+ redirectUri: "https://api.public.test/api/oauth/callback",
170
+ scopes: hydrated.scopes,
171
+ scopeSeparator: hydrated.scopeSeparator,
172
+ extraParams: hydrated.extraParams,
173
+ },
174
+ { flow: "generic" },
175
+ );
176
+
177
+ const parsed = new URL(url);
178
+ expect(parsed.searchParams.get("access_type")).toBe("offline");
179
+ expect(parsed.searchParams.get("prompt")).toBe("consent");
180
+ // Space separator surfaces as `+` / `%20` in the encoded scope.
181
+ expect(parsed.searchParams.get("scope")).toBe("openid email profile");
182
+ });
183
+ });
184
+
185
+ describe("oauth presets — HTTP", () => {
186
+ test("GET /api/oauth-presets lists all six presets with hints", async () => {
187
+ const res = await dispatch("/api/oauth-presets");
188
+ expect(res.status).toBe(200);
189
+ const body = (await res.json()) as {
190
+ presets: Array<{ id: string; authorizeUrl: string; setupHints: string[] }>;
191
+ };
192
+ expect(body.presets).toHaveLength(6);
193
+ expect(body.presets.map((p) => p.id).sort()).toEqual(
194
+ ["github", "google", "jira", "linear", "notion", "slack"].sort(),
195
+ );
196
+ for (const preset of body.presets) {
197
+ expect(preset.authorizeUrl.startsWith("https://")).toBe(true);
198
+ expect(preset.setupHints.length).toBeGreaterThan(0);
199
+ }
200
+ // No secrets ever live in presets.
201
+ expect(res.text).not.toContain("clientSecret");
202
+ });
203
+
204
+ test("POST /api/oauth-apps with presetId 'google' hydrates endpoints + source", async () => {
205
+ const res = await dispatch("/api/oauth-apps", {
206
+ method: "POST",
207
+ agentId: leadAgentId,
208
+ body: { presetId: "google", clientId: "google-client", clientSecret: "google-secret" },
209
+ });
210
+ expect(res.status).toBe(200);
211
+ const body = (await res.json()) as {
212
+ oauthApp: { provider: string; authorizeUrl: string; source: string };
213
+ redirectUri: string;
214
+ setupHints: string[];
215
+ };
216
+ expect(body.redirectUri).toContain("/api/oauth/callback");
217
+ expect(body.setupHints.length).toBeGreaterThan(0);
218
+ expect(body.oauthApp.source).toBe("curated-prefill");
219
+ expect(res.text).not.toContain("google-secret");
220
+
221
+ const stored = getOAuthApp("google");
222
+ expect(stored?.source).toBe("curated-prefill");
223
+ expect(stored?.authorizeUrl).toBe("https://accounts.google.com/o/oauth2/v2/auth");
224
+ expect(stored?.tokenUrl).toBe("https://oauth2.googleapis.com/token");
225
+ expect(stored?.scopeSeparator).toBe(" ");
226
+ expect(stored?.revocationUrl).toBe("https://oauth2.googleapis.com/revoke");
227
+ expect(stored?.extraParamsJson ?? "").toContain("access_type");
228
+ expect(stored?.clientSecret).toBe("google-secret");
229
+ });
230
+
231
+ test("no presetId + missing required endpoint fields is rejected with 400", async () => {
232
+ // provider/authorizeUrl/tokenUrl are optional in the schema (preset can
233
+ // supply them) — but with no presetId the handler must still reject a
234
+ // request that omits them, not write a half-configured app.
235
+ const res = await dispatch("/api/oauth-apps", {
236
+ method: "POST",
237
+ agentId: leadAgentId,
238
+ body: { provider: "bare_provider", clientId: "c", clientSecret: "s" },
239
+ });
240
+ expect(res.status).toBe(400);
241
+ expect(res.text).toContain("authorizeUrl");
242
+ expect(getOAuthApp("bare_provider")).toBeNull();
243
+ });
244
+
245
+ test("explicit fields override the preset during hydration", async () => {
246
+ const res = await dispatch("/api/oauth-apps", {
247
+ method: "POST",
248
+ agentId: leadAgentId,
249
+ body: {
250
+ presetId: "google",
251
+ provider: "google_custom",
252
+ clientId: "gc",
253
+ clientSecret: "gcs",
254
+ tokenUrl: "https://custom.example.com/token",
255
+ },
256
+ });
257
+ expect(res.status).toBe(200);
258
+ const stored = getOAuthApp("google_custom");
259
+ expect(stored?.tokenUrl).toBe("https://custom.example.com/token");
260
+ expect(stored?.authorizeUrl).toBe("https://accounts.google.com/o/oauth2/v2/auth");
261
+ expect(stored?.source).toBe("curated-prefill");
262
+ });
263
+
264
+ test("unknown presetId is rejected with 400 listing valid ids", async () => {
265
+ const res = await dispatch("/api/oauth-apps", {
266
+ method: "POST",
267
+ agentId: leadAgentId,
268
+ body: { presetId: "not-a-real-preset", clientId: "c", clientSecret: "s" },
269
+ });
270
+ expect(res.status).toBe(400);
271
+ const body = (await res.json()) as { error: string };
272
+ expect(body.error).toContain("Unknown presetId");
273
+ expect(body.error).toContain("google");
274
+ });
275
+ });