@desplega.ai/agent-swarm 2.6.0 → 2.7.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 +2 -0
- package/dist/{acp-adapter-dgfakfpw.js → acp-adapter-ma1js5n7.js} +6 -6
- package/dist/{actions-fbqma8aa.js → actions-1vhmtvg9.js} +18 -18
- package/dist/{amp-adapter-en0jy6hs.js → amp-adapter-sxqqvmdk.js} +3 -3
- package/dist/{app-k8a5cgpp.js → app-mwx1d5a9.js} +3 -3
- package/dist/{assistant-a16433gr.js → assistant-yx1p6g82.js} +20 -20
- package/dist/{boot-reembed-f4nn561n.js → boot-reembed-5h37a2c2.js} +1 -1
- package/dist/{boot-reembed-6c1sjfnr.js → boot-reembed-7h78vmvv.js} +2 -1
- package/dist/{boot-scrub-sweep-d4w9gajf.js → boot-scrub-sweep-8mawd4gw.js} +3 -3
- package/dist/{boot-seeds-9bmkspyw.js → boot-seeds-ca84dhnn.js} +4 -4
- package/dist/{claude-adapter-nys1kjxa.js → claude-adapter-wqt4hfkq.js} +7 -7
- package/dist/{claude-managed-adapter-55207973.js → claude-managed-adapter-6fjqtr2z.js} +2 -2
- package/dist/{claude-managed-setup-3cm35gey.js → claude-managed-setup-rqv98sea.js} +2 -2
- package/dist/{claude-sdk-session-nw5yd8cp.js → claude-sdk-session-f29f3b2x.js} +7 -7
- package/dist/{cli-sxgtf5fw.js → cli-07q03xm2.js} +1 -1
- package/dist/{cli-k21gx5ht.js → cli-1hhbzkbb.js} +6 -6
- package/dist/{cli-v49fkghp.js → cli-1m9x8dr5.js} +1 -1
- package/dist/{cli-24j1jeeb.js → cli-2pvzmas1.js} +3 -3
- package/dist/{cli-w2656ka1.js → cli-2y7v133v.js} +7 -7
- package/dist/{cli-g8ka5j4j.js → cli-48gxjmcz.js} +94 -36
- package/dist/{cli-2sjhpsns.js → cli-54sj64fc.js} +1 -1
- package/dist/{cli-aj8djyqt.js → cli-60sty27p.js} +2 -2
- package/dist/{cli-4hxrf0p4.js → cli-6a9qfa8b.js} +308 -47
- package/dist/{cli-n7asmce6.js → cli-6mgdba42.js} +3 -3
- package/dist/{cli-gf95q3bm.js → cli-6srv653c.js} +3 -3
- package/dist/{cli-9abdgn5t.js → cli-86ehn4s5.js} +3 -3
- package/dist/{cli-bqztfyve.js → cli-8mbc9za9.js} +1 -1
- package/dist/{cli-a2rr0fmg.js → cli-8wmj18cz.js} +1 -1
- package/dist/{cli-c2habvsf.js → cli-9gsm2rzp.js} +8 -8
- package/dist/{cli-4rf0f5vg.js → cli-9pshx6rd.js} +4 -2
- package/dist/{cli-abkm71vr.js → cli-a6ds2epe.js} +1 -1
- package/dist/{cli-esakngh8.js → cli-a89zdqz9.js} +1 -1
- package/dist/{cli-3ndmpxxv.js → cli-ay40dnj3.js} +2 -2
- package/dist/{cli-kf4jree9.js → cli-aypd09nf.js} +1 -1
- package/dist/{cli-6y51ryva.js → cli-b3yzyc57.js} +1 -1
- package/dist/{cli-ag2yrnt5.js → cli-c668vvcf.js} +2 -2
- package/dist/{cli-5sckpqcx.js → cli-d20qvpr2.js} +1 -1
- package/dist/{cli-a34pr3w1.js → cli-e1595bgx.js} +2 -2
- package/dist/{cli-m70bajzr.js → cli-f12mr20z.js} +1 -1
- package/dist/{cli-dqygeeh5.js → cli-f1e0ptjj.js} +10 -1
- package/dist/{cli-mvjz5an3.js → cli-f4dbh7a5.js} +7 -7
- package/dist/{cli-az25fn5f.js → cli-gsfk58nw.js} +2 -2
- package/dist/{cli-1tem6ns9.js → cli-gt4h8cxw.js} +3 -2
- package/dist/{cli-cz37jqss.js → cli-h5jeg46p.js} +2 -2
- package/dist/{cli-emhjcj2y.js → cli-he9gbdx0.js} +60 -10
- package/dist/{cli-3fwqy71j.js → cli-j0ppywdr.js} +38 -31
- package/dist/{cli-5rj83bam.js → cli-j3y0x2n4.js} +34 -33
- package/dist/{cli-3gfvmzht.js → cli-j9bpmykh.js} +73 -25
- package/dist/{cli-m5a9m0ak.js → cli-kv7sbc2c.js} +3 -3
- package/dist/{cli-g20jjwt4.js → cli-m30k9777.js} +12 -12
- package/dist/{cli-683y0b3a.js → cli-m5e802r7.js} +2 -2
- package/dist/{cli-nn6vx2mq.js → cli-ptfq202x.js} +5 -3
- package/dist/{cli-d2dpf95n.js → cli-qj442fws.js} +1 -1
- package/dist/{cli-03gg9pd1.js → cli-rd5m70gj.js} +4 -4
- package/dist/{cli-gnsb1zxa.js → cli-s09j9vp5.js} +1 -1
- package/dist/{cli-zqne1qcj.js → cli-s0mgx214.js} +2 -2
- package/dist/{cli-sqma6vw1.js → cli-sgqm2582.js} +5 -5
- package/dist/{cli-nq677csy.js → cli-sjctjr48.js} +1 -1
- package/dist/{cli-3aqd4jaa.js → cli-twctnkng.js} +3 -3
- package/dist/{cli-j6rktc91.js → cli-v6shg7na.js} +1 -1
- package/dist/{cli-7enjwya9.js → cli-vz78s5wm.js} +1 -1
- package/dist/{cli-art7c1zh.js → cli-wqr7cea4.js} +4 -4
- package/dist/{cli-hfeb68z4.js → cli-x37r7qwf.js} +11 -3
- package/dist/{cli-ffvsj2qw.js → cli-yd2nreqw.js} +2 -2
- package/dist/{cli-ca6banwz.js → cli-ymjpkxgn.js} +6 -6
- package/dist/{cli-mtnj4fwa.js → cli-yxbsmx56.js} +5 -5
- package/dist/cli.js +18 -16
- package/dist/{codex-adapter-d95q4qsn.js → codex-adapter-zk59x2zh.js} +5 -5
- package/dist/{codex-hook-brpnycn5.js → codex-hook-xyjhvxwd.js} +1 -1
- package/dist/{codex-session-runner-gdwd8h0c.js → codex-session-runner-kh6s4j3n.js} +5 -5
- package/dist/{commands-jqmgk54b.js → commands-68et1qv2.js} +2 -2
- package/dist/{controller-z3jq589f.js → controller-fyrbnzg4.js} +2 -2
- package/dist/{controllers-connect-r2za39ef.js → controllers-connect-nep9h2xv.js} +19 -19
- package/dist/{cursor-adapter-4mr4jgkw.js → cursor-adapter-yss3pg7t.js} +1 -1
- package/dist/{db-w36wwb5g.js → db-28gjyvxj.js} +2 -2
- package/dist/{db-ppv2tze7.js → db-8ftwvzba.js} +3 -3
- package/dist/{dispatcher-pv5h77vf.js → dispatcher-hayexmkr.js} +17 -17
- package/dist/{dsh-adapter-d4pdpb3r.js → dsh-adapter-46wawff9.js} +1 -1
- package/dist/{easter-egg-3d-4sp4dvmc.js → easter-egg-3d-8d1e0h4m.js} +1 -1
- package/dist/{execute-jev839y1.js → execute-cjrfxfnm.js} +1 -1
- package/dist/{external-channel-y6nkzhnn.js → external-channel-xbmxcg09.js} +2 -2
- package/dist/{grok-adapter-t08b9ntm.js → grok-adapter-ag4wks2a.js} +7 -7
- package/dist/handlers-tzmevqb5.js +41 -0
- package/dist/{heartbeat-vn31xwdk.js → heartbeat-psfvcgwp.js} +20 -20
- package/dist/{hook-6d0zbvvt.js → hook-8pz04697.js} +5 -5
- package/dist/{hook-cpjs8q51.js → hook-cyq4mryn.js} +6 -6
- package/dist/{http-9c6medda.js → http-haj1nd6k.js} +109 -114
- package/dist/{index-qhjf2htm.js → index-jwt2bj6f.js} +261 -96
- package/dist/{index-g6k9tctj.js → index-q52f47pe.js} +1 -1
- package/dist/{index-pkma1yf8.js → index-syvhnvtw.js} +1 -1
- package/dist/{index-yqgbfgaj.js → index-y1ajx04a.js} +20 -20
- package/dist/{index-k0yt1tvq.js → index-z5h22pes.js} +17 -17
- package/dist/{index-38t35h0n.js → index-zyq8ct52.js} +17 -17
- package/dist/{keepalive-em0117bp.js → keepalive-xdvsamvj.js} +3 -3
- package/dist/{lead-23e7z59t.js → lead-hqg57kdp.js} +35 -35
- package/dist/{maintenance-kbme3qy9.js → maintenance-4y2xzh3a.js} +1 -1
- package/dist/{oauth-refresh-sweep-xysj4yse.js → oauth-refresh-sweep-jd4r7zbs.js} +8 -3
- package/dist/{onboard-p6q0vz97.js → onboard-18447q9q.js} +5 -5
- package/dist/{opencode-adapter-esv3k9yn.js → opencode-adapter-26b2frdf.js} +5 -5
- package/dist/{otel-impl-54qhqh1z.js → otel-impl-f9tgr8yw.js} +1 -1
- package/dist/{pi-mono-adapter-mymbwhj0.js → pi-mono-adapter-qn6qbga1.js} +33 -12
- package/dist/{pricing-refresh-yb75wv33.js → pricing-refresh-dw2tgb53.js} +3 -3
- package/dist/{rbac-roles-1vp2mw4p.js → rbac-roles-aygm2760.js} +2 -2
- package/dist/{rbac-roles-dexhc9x8.js → rbac-roles-s4nwj2m3.js} +1 -1
- package/dist/{render-v2-tjay5hhf.js → render-v2-yfvdfr0x.js} +3 -3
- package/dist/{resume-8ayf37g5.js → resume-1bmvjbf3.js} +7 -7
- package/dist/{runtime-hqe5cqcd.js → runtime-f7tpzedy.js} +1 -1
- package/dist/{session-access-fdhp987s.js → session-access-r5garej6.js} +4 -4
- package/dist/{session-source-backfill-qgtc86z1.js → session-source-backfill-424wzv0r.js} +2 -2
- package/dist/{setup-4rrmqcq3.js → setup-8tygvxkq.js} +1 -1
- package/dist/{virtual-modules-52km00d6.js → virtual-modules-xsw323b3.js} +2 -2
- package/dist/{work-objects-p28fgd70.js → work-objects-ggnghpgf.js} +20 -20
- package/dist/{worker-e3w7vye1.js → worker-sqjd1nqf.js} +35 -35
- package/openapi.json +69 -1
- package/package.json +5 -3
- package/src/be/automation-preflight.ts +2 -0
- package/src/be/db/api-keys.ts +9 -0
- package/src/be/db/workflow-runs.ts +36 -5
- package/src/be/db.ts +80 -22
- package/src/be/memory/task-learning.ts +155 -0
- package/src/be/migrations/245_script_executor.sql +3 -0
- package/src/be/oauth-refresh-sweep.ts +2 -1
- package/src/be/scripts/boot-reembed.ts +2 -0
- package/src/be/scripts/db.ts +14 -4
- package/src/be/scripts/embeddings.ts +2 -0
- package/src/be/scripts/run-saved.ts +1 -0
- package/src/be/scripts/typecheck.ts +9 -3
- package/src/be/seed-skills/index.ts +7 -0
- package/src/commands/onboard/compose-generator.ts +5 -3
- package/src/commands/provider-credentials.ts +2 -1
- package/src/heartbeat/heartbeat.ts +14 -1
- package/src/heartbeat/script-runner-probe.ts +47 -0
- package/src/http/api-keys.ts +24 -9
- package/src/http/idempotency-key.ts +1 -1
- package/src/http/index.ts +3 -3
- package/src/http/pages-public.ts +38 -5
- package/src/http/scripts.ts +27 -0
- package/src/http/workflows.ts +10 -0
- package/src/http/x.ts +5 -0
- package/src/prompts/base-prompt.ts +21 -1
- package/src/prompts/session-templates.ts +17 -3
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/pi-mono-adapter.ts +41 -11
- package/src/scheduler/scheduler.ts +2 -1
- package/src/scripts-runtime/eval-harness.ts +8 -1
- package/src/scripts-runtime/executors/native.ts +22 -16
- package/src/scripts-runtime/executors/quickjs-prelude.ts +103 -1
- package/src/scripts-runtime/executors/quickjs-runner.ts +2 -1
- package/src/scripts-runtime/executors/quickjs.ts +7 -0
- package/src/scripts-runtime/executors/registry.ts +32 -0
- package/src/scripts-runtime/executors/types.ts +7 -0
- package/src/scripts-runtime/host-validated-args.ts +133 -0
- package/src/scripts-runtime/loader.ts +30 -3
- package/src/scripts-runtime/swarm-sdk.ts +9 -2
- package/src/scripts-runtime/types/stdlib.d.ts +9 -1
- package/src/scripts-runtime/types/swarm-sdk.d.ts +9 -1
- package/src/tests/api-key-status-by-suffix.test.ts +242 -0
- package/src/tests/base-prompt.test.ts +70 -2
- package/src/tests/community-linear-delivery-loop.test.ts +2099 -0
- package/src/tests/credential-check.test.ts +4 -2
- package/src/tests/docs-index.test.ts +135 -0
- package/src/tests/dsh-adapter.test.ts +1 -1
- package/src/tests/fetch-resolved-env.test.ts +16 -2
- package/src/tests/heartbeat-checklist.test.ts +89 -0
- package/src/tests/heartbeat-script-runner-probe.test.ts +83 -0
- package/src/tests/oauth-refresh-sweep.test.ts +34 -1
- package/src/tests/onboard-compose.test.ts +20 -0
- package/src/tests/opencode-adapter.test.ts +2 -1
- package/src/tests/pages-frame-ancestors.test.ts +162 -0
- package/src/tests/pi-codemode.test.ts +2 -1
- package/src/tests/pi-mono-adapter.test.ts +54 -0
- package/src/tests/pi-native-mcp.test.ts +13 -1
- package/src/tests/pi-tool-exposure.test.ts +8 -8
- package/src/tests/probe-tool-schema-tokens.test.ts +144 -0
- package/src/tests/prompt-template-session.test.ts +4 -3
- package/src/tests/request-composition-derive.test.ts +329 -0
- package/src/tests/script-connections.test.ts +2 -0
- package/src/tests/script-executor-conformance.test.ts +36 -1
- package/src/tests/script-executor-quickjs-pool.test.ts +50 -0
- package/src/tests/script-executor-quickjs.test.ts +140 -0
- package/src/tests/script-host-validated-args-native.test.ts +196 -0
- package/src/tests/script-per-script-executor.test.ts +415 -0
- package/src/tests/scripts-external-api.test.ts +45 -0
- package/src/tests/sdk-allowlist.test.ts +3 -2
- package/src/tests/self-improvement.test.ts +4 -4
- package/src/tests/store-progress-learning.test.ts +288 -0
- package/src/tests/ui-logs-parser.test.ts +180 -0
- package/src/tests/workflow-engine-v2.test.ts +116 -1
- package/src/tests/workflow-mcp-trigger-schema.test.ts +209 -0
- package/src/tests/workflow-run-finished-event.test.ts +263 -0
- package/src/tests/workflow-swarm-script.test.ts +34 -0
- package/src/tools/script-common.ts +1 -0
- package/src/tools/script-run.ts +7 -0
- package/src/tools/script-upsert.ts +7 -0
- package/src/tools/store-progress.ts +67 -1
- package/src/tools/workflows/trigger-workflow.ts +104 -10
- package/src/types.ts +12 -1
- package/src/utils/credentials.ts +5 -2
- package/src/workflows/engine.ts +18 -16
- package/src/workflows/executors/swarm-script.ts +5 -0
- package/src/workflows/foreach-driver.ts +5 -11
- package/src/workflows/node-timeout.ts +30 -0
- package/templates/community/linear-delivery-loop/PLAYBOOK.md +323 -0
- package/templates/community/linear-delivery-loop/README.md +38 -0
- package/templates/community/linear-delivery-loop/config.example.json +31 -0
- package/templates/community/linear-delivery-loop/lead-prompt.md +34 -0
- package/templates/community/linear-delivery-loop/schedule.json +10 -0
- package/templates/community/linear-delivery-loop/scripts/linear-delivery-setup.ts +148 -0
- package/templates/community/linear-delivery-loop/scripts/linear-delivery-tick.ts +1781 -0
- package/templates/skills/swarm-docs/config.json +14 -0
- package/templates/skills/swarm-docs/content.md +144 -0
- package/dist/handlers-eqsnmv95.js +0 -41
|
@@ -1323,10 +1323,12 @@ describe("NaN and Helmcode live probes", () => {
|
|
|
1323
1323
|
// ─── Migration 055: report composition + opt-out ────────────────────────────
|
|
1324
1324
|
|
|
1325
1325
|
describe("isCredCheckDisabled", () => {
|
|
1326
|
-
test("true
|
|
1326
|
+
test("true when CRED_CHECK_DISABLE is '1' or 'true' (case-insensitive)", () => {
|
|
1327
1327
|
expect(isCredCheckDisabled({})).toBe(false);
|
|
1328
1328
|
expect(isCredCheckDisabled({ CRED_CHECK_DISABLE: "0" })).toBe(false);
|
|
1329
|
-
expect(isCredCheckDisabled({ CRED_CHECK_DISABLE: "
|
|
1329
|
+
expect(isCredCheckDisabled({ CRED_CHECK_DISABLE: "false" })).toBe(false);
|
|
1330
|
+
expect(isCredCheckDisabled({ CRED_CHECK_DISABLE: "true" })).toBe(true);
|
|
1331
|
+
expect(isCredCheckDisabled({ CRED_CHECK_DISABLE: "TRUE" })).toBe(true);
|
|
1330
1332
|
expect(isCredCheckDisabled({ CRED_CHECK_DISABLE: "1" })).toBe(true);
|
|
1331
1333
|
});
|
|
1332
1334
|
});
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import {
|
|
6
|
+
collectDocsPages,
|
|
7
|
+
coverageErrors,
|
|
8
|
+
INDEX_END,
|
|
9
|
+
INDEX_START,
|
|
10
|
+
renderDocsIndex,
|
|
11
|
+
replaceIndexBlock,
|
|
12
|
+
SKILL_CONTENT_PATH,
|
|
13
|
+
} from "../../scripts/build-docs-index";
|
|
14
|
+
import { loadSeedSkills } from "../be/seed-skills";
|
|
15
|
+
|
|
16
|
+
const page = (frontmatter: string, body = "Body.\n") => `---\n${frontmatter}\n---\n\n${body}`;
|
|
17
|
+
|
|
18
|
+
describe("docs index (real docs tree)", () => {
|
|
19
|
+
test("every playbook is in the index and the coverage rules pass", async () => {
|
|
20
|
+
const pages = await collectDocsPages();
|
|
21
|
+
expect(coverageErrors(pages)).toEqual([]);
|
|
22
|
+
|
|
23
|
+
const index = renderDocsIndex(pages);
|
|
24
|
+
const playbooks = pages.filter((p) => p.path.startsWith("playbooks/"));
|
|
25
|
+
expect(playbooks.length).toBeGreaterThan(10);
|
|
26
|
+
for (const playbook of playbooks) {
|
|
27
|
+
expect(index).toContain(`(https://docs.agent-swarm.dev/docs/${playbook.slug})`);
|
|
28
|
+
}
|
|
29
|
+
// Positive control: a known playbook carries its trigger.
|
|
30
|
+
expect(index).toContain(
|
|
31
|
+
"[Open Oncall](https://docs.agent-swarm.dev/docs/playbooks/open-oncall). When: ",
|
|
32
|
+
);
|
|
33
|
+
// Negative control: opted-out pages are reference only, release notes are skipped.
|
|
34
|
+
expect(index).toContain("(https://docs.agent-swarm.dev/docs/playbooks). Reference.");
|
|
35
|
+
expect(index).not.toContain("/docs/releases/");
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("the committed skill body matches the generator", async () => {
|
|
39
|
+
const content = await Bun.file(SKILL_CONTENT_PATH).text();
|
|
40
|
+
const regenerated = replaceIndexBlock(content, renderDocsIndex(await collectDocsPages()));
|
|
41
|
+
expect(content).toBe(regenerated);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("the generator is deterministic", async () => {
|
|
45
|
+
const first = renderDocsIndex(await collectDocsPages());
|
|
46
|
+
const second = renderDocsIndex(await collectDocsPages());
|
|
47
|
+
expect(second).toBe(first);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("the seeded skill is a system default and carries the index", async () => {
|
|
51
|
+
const skill = (await loadSeedSkills()).find((s) => s.name === "swarm-docs");
|
|
52
|
+
expect(skill?.systemDefault).toBe(true);
|
|
53
|
+
expect(skill?.content).toContain("https://docs.agent-swarm.dev/docs/playbooks/open-oncall");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("docs index (fixture tree)", () => {
|
|
58
|
+
let root: string;
|
|
59
|
+
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
root = await mkdtemp(join(tmpdir(), "docs-index-"));
|
|
62
|
+
await mkdir(join(root, "playbooks"), { recursive: true });
|
|
63
|
+
await mkdir(join(root, "guides"), { recursive: true });
|
|
64
|
+
await mkdir(join(root, "releases"), { recursive: true });
|
|
65
|
+
await Bun.write(join(root, "meta.json"), JSON.stringify({ pages: ["index", "playbooks"] }));
|
|
66
|
+
await Bun.write(join(root, "index.mdx"), page("title: Home"));
|
|
67
|
+
await Bun.write(
|
|
68
|
+
join(root, "playbooks", "meta.json"),
|
|
69
|
+
JSON.stringify({ pages: ["index", "b", "---Separator---", "a"] }),
|
|
70
|
+
);
|
|
71
|
+
await Bun.write(
|
|
72
|
+
join(root, "playbooks", "index.mdx"),
|
|
73
|
+
page("title: Playbooks\nagent:\n offer: false"),
|
|
74
|
+
);
|
|
75
|
+
await Bun.write(
|
|
76
|
+
join(root, "playbooks", "a.mdx"),
|
|
77
|
+
page('title: A\nagent:\n when: "Someone wants A"\n template: "community/a"'),
|
|
78
|
+
);
|
|
79
|
+
await Bun.write(join(root, "playbooks", "b.mdx"), page("title: B"));
|
|
80
|
+
await Bun.write(
|
|
81
|
+
join(root, "guides", "g.mdx"),
|
|
82
|
+
page("title: G\nagent:\n offer: true\n notWhen: never"),
|
|
83
|
+
);
|
|
84
|
+
await Bun.write(join(root, "releases", "r.mdx"), page("title: R"));
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
afterAll(async () => {
|
|
88
|
+
await rm(root, { recursive: true, force: true });
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("orders by meta.json, appends unlisted folders, and skips releases", async () => {
|
|
92
|
+
const pages = await collectDocsPages(root);
|
|
93
|
+
expect(pages.map((p) => p.path)).toEqual([
|
|
94
|
+
"index",
|
|
95
|
+
"playbooks/index",
|
|
96
|
+
"playbooks/b",
|
|
97
|
+
"playbooks/a",
|
|
98
|
+
"guides/g",
|
|
99
|
+
]);
|
|
100
|
+
expect(pages.map((p) => p.slug)).toEqual([
|
|
101
|
+
"",
|
|
102
|
+
"playbooks",
|
|
103
|
+
"playbooks/b",
|
|
104
|
+
"playbooks/a",
|
|
105
|
+
"guides/g",
|
|
106
|
+
]);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("a playbook without when, and an offered guide without when, fail coverage", async () => {
|
|
110
|
+
const errors = coverageErrors(await collectDocsPages(root));
|
|
111
|
+
expect(errors).toEqual([
|
|
112
|
+
"playbooks/b: playbook needs agent.when (or agent.offer: false)",
|
|
113
|
+
"guides/g: agent.offer is true but agent.when is missing",
|
|
114
|
+
]);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("renders triggers and templates for offered pages only", async () => {
|
|
118
|
+
const index = renderDocsIndex(await collectDocsPages(root));
|
|
119
|
+
expect(index).toContain(
|
|
120
|
+
"- [A](https://docs.agent-swarm.dev/docs/playbooks/a). When: Someone wants A. Template: `community/a`.",
|
|
121
|
+
);
|
|
122
|
+
expect(index).toContain(
|
|
123
|
+
"- [Playbooks](https://docs.agent-swarm.dev/docs/playbooks). Reference.",
|
|
124
|
+
);
|
|
125
|
+
expect(index).toContain("- [Home](https://docs.agent-swarm.dev/docs)");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("replaceIndexBlock swaps only the marked block and rejects missing markers", () => {
|
|
129
|
+
const content = `before\n${INDEX_START}\nold\n${INDEX_END}\nafter\n`;
|
|
130
|
+
expect(replaceIndexBlock(content, `${INDEX_START}\nnew\n${INDEX_END}`)).toBe(
|
|
131
|
+
`before\n${INDEX_START}\nnew\n${INDEX_END}\nafter\n`,
|
|
132
|
+
);
|
|
133
|
+
expect(() => replaceIndexBlock("no markers", "x")).toThrow();
|
|
134
|
+
});
|
|
135
|
+
});
|
|
@@ -376,7 +376,7 @@ describe("dsh harness", () => {
|
|
|
376
376
|
await rename(join(config.cwd, "dsh"), join(config.cwd, "npx"));
|
|
377
377
|
config.env = { ...config.env, DSH_BINARY: "", PATH: config.cwd };
|
|
378
378
|
await expect(new DshAdapter().createSession(config)).rejects.toThrow(
|
|
379
|
-
"dsh CLI not found. Install @deepseek-ai/dsh@0.2.1-rc.
|
|
379
|
+
"dsh CLI not found. Install @deepseek-ai/dsh@0.2.1-rc.4",
|
|
380
380
|
);
|
|
381
381
|
expect(await Bun.file(join(config.cwd, "invocation.json")).exists()).toBe(false);
|
|
382
382
|
});
|
|
@@ -41,8 +41,12 @@ const defaultMockResponse: MockResponse = {
|
|
|
41
41
|
const mockResponsesByAgentId = new Map<string, MockResponse>();
|
|
42
42
|
const requestedRepoIds = new Map<string, string | null>();
|
|
43
43
|
const requestedTaskIds = new Map<string, string | null>();
|
|
44
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* Every `GET /api/keys/available` query string observed by the mock server,
|
|
46
|
+
* most recent last, without `keySuffixes` (recorded in `keysAvailableSuffixes`).
|
|
47
|
+
*/
|
|
45
48
|
const keysAvailableRequests: string[] = [];
|
|
49
|
+
const keysAvailableSuffixes: Array<string | null> = [];
|
|
46
50
|
|
|
47
51
|
beforeAll(() => {
|
|
48
52
|
server = Bun.serve({
|
|
@@ -63,7 +67,10 @@ beforeAll(() => {
|
|
|
63
67
|
}
|
|
64
68
|
|
|
65
69
|
if (url.pathname === "/api/keys/available") {
|
|
66
|
-
|
|
70
|
+
const params = new URLSearchParams(url.search);
|
|
71
|
+
keysAvailableSuffixes.push(params.get("keySuffixes"));
|
|
72
|
+
params.delete("keySuffixes");
|
|
73
|
+
keysAvailableRequests.push(`?${params}`);
|
|
67
74
|
const totalKeys = Number(url.searchParams.get("totalKeys") ?? "1");
|
|
68
75
|
const availableIndices = Array.from({ length: totalKeys }, (_, i) => i);
|
|
69
76
|
return Response.json({ success: true, availableIndices, totalKeys });
|
|
@@ -455,6 +462,13 @@ describe("fetchResolvedEnv", () => {
|
|
|
455
462
|
expect(keysAvailableRequests).toEqual([
|
|
456
463
|
"?keyType=CLAUDE_CODE_OAUTH_TOKEN&totalKeys=32&model=fable",
|
|
457
464
|
]);
|
|
465
|
+
// The pool's own suffixes, in order, so status is matched by key (issue #2425).
|
|
466
|
+
expect(keysAvailableSuffixes.at(-1)).toBe(
|
|
467
|
+
Object.values(envWithOauthPool(32))[0]!
|
|
468
|
+
.split(",")
|
|
469
|
+
.map((key) => key.slice(-5))
|
|
470
|
+
.join(","),
|
|
471
|
+
);
|
|
458
472
|
});
|
|
459
473
|
|
|
460
474
|
test("model: 'claude-fable-5-1' issues the same model=fable", async () => {
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
upsertSwarmConfig,
|
|
25
25
|
} from "../be/db";
|
|
26
26
|
import { getScript, upsertScriptByName } from "../be/scripts/db";
|
|
27
|
+
import * as savedScriptRunner from "../be/scripts/run-saved";
|
|
27
28
|
import { runSavedScriptAsAgent } from "../be/scripts/run-saved";
|
|
28
29
|
import {
|
|
29
30
|
checkHeartbeatChecklist,
|
|
@@ -624,6 +625,66 @@ describe("Heartbeat Checklist", () => {
|
|
|
624
625
|
});
|
|
625
626
|
});
|
|
626
627
|
|
|
628
|
+
for (const quiet of [true, false]) {
|
|
629
|
+
test(`healthy probe preserves the saved gate quiet=${quiet} verdict`, async () => {
|
|
630
|
+
process.env.HEARTBEAT_CHECKLIST_GATE_MODE = "enforce";
|
|
631
|
+
await saveGlobalGateScript("quiet-gate", "export default () => ({ quiet: true });");
|
|
632
|
+
const saved = spyOn(savedScriptRunner, "runSavedScriptAsAgent").mockResolvedValue({
|
|
633
|
+
exitCode: 0,
|
|
634
|
+
result: { quiet, reason: "existing verdict" },
|
|
635
|
+
stdout: "",
|
|
636
|
+
stderr: "",
|
|
637
|
+
durationMs: 1,
|
|
638
|
+
truncated: { stdout: false, stderr: false },
|
|
639
|
+
});
|
|
640
|
+
try {
|
|
641
|
+
setChecklistGateRunnerForTests((name, agentId, signal) =>
|
|
642
|
+
defaultChecklistGateRunner(name, agentId, signal, { probe: async () => null }),
|
|
643
|
+
);
|
|
644
|
+
await seedLead();
|
|
645
|
+
await checkHeartbeatChecklist();
|
|
646
|
+
expect(saved).toHaveBeenCalledTimes(1);
|
|
647
|
+
const tasks = await checklistTasks();
|
|
648
|
+
expect(tasks).toHaveLength(quiet ? 0 : 1);
|
|
649
|
+
if (!quiet) expect(tasks[0]!.tags).toContain("heartbeat-gate:wake");
|
|
650
|
+
} finally {
|
|
651
|
+
saved.mockRestore();
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
for (const mode of ["shadow", "enforce"] as const) {
|
|
657
|
+
test(`${mode}: an unhealthy probe produces a wake checklist`, async () => {
|
|
658
|
+
process.env.HEARTBEAT_CHECKLIST_GATE_MODE = mode;
|
|
659
|
+
setChecklistGateRunnerForTests((name, agentId, signal) =>
|
|
660
|
+
defaultChecklistGateRunner(name, agentId, signal, {
|
|
661
|
+
probe: async () => ({ quiet: false, reason: "script-runner-unhealthy: timeout" }),
|
|
662
|
+
}),
|
|
663
|
+
);
|
|
664
|
+
await seedLead();
|
|
665
|
+
await checkHeartbeatChecklist();
|
|
666
|
+
const tasks = await checklistTasks();
|
|
667
|
+
expect(tasks).toHaveLength(1);
|
|
668
|
+
expect(tasks[0]!.tags).toContain("heartbeat-gate:wake");
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
test("a bug in the probe produces wake rather than error", async () => {
|
|
673
|
+
process.env.HEARTBEAT_CHECKLIST_GATE_MODE = "enforce";
|
|
674
|
+
setChecklistGateRunnerForTests((name, agentId, signal) =>
|
|
675
|
+
defaultChecklistGateRunner(name, agentId, signal, {
|
|
676
|
+
probe: async () => {
|
|
677
|
+
throw new Error("probe bug");
|
|
678
|
+
},
|
|
679
|
+
}),
|
|
680
|
+
);
|
|
681
|
+
await seedLead();
|
|
682
|
+
await checkHeartbeatChecklist();
|
|
683
|
+
const tasks = await checklistTasks();
|
|
684
|
+
expect(tasks).toHaveLength(1);
|
|
685
|
+
expect(tasks[0]!.tags).toContain("heartbeat-gate:wake");
|
|
686
|
+
});
|
|
687
|
+
|
|
627
688
|
test("shadow mode (default): a quiet verdict still creates the task, tagged quiet", async () => {
|
|
628
689
|
useGate({ quiet: true, reason: "no change", summary: "nothing moved" });
|
|
629
690
|
const lead = await seedLead();
|
|
@@ -703,6 +764,9 @@ describe("Heartbeat Checklist", () => {
|
|
|
703
764
|
test("enforce mode: a missing global script fails open through the real runner", async () => {
|
|
704
765
|
process.env.HEARTBEAT_CHECKLIST_GATE_MODE = "enforce";
|
|
705
766
|
process.env.HEARTBEAT_CHECKLIST_GATE_SCRIPT = "no-such-gate-script";
|
|
767
|
+
setChecklistGateRunnerForTests((name, agentId, signal) =>
|
|
768
|
+
defaultChecklistGateRunner(name, agentId, signal, { probe: async () => null }),
|
|
769
|
+
);
|
|
706
770
|
await seedLead();
|
|
707
771
|
|
|
708
772
|
await checkHeartbeatChecklist();
|
|
@@ -739,6 +803,7 @@ describe("Heartbeat Checklist", () => {
|
|
|
739
803
|
let run: Promise<unknown> | undefined;
|
|
740
804
|
setChecklistGateRunnerForTests((scriptName, leadAgentId, signal) => {
|
|
741
805
|
run = defaultChecklistGateRunner(scriptName, leadAgentId, signal, {
|
|
806
|
+
probe: async () => null,
|
|
742
807
|
buildCredentials: () => {
|
|
743
808
|
setupStarted.resolve();
|
|
744
809
|
return new Promise((resolve) => {
|
|
@@ -798,6 +863,9 @@ describe("Heartbeat Checklist", () => {
|
|
|
798
863
|
"export default async function run() { await new Promise((r) => setTimeout(r, 30_000)); return { quiet: true }; }",
|
|
799
864
|
);
|
|
800
865
|
setChecklistGateTimeoutMsForTests(1_500);
|
|
866
|
+
setChecklistGateRunnerForTests((name, agentId, signal) =>
|
|
867
|
+
defaultChecklistGateRunner(name, agentId, signal, { probe: async () => null }),
|
|
868
|
+
);
|
|
801
869
|
await seedLead();
|
|
802
870
|
|
|
803
871
|
const started = Date.now();
|
|
@@ -808,6 +876,27 @@ describe("Heartbeat Checklist", () => {
|
|
|
808
876
|
expect(tasks).toHaveLength(1);
|
|
809
877
|
expect(tasks[0]!.tags).toContain("heartbeat-gate:error");
|
|
810
878
|
});
|
|
879
|
+
|
|
880
|
+
test("enforce mode: the real probe passes with no swarm API key, using the Lead's run token", async () => {
|
|
881
|
+
process.env.HEARTBEAT_CHECKLIST_GATE_MODE = "enforce";
|
|
882
|
+
await saveGlobalGateScript("quiet-gate", "export default () => ({ quiet: true });");
|
|
883
|
+
const savedKeys = { swarm: process.env.AGENT_SWARM_API_KEY, legacy: process.env.API_KEY };
|
|
884
|
+
process.env.AGENT_SWARM_API_KEY = "";
|
|
885
|
+
process.env.API_KEY = "";
|
|
886
|
+
try {
|
|
887
|
+
await seedLead();
|
|
888
|
+
await checkHeartbeatChecklist();
|
|
889
|
+
expect(await checklistTasks()).toHaveLength(0);
|
|
890
|
+
} finally {
|
|
891
|
+
for (const [name, value] of [
|
|
892
|
+
["AGENT_SWARM_API_KEY", savedKeys.swarm],
|
|
893
|
+
["API_KEY", savedKeys.legacy],
|
|
894
|
+
] as const) {
|
|
895
|
+
if (value === undefined) delete process.env[name];
|
|
896
|
+
else process.env[name] = value;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
});
|
|
811
900
|
});
|
|
812
901
|
|
|
813
902
|
// ==========================================================================
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
|
2
|
+
import { probeScriptRunner } from "../heartbeat/script-runner-probe";
|
|
3
|
+
import type { RunScriptOutput } from "../scripts-runtime/loader";
|
|
4
|
+
|
|
5
|
+
const ok: RunScriptOutput = {
|
|
6
|
+
result: true,
|
|
7
|
+
stdout: "",
|
|
8
|
+
stderr: "",
|
|
9
|
+
truncated: { stdout: false, stderr: false },
|
|
10
|
+
durationMs: 1,
|
|
11
|
+
exitCode: 0,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
afterEach(() => mock.restore());
|
|
15
|
+
|
|
16
|
+
describe("heartbeat script runner probe", () => {
|
|
17
|
+
test("success leaves the gate verdict unchanged and clears its deadline", async () => {
|
|
18
|
+
const clear = spyOn(globalThis, "clearTimeout");
|
|
19
|
+
const runner = mock(async () => ok);
|
|
20
|
+
expect(await probeScriptRunner("lead", runner)).toBeNull();
|
|
21
|
+
expect(runner).toHaveBeenCalledWith({
|
|
22
|
+
source: "export default async function () { return true; }",
|
|
23
|
+
agentId: "lead",
|
|
24
|
+
fsMode: "none",
|
|
25
|
+
timeoutMs: 10_000,
|
|
26
|
+
signal: expect.any(AbortSignal),
|
|
27
|
+
});
|
|
28
|
+
expect(clear).toHaveBeenCalledTimes(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("timeout wakes without waiting for a stalled runner and aborts it", async () => {
|
|
32
|
+
let expire: (() => void) | undefined;
|
|
33
|
+
spyOn(globalThis, "setTimeout").mockImplementation(((callback: () => void, ms: number) => {
|
|
34
|
+
expect(ms).toBe(10_000);
|
|
35
|
+
expire = callback;
|
|
36
|
+
return 1;
|
|
37
|
+
}) as typeof setTimeout);
|
|
38
|
+
const clear = spyOn(globalThis, "clearTimeout").mockImplementation(() => {});
|
|
39
|
+
let signal: AbortSignal | undefined;
|
|
40
|
+
let rejectRun: ((error: Error) => void) | undefined;
|
|
41
|
+
const result = probeScriptRunner("lead", async (input) => {
|
|
42
|
+
signal = input.signal;
|
|
43
|
+
return new Promise<RunScriptOutput>((_, reject) => {
|
|
44
|
+
rejectRun = reject;
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
expire!();
|
|
48
|
+
expect(await result).toEqual({
|
|
49
|
+
quiet: false,
|
|
50
|
+
reason: "script-runner-unhealthy: timeout after 10000ms",
|
|
51
|
+
});
|
|
52
|
+
expect(signal?.aborted).toBe(true);
|
|
53
|
+
expect(clear).toHaveBeenCalledTimes(1);
|
|
54
|
+
// A runner that rejects after the deadline remains handled by Promise.race.
|
|
55
|
+
rejectRun!(new Error("late rejection"));
|
|
56
|
+
await Promise.resolve();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("a thrown runner error wakes", async () => {
|
|
60
|
+
expect(
|
|
61
|
+
await probeScriptRunner("lead", async () => {
|
|
62
|
+
throw new Error("ECONNRESET");
|
|
63
|
+
}),
|
|
64
|
+
).toEqual({
|
|
65
|
+
quiet: false,
|
|
66
|
+
reason: "script-runner-unhealthy: ECONNRESET",
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
for (const [label, failure, reason] of [
|
|
71
|
+
["nonzero exit", { exitCode: 1 }, "script exited with code 1"],
|
|
72
|
+
["runner error", { error: "killed" }, "killed"],
|
|
73
|
+
["runtime error", { runtimeError: { message: "runtime failed" } }, "runtime failed"],
|
|
74
|
+
] as const) {
|
|
75
|
+
test(`${label} wakes`, async () => {
|
|
76
|
+
const result = await probeScriptRunner(
|
|
77
|
+
"lead",
|
|
78
|
+
async () => ({ ...ok, ...failure }) as RunScriptOutput,
|
|
79
|
+
);
|
|
80
|
+
expect(result).toEqual({ quiet: false, reason: `script-runner-unhealthy: ${reason}` });
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
});
|
|
@@ -8,7 +8,11 @@ import {
|
|
|
8
8
|
storeOAuthTokens,
|
|
9
9
|
upsertOAuthApp,
|
|
10
10
|
} from "../be/db-queries/oauth";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
startOAuthRefreshSweep,
|
|
13
|
+
stopOAuthRefreshSweep,
|
|
14
|
+
sweepOAuthTokenRefresh,
|
|
15
|
+
} from "../be/oauth-refresh-sweep";
|
|
12
16
|
|
|
13
17
|
async function authorizationStatus(provider: string): Promise<string | undefined> {
|
|
14
18
|
const id = await getDefaultAuthorizationIdForProvider(provider);
|
|
@@ -242,3 +246,32 @@ describe("sweepOAuthTokenRefresh", () => {
|
|
|
242
246
|
expect((await getOAuthTokens("vendor_a"))?.accessToken).toBe("example-new-access-token");
|
|
243
247
|
});
|
|
244
248
|
});
|
|
249
|
+
|
|
250
|
+
describe("startOAuthRefreshSweep", () => {
|
|
251
|
+
const originalDisable = process.env.OAUTH_REFRESH_SWEEP_DISABLE;
|
|
252
|
+
|
|
253
|
+
afterAll(async () => {
|
|
254
|
+
await stopOAuthRefreshSweep();
|
|
255
|
+
if (originalDisable === undefined) {
|
|
256
|
+
delete process.env.OAUTH_REFRESH_SWEEP_DISABLE;
|
|
257
|
+
} else {
|
|
258
|
+
process.env.OAUTH_REFRESH_SWEEP_DISABLE = originalDisable;
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
for (const val of ["true", "1", "TRUE"]) {
|
|
263
|
+
test(`does not start when OAUTH_REFRESH_SWEEP_DISABLE=${val}`, async () => {
|
|
264
|
+
process.env.OAUTH_REFRESH_SWEEP_DISABLE = val;
|
|
265
|
+
const logs: string[] = [];
|
|
266
|
+
const origLog = console.log;
|
|
267
|
+
console.log = (...args: unknown[]) => logs.push(args.map(String).join(" "));
|
|
268
|
+
try {
|
|
269
|
+
startOAuthRefreshSweep();
|
|
270
|
+
} finally {
|
|
271
|
+
console.log = origLog;
|
|
272
|
+
await stopOAuthRefreshSweep();
|
|
273
|
+
}
|
|
274
|
+
expect(logs).toEqual(["[oauth-refresh-sweep] Disabled via OAUTH_REFRESH_SWEEP_DISABLE"]);
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
});
|
|
@@ -136,6 +136,26 @@ describe("generateCompose", () => {
|
|
|
136
136
|
expect(yaml).not.toContain("pull_policy: always");
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
+
test.each([
|
|
140
|
+
"docker",
|
|
141
|
+
"podman",
|
|
142
|
+
] as const)("%s agent services mount volumes where the worker reads and writes", (containerEngine) => {
|
|
143
|
+
const doc = Bun.YAML.parse(generateCompose({ ...devState, containerEngine })) as {
|
|
144
|
+
services: Record<string, { volumes?: string[] }>;
|
|
145
|
+
};
|
|
146
|
+
const agents = Object.entries(doc.services).filter(([name]) => name !== "swarm-api");
|
|
147
|
+
|
|
148
|
+
expect(agents.map(([name]) => name)).toEqual(["lead", "worker-coder-1", "worker-coder-2"]);
|
|
149
|
+
for (const [name, service] of agents) {
|
|
150
|
+
expect(service.volumes).toEqual([
|
|
151
|
+
"swarm_logs:/logs",
|
|
152
|
+
"swarm_shared:/workspace/shared",
|
|
153
|
+
`swarm_${name.replaceAll("-", "_")}:/workspace/personal`,
|
|
154
|
+
]);
|
|
155
|
+
}
|
|
156
|
+
expect(doc.services["swarm-api"]?.volumes).toEqual(["swarm_data:/app/data"]);
|
|
157
|
+
});
|
|
158
|
+
|
|
139
159
|
test("dev preset produces 3 agent services + 1 API service", () => {
|
|
140
160
|
const yaml = generateCompose(devState);
|
|
141
161
|
// Only count service definitions in the services section (before volumes:)
|
|
@@ -675,7 +675,7 @@ describe("OpencodeSession — persisted rows render in the dashboard", () => {
|
|
|
675
675
|
const { items } = normalizeSessionLogs(rows);
|
|
676
676
|
|
|
677
677
|
const visible = items
|
|
678
|
-
.filter((item) => item.kind !== "lifecycle")
|
|
678
|
+
.filter((item) => item.kind !== "lifecycle" && item.kind !== "call_usage")
|
|
679
679
|
.map((item) =>
|
|
680
680
|
item.kind === "text"
|
|
681
681
|
? `${item.role} text: ${item.text}`
|
|
@@ -695,6 +695,7 @@ describe("OpencodeSession — persisted rows render in the dashboard", () => {
|
|
|
695
695
|
"system text: opencode error: provider overloaded",
|
|
696
696
|
]);
|
|
697
697
|
expect(items.some((item) => item.kind === "unknown")).toBe(false);
|
|
698
|
+
expect(items.filter((item) => item.kind === "call_usage")).toHaveLength(1);
|
|
698
699
|
});
|
|
699
700
|
});
|
|
700
701
|
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `PAGES_FRAME_ANCESTORS` widens `frame-ancestors` on `/p/:id` for PUBLIC
|
|
3
|
+
* pages only, so an outside site (for example a blog) can iframe a chart.
|
|
4
|
+
* Authed and password pages keep the app-origin-only header, and an unset
|
|
5
|
+
* env leaves every header unchanged.
|
|
6
|
+
*/
|
|
7
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
8
|
+
import crypto from "node:crypto";
|
|
9
|
+
import { unlink } from "node:fs/promises";
|
|
10
|
+
import {
|
|
11
|
+
createServer as createHttpServer,
|
|
12
|
+
type IncomingMessage,
|
|
13
|
+
type Server,
|
|
14
|
+
type ServerResponse,
|
|
15
|
+
} from "node:http";
|
|
16
|
+
import { closeDb, initDb } from "../be/db";
|
|
17
|
+
import { handlePages } from "../http/pages";
|
|
18
|
+
import { getPageFrameAncestors, handlePagesPublic } from "../http/pages-public";
|
|
19
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
20
|
+
import { signPageSession } from "../utils/page-session";
|
|
21
|
+
import { listenOnFreePort } from "./test-net";
|
|
22
|
+
|
|
23
|
+
const TEST_DB_PATH = "./test-pages-frame-ancestors.sqlite";
|
|
24
|
+
const EMBEDDERS = "https://www.agent-swarm.dev https://agent-swarm.dev";
|
|
25
|
+
let BASE = "";
|
|
26
|
+
|
|
27
|
+
function createTestServer(): Server {
|
|
28
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
29
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
30
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
31
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
32
|
+
if (await handlePagesPublic(req, res, pathSegments, queryParams)) return;
|
|
33
|
+
if (await handlePages(req, res, pathSegments, queryParams, myAgentId)) return;
|
|
34
|
+
res.writeHead(404);
|
|
35
|
+
res.end("not found");
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function frameAncestors(res: Response): string {
|
|
40
|
+
const csp = res.headers.get("content-security-policy") ?? "";
|
|
41
|
+
return csp.split(";").find((d) => d.trim().startsWith("frame-ancestors ")) ?? "";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe("PAGES_FRAME_ANCESTORS on /p/:id", () => {
|
|
45
|
+
let server: Server;
|
|
46
|
+
const agentId = crypto.randomUUID();
|
|
47
|
+
const headers = { "Content-Type": "application/json", "X-Agent-ID": agentId };
|
|
48
|
+
const ORIG = {
|
|
49
|
+
PAGES_FRAME_ANCESTORS: process.env.PAGES_FRAME_ANCESTORS,
|
|
50
|
+
APP_URL: process.env.APP_URL,
|
|
51
|
+
DASHBOARD_URL: process.env.DASHBOARD_URL,
|
|
52
|
+
PAGE_SESSION_SECRET: process.env.PAGE_SESSION_SECRET,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
function restore(key: keyof typeof ORIG): void {
|
|
56
|
+
if (ORIG[key] === undefined) delete process.env[key];
|
|
57
|
+
else process.env[key] = ORIG[key];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function createPage(
|
|
61
|
+
slug: string,
|
|
62
|
+
authMode: "public" | "authed" | "password",
|
|
63
|
+
): Promise<string> {
|
|
64
|
+
const post = await fetch(`${BASE}/api/pages`, {
|
|
65
|
+
method: "POST",
|
|
66
|
+
headers,
|
|
67
|
+
body: JSON.stringify({
|
|
68
|
+
slug,
|
|
69
|
+
title: slug,
|
|
70
|
+
contentType: "text/html",
|
|
71
|
+
authMode,
|
|
72
|
+
...(authMode === "password" ? { password: "swordfish" } : {}),
|
|
73
|
+
body: "<!doctype html><html><head></head><body><h1>chart</h1></body></html>",
|
|
74
|
+
}),
|
|
75
|
+
});
|
|
76
|
+
expect(post.status).toBe(201);
|
|
77
|
+
const { id } = (await post.json()) as { id: string };
|
|
78
|
+
return id;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
beforeAll(async () => {
|
|
82
|
+
process.env.PAGE_SESSION_SECRET = "example-test-frame-ancestors-secret";
|
|
83
|
+
process.env.APP_URL = "https://app.example.test";
|
|
84
|
+
delete process.env.DASHBOARD_URL;
|
|
85
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
86
|
+
try {
|
|
87
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
88
|
+
} catch {}
|
|
89
|
+
}
|
|
90
|
+
initDb(TEST_DB_PATH);
|
|
91
|
+
server = createTestServer();
|
|
92
|
+
const port = await listenOnFreePort(server);
|
|
93
|
+
BASE = `http://localhost:${port}`;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
afterEach(() => restore("PAGES_FRAME_ANCESTORS"));
|
|
97
|
+
|
|
98
|
+
afterAll(async () => {
|
|
99
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
100
|
+
closeDb();
|
|
101
|
+
for (const key of Object.keys(ORIG) as (keyof typeof ORIG)[]) restore(key);
|
|
102
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
103
|
+
try {
|
|
104
|
+
await unlink(`${TEST_DB_PATH}${suffix}`);
|
|
105
|
+
} catch {}
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("public page gets the configured origins", async () => {
|
|
110
|
+
process.env.PAGES_FRAME_ANCESTORS = EMBEDDERS;
|
|
111
|
+
const id = await createPage("public-embed", "public");
|
|
112
|
+
const res = await fetch(`${BASE}/p/${id}`);
|
|
113
|
+
expect(res.status).toBe(200);
|
|
114
|
+
const fa = frameAncestors(res);
|
|
115
|
+
expect(fa).toContain("'self'");
|
|
116
|
+
expect(fa).toContain("https://app.example.test");
|
|
117
|
+
expect(fa).toContain("https://www.agent-swarm.dev");
|
|
118
|
+
expect(fa).toContain("https://agent-swarm.dev");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("authed page keeps the app-origin-only header", async () => {
|
|
122
|
+
const id = await createPage("authed-embed", "authed");
|
|
123
|
+
const exp = Math.floor(Date.now() / 1000) + 3600;
|
|
124
|
+
const cookie = `page_session_${id}=${await signPageSession({ pageId: id, exp })}`;
|
|
125
|
+
|
|
126
|
+
const before = frameAncestors(await fetch(`${BASE}/p/${id}`, { headers: { cookie } }));
|
|
127
|
+
process.env.PAGES_FRAME_ANCESTORS = EMBEDDERS;
|
|
128
|
+
const res = await fetch(`${BASE}/p/${id}`, { headers: { cookie } });
|
|
129
|
+
expect(res.status).toBe(200);
|
|
130
|
+
expect(frameAncestors(res)).toBe(before);
|
|
131
|
+
expect(frameAncestors(res)).not.toContain("agent-swarm.dev");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test("password page keeps the app-origin-only header", async () => {
|
|
135
|
+
process.env.PAGES_FRAME_ANCESTORS = EMBEDDERS;
|
|
136
|
+
const id = await createPage("password-embed", "password");
|
|
137
|
+
const res = await fetch(`${BASE}/p/${id}?key=swordfish`);
|
|
138
|
+
expect(res.status).toBe(200);
|
|
139
|
+
expect(frameAncestors(res)).not.toContain("agent-swarm.dev");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test("unset env leaves the public header unchanged", async () => {
|
|
143
|
+
const id = await createPage("public-unset", "public");
|
|
144
|
+
delete process.env.PAGES_FRAME_ANCESTORS;
|
|
145
|
+
const unset = frameAncestors(await fetch(`${BASE}/p/${id}`));
|
|
146
|
+
process.env.PAGES_FRAME_ANCESTORS = "";
|
|
147
|
+
const blank = frameAncestors(await fetch(`${BASE}/p/${id}`));
|
|
148
|
+
expect(unset).toContain("https://app.example.test");
|
|
149
|
+
expect(unset).not.toContain("agent-swarm.dev");
|
|
150
|
+
expect(blank).toBe(unset);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("parser accepts comma or space lists and drops invalid entries", () => {
|
|
154
|
+
process.env.PAGES_FRAME_ANCESTORS =
|
|
155
|
+
"https://www.agent-swarm.dev/, https://*.example.com:8443 blog.example.org,* 'none' https://evil.test;script-src javascript:alert(1) https://x.test/path";
|
|
156
|
+
expect(getPageFrameAncestors()).toEqual([
|
|
157
|
+
"https://www.agent-swarm.dev",
|
|
158
|
+
"https://*.example.com:8443",
|
|
159
|
+
"blog.example.org",
|
|
160
|
+
]);
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -104,7 +104,8 @@ describe("PiMonoAdapter.createSession — codemode", () => {
|
|
|
104
104
|
process.env.OPENROUTER_BASE_URL = "http://127.0.0.1:9/api/v1";
|
|
105
105
|
delete process.env.PI_CODEMODE;
|
|
106
106
|
delete process.env.PI_CODEMODE_MODELS;
|
|
107
|
-
|
|
107
|
+
// Deferral off, so every swarm tool shows up as an active tool name.
|
|
108
|
+
process.env.PI_TOOL_DEFERRAL = "false";
|
|
108
109
|
});
|
|
109
110
|
afterEach(() => {
|
|
110
111
|
for (const key of envKeys) {
|