@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
@@ -0,0 +1,263 @@
1
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { unlink } from "node:fs/promises";
3
+ import { closeDb, createAgent, getDb, initDb } from "../be/db";
4
+ import {
5
+ getAuthorizationById,
6
+ getOAuthApp,
7
+ storeOAuthTokens,
8
+ upsertAuthorization,
9
+ upsertOAuthApp,
10
+ } from "../be/db-queries/oauth";
11
+ import { upsertCredentialBinding } from "../be/script-connections";
12
+ import { buildScriptCredentialBindingsWithFailures } from "../be/script-credential-broker";
13
+ import {
14
+ ensureAuthorizationTokenOrThrow,
15
+ forceRefreshAuthorizationOrThrow,
16
+ OAuthRefreshError,
17
+ } from "../oauth/ensure-token";
18
+ import { patchFetchWithCredentialBroker } from "../scripts-runtime/credential-broker";
19
+ import { clearVolatileSecretsForTesting } from "../utils/secret-scrubber";
20
+
21
+ const TEST_DB_PATH = "./test-oauth-refresh-failure.sqlite";
22
+ const LEAD_ID = "aaaa9500-0000-4000-8000-000000000001";
23
+ const originalFetch = globalThis.fetch;
24
+
25
+ function appConfig(provider: string, tokenUrl = `https://oauth.${provider}.test/token`) {
26
+ return {
27
+ clientId: `${provider}-client-id`,
28
+ clientSecret: `${provider}-client-secret`,
29
+ authorizeUrl: `https://oauth.${provider}.test/authorize`,
30
+ tokenUrl,
31
+ redirectUri: "http://localhost:3013/callback",
32
+ scopes: "read,write",
33
+ };
34
+ }
35
+
36
+ /**
37
+ * Mock the token endpoint. Refresh requests whose body carries a refresh token
38
+ * listed in `failFor` get a 400 (invalid_grant); everything else gets a fresh
39
+ * token whose access value is derived from the incoming refresh token so
40
+ * per-authorization isolation is observable.
41
+ */
42
+ function mockTokenEndpoint(failFor: string[] = []): void {
43
+ globalThis.fetch = (async (_input: string | URL | Request, init?: RequestInit) => {
44
+ const body = typeof init?.body === "string" ? init.body : "";
45
+ const failing = failFor.some((token) => body.includes(`refresh_token=${token}`));
46
+ if (failing) {
47
+ return new Response(JSON.stringify({ error: "invalid_grant" }), {
48
+ status: 400,
49
+ headers: { "content-type": "application/json" },
50
+ });
51
+ }
52
+ return new Response(
53
+ JSON.stringify({
54
+ access_token: "healed-access-token",
55
+ token_type: "bearer",
56
+ expires_in: 3600,
57
+ refresh_token: "rotated-refresh-token",
58
+ scope: "read,write",
59
+ }),
60
+ { status: 200, headers: { "content-type": "application/json" } },
61
+ );
62
+ }) as typeof fetch;
63
+ }
64
+
65
+ beforeAll(async () => {
66
+ await unlink(TEST_DB_PATH).catch(() => {});
67
+ await unlink(`${TEST_DB_PATH}-wal`).catch(() => {});
68
+ await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
69
+ initDb(TEST_DB_PATH);
70
+ createAgent({ id: LEAD_ID, name: "OAuth Refresh Lead", isLead: true, status: "idle" });
71
+ });
72
+
73
+ beforeEach(() => {
74
+ globalThis.fetch = originalFetch;
75
+ clearVolatileSecretsForTesting();
76
+ getDb().run("DELETE FROM oauth_refresh_locks");
77
+ getDb().run("DELETE FROM script_credential_bindings");
78
+ getDb().run("DELETE FROM oauth_authorizations");
79
+ getDb().run("DELETE FROM oauth_apps");
80
+ });
81
+
82
+ afterAll(async () => {
83
+ globalThis.fetch = originalFetch;
84
+ closeDb();
85
+ for (const suffix of ["", "-wal", "-shm"]) {
86
+ await unlink(TEST_DB_PATH + suffix).catch(() => {});
87
+ }
88
+ });
89
+
90
+ function seedDefault(provider: string, refreshToken: string | null, expiresInMs: number): string {
91
+ storeOAuthTokens(provider, {
92
+ accessToken: `${provider}-access`,
93
+ refreshToken,
94
+ expiresAt: new Date(Date.now() + expiresInMs).toISOString(),
95
+ scope: "read,write",
96
+ });
97
+ const id = getDb()
98
+ .query(
99
+ `SELECT z.id AS id FROM oauth_authorizations z
100
+ JOIN oauth_apps a ON a.id = z.appId
101
+ WHERE a.provider = ? AND a.mcpServerId IS NULL AND z.label = 'default'`,
102
+ )
103
+ .get(provider) as { id: string } | null;
104
+ if (!id) throw new Error("default authorization not seeded");
105
+ return id.id;
106
+ }
107
+
108
+ describe("OAuth refresh failure semantics", () => {
109
+ test("a rejected refresh persists refresh-failed + lastErrorMessage and throws typed error", async () => {
110
+ upsertOAuthApp("acme", appConfig("acme"));
111
+ const authId = seedDefault("acme", "acme-refresh", 60 * 1000);
112
+ mockTokenEndpoint(["acme-refresh"]);
113
+
114
+ await expect(forceRefreshAuthorizationOrThrow(authId)).rejects.toBeInstanceOf(
115
+ OAuthRefreshError,
116
+ );
117
+
118
+ const after = getAuthorizationById(authId);
119
+ expect(after?.status).toBe("refresh-failed");
120
+ expect(after?.lastErrorMessage).toBeTruthy();
121
+ expect(after?.lastErrorMessage).toContain("400");
122
+ });
123
+
124
+ test("a recovered provider flips a refresh-failed authorization back to active", async () => {
125
+ upsertOAuthApp("acme", appConfig("acme"));
126
+ const authId = seedDefault("acme", "acme-refresh", 60 * 1000);
127
+
128
+ mockTokenEndpoint(["acme-refresh"]);
129
+ await expect(forceRefreshAuthorizationOrThrow(authId)).rejects.toBeInstanceOf(
130
+ OAuthRefreshError,
131
+ );
132
+ expect(getAuthorizationById(authId)?.status).toBe("refresh-failed");
133
+
134
+ // Provider recovers.
135
+ mockTokenEndpoint();
136
+ await forceRefreshAuthorizationOrThrow(authId);
137
+ const healed = getAuthorizationById(authId);
138
+ expect(healed?.status).toBe("active");
139
+ expect(healed?.lastErrorMessage).toBeNull();
140
+ expect(healed?.accessToken).toBe("healed-access-token");
141
+ });
142
+
143
+ test("the broker surfaces a failed OAuth binding while other bindings still resolve", async () => {
144
+ upsertOAuthApp("acme", { ...appConfig("acme"), displayName: "Acme Corp" });
145
+ const authId = seedDefault("acme", "acme-refresh", 60 * 1000);
146
+ upsertCredentialBinding({
147
+ configKey: "ACME_OAUTH",
148
+ allowedHosts: ["api.acme.test"],
149
+ headerTemplate: "Authorization: Bearer [REDACTED:ACME_OAUTH]",
150
+ authKind: "oauth",
151
+ oauthAuthorizationId: authId,
152
+ });
153
+ upsertCredentialBinding({
154
+ configKey: "HEALTHY_CONFIG",
155
+ allowedHosts: ["api.acme.test"],
156
+ headerTemplate: "Authorization: Bearer [REDACTED:HEALTHY_CONFIG]",
157
+ });
158
+ process.env.HEALTHY_CONFIG = "healthy-config-value";
159
+ mockTokenEndpoint(["acme-refresh"]);
160
+
161
+ try {
162
+ const { egressSecrets, failedBindings } = await buildScriptCredentialBindingsWithFailures({});
163
+
164
+ expect(egressSecrets.some((b) => b.configKey === "ACME_OAUTH")).toBe(false);
165
+ expect(egressSecrets).toContainEqual(
166
+ expect.objectContaining({ configKey: "HEALTHY_CONFIG", value: "healthy-config-value" }),
167
+ );
168
+ expect(failedBindings).toContainEqual(
169
+ expect.objectContaining({
170
+ placeholder: "[REDACTED:ACME_OAUTH]",
171
+ allowedHosts: ["api.acme.test"],
172
+ reason: "refresh_rejected",
173
+ authorizationLabel: "Acme Corp",
174
+ }),
175
+ );
176
+ // The authorization is now persisted as broken.
177
+ expect(getAuthorizationById(authId)?.status).toBe("refresh-failed");
178
+ } finally {
179
+ delete process.env.HEALTHY_CONFIG;
180
+ }
181
+ });
182
+
183
+ test("the sandbox fetch throws a typed error for a failed binding's host", async () => {
184
+ try {
185
+ // Observer stub so non-blocked requests pass through without real network I/O.
186
+ let observedCalls = 0;
187
+ globalThis.fetch = (async () => {
188
+ observedCalls++;
189
+ return Response.json({ ok: true });
190
+ }) as typeof fetch;
191
+
192
+ patchFetchWithCredentialBroker(
193
+ [],
194
+ [
195
+ {
196
+ placeholder: "[REDACTED:ACME_OAUTH]",
197
+ allowedHosts: ["api.acme.test"],
198
+ reason: "refresh_rejected",
199
+ authorizationLabel: "Acme Corp",
200
+ },
201
+ ],
202
+ );
203
+
204
+ // Targets the failed binding's host WITH its placeholder → typed throw.
205
+ expect(() =>
206
+ fetch("https://api.acme.test/v1/things", {
207
+ headers: { Authorization: "Bearer [REDACTED:ACME_OAUTH]" },
208
+ }),
209
+ ).toThrow(/OAuth authorization 'Acme Corp' is in refresh-failed state: refresh_rejected/);
210
+
211
+ // A request to the same host WITHOUT the placeholder is not blocked.
212
+ await fetch("https://api.acme.test/v1/public", { headers: { "x-foo": "bar" } });
213
+ // A different host is not blocked even with the placeholder present.
214
+ await fetch("https://example.com/leak", {
215
+ headers: { Authorization: "Bearer [REDACTED:ACME_OAUTH]" },
216
+ });
217
+ expect(observedCalls).toBe(2);
218
+ } finally {
219
+ globalThis.fetch = originalFetch;
220
+ }
221
+ });
222
+
223
+ test("two authorizations under one app refresh independently (per-authorization isolation)", async () => {
224
+ upsertOAuthApp("acme", appConfig("acme"));
225
+ const app = getOAuthApp("acme");
226
+ if (!app) throw new Error("app not created");
227
+
228
+ const broken = upsertAuthorization({
229
+ appId: app.id,
230
+ label: "default",
231
+ accessToken: "broken-access",
232
+ refreshToken: "broken-refresh",
233
+ expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
234
+ status: "active",
235
+ });
236
+ const healthy = upsertAuthorization({
237
+ appId: app.id,
238
+ label: "secondary",
239
+ accessToken: "healthy-access",
240
+ refreshToken: "healthy-refresh",
241
+ expiresAt: new Date(Date.now() + 60 * 1000).toISOString(),
242
+ status: "active",
243
+ });
244
+
245
+ mockTokenEndpoint(["broken-refresh"]);
246
+
247
+ const [brokenResult, healthyResult] = await Promise.allSettled([
248
+ ensureAuthorizationTokenOrThrow(broken.id),
249
+ ensureAuthorizationTokenOrThrow(healthy.id),
250
+ ]);
251
+
252
+ expect(brokenResult.status).toBe("rejected");
253
+ if (brokenResult.status === "rejected") {
254
+ expect(brokenResult.reason).toBeInstanceOf(OAuthRefreshError);
255
+ }
256
+ expect(healthyResult.status).toBe("fulfilled");
257
+
258
+ expect(getAuthorizationById(broken.id)?.status).toBe("refresh-failed");
259
+ const healed = getAuthorizationById(healthy.id);
260
+ expect(healed?.status).toBe("active");
261
+ expect(healed?.accessToken).toBe("healed-access-token");
262
+ });
263
+ });
@@ -1,9 +1,20 @@
1
1
  import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import { closeDb, getDb, initDb } from "../be/db";
