@desplega.ai/agent-swarm 1.113.0 → 1.115.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 (237) hide show
  1. package/README.md +2 -2
  2. package/dist/{actions-q4n7cz6e.js → actions-17ya5wde.js} +8 -9
  3. package/dist/{app-es4nzc71.js → app-4f718yk0.js} +6 -6
  4. package/dist/{assistant-0x8ey0vs.js → assistant-ah7csnjz.js} +19 -18
  5. package/dist/{boot-reembed-69agkhv6.js → boot-reembed-4jpxhb25.js} +6 -6
  6. package/dist/{boot-reembed-p0ny05t8.js → boot-reembed-qata34xa.js} +5 -5
  7. package/dist/{boot-scrub-logs-yybrkmvr.js → boot-scrub-logs-tv48nn3z.js} +4 -4
  8. package/dist/{claude-adapter-mma9a1yr.js → claude-adapter-3csn5bks.js} +3 -3
  9. package/dist/{claude-managed-adapter-pv2nbqy4.js → claude-managed-adapter-kysmkz89.js} +2 -2
  10. package/dist/cli-0pygq9r1.js +429 -0
  11. package/dist/{cli-hsetkkd3.js → cli-12vb0xg6.js} +33 -9
  12. package/dist/{cli-ncfgsqbd.js → cli-15y7qhzp.js} +262 -33
  13. package/dist/{cli-7ga43spr.js → cli-1yjrbg1f.js} +1 -1
  14. package/dist/{cli-36ymmpmp.js → cli-3samkyap.js} +2 -2
  15. package/dist/cli-52fg7s7x.js +269 -0
  16. package/dist/{cli-hjhvekf4.js → cli-65ekje01.js} +11 -8
  17. package/dist/{cli-h3k622d0.js → cli-6aq8xhvm.js} +90 -3
  18. package/dist/{cli-f3qa069v.js → cli-7xje5amz.js} +1 -1
  19. package/dist/cli-86fdnerz.js +405 -0
  20. package/dist/{cli-9cy6nc5m.js → cli-8fjwym53.js} +1 -1
  21. package/dist/{cli-c3frqf65.js → cli-b23axgz4.js} +3 -3
  22. package/dist/{cli-a0t7vapf.js → cli-bc89my0z.js} +1 -1
  23. package/dist/{cli-bnzbn8j8.js → cli-beea4a3y.js} +73 -24
  24. package/dist/{cli-q21d49ac.js → cli-bw80ck94.js} +1 -1
  25. package/dist/{cli-pwc5e83c.js → cli-cchksf5q.js} +12 -7
  26. package/dist/{cli-30bbaveh.js → cli-cmw9rzy5.js} +2 -2
  27. package/dist/{cli-y6mrptcn.js → cli-cnwssmsr.js} +1 -1
  28. package/dist/{cli-r5g6ngtn.js → cli-dcca6j8m.js} +46 -5
  29. package/dist/{cli-tkmm5nf9.js → cli-e61fs2hp.js} +59 -2
  30. package/dist/{cli-6wscj9z0.js → cli-e7306swv.js} +14 -14
  31. package/dist/{cli-dcfyyh3t.js → cli-ftyf58xj.js} +2 -2
  32. package/dist/{cli-4xyj9jtq.js → cli-hrhye2rm.js} +1062 -478
  33. package/dist/cli-jkhwmmgg.js +136 -0
  34. package/dist/{cli-y4ycrnjc.js → cli-k3y4z3h5.js} +1 -1
  35. package/dist/{cli-6xd0bvf8.js → cli-ma04faw5.js} +1 -1
  36. package/dist/{cli-2fc97xm5.js → cli-mnbgv3w7.js} +4 -4
  37. package/dist/{cli-trmapn9k.js → cli-pet3aev6.js} +95 -10
  38. package/dist/{cli-z46pnksz.js → cli-q1rx7g0d.js} +175 -28
  39. package/dist/{cli-qwgtacd6.js → cli-r9f6ngrf.js} +1244 -336
  40. package/dist/{cli-gtkqc144.js → cli-shvaxw27.js} +13 -7
  41. package/dist/{cli-ywez6yps.js → cli-sq7f72d6.js} +1 -1
  42. package/dist/cli-t0vqevnm.js +393 -0
  43. package/dist/{cli-tbw13sx8.js → cli-tece6zgm.js} +1 -1
  44. package/dist/{cli-bgvskydy.js → cli-tffvrh6j.js} +1 -1
  45. package/dist/cli-wh91zby6.js +187222 -0
  46. package/dist/{cli-anrj584m.js → cli-xz9aq0rf.js} +1 -1
  47. package/dist/{cli-mq580e06.js → cli-zsrdty74.js} +18 -18
  48. package/dist/cli.js +32 -15
  49. package/dist/{codex-adapter-5cmy858w.js → codex-adapter-de3w0nch.js} +10 -10
  50. package/dist/{codex-session-runner-46q8mrba.js → codex-session-runner-bnw8cysk.js} +10 -10
  51. package/dist/{commands-z5dbwta3.js → commands-hch4e2k1.js} +4 -4
  52. package/dist/{db-bbgahh4z.js → db-p3h2j06x.js} +14 -4
  53. package/dist/{handlers-6b44317z.js → handlers-9er4zx9q.js} +17 -31
  54. package/dist/{hook-sbp5fmps.js → hook-4axs756z.js} +7 -7
  55. package/dist/{http-2xz43gz3.js → http-809fvtz2.js} +1934 -886
  56. package/dist/{index-wz1ea15p.js → index-2dgfj9av.js} +3 -3
  57. package/dist/{index-nhat35em.js → index-47em3d2n.js} +14 -11
  58. package/dist/{index-5ghxn8s6.js → index-9fqaktjr.js} +12 -9
  59. package/dist/{index-0gzmqj4k.js → index-9ht8gr56.js} +13 -10
  60. package/dist/{index-vfedgdw6.js → index-rm85cfdx.js} +368 -40
  61. package/dist/{index-ykvs43z4.js → index-t2snazez.js} +4 -4
  62. package/dist/{keepalive-62gfj91d.js → keepalive-3cps6e3e.js} +7 -6
  63. package/dist/{lead-ag0akn0v.js → lead-fkk95wgq.js} +36 -32
  64. package/dist/{maintenance-xxrrzk5x.js → maintenance-fweqh0yj.js} +6 -6
  65. package/dist/{mistral-conversations-26yrnbf1.js → mistral-conversations-7gqnz2wt.js} +7 -7
  66. package/dist/oauth-refresh-sweep-sycqtbph.js +114 -0
  67. package/dist/{onboard-64zyfcbs.js → onboard-cy0rkgvx.js} +2 -2
  68. package/dist/{opencode-adapter-mqsxr170.js → opencode-adapter-p6bxnbm4.js} +2 -2
  69. package/dist/{otel-zjd9zaxs.js → otel-g299k41b.js} +2 -2
  70. package/dist/{otel-impl-b86qseaa.js → otel-impl-wbns8hf7.js} +1 -1
  71. package/dist/{pi-mono-adapter-kxjxpfgy.js → pi-mono-adapter-05sck1ds.js} +17 -109
  72. package/dist/{pricing-refresh-cgtkffpa.js → pricing-refresh-0hvazmkf.js} +6 -6
  73. package/dist/rbac-roles-kfesjy9y.js +28 -0
  74. package/dist/rbac-roles-mwcwhbxc.js +283 -0
  75. package/dist/{seed-pricing-1r1y1xkq.js → seed-pricing-h2kdq4cq.js} +5 -5
  76. package/dist/{setup-mkcgkjf7.js → setup-8506q97w.js} +2 -2
  77. package/dist/{worker-351sze63.js → worker-4r5c6zyb.js} +36 -32
  78. package/openapi.json +1327 -20
  79. package/package.json +3 -3
  80. package/src/agentmail/handlers.ts +16 -6
  81. package/src/agentmail/templates.ts +25 -5
  82. package/src/be/audit-user.ts +28 -4
  83. package/src/be/db-queries/oauth.ts +42 -0
  84. package/src/be/db.ts +314 -28
  85. package/src/be/identity.ts +79 -0
  86. package/src/be/mcp-proxy.ts +132 -0
  87. package/src/be/migrations/109_rbac_roles.sql +79 -0
  88. package/src/be/migrations/111_oauth_credential_bindings.sql +6 -0
  89. package/src/be/migrations/112_script_connections_graphql.sql +96 -0
  90. package/src/be/migrations/113_task_routing_affinity.sql +9 -0
  91. package/src/be/migrations/114_backfill_gpt_5_6_pricing.sql +15 -0
  92. package/src/be/modelsdev-cache.json +135 -0
  93. package/src/be/oauth-credential-bindings.ts +35 -0
  94. package/src/be/oauth-refresh-sweep.ts +159 -0
  95. package/src/be/rbac-audit.ts +52 -13
  96. package/src/be/rbac-roles.ts +424 -0
  97. package/src/be/script-connections.ts +459 -40
  98. package/src/be/script-credential-broker.ts +29 -3
  99. package/src/be/scripts/typecheck.ts +51 -7
  100. package/src/be/seed-scripts/catalog/catalog-report.inline.ts +325 -24
  101. package/src/be/seed-scripts/catalog/catalog-report.ts +325 -24
  102. package/src/be/users.ts +24 -0
  103. package/src/cli.tsx +17 -0
  104. package/src/github/handlers.ts +10 -9
  105. package/src/github/templates.ts +45 -9
  106. package/src/gitlab/handlers.ts +20 -8
  107. package/src/gitlab/templates.ts +18 -6
  108. package/src/heartbeat/heartbeat.ts +143 -21
  109. package/src/http/all-routes.ts +3 -0
  110. package/src/http/approval-requests.ts +12 -0
  111. package/src/http/config.ts +44 -10
  112. package/src/http/core.ts +30 -3
  113. package/src/http/favorites.ts +1 -5
  114. package/src/http/index.ts +25 -1
  115. package/src/http/mcp-oauth.ts +2 -0
  116. package/src/http/mcp-servers.ts +44 -3
  117. package/src/http/oauth-generic.ts +105 -0
  118. package/src/http/poll.ts +20 -3
  119. package/src/http/script-connection-proxy.ts +116 -0
  120. package/src/http/script-connections.ts +1657 -0
  121. package/src/http/scripts.ts +27 -7
  122. package/src/http/skills.ts +11 -0
  123. package/src/http/tasks.ts +27 -13
  124. package/src/http/x.ts +6 -2
  125. package/src/integrations/kapso/inbound.ts +23 -6
  126. package/src/jira/sync.ts +111 -17
  127. package/src/jira/templates.ts +10 -2
  128. package/src/mcp-client/http-client.ts +194 -0
  129. package/src/oauth/app-validation.ts +16 -0
  130. package/src/oauth/ensure-token.ts +69 -14
  131. package/src/oauth/mcp-wrapper.ts +15 -0
  132. package/src/oauth/wrapper.ts +48 -20
  133. package/src/prompts/session-templates.ts +3 -3
  134. package/src/providers/codex-models.ts +100 -30
  135. package/src/providers/modelsdev-reasoning.json +51 -0
  136. package/src/providers/pi-mono-mcp-client.ts +2 -133
  137. package/src/providers/reasoning-effort.ts +11 -5
  138. package/src/rbac/admission.ts +68 -0
  139. package/src/rbac/index.ts +2 -0
  140. package/src/rbac/legacy-policy.ts +12 -0
  141. package/src/rbac/permissions.ts +48 -0
  142. package/src/scheduler/scheduler.ts +9 -1
  143. package/src/scripts-runtime/api-client.ts +151 -19
  144. package/src/scripts-runtime/api-types.ts +38 -6
  145. package/src/scripts-runtime/credential-broker/default-bindings.ts +1 -0
  146. package/src/scripts-runtime/credential-broker/types.ts +8 -0
  147. package/src/scripts-runtime/ctx.ts +11 -1
  148. package/src/scripts-runtime/eval-harness.ts +5 -1
  149. package/src/scripts-runtime/executors/types.ts +2 -1
  150. package/src/scripts-runtime/loader.ts +3 -1
  151. package/src/scripts-runtime/mcp-client.ts +87 -0
  152. package/src/scripts-runtime/sdk-allowlist.ts +1 -0
  153. package/src/scripts-runtime/types/stdlib.d.ts +6 -0
  154. package/src/scripts-runtime/types/swarm-sdk.d.ts +19 -0
  155. package/src/server-user.ts +114 -59
  156. package/src/server.ts +12 -0
  157. package/src/slack/assistant.ts +14 -6
  158. package/src/slack/enrich.ts +17 -0
  159. package/src/slack/handlers.ts +8 -22
  160. package/src/slack/responses.ts +91 -1
  161. package/src/slack/thread-buffer.ts +6 -3
  162. package/src/slack/watcher.ts +9 -6
  163. package/src/tasks/worker-follow-up.ts +100 -3
  164. package/src/tests/agentmail-handlers.test.ts +32 -3
  165. package/src/tests/approval-requests.test.ts +61 -0
  166. package/src/tests/audit-user.test.ts +47 -1
  167. package/src/tests/codex-adapter.test.ts +46 -23
  168. package/src/tests/credential-broker.test.ts +5 -2
  169. package/src/tests/delete-page-tool.test.ts +197 -0
  170. package/src/tests/github-handlers.test.ts +28 -2
  171. package/src/tests/gitlab-handlers.test.ts +125 -0
  172. package/src/tests/heartbeat.test.ts +48 -3
  173. package/src/tests/identity.test.ts +117 -0
  174. package/src/tests/jira-sync.test.ts +99 -1
  175. package/src/tests/kapso-inbound.test.ts +7 -0
  176. package/src/tests/mcp-tools-user.test.ts +60 -6
  177. package/src/tests/model-control.test.ts +8 -2
  178. package/src/tests/oauth-credential-bindings.test.ts +490 -0
  179. package/src/tests/oauth-refresh-sweep.test.ts +160 -0
  180. package/src/tests/pool-affinity.test.ts +401 -0
  181. package/src/tests/prompt-template-remaining.test.ts +2 -2
  182. package/src/tests/rbac-admission-e2e.test.ts +386 -0
  183. package/src/tests/rbac-admission.test.ts +492 -0
  184. package/src/tests/rbac-e2e-helpers.ts +3 -0
  185. package/src/tests/rbac-engine.test.ts +50 -0
  186. package/src/tests/rbac-mcp-admission-e2e.test.ts +362 -0
  187. package/src/tests/rbac-roles.test.ts +328 -0
  188. package/src/tests/reasoning-effort.test.ts +15 -2
  189. package/src/tests/schedule-target-type.test.ts +50 -0
  190. package/src/tests/scheduled-tasks.test.ts +40 -0
  191. package/src/tests/script-apis-mcp.test.ts +37 -13
  192. package/src/tests/script-connections-http.test.ts +1158 -0
  193. package/src/tests/script-connections-mcp.test.ts +894 -0
  194. package/src/tests/script-connections.test.ts +754 -6
  195. package/src/tests/scripts-external-api.test.ts +49 -1
  196. package/src/tests/scripts-runtime-secret-egress.test.ts +1 -0
  197. package/src/tests/scripts-runtime.test.ts +291 -0
  198. package/src/tests/seed-scripts.test.ts +36 -0
  199. package/src/tests/slack-identity-resolution.test.ts +37 -3
  200. package/src/tests/slack-inline-output.test.ts +65 -0
  201. package/src/tests/slack-thread-buffer.test.ts +28 -0
  202. package/src/tests/tool-annotations.test.ts +1 -0
  203. package/src/tests/user-token-rest-auth.test.ts +140 -1
  204. package/src/tests/workflow-swarm-script.test.ts +44 -0
  205. package/src/tests/workflow-triggers-v2.test.ts +300 -1
  206. package/src/tools/credential-bindings/tool.ts +277 -18
  207. package/src/tools/delete-page.ts +145 -0
  208. package/src/tools/mcp-servers/mcp-server-delete.ts +7 -4
  209. package/src/tools/request-human-input.ts +2 -0
  210. package/src/tools/resolve-user.ts +77 -27
  211. package/src/tools/script-apis.ts +62 -3
  212. package/src/tools/script-connections/tool.ts +275 -31
  213. package/src/tools/send-task.ts +15 -1
  214. package/src/tools/task-action.ts +34 -2
  215. package/src/tools/templates.ts +53 -1
  216. package/src/tools/tool-config.ts +2 -1
  217. package/src/tools/utils.ts +2 -0
  218. package/src/tools/workflows/create-workflow.ts +8 -1
  219. package/src/tools/workflows/update-workflow.ts +11 -2
  220. package/src/types.ts +90 -11
  221. package/src/utils/scoped-resource.ts +25 -0
  222. package/src/workflows/executors/human-in-the-loop.ts +1 -0
  223. package/src/workflows/executors/swarm-script.ts +7 -1
  224. package/src/workflows/triggers.ts +146 -18
  225. package/templates/skills/swarm-scripts/content.md +33 -8
  226. package/dist/cli-bgef578c.js +0 -771
  227. package/dist/cli-c9mtm09b.js +0 -43
  228. package/dist/cli-ceewqajw.js +0 -1698
  229. package/dist/{anthropic-messages-8nc6r2dw.js → anthropic-messages-kq3pjhbp.js} +6 -6
  230. package/dist/{azure-openai-responses-6yfzw3qw.js → azure-openai-responses-gm1ejtmn.js} +7 -7
  231. package/dist/{cli-vbraamxr.js → cli-6b4wwmm8.js} +9 -9
  232. package/dist/{cli-2r7s8jhp.js → cli-sznberje.js} +3 -3
  233. package/dist/{google-generative-ai-vfq6ymz9.js → google-generative-ai-hyrra2jm.js} +3 -3
  234. package/dist/{google-vertex-mj5y5rfj.js → google-vertex-8bs2pzgn.js} +3 -3
  235. package/dist/{openai-codex-responses-8h6kvfv8.js → openai-codex-responses-3nkvje79.js} +4 -4
  236. package/dist/{openai-completions-x6kjnqjd.js → openai-completions-0asftkwg.js} +11 -11
  237. package/dist/{openai-responses-7jwyz3ds.js → openai-responses-pmatek45.js} +10 -10
