@edspencer/paddock 0.0.1 → 0.56.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 +512 -0
- package/packages/server/dist/config.js +593 -0
- package/packages/server/dist/curation-config.d.ts +55 -0
- package/packages/server/dist/curation-config.js +64 -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 +572 -0
- package/packages/server/dist/herdctl.js +1218 -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 +135 -0
- package/packages/server/dist/instance-config.js +345 -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-4cQGTj9Y.js +2 -0
- package/packages/web/dist/assets/MarkdownRenderer-CJZFLj_8.js +30 -0
- package/packages/web/dist/assets/ProjectRedirect-BHwVbbqB.js +2 -0
- package/packages/web/dist/assets/ProjectView-BOvuAITg.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-BCojZOGY.js +2 -0
- package/packages/web/dist/assets/arc-BVyxvnkl.js +2 -0
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-Cd9Puqg1.js +37 -0
- package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
- package/packages/web/dist/assets/blockDiagram-GPEHLZMM-B_AQt_Rx.js +133 -0
- package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
- package/packages/web/dist/assets/c4Diagram-AAUBKEIU-7e0TQtXB.js +11 -0
- package/packages/web/dist/assets/channel-C_1aISNn.js +2 -0
- package/packages/web/dist/assets/chunk-2J33WTMH-Dz4WZmUZ.js +2 -0
- package/packages/web/dist/assets/chunk-4BX2VUAB-DVWYldo6.js +2 -0
- package/packages/web/dist/assets/chunk-55IACEB6-BIMrizQd.js +2 -0
- package/packages/web/dist/assets/chunk-727SXJPM-CL2q9fF1.js +207 -0
- package/packages/web/dist/assets/chunk-AQP2D5EJ-BSq3HlQ9.js +232 -0
- package/packages/web/dist/assets/chunk-FMBD7UC4-Bgt64pLE.js +16 -0
- package/packages/web/dist/assets/chunk-ND2GUHAM-C8Pr2A38.js +2 -0
- package/packages/web/dist/assets/chunk-QZHKN3VN-BAOqh-MN.js +2 -0
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B8JE7cHJ.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B8JE7cHJ.js +2 -0
- package/packages/web/dist/assets/core-lJeGACQg.js +4 -0
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-BF4tameA.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-DdsLM6ql.js +5 -0
- package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
- package/packages/web/dist/assets/diagram-2AECGRRQ-BQ78S_RM.js +44 -0
- package/packages/web/dist/assets/diagram-5GNKFQAL-CAKE6nHE.js +11 -0
- package/packages/web/dist/assets/diagram-KO2AKTUF-fEvHRXgr.js +4 -0
- package/packages/web/dist/assets/diagram-LMA3HP47-CtWsgeSm.js +25 -0
- package/packages/web/dist/assets/diagram-OG6HWLK6-Buog8k7_.js +25 -0
- package/packages/web/dist/assets/erDiagram-TEJ5UH35-CWvVVPZS.js +86 -0
- package/packages/web/dist/assets/flowDiagram-I6XJVG4X-DsCGXtp4.js +163 -0
- package/packages/web/dist/assets/format-DamJQ9LO.js +2 -0
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-CsdwIvvC.js +293 -0
- package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-hZvrX1sD.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-CGbZdrm8.js +71 -0
- package/packages/web/dist/assets/index-DJDQ210-.css +1 -0
- package/packages/web/dist/assets/infoDiagram-5YYISTIA-nE_Vtz_l.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-2Nlqs0oH.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-mjU1fMc4.js +140 -0
- package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
- package/packages/web/dist/assets/kanban-definition-UN3LZRKU-C_9xNaa3.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-BxKd91ho.js +2 -0
- package/packages/web/dist/assets/mermaid.core-C-CuhD8I.js +310 -0
- package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bs6yfcZv.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-DNdqX7O5.js +31 -0
- package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
- package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-ZMJl9OdZ.js +8 -0
- package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
- package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-RmxMdm2q.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-OAAhWcfK.js +41 -0
- package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
- package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-BWF7rCyN.js +163 -0
- package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
- package/packages/web/dist/assets/stateDiagram-AJRCARHV-M3b_zqqo.js +2 -0
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CI7saFDa.js +2 -0
- package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
- package/packages/web/dist/assets/timeline-definition-PNZ67QCA-BpOCh63N.js +121 -0
- package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
- package/packages/web/dist/assets/vennDiagram-CIIHVFJN-D_iPH45Q.js +35 -0
- package/packages/web/dist/assets/wardley-L42UT6IY-DVD9I2Om.js +174 -0
- package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-Bn1IKwjn.js +79 -0
- package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
- package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-DwtI0bkR.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,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Management API policy primitives (issue #312 M1).
|
|
3
|
+
*
|
|
4
|
+
* The `ManagementPrincipal` (WHO is calling) + `ManagementScope` (WHAT they may
|
|
5
|
+
* reach) and the pure predicates that decide a single call. This module is
|
|
6
|
+
* deliberately transport-free and dependency-free: it knows nothing about MCP,
|
|
7
|
+
* HTTP, Fastify or the ops callbacks. Everything here is a pure function over
|
|
8
|
+
* plain data, so the security-critical decisions are unit-testable without a
|
|
9
|
+
* fleet, a socket, or a server.
|
|
10
|
+
*
|
|
11
|
+
* ── Why policy lives HERE and not in the MCP transport ──────────────────────
|
|
12
|
+
* The ticket's central structural decision: enforcement happens at the OPS
|
|
13
|
+
* layer, not in whichever transport happens to be calling. Paddock will grow a
|
|
14
|
+
* REST surface for the same turn-spawning operations (#465); if policy lived in
|
|
15
|
+
* the MCP handler, REST would have to reimplement it and the two would drift.
|
|
16
|
+
* So the transports authenticate (producing a principal) and the ops layer
|
|
17
|
+
* enforces (consuming one) — see management-ops.ts.
|
|
18
|
+
*
|
|
19
|
+
* ── The blast-radius framing that drives the defaults ───────────────────────
|
|
20
|
+
* `create_chat` / `send_message` / `fork_chat*` / `run_trigger` START KEEPER
|
|
21
|
+
* TURNS, and a keeper runs with Bash + Write. So **any write scope is
|
|
22
|
+
* effectively remote code execution on the host.** Read-only is therefore the
|
|
23
|
+
* default for a configured client, writes are opt-in per client, and the
|
|
24
|
+
* grammar below is deny-biased at every ambiguity: deny beats allow, an unknown
|
|
25
|
+
* operation is denied, and an empty allow-list grants nothing.
|
|
26
|
+
*/
|
|
27
|
+
// ── The operation catalogue ────────────────────────────────────────────────
|
|
28
|
+
// The canonical names policy is written against. These deliberately match the
|
|
29
|
+
// self-MCP tool names one-for-one, so an operator writing `allow: ["read_chat"]`
|
|
30
|
+
// names the same thing the agent sees as `mcp__paddock_manage__read_chat`, and
|
|
31
|
+
// so a future REST route can reuse the identifier unchanged.
|
|
32
|
+
/** Read-only operations: no state change, no turn started. */
|
|
33
|
+
export const READ_OPERATIONS = ["list_projects", "list_chats", "read_chat"];
|
|
34
|
+
/** Chat write operations. The first four START TURNS (see the RCE note above). */
|
|
35
|
+
export const WRITE_OPERATIONS = [
|
|
36
|
+
"create_project",
|
|
37
|
+
"create_chat",
|
|
38
|
+
"fork_chat",
|
|
39
|
+
"send_message",
|
|
40
|
+
"fork_chat_batch",
|
|
41
|
+
"archive_chat",
|
|
42
|
+
"unarchive_chat",
|
|
43
|
+
];
|
|
44
|
+
/** Unified trigger operations (Epic T / T3). `list_triggers` is the read one. */
|
|
45
|
+
export const TRIGGER_OPERATIONS = [
|
|
46
|
+
"list_triggers",
|
|
47
|
+
"set_trigger",
|
|
48
|
+
"remove_trigger",
|
|
49
|
+
"run_trigger",
|
|
50
|
+
];
|
|
51
|
+
/** Every operation the management surface knows about. */
|
|
52
|
+
export const ALL_OPERATIONS = [
|
|
53
|
+
...READ_OPERATIONS,
|
|
54
|
+
...WRITE_OPERATIONS,
|
|
55
|
+
...TRIGGER_OPERATIONS,
|
|
56
|
+
];
|
|
57
|
+
/**
|
|
58
|
+
* Operations that cause a keeper turn to run — directly (`create_chat`,
|
|
59
|
+
* `send_message`, `fork_chat*`, `run_trigger`) or by arming something that will
|
|
60
|
+
* (`set_trigger`). Granting ANY of these to a principal is granting code
|
|
61
|
+
* execution on the host; kept as an explicit set so that fact stays greppable
|
|
62
|
+
* and so docs/UI can warn on it.
|
|
63
|
+
*/
|
|
64
|
+
/**
|
|
65
|
+
* Operations that grant code execution on the host, and so define the risk class
|
|
66
|
+
* a write scope really is. A superset of {@link TURN_SPAWNING_OPERATIONS}:
|
|
67
|
+
* `create_project` starts no turn, but it clones a CALLER-SUPPLIED git URL and
|
|
68
|
+
* creates instance-level state, which belongs in the same warning.
|
|
69
|
+
*/
|
|
70
|
+
export const HIGH_RISK_OPERATIONS = [
|
|
71
|
+
"create_project",
|
|
72
|
+
"create_chat",
|
|
73
|
+
"fork_chat",
|
|
74
|
+
"fork_chat_batch",
|
|
75
|
+
"send_message",
|
|
76
|
+
"run_trigger",
|
|
77
|
+
"set_trigger",
|
|
78
|
+
];
|
|
79
|
+
export const TURN_SPAWNING_OPERATIONS = [
|
|
80
|
+
"create_chat",
|
|
81
|
+
"fork_chat",
|
|
82
|
+
"send_message",
|
|
83
|
+
"fork_chat_batch",
|
|
84
|
+
"run_trigger",
|
|
85
|
+
"set_trigger",
|
|
86
|
+
];
|
|
87
|
+
/**
|
|
88
|
+
* The DEFAULT scope for a configured client that specifies none — read-only
|
|
89
|
+
* across all projects. Matches the shape written on issue #312 verbatim
|
|
90
|
+
* (`allow: ["list_*", "read_chat"]`), which covers the three read tools plus
|
|
91
|
+
* `list_triggers` while excluding every mutating verb.
|
|
92
|
+
*/
|
|
93
|
+
export const DEFAULT_READ_ONLY_SCOPE = Object.freeze({
|
|
94
|
+
projects: ["*"],
|
|
95
|
+
allow: ["list_*", "read_chat"],
|
|
96
|
+
deny: [],
|
|
97
|
+
});
|
|
98
|
+
/** Unrestricted scope — used ONLY for the in-process keeper principal. */
|
|
99
|
+
export const FULL_SCOPE = Object.freeze({
|
|
100
|
+
projects: ["*"],
|
|
101
|
+
allow: ["*"],
|
|
102
|
+
deny: [],
|
|
103
|
+
});
|
|
104
|
+
/**
|
|
105
|
+
* The implicit full-trust principal for the in-process keeper path.
|
|
106
|
+
*
|
|
107
|
+
* The internal caller is bounded by DEPTH (`maxSpawnDepth`, shipped in #278),
|
|
108
|
+
* not by auth+scope — it has no credential because it isn't a distinct identity.
|
|
109
|
+
* Handing it a full scope keeps a single enforcement path (every op checks a
|
|
110
|
+
* principal) while guaranteeing the keeper behaviour is byte-for-byte unchanged:
|
|
111
|
+
* every check below short-circuits to allow.
|
|
112
|
+
*/
|
|
113
|
+
export const INTERNAL_PRINCIPAL = Object.freeze({
|
|
114
|
+
clientId: "internal",
|
|
115
|
+
kind: "internal",
|
|
116
|
+
scope: FULL_SCOPE,
|
|
117
|
+
});
|
|
118
|
+
/** True for the implicit in-process principal (bounded by depth, not scope). */
|
|
119
|
+
export function isInternalPrincipal(principal) {
|
|
120
|
+
return principal.kind === "internal";
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Match one pattern against one value. Supports exactly two forms — a bare `"*"`
|
|
124
|
+
* (everything) and a trailing-`*` prefix (`"list_*"`) — plus exact equality.
|
|
125
|
+
*
|
|
126
|
+
* Deliberately NOT a general glob: a security predicate should be trivially
|
|
127
|
+
* auditable by the operator writing it, and the ticket calls for starting with
|
|
128
|
+
* explicit lists and adding globs only as needed. Anything fancier (`?`, `[]`,
|
|
129
|
+
* embedded `*`) is treated as a literal, so a typo'd pattern fails CLOSED
|
|
130
|
+
* (matches nothing) rather than accidentally widening a grant.
|
|
131
|
+
*/
|
|
132
|
+
export function matchesPattern(pattern, value) {
|
|
133
|
+
if (pattern === "*")
|
|
134
|
+
return true;
|
|
135
|
+
if (pattern.endsWith("*") && !pattern.slice(0, -1).includes("*")) {
|
|
136
|
+
return value.startsWith(pattern.slice(0, -1));
|
|
137
|
+
}
|
|
138
|
+
return pattern === value;
|
|
139
|
+
}
|
|
140
|
+
function matchesAny(patterns, value) {
|
|
141
|
+
return (patterns ?? []).some((p) => matchesPattern(p, value));
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Whether `scope` permits invoking `operation`. Deny beats allow; an operation
|
|
145
|
+
* outside {@link ALL_OPERATIONS} is refused regardless of the allow-list, so a
|
|
146
|
+
* typo in config (or a tool added later that policy hasn't been taught about)
|
|
147
|
+
* can never be reached through a stale `"*"` grant.
|
|
148
|
+
*/
|
|
149
|
+
export function isOperationAllowed(scope, operation) {
|
|
150
|
+
if (!ALL_OPERATIONS.includes(operation))
|
|
151
|
+
return false;
|
|
152
|
+
if (matchesAny(scope.deny, operation))
|
|
153
|
+
return false;
|
|
154
|
+
return matchesAny(scope.allow, operation);
|
|
155
|
+
}
|
|
156
|
+
/** Whether `scope` permits touching `projectSlug`. Deny beats allow. */
|
|
157
|
+
export function isProjectAllowed(scope, projectSlug) {
|
|
158
|
+
if (matchesAny(scope.denyProjects, projectSlug))
|
|
159
|
+
return false;
|
|
160
|
+
return matchesAny(scope.projects, projectSlug);
|
|
161
|
+
}
|
|
162
|
+
/** The concrete operations `scope` grants, in catalogue order. */
|
|
163
|
+
export function allowedOperations(scope) {
|
|
164
|
+
return ALL_OPERATIONS.filter((op) => isOperationAllowed(scope, op));
|
|
165
|
+
}
|
|
166
|
+
/** True when `scope` grants at least one operation that starts a keeper turn. */
|
|
167
|
+
export function grantsTurnSpawning(scope) {
|
|
168
|
+
return TURN_SPAWNING_OPERATIONS.some((op) => isOperationAllowed(scope, op));
|
|
169
|
+
}
|
|
170
|
+
// ── OAuth scope vocabulary ─────────────────────────────────────────────────
|
|
171
|
+
// Two DIFFERENT granularities, deliberately:
|
|
172
|
+
//
|
|
173
|
+
// - Internally, a scope is a list of OPERATION names. That is the right
|
|
174
|
+
// granularity for an operator writing a config file, who wants to say
|
|
175
|
+
// exactly which verbs a CI token may call.
|
|
176
|
+
// - Over OAuth, scopes are coarse (`paddock:read` / `paddock:write`), because
|
|
177
|
+
// they are shown to a HUMAN on a consent screen. "Grant this app
|
|
178
|
+
// create_chat, fork_chat, fork_chat_batch, send_message…" is not a consent
|
|
179
|
+
// prompt anyone reads; "grant write access" is.
|
|
180
|
+
//
|
|
181
|
+
// So the coarse names are a projection of the fine-grained truth, used only in
|
|
182
|
+
// the `scope` parameter of a challenge and in the discovery document. Authorization
|
|
183
|
+
// is always decided on the fine-grained list.
|
|
184
|
+
export const OAUTH_SCOPE_READ = "paddock:read";
|
|
185
|
+
export const OAUTH_SCOPE_WRITE = "paddock:write";
|
|
186
|
+
/** Every OAuth scope this resource server understands (for discovery). */
|
|
187
|
+
export const OAUTH_SCOPES_SUPPORTED = [OAUTH_SCOPE_READ, OAUTH_SCOPE_WRITE];
|
|
188
|
+
/**
|
|
189
|
+
* The coarse OAuth scope an operation needs. Anything that mutates state or
|
|
190
|
+
* starts a turn is `write`; everything else is `read`.
|
|
191
|
+
*/
|
|
192
|
+
export function requiredOauthScope(operation) {
|
|
193
|
+
if (READ_OPERATIONS.includes(operation)) {
|
|
194
|
+
return OAUTH_SCOPE_READ;
|
|
195
|
+
}
|
|
196
|
+
return operation === "list_triggers" ? OAUTH_SCOPE_READ : OAUTH_SCOPE_WRITE;
|
|
197
|
+
}
|
|
198
|
+
/** The coarse OAuth scopes a fine-grained scope amounts to. */
|
|
199
|
+
export function oauthScopesFor(scope) {
|
|
200
|
+
const out = [];
|
|
201
|
+
if (ALL_OPERATIONS.some((op) => isOperationAllowed(scope, op) && requiredOauthScope(op) === OAUTH_SCOPE_READ)) {
|
|
202
|
+
out.push(OAUTH_SCOPE_READ);
|
|
203
|
+
}
|
|
204
|
+
if (ALL_OPERATIONS.some((op) => isOperationAllowed(scope, op) && requiredOauthScope(op) === OAUTH_SCOPE_WRITE)) {
|
|
205
|
+
out.push(OAUTH_SCOPE_WRITE);
|
|
206
|
+
}
|
|
207
|
+
return out;
|
|
208
|
+
}
|
|
209
|
+
/** True when `scope` grants any operation that amounts to code execution. */
|
|
210
|
+
export function grantsCodeExecution(scope) {
|
|
211
|
+
return HIGH_RISK_OPERATIONS.some((op) => isOperationAllowed(scope, op));
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* Thrown when policy refuses a call. Carries the structured facts a transport
|
|
215
|
+
* needs to render its own error (MCP `isError` text, or an HTTP 403) and that
|
|
216
|
+
* the server log wants — `clientId` + `operation`, never the credential.
|
|
217
|
+
*/
|
|
218
|
+
export class ManagementDeniedError extends Error {
|
|
219
|
+
code;
|
|
220
|
+
clientId;
|
|
221
|
+
operation;
|
|
222
|
+
projectSlug;
|
|
223
|
+
constructor(args) {
|
|
224
|
+
super(args.code === "operation_denied"
|
|
225
|
+
? `not permitted: operation "${args.operation}" is outside this client's scope`
|
|
226
|
+
: `not permitted: project "${args.projectSlug}" is outside this client's scope`);
|
|
227
|
+
this.name = "ManagementDeniedError";
|
|
228
|
+
this.code = args.code;
|
|
229
|
+
this.clientId = args.clientId;
|
|
230
|
+
this.operation = args.operation;
|
|
231
|
+
this.projectSlug = args.projectSlug;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** Throw unless `principal` may invoke `operation`. */
|
|
235
|
+
export function assertOperation(principal, operation) {
|
|
236
|
+
if (!isOperationAllowed(principal.scope, operation)) {
|
|
237
|
+
throw new ManagementDeniedError({
|
|
238
|
+
code: "operation_denied",
|
|
239
|
+
clientId: principal.clientId,
|
|
240
|
+
operation,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Throw unless `principal` may reach `projectSlug` via `operation`. Callers that
|
|
246
|
+
* ENUMERATE (list_projects/list_chats) must filter with {@link isProjectAllowed}
|
|
247
|
+
* instead — a scoped client listing its two projects should see those two, not a
|
|
248
|
+
* denial. This assert is for calls that name a target explicitly.
|
|
249
|
+
*/
|
|
250
|
+
export function assertProject(principal, operation, projectSlug) {
|
|
251
|
+
if (!isProjectAllowed(principal.scope, projectSlug)) {
|
|
252
|
+
throw new ManagementDeniedError({
|
|
253
|
+
code: "project_denied",
|
|
254
|
+
clientId: principal.clientId,
|
|
255
|
+
operation,
|
|
256
|
+
projectSlug,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHO caused a machine-injected turn. A human-typed message carries NO sender
|
|
3
|
+
* (absence = human, the quiet default), so this type only enumerates the machine
|
|
4
|
+
* sources. Mirrors the web's `MessageSender` (packages/web/src/lib/types.ts).
|
|
5
|
+
*
|
|
6
|
+
* - `chat` — another chat `send_message`d / forked / created this turn; carries
|
|
7
|
+
* the sending chat's project + sessionId (for a deep link) + the
|
|
8
|
+
* display name it had at injection time.
|
|
9
|
+
* - `schedule` — a schedule fire injected it; carries the schedule's name.
|
|
10
|
+
* - `hook` — an event hook fired it (Epic G / G1); carries the hook's name (and
|
|
11
|
+
* project) so a hook chat's kickoff turn is attributable.
|
|
12
|
+
* - `recovery` — Paddock's keeper-chat recovery nudged this turn (issue #301):
|
|
13
|
+
* a human clicked "Continue" on a killed-background-task
|
|
14
|
+
* affordance (Layer 2), or Layer 3 auto re-drove the hung keeper.
|
|
15
|
+
* - `agent` — a machine turn with no more specific identity (fallback).
|
|
16
|
+
*/
|
|
17
|
+
export type MessageSender = {
|
|
18
|
+
kind: "chat";
|
|
19
|
+
project: string;
|
|
20
|
+
sessionId: string;
|
|
21
|
+
name?: string;
|
|
22
|
+
} | {
|
|
23
|
+
kind: "schedule";
|
|
24
|
+
name: string;
|
|
25
|
+
project?: string;
|
|
26
|
+
} | {
|
|
27
|
+
kind: "hook";
|
|
28
|
+
name: string;
|
|
29
|
+
project?: string;
|
|
30
|
+
} | {
|
|
31
|
+
kind: "recovery";
|
|
32
|
+
} | {
|
|
33
|
+
kind: "agent";
|
|
34
|
+
};
|
|
35
|
+
/** One recorded injection: its sender + the (possibly truncated) injected content. */
|
|
36
|
+
export interface InjectedMarker {
|
|
37
|
+
sender: MessageSender;
|
|
38
|
+
/** The injected prompt, trimmed and capped to {@link MAX_CONTENT}. */
|
|
39
|
+
content: string;
|
|
40
|
+
/** True when `content` was capped — the join then prefix-matches instead of ==. */
|
|
41
|
+
truncated?: boolean;
|
|
42
|
+
}
|
|
43
|
+
export declare class MessageProvenanceStore {
|
|
44
|
+
private readonly stateFile;
|
|
45
|
+
/** In-memory map of sessionId -> ordered injection markers (loaded once, written through). */
|
|
46
|
+
private state;
|
|
47
|
+
/** The in-flight load, cached so concurrent first-callers share ONE read (see RunProvenanceStore). */
|
|
48
|
+
private loadPromise;
|
|
49
|
+
/** Serialises concurrent writes so the file never interleaves. */
|
|
50
|
+
private writing;
|
|
51
|
+
constructor(dataDir: string);
|
|
52
|
+
/** Load the persisted map (lazily, deduped; tolerant of a missing/corrupt file). */
|
|
53
|
+
private ensureLoaded;
|
|
54
|
+
/** This chat's ordered injection markers, or [] if none. Non-throwing. */
|
|
55
|
+
list(sessionId: string): Promise<InjectedMarker[]>;
|
|
56
|
+
/**
|
|
57
|
+
* The chat that most likely CREATED `sessionId` — the sender of its very FIRST
|
|
58
|
+
* injection marker, and only if that sender is a chat.
|
|
59
|
+
*
|
|
60
|
+
* This is a backfill, not a recorded fact. A chat spawned via `create_chat` /
|
|
61
|
+
* `fork_chat` gets its kickoff prompt injected BY the creating chat, so the
|
|
62
|
+
* first marker is the parent. Later markers are ordinary `send_message` traffic
|
|
63
|
+
* (any chat can message any other), which is why only the first one counts.
|
|
64
|
+
*
|
|
65
|
+
* That "only the first one counts" rule is POSITIONAL (#491). Scanning for the
|
|
66
|
+
* first `chat`-KIND marker instead — as this did originally, despite the doc
|
|
67
|
+
* comment — makes a chat whose creation was NOT a chat injection adopt whichever
|
|
68
|
+
* chat happens to message it first: a schedule-fired chat is re-parented under
|
|
69
|
+
* the child that reports back to it. So a non-chat first marker means "not
|
|
70
|
+
* created by a chat", and returns null rather than searching on.
|
|
71
|
+
*
|
|
72
|
+
* Newer chats carry a real, recorded edge on RunProvenance.parentSessionId;
|
|
73
|
+
* prefer that and fall back here for chats created before it existed. Returns
|
|
74
|
+
* null when the chat has no markers or was never injected into by a chat — that
|
|
75
|
+
* includes forks with no kickoff prompt, which record nothing at all.
|
|
76
|
+
*
|
|
77
|
+
* Pure in-memory read once loaded, so the chat list can call it per row.
|
|
78
|
+
*/
|
|
79
|
+
parentChat(sessionId: string): Promise<{
|
|
80
|
+
project: string;
|
|
81
|
+
sessionId: string;
|
|
82
|
+
name?: string;
|
|
83
|
+
} | null>;
|
|
84
|
+
/**
|
|
85
|
+
* Append one injection marker for `sessionId`. Order is significant — the join
|
|
86
|
+
* consumes markers in the order they were recorded — so this only ever appends.
|
|
87
|
+
* A blank/unsafe session id or malformed sender is ignored (best effort; the
|
|
88
|
+
* turn still runs, it just won't be attributed).
|
|
89
|
+
*/
|
|
90
|
+
record(sessionId: string, sender: MessageSender, content: string): Promise<void>;
|
|
91
|
+
/** Write-through, serialised so overlapping records can't corrupt the file. */
|
|
92
|
+
private persist;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Attach `sender` to each machine-injected user message by joining the parsed
|
|
96
|
+
* transcript against this session's ordered injection markers (issue #290).
|
|
97
|
+
*
|
|
98
|
+
* The join is greedy and in-order: a pointer walks the markers; for each plain
|
|
99
|
+
* user-text message we test it against the NEXT unconsumed marker, and on a match
|
|
100
|
+
* attach that sender and advance the pointer. A message that doesn't match the
|
|
101
|
+
* pending marker is treated as human-typed (left unlabelled) WITHOUT advancing the
|
|
102
|
+
* pointer, so a later injected message can still claim the marker. Order alignment
|
|
103
|
+
* holds because injections into a session are serialised and the transcript
|
|
104
|
+
* preserves order. Returns the input untouched when there are no markers.
|
|
105
|
+
*/
|
|
106
|
+
export declare function applyMessageProvenance<T extends {
|
|
107
|
+
role: string;
|
|
108
|
+
content: string;
|
|
109
|
+
toolCall?: unknown;
|
|
110
|
+
}>(messages: T[], markers: InjectedMarker[]): (T & {
|
|
111
|
+
sender?: MessageSender;
|
|
112
|
+
})[];
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessageProvenanceStore — a Paddock-side sidecar recording WHO injected each
|
|
3
|
+
* machine-added turn into a chat's transcript (issue #290). This is the
|
|
4
|
+
* per-MESSAGE analog of {@link RunProvenanceStore} (A1/#261), which records how a
|
|
5
|
+
* whole CHAT was created. As chats reliably inject messages into one another —
|
|
6
|
+
* `send_message` (a spawned child reporting back), scheduled fires, spawned-chat
|
|
7
|
+
* kickoffs — a transcript can interleave turns a HUMAN typed with turns a MACHINE
|
|
8
|
+
* added, and in the UI they look identical. This store lets the web attribute the
|
|
9
|
+
* machine-added ones ("↩ sent by <chat>", "⏰ scheduled by <name>").
|
|
10
|
+
*
|
|
11
|
+
* WHY a content-keyed sidecar (not a per-message uuid map): the injected user
|
|
12
|
+
* turn's transcript `uuid` is minted by Claude Code when it writes the JSONL line,
|
|
13
|
+
* so Paddock never sees it at injection time. But Paddock DOES know the exact
|
|
14
|
+
* prompt string it injected, and that string lands verbatim as the user message's
|
|
15
|
+
* content. So we record, per target session, an ORDERED list of injection markers
|
|
16
|
+
* carrying the sender + the injected content; at DTO-build time we walk the parsed
|
|
17
|
+
* messages and greedily match each machine-injected user message to the next
|
|
18
|
+
* unconsumed marker (see {@link applyMessageProvenance}). Injections into one
|
|
19
|
+
* session are serialised (herdctl keeper concurrency + same-session resume), and
|
|
20
|
+
* the transcript preserves order, so the in-order join is stable. A human-typed
|
|
21
|
+
* message won't match a pending marker's content, so it stays unlabelled (the
|
|
22
|
+
* default).
|
|
23
|
+
*
|
|
24
|
+
* Shape + durability mirror the other sidecars exactly: a `sessionId ->
|
|
25
|
+
* InjectedMarker[]` map persisted as a plain JSON object, lazy-loaded (with the
|
|
26
|
+
* in-flight load promise cached so concurrent first-callers can't lose an update),
|
|
27
|
+
* write-through, serialised, corruption-tolerant, `0o600`.
|
|
28
|
+
*/
|
|
29
|
+
import { promises as fs } from "node:fs";
|
|
30
|
+
import path from "node:path";
|
|
31
|
+
const STATE_FILE = "message-provenance.json";
|
|
32
|
+
/**
|
|
33
|
+
* The longest injected-content prefix we persist per marker. Bounds sidecar size
|
|
34
|
+
* for a large `promptFile`-driven schedule while keeping ordinary short kickoffs
|
|
35
|
+
* matched exactly. Beyond this the match falls back to prefix equality (a full
|
|
36
|
+
* transcript content that STARTS WITH the stored prefix), which is unambiguous
|
|
37
|
+
* enough given the in-order consumption.
|
|
38
|
+
*/
|
|
39
|
+
const MAX_CONTENT = 8192;
|
|
40
|
+
/** A session id we're willing to key on — mirrors RunProvenanceStore's guard. */
|
|
41
|
+
function isSafeId(sessionId) {
|
|
42
|
+
return typeof sessionId === "string" && /^[A-Za-z0-9._-]+$/.test(sessionId);
|
|
43
|
+
}
|
|
44
|
+
const SENDER_KINDS = new Set(["chat", "schedule", "hook", "recovery", "agent"]);
|
|
45
|
+
/** Validate + normalise an untrusted value into a MessageSender, or null. */
|
|
46
|
+
function coerceSender(value) {
|
|
47
|
+
if (!value || typeof value !== "object")
|
|
48
|
+
return null;
|
|
49
|
+
const o = value;
|
|
50
|
+
if (typeof o.kind !== "string" || !SENDER_KINDS.has(o.kind))
|
|
51
|
+
return null;
|
|
52
|
+
if (o.kind === "chat") {
|
|
53
|
+
if (typeof o.project !== "string" || typeof o.sessionId !== "string")
|
|
54
|
+
return null;
|
|
55
|
+
return {
|
|
56
|
+
kind: "chat",
|
|
57
|
+
project: o.project,
|
|
58
|
+
sessionId: o.sessionId,
|
|
59
|
+
...(typeof o.name === "string" ? { name: o.name } : {}),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (o.kind === "schedule") {
|
|
63
|
+
if (typeof o.name !== "string")
|
|
64
|
+
return null;
|
|
65
|
+
return {
|
|
66
|
+
kind: "schedule",
|
|
67
|
+
name: o.name,
|
|
68
|
+
...(typeof o.project === "string" ? { project: o.project } : {}),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (o.kind === "hook") {
|
|
72
|
+
if (typeof o.name !== "string")
|
|
73
|
+
return null;
|
|
74
|
+
return {
|
|
75
|
+
kind: "hook",
|
|
76
|
+
name: o.name,
|
|
77
|
+
...(typeof o.project === "string" ? { project: o.project } : {}),
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
if (o.kind === "recovery")
|
|
81
|
+
return { kind: "recovery" };
|
|
82
|
+
return { kind: "agent" };
|
|
83
|
+
}
|
|
84
|
+
/** Validate + normalise an untrusted value into an InjectedMarker, or null. */
|
|
85
|
+
function coerceMarker(value) {
|
|
86
|
+
if (!value || typeof value !== "object")
|
|
87
|
+
return null;
|
|
88
|
+
const o = value;
|
|
89
|
+
const sender = coerceSender(o.sender);
|
|
90
|
+
if (!sender)
|
|
91
|
+
return null;
|
|
92
|
+
if (typeof o.content !== "string")
|
|
93
|
+
return null;
|
|
94
|
+
return { sender, content: o.content, ...(o.truncated === true ? { truncated: true } : {}) };
|
|
95
|
+
}
|
|
96
|
+
/** Normalise + cap injected content for storage; also used at match time. */
|
|
97
|
+
function normaliseContent(content) {
|
|
98
|
+
const trimmed = content.trim();
|
|
99
|
+
if (trimmed.length <= MAX_CONTENT)
|
|
100
|
+
return { content: trimmed, truncated: false };
|
|
101
|
+
return { content: trimmed.slice(0, MAX_CONTENT), truncated: true };
|
|
102
|
+
}
|
|
103
|
+
export class MessageProvenanceStore {
|
|
104
|
+
stateFile;
|
|
105
|
+
/** In-memory map of sessionId -> ordered injection markers (loaded once, written through). */
|
|
106
|
+
state = null;
|
|
107
|
+
/** The in-flight load, cached so concurrent first-callers share ONE read (see RunProvenanceStore). */
|
|
108
|
+
loadPromise = null;
|
|
109
|
+
/** Serialises concurrent writes so the file never interleaves. */
|
|
110
|
+
writing = Promise.resolve();
|
|
111
|
+
constructor(dataDir) {
|
|
112
|
+
this.stateFile = path.join(dataDir, STATE_FILE);
|
|
113
|
+
}
|
|
114
|
+
/** Load the persisted map (lazily, deduped; tolerant of a missing/corrupt file). */
|
|
115
|
+
ensureLoaded() {
|
|
116
|
+
if (this.state)
|
|
117
|
+
return Promise.resolve(this.state);
|
|
118
|
+
if (!this.loadPromise) {
|
|
119
|
+
this.loadPromise = (async () => {
|
|
120
|
+
const map = new Map();
|
|
121
|
+
try {
|
|
122
|
+
const raw = await fs.readFile(this.stateFile, "utf8");
|
|
123
|
+
const parsed = JSON.parse(raw);
|
|
124
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
125
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
126
|
+
if (!Array.isArray(v))
|
|
127
|
+
continue;
|
|
128
|
+
const markers = v.map(coerceMarker).filter((m) => m !== null);
|
|
129
|
+
if (markers.length)
|
|
130
|
+
map.set(k, markers);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* missing or unreadable — start empty */
|
|
136
|
+
}
|
|
137
|
+
this.state = map;
|
|
138
|
+
return map;
|
|
139
|
+
})();
|
|
140
|
+
}
|
|
141
|
+
return this.loadPromise;
|
|
142
|
+
}
|
|
143
|
+
/** This chat's ordered injection markers, or [] if none. Non-throwing. */
|
|
144
|
+
async list(sessionId) {
|
|
145
|
+
if (!isSafeId(sessionId))
|
|
146
|
+
return [];
|
|
147
|
+
const map = await this.ensureLoaded().catch(() => new Map());
|
|
148
|
+
return map.get(sessionId) ?? [];
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* The chat that most likely CREATED `sessionId` — the sender of its very FIRST
|
|
152
|
+
* injection marker, and only if that sender is a chat.
|
|
153
|
+
*
|
|
154
|
+
* This is a backfill, not a recorded fact. A chat spawned via `create_chat` /
|
|
155
|
+
* `fork_chat` gets its kickoff prompt injected BY the creating chat, so the
|
|
156
|
+
* first marker is the parent. Later markers are ordinary `send_message` traffic
|
|
157
|
+
* (any chat can message any other), which is why only the first one counts.
|
|
158
|
+
*
|
|
159
|
+
* That "only the first one counts" rule is POSITIONAL (#491). Scanning for the
|
|
160
|
+
* first `chat`-KIND marker instead — as this did originally, despite the doc
|
|
161
|
+
* comment — makes a chat whose creation was NOT a chat injection adopt whichever
|
|
162
|
+
* chat happens to message it first: a schedule-fired chat is re-parented under
|
|
163
|
+
* the child that reports back to it. So a non-chat first marker means "not
|
|
164
|
+
* created by a chat", and returns null rather than searching on.
|
|
165
|
+
*
|
|
166
|
+
* Newer chats carry a real, recorded edge on RunProvenance.parentSessionId;
|
|
167
|
+
* prefer that and fall back here for chats created before it existed. Returns
|
|
168
|
+
* null when the chat has no markers or was never injected into by a chat — that
|
|
169
|
+
* includes forks with no kickoff prompt, which record nothing at all.
|
|
170
|
+
*
|
|
171
|
+
* Pure in-memory read once loaded, so the chat list can call it per row.
|
|
172
|
+
*/
|
|
173
|
+
async parentChat(sessionId) {
|
|
174
|
+
const [first] = await this.list(sessionId);
|
|
175
|
+
if (first?.sender.kind !== "chat")
|
|
176
|
+
return null;
|
|
177
|
+
// Never let a corrupt record make a chat its own parent — buildChatTree
|
|
178
|
+
// guards cycles too, but a self-edge is meaningless this far up.
|
|
179
|
+
if (first.sender.sessionId === sessionId)
|
|
180
|
+
return null;
|
|
181
|
+
return {
|
|
182
|
+
project: first.sender.project,
|
|
183
|
+
sessionId: first.sender.sessionId,
|
|
184
|
+
...(first.sender.name ? { name: first.sender.name } : {}),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* Append one injection marker for `sessionId`. Order is significant — the join
|
|
189
|
+
* consumes markers in the order they were recorded — so this only ever appends.
|
|
190
|
+
* A blank/unsafe session id or malformed sender is ignored (best effort; the
|
|
191
|
+
* turn still runs, it just won't be attributed).
|
|
192
|
+
*/
|
|
193
|
+
async record(sessionId, sender, content) {
|
|
194
|
+
if (!isSafeId(sessionId))
|
|
195
|
+
return;
|
|
196
|
+
const s = coerceSender(sender);
|
|
197
|
+
if (!s)
|
|
198
|
+
return;
|
|
199
|
+
const { content: capped, truncated } = normaliseContent(content);
|
|
200
|
+
const marker = { sender: s, content: capped, ...(truncated ? { truncated: true } : {}) };
|
|
201
|
+
const map = await this.ensureLoaded();
|
|
202
|
+
const list = map.get(sessionId);
|
|
203
|
+
if (list)
|
|
204
|
+
list.push(marker);
|
|
205
|
+
else
|
|
206
|
+
map.set(sessionId, [marker]);
|
|
207
|
+
await this.persist(map);
|
|
208
|
+
}
|
|
209
|
+
/** Write-through, serialised so overlapping records can't corrupt the file. */
|
|
210
|
+
persist(map) {
|
|
211
|
+
this.writing = this.writing.then(async () => {
|
|
212
|
+
const obj = {};
|
|
213
|
+
for (const [k, v] of map)
|
|
214
|
+
obj[k] = v;
|
|
215
|
+
const json = JSON.stringify(obj, null, 2);
|
|
216
|
+
await fs.writeFile(this.stateFile, json, { encoding: "utf8", mode: 0o600 }).catch(() => undefined);
|
|
217
|
+
});
|
|
218
|
+
return this.writing;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/** True when a message is a plain user text turn (a candidate injected turn). */
|
|
222
|
+
function isPlainUserText(m) {
|
|
223
|
+
if (m.role !== "user" || m.toolCall)
|
|
224
|
+
return false;
|
|
225
|
+
// A background `<task-notification>` is a harness-injected user line, not a
|
|
226
|
+
// send_message/schedule injection — never attribute it to a sender.
|
|
227
|
+
return !m.content.trimStart().startsWith("<task-notification>");
|
|
228
|
+
}
|
|
229
|
+
/** Does a parsed message's content satisfy a marker (exact, or prefix if capped)? */
|
|
230
|
+
function matchesMarker(content, marker) {
|
|
231
|
+
const trimmed = content.trim();
|
|
232
|
+
return marker.truncated ? trimmed.startsWith(marker.content) : trimmed === marker.content;
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Attach `sender` to each machine-injected user message by joining the parsed
|
|
236
|
+
* transcript against this session's ordered injection markers (issue #290).
|
|
237
|
+
*
|
|
238
|
+
* The join is greedy and in-order: a pointer walks the markers; for each plain
|
|
239
|
+
* user-text message we test it against the NEXT unconsumed marker, and on a match
|
|
240
|
+
* attach that sender and advance the pointer. A message that doesn't match the
|
|
241
|
+
* pending marker is treated as human-typed (left unlabelled) WITHOUT advancing the
|
|
242
|
+
* pointer, so a later injected message can still claim the marker. Order alignment
|
|
243
|
+
* holds because injections into a session are serialised and the transcript
|
|
244
|
+
* preserves order. Returns the input untouched when there are no markers.
|
|
245
|
+
*/
|
|
246
|
+
export function applyMessageProvenance(messages, markers) {
|
|
247
|
+
if (markers.length === 0)
|
|
248
|
+
return messages;
|
|
249
|
+
let i = 0;
|
|
250
|
+
return messages.map((m) => {
|
|
251
|
+
if (i >= markers.length || !isPlainUserText(m))
|
|
252
|
+
return m;
|
|
253
|
+
if (matchesMarker(m.content, markers[i])) {
|
|
254
|
+
const sender = markers[i].sender;
|
|
255
|
+
i++;
|
|
256
|
+
return { ...m, sender };
|
|
257
|
+
}
|
|
258
|
+
return m;
|
|
259
|
+
});
|
|
260
|
+
}
|