4
- import { getOAuthTokens, storeOAuthTokens, upsertOAuthApp } from "../be/db-queries/oauth";
4
+ import {
5
+ getAuthorizationById,
6
+ getDefaultAuthorizationIdForProvider,
7
+ getOAuthTokens,
8
+ storeOAuthTokens,
9
+ upsertOAuthApp,
10
+ } from "../be/db-queries/oauth";
5
11
  import { sweepOAuthTokenRefresh } from "../be/oauth-refresh-sweep";
6
12
 
13
+ function authorizationStatus(provider: string): string | undefined {
14
+ const id = getDefaultAuthorizationIdForProvider(provider);
15
+ return id ? getAuthorizationById(id)?.status : undefined;
16
+ }
17
+
7
18
  const TEST_DB_PATH = "./test-oauth-refresh-sweep.sqlite";
8
19
 
9
20
  const originalFetch = globalThis.fetch;
@@ -34,7 +45,11 @@ function seedTokens(
34
45
  function backdateTokenRow(provider: string, ageMs: number): void {
35
46
  const backdated = new Date(Date.now() - ageMs).toISOString();
36
47
  getDb()
37
- .query("UPDATE oauth_tokens SET updatedAt = ? WHERE provider = ?")
48
+ .query(
49
+ `UPDATE oauth_authorizations SET updatedAt = ?
50
+ WHERE appId = (SELECT id FROM oauth_apps WHERE provider = ? AND mcpServerId IS NULL LIMIT 1)
51
+ AND label = 'default'`,
52
+ )
38
53
  .run(backdated, provider);
39
54
  }
40
55
 
@@ -73,7 +88,7 @@ beforeAll(() => {
73
88
  beforeEach(() => {
74
89
  globalThis.fetch = originalFetch;
75
90
  getDb().run("DELETE FROM oauth_refresh_locks");
76
- getDb().run("DELETE FROM oauth_tokens");
91
+ getDb().run("DELETE FROM oauth_authorizations");
77
92
  getDb().run("DELETE FROM oauth_apps");
78
93
  });
79
94
 
@@ -112,6 +127,28 @@ describe("sweepOAuthTokenRefresh", () => {
112
127
  expect(getOAuthTokens("vendor_a")?.accessToken).toBe("vendor_a-old-access-token");
113
128
  });
114
129
 
130
+ test("skips a non-expiring (NULL expiry) row instead of proactively refreshing it", async () => {
131
+ upsertOAuthApp("vendor_a", appConfig("vendor_a"));
132
+ // GitHub-preset shape: a live token WITH a refresh token but NO expiry.
133
+ // NULL expiry means "does not expire" — the sweep must not treat it as
134
+ // expiring, must not refresh it proactively, and must never mark it
135
+ // refresh-failed.
136
+ storeOAuthTokens("vendor_a", {
137
+ accessToken: "vendor_a-old-access-token",
138
+ refreshToken: "vendor_a-refresh-token",
139
+ expiresAt: null,
140
+ scope: "read,write",
141
+ });
142
+
143
+ const captured = mockTokenEndpoint();
144
+ const result = await sweepOAuthTokenRefresh();
145
+
146
+ expect(result).toEqual({ checked: 1, refreshed: 0, skipped: 1, failed: [] });
147
+ expect(captured).toHaveLength(0);
148
+ expect(authorizationStatus("vendor_a")).toBe("active");
149
+ expect(getOAuthTokens("vendor_a")?.accessToken).toBe("vendor_a-old-access-token");
150
+ });
151
+
115
152
  test("skips fresh rows that are neither expiring nor stale", async () => {
116
153
  upsertOAuthApp("vendor_a", appConfig("vendor_a"));
117
154
  seedTokens("vendor_a", { expiresInMs: 24 * 60 * 60 * 1000 }); // expires in 24h, just updated
@@ -156,5 +193,48 @@ describe("sweepOAuthTokenRefresh", () => {
156
193
  ]);
157
194
  expect(getOAuthTokens("a_broken")?.accessToken).toBe("a_broken-old-access-token");
158
195
  expect(getOAuthTokens("b_healthy")?.accessToken).toBe("new-access-token");
196
+ // A failing refresh persists refresh-failed on the authorization; the
197
+ // healthy one stays active.
198
+ expect(authorizationStatus("a_broken")).toBe("refresh-failed");
199
+ expect(authorizationStatus("b_healthy")).toBe("active");
200
+ });
201
+
202
+ test("a refresh-failed authorization stays in the sweep and heals on the next pass", async () => {
203
+ upsertOAuthApp("vendor_a", appConfig("vendor_a"));
204
+ // Not expiring on its own — the only reason it should be swept is the
205
+ // persisted refresh-failed status.
206
+ seedTokens("vendor_a", { expiresInMs: 24 * 60 * 60 * 1000 });
207
+
208
+ // Pass 1: provider is down → status flips to refresh-failed.
209
+ mockTokenEndpoint(["https://oauth.vendor_a.test/token"]);
210
+ // Force a first failure via a near-expiry token so the sweep attempts it.
211
+ getDb()
212
+ .query(
213
+ `UPDATE oauth_authorizations SET expiresAt = ?
214
+ WHERE appId = (SELECT id FROM oauth_apps WHERE provider = 'vendor_a' AND mcpServerId IS NULL LIMIT 1)
215
+ AND label = 'default'`,
216
+ )
217
+ .run(new Date(Date.now() + 60 * 1000).toISOString());
218
+ const first = await sweepOAuthTokenRefresh();
219
+ expect(first.failed).toHaveLength(1);
220
+ expect(authorizationStatus("vendor_a")).toBe("refresh-failed");
221
+
222
+ // Bump expiry far into the future so the ONLY reason to sweep is the
223
+ // refresh-failed status, proving failed rows are retried each pass.
224
+ getDb()
225
+ .query(
226
+ `UPDATE oauth_authorizations SET expiresAt = ?
227
+ WHERE appId = (SELECT id FROM oauth_apps WHERE provider = 'vendor_a' AND mcpServerId IS NULL LIMIT 1)
228
+ AND label = 'default'`,
229
+ )
230
+ .run(new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString());
231
+
232
+ // Pass 2: provider recovers → refresh succeeds → status back to active.
233
+ mockTokenEndpoint();
234
+ const second = await sweepOAuthTokenRefresh();
235
+ expect(second.refreshed).toBe(1);
236
+ expect(second.failed).toHaveLength(0);
237
+ expect(authorizationStatus("vendor_a")).toBe("active");
238
+ expect(getOAuthTokens("vendor_a")?.accessToken).toBe("new-access-token");
159
239
  });
160
240
  });
@@ -1,12 +1,10 @@
1
- import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
1
+ import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
3
  import { closeDb, initDb } from "../be/db";
4
- import { upsertOAuthApp } from "../be/db-queries/oauth";
4
+ import { consumeOAuthPending, upsertOAuthApp } from "../be/db-queries/oauth";
5
5
  import {
6
- _clearPendingStates,
7
- _getPendingState,
8
6
  buildAuthorizationUrl,
9
- exchangeCode,
7
+ exchangeAuthorizationCode,
10
8
  type OAuthProviderConfig,
11
9
  } from "../oauth/wrapper";
12
10
 
@@ -18,14 +16,14 @@ const testConfig: OAuthProviderConfig = {
18
16
  clientSecret: "test-client-secret",
19
17
  authorizeUrl: "https://example.com/oauth/authorize",
20
18
  tokenUrl: "https://example.com/oauth/token",
21
- redirectUri: "http://localhost:3013/callback",
19
+ redirectUri: "http://localhost:3013/api/oauth/callback",
22
20
  scopes: ["read", "write"],
23
21
  extraParams: { actor: "app" },
24
22
  };
25
23
 
26
24
  beforeAll(() => {
27
25
  initDb(TEST_DB_PATH);
28
- // Create an oauth_app row so token storage works (FK constraint)
26
+ // Create an oauth_app row so pending persistence works (FK constraint on appId).
29
27
  upsertOAuthApp("test-provider", {
30
28
  clientId: testConfig.clientId,
31
29
  clientSecret: testConfig.clientSecret,
@@ -36,10 +34,6 @@ beforeAll(() => {
36
34
  });
37
35
  });
38
36
 
39
- beforeEach(() => {
40
- _clearPendingStates();
41
- });
42
-
43
37
  afterAll(async () => {
44
38
  closeDb();
45
39
  await unlink(TEST_DB_PATH).catch(() => {});
@@ -58,7 +52,7 @@ describe("buildAuthorizationUrl", () => {
58
52
  const url = new URL(result.url);
59
53
  expect(url.origin + url.pathname).toBe("https://example.com/oauth/authorize");
60
54
  expect(url.searchParams.get("client_id")).toBe("test-client-id");
61
- expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:3013/callback");
55
+ expect(url.searchParams.get("redirect_uri")).toBe("http://localhost:3013/api/oauth/callback");
62
56
  expect(url.searchParams.get("response_type")).toBe("code");
63
57
  expect(url.searchParams.get("scope")).toBe("read,write");
64
58
  expect(url.searchParams.get("state")).toBe(result.state);
@@ -72,14 +66,21 @@ describe("buildAuthorizationUrl", () => {
72
66
  expect(url.searchParams.get("actor")).toBe("app");
73
67
  });
74
68
 
75
- test("stores pending state with code verifier", async () => {
69
+ test("persists a DB pending row (default label, generic flow) with the code verifier", async () => {
76
70
  const result = await buildAuthorizationUrl(testConfig);
77
- const pending = _getPendingState(result.state);
71
+ const pending = consumeOAuthPending(result.state);
78
72
 
79
73
  expect(pending).toBeTruthy();
80
74
  expect(pending!.codeVerifier).toBe(result.codeVerifier);
81
- expect(pending!.config.provider).toBe("test-provider");
82
- expect(pending!.createdAt).toBeGreaterThan(0);
75
+ expect(pending!.label).toBe("default");
76
+ expect(pending!.flow).toBe("generic");
77
+ expect(pending!.redirectUri).toBe(testConfig.redirectUri);
78
+ });
79
+
80
+ test("honors an explicit label option", async () => {
81
+ const result = await buildAuthorizationUrl(testConfig, { label: "support" });
82
+ const pending = consumeOAuthPending(result.state);
83
+ expect(pending!.label).toBe("support");
83
84
  });
84
85
 
85
86
  test("generates unique state for each call", async () => {
@@ -90,60 +91,81 @@ describe("buildAuthorizationUrl", () => {
90
91
  expect(result1.codeVerifier).not.toBe(result2.codeVerifier);
91
92
  });
92
93
 
93
- test("works without extra params", async () => {
94
- const configNoExtras: OAuthProviderConfig = {
95
- ...testConfig,
96
- extraParams: undefined,
97
- };
94
+ test("throws when the provider has no configured app", async () => {
95
+ await expect(
96
+ buildAuthorizationUrl({ ...testConfig, provider: "no-such-provider" }),
97
+ ).rejects.toThrow("is not configured");
98
+ });
98
99
 
100
+ test("works without extra params", async () => {
101
+ const configNoExtras: OAuthProviderConfig = { ...testConfig, extraParams: undefined };
99
102
  const result = await buildAuthorizationUrl(configNoExtras);
100
103
  const url = new URL(result.url);
101
104
  expect(url.searchParams.get("actor")).toBeNull();
102
105
  });
103
106
  });
104
107
 
105
- describe("exchangeCode", () => {
106
- test("rejects invalid state", async () => {
107
- await expect(exchangeCode(testConfig, "some-code", "invalid-state")).rejects.toThrow(
108
- "Invalid or expired OAuth state",
108
+ describe("exchangeAuthorizationCode", () => {
109
+ test("exchanges a code for tokens (pure — no persistence)", async () => {
110
+ const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
111
+ Response.json({
112
+ access_token: "atk",
113
+ token_type: "Bearer",
114
+ expires_in: 3600,
115
+ refresh_token: "rtk",
116
+ scope: "read write",
117
+ }),
109
118
  );
110
- });
111
-
112
- test("rejects already-consumed state", async () => {
113
- const result = await buildAuthorizationUrl(testConfig);
114
-
115
- // Mock fetch to fail immediately (avoids real network call to example.com)
116
- const fetchSpy = spyOn(globalThis, "fetch").mockRejectedValueOnce(new Error("Network error"));
117
119
  try {
118
- await exchangeCode(testConfig, "some-code", result.state);
119
- } catch {
120
- // Expected: fetch fails, but state is consumed
120
+ const tokens = await exchangeAuthorizationCode(testConfig, {
121
+ code: "the-code",
122
+ codeVerifier: "the-verifier",
123
+ redirectUri: testConfig.redirectUri,
124
+ });
125
+ expect(tokens.accessToken).toBe("atk");
126
+ expect(tokens.refreshToken).toBe("rtk");
127
+ expect(tokens.expiresIn).toBe(3600);
128
+ expect(tokens.scope).toBe("read write");
129
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
130
+ expect(String(init.body)).toContain("grant_type=authorization_code");
131
+ expect(String(init.body)).toContain("code=the-code");
121
132
  } finally {
122
133
  fetchSpy.mockRestore();
123
134
  }
135
+ });
124
136
 
125
- // Second attempt with the same state should fail with "Invalid or expired"
126
- await expect(exchangeCode(testConfig, "some-code", result.state)).rejects.toThrow(
127
- "Invalid or expired OAuth state",
137
+ test("throws on a non-OK token response", async () => {
138
+ const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
139
+ new Response("nope", { status: 400 }),
128
140
  );
141
+ try {
142
+ await expect(
143
+ exchangeAuthorizationCode(testConfig, {
144
+ code: "x",
145
+ codeVerifier: "y",
146
+ redirectUri: testConfig.redirectUri,
147
+ }),
148
+ ).rejects.toThrow("Token exchange failed");
149
+ } finally {
150
+ fetchSpy.mockRestore();
151
+ }
129
152
  });
130
- });
131
-
132
- describe("state TTL cleanup", () => {
133
- test("expired states are cleaned up on next buildAuthorizationUrl call", async () => {
134
- // Manually insert an "expired" entry by backdating createdAt
135
- const result = await buildAuthorizationUrl(testConfig);
136
- const pending = _getPendingState(result.state);
137
- expect(pending).toBeTruthy();
138
-
139
- // Backdate to 11 minutes ago (past the 10-minute TTL)
140
- pending!.createdAt = Date.now() - 11 * 60 * 1000;
141
153
 
142
- // Building a new URL triggers cleanup
143
- await buildAuthorizationUrl(testConfig);
144
-
145
- // The expired state should be gone
146
- const expired = _getPendingState(result.state);
147
- expect(expired).toBeUndefined();
154
+ test("sends Accept: application/json so form-encoding providers (GitHub) return JSON", async () => {
155
+ const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValueOnce(
156
+ Response.json({ access_token: "atk", token_type: "Bearer" }),
157
+ );
158
+ try {
159
+ await exchangeAuthorizationCode(testConfig, {
160
+ code: "the-code",
161
+ codeVerifier: "the-verifier",
162
+ redirectUri: testConfig.redirectUri,
163
+ });
164
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit];
165
+ const headers = init.headers as Record<string, string>;
166
+ expect(headers.Accept).toBe("application/json");
167
+ } finally {
168
+ fetchSpy.mockRestore();
169
+ }
148
170
  });
149
171
  });
@@ -60,12 +60,13 @@ describe("Session templates — registration", () => {
60
60
  await ensureTemplatesRegistered();
61
61
  });
62
62
 
63
- test("all 16 system templates are registered", () => {
63
+ test("all 17 system templates are registered", () => {
64
64
  const systemTemplates = [
65
65
  "system.agent.role",
66
66
  "system.agent.register",
67
67
  "system.agent.lead",
68
68
  "system.agent.slack",
69
+ "system.agent.messaging",
69
70
  "system.agent.worker",
70
71
  "system.agent.worker.slack",
71
72
  "system.agent.filesystem",
@@ -98,13 +99,13 @@ describe("Session templates — registration", () => {
98
99
  }
99
100
  });
100
101
 
101
- test("total of 24 session/system templates registered", () => {
102
+ test("total of 25 session/system templates registered", () => {
102
103
  const all = getAllTemplateDefinitions();
103
104
  const sessionSystem = all.filter((d) => d.category === "system" || d.category === "session");
104
- // 24 = the original 19 + `system.session.worker.pi` + `system.agent.seed_scripts`
105
+ // 25 = the original 19 + `system.session.worker.pi` + `system.agent.seed_scripts`
105
106
  // + `system.agent.script_rubric` + `system.agent.scripts_only_mode`
106
- // + `system.agent.scripts_only_mode.slack`.
107
- expect(sessionSystem.length).toBe(24);
107
+ // + `system.agent.scripts_only_mode.slack` + `system.agent.messaging`.
108
+ expect(sessionSystem.length).toBe(25);
108
109
  });
109
110
  });
110
111