@desplega.ai/agent-swarm 1.94.0 → 1.96.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 (38) hide show
  1. package/README.md +3 -3
  2. package/openapi.json +46 -1
  3. package/package.json +4 -3
  4. package/src/be/boot-scrub-logs.ts +76 -0
  5. package/src/be/db.ts +22 -10
  6. package/src/be/migrations/094_mcp_extra_authorize_params.sql +4 -0
  7. package/src/be/modelsdev-cache.json +89422 -85636
  8. package/src/be/skill-sync.ts +4 -4
  9. package/src/be/swarm-config-guard.ts +8 -0
  10. package/src/commands/provider-credentials.ts +37 -9
  11. package/src/commands/runner.ts +28 -0
  12. package/src/http/agents.ts +1 -0
  13. package/src/http/config.ts +24 -4
  14. package/src/http/index.ts +9 -0
  15. package/src/http/mcp-oauth.ts +14 -0
  16. package/src/oauth/mcp-wrapper.ts +14 -0
  17. package/src/prompts/session-templates.ts +21 -0
  18. package/src/providers/codex-skill-resolver.ts +22 -8
  19. package/src/providers/opencode-adapter.ts +20 -2
  20. package/src/providers/pi-mono-adapter.ts +160 -21
  21. package/src/providers/types.ts +33 -0
  22. package/src/tests/bedrock-model-groups.test.ts +135 -0
  23. package/src/tests/credential-check.test.ts +538 -50
  24. package/src/tests/harness-provider-resolution.test.ts +23 -0
  25. package/src/tests/mcp-oauth-queries.test.ts +71 -1
  26. package/src/tests/mcp-oauth-wrapper.test.ts +109 -0
  27. package/src/tests/opencode-adapter.test.ts +29 -1
  28. package/src/tests/provider-command-format.test.ts +12 -0
  29. package/src/tests/secret-scrubber.test.ts +73 -1
  30. package/src/tests/skill-fs-writer.test.ts +7 -1
  31. package/src/tests/skill-sync.test.ts +15 -3
  32. package/src/tools/mcp-servers/mcp-server-create.ts +7 -0
  33. package/src/tools/mcp-servers/mcp-server-update.ts +8 -0
  34. package/src/tools/swarm-config/get-config.ts +9 -1
  35. package/src/tools/swarm-config/list-config.ts +8 -0
  36. package/src/types.ts +22 -0
  37. package/src/utils/secret-scrubber.ts +33 -12
  38. package/src/utils/skill-fs-writer.ts +11 -3
@@ -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 { 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) ──────────────────────────────────────────
@@ -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
 
@@ -1,5 +1,11 @@
1
1
  import { afterEach, beforeEach, describe, expect, test } from "bun:test";
2
- import { refreshSecretScrubberCache, scrubObject, scrubSecrets } from "../utils/secret-scrubber";
2
+ import {
3
+ clearVolatileSecretsForTesting,
4
+ refreshSecretScrubberCache,
5
+ registerVolatileSecret,
6
+ scrubObject,
7
+ scrubSecrets,
8
+ } from "../utils/secret-scrubber";
3
9
 
4
10
  // Snapshot/restore process.env between tests so env-derived cache entries
5
11
  // don't leak across cases.
@@ -221,6 +227,52 @@ describe("scrubSecrets — regex patterns", () => {
221
227
 
222
228
  expect(out).toBe("OTEL_EXPORTER_OTLP_HEADERS=[REDACTED:signoz_ingestion_key]");
223
229
  });
