@desplega.ai/agent-swarm 1.147.0 → 1.149.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 +13 -1
- package/dist/{acp-adapter-9n319wqc.js → acp-adapter-4jncb126.js} +12 -5
- package/dist/{actions-2vxqvpr9.js → actions-31vp3ae7.js} +10 -9
- package/dist/{app-w066xfy0.js → app-1hh67x8w.js} +6 -6
- package/dist/{assistant-kt31dj2h.js → assistant-z66dmz6p.js} +13 -11
- package/dist/{boot-reembed-az5rassp.js → boot-reembed-fqzxxzjh.js} +7 -7
- package/dist/{boot-reembed-j3mm3rfz.js → boot-reembed-wxa8ya0s.js} +8 -8
- package/dist/{boot-scrub-logs-b6h54817.js → boot-scrub-logs-jnyndh79.js} +5 -5
- package/dist/{claude-adapter-w1z04ann.js → claude-adapter-3qpa9w0j.js} +9 -9
- package/dist/{claude-managed-adapter-sactwn31.js → claude-managed-adapter-qvsmh6mv.js} +2 -2
- package/dist/{claude-sdk-session-f0cc1kec.js → claude-sdk-session-2yxgjs5d.js} +9 -9
- package/dist/{cli-y6b6fb76.js → cli-08b07b4r.js} +1 -1
- package/dist/{cli-8fc8de14.js → cli-0z2v4nhw.js} +1 -1
- package/dist/{cli-de14znh8.js → cli-19f354qr.js} +2 -2
- package/dist/{cli-hbv0kq6w.js → cli-2qts1hys.js} +3 -3
- package/dist/{cli-kpv03zhj.js → cli-3apbzgyk.js} +27 -15
- package/dist/{cli-1th1728g.js → cli-3q5ejx9p.js} +2 -2
- package/dist/{cli-d8brzxjb.js → cli-4wtq5jjv.js} +2 -2
- package/dist/{cli-ct0et58h.js → cli-5my3bjsd.js} +1 -1
- package/dist/{cli-cn6mmc7f.js → cli-6664w7y4.js} +1 -1
- package/dist/{cli-yz3djrm0.js → cli-6pqcb4q0.js} +53 -13
- package/dist/{cli-719k9j2c.js → cli-7smrsr25.js} +3 -3
- package/dist/{cli-rw51bq3j.js → cli-9fy6nk6g.js} +1 -1
- package/dist/{cli-zhjmqxzh.js → cli-9hspd3dp.js} +2 -2
- package/dist/{cli-3tgwnf43.js → cli-aae6hz6a.js} +4 -4
- package/dist/{cli-89xfd7f9.js → cli-cr12paw2.js} +5 -5
- package/dist/{cli-b5bq9crk.js → cli-d8ftsp62.js} +8 -8
- package/dist/{cli-4nr988h6.js → cli-e95cx1eb.js} +3 -3
- package/dist/{cli-n8508nre.js → cli-gjhjfeeg.js} +1 -1
- package/dist/{cli-p1d5b073.js → cli-gv88e1vf.js} +1 -1
- package/dist/{cli-rvqz34y5.js → cli-jecc01cb.js} +1 -1
- package/dist/{cli-8v0g1yc8.js → cli-k0cr4kat.js} +1 -1
- package/dist/{cli-mmemxxdc.js → cli-kgz4np97.js} +4 -4
- package/dist/{cli-w9kb3bk1.js → cli-kjwt6hdf.js} +3 -1
- package/dist/{cli-f7dsy61y.js → cli-mnmsfd1w.js} +6 -2
- package/dist/{cli-54wwve05.js → cli-n3dz2942.js} +37 -26
- package/dist/{cli-12fz972k.js → cli-nye12xk5.js} +8 -5
- package/dist/{cli-tzvk9haz.js → cli-phsxnkp6.js} +20 -20
- package/dist/{cli-53s590z8.js → cli-q50ef8g0.js} +1 -1
- package/dist/{cli-f146mn5s.js → cli-qaacms9y.js} +7 -6
- package/dist/{cli-xqcq9y5e.js → cli-r2ap2czm.js} +1 -0
- package/dist/{cli-m8yrsg97.js → cli-sd5pv3b1.js} +3 -3
- package/dist/{cli-0b6y7kxm.js → cli-xm87hzbw.js} +1 -1
- package/dist/{cli-ftht3zzj.js → cli-yab68w40.js} +285 -11
- package/dist/{cli-atcve9y8.js → cli-yb9qhqam.js} +227 -59
- package/dist/{cli-smby5m17.js → cli-ygweqcn4.js} +133 -22
- package/dist/{cli-dttph1bw.js → cli-ykxrnd1q.js} +2 -2
- package/dist/{cli-m9sxbkhm.js → cli-zvz62chv.js} +3 -3
- package/dist/cli.js +16 -14
- package/dist/{codex-adapter-zw4dwsx7.js → codex-adapter-8meawydj.js} +4 -4
- package/dist/{codex-hook-bw8p5nh4.js → codex-hook-hfrt9shr.js} +2 -2
- package/dist/{codex-session-runner-k981s65v.js → codex-session-runner-gjgxyp5j.js} +4 -4
- package/dist/{commands-sykrxx7e.js → commands-hz3nb95f.js} +5 -5
- package/dist/{db-sfbsc8wt.js → db-ggz97zbm.js} +5 -5
- package/dist/{e2b-6hb103d8.js → e2b-p04vmtx0.js} +1 -1
- package/dist/{handlers-0aspcejm.js → handlers-nbzdzn9k.js} +14 -11
- package/dist/{hook-hxccs7m5.js → hook-bks2q1f6.js} +7 -7
- package/dist/{hook-4nvqj2sb.js → hook-kq9wp0ep.js} +8 -8
- package/dist/{http-y06z517d.js → http-hrf77v87.js} +96 -45
- package/dist/{index-tce1rss4.js → index-6nsbg88z.js} +443 -144
- package/dist/{index-x5j894zf.js → index-8hmeg8j1.js} +14 -14
- package/dist/{index-k8znfx0z.js → index-cqtebbhb.js} +13 -13
- package/dist/{index-hssww7kj.js → index-n28pw1zt.js} +15 -15
- package/dist/{keepalive-w733ax66.js → keepalive-dxc12gvg.js} +7 -7
- package/dist/{lead-75bjqa9d.js → lead-yckfn18d.js} +32 -32
- package/dist/{maintenance-w4f1zjck.js → maintenance-6kfb2m5e.js} +8 -8
- package/dist/{oauth-refresh-sweep-wdetky26.js → oauth-refresh-sweep-1x1ac722.js} +6 -6
- package/dist/{onboard-bb5net8s.js → onboard-dcyt1609.js} +4 -3
- package/dist/{opencode-adapter-fef1mrn2.js → opencode-adapter-dce4wskd.js} +2 -2
- package/dist/{otel-impl-0w7c14ap.js → otel-impl-tbk3e5dr.js} +2 -2
- package/dist/{pi-mono-adapter-2has4gnp.js → pi-mono-adapter-zvshk5cj.js} +2 -2
- package/dist/{pricing-refresh-9nzsrgcq.js → pricing-refresh-kaqg567e.js} +7 -7
- package/dist/{rbac-roles-z1jrffbt.js → rbac-roles-ammq6rd2.js} +5 -5
- package/dist/{rbac-roles-r3hqjp09.js → rbac-roles-cf97v1vd.js} +6 -6
- package/dist/{render-v2-vbr89fnb.js → render-v2-80w603vy.js} +6 -6
- package/dist/{seed-pricing-g62hy1pk.js → seed-pricing-zbrn83xv.js} +6 -6
- package/dist/{setup-gnfqnptp.js → setup-fe66kczs.js} +2 -2
- package/dist/{worker-kznxp4qm.js → worker-qmb2d1e2.js} +32 -32
- package/dist/{x-n2phr5vm.js → x-n3e0v3xa.js} +2 -2
- package/openapi.json +46 -4
- package/package.json +3 -1
- package/src/agentmail/handlers.ts +5 -0
- package/src/automation-preflight-alert.ts +41 -0
- package/src/be/automation-preflight.ts +3 -3
- package/src/be/budget-refusal-notify.ts +1 -0
- package/src/be/db/tasks/read.ts +5 -0
- package/src/be/db.ts +132 -2
- package/src/be/migrations/150_deferred_task_waits.sql +18 -0
- package/src/be/migrations/151_repair_scheduled_task_required_params.sql +39 -0
- package/src/be/migrations/152_routing_decisions.sql +44 -0
- package/src/be/scripts/typecheck.ts +33 -22
- package/src/be/seed-scripts/catalog/delegate.ts +5 -1
- package/src/be/seed-skills/bundled-files.generated.json +10 -0
- package/src/be/seed-skills/index.ts +3 -0
- package/src/be/steering.ts +1 -0
- package/src/be/swarm-config-guard.ts +11 -0
- package/src/commands/onboard/steps/post-task.tsx +1 -0
- package/src/github/handlers.ts +9 -0
- package/src/gitlab/handlers.ts +4 -0
- package/src/heartbeat/heartbeat.ts +8 -0
- package/src/http/approval-requests.ts +1 -0
- package/src/http/apps.ts +1 -0
- package/src/http/config.ts +15 -6
- package/src/http/mcp.ts +31 -4
- package/src/http/script-runs.ts +6 -0
- package/src/http/tasks.ts +52 -30
- package/src/integrations/kapso/inbound.ts +1 -0
- package/src/jira/sync.ts +2 -0
- package/src/linear/sync.ts +2 -0
- package/src/prompts/session-templates.ts +29 -15
- package/src/scheduler/deferred-task-waits.ts +123 -0
- package/src/scheduler/schedule-task.ts +42 -0
- package/src/scheduler/scheduler.ts +22 -31
- package/src/script-workflows/workflow-ctx.ts +2 -0
- package/src/scripts-runtime/types/stdlib.d.ts +38 -24
- package/src/scripts-runtime/types/swarm-sdk.d.ts +38 -24
- package/src/server.ts +10 -1
- package/src/slack/actions.ts +1 -0
- package/src/slack/assistant.ts +2 -0
- package/src/slack/handlers.ts +3 -0
- package/src/slack/thread-buffer.ts +2 -0
- package/src/tasks/worker-follow-up.ts +9 -0
- package/src/tests/acp-adapter.test.ts +35 -6
- package/src/tests/acp-session-token.test.ts +67 -0
- package/src/tests/asset-key-api.test.ts +15 -2
- package/src/tests/asset-key-mcp.test.ts +2 -0
- package/src/tests/automation-preflight-alert.test.ts +91 -0
- package/src/tests/claude-worker-parity.test.ts +3 -0
- package/src/tests/config-session-auth.test.ts +143 -0
- package/src/tests/defer-task.test.ts +117 -6
- package/src/tests/deferred-task-wake.test.ts +456 -0
- package/src/tests/heartbeat-reroute-decision.test.ts +1 -0
- package/src/tests/http-api-integration.test.ts +33 -4
- package/src/tests/mcp-input-ergonomics.test.ts +386 -0
- package/src/tests/model-control.test.ts +18 -5
- package/src/tests/opencode-adapter.test.ts +2 -2
- package/src/tests/promote-draft-task-route.test.ts +4 -0
- package/src/tests/prompt-template-session.test.ts +13 -4
- package/src/tests/rbac-wire-e2e.test.ts +2 -2
- package/src/tests/routing-decision-persistence.test.ts +520 -0
- package/src/tests/routing-reason-contract.test.ts +89 -0
- package/src/tests/routing-reason-inventory.test.ts +55 -0
- package/src/tests/schedule-target-type.test.ts +48 -1
- package/src/tests/scheduled-task-required-params-migration.test.ts +182 -0
- package/src/tests/scheduled-tasks.test.ts +9 -5
- package/src/tests/script-connections.test.ts +4 -0
- package/src/tests/script-runs-http.test.ts +31 -0
- package/src/tests/scripts-typecheck.test.ts +11 -0
- package/src/tests/secret-scrubber.test.ts +12 -0
- package/src/tests/send-task-output-schema.test.ts +66 -1
- package/src/tests/send-task-requested-by.test.ts +1 -0
- package/src/tests/send-task-slack-routing-guard.test.ts +2 -0
- package/src/tests/store-progress-blocked-waiting-gate.test.ts +151 -0
- package/src/tests/swarm-tool-result-gate.test.ts +24 -0
- package/src/tests/task-tool-manifest.test.ts +47 -0
- package/src/tests/task-tool-preload-mcp.test.ts +125 -0
- package/src/tests/task-tools-ctx.test.ts +2 -0
- package/src/tests/tool-output-agent-id.test.ts +1 -0
- package/src/tests/ui-followup-lead-delegation.test.ts +6 -1
- package/src/tests/workflow-agent-task.test.ts +24 -1
- package/src/tests/workflow-engine-v2.test.ts +58 -2
- package/src/tools/accept-steer.ts +0 -1
- package/src/tools/defer-task.ts +73 -21
- package/src/tools/memory-get.ts +10 -3
- package/src/tools/memory-rate.ts +19 -7
- package/src/tools/memory-search.ts +2 -1
- package/src/tools/memory-store.ts +25 -5
- package/src/tools/send-task.ts +40 -2
- package/src/tools/skills/skill-publish.ts +1 -0
- package/src/tools/store-progress.ts +78 -4
- package/src/tools/templates.ts +2 -1
- package/src/tools/utils.ts +33 -2
- package/src/types.ts +14 -1
- package/src/utils/acp-session-token.ts +14 -4
- package/src/utils/secret-scrubber.ts +2 -0
- package/src/utils/task-tool-manifest.ts +33 -0
- package/src/workflows/engine.ts +4 -1
- package/src/workflows/executors/agent-task.ts +7 -1
- package/templates/ai-toolbox.manifest.json +6 -1
- package/templates/skills/comms/config.json +18 -0
- package/templates/skills/comms/content.md +74 -0
- package/templates/skills/comms/files/references/ste-rules.md +73 -0
- package/templates/skills/comms/files/references/visual-shapes.md +112 -0
- package/templates/skills/swarm-scripts/SKILL.md +3 -2
- package/templates/skills/swarm-scripts/content.md +3 -2
package/src/slack/handlers.ts
CHANGED
|
@@ -624,6 +624,7 @@ export function registerMessageHandler(app: App): void {
|
|
|
624
624
|
fullTaskDescription,
|
|
625
625
|
{
|
|
626
626
|
agentId: lead?.id,
|
|
627
|
+
routingReason: lead ? "skill" : undefined,
|
|
627
628
|
source: "slack",
|
|
628
629
|
slackChannelId: msg.channel,
|
|
629
630
|
slackThreadTs: threadTs,
|
|
@@ -739,6 +740,7 @@ export function registerMessageHandler(app: App): void {
|
|
|
739
740
|
fullTaskDescription,
|
|
740
741
|
{
|
|
741
742
|
agentId: agent.id,
|
|
743
|
+
routingReason: "human_pinned",
|
|
742
744
|
source: "slack",
|
|
743
745
|
slackChannelId: msg.channel,
|
|
744
746
|
slackThreadTs: threadTs,
|
|
@@ -762,6 +764,7 @@ export function registerMessageHandler(app: App): void {
|
|
|
762
764
|
fullTaskDescription,
|
|
763
765
|
{
|
|
764
766
|
agentId: agent.id,
|
|
767
|
+
routingReason: "human_pinned",
|
|
765
768
|
source: "slack",
|
|
766
769
|
slackChannelId: msg.channel,
|
|
767
770
|
slackThreadTs: threadTs,
|
|
@@ -209,6 +209,8 @@ async function slackFlush(
|
|
|
209
209
|
const mostRecentTask = await getMostRecentTaskInThread(channelId, threadTs);
|
|
210
210
|
const task = await createTaskWithSiblingAwareness(fullDescription, {
|
|
211
211
|
agentId: lead?.id,
|
|
212
|
+
routingReason:
|
|
213
|
+
lead && mostRecentTask?.agentId === lead.id ? "continuity" : lead ? "skill" : undefined,
|
|
212
214
|
source: "slack",
|
|
213
215
|
slackChannelId: channelId,
|
|
214
216
|
slackThreadTs: threadTs,
|
|
@@ -219,6 +219,7 @@ export async function createWorkerTaskFollowUp(args: {
|
|
|
219
219
|
|
|
220
220
|
return await createTaskExtended(followUpDescription, {
|
|
221
221
|
agentId: leadAgent.id,
|
|
222
|
+
routingReason: "skill",
|
|
222
223
|
source: "system",
|
|
223
224
|
taskType: "follow-up",
|
|
224
225
|
parentTaskId: task.id,
|
|
@@ -424,6 +425,12 @@ export async function createResumeFollowUp(args: {
|
|
|
424
425
|
: sourceAffinity;
|
|
425
426
|
const created = await createTaskExtended(followUpDescription, {
|
|
426
427
|
agentId: preferredAgentId,
|
|
428
|
+
routingReason:
|
|
429
|
+
preferredAgentId === undefined
|
|
430
|
+
? undefined
|
|
431
|
+
: preferredAgentId === parent.agentId
|
|
432
|
+
? "continuity"
|
|
433
|
+
: "reroute_fault",
|
|
427
434
|
creatorAgentId: parent.creatorAgentId,
|
|
428
435
|
source: "system",
|
|
429
436
|
taskType: "resume",
|
|
@@ -542,6 +549,7 @@ export async function createRerouteDecisionTask(args: {
|
|
|
542
549
|
// via parentTaskId. taskType is the distinct "reroute-decision" marker.
|
|
543
550
|
const created = await createTaskExtended(decision.text, {
|
|
544
551
|
agentId: leadAgent.id,
|
|
552
|
+
routingReason: "reroute_fault",
|
|
545
553
|
creatorAgentId: original.creatorAgentId,
|
|
546
554
|
source: "system",
|
|
547
555
|
taskType: "reroute-decision",
|
|
@@ -621,6 +629,7 @@ export async function createPoolStarvationDecisionTask(args: {
|
|
|
621
629
|
|
|
622
630
|
const created = await createTaskExtended(decision.text, {
|
|
623
631
|
agentId: leadAgent.id,
|
|
632
|
+
routingReason: "overflow",
|
|
624
633
|
creatorAgentId: original.creatorAgentId,
|
|
625
634
|
source: "system",
|
|
626
635
|
taskType: "reroute-decision",
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { afterEach, describe, expect, test } from "bun:test";
|
|
1
|
+
import { afterEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
2
|
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
3
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
4
4
|
import { tmpdir } from "node:os";
|
|
@@ -688,7 +688,7 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
|
|
|
688
688
|
expect(toAcpMcpServers(null)).toEqual([]);
|
|
689
689
|
});
|
|
690
690
|
|
|
691
|
-
test("
|
|
691
|
+
test("completes before the revoke response and logs a failed revocation", async () => {
|
|
692
692
|
const cwd = makeTempDir();
|
|
693
693
|
const agentPath = join(cwd, "fake-acp-ephem-agent.ts");
|
|
694
694
|
const sdkPath = join(process.cwd(), "node_modules/@agentclientprotocol/sdk/dist/acp.js");
|
|
@@ -734,6 +734,22 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
|
|
|
734
734
|
let mintCalled = false;
|
|
735
735
|
let revokeCalled = false;
|
|
736
736
|
let mintBody: Record<string, unknown> = {};
|
|
737
|
+
let revokeResponse: ServerResponse | undefined;
|
|
738
|
+
let resolveRevokeRequested!: () => void;
|
|
739
|
+
const revokeRequested = new Promise<void>((resolve) => {
|
|
740
|
+
resolveRevokeRequested = resolve;
|
|
741
|
+
});
|
|
742
|
+
let resolveWarning!: () => void;
|
|
743
|
+
const warned = new Promise<void>((resolve) => {
|
|
744
|
+
resolveWarning = resolve;
|
|
745
|
+
});
|
|
746
|
+
const revokeWarnings: string[] = [];
|
|
747
|
+
const warning = spyOn(console, "warn").mockImplementation((message) => {
|
|
748
|
+
if (String(message).includes("Session token revoke failed")) {
|
|
749
|
+
revokeWarnings.push(String(message));
|
|
750
|
+
resolveWarning();
|
|
751
|
+
}
|
|
752
|
+
});
|
|
737
753
|
|
|
738
754
|
const swarmServer = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
739
755
|
if (req.method === "POST" && req.url === "/api/sessions/tokens") {
|
|
@@ -749,8 +765,8 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
|
|
|
749
765
|
res.end(JSON.stringify({ tokenId: FAKE_TOKEN_ID, plaintext: FAKE_TOKEN }));
|
|
750
766
|
} else if (req.method === "DELETE" && req.url === `/api/sessions/tokens/${FAKE_TOKEN_ID}`) {
|
|
751
767
|
revokeCalled = true;
|
|
752
|
-
res
|
|
753
|
-
|
|
768
|
+
revokeResponse = res;
|
|
769
|
+
resolveRevokeRequested();
|
|
754
770
|
} else {
|
|
755
771
|
res.writeHead(404);
|
|
756
772
|
res.end();
|
|
@@ -778,8 +794,19 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
|
|
|
778
794
|
const events: ProviderEvent[] = [];
|
|
779
795
|
session.onEvent((e) => events.push(e));
|
|
780
796
|
const result = await session.waitForCompletion();
|
|
781
|
-
//
|
|
782
|
-
await
|
|
797
|
+
// Withhold the HTTP response until completion: cleanup must never block it.
|
|
798
|
+
await revokeRequested;
|
|
799
|
+
expect(revokeWarnings).toEqual([]);
|
|
800
|
+
expect(revokeResponse!.destroyed).toBe(false);
|
|
801
|
+
expect(revokeResponse!.writableEnded).toBe(false);
|
|
802
|
+
revokeResponse!.writeHead(503).end();
|
|
803
|
+
const observed = await Promise.race([
|
|
804
|
+
warned.then(() => true),
|
|
805
|
+
Bun.sleep(1000).then(() => false),
|
|
806
|
+
]);
|
|
807
|
+
expect(observed).toBe(true);
|
|
808
|
+
expect(revokeWarnings).toHaveLength(1);
|
|
809
|
+
expect(revokeWarnings[0]).toContain("HTTP 503");
|
|
783
810
|
|
|
784
811
|
expect(result.exitCode).toBe(0);
|
|
785
812
|
|
|
@@ -799,6 +826,8 @@ new AgentSideConnection((connection) => new FakeAgent(connection), stream);
|
|
|
799
826
|
const captured = await Bun.file(captureFile).text();
|
|
800
827
|
expect(captured).toBe(`Bearer ${FAKE_TOKEN}`);
|
|
801
828
|
} finally {
|
|
829
|
+
warning.mockRestore();
|
|
830
|
+
revokeResponse?.end();
|
|
802
831
|
await new Promise<void>((resolve) => swarmServer.close(() => resolve()));
|
|
803
832
|
}
|
|
804
833
|
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { revokeAcpSessionToken } from "../utils/acp-session-token";
|
|
3
|
+
|
|
4
|
+
describe("ACP session token revocation", () => {
|
|
5
|
+
test("logs HTTP 503 without rejecting or disclosing credentials", async () => {
|
|
6
|
+
const server = Bun.serve({
|
|
7
|
+
port: 0,
|
|
8
|
+
fetch: () => new Response("sensitive body", { status: 503 }),
|
|
9
|
+
});
|
|
10
|
+
const warning = spyOn(console, "warn").mockImplementation(() => {});
|
|
11
|
+
try {
|
|
12
|
+
await expect(
|
|
13
|
+
revokeAcpSessionToken(server.url.origin, "test-operator-key", "test-token-id"),
|
|
14
|
+
).resolves.toBeUndefined();
|
|
15
|
+
expect(warning).toHaveBeenCalled();
|
|
16
|
+
const log = JSON.stringify(warning.mock.calls);
|
|
17
|
+
expect(log).toContain("Session token revoke failed");
|
|
18
|
+
expect(log).toContain("503");
|
|
19
|
+
expect(log).not.toContain("test-operator-key");
|
|
20
|
+
expect(log).not.toContain("sensitive body");
|
|
21
|
+
} finally {
|
|
22
|
+
warning.mockRestore();
|
|
23
|
+
server.stop(true);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("logs connection failure against a stopped server without rejecting", async () => {
|
|
28
|
+
const server = Bun.serve({ port: 0, fetch: () => new Response(null, { status: 204 }) });
|
|
29
|
+
const url = server.url.origin;
|
|
30
|
+
await server.stop(true);
|
|
31
|
+
const warning = spyOn(console, "warn").mockImplementation(() => {});
|
|
32
|
+
try {
|
|
33
|
+
await expect(
|
|
34
|
+
revokeAcpSessionToken(url, "test-operator-key", "test-token-id"),
|
|
35
|
+
).resolves.toBeUndefined();
|
|
36
|
+
expect(warning).toHaveBeenCalled();
|
|
37
|
+
expect(JSON.stringify(warning.mock.calls)).toContain("Session token revoke failed");
|
|
38
|
+
} finally {
|
|
39
|
+
warning.mockRestore();
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("aborts a stalled revoke request within its timeout and logs the failure", async () => {
|
|
44
|
+
const server = Bun.serve({ port: 0, fetch: () => new Promise<Response>(() => {}) });
|
|
45
|
+
const warning = spyOn(console, "warn").mockImplementation(() => {});
|
|
46
|
+
// Exercise the real fetch cancellation with a short test-only clock budget.
|
|
47
|
+
const realTimeout = AbortSignal.timeout.bind(AbortSignal);
|
|
48
|
+
const timeout = spyOn(AbortSignal, "timeout").mockImplementation(() => realTimeout(25));
|
|
49
|
+
try {
|
|
50
|
+
const pending = revokeAcpSessionToken(
|
|
51
|
+
server.url.origin,
|
|
52
|
+
"test-operator-key",
|
|
53
|
+
"test-token-id",
|
|
54
|
+
);
|
|
55
|
+
expect(timeout).toHaveBeenCalled();
|
|
56
|
+
expect(timeout.mock.calls[0]![0]).toBeGreaterThan(0);
|
|
57
|
+
expect(timeout.mock.calls[0]![0]).toBeLessThanOrEqual(5000);
|
|
58
|
+
await expect(pending).resolves.toBeUndefined();
|
|
59
|
+
expect(warning).toHaveBeenCalled();
|
|
60
|
+
expect(JSON.stringify(warning.mock.calls)).toContain("Session token revoke failed");
|
|
61
|
+
} finally {
|
|
62
|
+
server.stop(true);
|
|
63
|
+
timeout.mockRestore();
|
|
64
|
+
warning.mockRestore();
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -108,7 +108,12 @@ describe("asset namespace REST contract", () => {
|
|
|
108
108
|
const task = await api(
|
|
109
109
|
"POST",
|
|
110
110
|
"/api/tasks",
|
|
111
|
-
{
|
|
111
|
+
{
|
|
112
|
+
task: "namespaced task",
|
|
113
|
+
agentId,
|
|
114
|
+
routingReason: "human_pinned",
|
|
115
|
+
key: "Shared/Team",
|
|
116
|
+
},
|
|
112
117
|
{ agentId },
|
|
113
118
|
);
|
|
114
119
|
expect(task.status).toBe(201);
|
|
@@ -245,6 +250,7 @@ describe("asset namespace REST contract", () => {
|
|
|
245
250
|
{
|
|
246
251
|
task: "personal task",
|
|
247
252
|
agentId,
|
|
253
|
+
routingReason: "human_pinned",
|
|
248
254
|
key: `personal/${userId}/drafts/`,
|
|
249
255
|
},
|
|
250
256
|
{ agentId, sourceTaskId },
|
|
@@ -258,6 +264,7 @@ describe("asset namespace REST contract", () => {
|
|
|
258
264
|
{
|
|
259
265
|
task: "wrong personal task",
|
|
260
266
|
agentId,
|
|
267
|
+
routingReason: "human_pinned",
|
|
261
268
|
key: `personal/${secondUserId}/drafts/`,
|
|
262
269
|
},
|
|
263
270
|
{ agentId, sourceTaskId },
|
|
@@ -270,6 +277,7 @@ describe("asset namespace REST contract", () => {
|
|
|
270
277
|
{
|
|
271
278
|
task: "unattributed personal task",
|
|
272
279
|
agentId: secondAgentId,
|
|
280
|
+
routingReason: "human_pinned",
|
|
273
281
|
key: `personal/${secondUserId}/drafts/`,
|
|
274
282
|
},
|
|
275
283
|
{ agentId: secondAgentId },
|
|
@@ -281,7 +289,12 @@ describe("asset namespace REST contract", () => {
|
|
|
281
289
|
const task = await api(
|
|
282
290
|
"POST",
|
|
283
291
|
"/api/tasks",
|
|
284
|
-
{
|
|
292
|
+
{
|
|
293
|
+
task: "move target",
|
|
294
|
+
agentId,
|
|
295
|
+
routingReason: "human_pinned",
|
|
296
|
+
key: "shared/inbox/",
|
|
297
|
+
},
|
|
285
298
|
{ agentId },
|
|
286
299
|
);
|
|
287
300
|
const move = await api(
|
|
@@ -76,6 +76,7 @@ describe("asset namespace MCP exposure", () => {
|
|
|
76
76
|
"send-task",
|
|
77
77
|
{
|
|
78
78
|
agentId: targetAgentId,
|
|
79
|
+
routingReason: "human_pinned",
|
|
79
80
|
task: `inherited namespace ${Date.now()}`,
|
|
80
81
|
allowDuplicate: true,
|
|
81
82
|
},
|
|
@@ -90,6 +91,7 @@ describe("asset namespace MCP exposure", () => {
|
|
|
90
91
|
"send-task",
|
|
91
92
|
{
|
|
92
93
|
agentId: targetAgentId,
|
|
94
|
+
routingReason: "human_pinned",
|
|
93
95
|
task: `wrong namespace ${Date.now()}`,
|
|
94
96
|
key: `personal/${otherUserId}/drafts/`,
|
|
95
97
|
allowDuplicate: true,
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
|
2
|
+
import { notifyAutomationPreflightFailure } from "../automation-preflight-alert";
|
|
3
|
+
import type { AutomationPreflightResult } from "../be/automation-preflight";
|
|
4
|
+
import * as slack from "../slack/app";
|
|
5
|
+
import { registerVolatileSecret } from "../utils/secret-scrubber";
|
|
6
|
+
|
|
7
|
+
const savedEnv = { ...process.env };
|
|
8
|
+
const preflight: AutomationPreflightResult = {
|
|
9
|
+
id: "test-schedule",
|
|
10
|
+
kind: "schedule",
|
|
11
|
+
name: "daily-report",
|
|
12
|
+
state: "needs_setup",
|
|
13
|
+
missing: { params: ["REPORT_NAME", "REPO_URL"], integrations: ["github", "slack"] },
|
|
14
|
+
fixes: [],
|
|
15
|
+
fixUrl: "/schedules/test-schedule?param=REPORT_NAME",
|
|
16
|
+
};
|
|
17
|
+
const postMessage = mock(async (_message: unknown) => ({ ok: true }));
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
process.env.SLACK_ALERTS_CHANNEL = "C_TEST_ALERTS";
|
|
21
|
+
process.env.APP_URL = "https://dashboard.example.com/";
|
|
22
|
+
postMessage.mockReset();
|
|
23
|
+
postMessage.mockResolvedValue({ ok: true });
|
|
24
|
+
spyOn(slack, "getSlackApp").mockReturnValue({
|
|
25
|
+
client: { chat: { postMessage } },
|
|
26
|
+
} as unknown as NonNullable<ReturnType<typeof slack.getSlackApp>>);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
afterEach(() => {
|
|
30
|
+
mock.restore();
|
|
31
|
+
process.env = { ...savedEnv };
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
describe("Automation preflight alerts", () => {
|
|
35
|
+
test("skips Slack notification when alerts channel env is unset", async () => {
|
|
36
|
+
delete process.env.SLACK_ALERTS_CHANNEL;
|
|
37
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
38
|
+
|
|
39
|
+
await expect(notifyAutomationPreflightFailure(preflight)).resolves.toBeUndefined();
|
|
40
|
+
|
|
41
|
+
expect(warn).toHaveBeenCalledWith(
|
|
42
|
+
"[Automation Preflight] SLACK_ALERTS_CHANNEL not set; skipping alert",
|
|
43
|
+
);
|
|
44
|
+
expect(slack.getSlackApp).not.toHaveBeenCalled();
|
|
45
|
+
expect(postMessage).not.toHaveBeenCalled();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("posts the skipped automation, missing setup, and dashboard fix link", async () => {
|
|
49
|
+
await notifyAutomationPreflightFailure(preflight);
|
|
50
|
+
|
|
51
|
+
expect(postMessage).toHaveBeenCalledWith({
|
|
52
|
+
channel: "C_TEST_ALERTS",
|
|
53
|
+
text:
|
|
54
|
+
'⚠️ Automation needs setup\nAutomation: schedule "daily-report"\n' +
|
|
55
|
+
"Missing params: REPORT_NAME, REPO_URL\nMissing integrations: github, slack\n" +
|
|
56
|
+
"Fire SKIPPED. It was not retried or queued.\n" +
|
|
57
|
+
"Fix setup: https://dashboard.example.com/schedules/test-schedule?param=REPORT_NAME",
|
|
58
|
+
unfurl_links: false,
|
|
59
|
+
unfurl_media: false,
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("skips Slack notification when the app is unavailable", async () => {
|
|
64
|
+
spyOn(slack, "getSlackApp").mockReturnValue(null);
|
|
65
|
+
const warn = spyOn(console, "warn").mockImplementation(() => {});
|
|
66
|
+
|
|
67
|
+
await expect(notifyAutomationPreflightFailure(preflight)).resolves.toBeUndefined();
|
|
68
|
+
|
|
69
|
+
expect(warn).toHaveBeenCalledWith(
|
|
70
|
+
"[Automation Preflight] Slack not available, cannot send notification",
|
|
71
|
+
);
|
|
72
|
+
expect(postMessage).not.toHaveBeenCalled();
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("scrubs alert text and logs Slack failures without throwing", async () => {
|
|
76
|
+
const secret = "preflight-alert-sensitive-fixture";
|
|
77
|
+
registerVolatileSecret(secret, "PREFLIGHT_TEST_SECRET");
|
|
78
|
+
postMessage.mockRejectedValueOnce(new Error(`Slack failed: ${secret}`));
|
|
79
|
+
const error = spyOn(console, "error").mockImplementation(() => {});
|
|
80
|
+
|
|
81
|
+
await expect(
|
|
82
|
+
notifyAutomationPreflightFailure({ ...preflight, name: secret }),
|
|
83
|
+
).resolves.toBeUndefined();
|
|
84
|
+
|
|
85
|
+
expect(postMessage).toHaveBeenCalledTimes(1);
|
|
86
|
+
expect(JSON.stringify(postMessage.mock.calls)).not.toContain(secret);
|
|
87
|
+
expect(error).toHaveBeenCalledTimes(1);
|
|
88
|
+
expect(JSON.stringify(error.mock.calls)).toContain("Slack failed:");
|
|
89
|
+
expect(JSON.stringify(error.mock.calls)).not.toContain(secret);
|
|
90
|
+
});
|
|
91
|
+
});
|
|
@@ -235,6 +235,7 @@ async function runTransport(transport: "cli" | "sdk", agentId: string): Promise<
|
|
|
235
235
|
const created = await api("POST", "/api/tasks", {
|
|
236
236
|
body: {
|
|
237
237
|
task: `Run the deterministic Claude ${transport} transport parity fixture and report completion.`,
|
|
238
|
+
routingReason: "human_pinned",
|
|
238
239
|
agentId,
|
|
239
240
|
source: "api",
|
|
240
241
|
},
|
|
@@ -373,6 +374,7 @@ async function runCredentialRotation(): Promise<void> {
|
|
|
373
374
|
const firstCreated = await api("POST", "/api/tasks", {
|
|
374
375
|
body: {
|
|
375
376
|
task: "Run the first deterministic credential rotation fixture task.",
|
|
377
|
+
routingReason: "human_pinned",
|
|
376
378
|
agentId: ROTATION_AGENT_ID,
|
|
377
379
|
source: "api",
|
|
378
380
|
},
|
|
@@ -483,6 +485,7 @@ async function runCredentialRotation(): Promise<void> {
|
|
|
483
485
|
const secondCreated = await api("POST", "/api/tasks", {
|
|
484
486
|
body: {
|
|
485
487
|
task: "Run the second deterministic credential rotation fixture task.",
|
|
488
|
+
routingReason: "human_pinned",
|
|
486
489
|
agentId: ROTATION_AGENT_ID,
|
|
487
490
|
source: "api",
|
|
488
491
|
},
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { createServer, type Server } from "node:http";
|
|
3
|
+
import { closeDb, createAgent, initDb, upsertSwarmConfig } from "../be/db";
|
|
4
|
+
import { handleConfig } from "../http/config";
|
|
5
|
+
import { handleCore } from "../http/core";
|
|
6
|
+
import { handleSessions } from "../http/sessions";
|
|
7
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
8
|
+
import { listenOnFreePort } from "./test-net";
|
|
9
|
+
|
|
10
|
+
const API_KEY = "config-session-test-operator";
|
|
11
|
+
const SECRET = "config-session-test-secret-value";
|
|
12
|
+
let server: Server;
|
|
13
|
+
let baseUrl: string;
|
|
14
|
+
let workerId: string;
|
|
15
|
+
let leadId: string;
|
|
16
|
+
let configId: string;
|
|
17
|
+
|
|
18
|
+
function headers(bearer: string, agentId?: string): Record<string, string> {
|
|
19
|
+
return {
|
|
20
|
+
Authorization: `Bearer ${bearer}`,
|
|
21
|
+
"Content-Type": "application/json",
|
|
22
|
+
...(agentId ? { "X-Agent-ID": agentId } : {}),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
async function mint(agentId: string) {
|
|
27
|
+
const res = await fetch(`${baseUrl}/api/sessions/tokens`, {
|
|
28
|
+
method: "POST",
|
|
29
|
+
headers: headers(API_KEY),
|
|
30
|
+
body: JSON.stringify({ agentId, taskId: crypto.randomUUID(), ttlMs: 60_000 }),
|
|
31
|
+
});
|
|
32
|
+
expect(res.status).toBe(200);
|
|
33
|
+
return (await res.json()) as { tokenId: string; plaintext: string };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
beforeAll(async () => {
|
|
37
|
+
initDb(":memory:");
|
|
38
|
+
workerId = (await createAgent({ name: "config-worker", isLead: false, status: "idle" })).id;
|
|
39
|
+
leadId = (await createAgent({ name: "config-lead", isLead: true, status: "idle" })).id;
|
|
40
|
+
configId = (
|
|
41
|
+
await upsertSwarmConfig({
|
|
42
|
+
scope: "agent",
|
|
43
|
+
scopeId: workerId,
|
|
44
|
+
key: "ACP_AUTH_TEST_SECRET",
|
|
45
|
+
value: SECRET,
|
|
46
|
+
isSecret: true,
|
|
47
|
+
})
|
|
48
|
+
).id;
|
|
49
|
+
server = createServer(async (req, res) => {
|
|
50
|
+
if (await handleCore(req, res, req.headers["x-agent-id"] as string | undefined, API_KEY))
|
|
51
|
+
return;
|
|
52
|
+
const segments = getPathSegments(req.url || "");
|
|
53
|
+
const query = parseQueryParams(req.url || "");
|
|
54
|
+
if (await handleSessions(req, res, segments, query)) return;
|
|
55
|
+
if (await handleConfig(req, res, segments, query)) return;
|
|
56
|
+
res.writeHead(404).end();
|
|
57
|
+
});
|
|
58
|
+
baseUrl = `http://127.0.0.1:${await listenOnFreePort(server)}`;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
afterAll(async () => {
|
|
62
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
63
|
+
closeDb();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("config HTTP session-token authorization", () => {
|
|
67
|
+
for (const route of ["list", "resolved", "by-id"] as const) {
|
|
68
|
+
test(`${route} masks secrets for a non-lead session even with a spoofed lead header`, async () => {
|
|
69
|
+
const token = await mint(workerId);
|
|
70
|
+
const lead = await mint(leadId);
|
|
71
|
+
const path =
|
|
72
|
+
route === "by-id"
|
|
73
|
+
? `/api/config/${configId}`
|
|
74
|
+
: route === "resolved"
|
|
75
|
+
? "/api/config/resolved"
|
|
76
|
+
: "/api/config";
|
|
77
|
+
const url = `${baseUrl}${path}?includeSecrets=true&agentId=${workerId}`;
|
|
78
|
+
// Positive controls: operator and a token bound to the actual lead may read.
|
|
79
|
+
for (const bearer of [API_KEY, lead.plaintext]) {
|
|
80
|
+
const res = await fetch(url, { headers: headers(bearer) });
|
|
81
|
+
expect(res.status).toBe(200);
|
|
82
|
+
expect(await res.text()).toContain(SECRET);
|
|
83
|
+
}
|
|
84
|
+
const responses: string[] = [];
|
|
85
|
+
for (const agentId of [undefined, workerId, leadId]) {
|
|
86
|
+
const res = await fetch(url, { headers: headers(token.plaintext, agentId) });
|
|
87
|
+
expect(res.status).toBe(200);
|
|
88
|
+
responses.push(await res.text());
|
|
89
|
+
}
|
|
90
|
+
const revoked = await fetch(`${baseUrl}/api/sessions/tokens/${token.tokenId}`, {
|
|
91
|
+
method: "DELETE",
|
|
92
|
+
headers: headers(API_KEY),
|
|
93
|
+
});
|
|
94
|
+
expect(revoked.status).toBe(204);
|
|
95
|
+
expect((await fetch(url, { headers: headers(token.plaintext, leadId) })).status).toBe(401);
|
|
96
|
+
for (const body of responses) {
|
|
97
|
+
expect(body).not.toContain(SECRET);
|
|
98
|
+
expect(body).toContain("secret values masked");
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
for (const method of ["PUT", "DELETE"] as const) {
|
|
104
|
+
test(`${method} cannot escalate a non-lead session by changing only X-Agent-ID`, async () => {
|
|
105
|
+
const token = await mint(workerId);
|
|
106
|
+
const key = `ACP_AUTH_TEST_${method}`;
|
|
107
|
+
const config = await upsertSwarmConfig({
|
|
108
|
+
scope: "agent",
|
|
109
|
+
scopeId: workerId,
|
|
110
|
+
key,
|
|
111
|
+
value: "original",
|
|
112
|
+
});
|
|
113
|
+
const url = `${baseUrl}/api/config${method === "DELETE" ? `/${config.id}` : ""}`;
|
|
114
|
+
const request = (bearer: string, agentId?: string) =>
|
|
115
|
+
fetch(url, {
|
|
116
|
+
method,
|
|
117
|
+
headers: headers(bearer, agentId),
|
|
118
|
+
...(method === "PUT"
|
|
119
|
+
? { body: JSON.stringify({ scope: "agent", scopeId: workerId, key, value: "changed" }) }
|
|
120
|
+
: {}),
|
|
121
|
+
});
|
|
122
|
+
const own = await request(token.plaintext, workerId);
|
|
123
|
+
expect(own.status).toBe(403);
|
|
124
|
+
await own.text();
|
|
125
|
+
const spoofed = await request(token.plaintext, leadId);
|
|
126
|
+
await spoofed.text();
|
|
127
|
+
const stored = await fetch(`${baseUrl}/api/config/${config.id}`, {
|
|
128
|
+
headers: headers(API_KEY),
|
|
129
|
+
});
|
|
130
|
+
const storedBody = await stored.text();
|
|
131
|
+
// Confirm both the response and persisted state; an error after writing is insufficient.
|
|
132
|
+
expect({
|
|
133
|
+
status: spoofed.status,
|
|
134
|
+
storedStatus: stored.status,
|
|
135
|
+
unchanged: storedBody.includes("original"),
|
|
136
|
+
}).toEqual({ status: 403, storedStatus: 200, unchanged: true });
|
|
137
|
+
const lead = await mint(leadId);
|
|
138
|
+
const allowed = await request(lead.plaintext);
|
|
139
|
+
expect(allowed.ok).toBe(true);
|
|
140
|
+
await allowed.text();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
});
|