@desplega.ai/agent-swarm 1.93.0 → 1.95.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 (85) hide show
  1. package/README.md +2 -2
  2. package/openapi.json +180 -1
  3. package/package.json +4 -3
  4. package/src/be/db.ts +74 -9
  5. package/src/be/migrations/090_model_tiers.sql +2 -0
  6. package/src/be/migrations/091_seed_swarm_operations_metrics.sql +12 -0
  7. package/src/be/migrations/092_metrics_dashboard_combobox_filters.sql +68 -0
  8. package/src/be/migrations/093_slack_message_tracking.sql +6 -0
  9. package/src/be/migrations/094_mcp_extra_authorize_params.sql +4 -0
  10. package/src/be/migrations/runner.ts +52 -0
  11. package/src/be/modelsdev-cache.json +2060 -198
  12. package/src/be/scripts/boot-reembed.ts +74 -0
  13. package/src/be/scripts/db.ts +19 -3
  14. package/src/be/seed/index.ts +1 -1
  15. package/src/be/seed/registry.ts +2 -2
  16. package/src/be/seed/runner.ts +5 -5
  17. package/src/be/seed/types.ts +6 -1
  18. package/src/be/seed-pricing.ts +1 -0
  19. package/src/be/seed-scripts/index.ts +3 -2
  20. package/src/be/skill-sync.ts +4 -4
  21. package/src/be/swarm-config-guard.ts +8 -0
  22. package/src/commands/provider-credentials.ts +14 -8
  23. package/src/commands/runner.ts +84 -13
  24. package/src/http/index.ts +13 -2
  25. package/src/http/mcp-oauth.ts +14 -0
  26. package/src/http/metrics.ts +55 -6
  27. package/src/http/schedules.ts +16 -15
  28. package/src/http/script-runs.ts +7 -1
  29. package/src/http/scripts.ts +147 -1
  30. package/src/http/tasks.ts +7 -0
  31. package/src/model-tiers.ts +140 -0
  32. package/src/oauth/mcp-wrapper.ts +14 -0
  33. package/src/providers/claude-managed-models.ts +9 -0
  34. package/src/providers/codex-skill-resolver.ts +22 -8
  35. package/src/providers/opencode-adapter.ts +21 -2
  36. package/src/providers/pi-mono-adapter.ts +143 -26
  37. package/src/providers/types.ts +12 -0
  38. package/src/scheduler/scheduler.ts +22 -34
  39. package/src/server-user.ts +8 -2
  40. package/src/slack/responses.ts +39 -11
  41. package/src/slack/watcher.ts +121 -8
  42. package/src/tests/agents-list-model-display.test.ts +13 -0
  43. package/src/tests/aws-error-classifier.test.ts +148 -0
  44. package/src/tests/claude-managed-adapter.test.ts +12 -0
  45. package/src/tests/context-window.test.ts +7 -0
  46. package/src/tests/credential-check.test.ts +185 -46
  47. package/src/tests/harness-provider-resolution.test.ts +23 -0
  48. package/src/tests/http-api-integration.test.ts +19 -0
  49. package/src/tests/mcp-oauth-queries.test.ts +71 -1
  50. package/src/tests/mcp-oauth-wrapper.test.ts +109 -0
  51. package/src/tests/metrics-http.test.ts +137 -3
  52. package/src/tests/migration-046-budgets.test.ts +33 -0
  53. package/src/tests/migration-runner-regressions.test.ts +69 -0
  54. package/src/tests/model-control.test.ts +162 -46
  55. package/src/tests/opencode-adapter.test.ts +38 -1
  56. package/src/tests/pi-mono-adapter.test.ts +319 -0
  57. package/src/tests/provider-command-format.test.ts +12 -0
  58. package/src/tests/providers/pi-cost.test.ts +9 -0
  59. package/src/tests/runner-fallback-output.test.ts +50 -0
  60. package/src/tests/scripts-boot-reembed.test.ts +163 -0
  61. package/src/tests/scripts-embeddings.test.ts +90 -0
  62. package/src/tests/seed.test.ts +26 -1
  63. package/src/tests/session-costs-model-key-normalize.test.ts +2 -0
  64. package/src/tests/skill-fs-writer.test.ts +7 -1
  65. package/src/tests/skill-sync.test.ts +15 -3
  66. package/src/tests/slack-watcher.test.ts +66 -0
  67. package/src/tests/workflow-agent-task.test.ts +5 -2
  68. package/src/tests/workflow-validation-port-routing.test.ts +181 -0
  69. package/src/tools/mcp-servers/mcp-server-create.ts +7 -0
  70. package/src/tools/mcp-servers/mcp-server-update.ts +8 -0
  71. package/src/tools/memory-get.ts +11 -0
  72. package/src/tools/memory-search.ts +18 -0
  73. package/src/tools/schedules/create-schedule.ts +71 -70
  74. package/src/tools/schedules/update-schedule.ts +43 -31
  75. package/src/tools/send-task.ts +16 -5
  76. package/src/tools/task-action.ts +11 -3
  77. package/src/types.ts +30 -0
  78. package/src/utils/aws-error-classifier.ts +97 -0
  79. package/src/utils/context-window.ts +2 -0
  80. package/src/utils/credentials.test.ts +68 -0
  81. package/src/utils/credentials.ts +44 -3
  82. package/src/utils/pretty-print.ts +25 -10
  83. package/src/utils/skill-fs-writer.ts +11 -3
  84. package/src/workflows/engine.ts +3 -2
  85. package/src/workflows/executors/agent-task.ts +3 -1
