@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
@@ -1,8 +1,16 @@
1
+ import { Database } from "bun:sqlite";
1
2
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
3
+ import { createHash } from "node:crypto";
4
+ import { readdirSync, readFileSync, unlinkSync } from "node:fs";
5
+ import { join } from "node:path";
2
6
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
7
  import { createAgent, deleteSwarmConfig, getDb, upsertSwarmConfig } from "../be/db";
8
+ import { runMigrations } from "../be/migrations/runner";
4
9
  import {
10
+ fetchOpenapiSpec,
5
11
  getScriptApiConnectionDescriptors,
12
+ refreshScriptConnection,
13
+ setOpenapiSpecFetchForTesting,
6
14
  upsertCredentialBinding,
7
15
  upsertScriptConnection,
8
16
  } from "../be/script-connections";
@@ -17,6 +25,7 @@ const createdConfigIds: string[] = [];
17
25
  const originalFetch = globalThis.fetch;
18
26
  const savedEnv = { ...process.env };
19
27
  const resources = { memoryMb: 2048, cpuTimeSec: 20, maxStdoutBytes: 1_048_576 };
28
+ const MIGRATION_REBUILD_DB_PATH = "./test-script-connections-graphql-migration.sqlite";
20
29
 
21
30
  type RegisteredTool = {
22
31
  handler: (args: unknown, extra: unknown) => Promise<unknown>;
@@ -32,6 +41,61 @@ function scriptConnectionsTool() {
32
41
  return tool;
33
42
  }
34
43
 
44
+ function removeDbFiles(path: string) {
45
+ for (const suffix of ["", "-wal", "-shm"]) {
46
+ try {
47
+ unlinkSync(path + suffix);
48
+ } catch (err) {
49
+ if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
50
+ }
51
+ }
52
+ }
53
+
54
+ function migrationsDir() {
55
+ return new URL("../be/migrations", import.meta.url).pathname;
56
+ }
57
+
58
+ function migrationSql(file: string) {
59
+ return readFileSync(join(migrationsDir(), file), "utf-8");
60
+ }
61
+
62
+ function markMigrationsAppliedThrough(database: Database, throughVersion: number) {
63
+ database.run(`
64
+ CREATE TABLE IF NOT EXISTS _migrations (
65
+ version INTEGER PRIMARY KEY,
66
+ name TEXT NOT NULL,
67
+ applied_at TEXT NOT NULL,
68
+ checksum TEXT NOT NULL
69
+ )
70
+ `);
71
+ const files = readdirSync(migrationsDir())
72
+ .filter((file) => file.endsWith(".sql"))
73
+ .sort();
74
+ const insert = database.prepare(
75
+ "INSERT INTO _migrations (version, name, applied_at, checksum) VALUES (?, ?, ?, ?)",
76
+ );
77
+ for (const file of files) {
78
+ const version = Number.parseInt(file.split("_")[0] ?? "0", 10);
79
+ if (!version || version > throughVersion) continue;
80
+ const sql = migrationSql(file);
81
+ const checksum = createHash("sha256").update(sql).digest("hex");
82
+ insert.run(version, file.replace(".sql", ""), new Date().toISOString(), checksum);
83
+ }
84
+ }
85
+
86
+ function markMigrationApplied(database: Database, file: string) {
87
+ const sql = migrationSql(file);
88
+ const version = Number.parseInt(file.split("_")[0] ?? "0", 10);
89
+ database
90
+ .prepare("INSERT INTO _migrations (version, name, applied_at, checksum) VALUES (?, ?, ?, ?)")
91
+ .run(
92
+ version,
93
+ file.replace(".sql", ""),
94
+ new Date().toISOString(),
95
+ createHash("sha256").update(sql).digest("hex"),
96
+ );
97
+ }
98
+
35
99
  function meta(agentId: string) {
36
100
  return {
37
101
  sessionId: "script-connections-test-session",
@@ -39,6 +103,32 @@ function meta(agentId: string) {
39
103
  };
40
104
  }
41
105
 
106
+ type OpenapiSpecFixture = {
107
+ body: string;
108
+ etag: string;
109
+ requests: Array<{ accept: string | null; ifNoneMatch: string | null }>;
110
+ };
111
+
112
+ const openapiSpecFixtures = new Map<string, OpenapiSpecFixture>();
113
+
114
+ const fixtureOpenapiFetch: typeof fetch = async (input, init) => {
115
+ const url = new URL(input instanceof Request ? input.url : String(input));
116
+ const fixture = openapiSpecFixtures.get(url.toString());
117
+ if (!fixture) return new Response("not found", { status: 404 });
118
+ const headers = new Headers(input instanceof Request ? input.headers : init?.headers);
119
+ const ifNoneMatch = headers.get("if-none-match");
120
+ fixture.requests.push({
121
+ accept: headers.get("accept"),
122
+ ifNoneMatch,
123
+ });
124
+ if (ifNoneMatch === fixture.etag) {
125
+ return new Response(null, { status: 304, headers: { ETag: fixture.etag } });
126
+ }
127
+ return new Response(fixture.body, {
128
+ headers: { "Content-Type": "application/json", ETag: fixture.etag },
129
+ });
130
+ };
131
+
42
132
  const openapiSpec = JSON.stringify({
43
133
  openapi: "3.1.0",
44
134
  info: { title: "Vendor", version: "1.0.0" },
@@ -109,13 +199,68 @@ const openapiSpec = JSON.stringify({
109
199
  },
110
200
  });
111
201
 
202
+ function specWithExtraOperation(operationId: string) {
203
+ const spec = JSON.parse(openapiSpec) as {
204
+ paths: Record<string, Record<string, unknown>>;
205
+ };
206
+ spec.paths["/orgs/{org}/repos"] = {
207
+ get: {
208
+ operationId,
209
+ parameters: [{ name: "org", in: "path", required: true, schema: { type: "string" } }],
210
+ responses: {
211
+ "200": {
212
+ description: "repos",
213
+ content: {
214
+ "application/json": {
215
+ schema: {
216
+ type: "array",
217
+ items: {
218
+ type: "object",
219
+ required: ["name"],
220
+ properties: { name: { type: "string" } },
221
+ },
222
+ },
223
+ },
224
+ },
225
+ },
226
+ },
227
+ },
228
+ };
229
+ return JSON.stringify(spec);
230
+ }
231
+
232
+ function serveOpenapiSpec(initialBody: string, initialEtag = '"v1"') {
233
+ const fixtureUrl = `http://script-openapi.test/${crypto.randomUUID()}/openapi.json`;
234
+ const fixture: OpenapiSpecFixture = {
235
+ body: initialBody,
236
+ etag: initialEtag,
237
+ requests: [],
238
+ };
239
+ openapiSpecFixtures.set(fixtureUrl, fixture);
240
+ return {
241
+ requests: fixture.requests,
242
+ url: fixtureUrl,
243
+ baseUrl: new URL(fixtureUrl).origin,
244
+ setBody(nextBody: string, nextEtag: string) {
245
+ fixture.body = nextBody;
246
+ fixture.etag = nextEtag;
247
+ },
248
+ stop() {
249
+ openapiSpecFixtures.delete(fixtureUrl);
250
+ },
251
+ };
252
+ }
253
+
112
254
  beforeEach(() => {
113
255
  process.env.AGENT_SWARM_API_KEY = "script-connections-test-key";
114
256
  delete process.env.API_KEY;
115
257
  process.env.MCP_BASE_URL = "http://localhost:3013";
258
+ setOpenapiSpecFetchForTesting(fixtureOpenapiFetch);
116
259
  });
117
260
 
118
261
  afterEach(() => {
262
+ setOpenapiSpecFetchForTesting(null);
263
+ openapiSpecFixtures.clear();
119
264
  globalThis.fetch = originalFetch;
120
265
  for (const key of Object.keys(process.env)) {
121
266
  if (!(key in savedEnv)) delete process.env[key];
@@ -134,10 +279,11 @@ afterEach(() => {
134
279
  for (const id of createdConfigIds.splice(0)) {
135
280
  deleteSwarmConfig(id);
136
281
  }
282
+ removeDbFiles(MIGRATION_REBUILD_DB_PATH);
137
283
  });
138
284
 
139
285
  describe("script connections", () => {
140
- test("relational credential bindings are resolved before legacy JSON config", () => {
286
+ test("relational credential bindings are resolved before legacy JSON config", async () => {
141
287
  const binding = upsertCredentialBinding({
142
288
  configKey: "REL_VENDOR_KEY",
143
289
  allowedHosts: ["api.vendor.test"],
@@ -152,7 +298,7 @@ describe("script connections", () => {
152
298
  });
153
299
  createdConfigIds.push(secretConfig.id);
154
300
 
155
- const egressSecrets = buildScriptCredentialBindings({});
301
+ const egressSecrets = await buildScriptCredentialBindings({});
156
302
 
157
303
  expect(egressSecrets).toContainEqual(
158
304
  expect.objectContaining({
@@ -181,14 +327,14 @@ describe("script connections", () => {
181
327
  expect(second.allowedHosts).toEqual(["new.vendor.test"]);
182
328
  });
183
329
 
184
- test("OpenAPI connections generate full ctx.api method, args, and response types", () => {
330
+ test("OpenAPI connections generate full ctx.api method, args, and response types", async () => {
185
331
  const binding = upsertCredentialBinding({
186
332
  configKey: "TYPE_VENDOR_KEY",
187
333
  allowedHosts: ["api.vendor.test"],
188
334
  headerTemplate: "Authorization: Bearer [REDACTED:TYPE_VENDOR_KEY]",
189
335
  });
190
336
  createdBindingIds.push(binding.id);
191
- const connection = upsertScriptConnection({
337
+ const connection = await upsertScriptConnection({
192
338
  slug: "vendorApi",
193
339
  kind: "openapi",
194
340
  baseUrl: "https://api.vendor.test",
@@ -208,15 +354,23 @@ describe("script connections", () => {
208
354
  const created = await ctx.api.vendorApi.createRepo({
209
355
  body: { name: "agent-swarm", private: false },
210
356
  });
357
+ const raw = await ctx.api.vendorApi.getRepo({
358
+ path: { owner: "desplega-ai", repo: "agent-swarm" },
359
+ }, { raw: true });
211
360
  const name: string = repo.full_name;
212
361
  const createdName: string = created.full_name;
213
362
  const isPrivate: boolean | undefined = repo.private;
214
- return { name, createdName, isPrivate };
363
+ const rawStatus: number = raw.status;
364
+ const rawBody: ArrayBuffer = await raw.response.arrayBuffer();
365
+ return { name, createdName, isPrivate, rawStatus, rawBody };
215
366
  };
216
367
  export default main;
217
368
  `;
218
369
 
219
370
  expect(typecheckScript(source)).toEqual({ ok: true });
371
+ expect(connection.generatedTypes).toContain(
372
+ "getRepo(args: VendorApiGetRepoArgs, options: ScriptApiRawOptions): Promise<ScriptApiRawResult>;",
373
+ );
220
374
  const descriptor = getScriptApiConnectionDescriptors().find(
221
375
  (candidate) => candidate.slug === "vendorApi",
222
376
  );
@@ -246,6 +400,493 @@ describe("script connections", () => {
246
400
  });
247
401
  });
248
402
 
403
+ test("GET operations never generate a body even when the spec declares one", async () => {
404
+ // readme.io-exported specs (e.g. Notion on apis.guru) declare form-encoded
405
+ // requestBody on GET operations; fetch() rejects GET bodies.
406
+ const spec = JSON.stringify({
407
+ openapi: "3.0.0",
408
+ info: { title: "Readmeio", version: "1.0.0" },
409
+ paths: {
410
+ "/v1/users/{id}": {
411
+ get: {
412
+ operationId: "retrieveAUser",
413
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
414
+ requestBody: {
415
+ content: {
416
+ "application/x-www-form-urlencoded": { schema: { type: "object" } },
417
+ },
418
+ },
419
+ responses: { "200": { description: "ok" } },
420
+ },
421
+ post: {
422
+ operationId: "updateAUser",
423
+ parameters: [{ name: "id", in: "path", required: true, schema: { type: "string" } }],
424
+ requestBody: {
425
+ content: { "application/json": { schema: { type: "object" } } },
426
+ },
427
+ responses: { "200": { description: "ok" } },
428
+ },
429
+ },
430
+ },
431
+ });
432
+ const connection = await upsertScriptConnection({
433
+ slug: "readmeio",
434
+ kind: "openapi",
435
+ baseUrl: "https://api.readmeio.test",
436
+ openapiSpecJson: spec,
437
+ });
438
+ createdConnectionIds.push(connection.id);
439
+
440
+ expect(connection.generationError).toBeNull();
441
+ const runtime = JSON.parse(connection.generatedRuntimeJson ?? "{}") as {
442
+ operations: Array<{ name: string; hasBody: boolean }>;
443
+ };
444
+ const byName = new Map(runtime.operations.map((op) => [op.name, op]));
445
+ expect(byName.get("retrieveAUser")?.hasBody).toBe(false);
446
+ expect(byName.get("updateAUser")?.hasBody).toBe(true);
447
+ // the generated GET args type must not demand a body either
448
+ expect(connection.generatedTypes).not.toMatch(/RetrieveAUserArgs = \{[^}]*body/);
449
+ });
450
+
451
+ test("GraphQL connections generate ctx.api descriptor and graphql method types", async () => {
452
+ const binding = upsertCredentialBinding({
453
+ configKey: "GRAPHQL_VENDOR_KEY",
454
+ allowedHosts: ["countries.vendor.test"],
455
+ headerTemplate: "Authorization: Bearer [REDACTED:GRAPHQL_VENDOR_KEY]",
456
+ });
457
+ createdBindingIds.push(binding.id);
458
+
459
+ const connection = await upsertScriptConnection({
460
+ slug: "countries",
461
+ kind: "graphql",
462
+ baseUrl: "https://countries.vendor.test/graphql",
463
+ allowedHosts: ["countries.vendor.test"],
464
+ credentialBindingId: binding.id,
465
+ });
466
+ createdConnectionIds.push(connection.id);
467
+
468
+ expect(connection.generationError).toBeNull();
469
+ expect(connection.generatedRuntimeJson).toBe(
470
+ JSON.stringify({
471
+ slug: "countries",
472
+ kind: "graphql",
473
+ baseUrl: "https://countries.vendor.test/graphql",
474
+ credential: {
475
+ configKey: "GRAPHQL_VENDOR_KEY",
476
+ headerTemplate: "Authorization: Bearer [REDACTED:GRAPHQL_VENDOR_KEY]",
477
+ },
478
+ }),
479
+ );
480
+ expect(connection.generatedTypes).toContain("interface CountriesApi");
481
+ expect(connection.generatedTypes).toContain(
482
+ "graphql<T = JsonValue>(query: string, variables?: Record<string, JsonValue>): Promise<T>;",
483
+ );
484
+
485
+ const descriptor = getScriptApiConnectionDescriptors().find(
486
+ (candidate) => candidate.slug === "countries",
487
+ );
488
+ expect(descriptor).toEqual({
489
+ slug: "countries",
490
+ kind: "graphql",
491
+ baseUrl: "https://countries.vendor.test/graphql",
492
+ credential: {
493
+ configKey: "GRAPHQL_VENDOR_KEY",
494
+ headerTemplate: "Authorization: Bearer [REDACTED:GRAPHQL_VENDOR_KEY]",
495
+ },
496
+ });
497
+
498
+ const source = `
499
+ import type { ScriptMain } from "swarm-sdk";
500
+ const main: ScriptMain = async (_args, ctx) => {
501
+ const result = await ctx.api.countries.graphql<{ country: { name: string; capital: string } }>(
502
+ "query Country($code: ID!) { country(code: $code) { name capital } }",
503
+ { code: "UA" },
504
+ );
505
+ const name: string = result.country.name;
506
+ const capital: string = result.country.capital;
507
+ return { name, capital };
508
+ };
509
+ export default main;
510
+ `;
511
+
512
+ expect(typecheckScript(source)).toEqual({ ok: true });
513
+ });
514
+
515
+ test("migration 112 rebuild preserves existing script connection rows", () => {
516
+ removeDbFiles(MIGRATION_REBUILD_DB_PATH);
517
+ const database = new Database(MIGRATION_REBUILD_DB_PATH, { create: true });
518
+ try {
519
+ database.run("CREATE TABLE users (id TEXT PRIMARY KEY)");
520
+ database.run("CREATE TABLE mcp_servers (id TEXT PRIMARY KEY)");
521
+ database.run("CREATE TABLE agent_tasks (id TEXT PRIMARY KEY)");
522
+ database.exec(migrationSql("101_script_connections.sql"));
523
+ database.exec(migrationSql("111_oauth_credential_bindings.sql"));
524
+ markMigrationsAppliedThrough(database, 111);
525
+ // This fixture intentionally starts from a partial schema to exercise the
526
+ // 112 rebuild only; unrelated later migrations may depend on tables that
527
+ // do not exist in the fixture.
528
+ markMigrationApplied(database, "114_backfill_gpt_5_6_pricing.sql");
529
+
530
+ const id = crypto.randomUUID();
531
+ const now = new Date().toISOString();
532
+ database.run(
533
+ `INSERT INTO script_connections (
534
+ id, slug, display_name, kind, scope, scope_id, base_url, allowed_hosts_json,
535
+ credential_binding_id, openapi_spec_source_kind, openapi_spec_source,
536
+ openapi_spec_json, openapi_spec_etag, openapi_spec_fetched_at, mcp_server_id,
537
+ generated_types, generated_runtime_json, generated_at, generation_error, enabled,
538
+ version, created_at, updated_at, created_by, updated_by
539
+ )
540
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
541
+ [
542
+ id,
543
+ "preGraphql",
544
+ "Pre GraphQL",
545
+ "raw",
546
+ "global",
547
+ null,
548
+ "https://api.vendor.test",
549
+ JSON.stringify(["api.vendor.test"]),
550
+ null,
551
+ null,
552
+ null,
553
+ null,
554
+ null,
555
+ null,
556
+ null,
557
+ "export interface PreGraphqlApi {}",
558
+ JSON.stringify({ slug: "preGraphql", baseUrl: "https://api.vendor.test" }),
559
+ now,
560
+ null,
561
+ 1,
562
+ 7,
563
+ now,
564
+ now,
565
+ null,
566
+ null,
567
+ ],
568
+ );
569
+
570
+ runMigrations(database);
571
+
572
+ const preserved = database
573
+ .prepare<
574
+ {
575
+ slug: string;
576
+ kind: string;
577
+ allowed_hosts_json: string;
578
+ generated_runtime_json: string | null;
579
+ version: number;
580
+ },
581
+ [string]
582
+ >(
583
+ `SELECT slug, kind, allowed_hosts_json, generated_runtime_json, version
584
+ FROM script_connections WHERE id = ?`,
585
+ )
586
+ .get(id);
587
+ expect(preserved).toEqual({
588
+ slug: "preGraphql",
589
+ kind: "raw",
590
+ allowed_hosts_json: JSON.stringify(["api.vendor.test"]),
591
+ generated_runtime_json: JSON.stringify({
592
+ slug: "preGraphql",
593
+ baseUrl: "https://api.vendor.test",
594
+ }),
595
+ version: 7,
596
+ });
597
+
598
+ const indexes = database
599
+ .prepare<{ name: string }, []>(
600
+ "SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = 'script_connections'",
601
+ )
602
+ .all()
603
+ .map((row) => row.name);
604
+ expect(indexes).toContain("idx_script_connections_slug_scope");
605
+ expect(indexes).toContain("idx_script_connections_kind_enabled");
606
+
607
+ expect(() => {
608
+ database.run(
609
+ `INSERT INTO script_connections (
610
+ id, slug, kind, scope, base_url, allowed_hosts_json, generated_types,
611
+ generated_runtime_json, generated_at, created_at, updated_at
612
+ )
613
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
614
+ [
615
+ crypto.randomUUID(),
616
+ "postGraphql",
617
+ "graphql",
618
+ "global",
619
+ "https://graphql.vendor.test",
620
+ JSON.stringify(["graphql.vendor.test"]),
621
+ "export interface PostGraphqlApi {}",
622
+ JSON.stringify({ slug: "postGraphql", kind: "graphql" }),
623
+ now,
624
+ now,
625
+ now,
626
+ ],
627
+ );
628
+ }).not.toThrow();
629
+ } finally {
630
+ database.close();
631
+ }
632
+ });
633
+
634
+ test("OpenAPI connections can be registered from a spec URL", async () => {
635
+ const server = serveOpenapiSpec(openapiSpec);
636
+ try {
637
+ const suffix = crypto.randomUUID().replace(/-/g, "");
638
+ const connection = await upsertScriptConnection({
639
+ slug: `urlVendor${suffix}`,
640
+ kind: "openapi",
641
+ baseUrl: server.baseUrl,
642
+ openapiSpecUrl: server.url,
643
+ });
644
+ createdConnectionIds.push(connection.id);
645
+
646
+ expect(connection.generationError).toBeNull();
647
+ expect(connection.openapiSpecSourceKind).toBe("url");
648
+ expect(connection.openapiSpecSource).toBe(server.url);
649
+ expect(connection.openapiSpecJson).toBe(JSON.stringify(JSON.parse(openapiSpec)));
650
+ expect(connection.openapiSpecEtag).toBe('"v1"');
651
+ expect(connection.openapiSpecFetchedAt).toBeString();
652
+ expect(server.requests).toEqual([
653
+ {
654
+ accept: "application/json, application/yaml;q=0.9, text/yaml;q=0.8, */*;q=0.5",
655
+ ifNoneMatch: null,
656
+ },
657
+ ]);
658
+ expect(connection.generatedTypes).toContain("getRepo");
659
+ } finally {
660
+ server.stop();
661
+ }
662
+ });
663
+
664
+ test("OpenAPI spec fetch rejects redirects to unsafe hosts in production", async () => {
665
+ process.env.NODE_ENV = "production";
666
+ const requests: Array<{ url: string; redirect?: RequestRedirect }> = [];
667
+ setOpenapiSpecFetchForTesting((async (input, init) => {
668
+ const url = input instanceof Request ? input.url : String(input);
669
+ requests.push({ url, redirect: init?.redirect });
670
+ if (url === "https://spec.vendor.test/openapi.json") {
671
+ return new Response(null, {
672
+ status: 302,
673
+ headers: { location: "http://127.0.0.1/spec.json" },
674
+ });
675
+ }
676
+ return new Response(openapiSpec, { status: 200 });
677
+ }) as typeof fetch);
678
+
679
+ await expect(fetchOpenapiSpec("https://spec.vendor.test/openapi.json")).rejects.toThrow(
680
+ /private IPv4|insecure/,
681
+ );
682
+ expect(requests).toEqual([
683
+ { url: "https://spec.vendor.test/openapi.json", redirect: "manual" },
684
+ ]);
685
+ });
686
+
687
+ test("OpenAPI spec fetch fails closed when NODE_ENV is unset", async () => {
688
+ // Production images often run with NODE_ENV undefined — the SSRF guard
689
+ // must NOT treat that as development.
690
+ delete process.env.NODE_ENV;
691
+ delete process.env.ALLOW_PRIVATE_NETWORK_URLS;
692
+ setOpenapiSpecFetchForTesting((async () => {
693
+ throw new Error("fetch should not be reached for unsafe URLs");
694
+ }) as unknown as typeof fetch);
695
+
696
+ await expect(fetchOpenapiSpec("http://127.0.0.1/openapi.json")).rejects.toThrow(
697
+ /private IPv4|insecure/,
698
+ );
699
+
700
+ // ...and the explicit override re-enables local fetches for dev setups.
701
+ process.env.ALLOW_PRIVATE_NETWORK_URLS = "true";
702
+ setOpenapiSpecFetchForTesting(
703
+ (async () => new Response(openapiSpec, { status: 200 })) as unknown as typeof fetch,
704
+ );
705
+ const fetched = await fetchOpenapiSpec("http://127.0.0.1/openapi.json");
706
+ expect(fetched.status).toBe("fetched");
707
+ });
708
+
709
+ test("YAML specs are accepted from a URL and canonicalized to JSON", async () => {
710
+ const yamlSpec = [
711
+ "openapi: 3.0.0",
712
+ "info:",
713
+ " title: YamlVendor",
714
+ " version: 1.0.0",
715
+ "paths:",
716
+ " /things/{id}:",
717
+ " get:",
718
+ " operationId: getThing",
719
+ " parameters:",
720
+ " - name: id",
721
+ " in: path",
722
+ " required: true",
723
+ " schema:",
724
+ " type: string",
725
+ " responses:",
726
+ ' "200":',
727
+ " description: ok",
728
+ "",
729
+ ].join("\n");
730
+ const server = serveOpenapiSpec(yamlSpec);
731
+ try {
732
+ const suffix = crypto.randomUUID().replace(/-/g, "");
733
+ const connection = await upsertScriptConnection({
734
+ slug: `yamlVendor${suffix}`,
735
+ kind: "openapi",
736
+ baseUrl: server.baseUrl,
737
+ openapiSpecUrl: server.url,
738
+ });
739
+ createdConnectionIds.push(connection.id);
740
+
741
+ expect(connection.generationError).toBeNull();
742
+ // stored canonically as JSON regardless of the fetched format
743
+ expect(JSON.parse(connection.openapiSpecJson ?? "{}")).toMatchObject({
744
+ openapi: "3.0.0",
745
+ info: { title: "YamlVendor" },
746
+ });
747
+ expect(connection.generatedTypes).toContain("getThing");
748
+ } finally {
749
+ server.stop();
750
+ }
751
+ });
752
+
753
+ test("inline YAML specs are accepted and canonicalized to JSON", async () => {
754
+ const suffix = crypto.randomUUID().replace(/-/g, "");
755
+ const connection = await upsertScriptConnection({
756
+ slug: `yamlInline${suffix}`,
757
+ kind: "openapi",
758
+ baseUrl: "https://api.yaml-inline.test",
759
+ openapiSpecJson: [
760
+ "openapi: 3.0.0",
761
+ "info: { title: InlineYaml, version: 1.0.0 }",
762
+ "paths:",
763
+ " /ping:",
764
+ " get:",
765
+ " operationId: ping",
766
+ " responses:",
767
+ ' "200": { description: ok }',
768
+ ].join("\n"),
769
+ });
770
+ createdConnectionIds.push(connection.id);
771
+
772
+ expect(connection.generationError).toBeNull();
773
+ expect(JSON.parse(connection.openapiSpecJson ?? "{}")).toMatchObject({
774
+ info: { title: "InlineYaml" },
775
+ });
776
+ expect(connection.generatedTypes).toContain("ping");
777
+ });
778
+
779
+ test("refresh sends If-None-Match and only bumps fetched_at on 304", async () => {
780
+ const server = serveOpenapiSpec(openapiSpec);
781
+ try {
782
+ const suffix = crypto.randomUUID().replace(/-/g, "");
783
+ const connection = await upsertScriptConnection({
784
+ slug: `etagVendor${suffix}`,
785
+ kind: "openapi",
786
+ baseUrl: server.baseUrl,
787
+ openapiSpecUrl: server.url,
788
+ });
789
+ createdConnectionIds.push(connection.id);
790
+ await Bun.sleep(5);
791
+
792
+ const refreshed = await refreshScriptConnection(connection.id);
793
+
794
+ expect(refreshed).toBeDefined();
795
+ expect(refreshed?.version).toBe(connection.version);
796
+ expect(refreshed?.openapiSpecEtag).toBe('"v1"');
797
+ expect(refreshed?.generatedRuntimeJson).toBe(connection.generatedRuntimeJson);
798
+ expect(refreshed?.openapiSpecFetchedAt).not.toBe(connection.openapiSpecFetchedAt);
799
+ const specAccept = "application/json, application/yaml;q=0.9, text/yaml;q=0.8, */*;q=0.5";
800
+ expect(server.requests).toEqual([
801
+ { accept: specAccept, ifNoneMatch: null },
802
+ { accept: specAccept, ifNoneMatch: '"v1"' },
803
+ ]);
804
+ } finally {
805
+ server.stop();
806
+ }
807
+ });
808
+
809
+ test("refresh updates changed URL specs and regenerates types", async () => {
810
+ const server = serveOpenapiSpec(openapiSpec);
811
+ try {
812
+ const suffix = crypto.randomUUID().replace(/-/g, "");
813
+ const connection = await upsertScriptConnection({
814
+ slug: `changedVendor${suffix}`,
815
+ kind: "openapi",
816
+ baseUrl: server.baseUrl,
817
+ openapiSpecUrl: server.url,
818
+ });
819
+ createdConnectionIds.push(connection.id);
820
+ expect(connection.generatedTypes).not.toContain("listOrgRepos");
821
+
822
+ server.setBody(specWithExtraOperation("listOrgRepos"), '"v2"');
823
+ const refreshed = await refreshScriptConnection(connection.id);
824
+
825
+ expect(refreshed?.version).toBe(connection.version + 1);
826
+ expect(refreshed?.openapiSpecEtag).toBe('"v2"');
827
+ expect(refreshed?.generationError).toBeNull();
828
+ expect(refreshed?.generatedTypes).toContain("listOrgRepos");
829
+ const descriptor = getScriptApiConnectionDescriptors().find(
830
+ (candidate) => candidate.slug === connection.slug,
831
+ );
832
+ expect(descriptor?.operations.some((operation) => operation.name === "listOrgRepos")).toBe(
833
+ true,
834
+ );
835
+ } finally {
836
+ server.stop();
837
+ }
838
+ });
839
+
840
+ test("invalid fetched OpenAPI specs are stored with generation_error", async () => {
841
+ const invalidSpec = JSON.stringify({
842
+ openapi: "3.1.0",
843
+ info: { title: "Invalid", version: "1.0.0" },
844
+ paths: {},
845
+ });
846
+ const server = serveOpenapiSpec(invalidSpec);
847
+ try {
848
+ const suffix = crypto.randomUUID().replace(/-/g, "");
849
+ const connection = await upsertScriptConnection({
850
+ slug: `invalidVendor${suffix}`,
851
+ kind: "openapi",
852
+ baseUrl: server.baseUrl,
853
+ openapiSpecUrl: server.url,
854
+ });
855
+ createdConnectionIds.push(connection.id);
856
+
857
+ expect(connection.openapiSpecSourceKind).toBe("url");
858
+ expect(connection.generationError).toContain("supported operations");
859
+ expect(connection.generatedTypes).toBeNull();
860
+ } finally {
861
+ server.stop();
862
+ }
863
+ });
864
+
865
+ test("invalid fetched spec content is rejected before storing a connection", async () => {
866
+ const server = serveOpenapiSpec("{not-valid-json");
867
+ try {
868
+ const suffix = crypto.randomUUID().replace(/-/g, "");
869
+
870
+ await expect(
871
+ upsertScriptConnection({
872
+ slug: `badContentVendor${suffix}`,
873
+ kind: "openapi",
874
+ baseUrl: server.baseUrl,
875
+ openapiSpecUrl: server.url,
876
+ }),
877
+ ).rejects.toThrow(/valid JSON|valid YAML|JSON specs only/);
878
+
879
+ const stored = getDb()
880
+ .prepare<{ count: number }, [string]>(
881
+ "SELECT COUNT(*) AS count FROM script_connections WHERE slug = ?",
882
+ )
883
+ .get(`badContentVendor${suffix}`);
884
+ expect(stored?.count).toBe(0);
885
+ } finally {
886
+ server.stop();
887
+ }
888
+ });
889
+
249
890
  test("script-connections tool registers OpenAPI connections from an agent header without FK failures", async () => {
250
891
  const suffix = crypto.randomUUID().replace(/-/g, "");
251
892
  const slug = `agentHeaderVendor${suffix}`;
@@ -305,6 +946,113 @@ describe("script connections", () => {
305
946
  createdBindingIds.push(bindingRow!.id);
306
947
  });
307
948
 
949
+ test("script-connections tool can upsert OpenAPI connections by URL and refresh them", async () => {
950
+ const server = serveOpenapiSpec(openapiSpec);
951
+ try {
952
+ const suffix = crypto.randomUUID().replace(/-/g, "");
953
+ const slug = `toolUrlVendor${suffix}`;
954
+ const lead = createAgent({
955
+ name: `script-connections-tool-url-lead-${crypto.randomUUID()}`,
956
+ isLead: true,
957
+ status: "idle",
958
+ });
959
+ const tool = scriptConnectionsTool();
960
+
961
+ const upsertResult = (await tool.handler(
962
+ {
963
+ action: "upsert-openapi",
964
+ slug,
965
+ displayName: "Tool URL Vendor",
966
+ baseUrl: server.baseUrl,
967
+ openapiSpecUrl: server.url,
968
+ },
969
+ meta(lead.id),
970
+ )) as {
971
+ structuredContent: { success: boolean; message: string };
972
+ };
973
+
974
+ expect(upsertResult.structuredContent.success).toBe(true);
975
+ const row = getDb()
976
+ .prepare<{ id: string }, [string]>("SELECT id FROM script_connections WHERE slug = ?")
977
+ .get(slug);
978
+ expect(row).toBeDefined();
979
+ createdConnectionIds.push(row!.id);
980
+
981
+ server.setBody(specWithExtraOperation("toolListOrgRepos"), '"v2"');
982
+ const refreshResult = (await tool.handler(
983
+ { action: "refresh", id: row!.id },
984
+ meta(lead.id),
985
+ )) as {
986
+ structuredContent: { success: boolean; message: string };
987
+ };
988
+
989
+ expect(refreshResult.structuredContent.success).toBe(true);
990
+ const refreshed = getDb()
991
+ .prepare<{ openapi_spec_etag: string | null; generated_types: string | null }, [string]>(
992
+ "SELECT openapi_spec_etag, generated_types FROM script_connections WHERE id = ?",
993
+ )
994
+ .get(row!.id);
995
+ expect(refreshed?.openapi_spec_etag).toBe('"v2"');
996
+ expect(refreshed?.generated_types).toContain("toolListOrgRepos");
997
+ } finally {
998
+ server.stop();
999
+ }
1000
+ });
1001
+
1002
+ test("script-connections tool can upsert GraphQL connections", async () => {
1003
+ const suffix = crypto.randomUUID().replace(/-/g, "");
1004
+ const slug = `toolGraphql${suffix}`;
1005
+ const configKey = `TOOL_GRAPHQL_KEY_${suffix}`;
1006
+ const lead = createAgent({
1007
+ name: `script-connections-tool-graphql-lead-${crypto.randomUUID()}`,
1008
+ isLead: true,
1009
+ status: "idle",
1010
+ });
1011
+ const tool = scriptConnectionsTool();
1012
+
1013
+ const result = (await tool.handler(
1014
+ {
1015
+ action: "upsert-graphql",
1016
+ slug,
1017
+ displayName: "Tool GraphQL",
1018
+ baseUrl: "https://graphql.vendor.test/query",
1019
+ allowedHosts: ["graphql.vendor.test"],
1020
+ configKey,
1021
+ },
1022
+ meta(lead.id),
1023
+ )) as {
1024
+ structuredContent: { success: boolean; message: string };
1025
+ };
1026
+
1027
+ expect(result.structuredContent.success).toBe(true);
1028
+ const row = getDb()
1029
+ .prepare<
1030
+ {
1031
+ id: string;
1032
+ kind: string;
1033
+ credential_binding_id: string | null;
1034
+ generated_types: string | null;
1035
+ },
1036
+ [string]
1037
+ >(
1038
+ `SELECT id, kind, credential_binding_id, generated_types
1039
+ FROM script_connections WHERE slug = ?`,
1040
+ )
1041
+ .get(slug);
1042
+ expect(row?.kind).toBe("graphql");
1043
+ expect(row?.generated_types).toContain("graphql<T = JsonValue>");
1044
+ createdConnectionIds.push(row!.id);
1045
+
1046
+ const bindingRow = getDb()
1047
+ .prepare<{ id: string; allowed_hosts_json: string }, [string]>(
1048
+ "SELECT id, allowed_hosts_json FROM script_credential_bindings WHERE config_key = ?",
1049
+ )
1050
+ .get(configKey);
1051
+ expect(bindingRow?.id).toBe(row?.credential_binding_id);
1052
+ expect(bindingRow?.allowed_hosts_json).toBe(JSON.stringify(["graphql.vendor.test"]));
1053
+ createdBindingIds.push(bindingRow!.id);
1054
+ });
1055
+
308
1056
  test("ctx.api runtime emits plain fetch with credential placeholders", async () => {
309
1057
  let observed: { url: string; authorization: string | null } | null = null;
310
1058
  const server = Bun.serve({
@@ -323,7 +1071,7 @@ describe("script connections", () => {
323
1071
  headerTemplate: "Authorization: Bearer [REDACTED:RUNTIME_VENDOR_KEY]",
324
1072
  });
325
1073
  createdBindingIds.push(binding.id);
326
- const connection = upsertScriptConnection({
1074
+ const connection = await upsertScriptConnection({
327
1075
  slug: "runtimeVendor",
328
1076
  kind: "openapi",
329
1077
  baseUrl: `http://127.0.0.1:${server.port}`,