@desplega.ai/agent-swarm 1.167.0 → 1.168.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/dist/{acp-adapter-zpvy6321.js → acp-adapter-zhejfecx.js} +2 -2
- package/dist/{actions-fq2zrwtf.js → actions-7jg840nr.js} +11 -11
- package/dist/{amp-adapter-5gbk7501.js → amp-adapter-ye6rnetm.js} +1 -1
- package/dist/{app-nctetn1e.js → app-ffes2kyf.js} +3 -3
- package/dist/{assistant-0aq8gjvj.js → assistant-fhm5smyz.js} +12 -12
- package/dist/{boot-reembed-1ze5853n.js → boot-reembed-20q3nzvg.js} +2 -2
- package/dist/{boot-reembed-fmp5ww69.js → boot-reembed-jvfadhsk.js} +2 -2
- package/dist/{boot-scrub-sweep-cbp8xd4e.js → boot-scrub-sweep-jtfajzve.js} +3 -3
- package/dist/{boot-seeds-22tx8a53.js → boot-seeds-9qt0j8j2.js} +5 -5
- package/dist/{claude-adapter-0jhgeh1d.js → claude-adapter-a8znhthz.js} +3 -3
- package/dist/{claude-sdk-session-wh77rh83.js → claude-sdk-session-7x7chdrs.js} +7590 -7578
- package/dist/{cli-ypgba1a8.js → cli-0jngabtb.js} +1 -1
- package/dist/{cli-tx2zvn23.js → cli-43jg9xdw.js} +3 -3
- package/dist/{cli-6qxmjpem.js → cli-47zfzshm.js} +5 -5
- package/dist/{cli-mkdsy1nq.js → cli-74ez9b66.js} +3 -2
- package/dist/{cli-ykt8f7va.js → cli-9q0bh411.js} +1 -1
- package/dist/{cli-p8rt8spp.js → cli-9q24yyvs.js} +2 -2
- package/dist/{cli-cexctgn5.js → cli-aj1e0h0d.js} +1 -1
- package/dist/{cli-z1dqhc9y.js → cli-bcspch6h.js} +1 -1
- package/dist/{cli-wydz3feg.js → cli-ee0402n1.js} +107 -30
- package/dist/{cli-ty4bzkqr.js → cli-ew0c3a1j.js} +1 -1
- package/dist/{cli-v1pwxreq.js → cli-ewhwpyrb.js} +1 -1
- package/dist/{cli-kw0jcfje.js → cli-fhj4y16f.js} +1 -1
- package/dist/{cli-m7vmeepg.js → cli-fqq06kkb.js} +1 -1
- package/dist/{cli-577bxkf6.js → cli-g6gx2dn1.js} +31 -6
- package/dist/{cli-c8r99akr.js → cli-j05x7pnb.js} +8 -8
- package/dist/{cli-c2tpw9ck.js → cli-jb931ba2.js} +1 -1
- package/dist/{cli-mxry91sa.js → cli-nctfv2yy.js} +72 -3
- package/dist/{cli-ymmc1zxg.js → cli-ng1pmdx6.js} +3 -3
- package/dist/{cli-6vfz44sr.js → cli-p5jqk8fr.js} +1 -1
- package/dist/{cli-qzmzwegq.js → cli-pgehz8e1.js} +4 -4
- package/dist/{cli-cf2vgn9d.js → cli-rr5z1j4p.js} +14 -14
- package/dist/{cli-1yrcfa5s.js → cli-svah6b6w.js} +4 -4
- package/dist/{cli-y0kjpfm9.js → cli-ytmmyj7f.js} +2 -2
- package/dist/{cli-pkj1w46r.js → cli-z48ggfyq.js} +1 -1
- package/dist/cli.js +13 -13
- package/dist/{codex-adapter-cgesnjkr.js → codex-adapter-n63k33d3.js} +2 -2
- package/dist/{codex-session-runner-8kd7ddhr.js → codex-session-runner-xkxfd81a.js} +2 -2
- package/dist/{commands-h6zaaxym.js → commands-q4mfhn51.js} +2 -2
- package/dist/{db-qg3myn53.js → db-1t0bj3pc.js} +2 -2
- package/dist/{dispatcher-7vnwj9qw.js → dispatcher-k1fhavcf.js} +10 -10
- package/dist/{dsh-adapter-tz6hfp5r.js → dsh-adapter-5423p70m.js} +1 -1
- package/dist/{grok-adapter-jdt2dz2s.js → grok-adapter-phrndeb8.js} +3 -3
- package/dist/{handlers-2qtkpncr.js → handlers-59ywqwzh.js} +13 -13
- package/dist/{hook-qnbdgwvd.js → hook-atj0swc2.js} +2 -2
- package/dist/{hook-51yfcgsv.js → hook-mew1chq9.js} +1 -1
- package/dist/{http-jsmtag08.js → http-ef2fnn9v.js} +659 -26
- package/dist/{index-t4g06w93.js → index-48pb9erw.js} +10 -10
- package/dist/{index-ekywyv2v.js → index-9fc2h7ww.js} +10 -10
- package/dist/{index-2t9kesn1.js → index-gr69c43q.js} +2 -2
- package/dist/{index-cx1sb9bq.js → index-h26wjcfy.js} +16 -6
- package/dist/{index-bt8wkv1w.js → index-w2yhye23.js} +11 -11
- package/dist/{keepalive-4aezx4bj.js → keepalive-qhcqz56v.js} +4 -4
- package/dist/{lead-r44bkpm5.js → lead-hpk5r3b1.js} +18 -18
- package/dist/{maintenance-grz5jrzh.js → maintenance-nvkyp2xe.js} +2 -2
- package/dist/{oauth-refresh-sweep-wm4ctjfj.js → oauth-refresh-sweep-ngskthwh.js} +3 -3
- package/dist/{onboard-v60zgqf0.js → onboard-yfnkqhxc.js} +2 -2
- package/dist/{otel-impl-ghh4dyf8.js → otel-impl-hhm64r6m.js} +1 -1
- package/dist/{pricing-refresh-mjrqvnvw.js → pricing-refresh-35f91nx2.js} +4 -4
- package/dist/{rbac-roles-axv4d74x.js → rbac-roles-97w370qj.js} +3 -3
- package/dist/{rbac-roles-fd5tskj9.js → rbac-roles-z6sje3gy.js} +2 -2
- package/dist/{render-v2-k50j33qd.js → render-v2-n4v66bj3.js} +3 -3
- package/dist/{setup-4syh0pd2.js → setup-m3hhyv5f.js} +2 -2
- package/dist/{work-objects-jcdbg8rr.js → work-objects-mxf2c842.js} +13 -13
- package/dist/{worker-x8j8c9sx.js → worker-9t07z1gq.js} +18 -18
- package/openapi.json +72 -1
- package/package.json +3 -3
- package/src/be/script-connections.ts +40 -6
- package/src/be/swarm-config-guard.ts +1 -0
- package/src/extensions/catalog.generated.json +17 -0
- package/src/http/script-connections.ts +35 -0
- package/src/providers/acp-targets.ts +1 -1
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/claude-adapter.ts +6 -0
- package/src/providers/claude-background-keepalive.ts +100 -0
- package/src/providers/claude-sdk-session.ts +6 -0
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/scheduler/scheduler.ts +82 -13
- package/src/scripts-runtime/executors/registry.ts +1 -1
- package/src/tests/claude-background-keepalive-wiring.test.ts +220 -0
- package/src/tests/claude-background-keepalive.fixture.ts +162 -0
- package/src/tests/claude-background-keepalive.test.ts +139 -0
- package/src/tests/connection-embedded-auth.test.ts +51 -0
- package/src/tests/docs-index-footprint.test.js +3 -3
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/extensions-catalog.test.ts +1 -0
- package/src/tests/extensions-example-slack-reply-gate.test.ts +756 -0
- package/src/tests/schedule-target-type.test.ts +136 -0
- package/src/tests/script-connections-http.test.ts +45 -0
- package/src/tests/script-connections-mcp.test.ts +41 -0
- package/src/tests/script-executor-registry.test.ts +18 -0
- package/src/tests/send-task-self-send.test.ts +107 -0
- package/src/tools/script-connections/tool.ts +35 -3
- package/src/tools/send-task.ts +9 -1
- package/templates/extensions/README.md +1 -0
- package/templates/extensions/slack-reply-gate/README.md +98 -0
- package/templates/extensions/slack-reply-gate/hooks.ts +582 -0
- package/templates/extensions/slack-reply-gate/manifest.json +8 -0
- package/templates/skills/swarm-scripts/SKILL.md +10 -0
- package/templates/skills/swarm-scripts/content.md +10 -0
|
@@ -33,6 +33,7 @@ import {
|
|
|
33
33
|
preflightAutomation,
|
|
34
34
|
renderAutomationTokens,
|
|
35
35
|
} from "../be/automation-preflight";
|
|
36
|
+
import * as dbModule from "../be/db";
|
|
36
37
|
import {
|
|
37
38
|
closeDb,
|
|
38
39
|
createAgent,
|
|
@@ -58,6 +59,7 @@ import {
|
|
|
58
59
|
startScheduler,
|
|
59
60
|
stopScheduler,
|
|
60
61
|
} from "../scheduler/scheduler";
|
|
62
|
+
import * as scriptLoader from "../scripts-runtime/loader";
|
|
61
63
|
import type { Workflow, WorkflowDefinition } from "../types";
|
|
62
64
|
import { InProcessEventBus } from "../workflows/event-bus";
|
|
63
65
|
import { BaseExecutor, type ExecutorResult } from "../workflows/executors/base";
|
|
@@ -148,6 +150,38 @@ async function saveGlobalScript(name: string, source: string) {
|
|
|
148
150
|
});
|
|
149
151
|
}
|
|
150
152
|
|
|
153
|
+
type ScheduleScriptRunRow = {
|
|
154
|
+
agentId: string;
|
|
155
|
+
scriptName: string;
|
|
156
|
+
source: string;
|
|
157
|
+
kind: string;
|
|
158
|
+
status: string;
|
|
159
|
+
output: string | null;
|
|
160
|
+
error: string | null;
|
|
161
|
+
finishedAt: string | null;
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
async function scriptRunsFor(scriptName: string): Promise<ScheduleScriptRunRow[]> {
|
|
165
|
+
return getDbClient().query<ScheduleScriptRunRow>(
|
|
166
|
+
"SELECT agentId, scriptName, source, kind, status, output, error, finishedAt FROM script_runs WHERE scriptName = ?",
|
|
167
|
+
[scriptName],
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
type RunScriptOutput = Awaited<ReturnType<typeof scriptLoader.runScript>>;
|
|
172
|
+
|
|
173
|
+
function fakeRunOutput(overrides: Partial<RunScriptOutput>): RunScriptOutput {
|
|
174
|
+
return {
|
|
175
|
+
result: undefined,
|
|
176
|
+
stdout: "",
|
|
177
|
+
stderr: "",
|
|
178
|
+
exitCode: 0,
|
|
179
|
+
durationMs: 1,
|
|
180
|
+
truncated: { stdout: false, stderr: false },
|
|
181
|
+
...overrides,
|
|
182
|
+
} as RunScriptOutput;
|
|
183
|
+
}
|
|
184
|
+
|
|
151
185
|
beforeAll(async () => {
|
|
152
186
|
savedEnv = { ...process.env };
|
|
153
187
|
await removeDbFiles();
|
|
@@ -718,6 +752,11 @@ describe("dispatchScheduleTarget — script target", () => {
|
|
|
718
752
|
const result = await dispatchScheduleTarget(schedule);
|
|
719
753
|
expect(result.triggeredWorkflows).toBe(false);
|
|
720
754
|
expect(result.task).toBeUndefined();
|
|
755
|
+
|
|
756
|
+
const runs = await scriptRunsFor("schedule-target-type-echo");
|
|
757
|
+
expect(runs).toHaveLength(1);
|
|
758
|
+
expect(runs[0]?.status).toBe("completed");
|
|
759
|
+
expect(JSON.parse(runs[0]?.output ?? "null")).toEqual({ received: { hello: "world" } });
|
|
721
760
|
}, 15_000);
|
|
722
761
|
|
|
723
762
|
skip("scheduled script runs receive ctx.api and ctx.mcp connections", async () => {
|
|
@@ -794,9 +833,106 @@ describe("dispatchScheduleTarget — script target", () => {
|
|
|
794
833
|
});
|
|
795
834
|
|
|
796
835
|
await expect(dispatchScheduleTarget(schedule)).rejects.toThrow();
|
|
836
|
+
|
|
837
|
+
const runs = await scriptRunsFor("schedule-target-type-throws");
|
|
838
|
+
expect(runs).toHaveLength(1);
|
|
839
|
+
expect(runs[0]?.status).toBe("failed");
|
|
840
|
+
expect(runs[0]?.error).toContain("boom");
|
|
797
841
|
}, 15_000);
|
|
798
842
|
});
|
|
799
843
|
|
|
844
|
+
describe("dispatchScheduleTarget — script target records a script_runs row", () => {
|
|
845
|
+
async function scheduleFor(scriptName: string) {
|
|
846
|
+
await saveGlobalScript(scriptName, `export default async () => ({ ok: true });`);
|
|
847
|
+
return createScheduledTask({
|
|
848
|
+
name: `record-${scriptName}`,
|
|
849
|
+
intervalMs: 60_000,
|
|
850
|
+
targetType: "script",
|
|
851
|
+
scriptName,
|
|
852
|
+
createdByAgentId: agentId,
|
|
853
|
+
});
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
test("a completed scheduled run writes a completed inline row that points at the script version", async () => {
|
|
857
|
+
const scriptName = `record-ok-${crypto.randomUUID()}`;
|
|
858
|
+
const schedule = await scheduleFor(scriptName);
|
|
859
|
+
const run = spyOn(scriptLoader, "runScript").mockResolvedValue(
|
|
860
|
+
fakeRunOutput({ result: { polled: 3 } }),
|
|
861
|
+
);
|
|
862
|
+
try {
|
|
863
|
+
await dispatchScheduleTarget(schedule);
|
|
864
|
+
} finally {
|
|
865
|
+
run.mockRestore();
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
const runs = await scriptRunsFor(scriptName);
|
|
869
|
+
expect(runs).toHaveLength(1);
|
|
870
|
+
const row = runs[0];
|
|
871
|
+
expect(row?.kind).toBe("inline");
|
|
872
|
+
expect(row?.status).toBe("completed");
|
|
873
|
+
expect(row?.agentId).toBe(agentId);
|
|
874
|
+
expect(row?.finishedAt).toBeTruthy();
|
|
875
|
+
expect(row?.error).toBeNull();
|
|
876
|
+
expect(JSON.parse(row?.output ?? "null")).toEqual({ polled: 3 });
|
|
877
|
+
expect(row?.source).toContain(`'${scriptName}' v1`);
|
|
878
|
+
expect(row?.source).toContain(`by schedule '${schedule.name}'`);
|
|
879
|
+
expect(row?.source).not.toContain("export default");
|
|
880
|
+
});
|
|
881
|
+
|
|
882
|
+
test("a failed scheduled run writes a failed row and still throws", async () => {
|
|
883
|
+
const scriptName = `record-fail-${crypto.randomUUID()}`;
|
|
884
|
+
const schedule = await scheduleFor(scriptName);
|
|
885
|
+
const run = spyOn(scriptLoader, "runScript").mockResolvedValue(
|
|
886
|
+
fakeRunOutput({ exitCode: 1, stderr: "upstream 503" }),
|
|
887
|
+
);
|
|
888
|
+
try {
|
|
889
|
+
await expect(dispatchScheduleTarget(schedule)).rejects.toThrow("upstream 503");
|
|
890
|
+
} finally {
|
|
891
|
+
run.mockRestore();
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
const runs = await scriptRunsFor(scriptName);
|
|
895
|
+
expect(runs).toHaveLength(1);
|
|
896
|
+
expect(runs[0]?.status).toBe("failed");
|
|
897
|
+
expect(runs[0]?.error).toBe("upstream 503");
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test("a runtime that throws before returning writes a failed row and rethrows", async () => {
|
|
901
|
+
const scriptName = `record-throw-${crypto.randomUUID()}`;
|
|
902
|
+
const schedule = await scheduleFor(scriptName);
|
|
903
|
+
const run = spyOn(scriptLoader, "runScript").mockRejectedValue(new Error("spawn failed"));
|
|
904
|
+
try {
|
|
905
|
+
await expect(dispatchScheduleTarget(schedule)).rejects.toThrow("spawn failed");
|
|
906
|
+
} finally {
|
|
907
|
+
run.mockRestore();
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
const runs = await scriptRunsFor(scriptName);
|
|
911
|
+
expect(runs.map((r) => [r.status, r.error])).toEqual([["failed", "spawn failed"]]);
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
test("a persistence error changes neither outcome", async () => {
|
|
915
|
+
const scriptName = `record-dberr-${crypto.randomUUID()}`;
|
|
916
|
+
const schedule = await scheduleFor(scriptName);
|
|
917
|
+
const record = spyOn(dbModule, "recordInlineScriptRun").mockRejectedValue(
|
|
918
|
+
new Error("database is locked"),
|
|
919
|
+
);
|
|
920
|
+
const run = spyOn(scriptLoader, "runScript")
|
|
921
|
+
.mockResolvedValueOnce(fakeRunOutput({ result: { ok: true } }))
|
|
922
|
+
.mockResolvedValueOnce(fakeRunOutput({ exitCode: 1, stderr: "real failure" }));
|
|
923
|
+
try {
|
|
924
|
+
const result = await dispatchScheduleTarget(schedule);
|
|
925
|
+
expect(result.triggeredWorkflows).toBe(false);
|
|
926
|
+
await expect(dispatchScheduleTarget(schedule)).rejects.toThrow("real failure");
|
|
927
|
+
expect(record).toHaveBeenCalledTimes(2);
|
|
928
|
+
} finally {
|
|
929
|
+
run.mockRestore();
|
|
930
|
+
record.mockRestore();
|
|
931
|
+
}
|
|
932
|
+
expect(await scriptRunsFor(scriptName)).toHaveLength(0);
|
|
933
|
+
});
|
|
934
|
+
});
|
|
935
|
+
|
|
800
936
|
// ─── HTTP route cross-field validation ────────────────────────────────────────
|
|
801
937
|
|
|
802
938
|
function makeHttpReq(
|
|
@@ -254,6 +254,51 @@ describe("/api/script-connections HTTP", () => {
|
|
|
254
254
|
expect(await res.json()).toEqual({ error: "Only the lead can manage script connections." });
|
|
255
255
|
});
|
|
256
256
|
|
|
257
|
+
test("DELETE removes a connection, its managed binding, and its inline secret", async () => {
|
|
258
|
+
const created = await dispatch("/api/script-connections", {
|
|
259
|
+
method: "POST",
|
|
260
|
+
agentId: leadAgentId,
|
|
261
|
+
body: {
|
|
262
|
+
kind: "openapi",
|
|
263
|
+
slug: "deleteHttpVendor",
|
|
264
|
+
baseUrl: "https://api.vendor.test",
|
|
265
|
+
allowedHosts: ["api.vendor.test"],
|
|
266
|
+
auth: { type: "bearer", secret: "example-http-delete-tok" },
|
|
267
|
+
openapiSpecJson: inlineOpenApiSpec(),
|
|
268
|
+
},
|
|
269
|
+
});
|
|
270
|
+
expect(created.status).toBe(200);
|
|
271
|
+
const { connection } = (await created.json()) as { connection: { id: string } };
|
|
272
|
+
|
|
273
|
+
const forbidden = await dispatch(`/api/script-connections/${connection.id}`, {
|
|
274
|
+
method: "DELETE",
|
|
275
|
+
agentId: workerAgentId,
|
|
276
|
+
});
|
|
277
|
+
expect(forbidden.status).toBe(403);
|
|
278
|
+
|
|
279
|
+
const res = await dispatch(`/api/script-connections/${connection.id}`, {
|
|
280
|
+
method: "DELETE",
|
|
281
|
+
agentId: leadAgentId,
|
|
282
|
+
});
|
|
283
|
+
expect(res.status).toBe(200);
|
|
284
|
+
expect(await res.json()).toEqual({ deleted: true, id: connection.id });
|
|
285
|
+
|
|
286
|
+
const counts = await getDbClient().get<{ conns: number; bindings: number; secrets: number }>(
|
|
287
|
+
`SELECT
|
|
288
|
+
(SELECT COUNT(*) FROM script_connections WHERE id = ?) AS conns,
|
|
289
|
+
(SELECT COUNT(*) FROM script_credential_bindings WHERE managed_by_connection_id = ?) AS bindings,
|
|
290
|
+
(SELECT COUNT(*) FROM swarm_config WHERE key = 'connection.deleteHttpVendor.secret') AS secrets`,
|
|
291
|
+
[connection.id, connection.id],
|
|
292
|
+
);
|
|
293
|
+
expect(counts).toEqual({ conns: 0, bindings: 0, secrets: 0 });
|
|
294
|
+
|
|
295
|
+
const missing = await dispatch(`/api/script-connections/${connection.id}`, {
|
|
296
|
+
method: "DELETE",
|
|
297
|
+
agentId: leadAgentId,
|
|
298
|
+
});
|
|
299
|
+
expect(missing.status).toBe(404);
|
|
300
|
+
});
|
|
301
|
+
|
|
257
302
|
test("list returns connections without secrets", async () => {
|
|
258
303
|
await upsertSwarmConfig({
|
|
259
304
|
scope: "global",
|
|
@@ -907,3 +907,44 @@ describe("script MCP connections", () => {
|
|
|
907
907
|
}
|
|
908
908
|
});
|
|
909
909
|
});
|
|
910
|
+
|
|
911
|
+
describe("script-connections MCP delete", () => {
|
|
912
|
+
test("lead deletes a connection and its inline secret; unknown id and non-lead fail", async () => {
|
|
913
|
+
const lead = await createAgent({ name: "mcp-delete-lead", isLead: true, status: "idle" });
|
|
914
|
+
const worker = await createAgent({ name: "mcp-delete-worker", isLead: false, status: "idle" });
|
|
915
|
+
const connection = await upsertScriptConnection({
|
|
916
|
+
slug: "mcpDeleteVendor",
|
|
917
|
+
kind: "graphql",
|
|
918
|
+
baseUrl: "https://api.vendor.test/graphql",
|
|
919
|
+
allowedHosts: ["api.vendor.test"],
|
|
920
|
+
auth: { type: "bearer", secret: "example-mcp-delete-tok" },
|
|
921
|
+
});
|
|
922
|
+
|
|
923
|
+
const denied = (await scriptConnectionsTool().handler(
|
|
924
|
+
{ action: "delete", id: connection.id },
|
|
925
|
+
meta(worker.id),
|
|
926
|
+
)) as ToolResult;
|
|
927
|
+
expect(denied.structuredContent.success).toBe(false);
|
|
928
|
+
expect(await getScriptConnectionById(connection.id)).not.toBeNull();
|
|
929
|
+
|
|
930
|
+
const result = (await scriptConnectionsTool().handler(
|
|
931
|
+
{ action: "delete", id: connection.id },
|
|
932
|
+
meta(lead.id),
|
|
933
|
+
)) as ToolResult;
|
|
934
|
+
expect(result.structuredContent.success).toBe(true);
|
|
935
|
+
expect(result.structuredContent.connections.map((c) => c.id)).not.toContain(connection.id);
|
|
936
|
+
expect(await getScriptConnectionById(connection.id)).toBeNull();
|
|
937
|
+
const secretRow = await getDbClient().get<{ key: string }>(
|
|
938
|
+
"SELECT key FROM swarm_config WHERE key = ?",
|
|
939
|
+
["connection.mcpDeleteVendor.secret"],
|
|
940
|
+
);
|
|
941
|
+
expect(secretRow).toBeNull();
|
|
942
|
+
|
|
943
|
+
const again = (await scriptConnectionsTool().handler(
|
|
944
|
+
{ action: "delete", id: connection.id },
|
|
945
|
+
meta(lead.id),
|
|
946
|
+
)) as ToolResult;
|
|
947
|
+
expect(again.structuredContent.success).toBe(false);
|
|
948
|
+
expect(again.structuredContent.message).toContain("not found");
|
|
949
|
+
});
|
|
950
|
+
});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { validateConfigValue } from "../be/swarm-config-guard";
|
|
2
3
|
import { NativeScriptExecutor } from "../scripts-runtime/executors/native";
|
|
3
4
|
import { QuickJSScriptExecutor } from "../scripts-runtime/executors/quickjs";
|
|
4
5
|
import { getScriptExecutor } from "../scripts-runtime/executors/registry";
|
|
@@ -20,3 +21,20 @@ describe("getScriptExecutor", () => {
|
|
|
20
21
|
expect(() => getScriptExecutor("e2b")).toThrow("Available: native, quickjs");
|
|
21
22
|
});
|
|
22
23
|
});
|
|
24
|
+
|
|
25
|
+
describe("SCRIPT_EXECUTOR configuration", () => {
|
|
26
|
+
test("every accepted value resolves to its executor", () => {
|
|
27
|
+
for (const value of ["native", "quickjs", " native ", " quickjs "]) {
|
|
28
|
+
expect(validateConfigValue("SCRIPT_EXECUTOR", value)).toBeNull();
|
|
29
|
+
expect(getScriptExecutor(value).name).toBe(value.trim());
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("rejects unsupported names, blank values and non-string values", () => {
|
|
34
|
+
for (const value of ["e2b", "Native", "QUICKJS", "", " ", null, undefined, 1, true]) {
|
|
35
|
+
expect(validateConfigValue("SCRIPT_EXECUTOR", value)).toBe(
|
|
36
|
+
"Invalid SCRIPT_EXECUTOR value (must be one of: native, quickjs)",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// send-task refuses an agent session that targets itself, but a script runs as its owner (a
|
|
2
|
+
// scheduled script as the schedule's creator), so a Lead-owned script must be able to file
|
|
3
|
+
// work for the Lead. Script calls reach the tool through /api/mcp-bridge as `script-sdk`.
|
|
4
|
+
|
|
5
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { unlinkSync } from "node:fs";
|
|
7
|
+
import {
|
|
8
|
+
createServer as createHttpServer,
|
|
9
|
+
type IncomingMessage,
|
|
10
|
+
type Server,
|
|
11
|
+
type ServerResponse,
|
|
12
|
+
} from "node:http";
|
|
13
|
+
import { closeDb, createAgent, getDbClient, initDb } from "../be/db";
|
|
14
|
+
import { handleMcpBridge } from "../http/mcp-bridge";
|
|
15
|
+
import { getPathSegments } from "../http/utils";
|
|
16
|
+
import { sendTaskHandler } from "../tools/send-task";
|
|
17
|
+
import { ownerCtx } from "../tools/task-tool-ctx";
|
|
18
|
+
import { getRequestInfo } from "../tools/utils";
|
|
19
|
+
|
|
20
|
+
const TEST_DB_PATH = "./test-send-task-self-send.sqlite";
|
|
21
|
+
|
|
22
|
+
let server: Server;
|
|
23
|
+
let baseUrl: string;
|
|
24
|
+
let leadId = "";
|
|
25
|
+
|
|
26
|
+
const selfSendArgs = (agentId: string) => ({
|
|
27
|
+
task: "x mention to triage",
|
|
28
|
+
agentId,
|
|
29
|
+
routingReason: "skill" as const,
|
|
30
|
+
routingNote: "Lead triages mentions itself",
|
|
31
|
+
offerMode: false,
|
|
32
|
+
allowDuplicate: true,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
async function tasksFor(agentId: string): Promise<number> {
|
|
36
|
+
return (await getDbClient().get<{ c: number }>(
|
|
37
|
+
"SELECT COUNT(*) AS c FROM agent_tasks WHERE agentId = ?",
|
|
38
|
+
[agentId],
|
|
39
|
+
))!.c;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeAll(async () => {
|
|
43
|
+
initDb(TEST_DB_PATH);
|
|
44
|
+
leadId = (
|
|
45
|
+
await createAgent({ name: "self-send-lead", isLead: true, status: "idle", maxTasks: 5 })
|
|
46
|
+
).id;
|
|
47
|
+
server = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
48
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
49
|
+
const ok = await handleMcpBridge(
|
|
50
|
+
req,
|
|
51
|
+
res,
|
|
52
|
+
getPathSegments(req.url || ""),
|
|
53
|
+
undefined,
|
|
54
|
+
myAgentId,
|
|
55
|
+
);
|
|
56
|
+
if (!ok) {
|
|
57
|
+
res.writeHead(404);
|
|
58
|
+
res.end("Not Found");
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
await new Promise<void>((resolve) => server.listen(0, resolve));
|
|
62
|
+
const addr = server.address();
|
|
63
|
+
if (!addr || typeof addr === "string") throw new Error("no port");
|
|
64
|
+
baseUrl = `http://127.0.0.1:${addr.port}`;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
afterAll(() => {
|
|
68
|
+
server.close();
|
|
69
|
+
closeDb();
|
|
70
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
71
|
+
try {
|
|
72
|
+
unlinkSync(TEST_DB_PATH + suffix);
|
|
73
|
+
} catch {
|
|
74
|
+
// ignore
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
describe("send-task self-send guard", () => {
|
|
80
|
+
test("a script sending a task to its own agent succeeds", async () => {
|
|
81
|
+
const before = await tasksFor(leadId);
|
|
82
|
+
const res = await fetch(`${baseUrl}/api/mcp-bridge`, {
|
|
83
|
+
method: "POST",
|
|
84
|
+
headers: { "Content-Type": "application/json", "x-agent-id": leadId },
|
|
85
|
+
body: JSON.stringify({ tool: "send-task", args: selfSendArgs(leadId) }),
|
|
86
|
+
});
|
|
87
|
+
expect(res.status).toBe(200);
|
|
88
|
+
const body = (await res.json()) as { success?: boolean; message?: string };
|
|
89
|
+
expect(body.message).not.toContain("Cannot send a task to yourself");
|
|
90
|
+
expect(body.success).toBe(true);
|
|
91
|
+
expect(await tasksFor(leadId)).toBe(before + 1);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("an agent session sending a task to itself is still refused", async () => {
|
|
95
|
+
const before = await tasksFor(leadId);
|
|
96
|
+
// An MCP session's meta carries no origin mark, so getRequestInfo reports `mcp`.
|
|
97
|
+
const info = getRequestInfo({
|
|
98
|
+
sessionId: "session-1",
|
|
99
|
+
requestInfo: { headers: { "x-agent-id": leadId } },
|
|
100
|
+
} as unknown as Parameters<typeof getRequestInfo>[0]);
|
|
101
|
+
expect(info.callOrigin).toBe("mcp");
|
|
102
|
+
const result = await sendTaskHandler(ownerCtx(info), selfSendArgs(leadId));
|
|
103
|
+
expect(result.ok).toBe(false);
|
|
104
|
+
expect(result.message).toContain("Cannot send a task to yourself");
|
|
105
|
+
expect(await tasksFor(leadId)).toBe(before);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
@@ -4,6 +4,7 @@ import { getAgentById } from "@/be/db";
|
|
|
4
4
|
import {
|
|
5
5
|
ConnectionAuthInputSchema,
|
|
6
6
|
connectionAuthInputFromFlat,
|
|
7
|
+
deleteScriptConnection,
|
|
7
8
|
getScriptConnectionById,
|
|
8
9
|
listScriptConnections,
|
|
9
10
|
refreshScriptConnection,
|
|
@@ -16,13 +17,23 @@ import { resolveScopedResourceId, scopedResourceScopeIdSchema } from "@/utils/sc
|
|
|
16
17
|
|
|
17
18
|
const scriptConnectionsInputSchema = z.object({
|
|
18
19
|
action: z
|
|
19
|
-
.enum([
|
|
20
|
-
|
|
20
|
+
.enum([
|
|
21
|
+
"list",
|
|
22
|
+
"upsert-openapi",
|
|
23
|
+
"upsert-mcp",
|
|
24
|
+
"upsert-graphql",
|
|
25
|
+
"refresh",
|
|
26
|
+
"disable",
|
|
27
|
+
"delete",
|
|
28
|
+
])
|
|
29
|
+
.describe(
|
|
30
|
+
"List, create/update, refresh, disable, or delete a script connection. delete is permanent: it removes the connection, its managed credential binding, and any inline secret it owns.",
|
|
31
|
+
),
|
|
21
32
|
id: z
|
|
22
33
|
.string()
|
|
23
34
|
.uuid()
|
|
24
35
|
.optional()
|
|
25
|
-
.describe("Existing connection ID for update, refresh, or
|
|
36
|
+
.describe("Existing connection ID for update, refresh, disable, or delete."),
|
|
26
37
|
slug: z
|
|
27
38
|
.string()
|
|
28
39
|
.min(1)
|
|
@@ -193,6 +204,27 @@ export const registerScriptConnectionsTool = (server: McpServer) => {
|
|
|
193
204
|
});
|
|
194
205
|
}
|
|
195
206
|
|
|
207
|
+
if (args.action === "delete") {
|
|
208
|
+
if (!args.id) {
|
|
209
|
+
return toolErr("id is required for delete.", {
|
|
210
|
+
data: {
|
|
211
|
+
yourAgentId: requestInfo.agentId,
|
|
212
|
+
connections: listScriptConnections({ includeDisabled: true, allScopes: true }),
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const deleted = await deleteScriptConnection(args.id);
|
|
217
|
+
const connections = listScriptConnections({ includeDisabled: true, allScopes: true });
|
|
218
|
+
if (!deleted) {
|
|
219
|
+
return toolErr(`Script connection ${args.id} not found.`, {
|
|
220
|
+
data: { yourAgentId: requestInfo.agentId, connections },
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
return toolOk(`Script connection ${deleted.slug} deleted.`, {
|
|
224
|
+
data: { yourAgentId: requestInfo.agentId, connections },
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
|
|
196
228
|
if (args.action === "refresh") {
|
|
197
229
|
if (!args.id) {
|
|
198
230
|
return toolErr("id is required for refresh.", {
|
package/src/tools/send-task.ts
CHANGED
|
@@ -355,7 +355,15 @@ export async function sendTaskHandler(
|
|
|
355
355
|
});
|
|
356
356
|
}
|
|
357
357
|
|
|
358
|
-
|
|
358
|
+
// The guard stops an agent session from handing work back to itself. A script runs as its
|
|
359
|
+
// owner (a scheduled script as the schedule's creator), so a Lead-owned poller that files
|
|
360
|
+
// work for the Lead is legitimate. The bridge marks script calls `script-sdk`; a caller that
|
|
361
|
+
// spoofs it can only send itself a task, which grants nothing it could not already do.
|
|
362
|
+
if (
|
|
363
|
+
ctx.kind === "owner" &&
|
|
364
|
+
requestedAgentId === ctx.agentId &&
|
|
365
|
+
ctx.requestInfo?.callOrigin !== "script-sdk"
|
|
366
|
+
) {
|
|
359
367
|
return toolErr("Cannot send a task to yourself, are you drunk?", {
|
|
360
368
|
data: { yourAgentId: ctx.agentId },
|
|
361
369
|
});
|
|
@@ -13,6 +13,7 @@ Each folder holds exactly one `manifest.yaml`, `manifest.yml`, or `manifest.json
|
|
|
13
13
|
| `require-ticket-ref` | `pre.task.create` | Blocks REST, MCP, and Slack tasks that do not name a ticket |
|
|
14
14
|
| `notify-on-complete` | `post.task.completed`, `post.task.failed` | Posts a summary to a Slack channel |
|
|
15
15
|
| `deploy-awareness` | `pre.task.create` | Appends a one-line note to new tasks while a Dokploy deploy is in progress |
|
|
16
|
+
| `slack-reply-gate` | `pre.task.create` | Asks Jev (TypeSafe or OpenRouter) whether Lead should answer a Slack thread follow-up; in `enforce` drops the wake-up and marks the message :mute: |
|
|
16
17
|
| `require-verification-note` | `pre.tool.call` | Refuses completion without a `Verified:` line in the output |
|
|
17
18
|
| `task-digest` | `post.task.completed`, `post.task.failed` | Ships the `task-digest-collect` script, the `task-digest-daily` schedule (09:00 UTC), the `task-digest-report` workflow, and the `task-digest-guide` skill |
|
|
18
19
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# slack-reply-gate
|
|
2
|
+
|
|
3
|
+
Lead is woken by every human message in a Slack thread the swarm takes part in, including "lol", an emoji, or two people chatting with each other. This extension asks Jev, [TypeSafe](https://typesafe.ai)'s decision model, whether Lead should answer a new thread message, and drops the wake-up when it should not.
|
|
4
|
+
|
|
5
|
+
Install config:
|
|
6
|
+
|
|
7
|
+
```json
|
|
8
|
+
{ "mode": "shadow" }
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Start in `shadow`, read the decisions in KV for a few days, then switch to `enforce`.
|
|
12
|
+
|
|
13
|
+
## Modes
|
|
14
|
+
|
|
15
|
+
| `mode` | Effect |
|
|
16
|
+
|---|---|
|
|
17
|
+
| `off` | Never evaluates. |
|
|
18
|
+
| `shadow` (default) | Asks Jev and records the verdict. Never blocks. |
|
|
19
|
+
| `enforce` | Asks Jev, records the verdict, and blocks the Lead task when the answer is no. The message gets :mute: instead: no Lead task, no reply, no outcome card. |
|
|
20
|
+
|
|
21
|
+
## What it gates
|
|
22
|
+
|
|
23
|
+
Hook: `pre.task.create`, for tasks with origin `slack` whose description carries the thread buffer's header `[Thread follow-up — N message(s) buffered]`. Those are the non-mention thread replies that `src/slack/thread-buffer.ts` batches into a Lead task. A direct @mention of the bot creates a task without that header and is never gated.
|
|
24
|
+
|
|
25
|
+
The header counts only at the start of the description or right after the closing `</thread_context>`. Thread context quotes old messages verbatim, and an older Lead reply can quote the header itself, so a header inside the context is ignored. Only the text after the real header is treated as new.
|
|
26
|
+
|
|
27
|
+
## Decision
|
|
28
|
+
|
|
29
|
+
1. A follow-up with downloaded files (status `draft`) passes without a Jev call.
|
|
30
|
+
2. **@mention pass-through.** A new message that @mentions Lead passes without a Jev call. A mention is the bot mention the engine rewrites to `<@U…> (that's you)`, a resolved `<@U…|lead>`, or a literal `@lead` (any name in `leadAliases`). Only the new messages are scanned, never the thread context, so an old mention earlier in the thread does not pass later banter.
|
|
31
|
+
3. Otherwise one Jev call (one `choice` question, see [Jev transport](#jev-transport)) with the new message(s), the sender kind, and the last `contextMessages` thread messages. Labels: `direct_ask`, `followup_to_lead`, `instruction_to_lead`, `reaction_only`, `banter_between_others`, `not_addressed_to_lead`, `other`.
|
|
32
|
+
4. The answer is no only when the top label is `reaction_only`, `banter_between_others`, or `not_addressed_to_lead`, and the summed probability of those three labels is at least `minConfidence`. A split between two skip labels is still a skip. An unsure verdict replies.
|
|
33
|
+
|
|
34
|
+
Several buffered messages are judged together. One mention in any of them passes the batch.
|
|
35
|
+
|
|
36
|
+
## Jev transport
|
|
37
|
+
|
|
38
|
+
Jev is reachable two ways. Both take the same request and return the same answer, so the decision is identical whichever one runs.
|
|
39
|
+
|
|
40
|
+
| `provider` | Endpoint | Key | Model |
|
|
41
|
+
|---|---|---|---|
|
|
42
|
+
| `typesafe` | `POST https://api.typesafe.ai/v1/systemone` | `TYPESAFE_API_KEY` (or `apiKey`) | `model`, default `jev-latest` |
|
|
43
|
+
| `openrouter` | `POST https://openrouter.ai/api/alpha/decisions` | `OPENROUTER_API_KEY` | `openrouterModel`, default `typesafe/jev-1.13` |
|
|
44
|
+
| `auto` (default) | TypeSafe when its key is set, else OpenRouter | | |
|
|
45
|
+
|
|
46
|
+
Under `auto`, `TYPESAFE_API_KEY` wins when both keys are set. With neither key, every message fails open and the KV row names both secrets. Each decision row records the `provider` that answered.
|
|
47
|
+
|
|
48
|
+
## Fail open
|
|
49
|
+
|
|
50
|
+
Every error lets the task through: a missing key, a TypeSafe HTTP error, a network error, a timeout, a malformed answer, or an unknown label. The handler never throws, so an outage cannot auto-disable the extension. Each failure is recorded as `fail-open` in KV and logged as a warning.
|
|
51
|
+
|
|
52
|
+
## :mute: on drop (enforce)
|
|
53
|
+
|
|
54
|
+
No swarm tool or extension `ctx` method adds a Slack reaction, so the hook calls the Slack Web API with the bot token. It needs the `reactions:write` scope, which the Slack app already uses for its acceptance reactions.
|
|
55
|
+
|
|
56
|
+
1. `reactions.remove` for each acceptance reaction the engine may have put on the triggering message: `eyes`, `heavy_plus_sign`, `zap`, `speech_balloon`, or their `SLACK_REACTION_ACCEPTED`, `SLACK_REACTION_BUFFERED`, `SLACK_REACTION_NOW`, and `SLACK_REACTION_STEERED` overrides. Slack only removes the bot's own reaction, so a human's :eyes: stays.
|
|
57
|
+
2. `reactions.add` with `muteReaction` (default `mute`). `already_reacted` counts as success. Skipped when `muteReaction` is `""`.
|
|
58
|
+
|
|
59
|
+
The block returns first and the Slack calls run after it. A slow or failing Slack call never delays the drop or turns it back into a task. Set `muteReaction` to `""` for a true no-op: step 1 still clears the bot's acceptance reaction(s), and nothing is added.
|
|
60
|
+
|
|
61
|
+
## Config
|
|
62
|
+
|
|
63
|
+
| Key | Default | Notes |
|
|
64
|
+
|---|---|---|
|
|
65
|
+
| `mode` | `shadow` | `off`, `shadow`, or `enforce` |
|
|
66
|
+
| `timeoutMs` | `3000` | Jev call timeout, capped at 4000 (the dispatcher cancels a handler at 5 s) |
|
|
67
|
+
| `provider` | `auto` | `auto`, `typesafe`, or `openrouter`. See [Jev transport](#jev-transport) |
|
|
68
|
+
| `model` | `jev-latest` | Jev model on TypeSafe |
|
|
69
|
+
| `openrouterModel` | `typesafe/jev-1.13` | Jev model on OpenRouter |
|
|
70
|
+
| `minConfidence` | `0.55` | Minimum summed skip probability to skip |
|
|
71
|
+
| `contextMessages` | `6` | Thread messages sent to Jev, 0 to 20 |
|
|
72
|
+
| `leadAliases` | `["lead"]` | A literal `@alias` in a new message counts as a mention |
|
|
73
|
+
| `botSlackUserIds` | `[]` | Slack user ids labelled `bot` as the sender for Jev |
|
|
74
|
+
| `apiKey` | unset | Explicit TypeSafe key. Prefer the `TYPESAFE_API_KEY` secret below |
|
|
75
|
+
| `muteReaction` | `mute` | Reaction for a dropped message; `""` only removes the acceptance reaction(s) |
|
|
76
|
+
| `slackTimeoutMs` | `5000` | Timeout per Slack call |
|
|
77
|
+
|
|
78
|
+
## Required secrets
|
|
79
|
+
|
|
80
|
+
All are read from the API process environment at call time. Set them as global `swarm_config` secrets: the API process loads global rows into `process.env` at boot and on config reload.
|
|
81
|
+
|
|
82
|
+
- `TYPESAFE_API_KEY` or `OPENROUTER_API_KEY`: one is required for any evaluation. Under `auto`, TypeSafe wins when both are set. Without either, every message fails open.
|
|
83
|
+
- `SLACK_BOT_TOKEN`: the Slack app's bot token. A swarm with Slack connected already has it. Without it, `enforce` still drops the message but cannot touch its reactions, and logs a `mute-error`.
|
|
84
|
+
|
|
85
|
+
No key is written to config, KV, logs, or a description.
|
|
86
|
+
|
|
87
|
+
## KV namespace `ext:slack-reply-gate`
|
|
88
|
+
|
|
89
|
+
- `d:<epochMs>:<channel>_<threadTs>`: one row per decision, with `action` (`pass`, `shadow-skip`, `skipped`, `fail-open`), `provider`, `reason`, `confidence`, `skipP`, `latencyMs`, `sender`, and a 300-character `preview`.
|
|
90
|
+
- `m:<epochMs>:<channel>_<threadTs>`: one row per mute attempt, with `action` (`muted`, `unreacted` when `muteReaction` is `""`, `mute-error`), `removed`, `added`, and `errors`.
|
|
91
|
+
- `last`: the latest decision. Mute rows never overwrite it.
|
|
92
|
+
- Counters: `total`, `mention`, `files`, `reply`, `shadow-skip`, `skipped`, `error`, `muted`, `unreacted`, `mute-error`, and `reason:<label>`.
|
|
93
|
+
|
|
94
|
+
## Known limits
|
|
95
|
+
|
|
96
|
+
- When Lead already has an active task in the thread, the buffer steers that task instead of creating one, so `pre.task.create` does not fire and the gate does not run.
|
|
97
|
+
- A blocked flush makes the thread buffer log a `TaskCreationBlockedError`. Nothing is lost: the next follow-up's `<thread_context>` carries the earlier messages.
|
|
98
|
+
- A flush of several messages mutes only the triggering (last) message. The others keep their acceptance reaction.
|