@desplega.ai/agent-swarm 2.7.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/dist/{acp-adapter-ma1js5n7.js → acp-adapter-8dn5tq56.js} +6 -6
- package/dist/{actions-1vhmtvg9.js → actions-3r6gw7dy.js} +18 -18
- package/dist/{amp-adapter-sxqqvmdk.js → amp-adapter-wjtq413p.js} +3 -3
- package/dist/{anthropic-messages-txs190dd.js → anthropic-messages-n4cby1rz.js} +4 -4
- package/dist/{app-mwx1d5a9.js → app-d53yhykk.js} +3 -3
- package/dist/{assistant-yx1p6g82.js → assistant-ksrawj3k.js} +20 -20
- package/dist/{boot-reembed-7h78vmvv.js → boot-reembed-fxfw17m2.js} +10 -3
- package/dist/{boot-reembed-5h37a2c2.js → boot-reembed-k5y5xbj0.js} +15 -8
- package/dist/{boot-scrub-sweep-8mawd4gw.js → boot-scrub-sweep-se41j9fd.js} +3 -3
- package/dist/{boot-seeds-ca84dhnn.js → boot-seeds-r3kwwv8b.js} +4 -4
- package/dist/{claude-adapter-wqt4hfkq.js → claude-adapter-ahmyf231.js} +7 -7
- package/dist/{claude-managed-adapter-6fjqtr2z.js → claude-managed-adapter-5knamegc.js} +3 -3
- package/dist/{claude-managed-setup-rqv98sea.js → claude-managed-setup-mdc7dy6b.js} +2 -2
- package/dist/{claude-sdk-session-f29f3b2x.js → claude-sdk-session-n5wen9zk.js} +8728 -8599
- package/dist/{cli-m5e802r7.js → cli-0dqtv2jg.js} +2 -2
- package/dist/{cli-e1595bgx.js → cli-0tww7dnz.js} +2 -2
- package/dist/{cli-2y7v133v.js → cli-0w4e4v58.js} +7 -7
- package/dist/{cli-d20qvpr2.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-twctnkng.js → cli-3v2cyqb8.js} +3 -3
- package/dist/{cli-he9gbdx0.js → cli-5qt9vzmq.js} +39 -20
- package/dist/{cli-a6ds2epe.js → cli-5rp1w4cr.js} +1 -1
- package/dist/{cli-6srv653c.js → cli-6vwjsetx.js} +4 -4
- package/dist/{cli-1hhbzkbb.js → cli-6w2yrrc0.js} +8 -8
- package/dist/{cli-f12mr20z.js → cli-75tdydgv.js} +1 -1
- package/dist/{cli-j3y0x2n4.js → cli-8feswf2v.js} +43 -33
- package/dist/{cli-07q03xm2.js → cli-8wd3mbyj.js} +1 -1
- package/dist/{cli-ymjpkxgn.js → cli-9csfthbw.js} +8 -6
- package/dist/{cli-f1e0ptjj.js → cli-b41p764a.js} +21 -2
- package/dist/{cli-kv7sbc2c.js → cli-c1t533hh.js} +3 -3
- package/dist/{cli-54sj64fc.js → cli-c28jxpcv.js} +1 -1
- package/dist/{cli-8mbc9za9.js → cli-cb50g9cd.js} +1 -1
- package/dist/{cli-c668vvcf.js → cli-d8gaphtr.js} +1 -1
- package/dist/{cli-86ehn4s5.js → cli-dgtfysne.js} +2 -2
- package/dist/{cli-sjctjr48.js → cli-dscnhn18.js} +1 -1
- package/dist/{cli-j0ppywdr.js → cli-dv11pgpv.js} +12 -12
- package/dist/{cli-1m9x8dr5.js → cli-ekptgk2m.js} +5 -5
- package/dist/{cli-9gsm2rzp.js → cli-f3mzy662.js} +17 -17
- package/dist/{cli-s0mgx214.js → cli-fk7k8s9x.js} +2 -2
- package/dist/{cli-vbhgkw9h.js → cli-h3e7bhvn.js} +3 -3
- package/dist/{cli-yd2nreqw.js → cli-hkxqdmnb.js} +2 -2
- package/dist/{cli-8wmj18cz.js → cli-hpkvgdad.js} +7 -4
- package/dist/{cli-wqr7cea4.js → cli-hq2v4rr5.js} +37 -9
- package/dist/{cli-sgqm2582.js → cli-k1zb7raw.js} +5 -5
- package/dist/{cli-4rvr7zfx.js → cli-k4awgvzm.js} +5 -0
- package/dist/{cli-ay40dnj3.js → cli-k7d3hvya.js} +2 -2
- package/dist/{cli-j9bpmykh.js → cli-kbtdftbw.js} +5 -5
- package/dist/{cli-x37r7qwf.js → cli-kj69b9tw.js} +25 -5
- package/dist/{cli-gsfk58nw.js → cli-kk9jy4nm.js} +1 -1
- package/dist/{cli-39aet655.js → cli-m52t4cta.js} +1 -1
- package/dist/{cli-h5jeg46p.js → cli-mdadewqp.js} +5 -5
- package/dist/{cli-a89zdqz9.js → cli-nf6k60sn.js} +1 -1
- package/dist/{cli-v6shg7na.js → cli-nzdf09js.js} +1 -1
- package/dist/{cli-rr7zx19j.js → cli-prcyzecg.js} +12 -12
- package/dist/{cli-6a9qfa8b.js → cli-pzafyqtk.js} +118 -48
- package/dist/{cli-48gxjmcz.js → cli-qvaa1gjp.js} +87 -24
- package/dist/{cli-qj442fws.js → cli-rg4b2g6j.js} +1 -1
- package/dist/{cli-aypd09nf.js → cli-rm66vqdz.js} +1 -1
- package/dist/{cli-9pshx6rd.js → cli-s504c3w7.js} +1 -1
- package/dist/{cli-ptfq202x.js → cli-swkx9jm2.js} +3 -3
- package/dist/{cli-m30k9777.js → cli-thsaq4pd.js} +16 -16
- package/dist/{cli-60sty27p.js → cli-twjnyvs8.js} +2 -2
- package/dist/{cli-rd5m70gj.js → cli-typdpe8g.js} +4 -4
- package/dist/{cli-yxbsmx56.js → cli-vg8pwmsd.js} +6 -6
- package/dist/{cli-s09j9vp5.js → cli-vhpckzvp.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-b3yzyc57.js → cli-x3cngb1h.js} +44 -12
- package/dist/{cli-6mgdba42.js → cli-xbz6mcg4.js} +2 -2
- package/dist/{cli-2pvzmas1.js → cli-y4tdxsqb.js} +3 -3
- package/dist/{cli-f4dbh7a5.js → cli-yqrsp3ny.js} +7 -7
- package/dist/cli.js +27 -19
- package/dist/{codex-adapter-zk59x2zh.js → codex-adapter-7wepzq15.js} +5 -5
- package/dist/{codex-hook-xyjhvxwd.js → codex-hook-pbdh4xhx.js} +2 -2
- package/dist/{codex-session-runner-kh6s4j3n.js → codex-session-runner-02sb49e4.js} +5 -5
- package/dist/{commands-68et1qv2.js → commands-3rcspxct.js} +2 -2
- package/dist/{controller-fyrbnzg4.js → controller-8ar4avd6.js} +3 -3
- package/dist/{controllers-connect-nep9h2xv.js → controllers-connect-ajvh9h0v.js} +19 -19
- package/dist/{cursor-adapter-yss3pg7t.js → cursor-adapter-gd0sa7bh.js} +1 -1
- package/dist/{db-28gjyvxj.js → db-dv9j7ts5.js} +2 -2
- package/dist/{db-8ftwvzba.js → db-z26vgdvj.js} +3 -3
- package/dist/{dispatcher-hayexmkr.js → dispatcher-8m0htjq8.js} +17 -17
- package/dist/{dsh-adapter-46wawff9.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-8d1e0h4m.js → easter-egg-3d-28f880xx.js} +1 -1
- package/dist/{execute-cjrfxfnm.js → execute-9seb79vh.js} +1 -1
- package/dist/{external-channel-xbmxcg09.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-ag4wks2a.js → grok-adapter-z0nnvtbn.js} +7 -7
- package/dist/handlers-pspryh6g.js +41 -0
- package/dist/{heartbeat-psfvcgwp.js → heartbeat-f928aqyp.js} +20 -20
- package/dist/{hook-cyq4mryn.js → hook-d7c605r4.js} +7 -7
- package/dist/{hook-8pz04697.js → hook-q9hv5xf9.js} +5 -5
- package/dist/{http-haj1nd6k.js → http-q6j4k989.js} +934 -180
- package/dist/{index-z5h22pes.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-zyq8ct52.js → index-ch0c1xff.js} +17 -17
- package/dist/{index-syvhnvtw.js → index-ebnjhxh1.js} +1 -1
- package/dist/{index-y1ajx04a.js → index-ed8dy8rn.js} +20 -20
- package/dist/{index-jwt2bj6f.js → index-ejqdkkqx.js} +11 -10
- package/dist/{index-dr94zbnh.js → index-farm1zsn.js} +32661 -29443
- package/dist/{index-q52f47pe.js → index-yn1yxg85.js} +1 -1
- package/dist/{keepalive-xdvsamvj.js → keepalive-zzc3hks1.js} +3 -3
- package/dist/{lead-hqg57kdp.js → lead-c0pjcaw4.js} +36 -36
- package/dist/{maintenance-4y2xzh3a.js → maintenance-kqsxzpzx.js} +1 -1
- package/dist/{oauth-refresh-sweep-jd4r7zbs.js → oauth-refresh-sweep-7qt0g55z.js} +2 -2
- package/dist/{onboard-18447q9q.js → onboard-k7x13fqg.js} +118 -5
- package/dist/{opencode-adapter-26b2frdf.js → opencode-adapter-tqvrtter.js} +5 -5
- package/dist/{otel-impl-f9tgr8yw.js → otel-impl-sqkf6k3b.js} +97 -97
- package/dist/{pi-mono-adapter-qn6qbga1.js → pi-mono-adapter-m2qnafxm.js} +63 -9
- package/dist/{pricing-refresh-dw2tgb53.js → pricing-refresh-wkpwhgvs.js} +3 -3
- package/dist/{rbac-roles-s4nwj2m3.js → rbac-roles-dpn0c0wj.js} +7 -4
- package/dist/{rbac-roles-aygm2760.js → rbac-roles-wk7xd6a6.js} +2 -2
- package/dist/{render-v2-yfvdfr0x.js → render-v2-ex2m4g2k.js} +3 -3
- package/dist/{resume-1bmvjbf3.js → resume-725g9b0b.js} +7 -7
- package/dist/{runtime-f7tpzedy.js → runtime-kajas15g.js} +1 -1
- package/dist/{session-access-r5garej6.js → session-access-0j2ezjje.js} +8 -4
- package/dist/{session-source-backfill-424wzv0r.js → session-source-backfill-62202awe.js} +2 -2
- package/dist/{setup-8tygvxkq.js → setup-9sj11ax6.js} +1 -1
- package/dist/{virtual-modules-xsw323b3.js → virtual-modules-c6fcq6zr.js} +2 -2
- package/dist/{work-objects-ggnghpgf.js → work-objects-0y07495y.js} +20 -20
- package/dist/{worker-sqjd1nqf.js → worker-d4q9sqbc.js} +36 -36
- package/openapi.json +1070 -9
- package/package.json +3 -3
- package/src/be/connector-codes.ts +65 -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 +110 -8
- package/src/be/memory/types.ts +13 -2
- package/src/be/migrations/246_connector_code_kind.sql +11 -0
- package/src/be/onboarding.ts +125 -15
- package/src/be/rbac-roles.ts +4 -1
- package/src/be/scripts/embeddings.ts +2 -2
- package/src/be/seed/agent-fs-provision.ts +75 -14
- 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 +112 -1
- 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/runner.ts +14 -1
- package/src/http/agentfs-proxy.ts +369 -0
- package/src/http/all-routes.ts +1 -0
- package/src/http/codex-oauth-device.ts +8 -1
- package/src/http/fs.ts +64 -10
- package/src/http/inbox-state.ts +27 -1
- package/src/http/index.ts +4 -1
- package/src/http/memory.ts +7 -4
- package/src/http/onboarding.ts +139 -11
- 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/prompts/base-prompt.ts +24 -9
- package/src/prompts/session-templates.ts +26 -2
- 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/pi-mono-adapter.ts +58 -3
- package/src/rbac/legacy-policy.ts +1 -0
- package/src/rbac/permissions.ts +4 -0
- package/src/tests/agent-fs-provision-seeder.test.ts +261 -92
- package/src/tests/agentfs-proxy.test.ts +1106 -0
- package/src/tests/base-prompt.test.ts +69 -2
- 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/docs-index-footprint.test.js +3 -3
- 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/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/onboard-compose.test.ts +142 -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/pi-mono-session-cost-on-abort.test.ts +270 -0
- package/src/tests/preload-env.test.ts +4 -0
- package/src/tests/preload.ts +4 -0
- package/src/tests/prompt-template-session.test.ts +19 -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/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 +95 -2
- package/src/tests/whoami-route.test.ts +41 -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/store-progress.ts +5 -1
- package/src/tools/utils.ts +12 -1
- package/src/utils/constants.ts +39 -0
- package/src/utils/task-attachment-links.ts +15 -3
- package/templates/skills/swarm-docs/content.md +1 -1
- package/dist/handlers-tzmevqb5.js +0 -41
- 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,85 @@
|
|
|
1
|
+
# Docker Compose for Agent Swarm
|
|
2
|
+
# Generated by `agent-swarm onboard`
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# docker compose --env-file .env up -d
|
|
6
|
+
#
|
|
7
|
+
# Image version: set AGENT_SWARM_VERSION in .env to a published release.
|
|
8
|
+
# Blank falls back to `latest`, which moves on every commit to main and is
|
|
9
|
+
# not a release. Releases: https://github.com/desplega-ai/agent-swarm/releases
|
|
10
|
+
# Upgrade/rollback (migrations are forward-only, so back up first):
|
|
11
|
+
# https://github.com/desplega-ai/agent-swarm/blob/main/skills/agent-swarm/references/upgrade.md
|
|
12
|
+
|
|
13
|
+
services:
|
|
14
|
+
swarm-api:
|
|
15
|
+
image: "ghcr.io/desplega-ai/agent-swarm:${AGENT_SWARM_VERSION:-latest}"
|
|
16
|
+
container_name: swarm-api
|
|
17
|
+
pull_policy: always
|
|
18
|
+
stop_grace_period: 60s
|
|
19
|
+
|
|
20
|
+
environment:
|
|
21
|
+
- API_KEY=${API_KEY}
|
|
22
|
+
- INSTALL_METHOD=${INSTALL_METHOD}
|
|
23
|
+
- INSTALL_PRESET=${INSTALL_PRESET:-}
|
|
24
|
+
- MEMORY_HYBRID_SEARCH=${MEMORY_HYBRID_SEARCH-1}
|
|
25
|
+
- MEMORY_GRAPH_EXPANSION=${MEMORY_GRAPH_EXPANSION-1}
|
|
26
|
+
- MEMORY_RATERS=${MEMORY_RATERS-implicit-citation,explicit-self}
|
|
27
|
+
- MEMORY_DEMOTION_FLOOR=${MEMORY_DEMOTION_FLOOR-1.0}
|
|
28
|
+
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN}
|
|
29
|
+
- MCP_BASE_URL=http://localhost:3013
|
|
30
|
+
- APP_URL=https://app.agent-swarm.dev
|
|
31
|
+
|
|
32
|
+
ports:
|
|
33
|
+
- "3013:3013"
|
|
34
|
+
|
|
35
|
+
volumes:
|
|
36
|
+
- swarm_data:/app/data
|
|
37
|
+
|
|
38
|
+
healthcheck:
|
|
39
|
+
test: ["CMD-SHELL", "curl -f http://localhost:3013/health || exit 1"]
|
|
40
|
+
interval: 10s
|
|
41
|
+
timeout: 5s
|
|
42
|
+
retries: 3
|
|
43
|
+
start_period: 15s
|
|
44
|
+
|
|
45
|
+
restart: unless-stopped
|
|
46
|
+
|
|
47
|
+
worker-coder:
|
|
48
|
+
image: "ghcr.io/desplega-ai/agent-swarm-worker:${AGENT_SWARM_VERSION:-latest}"
|
|
49
|
+
container_name: swarm-worker-coder
|
|
50
|
+
pull_policy: always
|
|
51
|
+
stop_grace_period: 60s
|
|
52
|
+
|
|
53
|
+
depends_on:
|
|
54
|
+
swarm-api:
|
|
55
|
+
condition: service_healthy
|
|
56
|
+
|
|
57
|
+
environment:
|
|
58
|
+
- HARNESS_PROVIDER=${HARNESS_PROVIDER}
|
|
59
|
+
- CLAUDE_CODE_OAUTH_TOKEN=${CLAUDE_CODE_OAUTH_TOKEN}
|
|
60
|
+
- API_KEY=${API_KEY}
|
|
61
|
+
- MEMORY_RATERS=${MEMORY_RATERS-implicit-citation,explicit-self}
|
|
62
|
+
- AGENT_ID=00000000-0000-4000-8000-000000000001
|
|
63
|
+
- AGENT_NAME=Coder
|
|
64
|
+
- AGENT_ROLE=worker
|
|
65
|
+
- TEMPLATE_ID=official/coder
|
|
66
|
+
- MCP_BASE_URL=http://swarm-api:3013
|
|
67
|
+
- YOLO=true
|
|
68
|
+
- SWARM_URL=http://swarm-api:3013
|
|
69
|
+
- MAX_CONCURRENT_TASKS=1
|
|
70
|
+
|
|
71
|
+
ports:
|
|
72
|
+
- "3201:3000"
|
|
73
|
+
|
|
74
|
+
volumes:
|
|
75
|
+
- swarm_logs:/logs
|
|
76
|
+
- swarm_shared:/workspace/shared
|
|
77
|
+
- swarm_worker_coder:/workspace/personal
|
|
78
|
+
|
|
79
|
+
restart: unless-stopped
|
|
80
|
+
|
|
81
|
+
volumes:
|
|
82
|
+
swarm_data:
|
|
83
|
+
swarm_logs:
|
|
84
|
+
swarm_shared:
|
|
85
|
+
swarm_worker_coder:
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# === Core ===
|
|
2
|
+
API_KEY=example-test-api-key
|
|
3
|
+
MCP_BASE_URL=http://localhost:3013
|
|
4
|
+
APP_URL=https://app.agent-swarm.dev
|
|
5
|
+
# Pin a release, e.g. AGENT_SWARM_VERSION=1.150.0. Blank tracks `latest`.
|
|
6
|
+
AGENT_SWARM_VERSION=
|
|
7
|
+
INSTALL_METHOD=onboard_noninteractive
|
|
8
|
+
INSTALL_PRESET=solo
|
|
9
|
+
|
|
10
|
+
# New-deployment memory preset: retrieval, citation ratings, self-rating hints.
|
|
11
|
+
# Set MEMORY_RATERS= to disable all raters; leave SKIP_SESSION_SUMMARY unset.
|
|
12
|
+
MEMORY_HYBRID_SEARCH=1
|
|
13
|
+
MEMORY_GRAPH_EXPANSION=1
|
|
14
|
+
MEMORY_RATERS=implicit-citation,explicit-self
|
|
15
|
+
MEMORY_DEMOTION_FLOOR=1.0
|
|
16
|
+
|
|
17
|
+
# === Authentication ===
|
|
18
|
+
HARNESS_PROVIDER=claude
|
|
19
|
+
CLAUDE_CODE_OAUTH_TOKEN=example-test-oauth
|
|
20
|
+
|
|
21
|
+
# === Integrations ===
|
|
22
|
+
GITHUB_DISABLE=true
|
|
23
|
+
SLACK_DISABLE=true
|
|
24
|
+
|
|
25
|
+
# === Agent IDs (for reference) ===
|
|
26
|
+
# worker-coder = 00000000-0000-4000-8000-000000000001
|
|
@@ -1,7 +1,19 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
|
-
import { safeRequestUrlForLog } from "../http/utils";
|
|
2
|
+
import { redactShareTokenInPath, safeRequestUrlForLog } from "../http/utils";
|
|
3
3
|
|
|
4
4
|
describe("safeRequestUrlForLog", () => {
|
|
5
|
+
test("redacts invite codes in request logs and the OTel path attribute", () => {
|
|
6
|
+
for (const suffix of ["", "/", "/extra"]) {
|
|
7
|
+
expect(safeRequestUrlForLog(`/invite/example_invite_code${suffix}?code=secret`)).toBe(
|
|
8
|
+
`/invite/[REDACTED]${suffix}?code=[REDACTED]`,
|
|
9
|
+
);
|
|
10
|
+
expect(redactShareTokenInPath(`/invite/example_invite_code${suffix}`)).toBe(
|
|
11
|
+
`/invite/[REDACTED]${suffix}`,
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
expect(safeRequestUrlForLog("/invite")).toBe("/invite");
|
|
15
|
+
expect(safeRequestUrlForLog("/api/invites")).toBe("/api/invites");
|
|
16
|
+
});
|
|
5
17
|
test("redacts OAuth callback query values", () => {
|
|
6
18
|
expect(
|
|
7
19
|
safeRequestUrlForLog(
|
|
@@ -21,4 +33,25 @@ describe("safeRequestUrlForLog", () => {
|
|
|
21
33
|
"/mcp?session=[REDACTED]&session=[REDACTED]&token=[REDACTED]",
|
|
22
34
|
);
|
|
23
35
|
});
|
|
36
|
+
|
|
37
|
+
test("redacts the agent-fs share token after /share/ and /site/", () => {
|
|
38
|
+
expect(safeRequestUrlForLog("/share/tok_secret123/raw")).toBe("/share/[REDACTED]/raw");
|
|
39
|
+
expect(safeRequestUrlForLog("/site/tok_secret123/")).toBe("/site/[REDACTED]/");
|
|
40
|
+
expect(safeRequestUrlForLog("/site/tok_secret123/a/index.html?v=1")).toBe(
|
|
41
|
+
"/site/[REDACTED]/a/index.html?v=[REDACTED]",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("redacts the share token behind the /agentfs proxy prefix", () => {
|
|
46
|
+
expect(safeRequestUrlForLog("/agentfs/share/tok_secret123")).toBe("/agentfs/share/[REDACTED]");
|
|
47
|
+
expect(safeRequestUrlForLog("/agentfs/site/tok_secret123/x.css")).toBe(
|
|
48
|
+
"/agentfs/site/[REDACTED]/x.css",
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("leaves other share-like paths alone", () => {
|
|
53
|
+
expect(safeRequestUrlForLog("/share")).toBe("/share");
|
|
54
|
+
expect(safeRequestUrlForLog("/api/share/abc")).toBe("/api/share/abc");
|
|
55
|
+
expect(safeRequestUrlForLog("/agentfs/orgs/o/share")).toBe("/agentfs/orgs/o/share");
|
|
56
|
+
});
|
|
24
57
|
});
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
completeTask,
|
|
11
11
|
createAgent,
|
|
12
12
|
createTaskExtended,
|
|
13
|
+
createUser,
|
|
13
14
|
getTaskById,
|
|
14
15
|
initDb,
|
|
15
16
|
startTask,
|
|
@@ -27,7 +28,9 @@ const ownerId = "aaaa0000-0000-4000-8000-000000000b01";
|
|
|
27
28
|
const otherId = "bbbb0000-0000-4000-8000-000000000b02";
|
|
28
29
|
const creatorId = "cccc0000-0000-4000-8000-000000000b03";
|
|
29
30
|
const leadId = "dddd0000-0000-4000-8000-000000000b04";
|
|
30
|
-
|
|
31
|
+
// A real user row: a new user gets the built-in admin role, so with RBAC on
|
|
32
|
+
// it may cancel any task, ownerless ones included.
|
|
33
|
+
let userId: string;
|
|
31
34
|
|
|
32
35
|
type Caller =
|
|
33
36
|
| { as: "operator"; agentId?: string }
|
|
@@ -85,6 +88,7 @@ beforeAll(async () => {
|
|
|
85
88
|
await createAgent({ id: otherId, name: "Other", isLead: false, status: "idle" });
|
|
86
89
|
await createAgent({ id: creatorId, name: "Creator", isLead: false, status: "idle" });
|
|
87
90
|
await createAgent({ id: leadId, name: "Lead", isLead: true, status: "idle" });
|
|
91
|
+
userId = (await createUser({ name: "Dashboard User" })).id;
|
|
88
92
|
|
|
89
93
|
server = createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
90
94
|
const caller = JSON.parse(String(req.headers["x-test-caller"])) as Caller;
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { unlink } from "node:fs/promises";
|
|
3
|
+
import {
|
|
4
|
+
createServer as createHttpServer,
|
|
5
|
+
type IncomingMessage,
|
|
6
|
+
type Server,
|
|
7
|
+
type ServerResponse,
|
|
8
|
+
} from "node:http";
|
|
9
|
+
import { createConnectorCode, INVITE_CODE_TTL_MS } from "../be/connector-codes";
|
|
10
|
+
import { closeDb, createUser, getDbClient, initDb, upsertSwarmConfig } from "../be/db";
|
|
11
|
+
import { ensureRbacSeedsSynced, listUserRoles, setUserRoles } from "../be/rbac-roles";
|
|
12
|
+
import { type IdentityActor, mintToken } from "../be/users";
|
|
13
|
+
import { handleCore } from "../http/core";
|
|
14
|
+
import {
|
|
15
|
+
_resetConnectorExchangeRateLimitForTests,
|
|
16
|
+
_resetInviteExchangeRateLimitForTests,
|
|
17
|
+
handleUsers,
|
|
18
|
+
} from "../http/users";
|
|
19
|
+
import { getPathSegments, parseQueryParams } from "../http/utils";
|
|
20
|
+
import { DEFAULT_APP_URL } from "../utils/constants";
|
|
21
|
+
import { listenOnFreePort } from "./test-net";
|
|
22
|
+
|
|
23
|
+
const TEST_DB_PATH = "./test-invites-http.sqlite";
|
|
24
|
+
const API_KEY = "example-test-invites-key";
|
|
25
|
+
const ACTOR: IdentityActor = { kind: "operator", id: "invites-test" };
|
|
26
|
+
const ORIGINAL_ENV = {
|
|
27
|
+
AGENT_SWARM_API_KEY: process.env.AGENT_SWARM_API_KEY,
|
|
28
|
+
PUBLIC_MCP_BASE_URL: process.env.PUBLIC_MCP_BASE_URL,
|
|
29
|
+
APP_URL: process.env.APP_URL,
|
|
30
|
+
DASHBOARD_URL: process.env.DASHBOARD_URL,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
async function removeDbFiles(path: string): Promise<void> {
|
|
34
|
+
for (const suffix of ["", "-wal", "-shm"]) {
|
|
35
|
+
try {
|
|
36
|
+
await unlink(path + suffix);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createTestServer(apiKey: string): Server {
|
|
44
|
+
return createHttpServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
45
|
+
const myAgentId = req.headers["x-agent-id"] as string | undefined;
|
|
46
|
+
const handled = await handleCore(req, res, myAgentId, apiKey);
|
|
47
|
+
if (handled) return;
|
|
48
|
+
const pathSegments = getPathSegments(req.url || "");
|
|
49
|
+
const queryParams = parseQueryParams(req.url || "");
|
|
50
|
+
const ok = await handleUsers(req, res, pathSegments, queryParams);
|
|
51
|
+
if (!ok) {
|
|
52
|
+
res.writeHead(404);
|
|
53
|
+
res.end("Not Found");
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function restoreEnv(key: keyof typeof ORIGINAL_ENV): void {
|
|
59
|
+
const value = ORIGINAL_ENV[key];
|
|
60
|
+
if (value === undefined) delete process.env[key];
|
|
61
|
+
else process.env[key] = value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let server: Server;
|
|
65
|
+
let port: number;
|
|
66
|
+
|
|
67
|
+
beforeAll(async () => {
|
|
68
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
69
|
+
initDb(TEST_DB_PATH);
|
|
70
|
+
ensureRbacSeedsSynced({ quiet: true });
|
|
71
|
+
process.env.AGENT_SWARM_API_KEY = API_KEY;
|
|
72
|
+
server = createTestServer(API_KEY);
|
|
73
|
+
port = await listenOnFreePort(server, "127.0.0.1");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterAll(async () => {
|
|
77
|
+
await new Promise<void>((resolve) => server.close(() => resolve()));
|
|
78
|
+
closeDb();
|
|
79
|
+
await removeDbFiles(TEST_DB_PATH);
|
|
80
|
+
for (const key of Object.keys(ORIGINAL_ENV) as Array<keyof typeof ORIGINAL_ENV>) {
|
|
81
|
+
restoreEnv(key);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
beforeEach(async () => {
|
|
86
|
+
process.env.PUBLIC_MCP_BASE_URL = "https://swarm.example.com";
|
|
87
|
+
process.env.APP_URL = "https://app.example.com";
|
|
88
|
+
delete process.env.DASHBOARD_URL;
|
|
89
|
+
_resetInviteExchangeRateLimitForTests();
|
|
90
|
+
_resetConnectorExchangeRateLimitForTests();
|
|
91
|
+
const client = getDbClient();
|
|
92
|
+
await client.run("DELETE FROM connector_codes");
|
|
93
|
+
await client.run("DELETE FROM swarm_config WHERE key IN ('PUBLIC_MCP_BASE_URL', 'APP_URL')");
|
|
94
|
+
await client.run("DELETE FROM user_identity_events");
|
|
95
|
+
await client.run("DELETE FROM user_tokens");
|
|
96
|
+
await client.run("DELETE FROM principal_roles WHERE principalType = 'user'");
|
|
97
|
+
await client.run("DELETE FROM users");
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
function url(path: string): string {
|
|
101
|
+
return `http://127.0.0.1:${port}${path}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function post(path: string, body: unknown, bearer: string | null = API_KEY): Promise<Response> {
|
|
105
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
106
|
+
if (bearer) headers.Authorization = `Bearer ${bearer}`;
|
|
107
|
+
return fetch(url(path), { method: "POST", headers, body: JSON.stringify(body) });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
type InviteBody = {
|
|
111
|
+
user: { id: string; email: string | null; name: string; status: string };
|
|
112
|
+
inviteUrl: string;
|
|
113
|
+
appUrl: string;
|
|
114
|
+
expiresAt: string;
|
|
115
|
+
apiReachable: "public" | "local";
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
async function invite(body: Record<string, unknown>, bearer = API_KEY): Promise<InviteBody> {
|
|
119
|
+
const response = await post("/api/invites", body, bearer);
|
|
120
|
+
expect(response.status).toBe(201);
|
|
121
|
+
return (await response.json()) as InviteBody;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function codeOf(inviteUrl: string): string {
|
|
125
|
+
const code = new URL(inviteUrl).pathname.split("/invite/")[1];
|
|
126
|
+
if (!code) throw new Error("invite URL has no code");
|
|
127
|
+
return code;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function exchange(code: string, headers: Record<string, string> = {}): Promise<Response> {
|
|
131
|
+
return fetch(url("/api/invites/exchange"), {
|
|
132
|
+
method: "POST",
|
|
133
|
+
headers: { "Content-Type": "application/json", ...headers },
|
|
134
|
+
body: JSON.stringify({ code }),
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function roleNames(userId: string): Promise<string[]> {
|
|
139
|
+
return (await listUserRoles(userId)).map((role) => role.name).sort();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function tokenCount(userId: string): Promise<number> {
|
|
143
|
+
return (await getDbClient().query("SELECT id FROM user_tokens WHERE userId = ?", [userId]))
|
|
144
|
+
.length;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
describe("POST /api/invites", () => {
|
|
148
|
+
test("creates an invited requester, stores only the code hash, and mints no token", async () => {
|
|
149
|
+
const before = Date.now();
|
|
150
|
+
const body = await invite({ email: "ada@example.com" });
|
|
151
|
+
|
|
152
|
+
expect(body.user.status).toBe("invited");
|
|
153
|
+
expect(body.user.email).toBe("ada@example.com");
|
|
154
|
+
expect(body.user.name).toBe("ada");
|
|
155
|
+
expect(body.apiReachable).toBe("public");
|
|
156
|
+
const code = codeOf(body.inviteUrl);
|
|
157
|
+
expect(body.inviteUrl).toBe(`https://swarm.example.com/invite/${code}`);
|
|
158
|
+
expect(body.appUrl).toBe("https://app.example.com");
|
|
159
|
+
expect(code).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
160
|
+
|
|
161
|
+
const ttl = new Date(body.expiresAt).getTime() - before;
|
|
162
|
+
expect(ttl).toBeGreaterThan(INVITE_CODE_TTL_MS - 60_000);
|
|
163
|
+
expect(ttl).toBeLessThanOrEqual(INVITE_CODE_TTL_MS + 1000);
|
|
164
|
+
|
|
165
|
+
const rows = await getDbClient().query<{ code_hash: string; kind: string; used_at: null }>(
|
|
166
|
+
"SELECT code_hash, kind, used_at FROM connector_codes WHERE user_id = ?",
|
|
167
|
+
[body.user.id],
|
|
168
|
+
);
|
|
169
|
+
expect(rows).toHaveLength(1);
|
|
170
|
+
expect(rows[0]!.code_hash).toHaveLength(64);
|
|
171
|
+
expect(rows[0]!.code_hash).not.toContain(code);
|
|
172
|
+
expect(rows[0]!.kind).toBe("invite");
|
|
173
|
+
expect(rows[0]!.used_at).toBeNull();
|
|
174
|
+
expect(await tokenCount(body.user.id)).toBe(0);
|
|
175
|
+
|
|
176
|
+
// The DB trigger's default admin role is replaced, not added to.
|
|
177
|
+
expect(await roleNames(body.user.id)).toEqual(["requester"]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("honors the role and name, and falls back to the hosted dashboard", async () => {
|
|
181
|
+
delete process.env.APP_URL;
|
|
182
|
+
const body = await invite({ email: "grace@example.com", name: "Grace H", role: "viewer" });
|
|
183
|
+
expect(body.user.name).toBe("Grace H");
|
|
184
|
+
expect(await roleNames(body.user.id)).toEqual(["viewer"]);
|
|
185
|
+
expect(body.appUrl).toBe(DEFAULT_APP_URL);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("reports a loopback, private or single-label API as local", async () => {
|
|
189
|
+
for (const origin of [
|
|
190
|
+
"http://localhost:3013",
|
|
191
|
+
"http://127.0.0.1:3013",
|
|
192
|
+
"http://192.168.1.10:3013",
|
|
193
|
+
"http://swarm-api:3013",
|
|
194
|
+
"http://box.local:3013",
|
|
195
|
+
]) {
|
|
196
|
+
process.env.PUBLIC_MCP_BASE_URL = origin;
|
|
197
|
+
await getDbClient().run("DELETE FROM users");
|
|
198
|
+
const body = await invite({ email: "local@example.com" });
|
|
199
|
+
expect(body.apiReachable).toBe("local");
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("an active or suspended email (or alias) gets 409 and no code", async () => {
|
|
204
|
+
await createUser({ name: "Active", email: "active@example.com" });
|
|
205
|
+
await createUser({ name: "Suspended", email: "suspended@example.com", status: "suspended" });
|
|
206
|
+
await createUser({
|
|
207
|
+
name: "Aliased",
|
|
208
|
+
email: "main@example.com",
|
|
209
|
+
emailAliases: ["alt@example.com"],
|
|
210
|
+
});
|
|
211
|
+
for (const email of ["active@example.com", "SUSPENDED@example.com", "alt@example.com"]) {
|
|
212
|
+
const response = await post("/api/invites", { email });
|
|
213
|
+
expect(response.status).toBe(409);
|
|
214
|
+
expect(await response.json()).toEqual({ error: "user_exists" });
|
|
215
|
+
}
|
|
216
|
+
expect(await getDbClient().query("SELECT code_hash FROM connector_codes")).toHaveLength(0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("a re-invite sets the new role and replaces the old code", async () => {
|
|
220
|
+
const first = await invite({ email: "re@example.com" });
|
|
221
|
+
const second = await invite({ email: "re@example.com", role: "viewer" });
|
|
222
|
+
expect(second.user.id).toBe(first.user.id);
|
|
223
|
+
expect(await roleNames(first.user.id)).toEqual(["viewer"]);
|
|
224
|
+
const codes = await getDbClient().query("SELECT code_hash FROM connector_codes");
|
|
225
|
+
expect(codes).toHaveLength(1);
|
|
226
|
+
|
|
227
|
+
const old = await exchange(codeOf(first.inviteUrl));
|
|
228
|
+
expect(old.status).toBe(404);
|
|
229
|
+
expect(await old.json()).toEqual({ error: "code_invalid" });
|
|
230
|
+
expect((await exchange(codeOf(second.inviteUrl))).status).toBe(200);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test("rejects bad input, missing auth, and a requester caller", async () => {
|
|
234
|
+
expect((await post("/api/invites", { email: "not-an-email" })).status).toBe(400);
|
|
235
|
+
expect((await post("/api/invites", { email: "x@example.com", role: "owner" })).status).toBe(
|
|
236
|
+
400,
|
|
237
|
+
);
|
|
238
|
+
expect((await post("/api/invites", { email: "x@example.com" }, null)).status).toBe(401);
|
|
239
|
+
|
|
240
|
+
// Keep an active admin so the requester's role set is legal.
|
|
241
|
+
const admin = await createUser({ name: "Admin", email: "admin@example.com" });
|
|
242
|
+
const requester = await createUser({ name: "Req", email: "req@example.com" });
|
|
243
|
+
await setUserRoles(requester.id, ["requester"], null);
|
|
244
|
+
const { plaintext } = await mintToken(requester.id, "req", ACTOR);
|
|
245
|
+
const denied = await post("/api/invites", { email: "x@example.com" }, plaintext);
|
|
246
|
+
expect(denied.status).toBe(403);
|
|
247
|
+
|
|
248
|
+
const adminToken = (await mintToken(admin.id, "admin", ACTOR)).plaintext;
|
|
249
|
+
const allowed = await invite({ email: "x@example.com" }, adminToken);
|
|
250
|
+
const event = await getDbClient().get<{ actor: string }>(
|
|
251
|
+
"SELECT actor FROM user_identity_events WHERE userId = ? AND eventType = 'identity_added'",
|
|
252
|
+
[allowed.user.id],
|
|
253
|
+
);
|
|
254
|
+
expect(event?.actor).toBe(`user:${admin.id}`);
|
|
255
|
+
|
|
256
|
+
const users = await getDbClient().query<{ email: string }>("SELECT email FROM users");
|
|
257
|
+
expect(users.map((u) => u.email).sort()).toEqual([
|
|
258
|
+
"admin@example.com",
|
|
259
|
+
"req@example.com",
|
|
260
|
+
"x@example.com",
|
|
261
|
+
]);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test("a failed role set leaves no user and no code", async () => {
|
|
265
|
+
await getDbClient().run("UPDATE roles SET name = 'requester-gone' WHERE name = 'requester'");
|
|
266
|
+
try {
|
|
267
|
+
const response = await post("/api/invites", { email: "rollback@example.com" });
|
|
268
|
+
expect(response.status).toBe(500);
|
|
269
|
+
const text = await response.text();
|
|
270
|
+
expect(text).not.toContain("code=");
|
|
271
|
+
} finally {
|
|
272
|
+
await getDbClient().run("UPDATE roles SET name = 'requester' WHERE name = 'requester-gone'");
|
|
273
|
+
}
|
|
274
|
+
expect(await getDbClient().query("SELECT id FROM users")).toHaveLength(0);
|
|
275
|
+
expect(await getDbClient().query("SELECT code_hash FROM connector_codes")).toHaveLength(0);
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("GET /invite/:code", () => {
|
|
280
|
+
test("redirects without authentication and leaves the code available for exchange", async () => {
|
|
281
|
+
const { inviteUrl } = await invite({ email: "preview@example.com" });
|
|
282
|
+
const code = codeOf(inviteUrl);
|
|
283
|
+
const response = await fetch(url(`/invite/${code}`), { redirect: "manual" });
|
|
284
|
+
expect(response.status).toBe(302);
|
|
285
|
+
expect(response.headers.get("location")).toBe(
|
|
286
|
+
`https://app.example.com/join?swarm=${encodeURIComponent("https://swarm.example.com")}#code=${code}`,
|
|
287
|
+
);
|
|
288
|
+
expect(response.headers.get("cache-control")).toBe("no-store");
|
|
289
|
+
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
|
|
290
|
+
expect((await exchange(code)).status).toBe(200);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test("resolves global config again when an existing link opens", async () => {
|
|
294
|
+
const { inviteUrl } = await invite({ email: "config@example.com" });
|
|
295
|
+
const code = codeOf(inviteUrl);
|
|
296
|
+
await upsertSwarmConfig({
|
|
297
|
+
scope: "global",
|
|
298
|
+
key: "APP_URL",
|
|
299
|
+
value: "https://custom.example.com/",
|
|
300
|
+
});
|
|
301
|
+
await upsertSwarmConfig({
|
|
302
|
+
scope: "global",
|
|
303
|
+
key: "PUBLIC_MCP_BASE_URL",
|
|
304
|
+
value: "https://public.example.com/",
|
|
305
|
+
});
|
|
306
|
+
const response = await fetch(url(`/invite/${code}`), { redirect: "manual" });
|
|
307
|
+
expect(response.status).toBe(302);
|
|
308
|
+
expect(response.headers.get("location")).toBe(
|
|
309
|
+
`https://custom.example.com/join?swarm=${encodeURIComponent("https://public.example.com")}#code=${code}`,
|
|
310
|
+
);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("uses the hosted dashboard when no dashboard URL is configured", async () => {
|
|
314
|
+
delete process.env.APP_URL;
|
|
315
|
+
const code = "unknown_code_1234";
|
|
316
|
+
const response = await fetch(url(`/invite/${code}`), { redirect: "manual" });
|
|
317
|
+
expect(response.status).toBe(302);
|
|
318
|
+
expect(response.headers.get("location")).toBe(
|
|
319
|
+
`${DEFAULT_APP_URL}/join?swarm=${encodeURIComponent("https://swarm.example.com")}#code=${code}`,
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("rejects malformed codes without any database call", async () => {
|
|
324
|
+
const client = getDbClient();
|
|
325
|
+
const spies = [
|
|
326
|
+
spyOn(client, "query"),
|
|
327
|
+
spyOn(client, "get"),
|
|
328
|
+
spyOn(client, "run"),
|
|
329
|
+
spyOn(client, "transaction"),
|
|
330
|
+
];
|
|
331
|
+
try {
|
|
332
|
+
for (const code of ["short", "x".repeat(257), "invalid.code_12345", "invalid%20code_12345"]) {
|
|
333
|
+
const response = await fetch(url(`/invite/${code}`), { redirect: "manual" });
|
|
334
|
+
expect(response.status).toBe(404);
|
|
335
|
+
expect(await response.json()).toEqual({ error: "code_invalid" });
|
|
336
|
+
}
|
|
337
|
+
for (const spy of spies) expect(spy).not.toHaveBeenCalled();
|
|
338
|
+
} finally {
|
|
339
|
+
for (const spy of spies) spy.mockRestore();
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
describe("POST /api/invites/exchange", () => {
|
|
345
|
+
test("activates the user and returns a token that works on whoami", async () => {
|
|
346
|
+
const { user, inviteUrl } = await invite({ email: "ada@example.com" });
|
|
347
|
+
|
|
348
|
+
const response = await exchange(codeOf(inviteUrl));
|
|
349
|
+
expect(response.status).toBe(200);
|
|
350
|
+
const body = (await response.json()) as { token: string; userId: string };
|
|
351
|
+
expect(body.userId).toBe(user.id);
|
|
352
|
+
expect(body.token.startsWith("aswt_")).toBe(true);
|
|
353
|
+
|
|
354
|
+
const whoami = await fetch(url("/api/whoami"), {
|
|
355
|
+
headers: { Authorization: `Bearer ${body.token}` },
|
|
356
|
+
});
|
|
357
|
+
expect(whoami.status).toBe(200);
|
|
358
|
+
const me = (await whoami.json()) as { kind: string; user: { id: string; status: string } };
|
|
359
|
+
expect(me.kind).toBe("user");
|
|
360
|
+
expect(me.user.id).toBe(user.id);
|
|
361
|
+
expect(me.user.status).toBe("active");
|
|
362
|
+
|
|
363
|
+
const status = await getDbClient().get<{ beforeJson: string; afterJson: string }>(
|
|
364
|
+
"SELECT beforeJson, afterJson FROM user_identity_events WHERE userId = ? AND eventType = 'status_changed'",
|
|
365
|
+
[user.id],
|
|
366
|
+
);
|
|
367
|
+
expect(JSON.parse(status!.beforeJson).status).toBe("invited");
|
|
368
|
+
expect(JSON.parse(status!.afterJson).status).toBe("active");
|
|
369
|
+
const minted = await getDbClient().get<{ afterJson: string }>(
|
|
370
|
+
"SELECT afterJson FROM user_identity_events WHERE userId = ? AND eventType = 'token_minted'",
|
|
371
|
+
[user.id],
|
|
372
|
+
);
|
|
373
|
+
expect(JSON.parse(minted!.afterJson).source).toBe("invite_exchange");
|
|
374
|
+
expect(await roleNames(user.id)).toEqual(["requester"]);
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
test("a second exchange, an expired code and an unknown code all get the same 404", async () => {
|
|
378
|
+
const { user, inviteUrl } = await invite({ email: "once@example.com" });
|
|
379
|
+
const code = codeOf(inviteUrl);
|
|
380
|
+
expect((await exchange(code)).status).toBe(200);
|
|
381
|
+
const again = await exchange(code);
|
|
382
|
+
expect(again.status).toBe(404);
|
|
383
|
+
expect(await again.json()).toEqual({ error: "code_invalid" });
|
|
384
|
+
|
|
385
|
+
const expiredInvite = await invite({ email: "late@example.com" });
|
|
386
|
+
await getDbClient().run("UPDATE connector_codes SET expires_at = ? WHERE user_id = ?", [
|
|
387
|
+
new Date(Date.now() - 1000).toISOString(),
|
|
388
|
+
expiredInvite.user.id,
|
|
389
|
+
]);
|
|
390
|
+
const expired = await exchange(codeOf(expiredInvite.inviteUrl));
|
|
391
|
+
expect(expired.status).toBe(404);
|
|
392
|
+
expect(await expired.json()).toEqual({ error: "code_invalid" });
|
|
393
|
+
expect(await tokenCount(expiredInvite.user.id)).toBe(0);
|
|
394
|
+
|
|
395
|
+
const unknown = await exchange("A".repeat(43));
|
|
396
|
+
expect(unknown.status).toBe(404);
|
|
397
|
+
expect(await unknown.json()).toEqual({ error: "code_invalid" });
|
|
398
|
+
expect(await tokenCount(user.id)).toBe(1);
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("a user suspended before the exchange gets 404 and no token", async () => {
|
|
402
|
+
const { user, inviteUrl } = await invite({ email: "gone@example.com" });
|
|
403
|
+
await getDbClient().run("UPDATE users SET status = 'suspended' WHERE id = ?", [user.id]);
|
|
404
|
+
const response = await exchange(codeOf(inviteUrl));
|
|
405
|
+
expect(response.status).toBe(404);
|
|
406
|
+
expect(await response.json()).toEqual({ error: "code_invalid" });
|
|
407
|
+
expect(await tokenCount(user.id)).toBe(0);
|
|
408
|
+
const row = await getDbClient().get<{ status: string }>(
|
|
409
|
+
"SELECT status FROM users WHERE id = ?",
|
|
410
|
+
[user.id],
|
|
411
|
+
);
|
|
412
|
+
expect(row?.status).toBe("suspended");
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
test("a connector code fails on the invite route, and an invite code on the connector route", async () => {
|
|
416
|
+
const connectorUser = await createUser({ name: "Connector" });
|
|
417
|
+
const { code: connectorCode } = await createConnectorCode(connectorUser.id, "c", ACTOR);
|
|
418
|
+
expect((await exchange(connectorCode)).status).toBe(404);
|
|
419
|
+
|
|
420
|
+
const { user, inviteUrl } = await invite({ email: "kind@example.com" });
|
|
421
|
+
const inviteCode = codeOf(inviteUrl);
|
|
422
|
+
const viaConnector = await fetch(url("/api/connector/exchange"), {
|
|
423
|
+
method: "POST",
|
|
424
|
+
headers: { "Content-Type": "application/json" },
|
|
425
|
+
body: JSON.stringify({ code: inviteCode }),
|
|
426
|
+
});
|
|
427
|
+
expect(viaConnector.status).toBe(404);
|
|
428
|
+
expect(await tokenCount(connectorUser.id)).toBe(0);
|
|
429
|
+
expect(await tokenCount(user.id)).toBe(0);
|
|
430
|
+
|
|
431
|
+
// Both codes still work on their own route.
|
|
432
|
+
expect((await exchange(inviteCode)).status).toBe(200);
|
|
433
|
+
const connectorOk = await fetch(url("/api/connector/exchange"), {
|
|
434
|
+
method: "POST",
|
|
435
|
+
headers: { "Content-Type": "application/json" },
|
|
436
|
+
body: JSON.stringify({ code: connectorCode }),
|
|
437
|
+
});
|
|
438
|
+
expect(connectorOk.status).toBe(200);
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
test("rejects malformed codes with 404 and oversized bodies with 413", async () => {
|
|
442
|
+
const { inviteUrl } = await invite({ email: "shape@example.com" });
|
|
443
|
+
const code = codeOf(inviteUrl);
|
|
444
|
+
expect((await exchange(code.slice(0, 42))).status).toBe(404);
|
|
445
|
+
const oversized = await fetch(url("/api/invites/exchange"), {
|
|
446
|
+
method: "POST",
|
|
447
|
+
headers: { "Content-Type": "application/json" },
|
|
448
|
+
body: JSON.stringify({ code: "x".repeat(4096) }),
|
|
449
|
+
});
|
|
450
|
+
expect(oversized.status).toBe(413);
|
|
451
|
+
expect((await exchange(code)).status).toBe(200);
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("is rate limited to 10 per minute per IP", async () => {
|
|
455
|
+
const ip = { "X-Forwarded-For": "203.0.113.9" };
|
|
456
|
+
for (let i = 0; i < 10; i++) {
|
|
457
|
+
expect((await exchange(`unknown-${i}`, ip)).status).toBe(404);
|
|
458
|
+
}
|
|
459
|
+
const limited = await exchange("unknown-11", ip);
|
|
460
|
+
expect(limited.status).toBe(429);
|
|
461
|
+
expect(limited.headers.get("retry-after")).toBe("6");
|
|
462
|
+
expect((await exchange("other-ip", { "X-Forwarded-For": "203.0.113.10" })).status).toBe(404);
|
|
463
|
+
});
|
|
464
|
+
});
|