@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,247 @@
|
|
|
1
|
+
import { createRemoteJWKSet, jwtVerify, } from "jose";
|
|
2
|
+
/**
|
|
3
|
+
* Paths that must never require auth (proxy / monitoring health probes).
|
|
4
|
+
*
|
|
5
|
+
* **Every member must be a REGISTERED route** (see `routes/meta.ts`). Exempting a
|
|
6
|
+
* path that no route serves does not make it 404 — it makes the SPA not-found
|
|
7
|
+
* handler (`app.ts`) reachable *without a credential*, so the probe receives the
|
|
8
|
+
* HTML app shell with a 200 and reads a locked-down or half-broken instance as
|
|
9
|
+
* healthy. In the authenticated modes that inverts the truthful answer: an
|
|
10
|
+
* unregistered path 401s honestly, but an exempted unregistered one reports 200.
|
|
11
|
+
*
|
|
12
|
+
* This set previously carried five conventional aliases (`/healthz`, `/-/health`,
|
|
13
|
+
* `/health`, `/readyz`, `/livez`) that were never registered — issue #569.
|
|
14
|
+
* `test/integration/auth-health-paths.test.ts` now asserts every member resolves
|
|
15
|
+
* to a real route returning JSON, so the set cannot drift from the router again.
|
|
16
|
+
*/
|
|
17
|
+
export const HEALTH_PATHS = new Set([
|
|
18
|
+
"/api/health", // paddock's own health route (routes/meta.ts)
|
|
19
|
+
]);
|
|
20
|
+
/**
|
|
21
|
+
* Strip the query string and trailing slash so exemption matching is robust.
|
|
22
|
+
*
|
|
23
|
+
* Note the trailing-slash half only affects the AUTH decision, not routing: Fastify
|
|
24
|
+
* is not configured with `ignoreTrailingSlash`, so `/api/health/` is exempt here yet
|
|
25
|
+
* still matches no route and answers a JSON 404. That is deliberate — an honest loud
|
|
26
|
+
* failure for a probe on the slashed form, never a false 200 (see #569).
|
|
27
|
+
*/
|
|
28
|
+
function normalizePath(url) {
|
|
29
|
+
const q = url.indexOf("?");
|
|
30
|
+
let p = q === -1 ? url : url.slice(0, q);
|
|
31
|
+
if (p.length > 1 && p.endsWith("/"))
|
|
32
|
+
p = p.slice(0, -1);
|
|
33
|
+
return p;
|
|
34
|
+
}
|
|
35
|
+
// Immutable front-end static assets (issue #223). These are the compiled web
|
|
36
|
+
// bundle — content-hashed JS/CSS/fonts under /assets, the /icons and /fonts sets,
|
|
37
|
+
// the service worker, and the PWA manifest. They carry no secrets and no per-user
|
|
38
|
+
// data. Serving them WITHOUT the JWT gate means a transient auth lapse (e.g. an
|
|
39
|
+
// Authentik session-refresh window, or the outpost briefly not injecting the
|
|
40
|
+
// header) no longer turns a chunk/asset fetch into a 401 → "Load failed" /
|
|
41
|
+
// "module script failed". The app shell (index.html / client routes) and every
|
|
42
|
+
// data route (/api, /ws) stay authenticated, so this exposes only the compiled,
|
|
43
|
+
// non-sensitive front-end — nothing an authenticated user's browser doesn't
|
|
44
|
+
// already download.
|
|
45
|
+
const STATIC_ASSET_PREFIXES = ["/assets/", "/icons/", "/fonts/"];
|
|
46
|
+
const STATIC_ASSET_FILES = new Set(["/sw.js", "/manifest.webmanifest", "/favicon.ico"]);
|
|
47
|
+
function isStaticAsset(pathname) {
|
|
48
|
+
if (STATIC_ASSET_FILES.has(pathname))
|
|
49
|
+
return true;
|
|
50
|
+
return STATIC_ASSET_PREFIXES.some((prefix) => pathname.startsWith(prefix));
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* The Management API surface (issue #312). Exempt from THIS hook because it
|
|
54
|
+
* authenticates itself — see management-auth.ts — and because the browser modes
|
|
55
|
+
* are actively wrong for it:
|
|
56
|
+
*
|
|
57
|
+
* - `jwt` mode reads `Authorization`, which COLLIDES head-on with an MCP
|
|
58
|
+
* client's `Authorization: Bearer <management token>`;
|
|
59
|
+
* - an SSO proxy in front answers with an HTML login redirect, which no MCP
|
|
60
|
+
* client can follow and which breaks OAuth discovery (M2).
|
|
61
|
+
*
|
|
62
|
+
* This exemption is safe ONLY because `/mcp` runs its own authenticator and
|
|
63
|
+
* fails closed (404 when unconfigured, 401 otherwise) — it is never open, not
|
|
64
|
+
* even at `auth.mode: none`. Prefix-matched so M2's sub-paths inherit it.
|
|
65
|
+
*/
|
|
66
|
+
const MANAGEMENT_API_PREFIX = "/mcp";
|
|
67
|
+
/**
|
|
68
|
+
* RFC 9728 protected-resource metadata (M2 discovery). Exempt for the same
|
|
69
|
+
* reason: an MCP client fetches this BEFORE it holds any credential, so gating
|
|
70
|
+
* it would make discovery impossible. The document is public by design — it
|
|
71
|
+
* names the authorization server and supported scopes, never a secret.
|
|
72
|
+
*
|
|
73
|
+
* Matched as a PREFIX because the path-inserted form is what clients actually
|
|
74
|
+
* request: for a resource at `/mcp` the metadata lives at
|
|
75
|
+
* `/.well-known/oauth-protected-resource/mcp`, not at the bare root.
|
|
76
|
+
*/
|
|
77
|
+
const PROTECTED_RESOURCE_METADATA_PREFIX = "/.well-known/oauth-protected-resource";
|
|
78
|
+
function isManagementApiPath(pathname) {
|
|
79
|
+
return (pathname === MANAGEMENT_API_PREFIX ||
|
|
80
|
+
pathname.startsWith(`${MANAGEMENT_API_PREFIX}/`) ||
|
|
81
|
+
pathname === PROTECTED_RESOURCE_METADATA_PREFIX ||
|
|
82
|
+
pathname.startsWith(`${PROTECTED_RESOURCE_METADATA_PREFIX}/`));
|
|
83
|
+
}
|
|
84
|
+
function isExempt(url) {
|
|
85
|
+
const p = normalizePath(url);
|
|
86
|
+
return HEALTH_PATHS.has(p) || isStaticAsset(p) || isManagementApiPath(p);
|
|
87
|
+
}
|
|
88
|
+
const ANONYMOUS = Object.freeze({ username: "anonymous", anonymous: true });
|
|
89
|
+
/** First non-empty value for a header (Fastify gives string | string[] | undefined). */
|
|
90
|
+
function header(req, name) {
|
|
91
|
+
const v = req.headers[name.toLowerCase()];
|
|
92
|
+
const raw = Array.isArray(v) ? v[0] : v;
|
|
93
|
+
return raw && raw.trim().length > 0 ? raw.trim() : undefined;
|
|
94
|
+
}
|
|
95
|
+
/** Split a comma/space-delimited group header into a clean string[]. */
|
|
96
|
+
function splitGroups(value) {
|
|
97
|
+
if (!value)
|
|
98
|
+
return undefined;
|
|
99
|
+
const parts = value
|
|
100
|
+
.split(/[,\s]+/)
|
|
101
|
+
.map((s) => s.trim())
|
|
102
|
+
.filter(Boolean);
|
|
103
|
+
return parts.length ? parts : undefined;
|
|
104
|
+
}
|
|
105
|
+
/** Resolve a username from JWT claims using the configured/fallback claim chain. */
|
|
106
|
+
function usernameFromClaims(payload, cfg) {
|
|
107
|
+
const pick = (key) => {
|
|
108
|
+
const v = payload[key];
|
|
109
|
+
return typeof v === "string" && v.trim().length > 0 ? v.trim() : undefined;
|
|
110
|
+
};
|
|
111
|
+
if (cfg.usernameClaim)
|
|
112
|
+
return pick(cfg.usernameClaim);
|
|
113
|
+
return pick("preferred_username") ?? pick("email") ?? pick("sub");
|
|
114
|
+
}
|
|
115
|
+
/** Extract groups from a JWT claim — accepts string[] or a delimited string. */
|
|
116
|
+
function groupsFromClaims(payload, cfg) {
|
|
117
|
+
const v = payload[cfg.groupsClaim];
|
|
118
|
+
if (Array.isArray(v)) {
|
|
119
|
+
const arr = v.filter((g) => typeof g === "string" && g.trim().length > 0);
|
|
120
|
+
return arr.length ? arr.map((s) => s.trim()) : undefined;
|
|
121
|
+
}
|
|
122
|
+
if (typeof v === "string")
|
|
123
|
+
return splitGroups(v);
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
/** Read the token from the configured header, stripping `Bearer ` for Authorization. */
|
|
127
|
+
function tokenFromRequest(req, cfg) {
|
|
128
|
+
const raw = header(req, cfg.jwtHeader);
|
|
129
|
+
if (!raw)
|
|
130
|
+
return undefined;
|
|
131
|
+
if (cfg.jwtHeader.toLowerCase() === "authorization") {
|
|
132
|
+
const m = /^Bearer\s+(.+)$/i.exec(raw);
|
|
133
|
+
return m ? m[1].trim() : raw;
|
|
134
|
+
}
|
|
135
|
+
return raw;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Register the auth layer on `app`. Adds a `user` request decorator and an
|
|
139
|
+
* `onRequest` hook that populates `req.user` (or replies 401). The decorator +
|
|
140
|
+
* hook are attached to the root app instance directly, so they guard every
|
|
141
|
+
* REST + WS request app-wide.
|
|
142
|
+
*
|
|
143
|
+
* In `jwt` mode the remote JWKS is created once and reused —
|
|
144
|
+
* `createRemoteJWKSet` fetches+caches keys and handles rotation, so we hold only
|
|
145
|
+
* the URL, never key material.
|
|
146
|
+
*
|
|
147
|
+
* Throws at registration time on a fatal misconfiguration (jwt mode without a
|
|
148
|
+
* JWKS URL) so the operator gets a clear startup failure instead of a server
|
|
149
|
+
* that rejects every request — failing closed but loudly.
|
|
150
|
+
*/
|
|
151
|
+
export function registerAuth(app, auth) {
|
|
152
|
+
const cfg = auth;
|
|
153
|
+
if (cfg.mode === "none") {
|
|
154
|
+
app.decorateRequest("user", null);
|
|
155
|
+
// This branch refuses nothing, so the management-API exemption changes no
|
|
156
|
+
// OUTCOME here — `/mcp` is reachable either way, and its own authenticator
|
|
157
|
+
// is what gates it (management-auth.ts). It is called out rather than
|
|
158
|
+
// applied because the rule that matters is "the browser hook never decides
|
|
159
|
+
// anything about /mcp", and in this mode the hook decides nothing at all.
|
|
160
|
+
app.addHook("onRequest", async (req) => {
|
|
161
|
+
req.user = ANONYMOUS;
|
|
162
|
+
});
|
|
163
|
+
app.log.info("auth: mode=none (open access — every request is anonymous)");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
// Decorate so the property exists on every request object up front (Fastify
|
|
167
|
+
// perf best-practice); the hook assigns the real value per request.
|
|
168
|
+
app.decorateRequest("user", null);
|
|
169
|
+
if (cfg.mode === "trusted-header") {
|
|
170
|
+
app.log.info({ userHeader: cfg.userHeader }, "auth: mode=trusted-header (identity trusted from proxy header)");
|
|
171
|
+
app.addHook("onRequest", async (req, reply) => {
|
|
172
|
+
if (isExempt(req.url)) {
|
|
173
|
+
req.user = ANONYMOUS;
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
const username = header(req, cfg.userHeader);
|
|
177
|
+
if (!username) {
|
|
178
|
+
req.log.warn({ url: req.url, header: cfg.userHeader }, "auth: missing user header");
|
|
179
|
+
return reply.code(401).send({ error: "unauthorized", code: "auth_required" });
|
|
180
|
+
}
|
|
181
|
+
const user = { username };
|
|
182
|
+
if (cfg.emailHeader) {
|
|
183
|
+
const email = header(req, cfg.emailHeader);
|
|
184
|
+
if (email)
|
|
185
|
+
user.email = email;
|
|
186
|
+
}
|
|
187
|
+
if (cfg.groupsHeader) {
|
|
188
|
+
const groups = splitGroups(header(req, cfg.groupsHeader));
|
|
189
|
+
if (groups)
|
|
190
|
+
user.groups = groups;
|
|
191
|
+
}
|
|
192
|
+
req.user = user;
|
|
193
|
+
});
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
// mode === "jwt"
|
|
197
|
+
if (!cfg.jwksUrl) {
|
|
198
|
+
throw new Error("auth: PADDOCK_AUTH_MODE=jwt requires PADDOCK_AUTH_JWKS_URL (the IdP's JWKS endpoint)");
|
|
199
|
+
}
|
|
200
|
+
let jwks;
|
|
201
|
+
try {
|
|
202
|
+
jwks = createRemoteJWKSet(new URL(cfg.jwksUrl));
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
throw new Error(`auth: invalid PADDOCK_AUTH_JWKS_URL (${cfg.jwksUrl}): ${err.message}`);
|
|
206
|
+
}
|
|
207
|
+
app.log.info({ jwksUrl: cfg.jwksUrl, jwtHeader: cfg.jwtHeader, issuer: cfg.jwtIssuer, audience: cfg.jwtAudience }, "auth: mode=jwt (verifying signed tokens against remote JWKS)");
|
|
208
|
+
app.addHook("onRequest", async (req, reply) => {
|
|
209
|
+
if (isExempt(req.url)) {
|
|
210
|
+
req.user = ANONYMOUS;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
const token = tokenFromRequest(req, cfg);
|
|
214
|
+
if (!token) {
|
|
215
|
+
req.log.warn({ url: req.url, header: cfg.jwtHeader }, "auth: missing JWT");
|
|
216
|
+
return reply.code(401).send({ error: "unauthorized", code: "auth_required" });
|
|
217
|
+
}
|
|
218
|
+
let payload;
|
|
219
|
+
try {
|
|
220
|
+
({ payload } = await jwtVerify(token, jwks, {
|
|
221
|
+
...(cfg.jwtIssuer ? { issuer: cfg.jwtIssuer } : {}),
|
|
222
|
+
...(cfg.jwtAudience ? { audience: cfg.jwtAudience } : {}),
|
|
223
|
+
}));
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
// Invalid signature, expired, wrong issuer/audience, malformed, etc.
|
|
227
|
+
req.log.warn({ url: req.url, err: err.message }, "auth: JWT verification failed");
|
|
228
|
+
return reply.code(401).send({ error: "invalid token", code: "auth_invalid" });
|
|
229
|
+
}
|
|
230
|
+
const username = usernameFromClaims(payload, cfg);
|
|
231
|
+
if (!username) {
|
|
232
|
+
req.log.warn({ url: req.url, claim: cfg.usernameClaim }, "auth: JWT verified but no username claim present");
|
|
233
|
+
return reply.code(401).send({ error: "invalid token", code: "auth_no_subject" });
|
|
234
|
+
}
|
|
235
|
+
const user = { username };
|
|
236
|
+
const email = payload.email;
|
|
237
|
+
if (typeof email === "string" && email.trim().length > 0)
|
|
238
|
+
user.email = email.trim();
|
|
239
|
+
// Groups: prefer the JWT claim; allow a header override only if explicitly set.
|
|
240
|
+
const groups = cfg.groupsHeader
|
|
241
|
+
? splitGroups(header(req, cfg.groupsHeader)) ?? groupsFromClaims(payload, cfg)
|
|
242
|
+
: groupsFromClaims(payload, cfg);
|
|
243
|
+
if (groups)
|
|
244
|
+
user.groups = groups;
|
|
245
|
+
req.user = user;
|
|
246
|
+
});
|
|
247
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Background-job & Monitor enrichment — issue #230.
|
|
3
|
+
*
|
|
4
|
+
* Keepers launch a lot of *detached* work: background `Bash`
|
|
5
|
+
* (`run_in_background:true`), `Monitor` event streams, and the background-task
|
|
6
|
+
* ops (`BashOutput`/`TaskOutput`/`TaskStop`/`KillShell`). herdctl's parser
|
|
7
|
+
* renders each as a plain tool call, identical to a synchronous `Read`, and the
|
|
8
|
+
* eventual result arrives *separately* as a `<task-notification>` (surfaced by
|
|
9
|
+
* herdctl #363 as `origin.kind:"task-notification"`) that the web draws as a
|
|
10
|
+
* disconnected status pill.
|
|
11
|
+
*
|
|
12
|
+
* This pass reconnects the two. Everything needed is already in the parsed
|
|
13
|
+
* messages — no raw-JSONL re-scan, no positional id recovery — because the
|
|
14
|
+
* **task id** appears on both sides:
|
|
15
|
+
*
|
|
16
|
+
* launch output → bg Bash: "Command running in background with ID: <id>…"
|
|
17
|
+
* Monitor: "Monitor started (task <id>, timeout …| persistent …)"
|
|
18
|
+
* notification → <task-id><id></task-id> + <status>/<summary>/<event>
|
|
19
|
+
*
|
|
20
|
+
* We flag background tool calls, fold the matching notification(s) into the
|
|
21
|
+
* launching `toolCall` (final status + completion summary for bg Bash; the
|
|
22
|
+
* streamed `<event>` lines for Monitor), and mark the consumed notification
|
|
23
|
+
* messages so the web no longer scatters them as standalone pills.
|
|
24
|
+
*
|
|
25
|
+
* Pure and synchronous — composes after {@link enrichWithSubagents} over the same
|
|
26
|
+
* `EnrichedMessage[]`. No herdctl change.
|
|
27
|
+
*/
|
|
28
|
+
import type { EnrichedMessage } from "./subagents.js";
|
|
29
|
+
/**
|
|
30
|
+
* Enrich background tool calls: flag them, parse task ids from launch outputs,
|
|
31
|
+
* index task-notifications by task id, fold status/summary/events into the
|
|
32
|
+
* launching `toolCall`, and mark the folded notification messages `bgConsumed`.
|
|
33
|
+
* Non-background tool calls and notification-free transcripts pass through
|
|
34
|
+
* unchanged (cheap early return).
|
|
35
|
+
*/
|
|
36
|
+
export declare function enrichWithBackground(messages: EnrichedMessage[]): EnrichedMessage[];
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { isTerminatedTaskStatus } from "./recovery-config.js";
|
|
2
|
+
/** Background-task *ops* — badge only (they operate on an already-detached task). */
|
|
3
|
+
const BACKGROUND_OP_NAMES = new Set(["BashOutput", "TaskOutput", "TaskStop", "KillShell"]);
|
|
4
|
+
/** bg `Bash` (and any tool) launch: "…running in background with ID: <id>…". */
|
|
5
|
+
const BG_LAUNCH_RE = /running in (?:the )?background with ID: ([A-Za-z0-9]+)/i;
|
|
6
|
+
/** `Monitor` launch: "Monitor started (task <id>, timeout … | persistent …)". */
|
|
7
|
+
const MONITOR_LAUNCH_RE = /^Monitor started \(task ([A-Za-z0-9]+)/;
|
|
8
|
+
/** `TaskStop` result: {"message":"Successfully stopped task: <id> (…)"}. */
|
|
9
|
+
const STOPPED_TASK_RE = /stopped task:\s*([A-Za-z0-9]+)/i;
|
|
10
|
+
/** Identify a background *launch* (Monitor / bg Bash) and its task id, else null. */
|
|
11
|
+
function detectLaunch(tc) {
|
|
12
|
+
const output = tc.output ?? "";
|
|
13
|
+
if (tc.toolName === "Monitor") {
|
|
14
|
+
const m = MONITOR_LAUNCH_RE.exec(output);
|
|
15
|
+
if (m)
|
|
16
|
+
return { taskId: m[1], kind: "monitor" };
|
|
17
|
+
// A Monitor whose start was denied/errored has no task — still background.
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const m = BG_LAUNCH_RE.exec(output);
|
|
21
|
+
if (m)
|
|
22
|
+
return { taskId: m[1], kind: "bash" };
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
/** Cheap gate: does this transcript contain any background-class tool call? */
|
|
26
|
+
function hasBackgroundTool(messages) {
|
|
27
|
+
return messages.some((m) => {
|
|
28
|
+
const tc = m.toolCall;
|
|
29
|
+
if (!tc)
|
|
30
|
+
return false;
|
|
31
|
+
return (tc.toolName === "Monitor" ||
|
|
32
|
+
BACKGROUND_OP_NAMES.has(tc.toolName) ||
|
|
33
|
+
BG_LAUNCH_RE.test(tc.output ?? ""));
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
const tag = (content, name) => {
|
|
37
|
+
const m = new RegExp(`<${name}>([\\s\\S]*?)</${name}>`).exec(content);
|
|
38
|
+
const v = m?.[1]?.trim();
|
|
39
|
+
return v && v.length ? v : undefined;
|
|
40
|
+
};
|
|
41
|
+
/** True when a `role:"user"` message is an internal `<task-notification>` block. */
|
|
42
|
+
function isTaskNotification(m) {
|
|
43
|
+
return (m.origin?.kind === "task-notification" ||
|
|
44
|
+
(m.role === "user" && m.content.trimStart().startsWith("<task-notification>")));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Enrich background tool calls: flag them, parse task ids from launch outputs,
|
|
48
|
+
* index task-notifications by task id, fold status/summary/events into the
|
|
49
|
+
* launching `toolCall`, and mark the folded notification messages `bgConsumed`.
|
|
50
|
+
* Non-background tool calls and notification-free transcripts pass through
|
|
51
|
+
* unchanged (cheap early return).
|
|
52
|
+
*/
|
|
53
|
+
export function enrichWithBackground(messages) {
|
|
54
|
+
if (!hasBackgroundTool(messages))
|
|
55
|
+
return messages;
|
|
56
|
+
// Pass 1 — index every task-notification by its task id.
|
|
57
|
+
const byTask = new Map();
|
|
58
|
+
for (const m of messages) {
|
|
59
|
+
if (!isTaskNotification(m))
|
|
60
|
+
continue;
|
|
61
|
+
const taskId = tag(m.content, "task-id");
|
|
62
|
+
if (!taskId)
|
|
63
|
+
continue;
|
|
64
|
+
let agg = byTask.get(taskId);
|
|
65
|
+
if (!agg) {
|
|
66
|
+
agg = { events: [] };
|
|
67
|
+
byTask.set(taskId, agg);
|
|
68
|
+
}
|
|
69
|
+
const event = tag(m.content, "event");
|
|
70
|
+
if (event !== undefined)
|
|
71
|
+
agg.events.push(event);
|
|
72
|
+
const status = tag(m.content, "status");
|
|
73
|
+
if (status) {
|
|
74
|
+
agg.status = status;
|
|
75
|
+
agg.completionSummary = tag(m.content, "summary");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
// Pass 2 — attach to each background launch; collect the consumed task ids.
|
|
79
|
+
const consumed = new Set();
|
|
80
|
+
const attached = messages.map((m) => {
|
|
81
|
+
const tc = m.toolCall;
|
|
82
|
+
if (!tc)
|
|
83
|
+
return m;
|
|
84
|
+
const launch = detectLaunch(tc);
|
|
85
|
+
if (launch) {
|
|
86
|
+
const agg = byTask.get(launch.taskId);
|
|
87
|
+
const next = { ...tc, background: true, taskId: launch.taskId };
|
|
88
|
+
if (launch.kind === "monitor") {
|
|
89
|
+
if (agg?.events.length)
|
|
90
|
+
next.monitorEvents = agg.events;
|
|
91
|
+
// A Monitor rarely gets an explicit <status>; it ends with a timeout
|
|
92
|
+
// *event* ("[Monitor timed out …]") or runs until TaskStop / session end.
|
|
93
|
+
const timedOut = agg?.events.some((e) => /monitor timed out/i.test(e));
|
|
94
|
+
next.taskStatus =
|
|
95
|
+
agg?.status ??
|
|
96
|
+
(timedOut ? "timed out" : /persistent/.test(tc.output ?? "") ? "persistent" : "running");
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
next.taskStatus = agg?.status ?? "running";
|
|
100
|
+
if (agg?.completionSummary)
|
|
101
|
+
next.taskResultSummary = agg.completionSummary;
|
|
102
|
+
}
|
|
103
|
+
if (agg)
|
|
104
|
+
consumed.add(launch.taskId);
|
|
105
|
+
return { ...m, toolCall: next };
|
|
106
|
+
}
|
|
107
|
+
if (BACKGROUND_OP_NAMES.has(tc.toolName)) {
|
|
108
|
+
// A background-task op — badge only. Recover a task id where cheap (TaskStop).
|
|
109
|
+
const stopped = STOPPED_TASK_RE.exec(tc.output ?? "");
|
|
110
|
+
return {
|
|
111
|
+
...m,
|
|
112
|
+
toolCall: { ...tc, background: true, ...(stopped ? { taskId: stopped[1] } : {}) },
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return m;
|
|
116
|
+
});
|
|
117
|
+
if (consumed.size === 0)
|
|
118
|
+
return attached;
|
|
119
|
+
// Pass 3 — mark folded notifications so the web drops the standalone pill.
|
|
120
|
+
// EXCEPTION (issue #301, Layer 2): a KILLED/STOPPED notification is NEVER folded
|
|
121
|
+
// away, even when its task id matches a launch. That's the turn-boundary-kill
|
|
122
|
+
// case (edspencer/herdctl#374) — the keeper is left alive-but-idle — and it must
|
|
123
|
+
// surface as a prominent standalone affordance ("keeper is idle" + Continue),
|
|
124
|
+
// not be reduced to a small "killed" chip on a scrolled-away tool block. The
|
|
125
|
+
// chip STILL renders (Pass 2 folded the status onto the launch); this only keeps
|
|
126
|
+
// the standalone notification turn alive so the recovery UI has something to hang.
|
|
127
|
+
return attached.map((m) => {
|
|
128
|
+
if (!isTaskNotification(m))
|
|
129
|
+
return m;
|
|
130
|
+
const taskId = tag(m.content, "task-id");
|
|
131
|
+
if (!taskId || !consumed.has(taskId))
|
|
132
|
+
return m;
|
|
133
|
+
if (isTerminatedTaskStatus(tag(m.content, "status")))
|
|
134
|
+
return m;
|
|
135
|
+
return { ...m, bgConsumed: true };
|
|
136
|
+
});
|
|
137
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Safe-by-default bind guard (#435).
|
|
3
|
+
*
|
|
4
|
+
* Paddock runs code and spends Claude tokens, so a careless launch on a routable
|
|
5
|
+
* interface is a real footgun. Two things protect against it:
|
|
6
|
+
*
|
|
7
|
+
* 1. The bind host now defaults to `127.0.0.1` (see config.ts) — a fresh
|
|
8
|
+
* source/tarball run is network-closed.
|
|
9
|
+
* 2. This guard couples EXPOSURE to AUTH: if the resolved bind host is
|
|
10
|
+
* NON-loopback AND `auth.mode === "none"`, Paddock REFUSES to start — mirroring
|
|
11
|
+
* the jwt-without-JWKS fail-closed behavior in auth.ts — unless the operator
|
|
12
|
+
* sets `PADDOCK_DANGEROUSLY_ALLOW_OPEN` (accepts 1/true/yes), in which case it
|
|
13
|
+
* starts but logs a loud one-line warning.
|
|
14
|
+
*
|
|
15
|
+
* Binding non-loopback WITH a real auth mode (`trusted-header`/`jwt`) is fine and
|
|
16
|
+
* needs no flag.
|
|
17
|
+
*
|
|
18
|
+
* Scope note: this is the APP/source-layer guard for bare-metal / tarball / VM /
|
|
19
|
+
* LXC-systemd runs. Inside a container the network namespace is the isolation
|
|
20
|
+
* boundary and the app can't see the host's port-publish posture, so the image
|
|
21
|
+
* keeps binding `0.0.0.0` and the deploy RECIPE carries the safe publish posture
|
|
22
|
+
* (see #406/#410) — this guard is not the container's police.
|
|
23
|
+
*/
|
|
24
|
+
import type { AuthMode } from "./config.js";
|
|
25
|
+
/** Inputs the bind-safety decision is a pure function of. */
|
|
26
|
+
export interface BindSafetyInput {
|
|
27
|
+
/** The resolved bind host (cfg.host). */
|
|
28
|
+
host: string;
|
|
29
|
+
/** The resolved auth mode (cfg.auth.mode). */
|
|
30
|
+
authMode: AuthMode;
|
|
31
|
+
/** Whether PADDOCK_DANGEROUSLY_ALLOW_OPEN is set (truthy). */
|
|
32
|
+
dangerouslyAllowOpen: boolean;
|
|
33
|
+
}
|
|
34
|
+
export type BindSafetyDecision = {
|
|
35
|
+
action: "allow";
|
|
36
|
+
} | {
|
|
37
|
+
action: "warn";
|
|
38
|
+
message: string;
|
|
39
|
+
} | {
|
|
40
|
+
action: "refuse";
|
|
41
|
+
message: string;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* True when `host` names a loopback-only interface — one that is NOT reachable
|
|
45
|
+
* from other machines. Covers `localhost`, the whole IPv4 `127.0.0.0/8` block,
|
|
46
|
+
* the IPv6 loopback `::1`, and IPv4-mapped loopback (`::ffff:127.x.x.x`). Bracket
|
|
47
|
+
* forms (`[::1]`) and surrounding whitespace/case are normalized away.
|
|
48
|
+
*
|
|
49
|
+
* `0.0.0.0` and `::` (all interfaces) are NOT loopback — they are the footgun.
|
|
50
|
+
*/
|
|
51
|
+
export declare function isLoopbackHost(host: string): boolean;
|
|
52
|
+
/**
|
|
53
|
+
* Decide whether it's safe to bind `host` under `authMode`. Pure — the caller
|
|
54
|
+
* (assertBindSafety) resolves env and acts on the decision (throw / warn / go).
|
|
55
|
+
*/
|
|
56
|
+
export declare function evaluateBindSafety(input: BindSafetyInput): BindSafetyDecision;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when `host` names a loopback-only interface — one that is NOT reachable
|
|
3
|
+
* from other machines. Covers `localhost`, the whole IPv4 `127.0.0.0/8` block,
|
|
4
|
+
* the IPv6 loopback `::1`, and IPv4-mapped loopback (`::ffff:127.x.x.x`). Bracket
|
|
5
|
+
* forms (`[::1]`) and surrounding whitespace/case are normalized away.
|
|
6
|
+
*
|
|
7
|
+
* `0.0.0.0` and `::` (all interfaces) are NOT loopback — they are the footgun.
|
|
8
|
+
*/
|
|
9
|
+
export function isLoopbackHost(host) {
|
|
10
|
+
let h = host.trim().toLowerCase();
|
|
11
|
+
if (h.startsWith("[") && h.endsWith("]"))
|
|
12
|
+
h = h.slice(1, -1);
|
|
13
|
+
if (h === "localhost")
|
|
14
|
+
return true;
|
|
15
|
+
if (h === "::1")
|
|
16
|
+
return true;
|
|
17
|
+
// IPv4-mapped IPv6 loopback, e.g. ::ffff:127.0.0.1
|
|
18
|
+
const mapped = h.startsWith("::ffff:") ? h.slice("::ffff:".length) : h;
|
|
19
|
+
// IPv4 127.0.0.0/8 — first octet 127, and a plausible dotted-quad shape.
|
|
20
|
+
return /^127(?:\.\d{1,3}){3}$/.test(mapped);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Decide whether it's safe to bind `host` under `authMode`. Pure — the caller
|
|
24
|
+
* (assertBindSafety) resolves env and acts on the decision (throw / warn / go).
|
|
25
|
+
*/
|
|
26
|
+
export function evaluateBindSafety(input) {
|
|
27
|
+
const { host, authMode, dangerouslyAllowOpen } = input;
|
|
28
|
+
// Loopback bind, or a real auth mode gating a non-loopback bind: safe.
|
|
29
|
+
if (isLoopbackHost(host) || authMode !== "none")
|
|
30
|
+
return { action: "allow" };
|
|
31
|
+
// Non-loopback + auth.mode=none — the footgun.
|
|
32
|
+
if (dangerouslyAllowOpen) {
|
|
33
|
+
return {
|
|
34
|
+
action: "warn",
|
|
35
|
+
message: `SECURITY: binding ${host} with PADDOCK_AUTH_MODE=none and ` +
|
|
36
|
+
`PADDOCK_DANGEROUSLY_ALLOW_OPEN set — Paddock is OPEN and UNAUTHENTICATED ` +
|
|
37
|
+
`on a routable interface. Anyone who can reach this port can run code and ` +
|
|
38
|
+
`spend Claude tokens as you. Put an auth mode / reverse proxy in front of it.`,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
action: "refuse",
|
|
43
|
+
message: `refusing to start: bind host "${host}" is not loopback and ` +
|
|
44
|
+
`PADDOCK_AUTH_MODE=none, which would expose an unauthenticated Paddock (it ` +
|
|
45
|
+
`runs code and spends Claude tokens) on a routable interface. Choose one: ` +
|
|
46
|
+
`set an auth mode behind a reverse proxy (PADDOCK_AUTH_MODE=trusted-header|jwt); ` +
|
|
47
|
+
`bind loopback (HOST=127.0.0.1) and reach it via a proxy/sidecar; or — only ` +
|
|
48
|
+
`if you TRULY intend an open, unauthenticated server — set ` +
|
|
49
|
+
`PADDOCK_DANGEROUSLY_ALLOW_OPEN=1.`,
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-instance branding injection (issue #34).
|
|
3
|
+
*
|
|
4
|
+
* The SPA's look (wordmark, logo glyph, accent color) is configured per
|
|
5
|
+
* instance via `PADDOCK_BRAND_*` env (see config.ts `BrandConfig`). Rather than
|
|
6
|
+
* bake it in at build time — which would force one image per instance — we
|
|
7
|
+
* inject it into `index.html` at serve time:
|
|
8
|
+
*
|
|
9
|
+
* - a `window.__PADDOCK_CONFIG__` global the SPA reads for name + logo, and
|
|
10
|
+
* - a `:root` `<style>` overriding the `--accent*` CSS channels the buttons and
|
|
11
|
+
* logo chip use.
|
|
12
|
+
*
|
|
13
|
+
* Injecting into the served HTML (rather than fetching config after load) means
|
|
14
|
+
* no title flash and no wrong-color flash before the first paint.
|
|
15
|
+
*/
|
|
16
|
+
import type { BrandConfig } from "./config.js";
|
|
17
|
+
export declare const DEFAULT_ACCENT = "#c2603c";
|
|
18
|
+
/** Parse a `#rgb` / `#rrggbb` hex color to `[r, g, b]` (0–255), or null. */
|
|
19
|
+
export declare function hexToRgb(hex: string): [number, number, number] | null;
|
|
20
|
+
/**
|
|
21
|
+
* The `:root` accent override for a given accent color, or null when it matches
|
|
22
|
+
* the default (so a default instance stays pixel-identical to the CSS defaults
|
|
23
|
+
* and only genuinely-branded instances get computed hover shades). An accent
|
|
24
|
+
* that doesn't parse as hex falls back to null (defaults apply).
|
|
25
|
+
*
|
|
26
|
+
* The 600/700 hover shades are derived by darkening — close enough to the
|
|
27
|
+
* hand-tuned terracotta ramp for a hover/active state, and it means an operator
|
|
28
|
+
* only has to pick ONE color.
|
|
29
|
+
*/
|
|
30
|
+
export declare function accentRootStyle(accent: string): string | null;
|
|
31
|
+
/**
|
|
32
|
+
* Inject branding into the built `index.html`:
|
|
33
|
+
* - replace the <title> with the configured name,
|
|
34
|
+
* - add a `window.__PADDOCK_CONFIG__` script (name + logo for the SPA),
|
|
35
|
+
* - add a `:root` accent override <style> (only when non-default).
|
|
36
|
+
*
|
|
37
|
+
* Everything is inserted just before `</head>` so it wins over the linked
|
|
38
|
+
* stylesheet's `--accent` defaults by source order. Idempotent-ish: called once
|
|
39
|
+
* at startup and the result cached.
|
|
40
|
+
*/
|
|
41
|
+
export declare function renderIndexHtml(rawHtml: string, brand: BrandConfig, openapi?: {
|
|
42
|
+
enabled: boolean;
|
|
43
|
+
path: string;
|
|
44
|
+
}): string;
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
export const DEFAULT_ACCENT = "#c2603c";
|
|
2
|
+
/** Parse a `#rgb` / `#rrggbb` hex color to `[r, g, b]` (0–255), or null. */
|
|
3
|
+
export function hexToRgb(hex) {
|
|
4
|
+
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
|
5
|
+
if (!m)
|
|
6
|
+
return null;
|
|
7
|
+
let h = m[1];
|
|
8
|
+
if (h.length === 3)
|
|
9
|
+
h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
|
|
10
|
+
const n = parseInt(h, 16);
|
|
11
|
+
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
|
|
12
|
+
}
|
|
13
|
+
/** Darken an RGB triple by `factor` (0–1), rounding + clamping to 0–255. */
|
|
14
|
+
function darken([r, g, b], factor) {
|
|
15
|
+
const clamp = (v) => Math.max(0, Math.min(255, Math.round(v * factor)));
|
|
16
|
+
return [clamp(r), clamp(g), clamp(b)];
|
|
17
|
+
}
|
|
18
|
+
/** RGB triple as the space-separated channel string Tailwind's tokens expect. */
|
|
19
|
+
function channels([r, g, b]) {
|
|
20
|
+
return `${r} ${g} ${b}`;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* The `:root` accent override for a given accent color, or null when it matches
|
|
24
|
+
* the default (so a default instance stays pixel-identical to the CSS defaults
|
|
25
|
+
* and only genuinely-branded instances get computed hover shades). An accent
|
|
26
|
+
* that doesn't parse as hex falls back to null (defaults apply).
|
|
27
|
+
*
|
|
28
|
+
* The 600/700 hover shades are derived by darkening — close enough to the
|
|
29
|
+
* hand-tuned terracotta ramp for a hover/active state, and it means an operator
|
|
30
|
+
* only has to pick ONE color.
|
|
31
|
+
*/
|
|
32
|
+
export function accentRootStyle(accent) {
|
|
33
|
+
const rgb = hexToRgb(accent);
|
|
34
|
+
if (!rgb)
|
|
35
|
+
return null;
|
|
36
|
+
if (accent.trim().toLowerCase() === DEFAULT_ACCENT)
|
|
37
|
+
return null;
|
|
38
|
+
const base = channels(rgb);
|
|
39
|
+
const c600 = channels(darken(rgb, 0.86));
|
|
40
|
+
const c700 = channels(darken(rgb, 0.71));
|
|
41
|
+
return `:root{--accent:${base};--accent-600:${c600};--accent-700:${c700};}`;
|
|
42
|
+
}
|
|
43
|
+
/** Escape a string for safe interpolation into HTML text (title). */
|
|
44
|
+
function escapeHtml(s) {
|
|
45
|
+
return s
|
|
46
|
+
.replace(/&/g, "&")
|
|
47
|
+
.replace(/</g, "<")
|
|
48
|
+
.replace(/>/g, ">");
|
|
49
|
+
}
|
|
50
|
+
/** Serialize a value to JSON safe to embed inside a <script> tag. */
|
|
51
|
+
function safeJson(value) {
|
|
52
|
+
// Escape `<` so a `</script>` (or `<!--`) in a value can't break out of the
|
|
53
|
+
// inline script; JSON.parse is unaffected by the < escape.
|
|
54
|
+
return JSON.stringify(value).replace(/</g, "\\u003c");
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Inject branding into the built `index.html`:
|
|
58
|
+
* - replace the <title> with the configured name,
|
|
59
|
+
* - add a `window.__PADDOCK_CONFIG__` script (name + logo for the SPA),
|
|
60
|
+
* - add a `:root` accent override <style> (only when non-default).
|
|
61
|
+
*
|
|
62
|
+
* Everything is inserted just before `</head>` so it wins over the linked
|
|
63
|
+
* stylesheet's `--accent` defaults by source order. Idempotent-ish: called once
|
|
64
|
+
* at startup and the result cached.
|
|
65
|
+
*/
|
|
66
|
+
export function renderIndexHtml(rawHtml, brand, openapi) {
|
|
67
|
+
let html = rawHtml;
|
|
68
|
+
// 1. Title.
|
|
69
|
+
html = html.replace(/<title>[^<]*<\/title>/i, `<title>${escapeHtml(brand.name)}</title>`);
|
|
70
|
+
// 2. Config global + accent override, injected before </head>. The `openapi`
|
|
71
|
+
// block lets the SPA render (or hide) the "Swagger API" nav link without a
|
|
72
|
+
// round-trip — flash-free, same as the brand.
|
|
73
|
+
const config = safeJson({
|
|
74
|
+
brand: { name: brand.name, logo: brand.logo, accent: brand.accent },
|
|
75
|
+
...(openapi ? { openapi } : {}),
|
|
76
|
+
});
|
|
77
|
+
const accentStyle = accentRootStyle(brand.accent);
|
|
78
|
+
const injection = `<script>window.__PADDOCK_CONFIG__=${config};</script>` +
|
|
79
|
+
(accentStyle ? `<style>${accentStyle}</style>` : "");
|
|
80
|
+
if (/<\/head>/i.test(html)) {
|
|
81
|
+
html = html.replace(/<\/head>/i, `${injection}</head>`);
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// No </head> (unexpected) — prepend so the config is still present.
|
|
85
|
+
html = injection + html;
|
|
86
|
+
}
|
|
87
|
+
return html;
|
|
88
|
+
}
|