230
+
231
+ test("redacts Linear OAuth tokens", () => {
232
+ const out = scrubSecrets("Authorization: Bearer lin_oauth_test123abcdef end");
233
+ expect(out).toContain("[REDACTED:linear_oauth]");
234
+ expect(out).not.toContain("lin_oauth_test123");
235
+ });
236
+
237
+ test("redacts Linear API keys", () => {
238
+ const out = scrubSecrets("key=lin_api_test123abcdef tail");
239
+ expect(out).toContain("[REDACTED:linear_api]");
240
+ expect(out).not.toContain("lin_api_test123");
241
+ });
242
+
243
+ test("redacts npm tokens", () => {
244
+ const out = scrubSecrets("npm=npm_abcdefghijklmnopqrstuvwxyz01234");
245
+ expect(out).toContain("[REDACTED:npm_token]");
246
+ expect(out).not.toContain("npm_abcdefghijklmnopqr");
247
+ });
248
+
249
+ test("redacts Atlassian/Jira API tokens", () => {
250
+ const out = scrubSecrets("jira=ATATT3xFfGF0abcdefghijklmnopqrstuvwxyz");
251
+ expect(out).toContain("[REDACTED:atlassian_token]");
252
+ expect(out).not.toContain("ATATT3xFfGF0");
253
+ });
254
+
255
+ test("redacts tokens adjacent to JSON escape sequences (\\n)", () => {
256
+ const content = "data\\nlin_oauth_test123abcdef\\nmore";
257
+ const out = scrubSecrets(content);
258
+ expect(out).toContain("[REDACTED:linear_oauth]");
259
+ expect(out).not.toContain("lin_oauth_test123");
260
+ });
261
+
262
+ test("redacts tokens adjacent to JSON escape sequences (\\t, \\r)", () => {
263
+ const outT = scrubSecrets("field\\tlin_oauth_test123abcdef");
264
+ expect(outT).toContain("[REDACTED:linear_oauth]");
265
+ const outR = scrubSecrets("line\\rlin_oauth_test123abcdef");
266
+ expect(outR).toContain("[REDACTED:linear_oauth]");
267
+ });
268
+
269
+ test("redacts tokens in double-encoded JSON with escape sequences", () => {
270
+ const inner = JSON.stringify({ access_token: "lin_oauth_test123abcdef", scope: "read" });
271
+ const content = `{"output":${JSON.stringify(inner)}}`;
272
+ const out = scrubSecrets(content);
273
+ expect(out).toContain("[REDACTED:linear_oauth]");
274
+ expect(out).not.toContain("lin_oauth_test123");
275
+ });
224
276
  });
225
277
 
226
278
  describe("scrubSecrets — does not over-scrub", () => {
@@ -300,3 +352,23 @@ describe("scrubObject", () => {
300
352
  expect(scrubObject(value)).toEqual({ a: "ok", self: "[Circular]" });
301
353
  });
302
354
  });
355
+
356
+ describe("registerVolatileSecret", () => {
357
+ afterEach(() => {
358
+ clearVolatileSecretsForTesting();
359
+ });
360
+
361
+ test("scrubs a runtime-registered volatile secret", () => {
362
+ const secret = "volatile_runtime_token_1234567890abcdef";
363
+ registerVolatileSecret(secret, "RUNTIME_TOKEN");
364
+ const out = scrubSecrets(`key=${secret}`);
365
+ expect(out).toBe("key=[REDACTED:RUNTIME_TOKEN]");
366
+ expect(out).not.toContain(secret);
367
+ });
368
+
369
+ test("ignores values shorter than the minimum length", () => {
370
+ registerVolatileSecret("short", "TOO_SHORT");
371
+ const out = scrubSecrets("contains short somewhere");
372
+ expect(out).toBe("contains short somewhere");
373
+ });
374
+ });
@@ -41,6 +41,8 @@ describe("writeSkillsToFilesystem", () => {
41
41
  rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
42
42
  rmSync(join(FAKE_HOME, ".pi"), { recursive: true, force: true });
43
43
  rmSync(join(FAKE_HOME, ".codex"), { recursive: true, force: true });
44
+ rmSync(join(FAKE_HOME, ".opencode"), { recursive: true, force: true });
45
+ rmSync(join(FAKE_HOME, ".agents"), { recursive: true, force: true });
44
46
  });
45
47
 
