@desplega.ai/agent-swarm 1.94.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.
- package/openapi.json +1 -1
- package/package.json +4 -3
- package/src/be/db.ts +11 -2
- package/src/be/migrations/094_mcp_extra_authorize_params.sql +4 -0
- package/src/be/skill-sync.ts +4 -4
- package/src/be/swarm-config-guard.ts +8 -0
- package/src/commands/provider-credentials.ts +14 -8
- package/src/commands/runner.ts +1 -0
- package/src/http/mcp-oauth.ts +14 -0
- package/src/oauth/mcp-wrapper.ts +14 -0
- package/src/providers/codex-skill-resolver.ts +22 -8
- package/src/providers/opencode-adapter.ts +20 -2
- package/src/providers/pi-mono-adapter.ts +65 -20
- package/src/providers/types.ts +12 -0
- package/src/tests/credential-check.test.ts +185 -46
- package/src/tests/harness-provider-resolution.test.ts +23 -0
- package/src/tests/mcp-oauth-queries.test.ts +71 -1
- package/src/tests/mcp-oauth-wrapper.test.ts +109 -0
- package/src/tests/opencode-adapter.test.ts +29 -1
- package/src/tests/provider-command-format.test.ts +12 -0
- package/src/tests/skill-fs-writer.test.ts +7 -1
- package/src/tests/skill-sync.test.ts +15 -3
- package/src/tools/mcp-servers/mcp-server-create.ts +7 -0
- package/src/tools/mcp-servers/mcp-server-update.ts +8 -0
- package/src/types.ts +1 -0
- package/src/utils/skill-fs-writer.ts +11 -3
|
@@ -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 })
|
|
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 })
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
205
|
-
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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:
|
|
229
|
-
// When
|
|
230
|
-
//
|
|
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)
|
|
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, {
|
|
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:
|
|
244
|
-
// The Anthropic-shape key is irrelevant
|
|
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, {
|
|
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:
|
|
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, {
|
|
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 ───────────────
|
|
@@ -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 {
|
|
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) ──────────────────────────────────────────
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
|
|
10
|
-
import { writeFileSync } from "node:fs";
|
|
10
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
12
|
import type { Event as OpencodeEvent } from "@opencode-ai/sdk";
|
|
13
13
|
import type { ProviderEvent, ProviderResult, ProviderSessionConfig } from "../providers/types";
|
|
@@ -614,16 +614,22 @@ describe("OpencodeSession — context_usage emission (phase 9 fix)", () => {
|
|
|
614
614
|
// ── DES-300: per-task isolation ────────────────────────────────────────────────
|
|
615
615
|
|
|
616
616
|
describe("OpencodeAdapter — per-task isolation (DES-300)", () => {
|
|
617
|
+
let prevOpencodeSkillsDir: string | undefined;
|
|
618
|
+
|
|
617
619
|
beforeEach(() => {
|
|
620
|
+
prevOpencodeSkillsDir = process.env.OPENCODE_SKILLS_DIR;
|
|
618
621
|
lastPromptArgs = undefined;
|
|
619
622
|
lastCreateOpencodeConfig = undefined;
|
|
620
623
|
mock.restore();
|
|
621
624
|
});
|
|
622
625
|
|
|
623
626
|
afterEach(() => {
|
|
627
|
+
if (prevOpencodeSkillsDir === undefined) delete process.env.OPENCODE_SKILLS_DIR;
|
|
628
|
+
else process.env.OPENCODE_SKILLS_DIR = prevOpencodeSkillsDir;
|
|
624
629
|
// Clean up any written files from tests
|
|
625
630
|
Bun.$`rm -rf /tmp/opencode-task-1.json /tmp/opencode-data-task-1`.quiet().nothrow();
|
|
626
631
|
Bun.$`rm -rf /tmp/test/.opencode`.quiet().nothrow();
|
|
632
|
+
rmSync("/tmp/opencode-skills-test", { recursive: true, force: true });
|
|
627
633
|
});
|
|
628
634
|
|
|
629
635
|
test("session.prompt receives agent=swarm-<taskId>", async () => {
|
|
@@ -638,6 +644,28 @@ describe("OpencodeAdapter — per-task isolation (DES-300)", () => {
|
|
|
638
644
|
expect(args.body?.agent).toBe("swarm-task-1");
|
|
639
645
|
});
|
|
640
646
|
|
|
647
|
+
test("inlines a leading slash skill before sending prompt", async () => {
|
|
648
|
+
const skillDir = "/tmp/opencode-skills-test/work-on-task";
|
|
649
|
+
mkdirSync(skillDir, { recursive: true });
|
|
650
|
+
writeFileSync(join(skillDir, "SKILL.md"), "Use the task worker procedure.");
|
|
651
|
+
process.env.OPENCODE_SKILLS_DIR = "/tmp/opencode-skills-test";
|
|
652
|
+
|
|
653
|
+
const events: OpencodeEvent[] = [
|
|
654
|
+
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
655
|
+
];
|
|
656
|
+
const cfg = testConfig({
|
|
657
|
+
taskId: "task-1",
|
|
658
|
+
prompt: "/work-on-task task-123\n\nTask body.",
|
|
659
|
+
});
|
|
660
|
+
await driveSession(events, cfg);
|
|
661
|
+
|
|
662
|
+
const args = lastPromptArgs as { body?: { parts?: Array<{ type: string; text: string }> } };
|
|
663
|
+
const text = args.body?.parts?.[0]?.text ?? "";
|
|
664
|
+
expect(text).toStartWith("Use the task worker procedure.");
|
|
665
|
+
expect(text).toContain("User request: task-123\n\nTask body.");
|
|
666
|
+
expect(text).not.toContain("/work-on-task task-123");
|
|
667
|
+
});
|
|
668
|
+
|
|
641
669
|
test("createOpencode receives config with model, mcp.swarm, and permission", async () => {
|
|
642
670
|
const events: OpencodeEvent[] = [
|
|
643
671
|
{ type: "session.idle", properties: { sessionID: "sess-abc-123" } },
|
|
@@ -2,12 +2,14 @@ import { describe, expect, test } from "bun:test";
|
|
|
2
2
|
import { ClaudeAdapter } from "../providers/claude-adapter";
|
|
3
3
|
import { CodexAdapter } from "../providers/codex-adapter";
|
|
4
4
|
import { createProviderAdapter } from "../providers/index";
|
|
5
|
+
import { OpencodeAdapter } from "../providers/opencode-adapter";
|
|
5
6
|
import { PiMonoAdapter } from "../providers/pi-mono-adapter";
|
|
6
7
|
|
|
7
8
|
describe("ProviderAdapter.formatCommand", () => {
|
|
8
9
|
const claude = new ClaudeAdapter();
|
|
9
10
|
const pi = new PiMonoAdapter();
|
|
10
11
|
const codex = new CodexAdapter();
|
|
12
|
+
const opencode = new OpencodeAdapter();
|
|
11
13
|
|
|
12
14
|
test("claude formats commands with / prefix", () => {
|
|
13
15
|
expect(claude.formatCommand("work-on-task")).toBe("/work-on-task");
|
|
@@ -31,22 +33,32 @@ describe("ProviderAdapter.formatCommand", () => {
|
|
|
31
33
|
expect(codex.formatCommand("swarm-chat")).toBe("/swarm-chat");
|
|
32
34
|
});
|
|
33
35
|
|
|
36
|
+
test("opencode formats commands with / prefix (skill resolver inlines SKILL.md at runtime)", () => {
|
|
37
|
+
expect(opencode.formatCommand("work-on-task")).toBe("/work-on-task");
|
|
38
|
+
expect(opencode.formatCommand("review-offered-task")).toBe("/review-offered-task");
|
|
39
|
+
expect(opencode.formatCommand("swarm-chat")).toBe("/swarm-chat");
|
|
40
|
+
});
|
|
41
|
+
|
|
34
42
|
test("adapter name matches expected provider", () => {
|
|
35
43
|
expect(claude.name).toBe("claude");
|
|
36
44
|
expect(pi.name).toBe("pi");
|
|
37
45
|
expect(codex.name).toBe("codex");
|
|
46
|
+
expect(opencode.name).toBe("opencode");
|
|
38
47
|
});
|
|
39
48
|
|
|
40
49
|
test("createProviderAdapter returns adapters that implement formatCommand", async () => {
|
|
41
50
|
const claudeAdapter = await createProviderAdapter("claude");
|
|
42
51
|
const piAdapter = await createProviderAdapter("pi");
|
|
43
52
|
const codexAdapter = await createProviderAdapter("codex");
|
|
53
|
+
const opencodeAdapter = await createProviderAdapter("opencode");
|
|
44
54
|
expect(typeof claudeAdapter.formatCommand).toBe("function");
|
|
45
55
|
expect(typeof piAdapter.formatCommand).toBe("function");
|
|
46
56
|
expect(typeof codexAdapter.formatCommand).toBe("function");
|
|
57
|
+
expect(typeof opencodeAdapter.formatCommand).toBe("function");
|
|
47
58
|
expect(claudeAdapter.formatCommand("work-on-task")).toBe("/work-on-task");
|
|
48
59
|
expect(piAdapter.formatCommand("work-on-task")).toBe("/skill:work-on-task");
|
|
49
60
|
expect(codexAdapter.formatCommand("work-on-task")).toBe("/work-on-task");
|
|
61
|
+
expect(opencodeAdapter.formatCommand("work-on-task")).toBe("/work-on-task");
|
|
50
62
|
});
|
|
51
63
|
});
|
|
52
64
|
|