@desplega.ai/agent-swarm 1.151.0 → 1.152.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 +1 -1
- package/dist/{acp-adapter-ex8hzjfc.js → acp-adapter-d05sg0q4.js} +1 -1
- package/dist/{actions-2bgpyhww.js → actions-4sc0v342.js} +13 -13
- package/dist/{app-1706zhr1.js → app-2d0v6kt6.js} +3 -3
- package/dist/{assistant-bavke5p7.js → assistant-zs6wdnrf.js} +14 -14
- package/dist/{boot-reembed-nafcmmmc.js → boot-reembed-3dwdnnsr.js} +3 -3
- package/dist/{boot-reembed-3cmrrmpa.js → boot-reembed-vhh33520.js} +4 -4
- package/dist/{boot-scrub-logs-cd6a2sq0.js → boot-scrub-logs-e1fnabcg.js} +2 -2
- package/dist/{claude-adapter-wfp2gzvz.js → claude-adapter-h58xqzzd.js} +3 -3
- package/dist/{claude-sdk-session-bht9jjak.js → claude-sdk-session-pbaczbgt.js} +3 -3
- package/dist/{cli-a1z3h107.js → cli-4fqn2vpg.js} +4 -4
- package/dist/{cli-g69fdc9g.js → cli-5bqt4gwa.js} +2 -2
- package/dist/{cli-gjyvhmmh.js → cli-5p007m2q.js} +5 -5
- package/dist/{cli-5jd2z7av.js → cli-6k2athpf.js} +6 -2
- package/dist/{cli-5tpgq60f.js → cli-72vksk1n.js} +152 -23
- package/dist/{cli-vwm416m6.js → cli-87hdfyde.js} +1 -1
- package/dist/{cli-3v9xhjcn.js → cli-8fy9dmpv.js} +2 -2
- package/dist/{cli-czjtgfys.js → cli-awb59f08.js} +2 -2
- package/dist/{cli-j9xczhaq.js → cli-cs9z1hw3.js} +1 -1
- package/dist/{cli-r2q6pevn.js → cli-cwvey7n1.js} +43 -21
- package/dist/{cli-ppm4975n.js → cli-evgy5w3y.js} +1 -1
- package/dist/{cli-frcta2w4.js → cli-f1f9n77e.js} +1 -1
- package/dist/{cli-9004vfdf.js → cli-fhdssm2v.js} +2 -2
- package/dist/{cli-s14agm5s.js → cli-gacq5hg8.js} +12 -3
- package/dist/{cli-zkxymas5.js → cli-jh9txj4n.js} +1 -1
- package/dist/{cli-q88st9aj.js → cli-nd5s3m4y.js} +2 -2
- package/dist/{cli-sjaae6ff.js → cli-p2hx1pxm.js} +3 -3
- package/dist/{cli-2tbvf1z1.js → cli-t1mykkng.js} +8 -8
- package/dist/{cli-7tgcstn2.js → cli-t6axtdcg.js} +2 -2
- package/dist/{cli-f1wky6zp.js → cli-wp7cejrg.js} +1 -1
- package/dist/{cli-0qb1z6s2.js → cli-zdcdh02m.js} +1 -1
- package/dist/{cli-gbfwcd3k.js → cli-zfzznn7c.js} +7 -6
- package/dist/cli.js +11 -11
- package/dist/{codex-adapter-jjjk8w4m.js → codex-adapter-v3cz462q.js} +2 -2
- package/dist/{codex-session-runner-nd6ayxkx.js → codex-session-runner-de1a5g5z.js} +2 -2
- package/dist/{commands-9v4psyq1.js → commands-wfqqpn95.js} +2 -2
- package/dist/{db-wnt58tpe.js → db-9zzv5y05.js} +2 -2
- package/dist/{dispatcher-05jh1aqk.js → dispatcher-hfm1jbgx.js} +12 -12
- package/dist/{handlers-tw90pkh5.js → handlers-wrrhsack.js} +15 -15
- package/dist/{hook-sxe3r349.js → hook-9skye8zd.js} +1 -1
- package/dist/{hook-123e2scb.js → hook-fgjv62k5.js} +2 -2
- package/dist/{http-ecsar8ez.js → http-7grttjfq.js} +37 -34
- package/dist/{index-nnrbpcjp.js → index-29gxx9ar.js} +12 -12
- package/dist/{index-hw26vj40.js → index-30pz2h9w.js} +12 -12
- package/dist/{index-zj518gf9.js → index-4ek1df87.js} +13 -13
- package/dist/{index-16js521z.js → index-myfwqqyq.js} +8 -8
- package/dist/{index-ymn7em0p.js → index-pfhzrh1d.js} +3 -3
- package/dist/{keepalive-asqtcm4y.js → keepalive-j75m2m7k.js} +4 -4
- package/dist/{lead-xbfr7fvt.js → lead-a1hpjt0a.js} +16 -16
- package/dist/{maintenance-qma55qvw.js → maintenance-tngjzqdq.js} +4 -4
- package/dist/{oauth-refresh-sweep-3s04c6qw.js → oauth-refresh-sweep-em1fn044.js} +3 -3
- package/dist/{onboard-drskd386.js → onboard-fv7ezvwx.js} +2 -2
- package/dist/{otel-impl-943c96er.js → otel-impl-6vbb568y.js} +1 -1
- package/dist/{pricing-refresh-mx51az05.js → pricing-refresh-nmm949an.js} +4 -4
- package/dist/{rbac-roles-wcmrg6sv.js → rbac-roles-0fz5qryx.js} +4 -4
- package/dist/{rbac-roles-78baecqd.js → rbac-roles-xtp05syf.js} +3 -3
- package/dist/{render-v2-8zjntgf5.js → render-v2-fpb8whme.js} +3 -3
- package/dist/{seed-pricing-jqjs6cws.js → seed-pricing-zwa8bs3t.js} +3 -3
- package/dist/{setup-4pks0vfk.js → setup-3ybwbdpd.js} +2 -2
- package/dist/{work-objects-wangt792.js → work-objects-rmayxcx7.js} +18 -17
- package/dist/{worker-p2mr422x.js → worker-rng4jhnh.js} +16 -16
- package/openapi.json +4 -4
- package/package.json +1 -1
- package/src/be/db.ts +1 -1
- package/src/be/scripts/typecheck.ts +4 -0
- package/src/be/swarm-config-guard.ts +7 -1
- package/src/extensions/lifecycle.ts +26 -4
- package/src/http/extensions.ts +8 -3
- package/src/rbac/legacy-policy.ts +13 -1
- package/src/scripts-runtime/sdk-allowlist.ts +4 -0
- package/src/scripts-runtime/types/stdlib.d.ts +4 -0
- package/src/scripts-runtime/types/swarm-sdk.d.ts +4 -0
- package/src/server.ts +12 -0
- package/src/slack/render-v2.ts +10 -4
- package/src/slack/responses.ts +7 -4
- package/src/slack/task-output.ts +21 -0
- package/src/slack/watcher.ts +3 -2
- package/src/slack/work-objects.ts +2 -1
- package/src/tests/extensions-example-mcp-install.test.ts +2 -2
- package/src/tests/extensions-http.test.ts +84 -3
- package/src/tests/extensions-lead-equivalence.test.ts +21 -0
- package/src/tests/extensions-mcp-tools.test.ts +111 -3
- package/src/tests/fixtures/store-progress-malformed-output.json +4 -0
- package/src/tests/rbac-admission-e2e.test.ts +35 -4
- package/src/tests/rbac-e2e-helpers.ts +18 -0
- package/src/tests/rbac-engine.test.ts +2 -2
- package/src/tests/rbac-mcp-admission-e2e.test.ts +22 -4
- package/src/tests/reload-config.test.ts +17 -0
- package/src/tests/scripts-only-gating.test.ts +4 -0
- package/src/tests/slack-render-v2.test.ts +24 -2
- package/src/tests/slack-task-output.test.ts +36 -0
- package/src/tests/store-progress-attachments-handler.test.ts +100 -1
- package/src/tests/swarm-config-reserved-keys.test.ts +6 -1
- package/src/tests/tool-annotations.test.ts +1 -1
- package/src/tools/extension-activate-version.ts +38 -0
- package/src/tools/extension-common.ts +2 -1
- package/src/tools/extension-delete.ts +29 -0
- package/src/tools/extension-disable.ts +36 -0
- package/src/tools/extension-enable.ts +36 -0
- package/src/tools/extension-install.ts +2 -2
- package/src/tools/store-progress.ts +30 -0
- package/src/tools/tool-config.ts +5 -1
- package/templates/skills/agent-browser/SKILL.md +84 -0
- package/templates/skills/agent-browser/content.md +21 -0
|
@@ -227,6 +227,23 @@ describe("reload-config", () => {
|
|
|
227
227
|
}
|
|
228
228
|
});
|
|
229
229
|
|
|
230
|
+
test("stored lead activation gate cannot override the deployment at boot or reload", async () => {
|
|
231
|
+
const key = "EXTENSION_ALLOW_LEAD_ACTIVATION";
|
|
232
|
+
const original = process.env[key];
|
|
233
|
+
const rowId = await insertLegacyReservedRow(key, "true");
|
|
234
|
+
try {
|
|
235
|
+
process.env[key] = "false";
|
|
236
|
+
for (const override of [false, true]) {
|
|
237
|
+
expect(await loadGlobalConfigsIntoEnv(override)).not.toContain(key);
|
|
238
|
+
expect(process.env[key]).toBe("false");
|
|
239
|
+
}
|
|
240
|
+
} finally {
|
|
241
|
+
await deleteSwarmConfig(rowId);
|
|
242
|
+
if (original === undefined) delete process.env[key];
|
|
243
|
+
else process.env[key] = original;
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
230
247
|
test("loadGlobalConfigsIntoEnv skips unreadable reserved secret rows before decrypting them", async () => {
|
|
231
248
|
const id = await insertUnreadableReservedSecretRow("SECRETS_ENCRYPTION_KEY");
|
|
232
249
|
|
|
@@ -17,6 +17,10 @@ import { listenOnFreePort } from "./test-net";
|
|
|
17
17
|
|
|
18
18
|
const TEST_DB_PATH = "./test-scripts-only-gating.sqlite";
|
|
19
19
|
const SCRIPT_TOOL_NAMES = [
|
|
20
|
+
"extension-delete",
|
|
21
|
+
"extension-enable",
|
|
22
|
+
"extension-disable",
|
|
23
|
+
"extension-activate-version",
|
|
20
24
|
"extension-install",
|
|
21
25
|
"extension-list",
|
|
22
26
|
"get-script-run",
|
|
@@ -1789,7 +1789,7 @@ describe("Slack renderer v2", () => {
|
|
|
1789
1789
|
expect(outcome?.finalizedAt).toBeDefined();
|
|
1790
1790
|
});
|
|
1791
1791
|
|
|
1792
|
-
test("
|
|
1792
|
+
test("omits the deferral ETA even when the agent already sent a slack-reply", async () => {
|
|
1793
1793
|
const lead = await createAgent({ name: "Deferring Lead", isLead: true, status: "idle" });
|
|
1794
1794
|
const { channelId, threadTs } = uniqueSlackAddress("C_RENDER_DEFER_AFTER_REPLY");
|
|
1795
1795
|
const ask = await createTaskExtended("defer after replying by hand", {
|
|
@@ -1814,10 +1814,32 @@ describe("Slack renderer v2", () => {
|
|
|
1814
1814
|
await processSlackRenderV2();
|
|
1815
1815
|
|
|
1816
1816
|
const started = calls.find((call) => call.method === "chat.startStream");
|
|
1817
|
-
expect(started?.payload.markdown_text).toBe(
|
|
1817
|
+
expect(started?.payload.markdown_text).toBe("✅\n\nPending: checking the new defer card");
|
|
1818
|
+
expect(JSON.stringify(calls)).not.toContain("17:30:19");
|
|
1819
|
+
expect(JSON.stringify(calls)).not.toContain("Deferred until");
|
|
1820
|
+
expect((await getTaskById(ask.id))?.output).toBe(deferralLine);
|
|
1818
1821
|
expect(started?.payload.markdown_text).not.toBe(`✅ ${lead.name} completed`);
|
|
1819
1822
|
});
|
|
1820
1823
|
|
|
1824
|
+
test("does not post an ETA-only deferral notice", async () => {
|
|
1825
|
+
const { channelId, threadTs } = uniqueSlackAddress("C_ETA_ONLY");
|
|
1826
|
+
const ask = await createTaskExtended("wait", {
|
|
1827
|
+
source: "slack",
|
|
1828
|
+
slackChannelId: channelId,
|
|
1829
|
+
slackThreadTs: threadTs,
|
|
1830
|
+
});
|
|
1831
|
+
await startTask(ask.id);
|
|
1832
|
+
await completeTask(
|
|
1833
|
+
ask.id,
|
|
1834
|
+
"Deferred until today 17:30 UTC ([12345678](https://example.com/schedules/12345678)) -> ",
|
|
1835
|
+
{ addTags: ["deferred"] },
|
|
1836
|
+
);
|
|
1837
|
+
calls.length = 0;
|
|
1838
|
+
await processSlackRenderV2();
|
|
1839
|
+
expect(calls.filter((call) => call.method === "chat.startStream")).toHaveLength(0);
|
|
1840
|
+
expect(JSON.stringify(calls)).not.toContain("17:30");
|
|
1841
|
+
});
|
|
1842
|
+
|
|
1821
1843
|
test("refreshes a stream started with stale content before finalizing it", async () => {
|
|
1822
1844
|
const lead = await createAgent({ name: "Refresh Lead", isLead: true, status: "idle" });
|
|
1823
1845
|
const { channelId, threadTs } = uniqueSlackAddress("C_RENDER_REFRESH_STALE");
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { slackTaskOutput } from "../slack/task-output";
|
|
3
|
+
|
|
4
|
+
const task = {
|
|
5
|
+
status: "completed" as const,
|
|
6
|
+
tags: ["deferred"],
|
|
7
|
+
output:
|
|
8
|
+
"Deferred until tomorrow 17:30 UTC ([12345678](https://example.com/schedules/12345678)) -> check the build",
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
describe("Slack deferral output", () => {
|
|
12
|
+
test("keeps the pending work without the ETA or schedule link", () => {
|
|
13
|
+
expect(slackTaskOutput(task)).toBe("Pending: check the build");
|
|
14
|
+
expect(task.output).toContain("tomorrow 17:30 UTC");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("removes an ETA-only line and preserves punctuation in the pending description", () => {
|
|
18
|
+
expect(slackTaskOutput({ ...task, output: task.output.replace("check the build", "") })).toBe(
|
|
19
|
+
"",
|
|
20
|
+
);
|
|
21
|
+
expect(slackTaskOutput({ ...task, output: `${task.output} (CI) -> deploy` })).toBe(
|
|
22
|
+
"Pending: check the build (CI) -> deploy",
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("preserves final continuation results even with inherited deferred tags", () => {
|
|
27
|
+
expect(slackTaskOutput({ ...task, output: "Build passed." })).toBe("Build passed.");
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("preserves schema output and non-deferral output verbatim", () => {
|
|
31
|
+
expect(slackTaskOutput({ ...task, outputSchema: { type: "string" } })).toBe(task.output);
|
|
32
|
+
expect(slackTaskOutput({ ...task, tags: [] })).toBe(task.output);
|
|
33
|
+
expect(slackTaskOutput({ ...task, status: "in_progress" })).toBe(task.output);
|
|
34
|
+
expect(slackTaskOutput({ ...task, output: undefined })).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* org/drive and credentials
|
|
18
18
|
* 4. missing files and wrong drives both block attachment + task writes
|
|
19
19
|
*/
|
|
20
|
-
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
20
|
+
import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test";
|
|
21
21
|
import crypto from "node:crypto";
|
|
22
22
|
import { unlink } from "node:fs/promises";
|
|
23
23
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
upsertSwarmConfig,
|
|
35
35
|
} from "../be/db";
|
|
36
36
|
import { registerStoreProgressTool } from "../tools/store-progress";
|
|
37
|
+
import malformedOutput from "./fixtures/store-progress-malformed-output.json";
|
|
37
38
|
|
|
38
39
|
const TEST_DB_PATH = "./test-store-progress-attachments-handler.sqlite";
|
|
39
40
|
const ORIGINAL_FETCH = globalThis.fetch;
|
|
@@ -137,6 +138,104 @@ describe("store-progress handler — attachments insert path", () => {
|
|
|
137
138
|
};
|
|
138
139
|
}
|
|
139
140
|
|
|
141
|
+
test("salvages the literal 59811b57 output and registers both URL attachments", async () => {
|
|
142
|
+
const task = await createTaskExtended("salvage leaked tool parameters", { agentId });
|
|
143
|
+
await startTask(task.id);
|
|
144
|
+
const result = (await buildServer().handler(
|
|
145
|
+
{ taskId: task.id, status: "completed", output: malformedOutput.output },
|
|
146
|
+
buildMeta(),
|
|
147
|
+
)) as StoreProgressResult;
|
|
148
|
+
|
|
149
|
+
expect(result.structuredContent.success).toBe(true);
|
|
150
|
+
const saved = await getTaskById(task.id);
|
|
151
|
+
expect(saved?.output).toBe(malformedOutput.output.split("</output>")[0]);
|
|
152
|
+
expect(saved?.output?.endsWith("reposted clean.")).toBe(true);
|
|
153
|
+
const attachments = await getTaskAttachments(task.id);
|
|
154
|
+
expect(attachments).toHaveLength(2);
|
|
155
|
+
expect(attachments.every((a) => a.kind === "url")).toBe(true);
|
|
156
|
+
expect(attachments.map((a) => a.url).sort()).toEqual([
|
|
157
|
+
"https://api.desplega.agent-swarm.dev/p/556b11781dbb40bf9b1eb1b3315adae6",
|
|
158
|
+
"https://x.com/desplegalabs/status/2100595056939827590",
|
|
159
|
+
]);
|
|
160
|
+
expect(attachments.find((a) => a.isPrimary)?.name).toBe("X reply in the Jev thread");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("preserves clean output byte-for-byte", async () => {
|
|
164
|
+
const output = "The output is ready: 2 < 3.\n Keep this whitespace. ";
|
|
165
|
+
const task = await createTaskExtended("clean output control", { agentId });
|
|
166
|
+
await startTask(task.id);
|
|
167
|
+
const result = (await buildServer().handler(
|
|
168
|
+
{ taskId: task.id, status: "completed", output },
|
|
169
|
+
buildMeta(),
|
|
170
|
+
)) as StoreProgressResult;
|
|
171
|
+
expect(result.structuredContent.success).toBe(true);
|
|
172
|
+
expect((await getTaskById(task.id))?.output).toBe(output);
|
|
173
|
+
expect(await getTaskAttachments(task.id)).toHaveLength(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test.each([
|
|
177
|
+
["invalid JSON", '</output>\n<parameter name="attachments">[broken'],
|
|
178
|
+
["invalid attachment schema", '</output><parameter name="attachments">[{"kind":"url"}]'],
|
|
179
|
+
["unknown parameter", '</output><parameter name="status">"failed"'],
|
|
180
|
+
["alternate closing tag", '</parameter>\n<parameter name="progress">Not JSON'],
|
|
181
|
+
["bare opener", '<parameter name="progress">Not JSON'],
|
|
182
|
+
["non-array JSON", '</output><parameter name="attachments">null'],
|
|
183
|
+
])("strips %s without losing the clean result", async (_name, tail) => {
|
|
184
|
+
const task = await createTaskExtended("unrecoverable tail", { agentId });
|
|
185
|
+
await startTask(task.id);
|
|
186
|
+
const warning = spyOn(console, "warn").mockImplementation(() => {});
|
|
187
|
+
try {
|
|
188
|
+
const result = (await buildServer().handler(
|
|
189
|
+
{ taskId: task.id, status: "completed", output: `Clean result.${tail}` },
|
|
190
|
+
buildMeta(),
|
|
191
|
+
)) as StoreProgressResult;
|
|
192
|
+
expect(result.structuredContent.success).toBe(true);
|
|
193
|
+
expect((await getTaskById(task.id))?.output).toBe("Clean result.");
|
|
194
|
+
expect((await getTaskById(task.id))?.status).toBe("completed");
|
|
195
|
+
expect(await getTaskAttachments(task.id)).toHaveLength(0);
|
|
196
|
+
expect(warning).toHaveBeenCalledTimes(1);
|
|
197
|
+
expect(warning.mock.calls[0]?.[0]).not.toContain(tail);
|
|
198
|
+
} finally {
|
|
199
|
+
warning.mockRestore();
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("appends recovered pointers to explicit attachments through the normal dedupe path", async () => {
|
|
204
|
+
const task = await createTaskExtended("mixed explicit and leaked attachments", { agentId });
|
|
205
|
+
await startTask(task.id);
|
|
206
|
+
const attachment = { kind: "url", name: "link", url: "https://example.com/link" };
|
|
207
|
+
const result = (await buildServer().handler(
|
|
208
|
+
{
|
|
209
|
+
taskId: task.id,
|
|
210
|
+
status: "completed",
|
|
211
|
+
output: `Done.<parameter name="attachments">${JSON.stringify([attachment])}</parameter>`,
|
|
212
|
+
attachments: [attachment],
|
|
213
|
+
},
|
|
214
|
+
buildMeta(),
|
|
215
|
+
)) as StoreProgressResult;
|
|
216
|
+
expect(result.structuredContent.success).toBe(true);
|
|
217
|
+
expect((await getTaskById(task.id))?.output).toBe("Done.");
|
|
218
|
+
expect(await getTaskAttachments(task.id)).toHaveLength(1);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test("recovered agent-fs pointers still require ordinary scope verification", async () => {
|
|
222
|
+
const task = await createTaskExtended("verify recovered pointer", { agentId });
|
|
223
|
+
await startTask(task.id);
|
|
224
|
+
const result = (await buildServer().handler(
|
|
225
|
+
{
|
|
226
|
+
taskId: task.id,
|
|
227
|
+
status: "completed",
|
|
228
|
+
output:
|
|
229
|
+
'Done.</output><parameter name="attachments">[{"kind":"agent-fs","name":"missing.md","path":"/missing.md"}]',
|
|
230
|
+
},
|
|
231
|
+
buildMeta(),
|
|
232
|
+
)) as StoreProgressResult;
|
|
233
|
+
expect(result.structuredContent.success).toBe(false);
|
|
234
|
+
expect(result.structuredContent.message).toContain("both orgId and driveId must resolve");
|
|
235
|
+
expect((await getTaskById(task.id))?.status).toBe("in_progress");
|
|
236
|
+
expect(await getTaskAttachments(task.id)).toHaveLength(0);
|
|
237
|
+
});
|
|
238
|
+
|
|
140
239
|
test("inserts attachment row on an in-progress task (baseline)", async () => {
|
|
141
240
|
const task = await createTaskExtended("handler in-progress baseline", {
|
|
142
241
|
agentId,
|
|
@@ -134,7 +134,12 @@ describe("swarm-config reserved keys guard", () => {
|
|
|
134
134
|
await unlink(`${TEST_DB_PATH}-shm`).catch(() => {});
|
|
135
135
|
});
|
|
136
136
|
|
|
137
|
-
for (const key of [
|
|
137
|
+
for (const key of [
|
|
138
|
+
"CORS_ALLOW_ANY_ORIGIN",
|
|
139
|
+
"cors_allow_any_origin",
|
|
140
|
+
"EXTENSION_ALLOW_LEAD_ACTIVATION",
|
|
141
|
+
"extension_allow_lead_activation",
|
|
142
|
+
]) {
|
|
138
143
|
test(`rejects deployment-only ${key} through DB, HTTP, and MCP`, async () => {
|
|
139
144
|
const config = { scope: "global" as const, key, value: "true" };
|
|
140
145
|
await expect(upsertSwarmConfig(config)).rejects.toThrow(EXPECTED_MESSAGE(key));
|
|
@@ -378,7 +378,7 @@ describe("Tool Annotations & Classification", () => {
|
|
|
378
378
|
// Includes 11 skill tools, 7 MCP server tools, reusable script tools, and the
|
|
379
379
|
// native Kapso/WhatsApp tools (register/unregister number + send/reply message).
|
|
380
380
|
expect(count).toBeGreaterThanOrEqual(45);
|
|
381
|
-
expect(count).toBeLessThanOrEqual(
|
|
381
|
+
expect(count).toBeLessThanOrEqual(147);
|
|
382
382
|
});
|
|
383
383
|
|
|
384
384
|
test("core tools are fewer than deferred tools", () => {
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { createToolRegistrar, toolOk } from "@/tools/utils";
|
|
4
|
+
import {
|
|
5
|
+
coerceExtensionSummary,
|
|
6
|
+
extensionToolOutputSchema,
|
|
7
|
+
proxyExtensionsApi,
|
|
8
|
+
} from "./extension-common";
|
|
9
|
+
|
|
10
|
+
export const registerExtensionActivateVersionTool = (server: McpServer) => {
|
|
11
|
+
createToolRegistrar(server)(
|
|
12
|
+
"extension-activate-version",
|
|
13
|
+
{
|
|
14
|
+
title: "Extension Activate Version",
|
|
15
|
+
description:
|
|
16
|
+
"Activate a stored extension version, reloading it if enabled. Requires a lead, operator, or dashboard user.",
|
|
17
|
+
annotations: { openWorldHint: false },
|
|
18
|
+
inputSchema: z
|
|
19
|
+
.object({
|
|
20
|
+
id: z.string().uuid().describe("Installed extension ID from extension-list."),
|
|
21
|
+
version: z.number().int().min(1).describe("Stored version number to activate."),
|
|
22
|
+
})
|
|
23
|
+
.strict(),
|
|
24
|
+
outputSchema: extensionToolOutputSchema,
|
|
25
|
+
},
|
|
26
|
+
async (args, requestInfo) =>
|
|
27
|
+
proxyExtensionsApi({
|
|
28
|
+
method: "POST",
|
|
29
|
+
path: `/api/extensions/${args.id}/activate-version`,
|
|
30
|
+
body: { version: args.version },
|
|
31
|
+
requestInfo,
|
|
32
|
+
success: (data) =>
|
|
33
|
+
toolOk("Extension activated.", {
|
|
34
|
+
data: coerceExtensionSummary((data as { extension?: unknown }).extension),
|
|
35
|
+
}),
|
|
36
|
+
}),
|
|
37
|
+
);
|
|
38
|
+
};
|
|
@@ -26,6 +26,7 @@ export const extensionToolOutputSchema = swarmToolOutputSchema({
|
|
|
26
26
|
priority: z.number().optional(),
|
|
27
27
|
consecutiveFailures: z.number().optional(),
|
|
28
28
|
contentDeduped: z.boolean().optional(),
|
|
29
|
+
deleted: z.boolean().optional(),
|
|
29
30
|
extensions: z.array(extensionListItemSchema).optional(),
|
|
30
31
|
});
|
|
31
32
|
|
|
@@ -61,7 +62,7 @@ function renderDiagnostics(data: unknown): string | undefined {
|
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
export async function proxyExtensionsApi<T>(args: {
|
|
64
|
-
method: "GET" | "POST";
|
|
65
|
+
method: "GET" | "POST" | "DELETE";
|
|
65
66
|
path: string;
|
|
66
67
|
body?: unknown;
|
|
67
68
|
requestInfo: RequestInfo;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { createToolRegistrar, toolOk } from "@/tools/utils";
|
|
4
|
+
import { extensionToolOutputSchema, proxyExtensionsApi } from "./extension-common";
|
|
5
|
+
|
|
6
|
+
export const registerExtensionDeleteTool = (server: McpServer) => {
|
|
7
|
+
createToolRegistrar(server)(
|
|
8
|
+
"extension-delete",
|
|
9
|
+
{
|
|
10
|
+
title: "Extension Delete",
|
|
11
|
+
description:
|
|
12
|
+
"Uninstall a disabled extension and delete its stored history. Disable it first with extension-disable. Requires a lead, operator, or dashboard user.",
|
|
13
|
+
annotations: { openWorldHint: false, destructiveHint: true },
|
|
14
|
+
inputSchema: z
|
|
15
|
+
.object({
|
|
16
|
+
id: z.string().uuid().describe("Installed extension ID from extension-list."),
|
|
17
|
+
})
|
|
18
|
+
.strict(),
|
|
19
|
+
outputSchema: extensionToolOutputSchema,
|
|
20
|
+
},
|
|
21
|
+
async (args, requestInfo) =>
|
|
22
|
+
proxyExtensionsApi({
|
|
23
|
+
method: "DELETE",
|
|
24
|
+
path: `/api/extensions/${args.id}`,
|
|
25
|
+
requestInfo,
|
|
26
|
+
success: () => toolOk("Extension deleted.", { data: { id: args.id, deleted: true } }),
|
|
27
|
+
}),
|
|
28
|
+
);
|
|
29
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { createToolRegistrar, toolOk } from "@/tools/utils";
|
|
4
|
+
import {
|
|
5
|
+
coerceExtensionSummary,
|
|
6
|
+
extensionToolOutputSchema,
|
|
7
|
+
proxyExtensionsApi,
|
|
8
|
+
} from "./extension-common";
|
|
9
|
+
|
|
10
|
+
export const registerExtensionDisableTool = (server: McpServer) => {
|
|
11
|
+
createToolRegistrar(server)(
|
|
12
|
+
"extension-disable",
|
|
13
|
+
{
|
|
14
|
+
title: "Extension Disable",
|
|
15
|
+
description:
|
|
16
|
+
"Unload and disable an installed extension. Requires a lead, operator, or dashboard user.",
|
|
17
|
+
annotations: { openWorldHint: false },
|
|
18
|
+
inputSchema: z
|
|
19
|
+
.object({
|
|
20
|
+
id: z.string().uuid().describe("Installed extension ID from extension-list."),
|
|
21
|
+
})
|
|
22
|
+
.strict(),
|
|
23
|
+
outputSchema: extensionToolOutputSchema,
|
|
24
|
+
},
|
|
25
|
+
async (args, requestInfo) =>
|
|
26
|
+
proxyExtensionsApi({
|
|
27
|
+
method: "POST",
|
|
28
|
+
path: `/api/extensions/${args.id}/disable`,
|
|
29
|
+
requestInfo,
|
|
30
|
+
success: (data) =>
|
|
31
|
+
toolOk("Extension disabled.", {
|
|
32
|
+
data: coerceExtensionSummary((data as { extension?: unknown }).extension),
|
|
33
|
+
}),
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import * as z from "zod";
|
|
3
|
+
import { createToolRegistrar, toolOk } from "@/tools/utils";
|
|
4
|
+
import {
|
|
5
|
+
coerceExtensionSummary,
|
|
6
|
+
extensionToolOutputSchema,
|
|
7
|
+
proxyExtensionsApi,
|
|
8
|
+
} from "./extension-common";
|
|
9
|
+
|
|
10
|
+
export const registerExtensionEnableTool = (server: McpServer) => {
|
|
11
|
+
createToolRegistrar(server)(
|
|
12
|
+
"extension-enable",
|
|
13
|
+
{
|
|
14
|
+
title: "Extension Enable",
|
|
15
|
+
description:
|
|
16
|
+
"Load and enable an installed extension. Requires a lead, operator, or dashboard user.",
|
|
17
|
+
annotations: { openWorldHint: false },
|
|
18
|
+
inputSchema: z
|
|
19
|
+
.object({
|
|
20
|
+
id: z.string().uuid().describe("Installed extension ID from extension-list."),
|
|
21
|
+
})
|
|
22
|
+
.strict(),
|
|
23
|
+
outputSchema: extensionToolOutputSchema,
|
|
24
|
+
},
|
|
25
|
+
async (args, requestInfo) =>
|
|
26
|
+
proxyExtensionsApi({
|
|
27
|
+
method: "POST",
|
|
28
|
+
path: `/api/extensions/${args.id}/enable`,
|
|
29
|
+
requestInfo,
|
|
30
|
+
success: (data) =>
|
|
31
|
+
toolOk("Extension enabled.", {
|
|
32
|
+
data: coerceExtensionSummary((data as { extension?: unknown }).extension),
|
|
33
|
+
}),
|
|
34
|
+
}),
|
|
35
|
+
);
|
|
36
|
+
};
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "./extension-common";
|
|
10
10
|
|
|
11
11
|
export const EXTENSION_INSTALL_DESCRIPTION =
|
|
12
|
-
"Validate and store a TypeScript extension bundle (manifest plus a files map with one hooks.ts). Fetch the hook contract first: GET /api/extensions/type-defs returns swarm-extension.d.ts with every event and modify shape. The extension remains disabled until
|
|
12
|
+
"Validate and store a TypeScript extension bundle (manifest plus a files map with one hooks.ts). Fetch the hook contract first: GET /api/extensions/type-defs returns swarm-extension.d.ts with every event and modify shape. The extension remains disabled until a lead, operator, or dashboard user enables it with extension-enable.";
|
|
13
13
|
|
|
14
14
|
function extensionInstallResult(data: unknown, fallbackName: string) {
|
|
15
15
|
const body = data as {
|
|
@@ -21,7 +21,7 @@ function extensionInstallResult(data: unknown, fallbackName: string) {
|
|
|
21
21
|
const version = typeof extension.version === "number" ? ` v${extension.version}` : "";
|
|
22
22
|
const deduped = body.contentDeduped === true ? " Content was unchanged." : "";
|
|
23
23
|
const enableInstruction =
|
|
24
|
-
"
|
|
24
|
+
"A lead, operator, or dashboard user can enable it with `extension-enable` or `POST /api/extensions/{id}/enable`.";
|
|
25
25
|
|
|
26
26
|
return toolOk(`Extension \`${name}\`${version} installed.${deduped} ${enableInstruction}`, {
|
|
27
27
|
data: {
|
|
@@ -167,6 +167,36 @@ export const registerStoreProgressTool = (server: McpServer) => {
|
|
|
167
167
|
? requestedStatus
|
|
168
168
|
: undefined;
|
|
169
169
|
|
|
170
|
+
// Stock Claude occasionally puts the next tool parameter inside output.
|
|
171
|
+
// Strip the leaked tail before validation/persistence; recovered pointers
|
|
172
|
+
// must still pass the ordinary attachment schema and verification below.
|
|
173
|
+
const leakedParameter = output?.match(
|
|
174
|
+
/(?:<\/(?:output|parameter)>\s*)?<parameter name="([a-zA-Z_]+)">\s*([\s\S]*)$/,
|
|
175
|
+
);
|
|
176
|
+
if (leakedParameter) {
|
|
177
|
+
output = output?.slice(0, leakedParameter.index);
|
|
178
|
+
let recovered = false;
|
|
179
|
+
if (leakedParameter[1] === "attachments") {
|
|
180
|
+
try {
|
|
181
|
+
const parsed = z
|
|
182
|
+
.array(AttachmentInputSchema)
|
|
183
|
+
.max(20)
|
|
184
|
+
.safeParse(JSON.parse(leakedParameter[2]!.replace(/\s*<\/parameter>\s*$/, "")));
|
|
185
|
+
if (parsed.success && (attachments?.length ?? 0) + parsed.data.length <= 20) {
|
|
186
|
+
attachments = [...(attachments ?? []), ...parsed.data];
|
|
187
|
+
recovered = true;
|
|
188
|
+
}
|
|
189
|
+
} catch {
|
|
190
|
+
// Malformed JSON must not prevent saving the cleaned task result.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (!recovered) {
|
|
194
|
+
console.warn(
|
|
195
|
+
`[store-progress] Stripped malformed output tail for task ${taskId}; parameter ${leakedParameter[1]} was not recovered.`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
170
200
|
// Verify agent-fs pointers before opening the write transaction. The
|
|
171
201
|
// registering agent's resolved config selects both credentials and the
|
|
172
202
|
// exact org/drive; never let the provider fall back to a personal drive.
|
package/src/tools/tool-config.ts
CHANGED
|
@@ -197,13 +197,17 @@ export const DEFERRED_TOOLS = new Set([
|
|
|
197
197
|
"room-reset",
|
|
198
198
|
"room-decode",
|
|
199
199
|
|
|
200
|
-
// Reusable scripts and extensions (
|
|
200
|
+
// Reusable scripts and extensions (12)
|
|
201
201
|
"script-upsert",
|
|
202
202
|
"script-delete",
|
|
203
203
|
"script-apis",
|
|
204
204
|
"launch-script-run",
|
|
205
205
|
"get-script-run",
|
|
206
206
|
"list-script-runs",
|
|
207
|
+
"extension-delete",
|
|
208
|
+
"extension-enable",
|
|
209
|
+
"extension-disable",
|
|
210
|
+
"extension-activate-version",
|
|
207
211
|
"extension-install",
|
|
208
212
|
"extension-list",
|
|
209
213
|
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: agent-browser
|
|
3
|
+
description: "Drive a real browser with the agent-browser CLI (headless Chromium over CDP). Use for any browser task: verifying a UI change, taking a screenshot, walking a web page, filling a form, extracting page text, reproducing a frontend bug, or attaching UI evidence to a PR, issue, Linear comment, or Slack thread. Starts with a `command -v agent-browser` check and falls back to manual QA on the slim worker image. Screenshots are shared through agent-fs signed URLs. This is the only browser automation tool; do not use qa-use."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# agent-browser
|
|
7
|
+
|
|
8
|
+
`agent-browser` (vercel-labs) is the swarm's browser-automation CLI. It drives a headless Chromium over CDP and returns accessibility-tree snapshots with compact `@eN` refs. Use it for any browser task: verify a UI change, take a screenshot, walk a page, fill a form, extract page text, reproduce a frontend bug, or attach UI evidence to a PR, issue, Linear comment, or Slack thread.
|
|
9
|
+
|
|
10
|
+
Do not use `qa-use`. It is not installed in the worker image any more.
|
|
11
|
+
|
|
12
|
+
## Step 0: check that the CLI is present
|
|
13
|
+
|
|
14
|
+
The full worker image (`agent-swarm-worker:latest`) ships `agent-browser` and a headless Chromium. The slim image (`:slim`) has neither. The first command of any browser work is:
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
command -v agent-browser
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
If it prints nothing, you are on the slim image. Say so in your progress report ("agent-browser is not available on this worker image") and fall back to manual QA: write the exact steps a human should run and mark the browser test cases as **Blocked**, not failed. Do not install a browser or the CLI yourself.
|
|
21
|
+
|
|
22
|
+
## Step 1: load the version-matched guide
|
|
23
|
+
|
|
24
|
+
The CLI bundles its own usage guide. Load it once per task before the first browser command. Do not guess flags from memory.
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
agent-browser skills get core # overview + common patterns
|
|
28
|
+
agent-browser skills get core --full # adds the full command reference
|
|
29
|
+
agent-browser skills list # specialized guides (dogfood, electron, slack, ...)
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Step 2: the core loop
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
agent-browser open <url>
|
|
36
|
+
agent-browser snapshot -i # accessibility tree, interactive elements only, @eN refs
|
|
37
|
+
agent-browser click @e12 # act on refs from the snapshot
|
|
38
|
+
agent-browser fill @e7 "text"
|
|
39
|
+
agent-browser screenshot /tmp/<name>.png
|
|
40
|
+
agent-browser close
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- Re-run `snapshot` after every navigation or state change. Refs are only valid for the snapshot that produced them.
|
|
44
|
+
- Run one browser session at a time. Call `store-progress` between page batches so the heartbeat watchdog sees you.
|
|
45
|
+
- The browser is headless. Local servers inside the worker container are reachable at `http://localhost:<port>`.
|
|
46
|
+
- The image launches Chromium with `--no-sandbox,--disable-dev-shm-usage` through `AGENT_BROWSER_ARGS`. If you pass your own `--args`, include `--no-sandbox` again or Chrome will not start.
|
|
47
|
+
|
|
48
|
+
## Recording
|
|
49
|
+
|
|
50
|
+
Default: record with `--cursor`, drive every supported pointer action (`click`, `mouse move`, `drag`) with `--human`, and ship the **1.5x video**. Keep the 1x original only if a reviewer asks for it. Human pointer movement follows an eased curve; use `--seed <n>` on `mouse move` to make the take reproducible instead of using a bare move.
|
|
51
|
+
|
|
52
|
+
Open the page first, then record the interaction:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
agent-browser record start ./in.mp4 --cursor
|
|
56
|
+
agent-browser snapshot -i
|
|
57
|
+
agent-browser mouse move 600 400 --human --seed 42 --duration 1400
|
|
58
|
+
agent-browser click @e3 --human # use a ref from the current snapshot
|
|
59
|
+
# Perform the remaining QA steps; use --human for drag actions too.
|
|
60
|
+
agent-browser record stop
|
|
61
|
+
ffmpeg -y -i in.mp4 -filter:v "setpts=PTS/1.5,fps=30" -an \
|
|
62
|
+
-c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p out-1.5x.mp4
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Speed-up is a post-process step: `record start` has no speed or human flag. Keep `fps=30` to preserve a steady 30 fps output rather than uneven frame drops at an inferred rate. `-an` disables audio because agent-browser recordings carry no audio track. The worker ships only `ffmpeg`, not `ffprobe`; inspect duration with `ffmpeg -i out-1.5x.mp4 2>&1 | grep Duration`.
|
|
66
|
+
|
|
67
|
+
Add `--contact-sheet` to the start command for a timestamped PNG summary of visual changes. Recording needs `ffmpeg` on PATH; check `agent-browser doctor` before starting. Stop the recording before closing the browser so the video is saved. Upload the 1.5x file through the same agent-fs QA path and signed-URL steps as screenshots below, and link it in the PR body.
|
|
68
|
+
|
|
69
|
+
## Step 3: share the screenshot through agent-fs
|
|
70
|
+
|
|
71
|
+
Screenshots on the worker disk disappear with the task. Upload them to agent-fs under the qa path convention and share the signed URL. `--file` is binary-safe. `--content` is text-only and mangles PNGs.
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
agent-fs write thoughts/<agent-id>/qa/<topic>-screenshots/<name>.png \
|
|
75
|
+
--file /tmp/<name>.png -m "<what it shows>"
|
|
76
|
+
agent-fs stat thoughts/<agent-id>/qa/<topic>-screenshots/<name>.png --json # confirm size > 0
|
|
77
|
+
agent-fs signed-url thoughts/<agent-id>/qa/<topic>-screenshots/<name>.png --json # 24h default, --expires-in up to 7d
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
- Embed the URL as `` in the PR body, review comment, Linear comment, or Slack message.
|
|
81
|
+
- In `store-progress`, list the upload in the `attachments` field with `kind: "agent-fs"` and the path, and paste the signed URL in the progress text.
|
|
82
|
+
- If `agent-fs auth whoami` fails, report the local path, say the upload was skipped, and continue.
|
|
83
|
+
|
|
84
|
+
The `artifacts` skill holds the full agent-fs recipe and the naming conventions.
|
|
@@ -40,6 +40,27 @@ agent-browser close
|
|
|
40
40
|
- The browser is headless. Local servers inside the worker container are reachable at `http://localhost:<port>`.
|
|
41
41
|
- The image launches Chromium with `--no-sandbox,--disable-dev-shm-usage` through `AGENT_BROWSER_ARGS`. If you pass your own `--args`, include `--no-sandbox` again or Chrome will not start.
|
|
42
42
|
|
|
43
|
+
## Recording
|
|
44
|
+
|
|
45
|
+
Default: record with `--cursor`, drive every supported pointer action (`click`, `mouse move`, `drag`) with `--human`, and ship the **1.5x video**. Keep the 1x original only if a reviewer asks for it. Human pointer movement follows an eased curve; use `--seed <n>` on `mouse move` to make the take reproducible instead of using a bare move.
|
|
46
|
+
|
|
47
|
+
Open the page first, then record the interaction:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
agent-browser record start ./in.mp4 --cursor
|
|
51
|
+
agent-browser snapshot -i
|
|
52
|
+
agent-browser mouse move 600 400 --human --seed 42 --duration 1400
|
|
53
|
+
agent-browser click @e3 --human # use a ref from the current snapshot
|
|
54
|
+
# Perform the remaining QA steps; use --human for drag actions too.
|
|
55
|
+
agent-browser record stop
|
|
56
|
+
ffmpeg -y -i in.mp4 -filter:v "setpts=PTS/1.5,fps=30" -an \
|
|
57
|
+
-c:v libx264 -preset veryfast -crf 23 -pix_fmt yuv420p out-1.5x.mp4
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Speed-up is a post-process step: `record start` has no speed or human flag. Keep `fps=30` to preserve a steady 30 fps output rather than uneven frame drops at an inferred rate. `-an` disables audio because agent-browser recordings carry no audio track. The worker ships only `ffmpeg`, not `ffprobe`; inspect duration with `ffmpeg -i out-1.5x.mp4 2>&1 | grep Duration`.
|
|
61
|
+
|
|
62
|
+
Add `--contact-sheet` to the start command for a timestamped PNG summary of visual changes. Recording needs `ffmpeg` on PATH; check `agent-browser doctor` before starting. Stop the recording before closing the browser so the video is saved. Upload the 1.5x file through the same agent-fs QA path and signed-URL steps as screenshots below, and link it in the PR body.
|
|
63
|
+
|
|
43
64
|
## Step 3: share the screenshot through agent-fs
|
|
44
65
|
|
|
45
66
|
Screenshots on the worker disk disappear with the task. Upload them to agent-fs under the qa path convention and share the signed URL. `--file` is binary-safe. `--content` is text-only and mangles PNGs.
|