@edspencer/paddock 0.0.1 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +69 -0
- package/install-notice.mjs +29 -0
- package/package.json +58 -5
- package/packages/server/dist/adoptable.d.ts +127 -0
- package/packages/server/dist/adoptable.js +315 -0
- package/packages/server/dist/app.d.ts +67 -0
- package/packages/server/dist/app.js +325 -0
- package/packages/server/dist/archive.d.ts +42 -0
- package/packages/server/dist/archive.js +125 -0
- package/packages/server/dist/attachments-config.d.ts +114 -0
- package/packages/server/dist/attachments-config.js +165 -0
- package/packages/server/dist/attachments-hint.d.ts +53 -0
- package/packages/server/dist/attachments-hint.js +140 -0
- package/packages/server/dist/attachments.d.ts +33 -0
- package/packages/server/dist/attachments.js +117 -0
- package/packages/server/dist/auth.d.ts +84 -0
- package/packages/server/dist/auth.js +247 -0
- package/packages/server/dist/background.d.ts +36 -0
- package/packages/server/dist/background.js +137 -0
- package/packages/server/dist/bind-safety.d.ts +56 -0
- package/packages/server/dist/bind-safety.js +51 -0
- package/packages/server/dist/brand.d.ts +44 -0
- package/packages/server/dist/brand.js +88 -0
- package/packages/server/dist/chat-dto.d.ts +210 -0
- package/packages/server/dist/chat-dto.js +308 -0
- package/packages/server/dist/cli/import-chats.d.ts +21 -0
- package/packages/server/dist/cli/import-chats.js +197 -0
- package/packages/server/dist/cli/paddock.d.ts +2 -0
- package/packages/server/dist/cli/paddock.js +247 -0
- package/packages/server/dist/concurrency.d.ts +57 -0
- package/packages/server/dist/concurrency.js +71 -0
- package/packages/server/dist/config.d.ts +538 -0
- package/packages/server/dist/config.js +625 -0
- package/packages/server/dist/curation-config.d.ts +55 -0
- package/packages/server/dist/curation-config.js +64 -0
- package/packages/server/dist/environment-prompt.d.ts +52 -0
- package/packages/server/dist/environment-prompt.js +61 -0
- package/packages/server/dist/event-bus.d.ts +87 -0
- package/packages/server/dist/event-bus.js +48 -0
- package/packages/server/dist/git.d.ts +128 -0
- package/packages/server/dist/git.js +446 -0
- package/packages/server/dist/github-auth.d.ts +44 -0
- package/packages/server/dist/github-auth.js +169 -0
- package/packages/server/dist/herdctl-agent-config.d.ts +98 -0
- package/packages/server/dist/herdctl-agent-config.js +331 -0
- package/packages/server/dist/herdctl-agent-names.d.ts +172 -0
- package/packages/server/dist/herdctl-agent-names.js +228 -0
- package/packages/server/dist/herdctl-jobs-index.d.ts +65 -0
- package/packages/server/dist/herdctl-jobs-index.js +225 -0
- package/packages/server/dist/herdctl-jobs.d.ts +98 -0
- package/packages/server/dist/herdctl-jobs.js +225 -0
- package/packages/server/dist/herdctl.d.ts +623 -0
- package/packages/server/dist/herdctl.js +1279 -0
- package/packages/server/dist/hook-config.d.ts +217 -0
- package/packages/server/dist/hook-config.js +224 -0
- package/packages/server/dist/http-bytes.d.ts +24 -0
- package/packages/server/dist/http-bytes.js +52 -0
- package/packages/server/dist/index.d.ts +1 -0
- package/packages/server/dist/index.js +14 -0
- package/packages/server/dist/instance-config.d.ts +143 -0
- package/packages/server/dist/instance-config.js +385 -0
- package/packages/server/dist/localcommand.d.ts +23 -0
- package/packages/server/dist/localcommand.js +140 -0
- package/packages/server/dist/management-auth.d.ts +99 -0
- package/packages/server/dist/management-auth.js +165 -0
- package/packages/server/dist/management-config.d.ts +164 -0
- package/packages/server/dist/management-config.js +286 -0
- package/packages/server/dist/management-mcp-server.d.ts +20 -0
- package/packages/server/dist/management-mcp-server.js +95 -0
- package/packages/server/dist/management-metadata.d.ts +67 -0
- package/packages/server/dist/management-metadata.js +56 -0
- package/packages/server/dist/management-ops.d.ts +110 -0
- package/packages/server/dist/management-ops.js +449 -0
- package/packages/server/dist/management-policy.d.ts +172 -0
- package/packages/server/dist/management-policy.js +259 -0
- package/packages/server/dist/message-provenance.d.ts +112 -0
- package/packages/server/dist/message-provenance.js +260 -0
- package/packages/server/dist/models.d.ts +164 -0
- package/packages/server/dist/models.js +208 -0
- package/packages/server/dist/openapi.d.ts +66 -0
- package/packages/server/dist/openapi.js +183 -0
- package/packages/server/dist/parent-detach.d.ts +30 -0
- package/packages/server/dist/parent-detach.js +109 -0
- package/packages/server/dist/preload.d.ts +43 -0
- package/packages/server/dist/preload.js +55 -0
- package/packages/server/dist/project-files.d.ts +76 -0
- package/packages/server/dist/project-files.js +191 -0
- package/packages/server/dist/project-mime.d.ts +35 -0
- package/packages/server/dist/project-mime.js +55 -0
- package/packages/server/dist/project-paths.d.ts +88 -0
- package/packages/server/dist/project-paths.js +126 -0
- package/packages/server/dist/project-types.d.ts +316 -0
- package/packages/server/dist/project-types.js +48 -0
- package/packages/server/dist/projects.d.ts +202 -0
- package/packages/server/dist/projects.js +894 -0
- package/packages/server/dist/queued-message.d.ts +35 -0
- package/packages/server/dist/queued-message.js +116 -0
- package/packages/server/dist/read-state.d.ts +46 -0
- package/packages/server/dist/read-state.js +139 -0
- package/packages/server/dist/recovery-config.d.ts +111 -0
- package/packages/server/dist/recovery-config.js +105 -0
- package/packages/server/dist/recovery.d.ts +262 -0
- package/packages/server/dist/recovery.js +490 -0
- package/packages/server/dist/resume-drain.d.ts +122 -0
- package/packages/server/dist/resume-drain.js +158 -0
- package/packages/server/dist/route-context.d.ts +168 -0
- package/packages/server/dist/route-context.js +39 -0
- package/packages/server/dist/route-errors.d.ts +8 -0
- package/packages/server/dist/route-errors.js +13 -0
- package/packages/server/dist/routes/chats.d.ts +12 -0
- package/packages/server/dist/routes/chats.js +1406 -0
- package/packages/server/dist/routes/git.d.ts +13 -0
- package/packages/server/dist/routes/git.js +236 -0
- package/packages/server/dist/routes/mcp.d.ts +83 -0
- package/packages/server/dist/routes/mcp.js +250 -0
- package/packages/server/dist/routes/meta.d.ts +15 -0
- package/packages/server/dist/routes/meta.js +447 -0
- package/packages/server/dist/routes/projects.d.ts +25 -0
- package/packages/server/dist/routes/projects.js +736 -0
- package/packages/server/dist/routes/triggers.d.ts +21 -0
- package/packages/server/dist/routes/triggers.js +324 -0
- package/packages/server/dist/routes/workspace-mount.d.ts +48 -0
- package/packages/server/dist/routes/workspace-mount.js +32 -0
- package/packages/server/dist/routes.d.ts +26 -0
- package/packages/server/dist/routes.js +29 -0
- package/packages/server/dist/run-provenance.d.ts +109 -0
- package/packages/server/dist/run-provenance.js +206 -0
- package/packages/server/dist/runs.d.ts +92 -0
- package/packages/server/dist/runs.js +72 -0
- package/packages/server/dist/schedule-config.d.ts +68 -0
- package/packages/server/dist/schedule-config.js +75 -0
- package/packages/server/dist/schedule-session.d.ts +34 -0
- package/packages/server/dist/schedule-session.js +120 -0
- package/packages/server/dist/self-mcp-descriptions.d.ts +20 -0
- package/packages/server/dist/self-mcp-descriptions.js +145 -0
- package/packages/server/dist/self-mcp-read.d.ts +15 -0
- package/packages/server/dist/self-mcp-read.js +97 -0
- package/packages/server/dist/self-mcp-triggers.d.ts +30 -0
- package/packages/server/dist/self-mcp-triggers.js +144 -0
- package/packages/server/dist/self-mcp-types.d.ts +248 -0
- package/packages/server/dist/self-mcp-types.js +10 -0
- package/packages/server/dist/self-mcp-util.d.ts +73 -0
- package/packages/server/dist/self-mcp-util.js +157 -0
- package/packages/server/dist/self-mcp-write.d.ts +35 -0
- package/packages/server/dist/self-mcp-write.js +215 -0
- package/packages/server/dist/self-mcp.d.ts +95 -0
- package/packages/server/dist/self-mcp.js +448 -0
- package/packages/server/dist/send-file-mcp.d.ts +46 -0
- package/packages/server/dist/send-file-mcp.js +281 -0
- package/packages/server/dist/session-hub.d.ts +186 -0
- package/packages/server/dist/session-hub.js +291 -0
- package/packages/server/dist/spawn-capability.d.ts +99 -0
- package/packages/server/dist/spawn-capability.js +106 -0
- package/packages/server/dist/star.d.ts +26 -0
- package/packages/server/dist/star.js +94 -0
- package/packages/server/dist/start.d.ts +1 -0
- package/packages/server/dist/start.js +21 -0
- package/packages/server/dist/subagents.d.ts +337 -0
- package/packages/server/dist/subagents.js +626 -0
- package/packages/server/dist/sweep.d.ts +122 -0
- package/packages/server/dist/sweep.js +696 -0
- package/packages/server/dist/tooldetails.d.ts +33 -0
- package/packages/server/dist/tooldetails.js +466 -0
- package/packages/server/dist/transcribe.d.ts +69 -0
- package/packages/server/dist/transcribe.js +119 -0
- package/packages/server/dist/transcripts.d.ts +42 -0
- package/packages/server/dist/transcripts.js +164 -0
- package/packages/server/dist/trigger-config.d.ts +278 -0
- package/packages/server/dist/trigger-config.js +417 -0
- package/packages/server/dist/trigger-runtime.d.ts +94 -0
- package/packages/server/dist/trigger-runtime.js +82 -0
- package/packages/server/dist/trigger-session.d.ts +34 -0
- package/packages/server/dist/trigger-session.js +122 -0
- package/packages/server/dist/triggers.d.ts +83 -0
- package/packages/server/dist/triggers.js +124 -0
- package/packages/server/dist/trusted-proxy.d.ts +57 -0
- package/packages/server/dist/trusted-proxy.js +157 -0
- package/packages/server/dist/turn-notice.d.ts +154 -0
- package/packages/server/dist/turn-notice.js +271 -0
- package/packages/server/dist/unread.d.ts +36 -0
- package/packages/server/dist/unread.js +123 -0
- package/packages/server/dist/usage.d.ts +66 -0
- package/packages/server/dist/usage.js +243 -0
- package/packages/server/dist/wake-injection.d.ts +156 -0
- package/packages/server/dist/wake-injection.js +136 -0
- package/packages/server/dist/whisper-local.d.ts +9 -0
- package/packages/server/dist/whisper-local.js +122 -0
- package/packages/server/dist/ws-context.d.ts +158 -0
- package/packages/server/dist/ws-context.js +1 -0
- package/packages/server/dist/ws-protocol.d.ts +423 -0
- package/packages/server/dist/ws-protocol.js +251 -0
- package/packages/server/dist/ws-self-mcp.d.ts +52 -0
- package/packages/server/dist/ws-self-mcp.js +28 -0
- package/packages/server/dist/ws-triggers.d.ts +29 -0
- package/packages/server/dist/ws-triggers.js +261 -0
- package/packages/server/dist/ws-turn.d.ts +86 -0
- package/packages/server/dist/ws-turn.js +751 -0
- package/packages/server/dist/ws.d.ts +62 -0
- package/packages/server/dist/ws.js +1024 -0
- package/packages/server/package.json +6 -0
- package/packages/web/dist/assets/InstanceConfigPage-Bypd_quL.js +2 -0
- package/packages/web/dist/assets/MarkdownRenderer-Co8w9iuX.js +30 -0
- package/packages/web/dist/assets/ProjectRedirect-DXrA24Cy.js +2 -0
- package/packages/web/dist/assets/ProjectView-B9opP7Sv.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-D2cwb2jY.js +2 -0
- package/packages/web/dist/assets/arc-B--FIRB8.js +2 -0
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-B6dRWZZ6.js +37 -0
- package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
- package/packages/web/dist/assets/blockDiagram-GPEHLZMM-DLxFAvvf.js +133 -0
- package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
- package/packages/web/dist/assets/c4Diagram-AAUBKEIU-CNee5ks4.js +11 -0
- package/packages/web/dist/assets/channel-Bw0A52CB.js +2 -0
- package/packages/web/dist/assets/chunk-2J33WTMH-DObw03Xl.js +2 -0
- package/packages/web/dist/assets/chunk-4BX2VUAB-CH2SR6nN.js +2 -0
- package/packages/web/dist/assets/chunk-55IACEB6-WlVpegT3.js +2 -0
- package/packages/web/dist/assets/chunk-727SXJPM-g8GT9Rgd.js +207 -0
- package/packages/web/dist/assets/chunk-AQP2D5EJ-DbN4PsDF.js +232 -0
- package/packages/web/dist/assets/chunk-FMBD7UC4-DSN3BjFG.js +16 -0
- package/packages/web/dist/assets/chunk-ND2GUHAM-D9R4le0z.js +2 -0
- package/packages/web/dist/assets/chunk-QZHKN3VN-D4YgRaKH.js +2 -0
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-itf_kt-q.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-itf_kt-q.js +2 -0
- package/packages/web/dist/assets/core-CysJ2gLj.js +4 -0
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-D1PB0KNu.js +2 -0
- package/packages/web/dist/assets/cpp-Bm3ii6kY.js +2 -0
- package/packages/web/dist/assets/csharp-BNpuYZMG.js +2 -0
- package/packages/web/dist/assets/css-DazXZka4.js +2 -0
- package/packages/web/dist/assets/cytoscape.esm-CUqq0XTU.js +332 -0
- package/packages/web/dist/assets/dagre-BM42HDAG-C6Uuq-dq.js +5 -0
- package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
- package/packages/web/dist/assets/diagram-2AECGRRQ-C9JvQ425.js +44 -0
- package/packages/web/dist/assets/diagram-5GNKFQAL-Cu6pdh2d.js +11 -0
- package/packages/web/dist/assets/diagram-KO2AKTUF-PmXcUQhY.js +4 -0
- package/packages/web/dist/assets/diagram-LMA3HP47-9ww6_9Iq.js +25 -0
- package/packages/web/dist/assets/diagram-OG6HWLK6-DWQVjv_n.js +25 -0
- package/packages/web/dist/assets/erDiagram-TEJ5UH35-6F7xTuff.js +86 -0
- package/packages/web/dist/assets/flowDiagram-I6XJVG4X-SCiGu7m6.js +163 -0
- package/packages/web/dist/assets/format-yf7Vf9kR.js +2 -0
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BNpPwxkL.js +293 -0
- package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-C0gyAJjW.js +107 -0
- package/packages/web/dist/assets/go-BPx_y7j8.js +2 -0
- package/packages/web/dist/assets/graph-BlwPajkw.js +2 -0
- package/packages/web/dist/assets/index-B-YuzkF6.js +71 -0
- package/packages/web/dist/assets/index-CvGiQddO.css +1 -0
- package/packages/web/dist/assets/infoDiagram-5YYISTIA-CJ2CFPfy.js +3 -0
- package/packages/web/dist/assets/ini-B3BVgEsG.js +2 -0
- package/packages/web/dist/assets/init-Gi6I4Gst.js +2 -0
- package/packages/web/dist/assets/ishikawaDiagram-YF4QCWOH-Cs0D32Ry.js +71 -0
- package/packages/web/dist/assets/java-B5VKqtH2.js +2 -0
- package/packages/web/dist/assets/javascript-BKRaQes9.js +2 -0
- package/packages/web/dist/assets/journeyDiagram-JHISSGLW-Bc9fuI2i.js +140 -0
- package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
- package/packages/web/dist/assets/kanban-definition-UN3LZRKU-D_tC7QfC.js +90 -0
- package/packages/web/dist/assets/katex-C5jXJg4s.js +258 -0
- package/packages/web/dist/assets/kotlin-Cw-eF_bm.js +3 -0
- package/packages/web/dist/assets/lastTab-CU1zsF0j.js +2 -0
- package/packages/web/dist/assets/layout-Boy8vhSP.js +2 -0
- package/packages/web/dist/assets/linear-BzGHkF9K.js +2 -0
- package/packages/web/dist/assets/mermaid.core-DlbsYzE-.js +310 -0
- package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bxeh-ToD.js +97 -0
- package/packages/web/dist/assets/ordinal-Cboi1Yqb.js +2 -0
- package/packages/web/dist/assets/php-CRdd4dfS.js +3 -0
- package/packages/web/dist/assets/pieDiagram-4H26LBE5-CDmilo8S.js +31 -0
- package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
- package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-D14-JyE9.js +8 -0
- package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
- package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-CJ1PUGu-.js +85 -0
- package/packages/web/dist/assets/ruby-7T4ktxyv.js +2 -0
- package/packages/web/dist/assets/rust-HC3MBUhY.js +2 -0
- package/packages/web/dist/assets/sankeyDiagram-5OEKKPKP-BkUK73W0.js +41 -0
- package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
- package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-sbcvsuLp.js +163 -0
- package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
- package/packages/web/dist/assets/stateDiagram-AJRCARHV-DV-7BdHK.js +2 -0
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BlkGdXKW.js +2 -0
- package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
- package/packages/web/dist/assets/timeline-definition-PNZ67QCA-C5su21G_.js +121 -0
- package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
- package/packages/web/dist/assets/vennDiagram-CIIHVFJN-JNkIJAaQ.js +35 -0
- package/packages/web/dist/assets/wardley-L42UT6IY-qcz6t2vR.js +174 -0
- package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-CMrOIZ-c.js +79 -0
- package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
- package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-C9q6Oy9u.js +8 -0
- package/packages/web/dist/assets/yaml-drsDnIKT.js +2 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/fonts/inter-latin.woff2 +0 -0
- package/packages/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/packages/web/dist/icons/apple-touch-icon.png +0 -0
- package/packages/web/dist/icons/favicon-16.png +0 -0
- package/packages/web/dist/icons/favicon-32.png +0 -0
- package/packages/web/dist/icons/icon-192.png +0 -0
- package/packages/web/dist/icons/icon-512.png +0 -0
- package/packages/web/dist/icons/maskable-512.png +0 -0
- package/packages/web/dist/index.html +56 -0
- package/packages/web/dist/manifest.webmanifest +32 -0
- package/packages/web/dist/sw.js +139 -0
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound attachment config — the config foundation for issue #328 Phase 1
|
|
3
|
+
* (ChatGPT-style file/image upload in the composer, "Approach A").
|
|
4
|
+
*
|
|
5
|
+
* ── What this governs ────────────────────────────────────────────────────────
|
|
6
|
+
* A user can pick / drag / paste files into the composer; each is copied into the
|
|
7
|
+
* per-instance attachment store (reusing the `send_file` store, #112) and the
|
|
8
|
+
* keeper is pointed at the absolute paths so its `Read` tool can view them
|
|
9
|
+
* (native vision on images/PDFs). This module resolves the four knobs that gate
|
|
10
|
+
* that flow: whether it's enabled at all, a per-file size cap, a per-message file
|
|
11
|
+
* count cap, and an allow-list of file types.
|
|
12
|
+
*
|
|
13
|
+
* ── Config discipline (the `recovery`/`driveMode` pattern) ───────────────────
|
|
14
|
+
* Every field is an instance default (`PADDOCK_ATTACHMENTS_*` env, YAML instance
|
|
15
|
+
* file beneath it) with an optional PER-PROJECT override (`project.yaml` →
|
|
16
|
+
* {@link import("./projects.js").ProjectYaml.attachments}). An absent/invalid
|
|
17
|
+
* override field inherits the instance default, resolved at request time by
|
|
18
|
+
* {@link resolveAttachmentsConfig} — never baked into the DTO (mirrors how
|
|
19
|
+
* `recovery` resolves via {@link import("./recovery-config.js").resolveRecoveryConfig}).
|
|
20
|
+
* A malformed override is ignored rather than fatal so a hand-edited `project.yaml`
|
|
21
|
+
* can't wedge an upload.
|
|
22
|
+
*
|
|
23
|
+
* ── allowedTypes semantics ───────────────────────────────────────────────────
|
|
24
|
+
* Each entry is either a MIME pattern (contains `/`, `*` wildcards a segment —
|
|
25
|
+
* `image/*`, `*/*`) or an extension (starts with `.` — `.csv`, `.ipynb`). A file
|
|
26
|
+
* is allowed if its MIME matches any MIME pattern OR its extension matches any
|
|
27
|
+
* extension entry. The sentinel `"*"` (or `"*/*"`) allows everything (the
|
|
28
|
+
* default). The extension fallback matters because browsers report empty/generic
|
|
29
|
+
* MIME for many real attachments (`.md`, `.ts`, `.heic` → `""`/`text/plain`).
|
|
30
|
+
*
|
|
31
|
+
* NOTE: `File.type` / multipart content-type are client-provided and spoofable,
|
|
32
|
+
* so allowedTypes is a hygiene/UX guardrail, NOT a security boundary (no
|
|
33
|
+
* magic-byte sniffing in v1) — appropriate for a personal/LAN tool.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* The built-in attachment defaults (beneath env + YAML + per-project override):
|
|
37
|
+
* enabled, 25 MB/file, 10 files/message, allow-all.
|
|
38
|
+
*/
|
|
39
|
+
export const DEFAULT_ATTACHMENTS = Object.freeze({
|
|
40
|
+
enabled: true,
|
|
41
|
+
maxFileSizeMb: 25,
|
|
42
|
+
maxFilesPerMessage: 10,
|
|
43
|
+
allowedTypes: Object.freeze(["*"]),
|
|
44
|
+
});
|
|
45
|
+
/** 1 MB in bytes — the unit `maxFileSizeMb` is expressed in. */
|
|
46
|
+
export const BYTES_PER_MB = 1024 * 1024;
|
|
47
|
+
/** The per-file size cap in BYTES for a resolved config. */
|
|
48
|
+
export function maxFileBytes(cfg) {
|
|
49
|
+
return cfg.maxFileSizeMb * BYTES_PER_MB;
|
|
50
|
+
}
|
|
51
|
+
/** True when `n` is a valid positive integer knob (size/count must be ≥ 1). */
|
|
52
|
+
function isPositiveInt(n) {
|
|
53
|
+
return typeof n === "number" && Number.isInteger(n) && n >= 1 && Number.isFinite(n);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Normalise an allowedTypes list: keep only non-blank strings, trimmed and
|
|
57
|
+
* lower-cased (MIME + extensions are case-insensitive). Returns `undefined` when
|
|
58
|
+
* nothing valid remains (so an empty list never silently means "deny all").
|
|
59
|
+
*/
|
|
60
|
+
export function sanitizeAllowedTypes(value) {
|
|
61
|
+
if (!Array.isArray(value))
|
|
62
|
+
return undefined;
|
|
63
|
+
const out = value
|
|
64
|
+
.filter((e) => typeof e === "string")
|
|
65
|
+
.map((e) => e.trim().toLowerCase())
|
|
66
|
+
.filter((e) => e.length > 0);
|
|
67
|
+
return out.length > 0 ? out : undefined;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Validate + normalise an untrusted value (from `project.yaml` or a PATCH body)
|
|
71
|
+
* into an {@link AttachmentsOverride}, dropping any field that is missing or
|
|
72
|
+
* invalid, and returning `undefined` when nothing valid remains (so an empty
|
|
73
|
+
* override is never persisted). `enabled` must be a real boolean; the numeric
|
|
74
|
+
* knobs must be positive integers; `allowedTypes` must be a non-empty string
|
|
75
|
+
* array. Defensive by design — a malformed hand-edit degrades to "inherit the
|
|
76
|
+
* instance default", never a crash.
|
|
77
|
+
*/
|
|
78
|
+
export function sanitizeAttachmentsOverride(value) {
|
|
79
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
80
|
+
return undefined;
|
|
81
|
+
const o = value;
|
|
82
|
+
const out = {};
|
|
83
|
+
if (typeof o.enabled === "boolean")
|
|
84
|
+
out.enabled = o.enabled;
|
|
85
|
+
if (isPositiveInt(o.maxFileSizeMb))
|
|
86
|
+
out.maxFileSizeMb = o.maxFileSizeMb;
|
|
87
|
+
if (isPositiveInt(o.maxFilesPerMessage))
|
|
88
|
+
out.maxFilesPerMessage = o.maxFilesPerMessage;
|
|
89
|
+
const types = sanitizeAllowedTypes(o.allowedTypes);
|
|
90
|
+
if (types)
|
|
91
|
+
out.allowedTypes = types;
|
|
92
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Resolve the effective attachment config for a request: a valid per-project
|
|
96
|
+
* override wins field-by-field; every absent field inherits the instance default.
|
|
97
|
+
* Mirrors how `recovery`/`driveMode` resolve — the override is carried on disk
|
|
98
|
+
* only for the fields explicitly set, so an absent value transparently inherits
|
|
99
|
+
* the instance (env/YAML) default. The override is re-sanitised so a corrupt
|
|
100
|
+
* on-disk value can't leak through.
|
|
101
|
+
*/
|
|
102
|
+
export function resolveAttachmentsConfig(override, instanceDefault) {
|
|
103
|
+
const clean = sanitizeAttachmentsOverride(override) ?? {};
|
|
104
|
+
return {
|
|
105
|
+
enabled: clean.enabled ?? instanceDefault.enabled,
|
|
106
|
+
maxFileSizeMb: clean.maxFileSizeMb ?? instanceDefault.maxFileSizeMb,
|
|
107
|
+
maxFilesPerMessage: clean.maxFilesPerMessage ?? instanceDefault.maxFilesPerMessage,
|
|
108
|
+
allowedTypes: clean.allowedTypes ?? instanceDefault.allowedTypes,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** The extension of a filename, lower-cased, WITH the leading dot (`.csv`), or "". */
|
|
112
|
+
function extensionOf(filename) {
|
|
113
|
+
const base = filename.slice(filename.lastIndexOf("/") + 1);
|
|
114
|
+
const dot = base.lastIndexOf(".");
|
|
115
|
+
// No dot, or a dotfile with no extension (`.gitignore` → treat as no ext).
|
|
116
|
+
return dot > 0 ? base.slice(dot).toLowerCase() : "";
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Does a concrete `mime` (e.g. `image/png`) match a MIME PATTERN entry (e.g.
|
|
120
|
+
* `image/*`, `*/*`, `text/csv`)? `*` wildcards a whole segment. Both are
|
|
121
|
+
* lower-cased by the caller / sanitiser. A blank mime never matches a pattern
|
|
122
|
+
* (fall back to the extension test instead).
|
|
123
|
+
*/
|
|
124
|
+
function mimeMatches(pattern, mime) {
|
|
125
|
+
if (!mime)
|
|
126
|
+
return false;
|
|
127
|
+
const [pType, pSub] = pattern.split("/", 2);
|
|
128
|
+
const [mType, mSub] = mime.split("/", 2);
|
|
129
|
+
const seg = (p, m) => p === "*" || p === m;
|
|
130
|
+
return seg(pType, mType) && seg(pSub, mSub);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* True when a file (identified by its browser-reported `mime` and its
|
|
134
|
+
* `filename`) is allowed by `allowedTypes`. Allowed if any entry matches: a
|
|
135
|
+
* MIME-pattern entry (contains `/`) is tested against `mime`; an extension entry
|
|
136
|
+
* (starts with `.`) against the filename's extension. The sentinel `"*"` /
|
|
137
|
+
* `"*/*"` allows everything. See the module doc for why the extension fallback
|
|
138
|
+
* exists. `mime`/`filename` may be empty (browsers often omit one).
|
|
139
|
+
*/
|
|
140
|
+
export function isTypeAllowed(allowedTypes, mime, filename) {
|
|
141
|
+
const m = (mime ?? "").trim().toLowerCase();
|
|
142
|
+
const ext = extensionOf(filename ?? "");
|
|
143
|
+
for (const raw of allowedTypes) {
|
|
144
|
+
const entry = raw.trim().toLowerCase();
|
|
145
|
+
if (!entry)
|
|
146
|
+
continue;
|
|
147
|
+
// Allow-all sentinels.
|
|
148
|
+
if (entry === "*" || entry === "*/*")
|
|
149
|
+
return true;
|
|
150
|
+
if (entry.includes("/")) {
|
|
151
|
+
if (mimeMatches(entry, m))
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
else if (entry.startsWith(".")) {
|
|
155
|
+
if (ext && ext === entry)
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
// A bare token (e.g. "png") — tolerate it as an extension match.
|
|
160
|
+
if (ext && ext === `.${entry}`)
|
|
161
|
+
return true;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** The renderer hint carried per attachment (a subset of send_file's kinds). */
|
|
2
|
+
export type AttachmentKind = "image" | "video" | "pdf" | "markdown" | "code" | "text" | "html" | "file";
|
|
3
|
+
export declare const ATTACHMENTS_OPEN = "<paddock-attachments>";
|
|
4
|
+
export declare const ATTACHMENTS_CLOSE = "</paddock-attachments>";
|
|
5
|
+
/** The literal boundary between the attachment block and the user's real request. */
|
|
6
|
+
export declare const ATTACHMENTS_REQUEST_MARKER = "</paddock-attachments>\n\n";
|
|
7
|
+
/** One attachment as the server knows it when building the prompt block. */
|
|
8
|
+
export interface PromptAttachment {
|
|
9
|
+
/** Opaque attachment-store id (`<uuid><ext>`), also the basename of `path`. */
|
|
10
|
+
id: string;
|
|
11
|
+
/** Original display filename (as uploaded). */
|
|
12
|
+
filename: string;
|
|
13
|
+
/** Renderer hint. */
|
|
14
|
+
kind: AttachmentKind;
|
|
15
|
+
/** Absolute path to the stored bytes, so the keeper's Read tool can open it. */
|
|
16
|
+
path: string;
|
|
17
|
+
}
|
|
18
|
+
/** A parsed attachment ref for the web (no absolute path — it fetches by id). */
|
|
19
|
+
export interface ParsedAttachment {
|
|
20
|
+
id: string;
|
|
21
|
+
filename: string;
|
|
22
|
+
kind: AttachmentKind;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Infer a renderer {@link AttachmentKind} from a filename. Images/videos/PDFs get
|
|
26
|
+
* rich rendering; source/markup/text get a typed chip; everything else is a
|
|
27
|
+
* generic `file`. IMAGE is tested before VIDEO so `.webp` (image) never collides
|
|
28
|
+
* with `.webm` (video).
|
|
29
|
+
*/
|
|
30
|
+
export declare function inferAttachmentKind(filename: string, mime?: string): AttachmentKind;
|
|
31
|
+
/**
|
|
32
|
+
* Build the attachment-wrapped prompt. Returns `message` unchanged when there are
|
|
33
|
+
* no attachments, so the caller can always wrap unconditionally.
|
|
34
|
+
*/
|
|
35
|
+
export declare function wrapAttachments(attachments: PromptAttachment[], message: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Parse a (possibly) attachment-wrapped message into its attachment refs + the
|
|
38
|
+
* user's real request. If `text` isn't wrapped it's returned unchanged with an
|
|
39
|
+
* empty list. The block may be nested inside a preload wrapper, so we locate it
|
|
40
|
+
* anywhere in `text` rather than only at the very start.
|
|
41
|
+
*/
|
|
42
|
+
export declare function parseAttachmentsWrapper(text: string): {
|
|
43
|
+
attachments: ParsedAttachment[];
|
|
44
|
+
message: string;
|
|
45
|
+
};
|
|
46
|
+
/** Just the attachment ids referenced by a wrapped message (cleanup-on-delete). */
|
|
47
|
+
export declare function parseAttachmentIds(text: string): string[];
|
|
48
|
+
/**
|
|
49
|
+
* Recover the user's real request from a (possibly) attachment-wrapped message —
|
|
50
|
+
* the clean text with the `<paddock-attachments>` block removed. Used for chat
|
|
51
|
+
* display-name derivation (mirrors {@link import("./preload.js").stripPreloadWrapper}).
|
|
52
|
+
*/
|
|
53
|
+
export declare function stripAttachmentsWrapper(text: string): string;
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer-attachment "hint" wrapper for issue #328 Phase 1.
|
|
3
|
+
*
|
|
4
|
+
* On a turn that carries uploaded files, the server prepends a delimited block to
|
|
5
|
+
* the prompt string — exactly like the `<project-context>` preload wrapper
|
|
6
|
+
* (preload.ts) — telling the keeper which files were attached and pointing its
|
|
7
|
+
* `Read` tool at their absolute paths (native vision on images/PDFs). The block
|
|
8
|
+
* is single-sourced HERE so the WS layer that BUILDS it and the web that STRIPS +
|
|
9
|
+
* PARSES it (for the transcript chips) can't drift.
|
|
10
|
+
*
|
|
11
|
+
* <paddock-attachments>
|
|
12
|
+
* The user attached these files. Use the Read tool to view images and PDFs;
|
|
13
|
+
* each line is: id \t kind \t filename \t absolute-path.
|
|
14
|
+
* <id>\t<kind>\t<filename>\t<abs path>
|
|
15
|
+
* …
|
|
16
|
+
* </paddock-attachments>
|
|
17
|
+
*
|
|
18
|
+
* <the user's actual message>
|
|
19
|
+
*
|
|
20
|
+
* Both audiences read the same block: the keeper reads the human header + the
|
|
21
|
+
* visible absolute path in each line; the web parses the tab-delimited lines to
|
|
22
|
+
* rebuild image thumbnails / file chips from the attachment store on reload. The
|
|
23
|
+
* cleanup-on-delete path also parses it (via {@link parseAttachmentIds}) so an
|
|
24
|
+
* uploaded file's bytes are removed with its chat.
|
|
25
|
+
*
|
|
26
|
+
* The wrapper nests INSIDE the preload wrapper when both apply (attachments wrap
|
|
27
|
+
* the user's message first, then preload wraps the whole thing), so stripping the
|
|
28
|
+
* attachment block yields the same clean request the preload strip already
|
|
29
|
+
* expects.
|
|
30
|
+
*/
|
|
31
|
+
import { extname } from "node:path";
|
|
32
|
+
export const ATTACHMENTS_OPEN = "<paddock-attachments>";
|
|
33
|
+
export const ATTACHMENTS_CLOSE = "</paddock-attachments>";
|
|
34
|
+
/** The literal boundary between the attachment block and the user's real request. */
|
|
35
|
+
export const ATTACHMENTS_REQUEST_MARKER = `${ATTACHMENTS_CLOSE}\n\n`;
|
|
36
|
+
const HEADER = "The user attached these files. Use the Read tool to view images and PDFs; " +
|
|
37
|
+
"each line is: id\\tkind\\tfilename\\tabsolute-path.";
|
|
38
|
+
const IMAGE_EXT = new Set([".png", ".jpg", ".jpeg", ".gif", ".webp", ".avif", ".svg", ".heic"]);
|
|
39
|
+
const VIDEO_EXT = new Set([".mp4", ".webm", ".mov", ".m4v"]);
|
|
40
|
+
const PDF_EXT = new Set([".pdf"]);
|
|
41
|
+
const MARKDOWN_EXT = new Set([".md", ".mdx", ".markdown"]);
|
|
42
|
+
const HTML_EXT = new Set([".html", ".htm"]);
|
|
43
|
+
const CODE_EXT = new Set([
|
|
44
|
+
".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".py", ".rb", ".go", ".rs",
|
|
45
|
+
".java", ".c", ".h", ".cpp", ".cc", ".cs", ".php", ".swift", ".kt", ".sh",
|
|
46
|
+
".bash", ".zsh", ".sql", ".json", ".yaml", ".yml", ".toml", ".css", ".scss",
|
|
47
|
+
".xml",
|
|
48
|
+
]);
|
|
49
|
+
const TEXT_EXT = new Set([".txt", ".text", ".log", ".csv", ".tsv", ".env", ".ini"]);
|
|
50
|
+
/**
|
|
51
|
+
* Infer a renderer {@link AttachmentKind} from a filename. Images/videos/PDFs get
|
|
52
|
+
* rich rendering; source/markup/text get a typed chip; everything else is a
|
|
53
|
+
* generic `file`. IMAGE is tested before VIDEO so `.webp` (image) never collides
|
|
54
|
+
* with `.webm` (video).
|
|
55
|
+
*/
|
|
56
|
+
export function inferAttachmentKind(filename, mime) {
|
|
57
|
+
const ext = extname(filename).toLowerCase();
|
|
58
|
+
if (IMAGE_EXT.has(ext) || (mime ?? "").startsWith("image/"))
|
|
59
|
+
return "image";
|
|
60
|
+
if (VIDEO_EXT.has(ext) || (mime ?? "").startsWith("video/"))
|
|
61
|
+
return "video";
|
|
62
|
+
if (PDF_EXT.has(ext) || mime === "application/pdf")
|
|
63
|
+
return "pdf";
|
|
64
|
+
if (MARKDOWN_EXT.has(ext))
|
|
65
|
+
return "markdown";
|
|
66
|
+
if (HTML_EXT.has(ext))
|
|
67
|
+
return "html";
|
|
68
|
+
if (CODE_EXT.has(ext))
|
|
69
|
+
return "code";
|
|
70
|
+
if (TEXT_EXT.has(ext) || (mime ?? "").startsWith("text/"))
|
|
71
|
+
return "text";
|
|
72
|
+
return "file";
|
|
73
|
+
}
|
|
74
|
+
/** A tab or newline in a field would corrupt the block; collapse to a space. */
|
|
75
|
+
function sanitizeField(s) {
|
|
76
|
+
return s.replace(/[\t\r\n]+/g, " ").trim();
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Build the attachment-wrapped prompt. Returns `message` unchanged when there are
|
|
80
|
+
* no attachments, so the caller can always wrap unconditionally.
|
|
81
|
+
*/
|
|
82
|
+
export function wrapAttachments(attachments, message) {
|
|
83
|
+
if (attachments.length === 0)
|
|
84
|
+
return message;
|
|
85
|
+
const lines = attachments.map((a) => `${a.id}\t${a.kind}\t${sanitizeField(a.filename)}\t${a.path}`);
|
|
86
|
+
return `${ATTACHMENTS_OPEN}\n${HEADER}\n${lines.join("\n")}\n${ATTACHMENTS_REQUEST_MARKER}${message}`;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Parse a (possibly) attachment-wrapped message into its attachment refs + the
|
|
90
|
+
* user's real request. If `text` isn't wrapped it's returned unchanged with an
|
|
91
|
+
* empty list. The block may be nested inside a preload wrapper, so we locate it
|
|
92
|
+
* anywhere in `text` rather than only at the very start.
|
|
93
|
+
*/
|
|
94
|
+
export function parseAttachmentsWrapper(text) {
|
|
95
|
+
const open = text.indexOf(ATTACHMENTS_OPEN);
|
|
96
|
+
if (open === -1)
|
|
97
|
+
return { attachments: [], message: text };
|
|
98
|
+
const bodyStart = open + ATTACHMENTS_OPEN.length;
|
|
99
|
+
const close = text.indexOf(ATTACHMENTS_CLOSE, bodyStart);
|
|
100
|
+
if (close === -1)
|
|
101
|
+
return { attachments: [], message: text };
|
|
102
|
+
const body = text.slice(bodyStart, close);
|
|
103
|
+
const attachments = [];
|
|
104
|
+
for (const line of body.split("\n")) {
|
|
105
|
+
const parts = line.split("\t");
|
|
106
|
+
// A data line is `id \t kind \t filename [\t path]`. The header line has no
|
|
107
|
+
// tabs, so it's naturally skipped.
|
|
108
|
+
if (parts.length < 3)
|
|
109
|
+
continue;
|
|
110
|
+
const [id, kind, filename] = parts;
|
|
111
|
+
if (!id || !filename)
|
|
112
|
+
continue;
|
|
113
|
+
attachments.push({
|
|
114
|
+
id: id.trim(),
|
|
115
|
+
filename: filename.trim(),
|
|
116
|
+
kind: (kind.trim() || "file"),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
// Strip the whole block to recover the user's clean request: everything before
|
|
120
|
+
// the open tag + everything after the close marker. (Anything before the open
|
|
121
|
+
// tag is a preload wrapper we deliberately leave intact — existing behavior.)
|
|
122
|
+
const before = text.slice(0, open);
|
|
123
|
+
const afterClose = close + ATTACHMENTS_CLOSE.length;
|
|
124
|
+
const after = text.startsWith(ATTACHMENTS_REQUEST_MARKER, close)
|
|
125
|
+
? text.slice(close + ATTACHMENTS_REQUEST_MARKER.length)
|
|
126
|
+
: text.slice(afterClose).replace(/^\n+/, "");
|
|
127
|
+
return { attachments, message: before + after };
|
|
128
|
+
}
|
|
129
|
+
/** Just the attachment ids referenced by a wrapped message (cleanup-on-delete). */
|
|
130
|
+
export function parseAttachmentIds(text) {
|
|
131
|
+
return parseAttachmentsWrapper(text).attachments.map((a) => a.id);
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Recover the user's real request from a (possibly) attachment-wrapped message —
|
|
135
|
+
* the clean text with the `<paddock-attachments>` block removed. Used for chat
|
|
136
|
+
* display-name derivation (mirrors {@link import("./preload.js").stripPreloadWrapper}).
|
|
137
|
+
*/
|
|
138
|
+
export function stripAttachmentsWrapper(text) {
|
|
139
|
+
return parseAttachmentsWrapper(text).message;
|
|
140
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ChatMessage } from "@herdctl/core";
|
|
2
|
+
export declare class AttachmentStore {
|
|
3
|
+
private readonly root;
|
|
4
|
+
constructor(root: string);
|
|
5
|
+
init(): Promise<void>;
|
|
6
|
+
/** Persist bytes, returning an opaque id (`<uuid>` + the source's extension). */
|
|
7
|
+
save(bytes: Buffer, filenameForExt: string): Promise<string>;
|
|
8
|
+
/**
|
|
9
|
+
* The absolute on-disk path for a (validated) attachment id, or `null` if the
|
|
10
|
+
* id is malformed. Used to point the keeper's `Read` tool at an uploaded file
|
|
11
|
+
* (issue #328) — the store file IS the on-disk copy the agent reads. Does not
|
|
12
|
+
* check existence; pair with {@link exists} when that matters.
|
|
13
|
+
*/
|
|
14
|
+
absolutePath(id: string): string | null;
|
|
15
|
+
/** True when a (validated) attachment id currently has bytes on disk. */
|
|
16
|
+
exists(id: string): Promise<boolean>;
|
|
17
|
+
/** Read an attachment by id (validated), with a content-type for serving. */
|
|
18
|
+
read(id: string): Promise<{
|
|
19
|
+
bytes: Buffer;
|
|
20
|
+
mime: string;
|
|
21
|
+
} | null>;
|
|
22
|
+
/** Best-effort removal of a set of attachments (used on chat delete). */
|
|
23
|
+
remove(ids: string[]): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Extract the attachment ids a chat's transcript references, used to clean up a
|
|
27
|
+
* chat's attachments when it's deleted. Two sources:
|
|
28
|
+
* - agent `send_file` tool outputs (#112) — the JSON envelope's `attachmentId`;
|
|
29
|
+
* - user upload wrappers (#328) — the `<paddock-attachments>` block a user turn
|
|
30
|
+
* carries, parsed by {@link parseAttachmentIds}.
|
|
31
|
+
* Inline `send_file` sends carry no id, so they're naturally skipped.
|
|
32
|
+
*/
|
|
33
|
+
export declare function collectAttachmentIds(messages: ChatMessage[]): string[];
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attachment store for files shared via `mcp__paddock__send_file` (issue #112).
|
|
3
|
+
*
|
|
4
|
+
* When an agent sends a REAL file, we copy its bytes here AT SEND TIME and record
|
|
5
|
+
* only an opaque attachment id in the chat transcript (via the tool's output
|
|
6
|
+
* envelope). This makes a shared file an immutable snapshot — it renders
|
|
7
|
+
* identically forever, even if the original is later edited, moved, or deleted —
|
|
8
|
+
* and means the render endpoint only ever serves files that were explicitly
|
|
9
|
+
* sent, never an arbitrary path on the box. (Inline/virtual content is NOT stored
|
|
10
|
+
* here; it rides in the transcript envelope so it stays in the agent's context.)
|
|
11
|
+
*
|
|
12
|
+
* Layout: a flat directory of `<uuid><ext>` files. Ids are self-locating, so the
|
|
13
|
+
* serving endpoint needs only the id. Cleanup on chat delete parses the chat's
|
|
14
|
+
* transcript for the ids it referenced (see `collectAttachmentIds`).
|
|
15
|
+
*/
|
|
16
|
+
import { promises as fs } from "node:fs";
|
|
17
|
+
import { randomUUID } from "node:crypto";
|
|
18
|
+
import path from "node:path";
|
|
19
|
+
import { contentTypeFor } from "./projects.js";
|
|
20
|
+
import { SEND_FILE_TOOL_NAME } from "./send-file-mcp.js";
|
|
21
|
+
import { ATTACHMENTS_OPEN, parseAttachmentIds } from "./attachments-hint.js";
|
|
22
|
+
/** `<uuid>` optionally followed by a short `.ext` — the only shape we serve. */
|
|
23
|
+
const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(\.[A-Za-z0-9]{1,8})?$/;
|
|
24
|
+
export class AttachmentStore {
|
|
25
|
+
root;
|
|
26
|
+
constructor(root) {
|
|
27
|
+
this.root = root;
|
|
28
|
+
}
|
|
29
|
+
async init() {
|
|
30
|
+
await fs.mkdir(this.root, { recursive: true });
|
|
31
|
+
}
|
|
32
|
+
/** Persist bytes, returning an opaque id (`<uuid>` + the source's extension). */
|
|
33
|
+
async save(bytes, filenameForExt) {
|
|
34
|
+
const ext = path.extname(filenameForExt).toLowerCase().replace(/[^.a-z0-9]/g, "");
|
|
35
|
+
const id = `${randomUUID()}${ext}`;
|
|
36
|
+
await fs.mkdir(this.root, { recursive: true });
|
|
37
|
+
await fs.writeFile(path.join(this.root, id), bytes);
|
|
38
|
+
return id;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* The absolute on-disk path for a (validated) attachment id, or `null` if the
|
|
42
|
+
* id is malformed. Used to point the keeper's `Read` tool at an uploaded file
|
|
43
|
+
* (issue #328) — the store file IS the on-disk copy the agent reads. Does not
|
|
44
|
+
* check existence; pair with {@link exists} when that matters.
|
|
45
|
+
*/
|
|
46
|
+
absolutePath(id) {
|
|
47
|
+
if (!ID_RE.test(id))
|
|
48
|
+
return null;
|
|
49
|
+
return path.join(this.root, id);
|
|
50
|
+
}
|
|
51
|
+
/** True when a (validated) attachment id currently has bytes on disk. */
|
|
52
|
+
async exists(id) {
|
|
53
|
+
if (!ID_RE.test(id))
|
|
54
|
+
return false;
|
|
55
|
+
try {
|
|
56
|
+
const info = await fs.stat(path.join(this.root, id));
|
|
57
|
+
return info.isFile();
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Read an attachment by id (validated), with a content-type for serving. */
|
|
64
|
+
async read(id) {
|
|
65
|
+
if (!ID_RE.test(id))
|
|
66
|
+
return null;
|
|
67
|
+
try {
|
|
68
|
+
const bytes = await fs.readFile(path.join(this.root, id));
|
|
69
|
+
const mime = contentTypeFor(id);
|
|
70
|
+
return {
|
|
71
|
+
bytes,
|
|
72
|
+
// Non-image kinds (markdown/code/text/html) are fetched as text.
|
|
73
|
+
mime: mime === "application/octet-stream" ? "text/plain; charset=utf-8" : mime,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Best-effort removal of a set of attachments (used on chat delete). */
|
|
81
|
+
async remove(ids) {
|
|
82
|
+
await Promise.all(ids
|
|
83
|
+
.filter((id) => ID_RE.test(id))
|
|
84
|
+
.map((id) => fs.rm(path.join(this.root, id), { force: true }).catch(() => undefined)));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Extract the attachment ids a chat's transcript references, used to clean up a
|
|
89
|
+
* chat's attachments when it's deleted. Two sources:
|
|
90
|
+
* - agent `send_file` tool outputs (#112) — the JSON envelope's `attachmentId`;
|
|
91
|
+
* - user upload wrappers (#328) — the `<paddock-attachments>` block a user turn
|
|
92
|
+
* carries, parsed by {@link parseAttachmentIds}.
|
|
93
|
+
* Inline `send_file` sends carry no id, so they're naturally skipped.
|
|
94
|
+
*/
|
|
95
|
+
export function collectAttachmentIds(messages) {
|
|
96
|
+
const ids = [];
|
|
97
|
+
for (const m of messages) {
|
|
98
|
+
// Agent-sent files (send_file tool output envelope).
|
|
99
|
+
if (m.toolCall?.toolName === SEND_FILE_TOOL_NAME && m.toolCall.output) {
|
|
100
|
+
try {
|
|
101
|
+
const env = JSON.parse(m.toolCall.output);
|
|
102
|
+
if (env?.paddockSendFile === 1 && env.source === "file" && env.attachmentId) {
|
|
103
|
+
ids.push(env.attachmentId);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// Not our envelope / not JSON — skip.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// User-uploaded files (composer attachment wrapper). A user turn's content is
|
|
111
|
+
// the raw prompt string, which may carry the `<paddock-attachments>` block.
|
|
112
|
+
if (typeof m.content === "string" && m.content.includes(ATTACHMENTS_OPEN)) {
|
|
113
|
+
ids.push(...parseAttachmentIds(m.content));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
return ids;
|
|
117
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-agnostic user authentication.
|
|
3
|
+
*
|
|
4
|
+
* Paddock has no login of its own — it is meant to sit behind a reverse proxy
|
|
5
|
+
* that has already authenticated the user against an OIDC IdP (Authentik,
|
|
6
|
+
* oauth2-proxy, Authelia, Cloudflare Access, Keycloak, …). This plugin turns
|
|
7
|
+
* that upstream identity into a `req.user` that the rest of the app can read,
|
|
8
|
+
* WITHOUT hardcoding any single provider. Behaviour is driven entirely by the
|
|
9
|
+
* `PADDOCK_AUTH_*` env vars resolved into `cfg.auth` (see config.ts + AUTH.md).
|
|
10
|
+
*
|
|
11
|
+
* Three modes:
|
|
12
|
+
* - `none` — no-op; every request is anonymous. Fully open (default).
|
|
13
|
+
* - `trusted-header` — read identity from proxy-set header(s). 401 if absent.
|
|
14
|
+
* Trust is network-level: only safe when the proxy is the
|
|
15
|
+
* sole path to paddock (it can forge headers otherwise).
|
|
16
|
+
* - `jwt` — verify a signed JWT (from a configured header) against a
|
|
17
|
+
* remote JWKS using `jose`. Zero-trust / spoof-proof:
|
|
18
|
+
* paddock holds no key material, only the JWKS URL, and
|
|
19
|
+
* rejects missing/invalid/expired tokens with 401.
|
|
20
|
+
*
|
|
21
|
+
* Paddock's health route (`/api/health`) is always exempt so the proxy and
|
|
22
|
+
* monitoring can poll a server that is otherwise locked down. Only registered
|
|
23
|
+
* routes may be exempted — see HEALTH_PATHS below for why (issue #569).
|
|
24
|
+
*
|
|
25
|
+
* Registered in app.ts BEFORE the routes so the `onRequest` hook guards every
|
|
26
|
+
* REST + WS request. The decorator + hook are added directly to the root app
|
|
27
|
+
* instance (no fastify-plugin wrapper needed) so they apply app-wide. The hook
|
|
28
|
+
* never throws past Fastify — auth failures are sent as a clean 401 JSON body
|
|
29
|
+
* and logged via `req.log`.
|
|
30
|
+
*/
|
|
31
|
+
import type { FastifyInstance } from "fastify";
|
|
32
|
+
import type { AuthConfig } from "./config.js";
|
|
33
|
+
/** The authenticated (or anonymous) principal attached to every request. */
|
|
34
|
+
export interface AuthUser {
|
|
35
|
+
/** Stable identifier for the user (username / sub / email, per config). */
|
|
36
|
+
username: string;
|
|
37
|
+
/** Email, when the provider supplies one. */
|
|
38
|
+
email?: string;
|
|
39
|
+
/** Group / role memberships, when supplied. */
|
|
40
|
+
groups?: string[];
|
|
41
|
+
/** True for the synthetic principal used in `none` mode (no real identity). */
|
|
42
|
+
anonymous?: boolean;
|
|
43
|
+
}
|
|
44
|
+
declare module "fastify" {
|
|
45
|
+
interface FastifyRequest {
|
|
46
|
+
/**
|
|
47
|
+
* The authenticated principal. Always present once the auth plugin has run:
|
|
48
|
+
* a real user in trusted-header/jwt modes, or an anonymous principal in
|
|
49
|
+
* `none` mode.
|
|
50
|
+
*/
|
|
51
|
+
user: AuthUser;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Paths that must never require auth (proxy / monitoring health probes).
|
|
56
|
+
*
|
|
57
|
+
* **Every member must be a REGISTERED route** (see `routes/meta.ts`). Exempting a
|
|
58
|
+
* path that no route serves does not make it 404 — it makes the SPA not-found
|
|
59
|
+
* handler (`app.ts`) reachable *without a credential*, so the probe receives the
|
|
60
|
+
* HTML app shell with a 200 and reads a locked-down or half-broken instance as
|
|
61
|
+
* healthy. In the authenticated modes that inverts the truthful answer: an
|
|
62
|
+
* unregistered path 401s honestly, but an exempted unregistered one reports 200.
|
|
63
|
+
*
|
|
64
|
+
* This set previously carried five conventional aliases (`/healthz`, `/-/health`,
|
|
65
|
+
* `/health`, `/readyz`, `/livez`) that were never registered — issue #569.
|
|
66
|
+
* `test/integration/auth-health-paths.test.ts` now asserts every member resolves
|
|
67
|
+
* to a real route returning JSON, so the set cannot drift from the router again.
|
|
68
|
+
*/
|
|
69
|
+
export declare const HEALTH_PATHS: ReadonlySet<string>;
|
|
70
|
+
/**
|
|
71
|
+
* Register the auth layer on `app`. Adds a `user` request decorator and an
|
|
72
|
+
* `onRequest` hook that populates `req.user` (or replies 401). The decorator +
|
|
73
|
+
* hook are attached to the root app instance directly, so they guard every
|
|
74
|
+
* REST + WS request app-wide.
|
|
75
|
+
*
|
|
76
|
+
* In `jwt` mode the remote JWKS is created once and reused —
|
|
77
|
+
* `createRemoteJWKSet` fetches+caches keys and handles rotation, so we hold only
|
|
78
|
+
* the URL, never key material.
|
|
79
|
+
*
|
|
80
|
+
* Throws at registration time on a fatal misconfiguration (jwt mode without a
|
|
81
|
+
* JWKS URL) so the operator gets a clear startup failure instead of a server
|
|
82
|
+
* that rejects every request — failing closed but loudly.
|
|
83
|
+
*/
|
|
84
|
+
export declare function registerAuth(app: FastifyInstance, auth: AuthConfig): void;
|