@@ -136,63 +136,98 @@ describe("checkCodexCredentials", () => {
136
136
 
137
137
  // ─── pi-mono ─────────────────────────────────────────────────────────────────
138
138
 
139
+ /**
140
+ * Stub probes for Bedrock tests. These replace the real @aws-sdk/client-bedrock
141
+ * ListFoundationModels call so unit tests never hit AWS.
142
+ */
143
+ const bedrockProbeSuccess = async () => {};
144
+ const bedrockProbeAuthFail = async () => {
145
+ throw new Error("ExpiredTokenException: The security token included in the request is expired");
146
+ };
147
+ const bedrockProbeAccessFail = async () => {
148
+ throw new Error("AccessDeniedException: not authorized to perform: bedrock:ListFoundationModels");
149
+ };
150
+ const bedrockProbeRegionFail = async () => {
151
+ throw new Error(
152
+ "ValidationException: Provided region us-west-99 is not supported by Amazon Bedrock",
153
+ );
154
+ };
155
+
139
156
  describe("checkPiMonoCredentials", () => {
140
157
  const HOME = "/home/worker";
141
158
  const AUTH = `${HOME}/.pi/agent/auth.json`;
142
159
 
143
- test("ready (file) when ~/.pi/agent/auth.json exists", () => {
144
- const status = checkPiMonoCredentials({}, { homeDir: HOME, fs: fsWith(new Set([AUTH])) });
160
+ test("ready (file) when ~/.pi/agent/auth.json exists", async () => {
161
+ const status = await checkPiMonoCredentials({}, { homeDir: HOME, fs: fsWith(new Set([AUTH])) });
145
162
  expect(status.ready).toBe(true);
146
163
  expect(status.satisfiedBy).toBe("file");
147
164
  });
148
165
 
149
- test("permissive: ready when MODEL_OVERRIDE unset and any one supported key is present", () => {
166
+ test("permissive: ready when MODEL_OVERRIDE unset and any one supported key is present", async () => {
150
167
  expect(
151
- checkPiMonoCredentials({ ANTHROPIC_API_KEY: "x" }, { homeDir: HOME, fs: noFiles }).ready,
168
+ (await checkPiMonoCredentials({ ANTHROPIC_API_KEY: "x" }, { homeDir: HOME, fs: noFiles }))
169
+ .ready,
152
170
  ).toBe(true);
153
171
  expect(
154
- checkPiMonoCredentials({ OPENROUTER_API_KEY: "x" }, { homeDir: HOME, fs: noFiles }).ready,
172
+ (await checkPiMonoCredentials({ OPENROUTER_API_KEY: "x" }, { homeDir: HOME, fs: noFiles }))
173
+ .ready,
155
174
  ).toBe(true);
156
175
  expect(
157
- checkPiMonoCredentials({ OPENAI_API_KEY: "x" }, { homeDir: HOME, fs: noFiles }).ready,
176
+ (await checkPiMonoCredentials({ OPENAI_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })).ready,
158
177
  ).toBe(true);
159
178
  });
160
179
 
161
- test("permissive: not ready when MODEL_OVERRIDE unset and no keys are set", () => {
162
- const status = checkPiMonoCredentials({}, { homeDir: HOME, fs: noFiles });
180
+ test("permissive: not ready when MODEL_OVERRIDE unset and no keys are set", async () => {
181
+ const status = await checkPiMonoCredentials({}, { homeDir: HOME, fs: noFiles });
163
182
  expect(status.ready).toBe(false);
164
183
  expect(status.missing).toContain("ANTHROPIC_API_KEY");
165
184
  expect(status.missing).toContain("OPENROUTER_API_KEY");
166
185
  expect(status.missing).toContain("OPENAI_API_KEY");
167
186
  });
168
187
 
169
- test("strict: MODEL_OVERRIDE=anthropic/... requires ANTHROPIC_API_KEY", () => {
188
+ test("strict: MODEL_OVERRIDE=anthropic/... requires ANTHROPIC_API_KEY", async () => {
170
189
  const env = { MODEL_OVERRIDE: "anthropic/claude-sonnet-4" };
171
- expect(checkPiMonoCredentials(env, { homeDir: HOME, fs: noFiles }).ready).toBe(false);
190
+ expect((await checkPiMonoCredentials(env, { homeDir: HOME, fs: noFiles })).ready).toBe(false);
172
191
  expect(
173
- checkPiMonoCredentials({ ...env, ANTHROPIC_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
174
- .ready,
192
+ (
193
+ await checkPiMonoCredentials(
194
+ { ...env, ANTHROPIC_API_KEY: "x" },
195
+ { homeDir: HOME, fs: noFiles },
196
+ )
197
+ ).ready,
175
198
  ).toBe(true);
176
199
  // OPENROUTER_API_KEY does NOT satisfy an anthropic-prefixed model
177
200
  expect(
178
- checkPiMonoCredentials({ ...env, OPENROUTER_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
179
- .ready,
201
+ (
202
+ await checkPiMonoCredentials(
203
+ { ...env, OPENROUTER_API_KEY: "x" },
204
+ { homeDir: HOME, fs: noFiles },
205
+ )
206
+ ).ready,
180
207
  ).toBe(false);
181
208
  });
182
209
 
183
- test("strict: MODEL_OVERRIDE=openrouter/... requires OPENROUTER_API_KEY", () => {
210
+ test("strict: MODEL_OVERRIDE=openrouter/... requires OPENROUTER_API_KEY", async () => {
184
211
  const env = { MODEL_OVERRIDE: "openrouter/google/gemini-2.5-flash-lite" };
185
212
  expect(
186
- checkPiMonoCredentials({ ...env, OPENROUTER_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
187
- .ready,
213
+ (
214
+ await checkPiMonoCredentials(
215
+ { ...env, OPENROUTER_API_KEY: "x" },
216
+ { homeDir: HOME, fs: noFiles },
217
+ )
218
+ ).ready,
188
219
  ).toBe(true);
189
220
  expect(
190
- checkPiMonoCredentials({ ...env, ANTHROPIC_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
191
- .ready,
221
+ (
222
+ await checkPiMonoCredentials(
223
+ { ...env, ANTHROPIC_API_KEY: "x" },
224
+ { homeDir: HOME, fs: noFiles },
225
+ )
226
+ ).ready,
192
227
  ).toBe(false);
193
228
  });
194
229
 
195
- test("shortname `sonnet` accepts ANTHROPIC_API_KEY *or* OPENROUTER_API_KEY", () => {
230
+ test("shortname `sonnet` accepts ANTHROPIC_API_KEY *or* OPENROUTER_API_KEY", async () => {
196
231
  // Anthropic-shortname models (sonnet/haiku/opus) prefer the native
197
232
  // ANTHROPIC_* credential, but pi-mono-adapter reroutes through the
198
233
  // OpenRouter mirror when only OPENROUTER_API_KEY is available — so the
@@ -201,76 +236,180 @@ describe("checkPiMonoCredentials", () => {
201
236
  // tracked in HEARTBEAT.md (2026-04-13 → 2026-05-11).
202
237
  const env = { MODEL_OVERRIDE: "sonnet" };
203
238
  expect(
204
- checkPiMonoCredentials({ ...env, ANTHROPIC_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
205
- .ready,
239
+ (
240
+ await checkPiMonoCredentials(
241
+ { ...env, ANTHROPIC_API_KEY: "x" },
242
+ { homeDir: HOME, fs: noFiles },
243
+ )
244
+ ).ready,
206
245
  ).toBe(true);
207
246
  expect(
208
- checkPiMonoCredentials({ ...env, OPENROUTER_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
209
- .ready,
247
+ (
248
+ await checkPiMonoCredentials(
249
+ { ...env, OPENROUTER_API_KEY: "x" },
250
+ { homeDir: HOME, fs: noFiles },
251
+ )
252
+ ).ready,
210
253
  ).toBe(true);
211
254
  // Neither key set → still not ready, and missing includes both options.
212
- const empty = checkPiMonoCredentials(env, { homeDir: HOME, fs: noFiles });
255
+ const empty = await checkPiMonoCredentials(env, { homeDir: HOME, fs: noFiles });
213
256
  expect(empty.ready).toBe(false);
214
257
  expect(empty.missing).toContain("ANTHROPIC_API_KEY");
215
258
  expect(empty.missing).toContain("OPENROUTER_API_KEY");
216
259
  });
217
260
 
218
- test("haiku and opus shortnames also accept OPENROUTER_API_KEY", () => {
261
+ test("haiku and opus shortnames also accept OPENROUTER_API_KEY", async () => {
219
262
  for (const model of ["haiku", "opus"]) {
220
263
  const env = { MODEL_OVERRIDE: model };
221
264
  expect(
222
- checkPiMonoCredentials({ ...env, OPENROUTER_API_KEY: "x" }, { homeDir: HOME, fs: noFiles })
223
- .ready,
265
+ (
266
+ await checkPiMonoCredentials(
267
+ { ...env, OPENROUTER_API_KEY: "x" },
268
+ { homeDir: HOME, fs: noFiles },
269
+ )
270
+ ).ready,
224
271
  ).toBe(true);
225
272
  }
226
273
  });
227
274
 
228
- // ─── amazon-bedrock: AWS SDK delegates credential resolution ───────────────
229
- // When MODEL_OVERRIDE selects amazon-bedrock, pi-mono routes through the AWS
230
- // SDK's default credential chain (env, ~/.aws/*, SSO, IMDS, assume-role,
231
- // web-identity, …). agent-swarm does no presence check beyond detecting the
232
- // `amazon-bedrock/` prefix — the SDK validates at first inference call.
233
- // Mirrors the codex auth.json "presence-only" pattern.
275
+ // ─── amazon-bedrock prefix inference: probe triggered, result depends on creds ─
276
+ // When BEDROCK_AUTH_MODE is absent and MODEL_OVERRIDE starts with
277
+ // "amazon-bedrock/", the probe runs. Tests inject a stub to avoid hitting AWS.
234
278
 
235
- test("amazon-bedrock: ready (sdk-delegated) with no env vars and no auth.json", () => {
279
+ test("amazon-bedrock: probe success → ready (sdk-delegated)", async () => {
236
280
  const env = { MODEL_OVERRIDE: "amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0" };
237
- const status = checkPiMonoCredentials(env, { homeDir: HOME, fs: noFiles });
281
+ const status = await checkPiMonoCredentials(env, {
282
+ homeDir: HOME,
283
+ fs: noFiles,
284
+ bedrockProbe: bedrockProbeSuccess,
285
+ });
238
286
  expect(status.ready).toBe(true);
239
287
  expect(status.satisfiedBy).toBe("sdk-delegated");
240
288
  expect(status.missing).toEqual([]);
241
289
  });
242
290
 
243
- test("amazon-bedrock: stays sdk-delegated even when ANTHROPIC_API_KEY is also set", () => {
244
- // The Anthropic-shape key is irrelevant here the model is routed through
245
- // AWS Bedrock, not Anthropic. Reporting satisfiedBy="env" would mislead.
291
+ test("amazon-bedrock: probe success even when ANTHROPIC_API_KEY also set (Bedrock wins)", async () => {
292
+ // The Anthropic-shape key is irrelevant — model is routed through AWS Bedrock.
246
293
  const env = {
247
294
  MODEL_OVERRIDE: "amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0",
248
295
  ANTHROPIC_API_KEY: "x",
249
296
  };
250
- const status = checkPiMonoCredentials(env, { homeDir: HOME, fs: noFiles });
297
+ const status = await checkPiMonoCredentials(env, {
298
+ homeDir: HOME,
299
+ fs: noFiles,
300
+ bedrockProbe: bedrockProbeSuccess,
301
+ });
251
302
  expect(status.ready).toBe(true);
252
303
  expect(status.satisfiedBy).toBe("sdk-delegated");
253
304
  });
254
305
 
255
- test("amazon-bedrock: stays sdk-delegated even when auth.json exists", () => {
306
+ test("amazon-bedrock: probe success even when auth.json exists (Bedrock wins over file)", async () => {
256
307
  // auth.json holds Anthropic/OpenRouter/OpenAI creds — none used by Bedrock.
257
- // Bedrock branch must win over the file probe.
258
308
  const env = { MODEL_OVERRIDE: "amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0" };
259
- const status = checkPiMonoCredentials(env, {
309
+ const status = await checkPiMonoCredentials(env, {
260
310
  homeDir: HOME,
261
311
  fs: fsWith(new Set([AUTH])),
312
+ bedrockProbe: bedrockProbeSuccess,
262
313
  });
263
314
  expect(status.ready).toBe(true);
264
315
  expect(status.satisfiedBy).toBe("sdk-delegated");
265
316
  });
266
317
 
267
- test("amazon-bedrock: provider-prefix match is case-insensitive", () => {
268
- // Mirrors modelToCredKeys' .toLowerCase() at line 54 of pi-mono-adapter.
318
+ test("amazon-bedrock: provider-prefix match is case-insensitive", async () => {
269
319
  const env = { MODEL_OVERRIDE: "Amazon-Bedrock/anthropic.claude-sonnet-4-20250514-v1:0" };
270
- const status = checkPiMonoCredentials(env, { homeDir: HOME, fs: noFiles });
320
+ const status = await checkPiMonoCredentials(env, {
321
+ homeDir: HOME,
322
+ fs: noFiles,
323
+ bedrockProbe: bedrockProbeSuccess,
324
+ });
271
325
  expect(status.ready).toBe(true);
272
326
  expect(status.satisfiedBy).toBe("sdk-delegated");
273
327
  });
328
+
329
+ test("amazon-bedrock: probe auth failure → ready:false with aws-auth hint", async () => {
330
+ const env = { MODEL_OVERRIDE: "amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0" };
331
+ const status = await checkPiMonoCredentials(env, {
332
+ homeDir: HOME,
333
+ fs: noFiles,
334
+ bedrockProbe: bedrockProbeAuthFail,
335
+ });
336
+ expect(status.ready).toBe(false);
337
+ expect(status.hint).toContain("aws sso login");
338
+ });
339
+
340
+ test("amazon-bedrock: probe access failure → ready:false with aws-access hint", async () => {
341
+ const env = { MODEL_OVERRIDE: "amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0" };
342
+ const status = await checkPiMonoCredentials(env, {
343
+ homeDir: HOME,
344
+ fs: noFiles,
345
+ bedrockProbe: bedrockProbeAccessFail,
346
+ });
347
+ expect(status.ready).toBe(false);
348
+ expect(status.hint).toContain("bedrock:InvokeModel");
349
+ });
350
+
351
+ test("amazon-bedrock: probe region failure → ready:false (unclassified hint)", async () => {
352
+ const env = { MODEL_OVERRIDE: "amazon-bedrock/anthropic.claude-sonnet-4-20250514-v1:0" };
353
+ const status = await checkPiMonoCredentials(env, {
354
+ homeDir: HOME,
355
+ fs: noFiles,
356
+ bedrockProbe: bedrockProbeRegionFail,
357
+ });
358
+ expect(status.ready).toBe(false);
359
+ // Not matching a known AWS category → raw probe error surfaced in hint
360
+ expect(status.hint).toBeDefined();
361
+ });
362
+
363
+ // ─── BEDROCK_AUTH_MODE=sdk: explicit mode, decoupled from MODEL_OVERRIDE ────
364
+
365
+ test("BEDROCK_AUTH_MODE=sdk: probe triggered even without amazon-bedrock/ prefix", async () => {
366
+ // Explicit mode — MODEL_OVERRIDE can be anything (or absent); the Bedrock
367
+ // path is taken because the operator explicitly declared BEDROCK_AUTH_MODE=sdk.
368
+ const env = { BEDROCK_AUTH_MODE: "sdk", MODEL_OVERRIDE: "some-other-model" };
369
+ const status = await checkPiMonoCredentials(env, {
370
+ homeDir: HOME,
371
+ fs: noFiles,
372
+ bedrockProbe: bedrockProbeSuccess,
373
+ });
374
+ expect(status.ready).toBe(true);
375
+ expect(status.satisfiedBy).toBe("sdk-delegated");
376
+ });
377
+
378
+ test("BEDROCK_AUTH_MODE=sdk: probe failure → ready:false", async () => {
379
+ const env = { BEDROCK_AUTH_MODE: "sdk" };
380
+ const status = await checkPiMonoCredentials(env, {
381
+ homeDir: HOME,
382
+ fs: noFiles,
383
+ bedrockProbe: bedrockProbeAuthFail,
384
+ });
385
+ expect(status.ready).toBe(false);
386
+ });
387
+
388
+ test("BEDROCK_AUTH_MODE=bearer: does NOT trigger the sdk probe (falls through)", async () => {
389
+ // The bearer path is declared/validated but the full implementation is
390
+ // out of scope for PR1. With no other credentials set it should be not-ready
391
+ // via the standard permissive check, not via the sdk probe.
392
+ const env = { BEDROCK_AUTH_MODE: "bearer" };
393
+ // No other keys set, no auth.json → not-ready from the permissive path.
394
+ const status = await checkPiMonoCredentials(env, { homeDir: HOME, fs: noFiles });
395
+ expect(status.ready).toBe(false);
396
+ // Satisfying via any standard key still works for the bearer mode (PR1 scope).
397
+ const withKey = await checkPiMonoCredentials(
398
+ { BEDROCK_AUTH_MODE: "bearer", ANTHROPIC_API_KEY: "x" },
399
+ { homeDir: HOME, fs: noFiles },
400
+ );
401
+ expect(withKey.ready).toBe(true);
402
+ expect(withKey.satisfiedBy).toBe("env");
403
+ });
404
+
405
+ test("BEDROCK_AUTH_MODE absent + no MODEL_OVERRIDE=amazon-bedrock: no probe", async () => {
406
+ // Fallback inference: neither BEDROCK_AUTH_MODE nor an amazon-bedrock MODEL_OVERRIDE
407
+ // → standard permissive path, no AWS call.
408
+ const env = { ANTHROPIC_API_KEY: "x" };
409
+ const status = await checkPiMonoCredentials(env, { homeDir: HOME, fs: noFiles });
410
+ expect(status.ready).toBe(true);
411
+ expect(status.satisfiedBy).toBe("env");
412
+ });
274
413
  });
275
414
 
276
415
  // ─── opencode ────────────────────────────────────────────────────────────────
@@ -171,6 +171,29 @@ describe("validateConfigValue", () => {
171
171
  );
172
172
  expect(validateConfigValue("SWARM_USE_CLAUDE_BRIDGE", true)).toMatch(/SWARM_USE_CLAUDE_BRIDGE/);
173
173
  });
174
+
175
+ test("accepts valid BEDROCK_AUTH_MODE values: sdk, bearer", () => {
176
+ expect(validateConfigValue("BEDROCK_AUTH_MODE", "sdk")).toBeNull();
177
+ expect(validateConfigValue("BEDROCK_AUTH_MODE", "bearer")).toBeNull();
178
+ // key lookup is case-insensitive
179
+ expect(validateConfigValue("bedrock_auth_mode", "sdk")).toBeNull();
180
+ });
181
+
182
+ test("rejects invalid BEDROCK_AUTH_MODE values with a helpful error", () => {
183
+ const badValues = ["Sdk", "SDK", "BEARER", "iam", "sso", "basic", "", " sdk"];
184
+ for (const bad of badValues) {
185
+ const err = validateConfigValue("BEDROCK_AUTH_MODE", bad);
186
+ expect(err).not.toBeNull();
187
+ expect(err).toMatch(/BEDROCK_AUTH_MODE/);
188
+ expect(err).toMatch(/sdk/);
189
+ expect(err).toMatch(/bearer/);
190
+ }
191
+ });
192
+
193
+ test("BEDROCK_AUTH_MODE is optional — absent key is not validated (returns null)", () => {
194
+ // Undefined / unset key → no validator → null (no error)
195
+ expect(validateConfigValue("OTHER_KEY", "sdk")).toBeNull();
196
+ });
174
197
  });
175
198
 
176
199
  // ─── getResolvedConfig — scope precedence for HARNESS_PROVIDER ───────────────
@@ -1012,6 +1012,25 @@ describe("Schedule CRUD", () => {
1012
1012
  expect(body.task.id).toBeDefined();
1013
1013
  });
1014
1014
 
1015
+ test("POST /api/schedules/:id/run — propagates modelTier to the created task", async () => {
1016
+ const { body: created } = await post("/api/schedules", {
1017
+ body: {
1018
+ name: "model-tier-manual-run",
1019
+ taskTemplate: "Run model tier integration test",
1020
+ cronExpression: "0 * * * *",
1021
+ modelTier: "smart",
1022
+ },
1023
+ });
1024
+
1025
+ const { status, body } = await post(`/api/schedules/${created.id}/run`);
1026
+ expect(status).toBe(200);
1027
+ expect(body.task).toBeDefined();
1028
+ expect(body.task.model).toBeUndefined();
1029
+ expect(body.task.modelTier).toBe("smart");
1030
+
1031
+ await del(`/api/schedules/${created.id}`);
1032
+ });
1033
+
1015
1034
  test("POST /api/schedules/:id/run — disabled schedule returns 400", async () => {
1016
1035
  // Disable the schedule first
1017
1036
  await put(`/api/schedules/${scheduleId}`, {
@@ -1,6 +1,13 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { unlink } from "node:fs/promises";
3
- import { closeDb, createMcpServer, createUser, initDb } from "../be/db";
3
+ import {
4
+ closeDb,
5
+ createMcpServer,
6
+ createUser,
7
+ getMcpServerById,
8
+ initDb,
9
+ updateMcpServer,
10
+ } from "../be/db";
4
11
  import {
5
12
  consumeMcpOAuthPending,
6
13
  deleteMcpOAuthToken,
@@ -221,6 +228,69 @@ describe("mcp_oauth_pending (state PK)", () => {
221
228
  });
222
229
  });
223
230
 
231
+ describe("mcp_servers.extraAuthorizeParams round-trip", () => {
232
+ test("createMcpServer persists extraAuthorizeParams", () => {
233
+ const server = createMcpServer({
234
+ name: "bigquery-mcp",
235
+ transport: "http",
236
+ url: "https://bigquery.googleapis.com/",
237
+ scope: "swarm",
238
+ extraAuthorizeParams: '{"access_type":"offline","prompt":"consent"}',
239
+ });
240
+ expect(server.extraAuthorizeParams).toBe('{"access_type":"offline","prompt":"consent"}');
241
+
242
+ const fetched = getMcpServerById(server.id);
243
+ expect(fetched).not.toBeNull();
244
+ expect(fetched!.extraAuthorizeParams).toBe('{"access_type":"offline","prompt":"consent"}');
245
+ });
246
+
247
+ test("createMcpServer with no extraAuthorizeParams defaults to null", () => {
248
+ const server = createMcpServer({
249
+ name: "hubspot-mcp",
250
+ transport: "http",
251
+ url: "https://api.hubspot.com/",
252
+ scope: "swarm",
253
+ });
254
+ expect(server.extraAuthorizeParams).toBeNull();
255
+ });
256
+
257
+ test("updateMcpServer persists extraAuthorizeParams and bumps version", () => {
258
+ const server = createMcpServer({
259
+ name: "gdrive-mcp",
260
+ transport: "http",
261
+ url: "https://www.googleapis.com/drive/v3/",
262
+ scope: "swarm",
263
+ });
264
+ const versionBefore = server.version;
265
+
266
+ const updated = updateMcpServer(server.id, {
267
+ extraAuthorizeParams: '{"access_type":"offline","prompt":"consent"}',
268
+ });
269
+ expect(updated).not.toBeNull();
270
+ expect(updated!.extraAuthorizeParams).toBe('{"access_type":"offline","prompt":"consent"}');
271
+ expect(updated!.version).toBe(versionBefore + 1);
272
+ });
273
+
274
+ test("updateMcpServer can clear extraAuthorizeParams to null without bumping version twice", () => {
275
+ const server = createMcpServer({
276
+ name: "sheets-mcp",
277
+ transport: "http",
278
+ url: "https://sheets.googleapis.com/",
279
+ scope: "swarm",
280
+ extraAuthorizeParams: '{"access_type":"offline"}',
281
+ });
282
+
283
+ const cleared = updateMcpServer(server.id, { extraAuthorizeParams: undefined });
284
+ // No extraAuthorizeParams key → no version bump, field untouched
285
+ expect(cleared!.extraAuthorizeParams).toBe('{"access_type":"offline"}');
286
+ expect(cleared!.version).toBe(server.version);
287
+
288
+ const nulled = updateMcpServer(server.id, { extraAuthorizeParams: null as unknown as string });
289
+ expect(nulled!.extraAuthorizeParams).toBeNull();
290
+ expect(nulled!.version).toBe(server.version + 1);
291
+ });
292
+ });
293
+
224
294
  describe("mcp_servers.authMethod accessor", () => {
225
295
  test("default is 'static' for newly created servers", () => {
226
296
  const server = makeServer("mcp-auth-default");
@@ -137,6 +137,115 @@ describe("buildAuthorizeUrl (PKCE S256, RFC 8707)", () => {
137
137
  });
138
138
  expect(new URL(result.url).searchParams.has("scope")).toBe(false);
139
139
  });
140
+
141
+ test("extraParams are appended to the authorize URL (e.g. BigQuery offline access)", async () => {
142
+ const result = await buildAuthorizeUrl({
143
+ authorizeUrl: "https://as.example.com/authorize",
144
+ tokenUrl: "https://as.example.com/token",
145
+ clientId: "bq-client",
146
+ redirectUri: "https://swarm.example.com/callback",
147
+ scopes: ["https://www.googleapis.com/auth/bigquery"],
148
+ resource: "https://bigquery.googleapis.com/",
149
+ extraParams: { access_type: "offline", prompt: "consent" },
150
+ });
151
+
152
+ const u = new URL(result.url);
153
+ expect(u.searchParams.get("access_type")).toBe("offline");
154
+ expect(u.searchParams.get("prompt")).toBe("consent");
155
+ });
156
+
157
+ test("extraParams cannot override reserved OAuth params (redirect_uri, state, etc.)", async () => {
158
+ const result = await buildAuthorizeUrl({
159
+ authorizeUrl: "https://as.example.com/authorize",
160
+ tokenUrl: "https://as.example.com/token",
161
+ clientId: "c",
162
+ redirectUri: "https://swarm.example.com/cb",
163
+ scopes: ["read"],
164
+ resource: "https://mcp.example.com/",
165
+ state: "safe-state",
166
+ extraParams: {
167
+ redirect_uri: "https://evil.com",
168
+ state: "injected",
169
+ code_challenge: "malicious",
170
+ code_challenge_method: "plain",
171
+ response_type: "token",
172
+ client_id: "attacker",
173
+ scope: "admin",
174
+ resource: "https://evil.com/",
175
+ },
176
+ });
177
+ const u = new URL(result.url);
178
+ expect(u.searchParams.get("redirect_uri")).toBe("https://swarm.example.com/cb");
179
+ expect(u.searchParams.get("state")).toBe("safe-state");
180
+ expect(u.searchParams.get("code_challenge_method")).toBe("S256");
181
+ expect(u.searchParams.get("response_type")).toBe("code");
182
+ expect(u.searchParams.get("client_id")).toBe("c");
183
+ expect(u.searchParams.get("resource")).toBe("https://mcp.example.com/");
184
+ // Attacker values must not have landed
185
+ const challenge = u.searchParams.get("code_challenge");
186
+ expect(challenge).not.toBeNull();
187
+ expect(challenge).not.toBe("malicious");
188
+ expect(u.searchParams.get("scope")).toBe("read");
189
+ });
190
+
191
+ test("mixed-case reserved keys in extraParams are rejected (case-insensitive guard)", async () => {
192
+ const result = await buildAuthorizeUrl({
193
+ authorizeUrl: "https://as.example.com/authorize",
194
+ tokenUrl: "https://as.example.com/token",
195
+ clientId: "c",
196
+ redirectUri: "https://swarm.example.com/cb",
197
+ scopes: ["read"],
198
+ resource: "https://mcp.example.com/",
199
+ state: "safe-state",
200
+ extraParams: {
201
+ Redirect_Uri: "https://evil.example",
202
+ STATE: "evil-state",
203
+ Code_Challenge: "malicious-challenge",
204
+ SCOPE: "admin",
205
+ },
206
+ });
207
+ const u = new URL(result.url);
208
+ // Attacker mixed-case keys must NOT appear in the URL
209
+ expect(u.searchParams.get("Redirect_Uri")).toBeNull();
210
+ expect(u.searchParams.get("STATE")).toBeNull();
211
+ expect(u.searchParams.get("Code_Challenge")).toBeNull();
212
+ expect(u.searchParams.get("SCOPE")).toBeNull();
213
+ // Core params must retain their original legitimate values
214
+ expect(u.searchParams.get("redirect_uri")).toBe("https://swarm.example.com/cb");
215
+ expect(u.searchParams.get("state")).toBe("safe-state");
216
+ expect(u.searchParams.get("scope")).toBe("read");
217
+ });
218
+
219
+ test("null/undefined extraParams leaves URL unchanged (no blast radius for existing servers)", async () => {
220
+ const withExtra = await buildAuthorizeUrl({
221
+ authorizeUrl: "https://as.example.com/authorize",
222
+ tokenUrl: "https://as.example.com/token",
223
+ clientId: "c",
224
+ redirectUri: "https://swarm.example.com/cb",
225
+ scopes: ["read"],
226
+ resource: "https://mcp.example.com/",
227
+ extraParams: { access_type: "offline" },
228
+ state: "fixed-state",
229
+ });
230
+
231
+ const withoutExtra = await buildAuthorizeUrl({
232
+ authorizeUrl: "https://as.example.com/authorize",
233
+ tokenUrl: "https://as.example.com/token",
234
+ clientId: "c",
235
+ redirectUri: "https://swarm.example.com/cb",
236
+ scopes: ["read"],
237
+ resource: "https://mcp.example.com/",
238
+ state: "fixed-state",
239
+ });
240
+
241
+ const uWith = new URL(withExtra.url);
242
+ const uWithout = new URL(withoutExtra.url);
243
+ expect(uWith.searchParams.has("access_type")).toBe(true);
244
+ expect(uWithout.searchParams.has("access_type")).toBe(false);
245
+ // Core params are identical
246
+ expect(uWith.searchParams.get("client_id")).toBe(uWithout.searchParams.get("client_id"));
247
+ expect(uWith.searchParams.get("state")).toBe(uWithout.searchParams.get("state"));
248
+ });
140
249
  });
141
250
 
142
251
  // ─── Discovery (PRMD + AS metadata) ──────────────────────────────────────────