@desplega.ai/agent-swarm 2.6.0 → 2.7.1
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-8dn5tq56.js} +6 -6
- package/dist/{actions-fbqma8aa.js → actions-3r6gw7dy.js} +18 -18
- package/dist/{amp-adapter-en0jy6hs.js → amp-adapter-wjtq413p.js} +3 -3
- package/dist/{anthropic-messages-txs190dd.js → anthropic-messages-n4cby1rz.js} +4 -4
- package/dist/{app-k8a5cgpp.js → app-d53yhykk.js} +3 -3
- package/dist/{assistant-a16433gr.js → assistant-ksrawj3k.js} +20 -20
- package/dist/{boot-reembed-6c1sjfnr.js → boot-reembed-fxfw17m2.js} +11 -3
- package/dist/{boot-reembed-f4nn561n.js → boot-reembed-k5y5xbj0.js} +15 -8
- package/dist/{boot-scrub-sweep-d4w9gajf.js → boot-scrub-sweep-se41j9fd.js} +3 -3
- package/dist/{boot-seeds-9bmkspyw.js → boot-seeds-r3kwwv8b.js} +4 -4
- package/dist/{claude-adapter-nys1kjxa.js → claude-adapter-ahmyf231.js} +8 -8
- package/dist/{claude-managed-adapter-55207973.js → claude-managed-adapter-5knamegc.js} +3 -3
- package/dist/{claude-managed-setup-3cm35gey.js → claude-managed-setup-mdc7dy6b.js} +3 -3
- package/dist/{claude-sdk-session-nw5yd8cp.js → claude-sdk-session-n5wen9zk.js} +8729 -8600
- package/dist/{cli-683y0b3a.js → cli-0dqtv2jg.js} +2 -2
- package/dist/{cli-a34pr3w1.js → cli-0tww7dnz.js} +2 -2
- package/dist/{cli-w2656ka1.js → cli-0w4e4v58.js} +7 -7
- package/dist/{cli-5sckpqcx.js → cli-2sjm3t4r.js} +1 -1
- package/dist/{cli-gf2850q7.js → cli-3cywb44q.js} +18 -1
- package/dist/{cli-8wkb09q5.js → cli-3k9jej0c.js} +2 -2
- package/dist/{cli-3aqd4jaa.js → cli-3v2cyqb8.js} +3 -3
- package/dist/{cli-emhjcj2y.js → cli-5qt9vzmq.js} +89 -20
- package/dist/{cli-abkm71vr.js → cli-5rp1w4cr.js} +1 -1
- package/dist/{cli-gf95q3bm.js → cli-6vwjsetx.js} +4 -4
- package/dist/{cli-k21gx5ht.js → cli-6w2yrrc0.js} +10 -10
- package/dist/{cli-m70bajzr.js → cli-75tdydgv.js} +1 -1
- package/dist/{cli-5rj83bam.js → cli-8feswf2v.js} +47 -36
- package/dist/{cli-sxgtf5fw.js → cli-8wd3mbyj.js} +1 -1
- package/dist/{cli-ca6banwz.js → cli-9csfthbw.js} +10 -8
- package/dist/{cli-dqygeeh5.js → cli-b41p764a.js} +31 -3
- package/dist/{cli-m5a9m0ak.js → cli-c1t533hh.js} +3 -3
- package/dist/{cli-2sjhpsns.js → cli-c28jxpcv.js} +1 -1
- package/dist/{cli-bqztfyve.js → cli-cb50g9cd.js} +1 -1
- package/dist/{cli-ag2yrnt5.js → cli-d8gaphtr.js} +2 -2
- package/dist/{cli-9abdgn5t.js → cli-dgtfysne.js} +3 -3
- package/dist/{cli-nq677csy.js → cli-dscnhn18.js} +1 -1
- package/dist/{cli-3fwqy71j.js → cli-dv11pgpv.js} +39 -32
- package/dist/{cli-v49fkghp.js → cli-ekptgk2m.js} +6 -6
- package/dist/{cli-c2habvsf.js → cli-f3mzy662.js} +17 -17
- package/dist/{cli-zqne1qcj.js → cli-fk7k8s9x.js} +2 -2
- package/dist/{cli-1tem6ns9.js → cli-gt4h8cxw.js} +3 -2
- package/dist/{cli-vbhgkw9h.js → cli-h3e7bhvn.js} +3 -3
- package/dist/{cli-ffvsj2qw.js → cli-hkxqdmnb.js} +3 -3
- package/dist/{cli-a2rr0fmg.js → cli-hpkvgdad.js} +7 -4
- package/dist/{cli-art7c1zh.js → cli-hq2v4rr5.js} +37 -9
- package/dist/{cli-sqma6vw1.js → cli-k1zb7raw.js} +5 -5
- package/dist/{cli-4rvr7zfx.js → cli-k4awgvzm.js} +5 -0
- package/dist/{cli-3ndmpxxv.js → cli-k7d3hvya.js} +2 -2
- package/dist/{cli-3gfvmzht.js → cli-kbtdftbw.js} +74 -26
- package/dist/{cli-hfeb68z4.js → cli-kj69b9tw.js} +34 -6
- package/dist/{cli-az25fn5f.js → cli-kk9jy4nm.js} +2 -2
- package/dist/{cli-39aet655.js → cli-m52t4cta.js} +1 -1
- package/dist/{cli-cz37jqss.js → cli-mdadewqp.js} +5 -5
- package/dist/{cli-esakngh8.js → cli-nf6k60sn.js} +1 -1
- package/dist/{cli-j6rktc91.js → cli-nzdf09js.js} +1 -1
- package/dist/{cli-rr7zx19j.js → cli-prcyzecg.js} +12 -12
- package/dist/{cli-4hxrf0p4.js → cli-pzafyqtk.js} +393 -62
- package/dist/{cli-g8ka5j4j.js → cli-qvaa1gjp.js} +173 -52
- package/dist/{cli-d2dpf95n.js → cli-rg4b2g6j.js} +1 -1
- package/dist/{cli-kf4jree9.js → cli-rm66vqdz.js} +1 -1
- package/dist/{cli-4rf0f5vg.js → cli-s504c3w7.js} +5 -3
- package/dist/{cli-nn6vx2mq.js → cli-swkx9jm2.js} +7 -5
- package/dist/{cli-g20jjwt4.js → cli-thsaq4pd.js} +16 -16
- package/dist/{cli-aj8djyqt.js → cli-twjnyvs8.js} +2 -2
- package/dist/{cli-03gg9pd1.js → cli-typdpe8g.js} +4 -4
- package/dist/{cli-mtnj4fwa.js → cli-vg8pwmsd.js} +6 -6
- package/dist/{cli-gnsb1zxa.js → cli-vhpckzvp.js} +1 -1
- package/dist/{cli-7enjwya9.js → cli-vz78s5wm.js} +1 -1
- package/dist/{cli-dv97kdzv.js → cli-wdxgsdz5.js} +2 -2
- package/dist/{cli-7a6p4e65.js → cli-wtjg6je3.js} +1 -1
- package/dist/{cli-6y51ryva.js → cli-x3cngb1h.js} +44 -12
- package/dist/{cli-n7asmce6.js → cli-xbz6mcg4.js} +3 -3
- package/dist/{cli-24j1jeeb.js → cli-y4tdxsqb.js} +3 -3
- package/dist/{cli-mvjz5an3.js → cli-yqrsp3ny.js} +7 -7
- package/dist/cli.js +31 -21
- package/dist/{codex-adapter-d95q4qsn.js → codex-adapter-7wepzq15.js} +6 -6
- package/dist/{codex-hook-brpnycn5.js → codex-hook-pbdh4xhx.js} +2 -2
- package/dist/{codex-session-runner-gdwd8h0c.js → codex-session-runner-02sb49e4.js} +6 -6
- package/dist/{commands-jqmgk54b.js → commands-3rcspxct.js} +2 -2
- package/dist/{controller-z3jq589f.js → controller-8ar4avd6.js} +3 -3
- package/dist/{controllers-connect-r2za39ef.js → controllers-connect-ajvh9h0v.js} +19 -19
- package/dist/{cursor-adapter-4mr4jgkw.js → cursor-adapter-gd0sa7bh.js} +1 -1
- package/dist/{db-w36wwb5g.js → db-dv9j7ts5.js} +2 -2
- package/dist/{db-ppv2tze7.js → db-z26vgdvj.js} +3 -3
- package/dist/{dispatcher-pv5h77vf.js → dispatcher-8m0htjq8.js} +17 -17
- package/dist/{dsh-adapter-d4pdpb3r.js → dsh-adapter-y8gyk130.js} +1 -1
- package/dist/{e2b-fn3zacvt.js → e2b-74vg9b7e.js} +1 -1
- package/dist/{e2b-222hsajc.js → e2b-jyay8wzn.js} +1 -1
- package/dist/{easter-egg-3d-4sp4dvmc.js → easter-egg-3d-28f880xx.js} +1 -1
- package/dist/{execute-jev839y1.js → execute-9seb79vh.js} +1 -1
- package/dist/{external-channel-y6nkzhnn.js → external-channel-smz7jyth.js} +2 -2
- package/dist/{getMachineId-bsd-brn0xym3.js → getMachineId-bsd-whfre6gr.js} +3 -3
- package/dist/{getMachineId-darwin-t339sfxm.js → getMachineId-darwin-5zjynb19.js} +3 -3
- package/dist/{getMachineId-linux-5eq95d8b.js → getMachineId-linux-8mksrrsq.js} +3 -3
- package/dist/{getMachineId-unsupported-qavrtc7j.js → getMachineId-unsupported-fxtqsdt2.js} +3 -3
- package/dist/{getMachineId-win-q7fe1wx7.js → getMachineId-win-nzfmz857.js} +3 -3
- package/dist/{grok-adapter-t08b9ntm.js → grok-adapter-z0nnvtbn.js} +7 -7
- package/dist/handlers-pspryh6g.js +41 -0
- package/dist/{heartbeat-vn31xwdk.js → heartbeat-f928aqyp.js} +20 -20
- package/dist/{hook-cpjs8q51.js → hook-d7c605r4.js} +7 -7
- package/dist/{hook-6d0zbvvt.js → hook-q9hv5xf9.js} +5 -5
- package/dist/{http-9c6medda.js → http-q6j4k989.js} +1000 -251
- package/dist/{index-k0yt1tvq.js → index-5nb266jr.js} +17 -17
- package/dist/{index-ysd45kgc.js → index-8fxt5eah.js} +10 -10
- package/dist/{index-zgvqzjsw.js → index-cekzwhez.js} +5 -5
- package/dist/{index-38t35h0n.js → index-ch0c1xff.js} +17 -17
- package/dist/{index-pkma1yf8.js → index-ebnjhxh1.js} +1 -1
- package/dist/{index-yqgbfgaj.js → index-ed8dy8rn.js} +20 -20
- package/dist/{index-qhjf2htm.js → index-ejqdkkqx.js} +262 -96
- package/dist/{index-dr94zbnh.js → index-farm1zsn.js} +32661 -29443
- package/dist/{index-g6k9tctj.js → index-yn1yxg85.js} +1 -1
- package/dist/{keepalive-em0117bp.js → keepalive-zzc3hks1.js} +3 -3
- package/dist/lead-c0pjcaw4.js +79 -0
- package/dist/{maintenance-kbme3qy9.js → maintenance-kqsxzpzx.js} +1 -1
- package/dist/{oauth-refresh-sweep-xysj4yse.js → oauth-refresh-sweep-7qt0g55z.js} +8 -3
- package/dist/{onboard-p6q0vz97.js → onboard-k7x13fqg.js} +121 -8
- package/dist/{opencode-adapter-esv3k9yn.js → opencode-adapter-tqvrtter.js} +5 -5
- package/dist/{otel-impl-54qhqh1z.js → otel-impl-sqkf6k3b.js} +97 -97
- package/dist/{pi-mono-adapter-mymbwhj0.js → pi-mono-adapter-m2qnafxm.js} +89 -14
- package/dist/{pricing-refresh-yb75wv33.js → pricing-refresh-wkpwhgvs.js} +3 -3
- package/dist/{rbac-roles-dexhc9x8.js → rbac-roles-dpn0c0wj.js} +7 -4
- package/dist/{rbac-roles-1vp2mw4p.js → rbac-roles-wk7xd6a6.js} +2 -2
- package/dist/{render-v2-tjay5hhf.js → render-v2-ex2m4g2k.js} +3 -3
- package/dist/{resume-8ayf37g5.js → resume-725g9b0b.js} +7 -7
- package/dist/{runtime-hqe5cqcd.js → runtime-kajas15g.js} +1 -1
- package/dist/{session-access-fdhp987s.js → session-access-0j2ezjje.js} +8 -4
- package/dist/{session-source-backfill-qgtc86z1.js → session-source-backfill-62202awe.js} +2 -2
- package/dist/{setup-4rrmqcq3.js → setup-9sj11ax6.js} +1 -1
- package/dist/{virtual-modules-52km00d6.js → virtual-modules-c6fcq6zr.js} +2 -2
- package/dist/{work-objects-p28fgd70.js → work-objects-0y07495y.js} +20 -20
- package/dist/worker-d4q9sqbc.js +79 -0
- package/openapi.json +1536 -407
- package/package.json +7 -5
- package/src/be/automation-preflight.ts +2 -0
- package/src/be/connector-codes.ts +65 -22
- 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/boot-reembed.ts +4 -1
- package/src/be/memory/constants.ts +6 -0
- package/src/be/memory/embedding-recovery.ts +48 -0
- package/src/be/memory/index-content.ts +5 -2
- package/src/be/memory/providers/openai-embedding.ts +65 -14
- package/src/be/memory/task-learning.ts +257 -0
- package/src/be/memory/types.ts +13 -2
- package/src/be/migrations/245_script_executor.sql +3 -0
- package/src/be/migrations/246_connector_code_kind.sql +11 -0
- package/src/be/oauth-refresh-sweep.ts +2 -1
- package/src/be/onboarding.ts +125 -15
- package/src/be/rbac-roles.ts +4 -1
- package/src/be/scripts/boot-reembed.ts +2 -0
- package/src/be/scripts/db.ts +14 -4
- package/src/be/scripts/embeddings.ts +4 -2
- package/src/be/scripts/run-saved.ts +1 -0
- package/src/be/scripts/typecheck.ts +9 -3
- package/src/be/seed/agent-fs-provision.ts +75 -14
- package/src/be/seed-skills/index.ts +7 -0
- package/src/be/swarm-config-guard.ts +3 -0
- package/src/cli.tsx +9 -0
- package/src/comb/config.ts +6 -1
- package/src/commands/onboard/agent-fs-version.ts +5 -0
- package/src/commands/onboard/compose-generator.ts +117 -4
- package/src/commands/onboard/env-generator.ts +9 -0
- package/src/commands/onboard/tei-image.ts +12 -0
- package/src/commands/onboard/types.ts +7 -0
- package/src/commands/onboard.tsx +11 -1
- package/src/commands/provider-credentials.ts +2 -1
- package/src/commands/runner.ts +14 -1
- package/src/heartbeat/heartbeat.ts +14 -1
- package/src/heartbeat/script-runner-probe.ts +47 -0
- package/src/http/agentfs-proxy.ts +369 -0
- package/src/http/all-routes.ts +1 -0
- package/src/http/api-keys.ts +24 -9
- package/src/http/codex-oauth-device.ts +8 -1
- package/src/http/fs.ts +64 -10
- package/src/http/idempotency-key.ts +1 -1
- package/src/http/inbox-state.ts +27 -1
- package/src/http/index.ts +7 -4
- package/src/http/memory.ts +7 -4
- package/src/http/onboarding.ts +139 -11
- package/src/http/pages-public.ts +38 -5
- package/src/http/scripts.ts +27 -0
- package/src/http/session-access.ts +24 -0
- package/src/http/sessions.ts +10 -6
- package/src/http/tasks.ts +85 -11
- package/src/http/telemetry-ui.ts +1 -1
- package/src/http/user-roles.ts +4 -1
- package/src/http/users.ts +272 -3
- package/src/http/utils.ts +22 -3
- package/src/http/workflows.ts +10 -0
- package/src/http/x.ts +5 -0
- package/src/prompts/base-prompt.ts +45 -10
- package/src/prompts/session-templates.ts +43 -5
- package/src/providers/amp-adapter.ts +1 -1
- package/src/providers/codex-adapter.ts +6 -1
- package/src/providers/cursor-adapter.ts +1 -1
- package/src/providers/dsh-adapter.ts +1 -1
- package/src/providers/pi-mono-adapter.ts +99 -14
- package/src/rbac/legacy-policy.ts +1 -0
- package/src/rbac/permissions.ts +4 -0
- 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/agent-fs-provision-seeder.test.ts +261 -92
- package/src/tests/agentfs-proxy.test.ts +1106 -0
- package/src/tests/api-key-status-by-suffix.test.ts +242 -0
- package/src/tests/base-prompt.test.ts +139 -4
- package/src/tests/codex-adapter.test.ts +46 -8
- package/src/tests/codex-oauth-device.test.ts +12 -1
- package/src/tests/comb-config-guard.test.ts +20 -0
- package/src/tests/comb-links.test.ts +79 -1
- package/src/tests/comb-status.test.ts +44 -0
- 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-footprint.test.js +3 -3
- 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/fixtures/onboard/baseline-states.ts +68 -0
- package/src/tests/fixtures/onboard/content.compose.yml.txt +190 -0
- package/src/tests/fixtures/onboard/content.env.txt +29 -0
- package/src/tests/fixtures/onboard/dev.compose.yml.txt +155 -0
- package/src/tests/fixtures/onboard/dev.env.txt +28 -0
- package/src/tests/fixtures/onboard/full-all.compose.yml.txt +582 -0
- package/src/tests/fixtures/onboard/full-all.env.txt +59 -0
- package/src/tests/fixtures/onboard/full.compose.yml.txt +435 -0
- package/src/tests/fixtures/onboard/full.env.txt +36 -0
- package/src/tests/fixtures/onboard/research.compose.yml.txt +155 -0
- package/src/tests/fixtures/onboard/research.env.txt +28 -0
- package/src/tests/fixtures/onboard/solo.compose.yml.txt +85 -0
- package/src/tests/fixtures/onboard/solo.env.txt +26 -0
- package/src/tests/heartbeat-checklist.test.ts +89 -0
- package/src/tests/heartbeat-script-runner-probe.test.ts +83 -0
- package/src/tests/http-log-scrubbing.test.ts +34 -1
- package/src/tests/http-task-write-ownership.test.ts +5 -1
- package/src/tests/invites-http.test.ts +464 -0
- package/src/tests/memory-embedding-input-cap.test.ts +122 -1
- package/src/tests/memory-embedding-recovery.test.ts +137 -0
- package/src/tests/oauth-refresh-sweep.test.ts +34 -1
- package/src/tests/onboard-compose.test.ts +162 -0
- package/src/tests/onboard-env.test.ts +24 -0
- package/src/tests/onboard-non-interactive.test.ts +29 -0
- package/src/tests/onboard-tei-image.test.ts +36 -0
- package/src/tests/onboarding.test.ts +451 -4
- package/src/tests/openai-embedding-provider.test.ts +105 -2
- 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-mono-session-cost-on-abort.test.ts +270 -0
- package/src/tests/pi-native-mcp.test.ts +13 -1
- package/src/tests/pi-tool-exposure.test.ts +8 -8
- package/src/tests/preload-env.test.ts +4 -0
- package/src/tests/preload.ts +4 -0
- package/src/tests/probe-tool-schema-tokens.test.ts +144 -0
- package/src/tests/prompt-template-session.test.ts +20 -3
- package/src/tests/rbac-admission-e2e.test.ts +22 -6
- package/src/tests/rbac-admission.test.ts +342 -4
- package/src/tests/rbac-engine.test.ts +1 -0
- package/src/tests/rbac-roles.test.ts +9 -1
- 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/send-task-requester-gate.test.ts +64 -3
- package/src/tests/sessions-move.test.ts +39 -1
- package/src/tests/store-progress-learning.test.ts +381 -0
- package/src/tests/ui-logs-parser.test.ts +180 -0
- package/src/tests/whoami-route.test.ts +41 -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/inject-learning.ts +1 -1
- package/src/tools/memory-edit.ts +1 -1
- package/src/tools/memory-search.ts +1 -1
- 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 +71 -1
- package/src/tools/utils.ts +12 -1
- package/src/tools/workflows/trigger-workflow.ts +104 -10
- package/src/types.ts +12 -1
- package/src/utils/constants.ts +39 -0
- package/src/utils/credentials.ts +5 -2
- package/src/utils/task-attachment-links.ts +15 -3
- 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
- package/dist/lead-23e7z59t.js +0 -79
- package/dist/worker-e3w7vye1.js +0 -79
- package/dist/{azure-openai-responses-9kfwhmn7.js → azure-openai-responses-43asa23s.js} +0 -0
- package/dist/{node-vxx896fa.js → node-t28cc56b.js} +0 -0
- package/dist/{openai-codex-responses-79s8xnbh.js → openai-codex-responses-jhr3sjtm.js} +3 -3
- package/dist/{openai-completions-tdbyts0x.js → openai-completions-gs6yn37k.js} +3 -3
- /package/dist/{openai-responses-dm3t5vqp.js → openai-responses-jqm9vwwk.js} +0 -0
|
@@ -0,0 +1,1106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /agentfs/* pass-through proxy: spawns a real agent-fs server (from source,
|
|
3
|
+
* local-disk storage, embeddings off) and a real `src/http.ts`, then drives
|
|
4
|
+
* agent-fs only through the proxy. Timings print as `[proxy-timing] {...}` lines.
|
|
5
|
+
*
|
|
6
|
+
* agent-fs source: AGENT_FS_SRC (default ../agent-fs next to the swarm checkout).
|
|
7
|
+
* That suite is skipped when the checkout or its node_modules is missing (CI has none). The suites after it
|
|
8
|
+
* use an in-process fake agent-fs and always run.
|
|
9
|
+
*/
|
|
10
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
11
|
+
import { existsSync } from "node:fs";
|
|
12
|
+
import { mkdir, rm } from "node:fs/promises";
|
|
13
|
+
import { createServer, request as httpRequest, type Server } from "node:http";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import type { Subprocess } from "bun";
|
|
17
|
+
import { closeDb, createUser, initDb } from "../be/db";
|
|
18
|
+
import { mintToken } from "../be/users";
|
|
19
|
+
import { _resetAgentFsRegisterRateLimitForTests, handleAgentFsProxy } from "../http/agentfs-proxy";
|
|
20
|
+
import { setCorsHeaders } from "../http/utils";
|
|
21
|
+
import {
|
|
22
|
+
getFreePort,
|
|
23
|
+
listenOnFreePort,
|
|
24
|
+
PORT_IN_USE_LOG,
|
|
25
|
+
PortInUseError,
|
|
26
|
+
SERVER_BOOT_HOOK_TIMEOUT_MS,
|
|
27
|
+
startOnFreePort,
|
|
28
|
+
waitForServer,
|
|
29
|
+
} from "./test-net";
|
|
30
|
+
import { runChild } from "./test-proc";
|
|
31
|
+
|
|
32
|
+
const AGENT_FS_SRC = process.env.AGENT_FS_SRC ?? join(import.meta.dir, "../../../agent-fs");
|
|
33
|
+
const HAVE_AGENT_FS =
|
|
34
|
+
existsSync(join(AGENT_FS_SRC, "packages/server/src/index.ts")) &&
|
|
35
|
+
existsSync(join(AGENT_FS_SRC, "node_modules"));
|
|
36
|
+
const ORIGIN = "http://localhost:5274";
|
|
37
|
+
const MB = 1024 * 1024;
|
|
38
|
+
|
|
39
|
+
const SWARM_KEY = "example-agentfs-proxy-swarm-key";
|
|
40
|
+
let dir = "";
|
|
41
|
+
let swarmUrl = "";
|
|
42
|
+
let afUrl = "";
|
|
43
|
+
let swarmProc: Subprocess | undefined;
|
|
44
|
+
let afProc: Subprocess | undefined;
|
|
45
|
+
let afKey = "";
|
|
46
|
+
let orgId = "";
|
|
47
|
+
let driveId = "";
|
|
48
|
+
const timings: Record<string, number | string> = {};
|
|
49
|
+
|
|
50
|
+
async function rssMb(pid: number): Promise<number> {
|
|
51
|
+
const out = (await runChild(["ps", "-o", "rss=", "-p", String(pid)], { timeoutMs: 5000 })).stdout;
|
|
52
|
+
return Number.parseInt(out.trim(), 10) / 1024;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function auth(extra: Record<string, string> = {}): Record<string, string> {
|
|
56
|
+
return { Authorization: `Bearer ${afKey}`, ...extra };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function rawUrl(base: string, file: string): string {
|
|
60
|
+
return `${base}/orgs/${orgId}/drives/${driveId}/files/${encodeURIComponent(file)}/raw`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe.skipIf(!HAVE_AGENT_FS)("/agentfs proxy against a real agent-fs", () => {
|
|
64
|
+
beforeAll(async () => {
|
|
65
|
+
dir = join(tmpdir(), `afo-proxy-test-${Date.now()}`);
|
|
66
|
+
await mkdir(join(dir, "home"), { recursive: true });
|
|
67
|
+
const [afPort, swarmPort] = [await getFreePort(), await getFreePort()];
|
|
68
|
+
afUrl = `http://127.0.0.1:${afPort}`;
|
|
69
|
+
swarmUrl = `http://127.0.0.1:${swarmPort}`;
|
|
70
|
+
|
|
71
|
+
afProc = Bun.spawn(["bun", "packages/server/src/index.ts"], {
|
|
72
|
+
cwd: AGENT_FS_SRC,
|
|
73
|
+
env: {
|
|
74
|
+
...process.env,
|
|
75
|
+
AGENT_FS_HOME: join(dir, "home"),
|
|
76
|
+
AGENT_FS_STORAGE_PROVIDER: "local",
|
|
77
|
+
AGENT_FS_LOCAL_ROOT: join(dir, "blobs"),
|
|
78
|
+
AGENT_FS_MAX_UPLOAD_BYTES: String(400 * MB),
|
|
79
|
+
// An explicit provider without a key makes agent-fs skip embeddings (no download).
|
|
80
|
+
EMBEDDING_PROVIDER: "openai",
|
|
81
|
+
OPENAI_API_KEY: "",
|
|
82
|
+
PORT: String(afPort),
|
|
83
|
+
HOST: "127.0.0.1",
|
|
84
|
+
},
|
|
85
|
+
stdout: "ignore",
|
|
86
|
+
stderr: "ignore",
|
|
87
|
+
});
|
|
88
|
+
await waitForServer(`${afUrl}/health`);
|
|
89
|
+
|
|
90
|
+
swarmProc = Bun.spawn(["bun", "src/http.ts"], {
|
|
91
|
+
cwd: `${import.meta.dir}/../..`,
|
|
92
|
+
env: {
|
|
93
|
+
...process.env,
|
|
94
|
+
PORT: String(swarmPort),
|
|
95
|
+
DATABASE_PATH: join(dir, "swarm.sqlite"),
|
|
96
|
+
API_KEY: SWARM_KEY,
|
|
97
|
+
AGENT_SWARM_API_KEY: SWARM_KEY,
|
|
98
|
+
MCP_BASE_URL: swarmUrl,
|
|
99
|
+
DESPLEGA_TELEMETRY_ENV: "test",
|
|
100
|
+
SLACK_DISABLE: "true",
|
|
101
|
+
GITHUB_DISABLE: "true",
|
|
102
|
+
JIRA_DISABLE: "true",
|
|
103
|
+
LINEAR_DISABLE: "true",
|
|
104
|
+
AGENT_FS_API_URL: afUrl,
|
|
105
|
+
AGENT_FS_PROXY: "true",
|
|
106
|
+
},
|
|
107
|
+
stdout: "ignore",
|
|
108
|
+
stderr: "ignore",
|
|
109
|
+
});
|
|
110
|
+
await waitForServer(`${swarmUrl}/health`);
|
|
111
|
+
|
|
112
|
+
// Register through the proxy: it needs a swarm credential (the operator key here).
|
|
113
|
+
const reg = await fetch(`${swarmUrl}/agentfs/auth/register`, {
|
|
114
|
+
method: "POST",
|
|
115
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${SWARM_KEY}` },
|
|
116
|
+
body: JSON.stringify({ email: `proxy-${Date.now()}@example.test` }),
|
|
117
|
+
});
|
|
118
|
+
expect(reg.status).toBe(200);
|
|
119
|
+
const body = (await reg.json()) as { apiKey: string; orgId: string };
|
|
120
|
+
afKey = body.apiKey;
|
|
121
|
+
orgId = body.orgId;
|
|
122
|
+
|
|
123
|
+
const me = await fetch(`${swarmUrl}/agentfs/orgs/${orgId}/drives`, { headers: auth() });
|
|
124
|
+
expect(me.status).toBe(200);
|
|
125
|
+
const drives = (await me.json()) as { drives: Array<{ id: string; isDefault?: boolean }> };
|
|
126
|
+
driveId = (drives.drives.find((d) => d.isDefault) ?? drives.drives[0]).id;
|
|
127
|
+
|
|
128
|
+
// A 200 MB fixture, written straight to agent-fs so the GET check is isolated.
|
|
129
|
+
const t = performance.now();
|
|
130
|
+
const put = await fetch(rawUrl(afUrl, "big-200mb.bin"), {
|
|
131
|
+
method: "PUT",
|
|
132
|
+
headers: auth({ "If-None-Match": "*" }),
|
|
133
|
+
body: new Uint8Array(200 * MB).fill(7),
|
|
134
|
+
});
|
|
135
|
+
expect(put.status).toBeLessThan(300);
|
|
136
|
+
timings.fixture200mbDirectPutMs = Math.round(performance.now() - t);
|
|
137
|
+
}, SERVER_BOOT_HOOK_TIMEOUT_MS * 2);
|
|
138
|
+
|
|
139
|
+
afterAll(async () => {
|
|
140
|
+
console.log(`[proxy-timing] ${JSON.stringify(timings)}`);
|
|
141
|
+
swarmProc?.kill();
|
|
142
|
+
afProc?.kill();
|
|
143
|
+
await swarmProc?.exited;
|
|
144
|
+
await afProc?.exited;
|
|
145
|
+
if (dir) await rm(dir, { recursive: true, force: true });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("ops POST works through the proxy, with no swarm bearer", async () => {
|
|
149
|
+
const t = performance.now();
|
|
150
|
+
const w = await fetch(`${swarmUrl}/agentfs/orgs/${orgId}/ops`, {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: auth({ "Content-Type": "application/json" }),
|
|
153
|
+
body: JSON.stringify({ op: "write", path: "/hello.md", content: "hi from the proxy" }),
|
|
154
|
+
});
|
|
155
|
+
expect(w.status).toBe(200);
|
|
156
|
+
const c = await fetch(`${swarmUrl}/agentfs/orgs/${orgId}/ops`, {
|
|
157
|
+
method: "POST",
|
|
158
|
+
headers: auth({ "Content-Type": "application/json" }),
|
|
159
|
+
body: JSON.stringify({ op: "cat", path: "/hello.md" }),
|
|
160
|
+
});
|
|
161
|
+
expect(c.status).toBe(200);
|
|
162
|
+
expect(JSON.stringify(await c.json())).toContain("hi from the proxy");
|
|
163
|
+
timings.opsRoundTripsMs = Math.round(performance.now() - t);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("a request with no credentials gets agent-fs's own 401", async () => {
|
|
167
|
+
const res = await fetch(`${swarmUrl}/agentfs/auth/me`);
|
|
168
|
+
expect(res.status).toBe(401);
|
|
169
|
+
expect(((await res.json()) as { error: string }).error).toBe("UNAUTHORIZED");
|
|
170
|
+
const bad = await fetch(`${swarmUrl}/agentfs/auth/me`, {
|
|
171
|
+
headers: { Authorization: "Bearer af_not-a-real-key" },
|
|
172
|
+
});
|
|
173
|
+
expect(bad.status).toBe(401);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("CORS preflight from the dashboard origin passes, with one ACAO value", async () => {
|
|
177
|
+
const pre = await fetch(`${swarmUrl}/agentfs/orgs/${orgId}/ops`, {
|
|
178
|
+
method: "OPTIONS",
|
|
179
|
+
headers: {
|
|
180
|
+
Origin: ORIGIN,
|
|
181
|
+
"Access-Control-Request-Method": "POST",
|
|
182
|
+
"Access-Control-Request-Headers": "authorization,content-type,x-agent-fs-message",
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
expect(pre.status).toBe(204);
|
|
186
|
+
expect(pre.headers.get("access-control-allow-origin")).toBe(ORIGIN);
|
|
187
|
+
expect(pre.headers.get("access-control-allow-headers")).toContain("x-agent-fs-message");
|
|
188
|
+
|
|
189
|
+
// agent-fs itself answers ACAO "*"; the proxy must not leak it next to the swarm's echo.
|
|
190
|
+
const get = await fetch(`${swarmUrl}/agentfs/auth/me`, {
|
|
191
|
+
headers: auth({ Origin: ORIGIN }),
|
|
192
|
+
});
|
|
193
|
+
expect(get.status).toBe(200);
|
|
194
|
+
expect(get.headers.get("access-control-allow-origin")).toBe(ORIGIN);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("X-Agent-FS-* headers reach the client and are exposed to the browser", async () => {
|
|
198
|
+
const res = await fetch(rawUrl(`${swarmUrl}/agentfs`, "hello.md"), {
|
|
199
|
+
headers: auth({ Origin: ORIGIN }),
|
|
200
|
+
});
|
|
201
|
+
expect(res.status).toBe(200);
|
|
202
|
+
expect(await res.text()).toContain("hi from the proxy");
|
|
203
|
+
expect(res.headers.get("x-agent-fs-version")).toBeTruthy();
|
|
204
|
+
expect(res.headers.get("etag")).toBeTruthy();
|
|
205
|
+
const expose = res.headers.get("access-control-expose-headers") ?? "";
|
|
206
|
+
expect(expose.toLowerCase()).toContain("x-agent-fs-version");
|
|
207
|
+
expect(expose.toLowerCase()).toContain("x-agent-fs-content-hash");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("a 50 MB upload streams through, with a 413 untouched when over the cap", async () => {
|
|
211
|
+
const t = performance.now();
|
|
212
|
+
const put = await fetch(rawUrl(`${swarmUrl}/agentfs`, "up-50mb.bin"), {
|
|
213
|
+
method: "PUT",
|
|
214
|
+
headers: auth({ "If-None-Match": "*", "X-Agent-FS-Message": "spike upload" }),
|
|
215
|
+
body: new Uint8Array(50 * MB).fill(3),
|
|
216
|
+
});
|
|
217
|
+
expect(put.status).toBeLessThan(300);
|
|
218
|
+
timings.upload50mbMs = Math.round(performance.now() - t);
|
|
219
|
+
const back = await fetch(rawUrl(afUrl, "up-50mb.bin"), { headers: auth() });
|
|
220
|
+
expect((await back.arrayBuffer()).byteLength).toBe(50 * MB);
|
|
221
|
+
}, 60_000);
|
|
222
|
+
|
|
223
|
+
// Runs first, on a fresh API process: buffering the file would grow RSS by its size.
|
|
224
|
+
test("a stalled client applies backpressure: the API does not buffer the file", async () => {
|
|
225
|
+
const pid = swarmProc?.pid ?? 0;
|
|
226
|
+
const before = await rssMb(pid);
|
|
227
|
+
const res = await fetch(rawUrl(`${swarmUrl}/agentfs`, "big-200mb.bin"), { headers: auth() });
|
|
228
|
+
const reader = (res.body as ReadableStream<Uint8Array>).getReader();
|
|
229
|
+
await reader.read();
|
|
230
|
+
await Bun.sleep(3000); // read nothing more while the proxy would be free to buffer
|
|
231
|
+
const stalled = await rssMb(pid);
|
|
232
|
+
await reader.cancel();
|
|
233
|
+
timings.stalledClientRssGrowthMb = Math.round(stalled - before);
|
|
234
|
+
expect(stalled - before).toBeLessThan(100);
|
|
235
|
+
}, 30_000);
|
|
236
|
+
|
|
237
|
+
test("a 200 MB raw GET streams: early first byte, no memory kept per download", async () => {
|
|
238
|
+
const pid = swarmProc?.pid ?? 0;
|
|
239
|
+
const readAll = async () => {
|
|
240
|
+
const t0 = performance.now();
|
|
241
|
+
const res = await fetch(rawUrl(`${swarmUrl}/agentfs`, "big-200mb.bin"), {
|
|
242
|
+
headers: auth(),
|
|
243
|
+
});
|
|
244
|
+
const headersMs = performance.now() - t0;
|
|
245
|
+
expect(res.status).toBe(200);
|
|
246
|
+
const reader = (res.body as ReadableStream<Uint8Array>).getReader();
|
|
247
|
+
let bytes = 0;
|
|
248
|
+
let firstByteMs = 0;
|
|
249
|
+
for (;;) {
|
|
250
|
+
const { done, value } = await reader.read();
|
|
251
|
+
if (done) break;
|
|
252
|
+
if (bytes === 0) firstByteMs = performance.now() - t0;
|
|
253
|
+
bytes += value.byteLength;
|
|
254
|
+
}
|
|
255
|
+
return { bytes, headersMs, firstByteMs, totalMs: performance.now() - t0 };
|
|
256
|
+
};
|
|
257
|
+
const before = await rssMb(pid);
|
|
258
|
+
let peak = before;
|
|
259
|
+
const sampler = setInterval(() => {
|
|
260
|
+
void rssMb(pid).then((mb) => {
|
|
261
|
+
peak = Math.max(peak, mb);
|
|
262
|
+
});
|
|
263
|
+
}, 25);
|
|
264
|
+
const run1 = await readAll();
|
|
265
|
+
const peak1 = peak;
|
|
266
|
+
const run2 = await readAll();
|
|
267
|
+
await Bun.sleep(100);
|
|
268
|
+
clearInterval(sampler);
|
|
269
|
+
|
|
270
|
+
// Direct baseline against agent-fs for the same file.
|
|
271
|
+
const d0 = performance.now();
|
|
272
|
+
const direct = await fetch(rawUrl(afUrl, "big-200mb.bin"), { headers: auth() });
|
|
273
|
+
const dr = (direct.body as ReadableStream<Uint8Array>).getReader();
|
|
274
|
+
const first = await dr.read();
|
|
275
|
+
const directFirstByteMs = performance.now() - d0;
|
|
276
|
+
await dr.cancel();
|
|
277
|
+
expect(first.value?.byteLength).toBeGreaterThan(0);
|
|
278
|
+
|
|
279
|
+
expect(run1.bytes).toBe(200 * MB);
|
|
280
|
+
expect(run2.bytes).toBe(200 * MB);
|
|
281
|
+
Object.assign(timings, {
|
|
282
|
+
get200mbHeadersMs: Math.round(run1.headersMs),
|
|
283
|
+
get200mbFirstByteMs: Math.round(run1.firstByteMs),
|
|
284
|
+
get200mbTotalMs: Math.round(run1.totalMs),
|
|
285
|
+
directFirstByteMs: Math.round(directFirstByteMs),
|
|
286
|
+
apiRssBeforeMb: Math.round(before),
|
|
287
|
+
apiRssPeakMb: Math.round(peak1),
|
|
288
|
+
apiRssGrowthMb: Math.round(peak1 - before),
|
|
289
|
+
apiRssSecondDownloadGrowthMb: Math.round(peak - peak1),
|
|
290
|
+
});
|
|
291
|
+
// A fast reader can raise RSS by up to the file size before the GC catches up
|
|
292
|
+
// (seen on Linux, not on macOS). A copy the proxy kept would add the whole
|
|
293
|
+
// 200 MB again on the second download, so stay well under the file size; the
|
|
294
|
+
// stalled-client test above is the buffering check.
|
|
295
|
+
expect(peak - peak1).toBeLessThan(150);
|
|
296
|
+
}, 120_000);
|
|
297
|
+
|
|
298
|
+
test("the events SSE stream survives 20 s of silence", async () => {
|
|
299
|
+
const ac = new AbortController();
|
|
300
|
+
const t0 = performance.now();
|
|
301
|
+
const res = await fetch(`${swarmUrl}/agentfs/orgs/${orgId}/drives/${driveId}/events`, {
|
|
302
|
+
headers: auth({ Accept: "text/event-stream" }),
|
|
303
|
+
signal: ac.signal,
|
|
304
|
+
});
|
|
305
|
+
expect(res.status).toBe(200);
|
|
306
|
+
expect(res.headers.get("content-type")).toContain("text/event-stream");
|
|
307
|
+
const reader = (res.body as ReadableStream<Uint8Array>).getReader();
|
|
308
|
+
const decoder = new TextDecoder();
|
|
309
|
+
let text = "";
|
|
310
|
+
let firstChunkMs = -1;
|
|
311
|
+
const deadline = t0 + 21_000;
|
|
312
|
+
while (performance.now() < deadline) {
|
|
313
|
+
const next = await Promise.race([
|
|
314
|
+
reader.read(),
|
|
315
|
+
Bun.sleep(Math.max(0, deadline - performance.now())).then(() => null),
|
|
316
|
+
]);
|
|
317
|
+
if (next === null) break;
|
|
318
|
+
if (next.done) throw new Error("SSE stream closed before 20 s of silence passed");
|
|
319
|
+
if (firstChunkMs < 0) firstChunkMs = performance.now() - t0;
|
|
320
|
+
text += decoder.decode(next.value);
|
|
321
|
+
}
|
|
322
|
+
ac.abort();
|
|
323
|
+
const pings = (text.match(/: ?ping/g) ?? []).length;
|
|
324
|
+
timings.sseFirstChunkMs = Math.round(firstChunkMs);
|
|
325
|
+
timings.ssePingsIn21s = pings;
|
|
326
|
+
expect(pings).toBeGreaterThanOrEqual(3);
|
|
327
|
+
}, 40_000);
|
|
328
|
+
|
|
329
|
+
test("closing an SSE client aborts the upstream stream", async () => {
|
|
330
|
+
const ac = new AbortController();
|
|
331
|
+
const res = await fetch(`${swarmUrl}/agentfs/orgs/${orgId}/drives/${driveId}/events`, {
|
|
332
|
+
headers: auth(),
|
|
333
|
+
signal: ac.signal,
|
|
334
|
+
});
|
|
335
|
+
await (res.body as ReadableStream<Uint8Array>).getReader().read();
|
|
336
|
+
ac.abort();
|
|
337
|
+
// 8 streams per user are allowed. 9 open+close cycles only succeed when each close frees its slot.
|
|
338
|
+
for (let i = 0; i < 9; i++) {
|
|
339
|
+
const c = new AbortController();
|
|
340
|
+
const r = await fetch(`${swarmUrl}/agentfs/orgs/${orgId}/drives/${driveId}/events`, {
|
|
341
|
+
headers: auth(),
|
|
342
|
+
signal: c.signal,
|
|
343
|
+
});
|
|
344
|
+
expect(r.status).toBe(200);
|
|
345
|
+
await (r.body as ReadableStream<Uint8Array>).getReader().read();
|
|
346
|
+
c.abort();
|
|
347
|
+
await Bun.sleep(100);
|
|
348
|
+
}
|
|
349
|
+
}, 30_000);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
// ─── CI-safe tests: a fake agent-fs (Bun.serve on port 0), no checkout needed ───
|
|
353
|
+
|
|
354
|
+
interface FakeAgentFs {
|
|
355
|
+
url: string;
|
|
356
|
+
/** Headers and URL of the last request the fake saw. */
|
|
357
|
+
last: { url: string; headers: Headers; method: string } | null;
|
|
358
|
+
/** The body of the last POST /auth/register. */
|
|
359
|
+
registerBody: string | null;
|
|
360
|
+
hits: number;
|
|
361
|
+
/** performance.now() when the fake wrote the last byte of /big. */
|
|
362
|
+
bigDoneAt: number;
|
|
363
|
+
/** Set when an /events stream was closed from the proxy side. */
|
|
364
|
+
sseClosed: boolean;
|
|
365
|
+
stop(): void;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const FAKE_SHARE_TOKEN = "tok_fake_share_secret_0123456789";
|
|
369
|
+
/** The swarm key in the fake-agent-fs suites (register through the proxy needs one). */
|
|
370
|
+
const FAKE_SWARM_KEY = "example-agentfs-proxy-fake-swarm-key";
|
|
371
|
+
|
|
372
|
+
function startFakeAgentFs(): FakeAgentFs {
|
|
373
|
+
const fake: FakeAgentFs = {
|
|
374
|
+
url: "",
|
|
375
|
+
last: null,
|
|
376
|
+
registerBody: null,
|
|
377
|
+
hits: 0,
|
|
378
|
+
bigDoneAt: 0,
|
|
379
|
+
sseClosed: false,
|
|
380
|
+
stop: () => {},
|
|
381
|
+
};
|
|
382
|
+
const server = Bun.serve({
|
|
383
|
+
port: 0,
|
|
384
|
+
hostname: "127.0.0.1",
|
|
385
|
+
idleTimeout: 0,
|
|
386
|
+
fetch(request) {
|
|
387
|
+
const url = new URL(request.url);
|
|
388
|
+
fake.hits++;
|
|
389
|
+
fake.last = {
|
|
390
|
+
url: url.pathname + url.search,
|
|
391
|
+
headers: new Headers(request.headers),
|
|
392
|
+
method: request.method,
|
|
393
|
+
};
|
|
394
|
+
if (url.pathname === "/auth/register" && request.method === "POST") {
|
|
395
|
+
return request.text().then((text) => {
|
|
396
|
+
fake.registerBody = text;
|
|
397
|
+
return Response.json({ apiKey: "af_new", orgId: "org-1" });
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
if (url.pathname === "/big") {
|
|
401
|
+
// 20 x 1 MB chunks, 15 ms apart: the upstream needs about 300 ms to finish.
|
|
402
|
+
const total = Number(url.searchParams.get("mb") ?? "20");
|
|
403
|
+
let sent = 0;
|
|
404
|
+
const chunk = new Uint8Array(MB).fill(5);
|
|
405
|
+
return new Response(
|
|
406
|
+
new ReadableStream({
|
|
407
|
+
async pull(controller) {
|
|
408
|
+
if (sent >= total) {
|
|
409
|
+
fake.bigDoneAt = performance.now();
|
|
410
|
+
controller.close();
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (sent > 0) await Bun.sleep(15);
|
|
414
|
+
controller.enqueue(chunk);
|
|
415
|
+
sent++;
|
|
416
|
+
},
|
|
417
|
+
}),
|
|
418
|
+
{ headers: { "Content-Type": "application/octet-stream" } },
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
if (url.pathname === "/events") {
|
|
422
|
+
const interval = Number(url.searchParams.get("interval") ?? "200");
|
|
423
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
424
|
+
const close = () => {
|
|
425
|
+
if (timer) clearInterval(timer);
|
|
426
|
+
fake.sseClosed = true;
|
|
427
|
+
};
|
|
428
|
+
request.signal.addEventListener("abort", close);
|
|
429
|
+
return new Response(
|
|
430
|
+
new ReadableStream({
|
|
431
|
+
start(controller) {
|
|
432
|
+
controller.enqueue(new TextEncoder().encode(": ping\n\n"));
|
|
433
|
+
timer = setInterval(() => {
|
|
434
|
+
try {
|
|
435
|
+
controller.enqueue(new TextEncoder().encode(": ping\n\n"));
|
|
436
|
+
} catch {
|
|
437
|
+
close();
|
|
438
|
+
}
|
|
439
|
+
}, interval);
|
|
440
|
+
},
|
|
441
|
+
cancel: close,
|
|
442
|
+
}),
|
|
443
|
+
{ headers: { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" } },
|
|
444
|
+
);
|
|
445
|
+
}
|
|
446
|
+
if (url.pathname.startsWith("/share/") || url.pathname.startsWith("/site/")) {
|
|
447
|
+
return new Response("<html>shared</html>", {
|
|
448
|
+
headers: {
|
|
449
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
450
|
+
"Access-Control-Allow-Origin": "*",
|
|
451
|
+
"Content-Security-Policy": "sandbox allow-scripts; frame-ancestors *",
|
|
452
|
+
"X-Content-Type-Options": "nosniff",
|
|
453
|
+
},
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
return new Response("ok", {
|
|
457
|
+
headers: {
|
|
458
|
+
"Content-Type": "text/plain",
|
|
459
|
+
"Access-Control-Allow-Origin": "*",
|
|
460
|
+
"X-Agent-FS-Version": "9",
|
|
461
|
+
"Set-Cookie": "af=1",
|
|
462
|
+
"X-Upstream-Internal": "secret",
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
},
|
|
466
|
+
});
|
|
467
|
+
fake.url = `http://127.0.0.1:${server.port}`;
|
|
468
|
+
fake.stop = () => server.stop(true);
|
|
469
|
+
return fake;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
async function rawStatus(base: string, path: string): Promise<number> {
|
|
473
|
+
const { port, hostname } = new URL(base);
|
|
474
|
+
return new Promise<number>((resolve, reject) => {
|
|
475
|
+
// node:http sends the path untouched, unlike fetch which normalises dot segments.
|
|
476
|
+
const r = httpRequest({ host: hostname, port, path }, (res) => {
|
|
477
|
+
res.resume();
|
|
478
|
+
resolve(res.statusCode ?? 0);
|
|
479
|
+
});
|
|
480
|
+
r.on("error", reject);
|
|
481
|
+
r.end();
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/** A POST with the path sent byte for byte (fetch would resolve `.` and `\\`), with the operator key. */
|
|
486
|
+
async function rawPostStatus(base: string, path: string, ip: string): Promise<number> {
|
|
487
|
+
const { port, hostname } = new URL(base);
|
|
488
|
+
const body = JSON.stringify({ email: "a@example.test" });
|
|
489
|
+
return new Promise<number>((resolve, reject) => {
|
|
490
|
+
const r = httpRequest(
|
|
491
|
+
{
|
|
492
|
+
host: hostname,
|
|
493
|
+
port,
|
|
494
|
+
path,
|
|
495
|
+
method: "POST",
|
|
496
|
+
headers: {
|
|
497
|
+
"Content-Type": "application/json",
|
|
498
|
+
"Content-Length": Buffer.byteLength(body),
|
|
499
|
+
"X-Forwarded-For": ip,
|
|
500
|
+
Authorization: `Bearer ${FAKE_SWARM_KEY}`,
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
(res) => {
|
|
504
|
+
res.resume();
|
|
505
|
+
resolve(res.statusCode ?? 0);
|
|
506
|
+
},
|
|
507
|
+
);
|
|
508
|
+
r.on("error", reject);
|
|
509
|
+
r.end(body);
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
describe("handleAgentFsProxy against a fake agent-fs", () => {
|
|
514
|
+
let fake: FakeAgentFs;
|
|
515
|
+
let server: Server;
|
|
516
|
+
let proxyUrl = "";
|
|
517
|
+
const saved = {
|
|
518
|
+
proxy: process.env.AGENT_FS_PROXY,
|
|
519
|
+
url: process.env.AGENT_FS_API_URL,
|
|
520
|
+
key: process.env.AGENT_SWARM_API_KEY,
|
|
521
|
+
};
|
|
522
|
+
|
|
523
|
+
beforeAll(async () => {
|
|
524
|
+
process.env.AGENT_SWARM_API_KEY = FAKE_SWARM_KEY;
|
|
525
|
+
fake = startFakeAgentFs();
|
|
526
|
+
process.env.AGENT_FS_API_URL = `${fake.url}/`;
|
|
527
|
+
// Same order as the real pipeline: the swarm's CORS first, then the proxy.
|
|
528
|
+
server = createServer(async (req, res) => {
|
|
529
|
+
setCorsHeaders(req, res);
|
|
530
|
+
if (!(await handleAgentFsProxy(req, res))) {
|
|
531
|
+
res.writeHead(404);
|
|
532
|
+
res.end("Not Found");
|
|
533
|
+
}
|
|
534
|
+
});
|
|
535
|
+
proxyUrl = `http://127.0.0.1:${await listenOnFreePort(server, "127.0.0.1")}`;
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
beforeEach(() => {
|
|
539
|
+
process.env.AGENT_FS_PROXY = "true";
|
|
540
|
+
process.env.AGENT_FS_API_URL = `${fake.url}/`;
|
|
541
|
+
_resetAgentFsRegisterRateLimitForTests();
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
afterAll(() => {
|
|
545
|
+
server.close();
|
|
546
|
+
fake.stop();
|
|
547
|
+
for (const [k, v] of [
|
|
548
|
+
["AGENT_FS_PROXY", saved.proxy],
|
|
549
|
+
["AGENT_FS_API_URL", saved.url],
|
|
550
|
+
["AGENT_SWARM_API_KEY", saved.key],
|
|
551
|
+
] as const) {
|
|
552
|
+
if (v === undefined) delete process.env[k];
|
|
553
|
+
else process.env[k] = v;
|
|
554
|
+
}
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test("flag unset or off: the handler declines on every prefix", async () => {
|
|
558
|
+
for (const value of [undefined, "false", "0"]) {
|
|
559
|
+
if (value === undefined) delete process.env.AGENT_FS_PROXY;
|
|
560
|
+
else process.env.AGENT_FS_PROXY = value;
|
|
561
|
+
const before = fake.hits;
|
|
562
|
+
for (const path of ["/agentfs/health", "/share/x", "/site/x"]) {
|
|
563
|
+
expect((await fetch(`${proxyUrl}${path}`)).status).toBe(404);
|
|
564
|
+
}
|
|
565
|
+
expect(fake.hits).toBe(before);
|
|
566
|
+
}
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
test("every boolean literal the config validator accepts turns it on", async () => {
|
|
570
|
+
for (const value of ["true", " TRUE ", "1"]) {
|
|
571
|
+
process.env.AGENT_FS_PROXY = value;
|
|
572
|
+
expect((await fetch(`${proxyUrl}/agentfs/health`)).status).toBe(200);
|
|
573
|
+
}
|
|
574
|
+
});
|
|
575
|
+
|
|
576
|
+
test("strips the prefix, keeps %2F, forwards only allowlisted headers", async () => {
|
|
577
|
+
const res = await fetch(`${proxyUrl}/agentfs/orgs/o/files/a%2Fb.md/raw?x=1`, {
|
|
578
|
+
headers: {
|
|
579
|
+
Authorization: "Bearer af_example",
|
|
580
|
+
Cookie: "session=secret",
|
|
581
|
+
"X-Agent-ID": "agent",
|
|
582
|
+
"X-Agent-FS-Message": "m",
|
|
583
|
+
"X-Agent-FS-Message-Encoding": "base64",
|
|
584
|
+
"If-Match": '"v1"',
|
|
585
|
+
Range: "bytes=0-1",
|
|
586
|
+
"X-Swarm-User-Id": "user",
|
|
587
|
+
},
|
|
588
|
+
});
|
|
589
|
+
expect(res.status).toBe(200);
|
|
590
|
+
expect(fake.last?.url).toBe("/orgs/o/files/a%2Fb.md/raw?x=1");
|
|
591
|
+
const seen = fake.last?.headers ?? new Headers();
|
|
592
|
+
expect(seen.get("authorization")).toBe("Bearer af_example");
|
|
593
|
+
expect(seen.get("x-agent-fs-message")).toBe("m");
|
|
594
|
+
expect(seen.get("x-agent-fs-message-encoding")).toBe("base64");
|
|
595
|
+
expect(seen.get("if-match")).toBe('"v1"');
|
|
596
|
+
expect(seen.get("range")).toBe("bytes=0-1");
|
|
597
|
+
expect(seen.get("cookie")).toBeNull();
|
|
598
|
+
expect(seen.get("x-agent-id")).toBeNull();
|
|
599
|
+
expect(seen.get("x-swarm-user-id")).toBeNull();
|
|
600
|
+
// Response: upstream CORS, Set-Cookie and unknown headers dropped; x-agent-fs-* kept.
|
|
601
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*"); // the swarm's own no-Origin answer
|
|
602
|
+
expect(res.headers.get("set-cookie")).toBeNull();
|
|
603
|
+
expect(res.headers.get("x-upstream-internal")).toBeNull();
|
|
604
|
+
expect(res.headers.get("x-agent-fs-version")).toBe("9");
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test("a swarm API key or an aswt_ token never reaches agent-fs", async () => {
|
|
608
|
+
for (const value of ["Bearer aswt_example-user-token", "Bearer 123123", "Basic YTpi", "af_x"]) {
|
|
609
|
+
const res = await fetch(`${proxyUrl}/agentfs/auth/me`, {
|
|
610
|
+
headers: { Authorization: value },
|
|
611
|
+
});
|
|
612
|
+
expect(res.status).toBe(200);
|
|
613
|
+
expect(fake.last?.headers.get("authorization")).toBeNull();
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
|
|
617
|
+
test("upstream CORS is replaced by the swarm's on /agentfs", async () => {
|
|
618
|
+
const res = await fetch(`${proxyUrl}/agentfs/auth/me`, {
|
|
619
|
+
headers: { Origin: ORIGIN, Authorization: "Bearer af_example" },
|
|
620
|
+
});
|
|
621
|
+
expect(res.headers.get("access-control-allow-origin")).toBe(ORIGIN);
|
|
622
|
+
expect(res.headers.get("access-control-expose-headers")?.toLowerCase()).toContain(
|
|
623
|
+
"x-agent-fs-version",
|
|
624
|
+
);
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
test("a 20 MB body streams: the first chunk arrives before the upstream finishes", async () => {
|
|
628
|
+
fake.bigDoneAt = 0;
|
|
629
|
+
const res = await fetch(`${proxyUrl}/agentfs/big?mb=20`);
|
|
630
|
+
expect(res.status).toBe(200);
|
|
631
|
+
const reader = (res.body as ReadableStream<Uint8Array>).getReader();
|
|
632
|
+
const first = await reader.read();
|
|
633
|
+
const firstAt = performance.now();
|
|
634
|
+
expect(first.value?.byteLength).toBeGreaterThan(0);
|
|
635
|
+
let bytes = first.value?.byteLength ?? 0;
|
|
636
|
+
for (;;) {
|
|
637
|
+
const { done, value } = await reader.read();
|
|
638
|
+
if (done) break;
|
|
639
|
+
bytes += value.byteLength;
|
|
640
|
+
}
|
|
641
|
+
expect(bytes).toBe(20 * MB);
|
|
642
|
+
expect(fake.bigDoneAt).toBeGreaterThan(0);
|
|
643
|
+
expect(firstAt).toBeLessThan(fake.bigDoneAt);
|
|
644
|
+
}, 30_000);
|
|
645
|
+
|
|
646
|
+
test("SSE pings keep the stream open for 2 s, and a client abort closes the upstream", async () => {
|
|
647
|
+
fake.sseClosed = false;
|
|
648
|
+
const ac = new AbortController();
|
|
649
|
+
const res = await fetch(`${proxyUrl}/agentfs/events?interval=200`, { signal: ac.signal });
|
|
650
|
+
expect(res.status).toBe(200);
|
|
651
|
+
expect(res.headers.get("content-type")).toContain("text/event-stream");
|
|
652
|
+
const reader = (res.body as ReadableStream<Uint8Array>).getReader();
|
|
653
|
+
const decoder = new TextDecoder();
|
|
654
|
+
let text = "";
|
|
655
|
+
const deadline = performance.now() + 2000;
|
|
656
|
+
while (performance.now() < deadline) {
|
|
657
|
+
const next = await Promise.race([
|
|
658
|
+
reader.read(),
|
|
659
|
+
Bun.sleep(Math.max(0, deadline - performance.now())).then(() => null),
|
|
660
|
+
]);
|
|
661
|
+
if (next === null) break;
|
|
662
|
+
if (next.done) throw new Error("SSE stream closed before 2 s passed");
|
|
663
|
+
text += decoder.decode(next.value);
|
|
664
|
+
}
|
|
665
|
+
expect((text.match(/: ping/g) ?? []).length).toBeGreaterThanOrEqual(5);
|
|
666
|
+
expect(fake.sseClosed).toBe(false);
|
|
667
|
+
ac.abort();
|
|
668
|
+
const closeDeadline = performance.now() + 3000;
|
|
669
|
+
while (!fake.sseClosed && performance.now() < closeDeadline) await Bun.sleep(25);
|
|
670
|
+
expect(fake.sseClosed).toBe(true);
|
|
671
|
+
}, 15_000);
|
|
672
|
+
|
|
673
|
+
test("/site/<token>/ with Origin: null keeps agent-fs's Access-Control-Allow-Origin: *", async () => {
|
|
674
|
+
const res = await fetch(`${proxyUrl}/site/${FAKE_SHARE_TOKEN}/`, {
|
|
675
|
+
headers: { Origin: "null" },
|
|
676
|
+
});
|
|
677
|
+
expect(res.status).toBe(200);
|
|
678
|
+
expect(fake.last?.url).toBe(`/site/${FAKE_SHARE_TOKEN}/`);
|
|
679
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
680
|
+
expect(res.headers.get("content-security-policy")).toContain("sandbox");
|
|
681
|
+
expect(res.headers.get("x-content-type-options")).toBe("nosniff");
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
test("/share/<token>/raw from an allowlisted origin still gets agent-fs's CORS, not the swarm's", async () => {
|
|
685
|
+
const res = await fetch(`${proxyUrl}/share/${FAKE_SHARE_TOKEN}/raw`, {
|
|
686
|
+
headers: { Origin: ORIGIN },
|
|
687
|
+
});
|
|
688
|
+
expect(res.status).toBe(200);
|
|
689
|
+
expect(fake.last?.url).toBe(`/share/${FAKE_SHARE_TOKEN}/raw`);
|
|
690
|
+
expect(await res.text()).toContain("shared");
|
|
691
|
+
expect(res.headers.get("access-control-allow-origin")).toBe("*");
|
|
692
|
+
expect(res.headers.get("access-control-allow-credentials")).toBeNull();
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test("/share and /site accept GET only", async () => {
|
|
696
|
+
const res = await fetch(`${proxyUrl}/share/${FAKE_SHARE_TOKEN}`, { method: "POST" });
|
|
697
|
+
expect(res.status).toBe(404);
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
test("encoded and doubled-slash register paths share the same per-IP limit", async () => {
|
|
701
|
+
const paths = [
|
|
702
|
+
"/agentfs/auth/%72egister",
|
|
703
|
+
"/agentfs//auth/register",
|
|
704
|
+
"/agentfs/auth/register/",
|
|
705
|
+
"/agentfs/auth%2Fregister",
|
|
706
|
+
"/agentfs/auth/register",
|
|
707
|
+
];
|
|
708
|
+
for (const path of paths) {
|
|
709
|
+
const res = await fetch(`${proxyUrl}${path}`, {
|
|
710
|
+
method: "POST",
|
|
711
|
+
headers: {
|
|
712
|
+
"Content-Type": "application/json",
|
|
713
|
+
"X-Forwarded-For": "203.0.113.9",
|
|
714
|
+
Authorization: `Bearer ${FAKE_SWARM_KEY}`,
|
|
715
|
+
},
|
|
716
|
+
body: JSON.stringify({ email: "a@example.test" }),
|
|
717
|
+
});
|
|
718
|
+
expect({ path, limited: res.status === 429 }).toEqual({ path, limited: false });
|
|
719
|
+
}
|
|
720
|
+
const sixth = await fetch(`${proxyUrl}/agentfs/auth/%72egister`, {
|
|
721
|
+
method: "POST",
|
|
722
|
+
headers: {
|
|
723
|
+
"Content-Type": "application/json",
|
|
724
|
+
"X-Forwarded-For": "203.0.113.9",
|
|
725
|
+
Authorization: `Bearer ${FAKE_SWARM_KEY}`,
|
|
726
|
+
},
|
|
727
|
+
body: JSON.stringify({ email: "a@example.test" }),
|
|
728
|
+
});
|
|
729
|
+
expect(sixth.status).toBe(429);
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
test("dot-segment and backslash register paths are rejected, not passed upstream", async () => {
|
|
733
|
+
const ip = "203.0.113.10";
|
|
734
|
+
for (const path of [
|
|
735
|
+
"/agentfs/./auth/register",
|
|
736
|
+
"/agentfs/auth/./register",
|
|
737
|
+
"/agentfs/%2e/auth/register",
|
|
738
|
+
"/agentfs/auth%2F.%2Fregister",
|
|
739
|
+
"/agentfs/auth\\register",
|
|
740
|
+
"/agentfs/auth%5Cregister",
|
|
741
|
+
]) {
|
|
742
|
+
fake.last = null;
|
|
743
|
+
// Seven tries: on the old code each one reached upstream /auth/register unlimited.
|
|
744
|
+
for (let i = 0; i < 7; i++) {
|
|
745
|
+
expect({ path, status: await rawPostStatus(proxyUrl, path, ip) }).toEqual({
|
|
746
|
+
path,
|
|
747
|
+
status: 400,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
expect(fake.last).toBeNull();
|
|
751
|
+
}
|
|
752
|
+
// The canonical path still has its full bucket of five.
|
|
753
|
+
for (let i = 0; i < 5; i++) {
|
|
754
|
+
expect(await rawPostStatus(proxyUrl, "/agentfs/auth/register", ip)).toBe(200);
|
|
755
|
+
}
|
|
756
|
+
expect(await rawPostStatus(proxyUrl, "/agentfs/auth/register", ip)).toBe(429);
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
test("a raw #fragment on the register path still hits the register limit", async () => {
|
|
760
|
+
for (const [path, ip] of [
|
|
761
|
+
["/agentfs/auth/register#x", "203.0.113.11"],
|
|
762
|
+
["/agentfs/auth/register?a=1#x", "203.0.113.12"],
|
|
763
|
+
] as const) {
|
|
764
|
+
// fetch() drops the fragment, so these reach upstream /auth/register.
|
|
765
|
+
for (let i = 0; i < 5; i++) {
|
|
766
|
+
expect({ path, status: await rawPostStatus(proxyUrl, path, ip) }).toEqual({
|
|
767
|
+
path,
|
|
768
|
+
status: 200,
|
|
769
|
+
});
|
|
770
|
+
expect(fake.last?.url.split("?")[0]).toBe("/auth/register");
|
|
771
|
+
}
|
|
772
|
+
for (let i = 0; i < 2; i++) {
|
|
773
|
+
fake.last = null;
|
|
774
|
+
expect({ path, status: await rawPostStatus(proxyUrl, path, ip) }).toEqual({
|
|
775
|
+
path,
|
|
776
|
+
status: 429,
|
|
777
|
+
});
|
|
778
|
+
expect(fake.last).toBeNull();
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
test("the sixth POST /agentfs/auth/register from one IP within a minute gets 429", async () => {
|
|
784
|
+
const register = (ip: string) =>
|
|
785
|
+
fetch(`${proxyUrl}/agentfs/auth/register`, {
|
|
786
|
+
method: "POST",
|
|
787
|
+
headers: {
|
|
788
|
+
"Content-Type": "application/json",
|
|
789
|
+
"X-Forwarded-For": ip,
|
|
790
|
+
Authorization: `Bearer ${FAKE_SWARM_KEY}`,
|
|
791
|
+
},
|
|
792
|
+
body: JSON.stringify({ email: "a@example.test" }),
|
|
793
|
+
});
|
|
794
|
+
for (let i = 0; i < 5; i++) expect((await register("203.0.113.7")).status).toBe(200);
|
|
795
|
+
const sixth = await register("203.0.113.7");
|
|
796
|
+
expect(sixth.status).toBe(429);
|
|
797
|
+
expect(sixth.headers.get("retry-after")).toBeTruthy();
|
|
798
|
+
// Another IP has its own bucket, and other agent-fs routes are not limited.
|
|
799
|
+
expect((await register("203.0.113.8")).status).toBe(200);
|
|
800
|
+
expect(
|
|
801
|
+
(
|
|
802
|
+
await fetch(`${proxyUrl}/agentfs/auth/me`, {
|
|
803
|
+
headers: { "X-Forwarded-For": "203.0.113.7" },
|
|
804
|
+
})
|
|
805
|
+
).status,
|
|
806
|
+
).toBe(200);
|
|
807
|
+
});
|
|
808
|
+
|
|
809
|
+
test("path traversal is rejected on every prefix", async () => {
|
|
810
|
+
for (const path of [
|
|
811
|
+
"/agentfs/a/../../etc",
|
|
812
|
+
"/agentfs/a/%2e%2E/etc",
|
|
813
|
+
"/share/t/../../etc",
|
|
814
|
+
"/site/t/%2E%2e/x",
|
|
815
|
+
"/agentfs/auth/%2e./register",
|
|
816
|
+
"/share/t/./raw",
|
|
817
|
+
"/site/t\\x",
|
|
818
|
+
]) {
|
|
819
|
+
expect(await rawStatus(proxyUrl, path)).toBe(400);
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
|
|
823
|
+
test("an unreachable upstream answers 502", async () => {
|
|
824
|
+
process.env.AGENT_FS_API_URL = `http://127.0.0.1:${await getFreePort()}`;
|
|
825
|
+
expect((await fetch(`${proxyUrl}/agentfs/health`)).status).toBe(502);
|
|
826
|
+
expect((await fetch(`${proxyUrl}/share/${FAKE_SHARE_TOKEN}`)).status).toBe(502);
|
|
827
|
+
});
|
|
828
|
+
});
|
|
829
|
+
|
|
830
|
+
// ─── The real API (src/http.ts) in front of the fake agent-fs ───
|
|
831
|
+
|
|
832
|
+
interface SwarmChild {
|
|
833
|
+
url: string;
|
|
834
|
+
proc: Subprocess;
|
|
835
|
+
log: () => string;
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
async function startSwarm(env: Record<string, string>, dbDir: string): Promise<SwarmChild> {
|
|
839
|
+
return startOnFreePort(async (port) => {
|
|
840
|
+
const url = `http://127.0.0.1:${port}`;
|
|
841
|
+
const proc = Bun.spawn(["bun", "src/http.ts"], {
|
|
842
|
+
cwd: `${import.meta.dir}/../..`,
|
|
843
|
+
env: {
|
|
844
|
+
...process.env,
|
|
845
|
+
PORT: String(port),
|
|
846
|
+
DATABASE_PATH: join(dbDir, "swarm.sqlite"),
|
|
847
|
+
API_KEY: SWARM_KEY,
|
|
848
|
+
AGENT_SWARM_API_KEY: SWARM_KEY,
|
|
849
|
+
MCP_BASE_URL: url,
|
|
850
|
+
DESPLEGA_TELEMETRY_ENV: "test",
|
|
851
|
+
SLACK_DISABLE: "true",
|
|
852
|
+
GITHUB_DISABLE: "true",
|
|
853
|
+
JIRA_DISABLE: "true",
|
|
854
|
+
LINEAR_DISABLE: "true",
|
|
855
|
+
...env,
|
|
856
|
+
},
|
|
857
|
+
stdout: "pipe",
|
|
858
|
+
stderr: "pipe",
|
|
859
|
+
});
|
|
860
|
+
let out = "";
|
|
861
|
+
const drain = async (stream: ReadableStream<Uint8Array>) => {
|
|
862
|
+
const decoder = new TextDecoder();
|
|
863
|
+
for await (const chunk of stream) out += decoder.decode(chunk);
|
|
864
|
+
};
|
|
865
|
+
void drain(proc.stdout as ReadableStream<Uint8Array>);
|
|
866
|
+
void drain(proc.stderr as ReadableStream<Uint8Array>);
|
|
867
|
+
try {
|
|
868
|
+
await waitForServer(`${url}/health`);
|
|
869
|
+
} catch (error) {
|
|
870
|
+
proc.kill();
|
|
871
|
+
if (PORT_IN_USE_LOG.test(out)) throw new PortInUseError(out);
|
|
872
|
+
throw error;
|
|
873
|
+
}
|
|
874
|
+
return { url, proc, log: () => out };
|
|
875
|
+
});
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
describe("the API in front of a fake agent-fs", () => {
|
|
879
|
+
let fake: FakeAgentFs;
|
|
880
|
+
let on: SwarmChild;
|
|
881
|
+
let off: SwarmChild;
|
|
882
|
+
let tmp = "";
|
|
883
|
+
|
|
884
|
+
beforeAll(async () => {
|
|
885
|
+
fake = startFakeAgentFs();
|
|
886
|
+
tmp = join(tmpdir(), `afo-proxy-fake-${Date.now()}-${process.pid}`);
|
|
887
|
+
await mkdir(join(tmp, "on"), { recursive: true });
|
|
888
|
+
await mkdir(join(tmp, "off"), { recursive: true });
|
|
889
|
+
[on, off] = await Promise.all([
|
|
890
|
+
startSwarm({ AGENT_FS_API_URL: fake.url, AGENT_FS_PROXY: "true" }, join(tmp, "on")),
|
|
891
|
+
startSwarm({ AGENT_FS_API_URL: fake.url }, join(tmp, "off")),
|
|
892
|
+
]);
|
|
893
|
+
}, SERVER_BOOT_HOOK_TIMEOUT_MS * 2);
|
|
894
|
+
|
|
895
|
+
afterAll(async () => {
|
|
896
|
+
on?.proc.kill();
|
|
897
|
+
off?.proc.kill();
|
|
898
|
+
await on?.proc.exited;
|
|
899
|
+
await off?.proc.exited;
|
|
900
|
+
fake.stop();
|
|
901
|
+
if (tmp) await rm(tmp, { recursive: true, force: true });
|
|
902
|
+
});
|
|
903
|
+
|
|
904
|
+
test("CORS preflight from the dashboard origin returns 204 with one ACAO", async () => {
|
|
905
|
+
const pre = await fetch(`${on.url}/agentfs/orgs/o/ops`, {
|
|
906
|
+
method: "OPTIONS",
|
|
907
|
+
headers: {
|
|
908
|
+
Origin: ORIGIN,
|
|
909
|
+
"Access-Control-Request-Method": "POST",
|
|
910
|
+
"Access-Control-Request-Headers": "authorization,content-type,x-agent-fs-message",
|
|
911
|
+
},
|
|
912
|
+
});
|
|
913
|
+
expect(pre.status).toBe(204);
|
|
914
|
+
expect(pre.headers.get("access-control-allow-origin")).toBe(ORIGIN);
|
|
915
|
+
expect(pre.headers.get("access-control-allow-headers")).toContain("x-agent-fs-message");
|
|
916
|
+
|
|
917
|
+
const get = await fetch(`${on.url}/agentfs/auth/me`, {
|
|
918
|
+
headers: { Origin: ORIGIN, Authorization: "Bearer af_example" },
|
|
919
|
+
});
|
|
920
|
+
expect(get.status).toBe(200);
|
|
921
|
+
expect(get.headers.get("access-control-allow-origin")).toBe(ORIGIN);
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
test("no swarm credential is needed, and the swarm API key is not forwarded", async () => {
|
|
925
|
+
expect((await fetch(`${on.url}/agentfs/auth/me`)).status).toBe(200);
|
|
926
|
+
const res = await fetch(`${on.url}/agentfs/auth/me`, {
|
|
927
|
+
headers: { Authorization: `Bearer ${SWARM_KEY}`, "X-Agent-ID": "agent" },
|
|
928
|
+
});
|
|
929
|
+
expect(res.status).toBe(200);
|
|
930
|
+
expect(fake.last?.headers.get("authorization")).toBeNull();
|
|
931
|
+
expect(fake.last?.headers.get("x-agent-id")).toBeNull();
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
test("/share/<token>/raw forwards, and the access log line does not contain the token", async () => {
|
|
935
|
+
const res = await fetch(`${on.url}/share/${FAKE_SHARE_TOKEN}/raw`);
|
|
936
|
+
expect(res.status).toBe(200);
|
|
937
|
+
expect(fake.last?.url).toBe(`/share/${FAKE_SHARE_TOKEN}/raw`);
|
|
938
|
+
const deadline = performance.now() + 5000;
|
|
939
|
+
while (!on.log().includes("/share/[REDACTED]/raw") && performance.now() < deadline) {
|
|
940
|
+
await Bun.sleep(25);
|
|
941
|
+
}
|
|
942
|
+
expect(on.log()).toContain("GET /share/[REDACTED]/raw → 200");
|
|
943
|
+
expect(on.log()).not.toContain(FAKE_SHARE_TOKEN);
|
|
944
|
+
});
|
|
945
|
+
|
|
946
|
+
test("flag off: authenticated /agentfs, /share and /site keep today's 404", async () => {
|
|
947
|
+
const before = fake.hits;
|
|
948
|
+
for (const path of ["/agentfs/x", "/agentfs/health", "/share/x", "/site/x"]) {
|
|
949
|
+
const res = await fetch(`${off.url}${path}`, {
|
|
950
|
+
headers: { Authorization: `Bearer ${SWARM_KEY}` },
|
|
951
|
+
});
|
|
952
|
+
expect(res.status).toBe(404);
|
|
953
|
+
}
|
|
954
|
+
expect(fake.hits).toBe(before);
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
test("flag off: unauthenticated requests now get 404, not 401 (accepted change)", async () => {
|
|
958
|
+
for (const path of ["/agentfs/x", "/share/x", "/site/x"]) {
|
|
959
|
+
expect((await fetch(`${off.url}${path}`)).status).toBe(404);
|
|
960
|
+
}
|
|
961
|
+
});
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
describe("POST /agentfs/auth/register needs a swarm credential", () => {
|
|
965
|
+
let fake: FakeAgentFs;
|
|
966
|
+
let server: Server;
|
|
967
|
+
let proxyUrl = "";
|
|
968
|
+
let userToken = "";
|
|
969
|
+
const saved = {
|
|
970
|
+
proxy: process.env.AGENT_FS_PROXY,
|
|
971
|
+
url: process.env.AGENT_FS_API_URL,
|
|
972
|
+
key: process.env.AGENT_SWARM_API_KEY,
|
|
973
|
+
};
|
|
974
|
+
|
|
975
|
+
beforeAll(async () => {
|
|
976
|
+
process.env.AGENT_SWARM_API_KEY = FAKE_SWARM_KEY;
|
|
977
|
+
initDb(":memory:");
|
|
978
|
+
const user = await createUser({
|
|
979
|
+
name: "Proxy teammate",
|
|
980
|
+
email: "Mate@Example.test",
|
|
981
|
+
emailAliases: ["mate.alias@example.test"],
|
|
982
|
+
});
|
|
983
|
+
const actor = { kind: "operator", id: "agentfs-proxy-test" } as const;
|
|
984
|
+
userToken = (await mintToken(user.id, "agentfs-proxy-test", actor)).plaintext;
|
|
985
|
+
fake = startFakeAgentFs();
|
|
986
|
+
server = createServer(async (req, res) => {
|
|
987
|
+
if (!(await handleAgentFsProxy(req, res))) {
|
|
988
|
+
res.writeHead(404);
|
|
989
|
+
res.end("Not Found");
|
|
990
|
+
}
|
|
991
|
+
});
|
|
992
|
+
proxyUrl = `http://127.0.0.1:${await listenOnFreePort(server, "127.0.0.1")}`;
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
beforeEach(() => {
|
|
996
|
+
process.env.AGENT_FS_PROXY = "true";
|
|
997
|
+
process.env.AGENT_FS_API_URL = fake.url;
|
|
998
|
+
_resetAgentFsRegisterRateLimitForTests();
|
|
999
|
+
fake.registerBody = null;
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
afterAll(() => {
|
|
1003
|
+
server.close();
|
|
1004
|
+
fake.stop();
|
|
1005
|
+
closeDb();
|
|
1006
|
+
for (const [k, v] of [
|
|
1007
|
+
["AGENT_FS_PROXY", saved.proxy],
|
|
1008
|
+
["AGENT_FS_API_URL", saved.url],
|
|
1009
|
+
["AGENT_SWARM_API_KEY", saved.key],
|
|
1010
|
+
] as const) {
|
|
1011
|
+
if (v === undefined) delete process.env[k];
|
|
1012
|
+
else process.env[k] = v;
|
|
1013
|
+
}
|
|
1014
|
+
});
|
|
1015
|
+
|
|
1016
|
+
const register = (body: unknown, authorization?: string) =>
|
|
1017
|
+
fetch(`${proxyUrl}/agentfs/auth/register`, {
|
|
1018
|
+
method: "POST",
|
|
1019
|
+
headers: {
|
|
1020
|
+
"Content-Type": "application/json",
|
|
1021
|
+
...(authorization ? { Authorization: authorization } : {}),
|
|
1022
|
+
},
|
|
1023
|
+
body: typeof body === "string" ? body : JSON.stringify(body),
|
|
1024
|
+
});
|
|
1025
|
+
|
|
1026
|
+
test("no credential, an af_ key, a wrong key or an unknown user token: 401, never upstream", async () => {
|
|
1027
|
+
for (const authorization of [
|
|
1028
|
+
undefined,
|
|
1029
|
+
"Bearer af_someone",
|
|
1030
|
+
"Bearer not-the-swarm-key",
|
|
1031
|
+
"Bearer aswt_unknown",
|
|
1032
|
+
]) {
|
|
1033
|
+
const res = await register({ email: "mate@example.test" }, authorization);
|
|
1034
|
+
expect({ authorization, status: res.status }).toEqual({ authorization, status: 401 });
|
|
1035
|
+
}
|
|
1036
|
+
expect(fake.registerBody).toBeNull();
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
test("the operator key registers any email, and no swarm bearer goes upstream", async () => {
|
|
1040
|
+
const res = await register({ email: "someone@example.test" }, `Bearer ${FAKE_SWARM_KEY}`);
|
|
1041
|
+
expect(res.status).toBe(200);
|
|
1042
|
+
expect(JSON.parse(fake.registerBody ?? "null")).toEqual({ email: "someone@example.test" });
|
|
1043
|
+
expect(fake.last?.headers.get("authorization")).toBeNull();
|
|
1044
|
+
});
|
|
1045
|
+
|
|
1046
|
+
test("a swarm key shaped like an agent-fs key never goes upstream", async () => {
|
|
1047
|
+
const afShaped = "af_example-swarm-operator-key"; // gitleaks:allow (test fixture, not a real key)
|
|
1048
|
+
process.env.AGENT_SWARM_API_KEY = afShaped;
|
|
1049
|
+
try {
|
|
1050
|
+
const res = await register({ email: "someone@example.test" }, `Bearer ${afShaped}`);
|
|
1051
|
+
expect(res.status).toBe(200);
|
|
1052
|
+
expect(fake.last?.headers.get("authorization")).toBeNull();
|
|
1053
|
+
|
|
1054
|
+
await fetch(`${proxyUrl}/agentfs/auth/me`, {
|
|
1055
|
+
headers: { Authorization: `Bearer ${afShaped}` },
|
|
1056
|
+
});
|
|
1057
|
+
expect(fake.last?.url).toBe("/auth/me");
|
|
1058
|
+
expect(fake.last?.headers.get("authorization")).toBeNull();
|
|
1059
|
+
|
|
1060
|
+
// A teammate's own agent-fs key still goes upstream.
|
|
1061
|
+
await fetch(`${proxyUrl}/agentfs/auth/me`, { headers: { Authorization: "Bearer af_mate" } });
|
|
1062
|
+
expect(fake.last?.headers.get("authorization")).toBe("Bearer af_mate");
|
|
1063
|
+
} finally {
|
|
1064
|
+
process.env.AGENT_SWARM_API_KEY = FAKE_SWARM_KEY;
|
|
1065
|
+
}
|
|
1066
|
+
});
|
|
1067
|
+
|
|
1068
|
+
test("a user token registers its own email or an alias, not a teammate's", async () => {
|
|
1069
|
+
for (const email of ["MATE@example.test", "mate.alias@example.test"]) {
|
|
1070
|
+
const res = await register({ email }, `Bearer ${userToken}`);
|
|
1071
|
+
expect({ email, status: res.status }).toEqual({ email, status: 200 });
|
|
1072
|
+
}
|
|
1073
|
+
fake.registerBody = null;
|
|
1074
|
+
const other = await register({ email: "lead@example.test" }, `Bearer ${userToken}`);
|
|
1075
|
+
expect(other.status).toBe(403);
|
|
1076
|
+
const noEmail = await register({}, `Bearer ${userToken}`);
|
|
1077
|
+
expect(noEmail.status).toBe(403);
|
|
1078
|
+
expect(fake.registerBody).toBeNull();
|
|
1079
|
+
});
|
|
1080
|
+
|
|
1081
|
+
test("agent-fs reads the same email the proxy checked, even with a repeated key", async () => {
|
|
1082
|
+
// JSON.parse keeps the last key. The proxy sends the parsed body again.
|
|
1083
|
+
const taken = await register(
|
|
1084
|
+
'{"email":"mate@example.test","email":"lead@example.test"}',
|
|
1085
|
+
`Bearer ${userToken}`,
|
|
1086
|
+
);
|
|
1087
|
+
expect(taken.status).toBe(403);
|
|
1088
|
+
const own = await register(
|
|
1089
|
+
'{"email":"lead@example.test","email":"mate@example.test"}',
|
|
1090
|
+
`Bearer ${userToken}`,
|
|
1091
|
+
);
|
|
1092
|
+
expect(own.status).toBe(200);
|
|
1093
|
+
expect(JSON.parse(fake.registerBody ?? "null")).toEqual({ email: "mate@example.test" });
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
test("a body over 16 KB gets 413, and bad JSON gets 400", async () => {
|
|
1097
|
+
const big = await register(
|
|
1098
|
+
{ email: "a@example.test", pad: "x".repeat(17_000) },
|
|
1099
|
+
`Bearer ${FAKE_SWARM_KEY}`,
|
|
1100
|
+
);
|
|
1101
|
+
expect(big.status).toBe(413);
|
|
1102
|
+
const bad = await register("{not json", `Bearer ${FAKE_SWARM_KEY}`);
|
|
1103
|
+
expect(bad.status).toBe(400);
|
|
1104
|
+
expect(fake.registerBody).toBeNull();
|
|
1105
|
+
});
|
|
1106
|
+
});
|