@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,369 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
|
+
import { Readable } from "node:stream";
|
|
3
|
+
import { getApiKey } from "../utils/api-key";
|
|
4
|
+
import { isAgentFsProxyEnabled } from "../utils/constants";
|
|
5
|
+
import { resolveHttpRequestAuth } from "./auth";
|
|
6
|
+
import { clientIp, createIpRateLimiter } from "./ip-rate-limit";
|
|
7
|
+
import { route } from "./route-def";
|
|
8
|
+
import { jsonError, parseBody, RequestBodyTooLargeError } from "./utils";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Streaming pass-through to the agent-fs server at AGENT_FS_API_URL, active
|
|
12
|
+
* only when AGENT_FS_PROXY is on:
|
|
13
|
+
*
|
|
14
|
+
* - `/agentfs/*` -> agent-fs `/*` (the API Comb calls from the browser);
|
|
15
|
+
* - `/share/*` and `/site/*` -> agent-fs `/share/*` and `/site/*` unchanged.
|
|
16
|
+
* agent-fs share pages embed root-absolute `/share/<token>/raw` URLs and
|
|
17
|
+
* redirect to `/site/<token>/`, so they 404 behind the `/agentfs` prefix alone.
|
|
18
|
+
*
|
|
19
|
+
* agent-fs enforces its own `af_` bearer. The one swarm check is on
|
|
20
|
+
* `POST /agentfs/auth/register` (see `checkRegister`). Only an
|
|
21
|
+
* `Authorization: Bearer af_...` credential is forwarded (never a swarm API key
|
|
22
|
+
* or an `aswt_` token), plus an allowlist of other request headers (never
|
|
23
|
+
* Cookie or X-Agent-ID). On `/agentfs/*` the upstream CORS headers are dropped
|
|
24
|
+
* (the swarm's own setCorsHeaders applies); on `/share/*` and `/site/*` they
|
|
25
|
+
* are kept, because sandboxed site pages run in an opaque origin and need
|
|
26
|
+
* agent-fs's `Access-Control-Allow-Origin: *`. Both bodies are streamed.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const API_PREFIX = "/agentfs";
|
|
30
|
+
const PUBLIC_PREFIXES = ["/share/", "/site/"];
|
|
31
|
+
|
|
32
|
+
function upstreamBase(): string | null {
|
|
33
|
+
const v = process.env.AGENT_FS_API_URL?.trim().replace(/\/+$/, "");
|
|
34
|
+
return v || null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Request headers forwarded as is. `authorization` is handled separately. */
|
|
38
|
+
const REQUEST_HEADER_ALLOWLIST = [
|
|
39
|
+
"content-type",
|
|
40
|
+
// Framing for streamed uploads: agent-fs checks it against its upload cap.
|
|
41
|
+
"content-length",
|
|
42
|
+
"accept",
|
|
43
|
+
"if-match",
|
|
44
|
+
"if-none-match",
|
|
45
|
+
"range",
|
|
46
|
+
"x-agent-fs-message",
|
|
47
|
+
"x-agent-fs-message-encoding",
|
|
48
|
+
];
|
|
49
|
+
const AGENT_FS_BEARER = /^Bearer\s+af_\S+$/i;
|
|
50
|
+
|
|
51
|
+
/** Response headers copied from agent-fs (plus any `x-agent-fs-*`). */
|
|
52
|
+
const RESPONSE_HEADER_ALLOWLIST = new Set([
|
|
53
|
+
"content-type",
|
|
54
|
+
"content-length",
|
|
55
|
+
"content-disposition",
|
|
56
|
+
"content-range",
|
|
57
|
+
"accept-ranges",
|
|
58
|
+
"etag",
|
|
59
|
+
"last-modified",
|
|
60
|
+
"cache-control",
|
|
61
|
+
"location",
|
|
62
|
+
"retry-after",
|
|
63
|
+
// agent-fs serves user files (HTML included) with a sandbox CSP. Dropping
|
|
64
|
+
// these would run that content in the API's origin.
|
|
65
|
+
"content-security-policy",
|
|
66
|
+
"x-content-type-options",
|
|
67
|
+
"x-frame-options",
|
|
68
|
+
"referrer-policy",
|
|
69
|
+
"x-robots-tag",
|
|
70
|
+
"cross-origin-resource-policy",
|
|
71
|
+
]);
|
|
72
|
+
const EXPOSED_HEADERS = ["X-Agent-FS-Version", "X-Agent-FS-Content-Hash", "Content-Disposition"];
|
|
73
|
+
|
|
74
|
+
const UNGATED =
|
|
75
|
+
"agent-fs enforces its own af_ auth; register needs a swarm credential; swarm credentials are never forwarded";
|
|
76
|
+
const OK_200 = {
|
|
77
|
+
description: "Upstream agent-fs response, streamed verbatim",
|
|
78
|
+
unstructured: "Body is streamed from agent-fs; shape and content-type vary per upstream route",
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
for (const method of ["get", "post", "put", "delete", "patch"] as const) {
|
|
82
|
+
route({
|
|
83
|
+
method,
|
|
84
|
+
path: "/agentfs/{path}",
|
|
85
|
+
pattern: ["agentfs", null],
|
|
86
|
+
exact: false,
|
|
87
|
+
summary: "Streaming pass-through proxy to agent-fs (enabled by AGENT_FS_PROXY)",
|
|
88
|
+
tags: ["Comb"],
|
|
89
|
+
responses: {
|
|
90
|
+
200: OK_200,
|
|
91
|
+
401: {
|
|
92
|
+
description:
|
|
93
|
+
"Returned by agent-fs when credentials are missing or invalid, or by the swarm when POST /agentfs/auth/register has no swarm credential",
|
|
94
|
+
},
|
|
95
|
+
403: {
|
|
96
|
+
description:
|
|
97
|
+
"POST /agentfs/auth/register with a user token for an email that is not the user's",
|
|
98
|
+
},
|
|
99
|
+
413: { description: "POST /agentfs/auth/register body over 16 KB" },
|
|
100
|
+
429: { description: "Too many registrations from this IP (POST /agentfs/auth/register)" },
|
|
101
|
+
502: { description: "agent-fs is unreachable" },
|
|
102
|
+
},
|
|
103
|
+
auth: { apiKey: false },
|
|
104
|
+
...(method === "get" ? {} : { rbac: { ungated: UNGATED } }),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
for (const prefix of ["share", "site"] as const) {
|
|
109
|
+
route({
|
|
110
|
+
method: "get",
|
|
111
|
+
path: `/${prefix}/{path}`,
|
|
112
|
+
pattern: [prefix, null],
|
|
113
|
+
exact: false,
|
|
114
|
+
summary: `Public agent-fs ${prefix} page, served through the API (enabled by AGENT_FS_PROXY)`,
|
|
115
|
+
tags: ["Comb"],
|
|
116
|
+
responses: {
|
|
117
|
+
200: {
|
|
118
|
+
description: `agent-fs /${prefix} response, streamed verbatim`,
|
|
119
|
+
unstructured: "HTML page or raw file streamed from agent-fs; content-type varies",
|
|
120
|
+
},
|
|
121
|
+
404: { description: "Unknown or expired link (from agent-fs), or the proxy is off" },
|
|
122
|
+
502: { description: "agent-fs is unreachable" },
|
|
123
|
+
},
|
|
124
|
+
auth: { apiKey: false },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** agent-fs `POST /auth/register` is public and unlimited upstream: 5 per minute per IP here. */
|
|
129
|
+
const registerRateLimiter = createIpRateLimiter({ capacity: 5, refillPerMs: 5 / 60_000 });
|
|
130
|
+
|
|
131
|
+
export function _resetAgentFsRegisterRateLimitForTests(): void {
|
|
132
|
+
registerRateLimiter.reset();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const REGISTER_BODY_MAX_BYTES = 16 * 1024;
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* agent-fs `POST /auth/register` does not check that the caller owns the
|
|
139
|
+
* email, and the swarm invites its users' emails to the shared drive. An
|
|
140
|
+
* account made first for a teammate's email would get that teammate's seat.
|
|
141
|
+
* So a registration through the proxy needs a swarm credential: the operator
|
|
142
|
+
* key may register any email, a user token only the user's own email or an
|
|
143
|
+
* alias. Returns the JSON body to send upstream, or null after it wrote the
|
|
144
|
+
* error response.
|
|
145
|
+
*/
|
|
146
|
+
async function checkRegister(req: IncomingMessage, res: ServerResponse): Promise<string | null> {
|
|
147
|
+
const auth = await resolveHttpRequestAuth(req, getApiKey());
|
|
148
|
+
if (!auth || auth.page || (auth.kind !== "operator" && auth.kind !== "user")) {
|
|
149
|
+
jsonError(res, "Creating an agent-fs account here needs a swarm API key or user token", 401);
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
let body: unknown;
|
|
153
|
+
try {
|
|
154
|
+
body = await parseBody(req, REGISTER_BODY_MAX_BYTES);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
if (err instanceof RequestBodyTooLargeError) jsonError(res, "Request body too large", 413);
|
|
157
|
+
else jsonError(res, "Invalid JSON body", 400);
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) {
|
|
161
|
+
jsonError(res, "Invalid JSON body", 400);
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
if (auth.kind === "user") {
|
|
165
|
+
const email = (body as { email?: unknown }).email;
|
|
166
|
+
const own = [auth.user.email, ...auth.user.emailAliases]
|
|
167
|
+
.filter((e): e is string => typeof e === "string")
|
|
168
|
+
.map((e) => e.trim().toLowerCase());
|
|
169
|
+
if (typeof email !== "string" || !own.includes(email.trim().toLowerCase())) {
|
|
170
|
+
jsonError(res, "You can create an agent-fs account only for your own email", 403);
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
// Re-encoded, so agent-fs reads the same email this check read.
|
|
175
|
+
return JSON.stringify(body);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The path as an upstream router would match it: percent-decoded, repeated
|
|
180
|
+
* slashes collapsed, trailing slash dropped. `/auth/%72egister` and
|
|
181
|
+
* `//auth/register/` must still hit the register limit.
|
|
182
|
+
*/
|
|
183
|
+
function routedPath(pathOnly: string): string {
|
|
184
|
+
let decoded = pathOnly;
|
|
185
|
+
try {
|
|
186
|
+
decoded = decodeURIComponent(pathOnly);
|
|
187
|
+
} catch {
|
|
188
|
+
// A malformed escape: compare the raw bytes.
|
|
189
|
+
}
|
|
190
|
+
return decoded.replace(/\/{2,}/g, "/").replace(/\/+$/, "");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* A path fetch() or the upstream router would rewrite: dot segments (raw or
|
|
195
|
+
* percent-encoded, also behind an encoded `%2F`) and backslashes, which the
|
|
196
|
+
* URL parser treats as `/`. Rejecting them keeps the register limit and the
|
|
197
|
+
* upstream on the same path: `/./auth/register` and `/auth\register` both
|
|
198
|
+
* reach upstream `/auth/register`.
|
|
199
|
+
*/
|
|
200
|
+
function isNonCanonical(pathOnly: string): boolean {
|
|
201
|
+
let decoded = pathOnly;
|
|
202
|
+
try {
|
|
203
|
+
decoded = decodeURIComponent(pathOnly);
|
|
204
|
+
} catch {
|
|
205
|
+
// A malformed escape: check the raw bytes only.
|
|
206
|
+
}
|
|
207
|
+
for (const p of [pathOnly, decoded]) {
|
|
208
|
+
if (p.includes("\\")) return true;
|
|
209
|
+
if (p.split("/").some((s) => s === "." || s === "..")) return true;
|
|
210
|
+
}
|
|
211
|
+
// `.%2e` and the like, in case the decode above failed on a malformed escape.
|
|
212
|
+
return pathOnly.split("/").some((s) => /^(\.|%2e){1,2}$/i.test(s));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function handleAgentFsProxy(
|
|
216
|
+
req: IncomingMessage,
|
|
217
|
+
res: ServerResponse,
|
|
218
|
+
): Promise<boolean> {
|
|
219
|
+
const url = req.url ?? "";
|
|
220
|
+
const isApi = url.startsWith(`${API_PREFIX}/`);
|
|
221
|
+
const isPublicPage = !isApi && PUBLIC_PREFIXES.some((p) => url.startsWith(p));
|
|
222
|
+
if (!isApi && !isPublicPage) return false;
|
|
223
|
+
const base = upstreamBase();
|
|
224
|
+
if (!base || !isAgentFsProxyEnabled()) return false;
|
|
225
|
+
const method = req.method ?? "GET";
|
|
226
|
+
const allowed = isApi ? ["GET", "POST", "PUT", "DELETE", "PATCH"] : ["GET"];
|
|
227
|
+
if (!allowed.includes(method)) return false;
|
|
228
|
+
|
|
229
|
+
// Keep the path and query bytes as sent, so an encoded `%2F` survives.
|
|
230
|
+
const upstreamPath = isApi ? url.slice(API_PREFIX.length) : url;
|
|
231
|
+
const queryStart = upstreamPath.indexOf("?");
|
|
232
|
+
const pathOnly = queryStart === -1 ? upstreamPath : upstreamPath.slice(0, queryStart);
|
|
233
|
+
if (isNonCanonical(pathOnly)) {
|
|
234
|
+
jsonError(res, "Invalid path", 400);
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
// Limit on the path fetch() will send, not on our own parse of the raw
|
|
238
|
+
// target: fetch drops a raw `#fragment`, so `/auth/register#x` reaches
|
|
239
|
+
// upstream `/auth/register`.
|
|
240
|
+
const upstreamUrl = `${base}${upstreamPath}`;
|
|
241
|
+
let sentPath: string;
|
|
242
|
+
try {
|
|
243
|
+
// Strip the base URL's own path prefix, if AGENT_FS_API_URL carries one.
|
|
244
|
+
const basePath = new URL(base).pathname.replace(/\/+$/, "");
|
|
245
|
+
sentPath = new URL(upstreamUrl).pathname.slice(basePath.length);
|
|
246
|
+
} catch {
|
|
247
|
+
jsonError(res, "Invalid path", 400);
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
const isRegister = isApi && method === "POST" && routedPath(sentPath) === "/auth/register";
|
|
251
|
+
if (isRegister && !registerRateLimiter.take(clientIp(req))) {
|
|
252
|
+
res.setHeader("Retry-After", "12");
|
|
253
|
+
jsonError(res, "rate_limited", 429);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
const registerBody = isRegister ? await checkRegister(req, res) : null;
|
|
257
|
+
if (isRegister && registerBody === null) return true;
|
|
258
|
+
|
|
259
|
+
const headers = new Headers();
|
|
260
|
+
const authorization = req.headers.authorization?.trim();
|
|
261
|
+
// Only an agent-fs key goes upstream: never on register (the swarm credential
|
|
262
|
+
// authorized it), and never the swarm API key, even when it looks like one.
|
|
263
|
+
if (
|
|
264
|
+
registerBody === null &&
|
|
265
|
+
authorization &&
|
|
266
|
+
AGENT_FS_BEARER.test(authorization) &&
|
|
267
|
+
authorization.replace(/^bearer\s+/i, "") !== getApiKey()
|
|
268
|
+
) {
|
|
269
|
+
headers.set("authorization", authorization);
|
|
270
|
+
}
|
|
271
|
+
for (const name of REQUEST_HEADER_ALLOWLIST) {
|
|
272
|
+
const v = req.headers[name];
|
|
273
|
+
if (typeof v === "string") headers.set(name, v);
|
|
274
|
+
}
|
|
275
|
+
if (registerBody !== null) {
|
|
276
|
+
// The body was read and re-encoded: fetch sets the new length.
|
|
277
|
+
headers.delete("content-length");
|
|
278
|
+
headers.set("content-type", "application/json");
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const ac = new AbortController();
|
|
282
|
+
res.on("close", () => {
|
|
283
|
+
if (!res.writableEnded) ac.abort();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
const hasBody = method !== "GET" && method !== "DELETE";
|
|
287
|
+
let upstream: Response;
|
|
288
|
+
try {
|
|
289
|
+
const init: RequestInit & { duplex?: "half" } = {
|
|
290
|
+
method,
|
|
291
|
+
headers,
|
|
292
|
+
redirect: "manual",
|
|
293
|
+
signal: ac.signal,
|
|
294
|
+
};
|
|
295
|
+
if (registerBody !== null) {
|
|
296
|
+
init.body = registerBody;
|
|
297
|
+
} else if (hasBody) {
|
|
298
|
+
// Stream the request body; Bun and undici need duplex "half" for that.
|
|
299
|
+
init.body = Readable.toWeb(req) as RequestInit["body"];
|
|
300
|
+
init.duplex = "half";
|
|
301
|
+
}
|
|
302
|
+
upstream = await fetch(upstreamUrl, init);
|
|
303
|
+
} catch {
|
|
304
|
+
if (!res.headersSent && !res.destroyed) jsonError(res, "agent-fs upstream unavailable", 502);
|
|
305
|
+
else res.destroy();
|
|
306
|
+
return true;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (isPublicPage) {
|
|
310
|
+
// agent-fs owns CORS on public pages: drop the swarm's echo before copying its headers.
|
|
311
|
+
for (const name of res.getHeaderNames()) {
|
|
312
|
+
if (name.startsWith("access-control-")) res.removeHeader(name);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
const encoded = upstream.headers.has("content-encoding");
|
|
316
|
+
upstream.headers.forEach((value, name) => {
|
|
317
|
+
const n = name.toLowerCase();
|
|
318
|
+
const keep =
|
|
319
|
+
RESPONSE_HEADER_ALLOWLIST.has(n) ||
|
|
320
|
+
n.startsWith("x-agent-fs-") ||
|
|
321
|
+
(isPublicPage && n.startsWith("access-control-"));
|
|
322
|
+
if (!keep) return;
|
|
323
|
+
// fetch() already decoded the body, so the encoded length no longer applies.
|
|
324
|
+
if (encoded && n === "content-length") return;
|
|
325
|
+
res.setHeader(name, value);
|
|
326
|
+
});
|
|
327
|
+
if (isApi) {
|
|
328
|
+
// Let allowlisted browser origins read the agent-fs response headers (the base
|
|
329
|
+
// list in setCorsHeaders only covers Content-Type, Content-Length, ETag, Location).
|
|
330
|
+
const expose = res.getHeader("Access-Control-Expose-Headers");
|
|
331
|
+
if (typeof expose === "string" && expose !== "*") {
|
|
332
|
+
res.setHeader("Access-Control-Expose-Headers", `${expose}, ${EXPOSED_HEADERS.join(", ")}`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
res.writeHead(upstream.status);
|
|
336
|
+
if (!upstream.body) {
|
|
337
|
+
res.end();
|
|
338
|
+
return true;
|
|
339
|
+
}
|
|
340
|
+
await pump(upstream.body, res);
|
|
341
|
+
return true;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Copy the upstream body to `res`, waiting for `drain` so a slow client never buffers the file. */
|
|
345
|
+
async function pump(body: ReadableStream<Uint8Array>, res: ServerResponse): Promise<void> {
|
|
346
|
+
const reader = body.getReader();
|
|
347
|
+
const closed = new Promise<void>((resolve) => {
|
|
348
|
+
if (res.destroyed) resolve();
|
|
349
|
+
else res.once("close", () => resolve());
|
|
350
|
+
});
|
|
351
|
+
try {
|
|
352
|
+
for (;;) {
|
|
353
|
+
if (res.destroyed) break;
|
|
354
|
+
const { done, value } = await reader.read();
|
|
355
|
+
if (done) {
|
|
356
|
+
res.end();
|
|
357
|
+
break;
|
|
358
|
+
}
|
|
359
|
+
if (!res.write(value)) {
|
|
360
|
+
await Promise.race([new Promise((resolve) => res.once("drain", resolve)), closed]);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
} catch {
|
|
364
|
+
res.destroy();
|
|
365
|
+
} finally {
|
|
366
|
+
reader.cancel().catch(() => {});
|
|
367
|
+
}
|
|
368
|
+
await closed;
|
|
369
|
+
}
|
package/src/http/all-routes.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { decryptSecret, encryptSecret, getEncryptionKey } from "../be/crypto";
|
|
4
|
-
import { getDbClient, upsertSwarmConfig } from "../be/db";
|
|
4
|
+
import { getDbClient, recordKeyValidation, upsertSwarmConfig } from "../be/db";
|
|
5
5
|
import { getCodexDeviceFlow, upsertCodexDeviceFlow } from "../be/db/codex-oauth-device-flows";
|
|
6
6
|
import { updateOnboardingAiFromCodexDevice } from "../be/onboarding";
|
|
7
|
+
import { deriveCodexKeySuffix } from "../providers/codex-oauth/auth-json";
|
|
7
8
|
import {
|
|
8
9
|
CODEX_DEVICE_VERIFICATION_URL,
|
|
9
10
|
DeviceCodeNotEnabledError,
|
|
@@ -214,6 +215,12 @@ async function completeFlow(
|
|
|
214
215
|
isSecret: true,
|
|
215
216
|
description: `Codex ChatGPT OAuth credentials slot ${slot} (stored by dashboard device login)`,
|
|
216
217
|
});
|
|
218
|
+
// OpenAI just issued this token. Record the account as checked, so the
|
|
219
|
+
// account list says "Signed in" now: a worker's Codex check is a presence
|
|
220
|
+
// check and reports no per-account result.
|
|
221
|
+
const keySuffix = deriveCodexKeySuffix(credentials.access, credentials.accountId);
|
|
222
|
+
const checked = [{ keyIndex: slot, keySuffix, ok: true }];
|
|
223
|
+
await recordKeyValidation("CODEX_OAUTH", checked, "global", null, { retireAbsent: false });
|
|
217
224
|
await updateOnboardingAiFromCodexDevice({ status: "complete" });
|
|
218
225
|
state.status = "complete";
|
|
219
226
|
state.slot = slot;
|
package/src/http/fs.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "node:http";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { deleteTaskAttachment, getTaskAttachments, getTaskById } from "../be/db";
|
|
4
4
|
import { appendDraftTaskText } from "../be/db/tasks/draft-text";
|
|
5
|
+
import { getUserGrant } from "../be/rbac-roles";
|
|
5
6
|
import {
|
|
6
7
|
ensureAgentFsCredentialsForAgent,
|
|
7
8
|
inviteEmailToSharedOrg,
|
|
@@ -14,10 +15,10 @@ import {
|
|
|
14
15
|
} from "../be/task-attachment-store";
|
|
15
16
|
import { type FileObject, FilesError, normalizeFilesError } from "../fs/provider";
|
|
16
17
|
import { getFileStorageProvider } from "../fs/registry";
|
|
17
|
-
import { can, type RbacResource } from "../rbac";
|
|
18
|
+
import { can, isRbacEnabled, type RbacResource } from "../rbac";
|
|
18
19
|
import { type TaskAttachment, TaskAttachmentSchema } from "../types";
|
|
19
20
|
import { attachmentContentDisposition } from "../utils/content-disposition";
|
|
20
|
-
import { getCurrentRequestAuth } from "../utils/request-auth-context";
|
|
21
|
+
import { getCurrentRequestAuth, getRequestAuth } from "../utils/request-auth-context";
|
|
21
22
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
22
23
|
import { transcribeAudioNotes } from "../utils/transcription";
|
|
23
24
|
import { requestPrincipal } from "./request-principal";
|
|
@@ -66,7 +67,20 @@ const agentFsCredentialStateSchema = z.object({
|
|
|
66
67
|
|
|
67
68
|
const agentFsInviteStateSchema = z.object({
|
|
68
69
|
orgId: z.string(),
|
|
69
|
-
invited: z.boolean(),
|
|
70
|
+
invited: z.boolean().describe("False only when the email already holds the requested role."),
|
|
71
|
+
status: z
|
|
72
|
+
.enum(["already_member", "added", "pending"])
|
|
73
|
+
.describe(
|
|
74
|
+
"already_member: no change. added: an existing agent-fs account joined the org. pending: the email has no account, so agent-fs created an invite the person must accept.",
|
|
75
|
+
),
|
|
76
|
+
inviteId: z.string().optional().describe("Pending invite id (status pending)."),
|
|
77
|
+
expiresAt: z.string().optional().describe("ISO time the accept link expires (status pending)."),
|
|
78
|
+
acceptUrl: z
|
|
79
|
+
.string()
|
|
80
|
+
.optional()
|
|
81
|
+
.describe(
|
|
82
|
+
"Link the invitee opens to accept (status pending). agent-fs sends no email, so hand it to the person. Absent when agent-fs has no app URL configured.",
|
|
83
|
+
),
|
|
70
84
|
});
|
|
71
85
|
|
|
72
86
|
const signedUrlResponseSchema = z.object({
|
|
@@ -112,22 +126,26 @@ const inviteMemberRoute = route({
|
|
|
112
126
|
pattern: ["api", "fs", "members", "invite"],
|
|
113
127
|
summary: "Invite an external member into the agent-fs shared org",
|
|
114
128
|
description:
|
|
115
|
-
"The API server performs the invite with its own bootstrap credentials (which are API-only and never served over HTTP), provisioning the shared org/drive first when needed. Intended for the cloud control plane's Connect-to-Drive flow. No keys are returned; the invitee obtains their own key via agent-fs registration.",
|
|
129
|
+
"The API server performs the invite with its own bootstrap credentials (which are API-only and never served over HTTP), provisioning the shared org/drive first when needed. Intended for the cloud control plane's Connect-to-Drive flow. An email with an agent-fs account is added directly (status added). An email without one gets a pending invite (status pending) with an acceptUrl when agent-fs returns one; agent-fs sends no email, so the caller must hand the link to the invitee. No keys are returned; the invitee obtains their own key via agent-fs registration.",
|
|
116
130
|
tags: ["FS"],
|
|
117
131
|
body: z.object({
|
|
118
132
|
email: z.email(),
|
|
119
133
|
role: z.enum(["viewer", "editor", "admin"]).default("editor"),
|
|
120
134
|
}),
|
|
121
135
|
responses: {
|
|
122
|
-
200: {
|
|
136
|
+
200: {
|
|
137
|
+
description: "Invite state ({ orgId, invited, status, acceptUrl? })",
|
|
138
|
+
schema: agentFsInviteStateSchema,
|
|
139
|
+
},
|
|
123
140
|
400: { description: "Invalid body" },
|
|
141
|
+
403: { description: "A non-admin user token named an email other than its own" },
|
|
124
142
|
500: { description: "Provisioning or invite failed" },
|
|
125
143
|
},
|
|
126
144
|
auth: { apiKey: true },
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
},
|
|
145
|
+
// The operator key and grant-all users invite any email at any role. Other
|
|
146
|
+
// users may only join with their own account email, at a role the handler
|
|
147
|
+
// derives from their grant.
|
|
148
|
+
rbac: { permission: "fs.join.own" },
|
|
131
149
|
});
|
|
132
150
|
|
|
133
151
|
const listTaskFilesRoute = route({
|
|
@@ -234,6 +252,37 @@ const deleteTaskFileRoute = route({
|
|
|
234
252
|
rbac: { permission: "task.fs.mutate" },
|
|
235
253
|
});
|
|
236
254
|
|
|
255
|
+
type InviteRole = "viewer" | "editor" | "admin";
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The email and role an invite runs with. The operator key and grant-all users
|
|
259
|
+
* keep the body as sent. Any other user joins only as itself: the body email
|
|
260
|
+
* must match the account email (ignoring case), the stored spelling is used,
|
|
261
|
+
* and the role is `editor` when the user may change task files, else `viewer`.
|
|
262
|
+
*/
|
|
263
|
+
async function resolveInvite(
|
|
264
|
+
req: IncomingMessage,
|
|
265
|
+
body: { email: string; role: InviteRole },
|
|
266
|
+
): Promise<{ email: string; role: InviteRole } | { denied: string }> {
|
|
267
|
+
const auth = getRequestAuth(req);
|
|
268
|
+
if (auth?.kind !== "user" || !isRbacEnabled()) return body;
|
|
269
|
+
const grant = await getUserGrant(auth.userId);
|
|
270
|
+
if (grant.grantsAll) return body;
|
|
271
|
+
const decision = can({
|
|
272
|
+
principal: { kind: "user", userId: auth.userId },
|
|
273
|
+
verb: "fs.join.own",
|
|
274
|
+
resource: { kind: "none" },
|
|
275
|
+
source: "http",
|
|
276
|
+
});
|
|
277
|
+
// Admission already required the role to hold `fs.join.own`.
|
|
278
|
+
if (!decision.allow) return { denied: decision.reason };
|
|
279
|
+
const ownEmail = auth.user.email?.trim();
|
|
280
|
+
if (!ownEmail || ownEmail.toLowerCase() !== body.email.trim().toLowerCase()) {
|
|
281
|
+
return { denied: "You can only join the shared drive with your own account email" };
|
|
282
|
+
}
|
|
283
|
+
return { email: ownEmail, role: grant.verbs.has("task.fs.mutate") ? "editor" : "viewer" };
|
|
284
|
+
}
|
|
285
|
+
|
|
237
286
|
export async function handleFs(
|
|
238
287
|
req: IncomingMessage,
|
|
239
288
|
res: ServerResponse,
|
|
@@ -264,8 +313,13 @@ export async function handleFs(
|
|
|
264
313
|
if (inviteMemberRoute.match(req.method, pathSegments)) {
|
|
265
314
|
const parsed = await inviteMemberRoute.parse(req, res, pathSegments, queryParams);
|
|
266
315
|
if (!parsed) return true;
|
|
316
|
+
const invite = await resolveInvite(req, parsed.body);
|
|
317
|
+
if ("denied" in invite) {
|
|
318
|
+
jsonError(res, invite.denied, 403);
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
267
321
|
try {
|
|
268
|
-
const result = await inviteEmailToSharedOrg(
|
|
322
|
+
const result = await inviteEmailToSharedOrg(invite.email, invite.role);
|
|
269
323
|
inviteMemberRoute.respond(res, 200, result);
|
|
270
324
|
} catch (error) {
|
|
271
325
|
const message = scrubSecrets(error instanceof Error ? error.message : String(error));
|
package/src/http/inbox-state.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { getDbClient, listInboxState, upsertInboxState } from "../be/db";
|
|
4
|
+
import { getUserGrant } from "../be/rbac-roles";
|
|
5
|
+
import { isRbacEnabled } from "../rbac";
|
|
4
6
|
import { InboxItemStateSchema, InboxItemStatusSchema, InboxItemTypeSchema } from "../types";
|
|
7
|
+
import { getRequestAuth } from "../utils/request-auth-context";
|
|
5
8
|
import { route } from "./route-def";
|
|
6
9
|
import { jsonError } from "./utils";
|
|
7
10
|
|
|
@@ -59,8 +62,10 @@ const upsertState = route({
|
|
|
59
62
|
},
|
|
60
63
|
400: { description: "Validation error" },
|
|
61
64
|
401: { description: "Unauthorized" },
|
|
65
|
+
403: { description: "A non-admin user token wrote another user's state" },
|
|
62
66
|
},
|
|
63
67
|
auth: { apiKey: true },
|
|
68
|
+
rbac: { permission: "favorite.write.own" },
|
|
64
69
|
});
|
|
65
70
|
|
|
66
71
|
const bulkUpsertState = route({
|
|
@@ -80,13 +85,26 @@ const bulkUpsertState = route({
|
|
|
80
85
|
},
|
|
81
86
|
400: { description: "Validation error" },
|
|
82
87
|
401: { description: "Unauthorized" },
|
|
88
|
+
403: { description: "A non-admin user token wrote another user's state" },
|
|
83
89
|
},
|
|
84
90
|
auth: { apiKey: true },
|
|
85
|
-
rbac: {
|
|
91
|
+
rbac: { permission: "favorite.write.own" },
|
|
86
92
|
});
|
|
87
93
|
|
|
88
94
|
// ─── Handler ─────────────────────────────────────────────────────────────────
|
|
89
95
|
|
|
96
|
+
const OTHER_USER_STATE_DENIED = "You can only change your own inbox state";
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* True when a user token without a grant-all role names another user's
|
|
100
|
+
* `userId`. Operator-key and admin callers keep writing any user's state.
|
|
101
|
+
*/
|
|
102
|
+
async function writesOtherUsersState(req: IncomingMessage, userId: string): Promise<boolean> {
|
|
103
|
+
const auth = getRequestAuth(req);
|
|
104
|
+
if (auth?.kind !== "user" || auth.userId === userId || !isRbacEnabled()) return false;
|
|
105
|
+
return !(await getUserGrant(auth.userId)).grantsAll;
|
|
106
|
+
}
|
|
107
|
+
|
|
90
108
|
export async function handleInboxState(
|
|
91
109
|
req: IncomingMessage,
|
|
92
110
|
res: ServerResponse,
|
|
@@ -108,6 +126,10 @@ export async function handleInboxState(
|
|
|
108
126
|
if (bulkUpsertState.match(req.method, pathSegments)) {
|
|
109
127
|
const parsed = await bulkUpsertState.parse(req, res, pathSegments, queryParams);
|
|
110
128
|
if (!parsed) return true;
|
|
129
|
+
if (await writesOtherUsersState(req, parsed.body.userId)) {
|
|
130
|
+
jsonError(res, OTHER_USER_STATE_DENIED, 403);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
111
133
|
try {
|
|
112
134
|
const items = await getDbClient().transaction(async () => {
|
|
113
135
|
const rows = [];
|
|
@@ -126,6 +148,10 @@ export async function handleInboxState(
|
|
|
126
148
|
if (upsertState.match(req.method, pathSegments)) {
|
|
127
149
|
const parsed = await upsertState.parse(req, res, pathSegments, queryParams);
|
|
128
150
|
if (!parsed) return true;
|
|
151
|
+
if (await writesOtherUsersState(req, parsed.body.userId)) {
|
|
152
|
+
jsonError(res, OTHER_USER_STATE_DENIED, 403);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
129
155
|
try {
|
|
130
156
|
const item = await upsertInboxState({
|
|
131
157
|
userId: parsed.body.userId,
|
package/src/http/index.ts
CHANGED
|
@@ -70,6 +70,7 @@ import { isEnvFlagEnabled } from "../utils/env-flag";
|
|
|
70
70
|
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
71
71
|
import { initWorkflows } from "../workflows";
|
|
72
72
|
import { handleActiveSessions } from "./active-sessions";
|
|
73
|
+
import { handleAgentFsProxy } from "./agentfs-proxy";
|
|
73
74
|
import { handleAgentRegister, handleAgentsRest } from "./agents";
|
|
74
75
|
import { handleApiKeys } from "./api-keys";
|
|
75
76
|
import { handleApprovalRequests } from "./approval-requests";
|
|
@@ -147,6 +148,7 @@ import {
|
|
|
147
148
|
getPathSegments,
|
|
148
149
|
httpServerSemconvAttributes,
|
|
149
150
|
parseQueryParams,
|
|
151
|
+
redactShareTokenInPath,
|
|
150
152
|
safeRequestUrlForLog,
|
|
151
153
|
setCorsHeaders,
|
|
152
154
|
warnIfCorsAllowsAnyOrigin,
|
|
@@ -319,7 +321,7 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
319
321
|
spanName,
|
|
320
322
|
{
|
|
321
323
|
"http.request.method": req.method ?? "",
|
|
322
|
-
"url.path": reqPath,
|
|
324
|
+
"url.path": redactShareTokenInPath(reqPath),
|
|
323
325
|
"url.scheme": semconv["url.scheme"],
|
|
324
326
|
"http.route": httpRoute,
|
|
325
327
|
"server.address": semconv["server.address"],
|
|
@@ -373,6 +375,7 @@ const httpServer = createHttpServer(async (req, res) => {
|
|
|
373
375
|
() => handlePoolKeys(req, res, pathSegments, queryParams),
|
|
374
376
|
() => handleConfig(req, res, pathSegments, queryParams),
|
|
375
377
|
() => handleFs(req, res, pathSegments, queryParams, myAgentId),
|
|
378
|
+
() => handleAgentFsProxy(req, res),
|
|
376
379
|
() => handleComb(req, res, pathSegments, queryParams, myAgentId),
|
|
377
380
|
() => handleKv(req, res, pathSegments, queryParams),
|
|
378
381
|
() => handleRooms(req, res, pathSegments, queryParams),
|
package/src/http/memory.ts
CHANGED
|
@@ -914,7 +914,7 @@ export async function handleMemory(
|
|
|
914
914
|
try {
|
|
915
915
|
const provider = getEmbeddingProvider();
|
|
916
916
|
const store = getMemoryStore();
|
|
917
|
-
const queryEmbedding = await provider.embed(query);
|
|
917
|
+
const queryEmbedding = await provider.embed(query, { kind: "query" });
|
|
918
918
|
|
|
919
919
|
const candidateLimit = Math.min(limit, 20) * CANDIDATE_SET_MULTIPLIER;
|
|
920
920
|
const candidates = await searchMemoryCandidates(
|
|
@@ -1039,7 +1039,7 @@ export async function handleMemory(
|
|
|
1039
1039
|
try {
|
|
1040
1040
|
if (query && query.trim().length > 0) {
|
|
1041
1041
|
const provider = getEmbeddingProvider();
|
|
1042
|
-
const queryEmbedding = await provider.embed(query.trim());
|
|
1042
|
+
const queryEmbedding = await provider.embed(query.trim(), { kind: "query" });
|
|
1043
1043
|
|
|
1044
1044
|
const candidateLimit = Math.min(
|
|
1045
1045
|
4096,
|
|
@@ -1289,7 +1289,7 @@ export async function handleMemory(
|
|
|
1289
1289
|
});
|
|
1290
1290
|
if (result.changed) {
|
|
1291
1291
|
const provider = getEmbeddingProvider();
|
|
1292
|
-
const embedding = await provider.embed(result.memory.content);
|
|
1292
|
+
const embedding = await provider.embed(result.memory.content, { kind: "document" });
|
|
1293
1293
|
if (embedding) await store.updateEmbedding(result.memory.id, embedding, provider.name);
|
|
1294
1294
|
try {
|
|
1295
1295
|
// Edit path: prune links derived from removed content (sequel links survive).
|
|
@@ -1378,7 +1378,10 @@ export async function handleMemory(
|
|
|
1378
1378
|
for (let i = 0; i < memories.length; i += batchSize) {
|
|
1379
1379
|
const batch = memories.slice(i, i + batchSize);
|
|
1380
1380
|
try {
|
|
1381
|
-
const embeddings = await provider.embedBatch(
|
|
1381
|
+
const embeddings = await provider.embedBatch(
|
|
1382
|
+
batch.map((m) => m.content),
|
|
1383
|
+
{ kind: "document" },
|
|
1384
|
+
);
|
|
1382
1385
|
for (let j = 0; j < embeddings.length; j++) {
|
|
1383
1386
|
if (embeddings[j]) {
|
|
1384
1387
|
await store.updateEmbedding(batch[j]!.id, embeddings[j]!, provider.name);
|