46
48
  afterAll(() => {
@@ -71,12 +73,16 @@ describe("writeSkillsToFilesystem", () => {
71
73
  const entries = [skillEntry({ name: "multi-skill", content: "# Multi" })];
72
74
  const result = writeSkillsToFilesystem(entries, "all", FAKE_HOME);
73
75
 
74
- expect(result.synced).toBe(3); // claude + pi + codex
76
+ expect(result.synced).toBe(5); // claude + pi + codex + opencode + .agents
75
77
  expect(existsSync(join(FAKE_HOME, ".claude", "skills", "multi-skill", "SKILL.md"))).toBe(true);
76
78
  expect(existsSync(join(FAKE_HOME, ".pi", "agent", "skills", "multi-skill", "SKILL.md"))).toBe(
77
79
  true,
78
80
  );
79
81
  expect(existsSync(join(FAKE_HOME, ".codex", "skills", "multi-skill", "SKILL.md"))).toBe(true);
82
+ expect(existsSync(join(FAKE_HOME, ".opencode", "skills", "multi-skill", "SKILL.md"))).toBe(
83
+ true,
84
+ );
85
+ expect(existsSync(join(FAKE_HOME, ".agents", "skills", "multi-skill", "SKILL.md"))).toBe(true);
80
86
  });
81
87
 
82
88
  test("writes complex skill SKILL.md plus non-binary bundled files", () => {
@@ -121,23 +121,29 @@ describe("syncSkillsToFilesystem", () => {
121
121
  expect(existsSync(piOnlyFile)).toBe(false);
122
122
  });
123
123
 
124
- test("syncs to claude, pi, and codex when harnessType is 'all'", () => {
124
+ test("syncs to all local harness skill trees when harnessType is 'all'", () => {
125
125
  // Clean up first to get accurate count
126
126
  rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
127
127
  rmSync(join(FAKE_HOME, ".pi"), { recursive: true, force: true });
128
128
  rmSync(join(FAKE_HOME, ".codex"), { recursive: true, force: true });
129
+ rmSync(join(FAKE_HOME, ".opencode"), { recursive: true, force: true });
130
+ rmSync(join(FAKE_HOME, ".agents"), { recursive: true, force: true });
129
131
 
130
132
  const result = syncSkillsToFilesystem(agentId, "all", FAKE_HOME);
131
133
 
132
134
  expect(result.errors).toHaveLength(0);
133
- expect(result.synced).toBe(6); // 2 DB-backed skills × 3 dirs
135
+ expect(result.synced).toBe(10); // 2 DB-backed skills × 5 dirs
134
136
 
135
137
  const claudeFile = join(FAKE_HOME, ".claude", "skills", "test-skill", "SKILL.md");
136
138
  const piFile = join(FAKE_HOME, ".pi", "agent", "skills", "test-skill", "SKILL.md");
137
139
  const codexFile = join(FAKE_HOME, ".codex", "skills", "test-skill", "SKILL.md");
140
+ const opencodeFile = join(FAKE_HOME, ".opencode", "skills", "test-skill", "SKILL.md");
141
+ const agentsFile = join(FAKE_HOME, ".agents", "skills", "test-skill", "SKILL.md");
138
142
  expect(existsSync(claudeFile)).toBe(true);
139
143
  expect(existsSync(piFile)).toBe(true);
140
144
  expect(existsSync(codexFile)).toBe(true);
145
+ expect(existsSync(opencodeFile)).toBe(true);
146
+ expect(existsSync(agentsFile)).toBe(true);
141
147
  });
142
148
 
143
149
  test("syncs DB-backed complex skill files and skips binary placeholders", () => {
@@ -299,19 +305,25 @@ describe("syncSkillsToFilesystem", () => {
299
305
  rmSync(join(FAKE_HOME, ".claude"), { recursive: true, force: true });
300
306
  rmSync(join(FAKE_HOME, ".pi"), { recursive: true, force: true });
301
307
  rmSync(join(FAKE_HOME, ".codex"), { recursive: true, force: true });
308
+ rmSync(join(FAKE_HOME, ".opencode"), { recursive: true, force: true });
309
+ rmSync(join(FAKE_HOME, ".agents"), { recursive: true, force: true });
302
310
 
303
311
  // Use 'all' explicitly with homeOverride (default harnessType would use real home)
304
312
  const result = syncSkillsToFilesystem(agentId, "all", FAKE_HOME);
305
313
 
306
314
  expect(result.errors).toHaveLength(0);
307
- expect(result.synced).toBeGreaterThanOrEqual(6);
315
+ expect(result.synced).toBeGreaterThanOrEqual(10);
308
316
 
309
317
  const claudeFile = join(FAKE_HOME, ".claude", "skills", "test-skill", "SKILL.md");
310
318
  const piFile = join(FAKE_HOME, ".pi", "agent", "skills", "test-skill", "SKILL.md");
311
319
  const codexFile = join(FAKE_HOME, ".codex", "skills", "test-skill", "SKILL.md");
320
+ const opencodeFile = join(FAKE_HOME, ".opencode", "skills", "test-skill", "SKILL.md");
321
+ const agentsFile = join(FAKE_HOME, ".agents", "skills", "test-skill", "SKILL.md");
312
322
  expect(existsSync(claudeFile)).toBe(true);
313
323
  expect(existsSync(piFile)).toBe(true);
314
324
  expect(existsSync(codexFile)).toBe(true);
325
+ expect(existsSync(opencodeFile)).toBe(true);
326
+ expect(existsSync(agentsFile)).toBe(true);
315
327
  });
316
328
 
317
329
  test("returns empty result for agent with no skills", () => {
@@ -35,6 +35,12 @@ export const registerMcpServerCreateTool = (server: McpServer) => {
35
35
  .string()
36
36
  .optional()
37
37
  .describe("JSON object mapping header names to config key paths for secret headers"),
38
+ extraAuthorizeParams: z
39
+ .string()
40
+ .optional()
41
+ .describe(
42
+ 'JSON object string of extra OAuth authorize-request params, e.g. {"access_type":"offline","prompt":"consent"}',
43
+ ),
38
44
  }),
39
45
  outputSchema: z.object({
40
46
  yourAgentId: z.string().uuid().optional(),
@@ -108,6 +114,7 @@ export const registerMcpServerCreateTool = (server: McpServer) => {
108
114
  headers: args.headers,
109
115
  envConfigKeys: args.envConfigKeys,
110
116
  headerConfigKeys: args.headerConfigKeys,
117
+ extraAuthorizeParams: args.extraAuthorizeParams,
111
118
  });
112
119
 
113
120
  // Auto-install for the creating agent
@@ -21,6 +21,12 @@ export const registerMcpServerUpdateTool = (server: McpServer) => {
21
21
  headers: z.string().optional().describe("New JSON object of non-secret headers"),
22
22
  envConfigKeys: z.string().optional().describe("New env config key mappings"),
23
23
  headerConfigKeys: z.string().optional().describe("New header config key mappings"),
24
+ extraAuthorizeParams: z
25
+ .string()
26
+ .optional()
27
+ .describe(
28
+ 'JSON object string of extra OAuth authorize-request params, e.g. {"access_type":"offline","prompt":"consent"}',
29
+ ),
24
30
  isEnabled: z.boolean().optional().describe("Toggle enabled/disabled"),
25
31
  }),
26
32
  outputSchema: z.object({
@@ -76,6 +82,8 @@ export const registerMcpServerUpdateTool = (server: McpServer) => {
76
82
  if (args.headers !== undefined) updates.headers = args.headers;
77
83
  if (args.envConfigKeys !== undefined) updates.envConfigKeys = args.envConfigKeys;
78
84
  if (args.headerConfigKeys !== undefined) updates.headerConfigKeys = args.headerConfigKeys;
85
+ if (args.extraAuthorizeParams !== undefined)
86
+ updates.extraAuthorizeParams = args.extraAuthorizeParams;
79
87
  if (args.isEnabled !== undefined) updates.isEnabled = args.isEnabled;
80
88
 
81
89
  const updated = updateMcpServer(args.id, updates);
@@ -3,6 +3,7 @@ import * as z from "zod";
3
3
  import { getResolvedConfig, maskSecrets } from "@/be/db";
4
4
  import { createToolRegistrar } from "@/tools/utils";
5
5
  import { SwarmConfigSchema } from "@/types";
6
+ import { registerVolatileSecret } from "@/utils/secret-scrubber";
6
7
 
7
8
  export const registerGetConfigTool = (server: McpServer) => {
8
9
  createToolRegistrar(server)(
@@ -10,7 +11,7 @@ export const registerGetConfigTool = (server: McpServer) => {
10
11
  {
11
12
  title: "Get Config",
12
13
  description:
13
- "Get resolved configuration values with scope resolution (repo > agent > global). Returns one entry per unique key with the most-specific scope winning. Use includeSecrets=true to see secret values.",
14
+ "Get resolved configuration values with scope resolution (repo > agent > global). Returns one entry per unique key with the most-specific scope winning. Use includeSecrets=true to see secret values. IMPORTANT: never pass returned secret values directly on a command line — write them to a temp .env file and source it instead, so the literal value stays out of logged commands.",
14
15
  annotations: { readOnlyHint: true },
15
16
 
16
17
  inputSchema: z.object({
@@ -62,6 +63,13 @@ export const registerGetConfigTool = (server: McpServer) => {
62
63
  }
63
64
 
64
65
  const result = includeSecrets ? configs : maskSecrets(configs);
66
+ if (includeSecrets) {
67
+ for (const c of result) {
68
+ if (c.isSecret && c.value) {
69
+ registerVolatileSecret(c.value, `config:${c.key}`);
70
+ }
71
+ }
72
+ }
65
73
  const count = result.length;
66
74
 
67
75
  const configList =
@@ -3,6 +3,7 @@ import * as z from "zod";
3
3
  import { getSwarmConfigs, maskSecrets } from "@/be/db";
4
4
  import { createToolRegistrar } from "@/tools/utils";
5
5
  import { SwarmConfigSchema, SwarmConfigScopeSchema } from "@/types";
6
+ import { registerVolatileSecret } from "@/utils/secret-scrubber";
6
7
 
7
8
  export const registerListConfigTool = (server: McpServer) => {
8
9
  createToolRegistrar(server)(
@@ -53,6 +54,13 @@ export const registerListConfigTool = (server: McpServer) => {
53
54
  });
54
55
 
55
56
  const result = includeSecrets ? configs : maskSecrets(configs);
57
+ if (includeSecrets) {
58
+ for (const c of result) {
59
+ if (c.isSecret && c.value) {
60
+ registerVolatileSecret(c.value, `config:${c.key}`);
61
+ }
62
+ }
63
+ }
56
64
  const count = result.length;
57
65
 
58
66
  const configList =
package/src/types.ts CHANGED
@@ -553,6 +553,25 @@ export const AgentLatestModelSchema = z.object({
553
553
  });
554
554
  export type AgentLatestModel = z.infer<typeof AgentLatestModelSchema>;
555
555
 
556
+ /**
557
+ * Worker-reported Bedrock enumeration block. Only present when the pi harness
558
+ * is in Bedrock SDK mode (`BEDROCK_AUTH_MODE=sdk` or
559
+ * `MODEL_OVERRIDE=amazon-bedrock/*`). Rides inside `cred_status` JSON (no new
560
+ * DB column). `models` is the intersection of the models invocable by this
561
+ * account/region (on-demand/ACTIVE foundation models ∪ inference profiles) with
562
+ * the set the pi-ai Converse harness can actually drive — Converse-incompatible
563
+ * entries (e.g. OpenAI models listed in the account) are excluded. An empty
564
+ * `region` means Bedrock mode with `AWS_REGION` unset (no region fabricated).
565
+ */
566
+ export const AgentBedrockStatusSchema = z.object({
567
+ region: z.string(),
568
+ probedAt: z.number(), // unix ms
569
+ ready: z.boolean(),
570
+ models: z.array(z.object({ id: z.string(), name: z.string() })).default([]),
571
+ error: z.string().optional(),
572
+ });
573
+ export type AgentBedrockStatus = z.infer<typeof AgentBedrockStatusSchema>;
574
+
556
575
  export const AgentCredStatusSchema = z.object({
557
576
  ready: z.boolean(),
558
577
  missing: z.array(z.string()).default([]),
@@ -565,6 +584,8 @@ export const AgentCredStatusSchema = z.object({
565
584
  latestModel: AgentLatestModelSchema.nullable().default(null),
566
585
  reportedAt: z.number(), // unix ms
567
586
  reportKind: z.enum(["boot", "post_task"]).default("boot"),
587
+ /** Pi-mono Bedrock enumeration block — null when not in Bedrock mode. */
588
+ bedrock: AgentBedrockStatusSchema.nullable().default(null),
568
589
  });
569
590
  export type AgentCredStatus = z.infer<typeof AgentCredStatusSchema>;
570
591
 
@@ -1878,6 +1899,7 @@ export const McpServerSchema = z.object({
1878
1899
  headers: z.string().nullable(),
1879
1900
  envConfigKeys: z.string().nullable(),
1880
1901
  headerConfigKeys: z.string().nullable(),
1902
+ extraAuthorizeParams: z.string().nullable(),
1881
1903
  authMethod: McpAuthMethodSchema.default("static"),
1882
1904
  isEnabled: z.boolean(),
1883
1905
  version: z.number(),