@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,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TriggerSessionStore — a Paddock-side sidecar mapping an *accreting* trigger
|
|
3
|
+
* (`run.session: "resume"`) to the ONE chat session it owns (Epic T / T1). A trigger
|
|
4
|
+
* whose run declares `session: "resume"` should build up a single long-lived
|
|
5
|
+
* transcript across every fire (a "manager" that accretes context) rather than
|
|
6
|
+
* spawning a fresh chat each time. To make that resume unambiguous, Paddock records
|
|
7
|
+
* the session id the trigger created on its FIRST fire and reuses it thereafter — so
|
|
8
|
+
* the binding survives a server restart (the resume rebinds off the reloaded sidecar).
|
|
9
|
+
*
|
|
10
|
+
* This is the unified successor to `ScheduleSessionStore` (issue #265): the same
|
|
11
|
+
* pattern, keyed by (project slug, TRIGGER name) instead of schedule name, so both
|
|
12
|
+
* schedule-type and event-type triggers can own an accreting session. A
|
|
13
|
+
* `session: "new"` trigger owns nothing — it gets a new chat every fire and never
|
|
14
|
+
* touches this store.
|
|
15
|
+
*
|
|
16
|
+
* Shape + concurrency: a `"<slug>\0<triggerName>" -> sessionId` map persisted as a
|
|
17
|
+
* plain JSON object — the exact ArchiveStore / RunProvenanceStore / ScheduleSessionStore
|
|
18
|
+
* sidecar pattern (lazy-loaded, write-through, serialised, corruption-tolerant,
|
|
19
|
+
* `0o600`), INCLUDING the in-flight-load-promise fix: {@link ensureLoaded} caches the
|
|
20
|
+
* load PROMISE (not just the resolved map) so concurrent first-callers share ONE read
|
|
21
|
+
* and can't each build a separate map and race on the assignment (a lost update).
|
|
22
|
+
* Reachable if several triggers fire in the same tick right after boot.
|
|
23
|
+
*/
|
|
24
|
+
import { promises as fs } from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
const STATE_FILE = "trigger-sessions.json";
|
|
27
|
+
/** The composite key separator — NUL can't appear in a slug or trigger name. */
|
|
28
|
+
const SEP = "\0";
|
|
29
|
+
/** A session id we're willing to key on — mirrors RunProvenanceStore's guard. */
|
|
30
|
+
function isSafeId(sessionId) {
|
|
31
|
+
return typeof sessionId === "string" && /^[A-Za-z0-9._-]+$/.test(sessionId);
|
|
32
|
+
}
|
|
33
|
+
export class TriggerSessionStore {
|
|
34
|
+
stateFile;
|
|
35
|
+
/** In-memory map of `<slug>\0<triggerName>` -> owned sessionId. */
|
|
36
|
+
state = null;
|
|
37
|
+
/**
|
|
38
|
+
* The in-flight load, cached so concurrent first-callers share ONE read (the
|
|
39
|
+
* RunProvenanceStore fix): caching only the resolved `state` would let several
|
|
40
|
+
* fires that begin before the first `fs.readFile` resolves each build their own
|
|
41
|
+
* map and race on the assignment — a lost update.
|
|
42
|
+
*/
|
|
43
|
+
loadPromise = null;
|
|
44
|
+
/** Serialises concurrent writes so the file never interleaves. */
|
|
45
|
+
writing = Promise.resolve();
|
|
46
|
+
constructor(dataDir) {
|
|
47
|
+
this.stateFile = path.join(dataDir, STATE_FILE);
|
|
48
|
+
}
|
|
49
|
+
static key(slug, triggerName) {
|
|
50
|
+
return `${slug}${SEP}${triggerName}`;
|
|
51
|
+
}
|
|
52
|
+
/** Load the persisted map (lazily, deduped; tolerant of a missing/corrupt file). */
|
|
53
|
+
ensureLoaded() {
|
|
54
|
+
if (this.state)
|
|
55
|
+
return Promise.resolve(this.state);
|
|
56
|
+
if (!this.loadPromise) {
|
|
57
|
+
this.loadPromise = (async () => {
|
|
58
|
+
const map = new Map();
|
|
59
|
+
try {
|
|
60
|
+
const raw = await fs.readFile(this.stateFile, "utf8");
|
|
61
|
+
const parsed = JSON.parse(raw);
|
|
62
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
63
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
64
|
+
if (typeof v === "string" && isSafeId(v))
|
|
65
|
+
map.set(k, v);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
/* missing or unreadable — start empty */
|
|
71
|
+
}
|
|
72
|
+
this.state = map;
|
|
73
|
+
return map;
|
|
74
|
+
})();
|
|
75
|
+
}
|
|
76
|
+
return this.loadPromise;
|
|
77
|
+
}
|
|
78
|
+
/** The session id this trigger owns, or undefined if it hasn't fired yet. Non-throwing. */
|
|
79
|
+
async get(slug, triggerName) {
|
|
80
|
+
const map = await this.ensureLoaded().catch(() => new Map());
|
|
81
|
+
return map.get(TriggerSessionStore.key(slug, triggerName));
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Record the session a trigger owns (its first-fire chat). Idempotent for the same
|
|
85
|
+
* id; an unsafe/blank id is ignored. Overwrites if called again (used only on a
|
|
86
|
+
* fresh first fire, or to re-point after a stale id was cleared).
|
|
87
|
+
*/
|
|
88
|
+
async set(slug, triggerName, sessionId) {
|
|
89
|
+
if (!isSafeId(sessionId))
|
|
90
|
+
return;
|
|
91
|
+
const key = TriggerSessionStore.key(slug, triggerName);
|
|
92
|
+
const map = await this.ensureLoaded();
|
|
93
|
+
if (map.get(key) === sessionId)
|
|
94
|
+
return; // no-op
|
|
95
|
+
map.set(key, sessionId);
|
|
96
|
+
await this.persist(map);
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Forget a trigger's owned session — used when the recorded transcript has vanished
|
|
100
|
+
* (so the next fire re-creates one) or the trigger was removed. No-op if there was
|
|
101
|
+
* nothing recorded.
|
|
102
|
+
*/
|
|
103
|
+
async clear(slug, triggerName) {
|
|
104
|
+
const key = TriggerSessionStore.key(slug, triggerName);
|
|
105
|
+
const map = await this.ensureLoaded();
|
|
106
|
+
if (!map.has(key))
|
|
107
|
+
return;
|
|
108
|
+
map.delete(key);
|
|
109
|
+
await this.persist(map);
|
|
110
|
+
}
|
|
111
|
+
/** Write-through, serialised so overlapping writes can't corrupt the file. */
|
|
112
|
+
persist(map) {
|
|
113
|
+
this.writing = this.writing.then(async () => {
|
|
114
|
+
const obj = {};
|
|
115
|
+
for (const [k, v] of map)
|
|
116
|
+
obj[k] = v;
|
|
117
|
+
const json = JSON.stringify(obj, null, 2);
|
|
118
|
+
await fs.writeFile(this.stateFile, json, { encoding: "utf8", mode: 0o600 }).catch(() => undefined);
|
|
119
|
+
});
|
|
120
|
+
return this.writing;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified trigger CRUD/registry service (Epic T "Unify Triggers", ticket T1).
|
|
3
|
+
*
|
|
4
|
+
* ONE registry over the single `startAgentTurn` execution core that subsumes what
|
|
5
|
+
* were two services — the Epic G `HookService` (event hooks) and the Epic D schedule
|
|
6
|
+
* registration (spread across `ProjectStore` + `HerdctlService`). A trigger is
|
|
7
|
+
* persisted per project (`project.yaml` `triggers` map) and armed against herdctl by
|
|
8
|
+
* type:
|
|
9
|
+
*
|
|
10
|
+
* - **event** trigger → its OWN herdctl agent `trigger-<slug>-<name>` (tool config
|
|
11
|
+
* = capability), fired by the in-process event bus (`onArchive` today).
|
|
12
|
+
* - **schedule** trigger → forwarded into the keeper agent's `schedules` block (the
|
|
13
|
+
* herdctl cron engine arms it — the cron TIMING), fired via
|
|
14
|
+
* `setScheduleTriggerHandler`. It executes on its OWN scoped `trigger-<slug>-<name>`
|
|
15
|
+
* agent (tool config = capability) when it declares a `run.tools` allow-list (T2 —
|
|
16
|
+
* #307), else on the keeper with the project-agent default toolset.
|
|
17
|
+
* - **webhook** trigger → shape reserved; persisted + validated but NOT armed and
|
|
18
|
+
* never fired (the ingress is the deferred T6/#311).
|
|
19
|
+
*
|
|
20
|
+
* The service is the FROZEN surface T2–T5 build on (the analogue of the shipped
|
|
21
|
+
* `HookService`): reads come straight off the live project record (project.yaml is the
|
|
22
|
+
* source of truth, re-armed on restart); writes persist THEN arm (best-effort — a
|
|
23
|
+
* transient fleet hiccup never loses the persisted definition, it's re-armed from the
|
|
24
|
+
* record on the next boot / `ensureProjectAgent`).
|
|
25
|
+
*/
|
|
26
|
+
import type { ProjectStore } from "./projects.js";
|
|
27
|
+
import type { HerdctlService } from "./herdctl.js";
|
|
28
|
+
import type { PaddockTrigger, TriggerDto, TriggerEvent } from "./trigger-config.js";
|
|
29
|
+
/** Project a persisted {@link PaddockTrigger} + its name/slug onto the {@link TriggerDto}. */
|
|
30
|
+
export declare function toTriggerDto(slug: string, name: string, trigger: PaddockTrigger): TriggerDto;
|
|
31
|
+
/**
|
|
32
|
+
* List/get/set/remove a project's triggers — the frozen surface T2 (schedule tool
|
|
33
|
+
* allow-list), T3 (REST + self-MCP verb collapse), T4 (Triggers tab) and T5 (sweeper
|
|
34
|
+
* fold-in) build on. Reads come off the live project record; writes persist THEN arm
|
|
35
|
+
* the trigger's herdctl agent / schedule.
|
|
36
|
+
*/
|
|
37
|
+
export declare class TriggerService {
|
|
38
|
+
private readonly projects;
|
|
39
|
+
private readonly herdctl;
|
|
40
|
+
constructor(projects: ProjectStore, herdctl: HerdctlService);
|
|
41
|
+
/** Every trigger declared by `slug`, as DTOs (empty when the project has none). */
|
|
42
|
+
list(slug: string): Promise<TriggerDto[]>;
|
|
43
|
+
/** One trigger by name, or `null` if the project declares no such trigger. */
|
|
44
|
+
get(slug: string, name: string): Promise<TriggerDto | null>;
|
|
45
|
+
/**
|
|
46
|
+
* The trigger whose registered agent is `agentName` (`trigger-<slug>-<name>`), or
|
|
47
|
+
* `null` — the reverse-map (mirrors `HookService.getByAgentName`). Resolved by
|
|
48
|
+
* matching the project's DECLARED triggers rather than by PARSING the agent string
|
|
49
|
+
* (a slug may contain hyphens, so `trigger-<slug>-<name>` isn't unambiguously
|
|
50
|
+
* splittable). Never throws (a missing project → `null`).
|
|
51
|
+
*/
|
|
52
|
+
getByAgentName(slug: string, agentName: string): Promise<TriggerDto | null>;
|
|
53
|
+
/**
|
|
54
|
+
* Create or update a trigger: persist it to `project.yaml` (validates the name +
|
|
55
|
+
* sanitises the record via the Zod schema, throwing `ProjectError` on a malformed
|
|
56
|
+
* trigger), then arm it so it's immediately fireable. Returns the persisted
|
|
57
|
+
* trigger's DTO. The arm is best-effort — re-armed from the record on the next
|
|
58
|
+
* restart / `ensureProjectAgent` if it fails — so a transient fleet hiccup never
|
|
59
|
+
* loses the persisted definition.
|
|
60
|
+
*/
|
|
61
|
+
set(slug: string, name: string, trigger: unknown): Promise<TriggerDto>;
|
|
62
|
+
/**
|
|
63
|
+
* Remove a trigger: delete it from `project.yaml` and unregister/disarm it. Returns
|
|
64
|
+
* whether a trigger actually existed (so a caller can echo `removed: false`).
|
|
65
|
+
*/
|
|
66
|
+
remove(slug: string, name: string): Promise<boolean>;
|
|
67
|
+
/**
|
|
68
|
+
* The enabled EVENT triggers a project declares for `event` (the dispatcher's
|
|
69
|
+
* resolver — the analogue of `HookService.enabledForEvent`). Schedule/webhook
|
|
70
|
+
* triggers are never returned here (they fire on cron / an ingress, not the bus).
|
|
71
|
+
*/
|
|
72
|
+
enabledForEvent(slug: string, event: TriggerEvent): Promise<TriggerDto[]>;
|
|
73
|
+
/**
|
|
74
|
+
* Arm a single trigger against herdctl (best-effort): an event trigger becomes its
|
|
75
|
+
* own `trigger-<slug>-<name>` agent; a schedule trigger is (re-)forwarded into the
|
|
76
|
+
* keeper's `schedules` block via a keeper re-register — which ALSO registers its OWN
|
|
77
|
+
* scoped `trigger-<slug>-<name>` agent when it declares a `run.tools` allow-list (T2,
|
|
78
|
+
* via `ensureProjectAgent` → `registerTriggerAgents`); a webhook trigger is reserved
|
|
79
|
+
* (nothing to arm — no ingress in T1).
|
|
80
|
+
*/
|
|
81
|
+
private arm;
|
|
82
|
+
private dtosOf;
|
|
83
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { triggerAgentName } from "./herdctl.js";
|
|
2
|
+
import { triggerRunsOnOwnAgent } from "./trigger-config.js";
|
|
3
|
+
/** Project a persisted {@link PaddockTrigger} + its name/slug onto the {@link TriggerDto}. */
|
|
4
|
+
export function toTriggerDto(slug, name, trigger) {
|
|
5
|
+
return { name, agentName: triggerAgentName(slug, name), ...trigger };
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* List/get/set/remove a project's triggers — the frozen surface T2 (schedule tool
|
|
9
|
+
* allow-list), T3 (REST + self-MCP verb collapse), T4 (Triggers tab) and T5 (sweeper
|
|
10
|
+
* fold-in) build on. Reads come off the live project record; writes persist THEN arm
|
|
11
|
+
* the trigger's herdctl agent / schedule.
|
|
12
|
+
*/
|
|
13
|
+
export class TriggerService {
|
|
14
|
+
projects;
|
|
15
|
+
herdctl;
|
|
16
|
+
constructor(projects, herdctl) {
|
|
17
|
+
this.projects = projects;
|
|
18
|
+
this.herdctl = herdctl;
|
|
19
|
+
}
|
|
20
|
+
/** Every trigger declared by `slug`, as DTOs (empty when the project has none). */
|
|
21
|
+
async list(slug) {
|
|
22
|
+
const project = await this.projects.get(slug); // throws not_found
|
|
23
|
+
return this.dtosOf(project);
|
|
24
|
+
}
|
|
25
|
+
/** One trigger by name, or `null` if the project declares no such trigger. */
|
|
26
|
+
async get(slug, name) {
|
|
27
|
+
const project = await this.projects.get(slug); // throws not_found
|
|
28
|
+
const trigger = project.triggers?.[name];
|
|
29
|
+
return trigger ? toTriggerDto(slug, name, trigger) : null;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The trigger whose registered agent is `agentName` (`trigger-<slug>-<name>`), or
|
|
33
|
+
* `null` — the reverse-map (mirrors `HookService.getByAgentName`). Resolved by
|
|
34
|
+
* matching the project's DECLARED triggers rather than by PARSING the agent string
|
|
35
|
+
* (a slug may contain hyphens, so `trigger-<slug>-<name>` isn't unambiguously
|
|
36
|
+
* splittable). Never throws (a missing project → `null`).
|
|
37
|
+
*/
|
|
38
|
+
async getByAgentName(slug, agentName) {
|
|
39
|
+
const project = await this.projects.get(slug).catch(() => null);
|
|
40
|
+
if (!project)
|
|
41
|
+
return null;
|
|
42
|
+
for (const [name, trigger] of Object.entries(project.triggers ?? {})) {
|
|
43
|
+
if (triggerAgentName(slug, name) === agentName)
|
|
44
|
+
return toTriggerDto(slug, name, trigger);
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Create or update a trigger: persist it to `project.yaml` (validates the name +
|
|
50
|
+
* sanitises the record via the Zod schema, throwing `ProjectError` on a malformed
|
|
51
|
+
* trigger), then arm it so it's immediately fireable. Returns the persisted
|
|
52
|
+
* trigger's DTO. The arm is best-effort — re-armed from the record on the next
|
|
53
|
+
* restart / `ensureProjectAgent` if it fails — so a transient fleet hiccup never
|
|
54
|
+
* loses the persisted definition.
|
|
55
|
+
*/
|
|
56
|
+
async set(slug, name, trigger) {
|
|
57
|
+
const project = await this.projects.setTrigger(slug, name, trigger);
|
|
58
|
+
const rec = project.triggers?.[name];
|
|
59
|
+
if (!rec)
|
|
60
|
+
throw new Error(`trigger not persisted: ${name}`);
|
|
61
|
+
await this.arm(project, name, rec).catch(() => undefined);
|
|
62
|
+
return toTriggerDto(slug, name, rec);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Remove a trigger: delete it from `project.yaml` and unregister/disarm it. Returns
|
|
66
|
+
* whether a trigger actually existed (so a caller can echo `removed: false`).
|
|
67
|
+
*/
|
|
68
|
+
async remove(slug, name) {
|
|
69
|
+
const before = await this.projects.get(slug); // throws not_found
|
|
70
|
+
const existed = Boolean(before.triggers?.[name]);
|
|
71
|
+
const rec = before.triggers?.[name];
|
|
72
|
+
const project = await this.projects.removeTrigger(slug, name);
|
|
73
|
+
if (rec) {
|
|
74
|
+
// Tear down the trigger's OWN scoped agent only if it HAD one — every event
|
|
75
|
+
// trigger, or a scoped schedule trigger with a `run.tools` allow-list (T2). An
|
|
76
|
+
// unscoped, keeper-run schedule never registered an agent, so skip the fleet call.
|
|
77
|
+
if (triggerRunsOnOwnAgent(rec)) {
|
|
78
|
+
await this.herdctl.removeTriggerAgent(slug, name).catch(() => undefined);
|
|
79
|
+
}
|
|
80
|
+
// A schedule trigger ALSO rode the keeper's forwarded `schedules` block (the cron
|
|
81
|
+
// timing) — re-register the keeper from the updated record so that arming is
|
|
82
|
+
// dropped (and any remaining scoped trigger agents are re-affirmed).
|
|
83
|
+
if (rec.trigger.type === "schedule") {
|
|
84
|
+
await this.herdctl.ensureProjectAgent(project).catch(() => undefined);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return existed;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The enabled EVENT triggers a project declares for `event` (the dispatcher's
|
|
91
|
+
* resolver — the analogue of `HookService.enabledForEvent`). Schedule/webhook
|
|
92
|
+
* triggers are never returned here (they fire on cron / an ingress, not the bus).
|
|
93
|
+
*/
|
|
94
|
+
async enabledForEvent(slug, event) {
|
|
95
|
+
const project = await this.projects.get(slug).catch(() => null);
|
|
96
|
+
if (!project)
|
|
97
|
+
return [];
|
|
98
|
+
return this.dtosOf(project).filter((t) => t.trigger.type === "event" && t.trigger.on === event && t.enabled === true);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Arm a single trigger against herdctl (best-effort): an event trigger becomes its
|
|
102
|
+
* own `trigger-<slug>-<name>` agent; a schedule trigger is (re-)forwarded into the
|
|
103
|
+
* keeper's `schedules` block via a keeper re-register — which ALSO registers its OWN
|
|
104
|
+
* scoped `trigger-<slug>-<name>` agent when it declares a `run.tools` allow-list (T2,
|
|
105
|
+
* via `ensureProjectAgent` → `registerTriggerAgents`); a webhook trigger is reserved
|
|
106
|
+
* (nothing to arm — no ingress in T1).
|
|
107
|
+
*/
|
|
108
|
+
async arm(project, name, trigger) {
|
|
109
|
+
if (trigger.trigger.type === "event") {
|
|
110
|
+
await this.herdctl.ensureTriggerAgent(project, name, trigger);
|
|
111
|
+
}
|
|
112
|
+
else if (trigger.trigger.type === "schedule") {
|
|
113
|
+
// Re-register the keeper so its forwarded `schedules` block picks up the new /
|
|
114
|
+
// edited schedule trigger (un-gated). ensureProjectAgent
|
|
115
|
+
// also re-runs registerTriggerAgents, so a scoped schedule (T2) gets its own agent
|
|
116
|
+
// armed here too. Idempotent.
|
|
117
|
+
await this.herdctl.ensureProjectAgent(project);
|
|
118
|
+
}
|
|
119
|
+
// webhook: shape reserved — nothing armed (deferred T6).
|
|
120
|
+
}
|
|
121
|
+
dtosOf(project) {
|
|
122
|
+
return Object.entries(project.triggers ?? {}).map(([name, trigger]) => toTriggerDto(project.slug, name, trigger));
|
|
123
|
+
}
|
|
124
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The compatibility default: loopback plus the private address space. See the
|
|
3
|
+
* module header for why this is the default and what it does not prove.
|
|
4
|
+
*/
|
|
5
|
+
export declare const DEFAULT_TRUSTED_PROXIES: readonly string[];
|
|
6
|
+
/** Believe no peer at all — the strict posture. */
|
|
7
|
+
export declare const TRUST_NONE = "none";
|
|
8
|
+
/** Believe every peer — restores the pre-#474 behaviour. Reported loudly. */
|
|
9
|
+
export declare const TRUST_ALL = "all";
|
|
10
|
+
/** A resolved trust list plus how we came by it. */
|
|
11
|
+
export interface TrustedProxies {
|
|
12
|
+
/**
|
|
13
|
+
* The entries as configured (already validated). Empty means "believe no
|
|
14
|
+
* peer". `["all"]` means "believe every peer".
|
|
15
|
+
*/
|
|
16
|
+
entries: string[];
|
|
17
|
+
/**
|
|
18
|
+
* True when the operator named these peers (env or YAML), false when this is
|
|
19
|
+
* {@link DEFAULT_TRUSTED_PROXIES}. Only an explicit list is a control, so the
|
|
20
|
+
* guard warns when a forwarded scheme is believed under the default.
|
|
21
|
+
*/
|
|
22
|
+
explicit: boolean;
|
|
23
|
+
}
|
|
24
|
+
/** The default list, wrapped. */
|
|
25
|
+
export declare function defaultTrustedProxies(): TrustedProxies;
|
|
26
|
+
export interface ParsedTrustedProxies {
|
|
27
|
+
trusted: TrustedProxies;
|
|
28
|
+
/** Entries that were dropped because they are not an IP, CIDR, or preset. */
|
|
29
|
+
errors: string[];
|
|
30
|
+
/** Advice worth logging at boot (currently: `all` disables the guard). */
|
|
31
|
+
warnings: string[];
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Parse an operator-supplied list (a YAML array, or a comma/newline-delimited
|
|
35
|
+
* string) into a validated trust list.
|
|
36
|
+
*
|
|
37
|
+
* An unparseable entry is DROPPED with an error rather than taking the instance
|
|
38
|
+
* down — one typo in a CIDR must not stop Paddock booting. Note the direction of
|
|
39
|
+
* that failure: dropping an entry can only make the guard stricter (a peer stops
|
|
40
|
+
* being trusted), never more permissive, so the fail-safe way is also the safe
|
|
41
|
+
* way here.
|
|
42
|
+
*
|
|
43
|
+
* `undefined` / empty input means "the operator said nothing" and yields the
|
|
44
|
+
* default list; an explicit `none` yields an empty, explicit list.
|
|
45
|
+
*/
|
|
46
|
+
export declare function parseTrustedProxies(raw: string[] | string | undefined): ParsedTrustedProxies;
|
|
47
|
+
/**
|
|
48
|
+
* Whether `addr` — the IMMEDIATE peer from the socket, never a header — is a
|
|
49
|
+
* trusted proxy. An unknown/absent address is never trusted.
|
|
50
|
+
*/
|
|
51
|
+
export declare function isTrustedProxy(addr: string | undefined, trusted: TrustedProxies): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Whether `addr` is a loopback socket address — the client is on this host, so
|
|
54
|
+
* nothing was put on a wire. Covers IPv4 `127.0.0.0/8`, IPv6 `::1`, and the
|
|
55
|
+
* IPv4-mapped form Node reports on a dual-stack listener.
|
|
56
|
+
*/
|
|
57
|
+
export declare function isLoopbackAddress(addr: string | undefined): boolean;
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which peers' `X-Forwarded-Proto` the `/mcp` transport guard believes (#474).
|
|
3
|
+
*
|
|
4
|
+
* #471 gave the Management API a plaintext guard: a bearer token must not cross
|
|
5
|
+
* a real network readable-in-transit, so `/mcp` refuses plaintext from anything
|
|
6
|
+
* but a loopback client. It also accepted `X-Forwarded-Proto: https` — from ANY
|
|
7
|
+
* peer, with no trust check — so the guard could be switched off by the very
|
|
8
|
+
* operator it protects, with a header copy-pasted out of a smoke-test recipe.
|
|
9
|
+
*
|
|
10
|
+
* ── What this is and is not ────────────────────────────────────────────────
|
|
11
|
+
* NOT an authentication control. A valid bearer token is still required, and an
|
|
12
|
+
* attacker who spoofs the header gains nothing they could not already do — they
|
|
13
|
+
* still need the credential. What the header could do was mislead an OPERATOR
|
|
14
|
+
* into believing their token was protected while it travelled in clear. So the
|
|
15
|
+
* job here is to make the "is this connection secure?" answer rest on something
|
|
16
|
+
* the caller cannot set: the immediate peer's address, taken from the socket.
|
|
17
|
+
*
|
|
18
|
+
* ── The trust list ─────────────────────────────────────────────────────────
|
|
19
|
+
* A forwarded scheme is believed only from a peer in the trusted list. Entries
|
|
20
|
+
* are IPs, CIDRs, or the {@link proxyAddr} preset names (`loopback`,
|
|
21
|
+
* `linklocal`, `uniquelocal`), plus two words of our own: `none` (believe no
|
|
22
|
+
* peer) and `all` (believe everyone — the pre-#474 behaviour, reportable and
|
|
23
|
+
* loud).
|
|
24
|
+
*
|
|
25
|
+
* The DEFAULT is {@link DEFAULT_TRUSTED_PROXIES}: loopback + link-local +
|
|
26
|
+
* unique-local (RFC1918 / fc00::/7 / fe80::/10). That is a COMPATIBILITY
|
|
27
|
+
* posture, chosen because in every deployment recipe the TLS terminator sits on
|
|
28
|
+
* the host or on a private container/pod network — an nginx or Caddy sidecar on
|
|
29
|
+
* a Compose bridge, an ingress in a pod network, a proxy on loopback. A peer
|
|
30
|
+
* with a PUBLIC address is never one of those, so believing it is always wrong;
|
|
31
|
+
* that case is now refused where it previously succeeded.
|
|
32
|
+
*
|
|
33
|
+
* The default's honest limit: it cannot tell your reverse proxy at 172.18.0.5
|
|
34
|
+
* from a laptop at 192.168.1.50, because nothing in an IP packet says which one
|
|
35
|
+
* is a proxy. Naming the proxy explicitly is what upgrades this from a footgun
|
|
36
|
+
* preventer to a control, and the guard says so at runtime — believing a
|
|
37
|
+
* forwarded scheme under the default list logs a one-per-peer warning.
|
|
38
|
+
*
|
|
39
|
+
* Deliberately NOT loopback-equivalent: the Docker bridge gateway (`172.17.0.1`).
|
|
40
|
+
* A request from another host to a `0.0.0.0`-published port is SNAT'd to that
|
|
41
|
+
* same address, so "peer is the gateway" does not prove the traffic stayed on
|
|
42
|
+
* the box. Smoke-test from INSIDE the container instead (`docker compose exec
|
|
43
|
+
* paddock curl http://127.0.0.1:4000/mcp …`), where the peer really is loopback.
|
|
44
|
+
*/
|
|
45
|
+
import proxyAddr from "proxy-addr";
|
|
46
|
+
/**
|
|
47
|
+
* The compatibility default: loopback plus the private address space. See the
|
|
48
|
+
* module header for why this is the default and what it does not prove.
|
|
49
|
+
*/
|
|
50
|
+
export const DEFAULT_TRUSTED_PROXIES = ["loopback", "linklocal", "uniquelocal"];
|
|
51
|
+
/** Believe no peer at all — the strict posture. */
|
|
52
|
+
export const TRUST_NONE = "none";
|
|
53
|
+
/** Believe every peer — restores the pre-#474 behaviour. Reported loudly. */
|
|
54
|
+
export const TRUST_ALL = "all";
|
|
55
|
+
/** The default list, wrapped. */
|
|
56
|
+
export function defaultTrustedProxies() {
|
|
57
|
+
return { entries: [...DEFAULT_TRUSTED_PROXIES], explicit: false };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Parse an operator-supplied list (a YAML array, or a comma/newline-delimited
|
|
61
|
+
* string) into a validated trust list.
|
|
62
|
+
*
|
|
63
|
+
* An unparseable entry is DROPPED with an error rather than taking the instance
|
|
64
|
+
* down — one typo in a CIDR must not stop Paddock booting. Note the direction of
|
|
65
|
+
* that failure: dropping an entry can only make the guard stricter (a peer stops
|
|
66
|
+
* being trusted), never more permissive, so the fail-safe way is also the safe
|
|
67
|
+
* way here.
|
|
68
|
+
*
|
|
69
|
+
* `undefined` / empty input means "the operator said nothing" and yields the
|
|
70
|
+
* default list; an explicit `none` yields an empty, explicit list.
|
|
71
|
+
*/
|
|
72
|
+
export function parseTrustedProxies(raw) {
|
|
73
|
+
const errors = [];
|
|
74
|
+
const warnings = [];
|
|
75
|
+
const tokens = (Array.isArray(raw)
|
|
76
|
+
? raw.map((v) => String(v))
|
|
77
|
+
: String(raw ?? "")
|
|
78
|
+
.split(/[\n,]/)
|
|
79
|
+
.map((v) => v))
|
|
80
|
+
.map((v) => v.trim().toLowerCase())
|
|
81
|
+
.filter((v) => v.length > 0);
|
|
82
|
+
if (tokens.length === 0)
|
|
83
|
+
return { trusted: defaultTrustedProxies(), errors, warnings };
|
|
84
|
+
if (tokens.includes(TRUST_ALL) || tokens.includes("*")) {
|
|
85
|
+
warnings.push(`managementApi.trustedProxies: "all" — every peer's X-Forwarded-Proto is believed, so the ` +
|
|
86
|
+
`/mcp plaintext guard can be switched off by any client that can reach this instance. ` +
|
|
87
|
+
`This is the pre-#474 behaviour; name your proxy's address instead.`);
|
|
88
|
+
return {
|
|
89
|
+
trusted: { entries: [TRUST_ALL], explicit: true },
|
|
90
|
+
errors,
|
|
91
|
+
warnings,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
const entries = [];
|
|
95
|
+
for (const token of tokens) {
|
|
96
|
+
if (token === TRUST_NONE)
|
|
97
|
+
continue; // an explicit empty list
|
|
98
|
+
try {
|
|
99
|
+
proxyAddr.compile([token]);
|
|
100
|
+
entries.push(token);
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
errors.push(`managementApi.trustedProxies: "${token}" is not an IP address, CIDR, or one of ` +
|
|
104
|
+
`loopback/linklocal/uniquelocal/none/all — entry ignored`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return { trusted: { entries, explicit: true }, errors, warnings };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Compiled matchers, keyed by the joined list. `proxyAddr.compile` parses every
|
|
111
|
+
* entry, so we do it once per distinct list rather than once per request.
|
|
112
|
+
*/
|
|
113
|
+
const compiled = new Map();
|
|
114
|
+
function matcherFor(entries) {
|
|
115
|
+
const key = entries.join(",");
|
|
116
|
+
let fn = compiled.get(key);
|
|
117
|
+
if (!fn) {
|
|
118
|
+
if (entries.length === 0) {
|
|
119
|
+
fn = () => false;
|
|
120
|
+
}
|
|
121
|
+
else if (entries.length === 1 && entries[0] === TRUST_ALL) {
|
|
122
|
+
fn = () => true;
|
|
123
|
+
}
|
|
124
|
+
else {
|
|
125
|
+
const trust = proxyAddr.compile(entries);
|
|
126
|
+
fn = (addr) => trust(addr, 0);
|
|
127
|
+
}
|
|
128
|
+
compiled.set(key, fn);
|
|
129
|
+
}
|
|
130
|
+
return fn;
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Whether `addr` — the IMMEDIATE peer from the socket, never a header — is a
|
|
134
|
+
* trusted proxy. An unknown/absent address is never trusted.
|
|
135
|
+
*/
|
|
136
|
+
export function isTrustedProxy(addr, trusted) {
|
|
137
|
+
if (!addr)
|
|
138
|
+
return false;
|
|
139
|
+
try {
|
|
140
|
+
return matcherFor(trusted.entries)(addr);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// proxyAddr throws on an address it cannot parse (it should not happen for a
|
|
144
|
+
// real socket, but a malformed value must not 500 the request).
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Whether `addr` is a loopback socket address — the client is on this host, so
|
|
150
|
+
* nothing was put on a wire. Covers IPv4 `127.0.0.0/8`, IPv6 `::1`, and the
|
|
151
|
+
* IPv4-mapped form Node reports on a dual-stack listener.
|
|
152
|
+
*/
|
|
153
|
+
export function isLoopbackAddress(addr) {
|
|
154
|
+
if (!addr)
|
|
155
|
+
return false;
|
|
156
|
+
return isTrustedProxy(addr, { entries: ["loopback"], explicit: false });
|
|
157
|
+
}
|