@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.
- package/README.md +3 -3
- package/openapi.json +46 -1
- package/package.json +4 -3
- package/src/be/boot-scrub-logs.ts +76 -0
- package/src/be/db.ts +22 -10
- package/src/be/migrations/094_mcp_extra_authorize_params.sql +4 -0
- package/src/be/modelsdev-cache.json +89422 -85636
- package/src/be/skill-sync.ts +4 -4
- package/src/be/swarm-config-guard.ts +8 -0
- package/src/commands/provider-credentials.ts +37 -9
- package/src/commands/runner.ts +28 -0
- package/src/http/agents.ts +1 -0
- package/src/http/config.ts +24 -4
- package/src/http/index.ts +9 -0
- package/src/http/mcp-oauth.ts +14 -0
- package/src/oauth/mcp-wrapper.ts +14 -0
- package/src/prompts/session-templates.ts +21 -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 +160 -21
- package/src/providers/types.ts +33 -0
- package/src/tests/bedrock-model-groups.test.ts +135 -0
- package/src/tests/credential-check.test.ts +538 -50
- 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/secret-scrubber.test.ts +73 -1
- 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/tools/swarm-config/get-config.ts +9 -1
- package/src/tools/swarm-config/list-config.ts +8 -0
- package/src/types.ts +22 -0
- package/src/utils/secret-scrubber.ts +33 -12
- 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 {
|
|
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 {
|
|
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(
|
|
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
|
|
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(
|
|
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(
|
|
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(),
|