@@ -0,0 +1,490 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import {
4
+ createServer as createHttpServer,
5
+ type IncomingMessage,
6
+ type Server,
7
+ type ServerResponse,
8
+ } from "node:http";
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { closeDb, createAgent, getDb, initDb } from "../be/db";
11
+ import {
12
+ getOAuthApp,
13
+ getOAuthTokens,
14
+ storeOAuthTokens,
15
+ upsertOAuthApp,
16
+ } from "../be/db-queries/oauth";
17
+ import { getOAuthProviderConfig } from "../be/oauth-credential-bindings";
18
+ import {
19
+ listRelationalCredentialBindings,
20
+ upsertCredentialBinding,
21
+ } from "../be/script-connections";
22
+ import { buildScriptCredentialBindings } from "../be/script-credential-broker";
23
+ import { handleGenericOAuth } from "../http/oauth-generic";
24
+ import { getPathSegments, parseQueryParams } from "../http/utils";
25
+ import { _clearPendingStates, buildAuthorizationUrl } from "../oauth/wrapper";
26
+ import { registerCredentialBindingsTool } from "../tools/credential-bindings";
27
+
28
+ const TEST_DB_PATH = "./test-oauth-credential-bindings.sqlite";
29
+ const LEAD_ID = "aaaa9000-0000-4000-8000-000000000001";
30
+ const originalFetch = globalThis.fetch;
31
+ const savedEnv = { ...process.env };
32
+
33
+ type RegisteredTool = {
34
+ handler: (args: unknown, extra: unknown) => Promise<unknown>;
35
+ };
36
+
37
+ type ToolResult = {
38
+ structuredContent: {
39
+ success: boolean;
40
+ message: string;
41
+ bindings: Array<{
42
+ configKey: string;
43
+ authKind?: "config" | "oauth";
44
+ oauthProvider?: string;
45
+ tokenStatus?: "ok" | "expiring" | "missing";
46
+ }>;
47
+ };
48
+ };
49
+
50
+ function testApp(provider: string, tokenUrl = "https://oauth.example.test/token") {
51
+ return {
52
+ clientId: `${provider}-client`,
53
+ clientSecret: `${provider}-secret`,
54
+ authorizeUrl: "https://oauth.example.test/authorize",
55
+ tokenUrl,
56
+ redirectUri: `https://api.public.test/api/oauth/${provider}/callback`,
57
+ scopes: "read,write",
58
+ };
59
+ }
60
+
61
+ function credentialBindingsTool() {
62
+ const server = new McpServer({ name: "oauth-credential-bindings-test", version: "1.0.0" });
63
+ registerCredentialBindingsTool(server);
64
+ const registered = (server as unknown as { _registeredTools: Record<string, RegisteredTool> })
65
+ ._registeredTools;
66
+ const tool = registered["credential-bindings"];
67
+ if (!tool) throw new Error("credential-bindings tool not registered");
68
+ return tool;
69
+ }
70
+
71
+ function meta(agentId = LEAD_ID) {
72
+ return {
73
+ sessionId: "oauth-credential-bindings-test-session",
74
+ requestInfo: { headers: { "x-agent-id": agentId } },
75
+ };
76
+ }
77
+
78
+ function createOAuthServer(): Server {
79
+ return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
80
+ const handled = await handleGenericOAuth(
81
+ req,
82
+ res,
83
+ getPathSegments(req.url || ""),
84
+ parseQueryParams(req.url || ""),
85
+ );
86
+ if (!handled) {
87
+ res.writeHead(404);
88
+ res.end("not found");
89
+ }
90
+ });
91
+ }
92
+
93
+ async function listen(server: Server): Promise<number> {
94
+ await new Promise<void>((resolve) => server.listen(0, resolve));
95
+ const address = server.address();
96
+ if (!address || typeof address === "string") throw new Error("test server did not bind");
97
+ return address.port;
98
+ }
99
+
100
+ function cleanupRows() {
101
+ const db = getDb();
102
+ db.run("DELETE FROM script_credential_bindings WHERE config_key LIKE 'PHASE2_%'");
103
+ db.run("DELETE FROM oauth_tokens WHERE provider LIKE 'phase2-%'");
104
+ db.run("DELETE FROM oauth_apps WHERE provider LIKE 'phase2-%'");
105
+ }
106
+
107
+ beforeAll(async () => {
108
+ await unlink(TEST_DB_PATH).catch(() => {});
109
+ await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
110
+ await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
111
+ initDb(TEST_DB_PATH);
112
+ createAgent({ id: LEAD_ID, name: "OAuth Credential Lead", isLead: true, status: "idle" });
113
+ });
114
+
115
+ beforeEach(() => {
116
+ cleanupRows();
117
+ _clearPendingStates();
118
+ process.env.PUBLIC_MCP_BASE_URL = "https://api.public.test";
119
+ globalThis.fetch = originalFetch;
120
+ });
121
+
122
+ afterEach(() => {
123
+ cleanupRows();
124
+ _clearPendingStates();
125
+ globalThis.fetch = originalFetch;
126
+ for (const key of Object.keys(process.env)) {
127
+ if (!(key in savedEnv)) delete process.env[key];
128
+ }
129
+ for (const [key, value] of Object.entries(savedEnv)) {
130
+ if (value === undefined) delete process.env[key];
131
+ else process.env[key] = value;
132
+ }
133
+ });
134
+
135
+ afterAll(async () => {
136
+ globalThis.fetch = originalFetch;
137
+ closeDb();
138
+ await unlink(TEST_DB_PATH).catch(() => {});
139
+ await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
140
+ await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
141
+ });
142
+
143
+ describe("OAuth credential bindings", () => {
144
+ test("migration columns round-trip through credential binding persistence", () => {
145
+ const columns = getDb()
146
+ .query<{ name: string }, []>("PRAGMA table_info(script_credential_bindings)")
147
+ .all()
148
+ .map((column) => column.name);
149
+ expect(columns).toContain("auth_kind");
150
+ expect(columns).toContain("oauth_provider");
151
+
152
+ const binding = upsertCredentialBinding({
153
+ configKey: "PHASE2_ROUNDTRIP_OAUTH",
154
+ allowedHosts: ["api.vendor.test"],
155
+ headerTemplate: "Authorization: Bearer [REDACTED:PHASE2_ROUNDTRIP_OAUTH]",
156
+ authKind: "oauth",
157
+ oauthProvider: "phase2-roundtrip",
158
+ });
159
+
160
+ expect(binding.authKind).toBe("oauth");
161
+ expect(binding.oauthProvider).toBe("phase2-roundtrip");
162
+
163
+ const listed = listRelationalCredentialBindings({ includeInactive: true }).find(
164
+ (item) => item.id === binding.id,
165
+ );
166
+ expect(listed?.authKind).toBe("oauth");
167
+ expect(listed?.oauthProvider).toBe("phase2-roundtrip");
168
+ });
169
+
170
+ test("OAuth binding resolves through the stored access token", async () => {
171
+ upsertOAuthApp("phase2-resolve", testApp("phase2-resolve"));
172
+ storeOAuthTokens("phase2-resolve", {
173
+ accessToken: "stored-oauth-access",
174
+ refreshToken: "stored-oauth-refresh",
175
+ expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
176
+ });
177
+ upsertCredentialBinding({
178
+ configKey: "PHASE2_RESOLVE_OAUTH",
179
+ allowedHosts: ["api.vendor.test"],
180
+ headerTemplate: "Authorization: Bearer [REDACTED:PHASE2_RESOLVE_OAUTH]",
181
+ authKind: "oauth",
182
+ oauthProvider: "phase2-resolve",
183
+ });
184
+ process.env.PHASE2_RESOLVE_OAUTH = "env-must-not-win";
185
+
186
+ const bindings = await buildScriptCredentialBindings({});
187
+
188
+ expect(bindings).toContainEqual(
189
+ expect.objectContaining({
190
+ configKey: "PHASE2_RESOLVE_OAUTH",
191
+ value: "stored-oauth-access",
192
+ }),
193
+ );
194
+ });
195
+
196
+ test("expiring OAuth binding token is refreshed before resolution", async () => {
197
+ upsertOAuthApp("phase2-refresh", testApp("phase2-refresh"));
198
+ storeOAuthTokens("phase2-refresh", {
199
+ accessToken: "old-access-token",
200
+ refreshToken: "old-refresh-token",
201
+ expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
202
+ });
203
+ upsertCredentialBinding({
204
+ configKey: "PHASE2_REFRESH_OAUTH",
205
+ allowedHosts: ["api.vendor.test"],
206
+ headerTemplate: "Authorization: Bearer [REDACTED:PHASE2_REFRESH_OAUTH]",
207
+ authKind: "oauth",
208
+ oauthProvider: "phase2-refresh",
209
+ });
210
+
211
+ const fetchSpy = mock((input: string | URL | Request, init?: RequestInit) => {
212
+ const url = input instanceof Request ? input.url : input.toString();
213
+ if (url !== "https://oauth.example.test/token") {
214
+ return originalFetch(input, init);
215
+ }
216
+ expect(init?.method).toBe("POST");
217
+ expect(String(init?.body)).toContain("grant_type=refresh_token");
218
+ expect(String(init?.body)).toContain("refresh_token=old-refresh-token");
219
+ return Promise.resolve(
220
+ Response.json({
221
+ access_token: "new-access-token",
222
+ token_type: "Bearer",
223
+ expires_in: 3600,
224
+ refresh_token: "new-refresh-token",
225
+ }),
226
+ );
227
+ });
228
+ globalThis.fetch = fetchSpy as typeof fetch;
229
+
230
+ const bindings = await buildScriptCredentialBindings({});
231
+
232
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
233
+ expect(bindings).toContainEqual(
234
+ expect.objectContaining({
235
+ configKey: "PHASE2_REFRESH_OAUTH",
236
+ value: "new-access-token",
237
+ }),
238
+ );
239
+ expect(getOAuthTokens("phase2-refresh")?.refreshToken).toBe("new-refresh-token");
240
+ });
241
+
242
+ test("basic tokenAuthStyle + json tokenBodyFormat reach the token endpoint (Notion-style)", async () => {
243
+ upsertOAuthApp("phase2-basic", {
244
+ ...testApp("phase2-basic"),
245
+ metadata: JSON.stringify({ tokenAuthStyle: "basic", tokenBodyFormat: "json" }),
246
+ });
247
+ storeOAuthTokens("phase2-basic", {
248
+ accessToken: "old-basic-access",
249
+ refreshToken: "old-basic-refresh",
250
+ expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
251
+ });
252
+ upsertCredentialBinding({
253
+ configKey: "PHASE2_BASIC_OAUTH",
254
+ allowedHosts: ["api.vendor.test"],
255
+ headerTemplate: "Authorization: Bearer [REDACTED:PHASE2_BASIC_OAUTH]",
256
+ authKind: "oauth",
257
+ oauthProvider: "phase2-basic",
258
+ });
259
+
260
+ const config = getOAuthProviderConfig("phase2-basic");
261
+ expect(config?.tokenAuthStyle).toBe("basic");
262
+ expect(config?.tokenBodyFormat).toBe("json");
263
+
264
+ const expectedBasic = `Basic ${Buffer.from("phase2-basic-client:phase2-basic-secret").toString("base64")}`;
265
+ const fetchSpy = mock((input: string | URL | Request, init?: RequestInit) => {
266
+ const url = input instanceof Request ? input.url : input.toString();
267
+ if (url !== "https://oauth.example.test/token") {
268
+ return originalFetch(input, init);
269
+ }
270
+ const headers = new Headers(init?.headers);
271
+ expect(headers.get("authorization")).toBe(expectedBasic);
272
+ expect(headers.get("content-type")).toBe("application/json");
273
+ const body = JSON.parse(String(init?.body)) as Record<string, string>;
274
+ expect(body.grant_type).toBe("refresh_token");
275
+ expect(body.refresh_token).toBe("old-basic-refresh");
276
+ // Basic auth carries the client credentials — they must NOT be in the body
277
+ expect(body.client_id).toBeUndefined();
278
+ expect(body.client_secret).toBeUndefined();
279
+ return Promise.resolve(
280
+ Response.json({
281
+ access_token: "new-basic-access",
282
+ token_type: "Bearer",
283
+ expires_in: 3600,
284
+ refresh_token: "new-basic-refresh",
285
+ }),
286
+ );
287
+ });
288
+ globalThis.fetch = fetchSpy as typeof fetch;
289
+
290
+ const bindings = await buildScriptCredentialBindings({});
291
+
292
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
293
+ expect(bindings).toContainEqual(
294
+ expect.objectContaining({
295
+ configKey: "PHASE2_BASIC_OAUTH",
296
+ value: "new-basic-access",
297
+ }),
298
+ );
299
+ });
300
+
301
+ test("generic OAuth authorize URL uses space-separated scopes", async () => {
302
+ upsertOAuthApp("phase2-scopes", testApp("phase2-scopes"));
303
+ const config = getOAuthProviderConfig("phase2-scopes");
304
+ expect(config).not.toBeNull();
305
+ if (!config) throw new Error("missing provider config");
306
+ const { url } = await buildAuthorizationUrl(config);
307
+ expect(url).toContain("scope=read+write");
308
+ expect(url).not.toContain("scope=read%2Cwrite");
309
+ });
310
+
311
+ test("credential-bindings tool rejects reserved tracker OAuth providers", async () => {
312
+ const result = (await credentialBindingsTool().handler(
313
+ {
314
+ action: "oauth-app-upsert",
315
+ provider: "jira",
316
+ clientId: "jira-client",
317
+ clientSecret: "jira-secret",
318
+ authorizeUrl: "https://oauth.example.test/authorize",
319
+ tokenUrl: "https://oauth.example.test/token",
320
+ scopes: [],
321
+ },
322
+ meta(),
323
+ )) as ToolResult;
324
+
325
+ expect(result.structuredContent.success).toBe(false);
326
+ expect(result.structuredContent.message).toContain("dedicated tracker");
327
+ expect(getOAuthApp("jira")).toBeNull();
328
+ });
329
+
330
+ test("credential-bindings tool validates OAuth app URLs in production", async () => {
331
+ process.env.NODE_ENV = "production";
332
+
333
+ const rejected = (await credentialBindingsTool().handler(
334
+ {
335
+ action: "oauth-app-upsert",
336
+ provider: "phase2-tool-unsafe",
337
+ clientId: "unsafe-client",
338
+ clientSecret: "unsafe-secret",
339
+ authorizeUrl: "https://oauth.example.test/authorize",
340
+ tokenUrl: "http://127.0.0.1/token",
341
+ scopes: [],
342
+ },
343
+ meta(),
344
+ )) as ToolResult;
345
+
346
+ expect(rejected.structuredContent.success).toBe(false);
347
+ expect(rejected.structuredContent.message).toMatch(/private IPv4|insecure/);
348
+ expect(getOAuthApp("phase2-tool-unsafe")).toBeNull();
349
+
350
+ const accepted = (await credentialBindingsTool().handler(
351
+ {
352
+ action: "oauth-app-upsert",
353
+ provider: "phase2-tool-safe",
354
+ clientId: "safe-client",
355
+ clientSecret: "safe-secret",
356
+ authorizeUrl: "https://oauth.example.test/authorize",
357
+ tokenUrl: "https://oauth.example.test/token",
358
+ scopes: ["read"],
359
+ },
360
+ meta(),
361
+ )) as ToolResult;
362
+
363
+ expect(accepted.structuredContent.success).toBe(true);
364
+ expect(getOAuthApp("phase2-tool-safe")?.clientId).toBe("safe-client");
365
+ });
366
+
367
+ test("failed OAuth token refresh skips only that binding, others still resolve", async () => {
368
+ upsertOAuthApp("phase2-broken", testApp("phase2-broken"));
369
+ storeOAuthTokens("phase2-broken", {
370
+ accessToken: "stale-access-token",
371
+ refreshToken: "stale-refresh-token",
372
+ expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
373
+ });
374
+ upsertCredentialBinding({
375
+ configKey: "PHASE2_BROKEN_OAUTH",
376
+ allowedHosts: ["api.vendor.test"],
377
+ headerTemplate: "Authorization: Bearer [REDACTED:PHASE2_BROKEN_OAUTH]",
378
+ authKind: "oauth",
379
+ oauthProvider: "phase2-broken",
380
+ });
381
+ upsertCredentialBinding({
382
+ configKey: "PHASE2_HEALTHY_CONFIG",
383
+ allowedHosts: ["api.vendor.test"],
384
+ headerTemplate: "Authorization: Bearer [REDACTED:PHASE2_HEALTHY_CONFIG]",
385
+ });
386
+ process.env.PHASE2_HEALTHY_CONFIG = "healthy-config-value";
387
+
388
+ const fetchSpy = mock((input: string | URL | Request, init?: RequestInit) => {
389
+ const url = input instanceof Request ? input.url : input.toString();
390
+ if (url !== "https://oauth.example.test/token") {
391
+ return originalFetch(input, init);
392
+ }
393
+ return Promise.resolve(Response.json({ error: "invalid_grant" }, { status: 400 }));
394
+ });
395
+ globalThis.fetch = fetchSpy as typeof fetch;
396
+
397
+ const bindings = await buildScriptCredentialBindings({});
398
+
399
+ expect(bindings.some((binding) => binding.configKey === "PHASE2_BROKEN_OAUTH")).toBe(false);
400
+ expect(bindings).toContainEqual(
401
+ expect.objectContaining({
402
+ configKey: "PHASE2_HEALTHY_CONFIG",
403
+ value: "healthy-config-value",
404
+ }),
405
+ );
406
+ delete process.env.PHASE2_HEALTHY_CONFIG;
407
+ });
408
+
409
+ test("missing OAuth token skips binding resolution and list reports missing", async () => {
410
+ upsertOAuthApp("phase2-missing", testApp("phase2-missing"));
411
+ upsertCredentialBinding({
412
+ configKey: "PHASE2_MISSING_OAUTH",
413
+ allowedHosts: ["api.vendor.test"],
414
+ headerTemplate: "Authorization: Bearer [REDACTED:PHASE2_MISSING_OAUTH]",
415
+ authKind: "oauth",
416
+ oauthProvider: "phase2-missing",
417
+ });
418
+ process.env.PHASE2_MISSING_OAUTH = "env-must-not-win";
419
+
420
+ const bindings = await buildScriptCredentialBindings({});
421
+ expect(bindings.some((binding) => binding.configKey === "PHASE2_MISSING_OAUTH")).toBe(false);
422
+
423
+ const result = (await credentialBindingsTool().handler(
424
+ { action: "list" },
425
+ meta(),
426
+ )) as ToolResult;
427
+ const listed = result.structuredContent.bindings.find(
428
+ (binding) => binding.configKey === "PHASE2_MISSING_OAUTH",
429
+ );
430
+ expect(result.structuredContent.success).toBe(true);
431
+ expect(listed?.tokenStatus).toBe("missing");
432
+ });
433
+
434
+ test("generic OAuth callback exchanges code and stores tokens", async () => {
435
+ const provider = "phase2-callback";
436
+ upsertOAuthApp(provider, testApp(provider));
437
+ const config = getOAuthProviderConfig(provider);
438
+ if (!config) throw new Error("missing test oauth config");
439
+ const { state } = await buildAuthorizationUrl(config);
440
+
441
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
442
+ const url = input instanceof Request ? input.url : input.toString();
443
+ if (url !== "https://oauth.example.test/token") {
444
+ return originalFetch(input, init);
445
+ }
446
+ expect(init?.method).toBe("POST");
447
+ expect(String(init?.body)).toContain("grant_type=authorization_code");
448
+ expect(String(init?.body)).toContain("code=callback-code");
449
+ return Response.json({
450
+ access_token: "callback-access-token",
451
+ token_type: "Bearer",
452
+ expires_in: 3600,
453
+ refresh_token: "callback-refresh-token",
454
+ scope: "read write",
455
+ });
456
+ }) as typeof fetch;
457
+
458
+ const server = createOAuthServer();
459
+ const port = await listen(server);
460
+ try {
461
+ const res = await fetch(
462
+ `http://localhost:${port}/api/oauth/${provider}/callback?code=callback-code&state=${state}`,
463
+ );
464
+
465
+ expect(res.status).toBe(200);
466
+ expect(await res.text()).toContain("You can close this tab.");
467
+ const tokens = getOAuthTokens(provider);
468
+ expect(tokens?.accessToken).toBe("callback-access-token");
469
+ expect(tokens?.refreshToken).toBe("callback-refresh-token");
470
+ } finally {
471
+ server.close();
472
+ }
473
+ });
474
+
475
+ test("generic OAuth callback rejects linear", async () => {
476
+ const server = createOAuthServer();
477
+ const port = await listen(server);
478
+ try {
479
+ const res = await fetch(
480
+ `http://localhost:${port}/api/oauth/linear/callback?code=callback-code&state=state`,
481
+ );
482
+
483
+ expect(res.status).toBe(409);
484
+ const body = (await res.json()) as { error: string };
485
+ expect(body.error).toContain("/api/trackers/linear/callback");
486
+ } finally {
487
+ server.close();
488
+ }
489
+ });
490
+ });
@@ -0,0 +1,160 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb, getDb, initDb } from "../be/db";
4
+ import { getOAuthTokens, storeOAuthTokens, upsertOAuthApp } from "../be/db-queries/oauth";
5
+ import { sweepOAuthTokenRefresh } from "../be/oauth-refresh-sweep";
6
+
7
+ const TEST_DB_PATH = "./test-oauth-refresh-sweep.sqlite";
8
+
9
+ const originalFetch = globalThis.fetch;
10
+
11
+ function appConfig(provider: string) {
12
+ return {
13
+ clientId: `${provider}-client-id`,
14
+ clientSecret: `${provider}-client-secret`,
15
+ authorizeUrl: `https://oauth.${provider}.test/authorize`,
16
+ tokenUrl: `https://oauth.${provider}.test/token`,
17
+ redirectUri: "http://localhost:3013/callback",
18
+ scopes: "read,write",
19
+ };
20
+ }
21
+
22
+ function seedTokens(
23
+ provider: string,
24
+ opts: { expiresInMs: number; refreshToken?: string | null } = { expiresInMs: 3_600_000 },
25
+ ): void {
26
+ storeOAuthTokens(provider, {
27
+ accessToken: `${provider}-old-access-token`,
28
+ refreshToken: opts.refreshToken === undefined ? `${provider}-refresh-token` : opts.refreshToken,
29
+ expiresAt: new Date(Date.now() + opts.expiresInMs).toISOString(),
30
+ scope: "read,write",
31
+ });
32
+ }
33
+
34
+ function backdateTokenRow(provider: string, ageMs: number): void {
35
+ const backdated = new Date(Date.now() - ageMs).toISOString();
36
+ getDb()
37
+ .query("UPDATE oauth_tokens SET updatedAt = ? WHERE provider = ?")
38
+ .run(backdated, provider);
39
+ }
40
+
41
+ type CapturedTokenRequest = { url: string; body: string };
42
+
43
+ /**
44
+ * Mock the global fetch as a token endpoint. Responds 200 with a fresh token
45
+ * for every URL except those listed in `failUrls` (which get a 500).
46
+ */
47
+ function mockTokenEndpoint(failUrls: string[] = []): CapturedTokenRequest[] {
48
+ const captured: CapturedTokenRequest[] = [];
49
+ globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
50
+ const url = String(input);
51
+ captured.push({ url, body: typeof init?.body === "string" ? init.body : "" });
52
+ if (failUrls.includes(url)) {
53
+ return new Response("upstream broke", { status: 500 });
54
+ }
55
+ return new Response(
56
+ JSON.stringify({
57
+ access_token: "new-access-token",
58
+ token_type: "bearer",
59
+ expires_in: 3600,
60
+ refresh_token: "new-refresh-token",
61
+ scope: "read,write",
62
+ }),
63
+ { status: 200, headers: { "content-type": "application/json" } },
64
+ );
65
+ }) as typeof fetch;
66
+ return captured;
67
+ }
68
+
69
+ beforeAll(() => {
70
+ initDb(TEST_DB_PATH);
71
+ });
72
+
73
+ beforeEach(() => {
74
+ globalThis.fetch = originalFetch;
75
+ getDb().run("DELETE FROM oauth_refresh_locks");
76
+ getDb().run("DELETE FROM oauth_tokens");
77
+ getDb().run("DELETE FROM oauth_apps");
78
+ });
79
+
80
+ afterAll(async () => {
81
+ globalThis.fetch = originalFetch;
82
+ closeDb();
83
+ for (const suffix of ["", "-wal", "-shm"]) {
84
+ await unlink(TEST_DB_PATH + suffix).catch(() => {});
85
+ }
86
+ });
87
+
88
+ describe("sweepOAuthTokenRefresh", () => {
89
+ test("refreshes a row whose access token expires within 30 minutes", async () => {
90
+ upsertOAuthApp("vendor_a", appConfig("vendor_a"));
91
+ seedTokens("vendor_a", { expiresInMs: 10 * 60 * 1000 }); // expires in 10 min
92
+
93
+ const captured = mockTokenEndpoint();
94
+ const result = await sweepOAuthTokenRefresh();
95
+
96
+ expect(result).toEqual({ checked: 1, refreshed: 1, skipped: 0, failed: [] });
97
+ expect(captured).toHaveLength(1);
98
+ expect(captured[0]?.url).toBe("https://oauth.vendor_a.test/token");
99
+ expect(captured[0]?.body).toContain("grant_type=refresh_token");
100
+ expect(getOAuthTokens("vendor_a")?.accessToken).toBe("new-access-token");
101
+ });
102
+
103
+ test("skips rows with no refresh token", async () => {
104
+ upsertOAuthApp("vendor_a", appConfig("vendor_a"));
105
+ seedTokens("vendor_a", { expiresInMs: 10 * 60 * 1000, refreshToken: null });
106
+
107
+ const captured = mockTokenEndpoint();
108
+ const result = await sweepOAuthTokenRefresh();
109
+
110
+ expect(result).toEqual({ checked: 1, refreshed: 0, skipped: 1, failed: [] });
111
+ expect(captured).toHaveLength(0);
112
+ expect(getOAuthTokens("vendor_a")?.accessToken).toBe("vendor_a-old-access-token");
113
+ });
114
+
115
+ test("skips fresh rows that are neither expiring nor stale", async () => {
116
+ upsertOAuthApp("vendor_a", appConfig("vendor_a"));
117
+ seedTokens("vendor_a", { expiresInMs: 24 * 60 * 60 * 1000 }); // expires in 24h, just updated
118
+
119
+ const captured = mockTokenEndpoint();
120
+ const result = await sweepOAuthTokenRefresh();
121
+
122
+ expect(result).toEqual({ checked: 1, refreshed: 0, skipped: 1, failed: [] });
123
+ expect(captured).toHaveLength(0);
124
+ });
125
+
126
+ test("keep-alives a stale row even when the access token is far from expiry", async () => {
127
+ upsertOAuthApp("vendor_a", appConfig("vendor_a"));
128
+ seedTokens("vendor_a", { expiresInMs: 30 * 24 * 60 * 60 * 1000 }); // expires in 30 days
129
+ backdateTokenRow("vendor_a", 8 * 24 * 60 * 60 * 1000); // untouched for 8 days
130
+
131
+ const captured = mockTokenEndpoint();
132
+ const result = await sweepOAuthTokenRefresh();
133
+
134
+ expect(result).toEqual({ checked: 1, refreshed: 1, skipped: 0, failed: [] });
135
+ expect(captured).toHaveLength(1);
136
+ expect(getOAuthTokens("vendor_a")?.accessToken).toBe("new-access-token");
137
+ });
138
+
139
+ test("survives a failing provider and still refreshes the others", async () => {
140
+ // "a_broken" sorts before "b_healthy", proving the sweep continues past a failure.
141
+ upsertOAuthApp("a_broken", appConfig("a_broken"));
142
+ upsertOAuthApp("b_healthy", appConfig("b_healthy"));
143
+ seedTokens("a_broken", { expiresInMs: 10 * 60 * 1000 });
144
+ seedTokens("b_healthy", { expiresInMs: 10 * 60 * 1000 });
145
+
146
+ const captured = mockTokenEndpoint(["https://oauth.a_broken.test/token"]);
147
+ const result = await sweepOAuthTokenRefresh();
148
+
149
+ expect(result.checked).toBe(2);
150
+ expect(result.refreshed).toBe(1);
151
+ expect(result.failed).toHaveLength(1);
152
+ expect(result.failed[0]).toContain("a_broken");
153
+ expect(captured.map((request) => request.url).sort()).toEqual([
154
+ "https://oauth.a_broken.test/token",
155
+ "https://oauth.b_healthy.test/token",
156
+ ]);
157
+ expect(getOAuthTokens("a_broken")?.accessToken).toBe("a_broken-old-access-token");
158
+ expect(getOAuthTokens("b_healthy")?.accessToken).toBe("new-access-token");
159
+ });
160
+ });