@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,449 @@
|
|
|
1
|
+
import { keeperAgentName } from "./herdctl.js";
|
|
2
|
+
import { isKnownModel, isKnownDriveMode } from "./models.js";
|
|
3
|
+
import { ROOT_KEY } from "./projects.js";
|
|
4
|
+
import { childOf } from "./run-provenance.js";
|
|
5
|
+
import { resolveMaxSpawnDepth } from "./spawn-capability.js";
|
|
6
|
+
import { isCuratorTrigger, mergeTriggerUpdate } from "./trigger-config.js";
|
|
7
|
+
import { assertOperation, assertProject, isOperationAllowed, isProjectAllowed, isInternalPrincipal, TRIGGER_OPERATIONS, WRITE_OPERATIONS, } from "./management-policy.js";
|
|
8
|
+
/**
|
|
9
|
+
* Frame an agent-initiated FORK kickoff (issue #214 Phase 2). A fork inherits the
|
|
10
|
+
* parent's transcript as context — and when the parent is the *live* chat doing
|
|
11
|
+
* the forking, that snapshot is taken mid-turn, so the child would otherwise
|
|
12
|
+
* inherit the parent's "I am still mid-task" identity and reject the seeded
|
|
13
|
+
* instruction (observed in QA). This preamble tells the child the history above
|
|
14
|
+
* is inherited background and that its job now is the given directive — which is
|
|
15
|
+
* exactly the fan-out contract ("fork this chat N times, one work-item each").
|
|
16
|
+
*/
|
|
17
|
+
export function forkKickoffPrompt(directive) {
|
|
18
|
+
return ("[Paddock fan-out] You are a NEW chat forked from the conversation above. " +
|
|
19
|
+
"That history is INHERITED CONTEXT — you are NOT in the middle of the prior " +
|
|
20
|
+
"turn, and its final exchange may be truncated at the fork point; do not try " +
|
|
21
|
+
"to continue it. Use it as background, then carry out this instruction as your " +
|
|
22
|
+
"task now:\n\n" +
|
|
23
|
+
directive);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Project a persisted {@link TriggerDto} (+ optional herdctl runtime state for a
|
|
27
|
+
* schedule trigger) onto the flat {@link SelfMcpTrigger} shape the unified trigger
|
|
28
|
+
* tools return (Epic T / T3).
|
|
29
|
+
*/
|
|
30
|
+
function toSelfMcpTrigger(dto, info) {
|
|
31
|
+
const when = dto.trigger;
|
|
32
|
+
const run = dto.run;
|
|
33
|
+
const isSchedule = when.type === "schedule";
|
|
34
|
+
return {
|
|
35
|
+
name: dto.name,
|
|
36
|
+
agentName: dto.agentName,
|
|
37
|
+
type: when.type,
|
|
38
|
+
cron: when.type === "schedule" ? when.cron ?? null : null,
|
|
39
|
+
interval: when.type === "schedule" ? when.interval ?? null : null,
|
|
40
|
+
event: when.type === "event" ? when.on : null,
|
|
41
|
+
path: when.type === "webhook" ? when.path : null,
|
|
42
|
+
prompt: run.prompt ?? null,
|
|
43
|
+
promptFile: run.promptFile ?? null,
|
|
44
|
+
session: run.session,
|
|
45
|
+
tools: run.tools ?? [],
|
|
46
|
+
maxSpawnDepth: run.maxSpawnDepth ?? null,
|
|
47
|
+
permissionMode: run.permissionMode ?? null,
|
|
48
|
+
model: run.model ?? null,
|
|
49
|
+
maxTurns: run.maxTurns ?? null,
|
|
50
|
+
enabled: dto.enabled === true,
|
|
51
|
+
status: isSchedule ? info?.status ?? (dto.enabled === false ? "disabled" : "idle") : null,
|
|
52
|
+
lastRunAt: isSchedule ? info?.lastRunAt ?? null : null,
|
|
53
|
+
nextRunAt: isSchedule ? info?.nextRunAt ?? null : null,
|
|
54
|
+
lastError: isSchedule ? info?.lastError ?? null : null,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Build the management operations bound to one caller's context.
|
|
59
|
+
*
|
|
60
|
+
* `parentProvenance` is the provenance of the chat these ops run IN — so any
|
|
61
|
+
* child they spawn is `childOf(parentProvenance)` (origin `spawned`, depth+1).
|
|
62
|
+
* The human path passes a human root (depth 0 → children depth 1); the spawned
|
|
63
|
+
* path passes the current turn's own `{ origin, depth }`, so the `maxSpawnDepth`
|
|
64
|
+
* bound descends and the tree can't run away.
|
|
65
|
+
*/
|
|
66
|
+
export function buildManagementOps(ctx, params) {
|
|
67
|
+
const { currentProjectSlug, currentSessionId, parentProvenance, includeWrite, includeTriggers, includeProjects, spawnDepthCap, } = params;
|
|
68
|
+
const { deps, hub, startAgentTurn, composePreloadedPrompt, fireTrigger } = ctx;
|
|
69
|
+
/**
|
|
70
|
+
* Every workspace an unfiltered enumeration should cover (#560): the ROOT plus
|
|
71
|
+
* its child projects.
|
|
72
|
+
*
|
|
73
|
+
* `ProjectStore.list()` deliberately returns children only — the root is
|
|
74
|
+
* reachable, never enumerated (pinned by `root-workspace.test.ts`) — so the
|
|
75
|
+
* root is fetched separately and prepended here, the same way
|
|
76
|
+
* `GET /api/projects` folds it into its response. Its read is tolerated rather
|
|
77
|
+
* than fatal: a root record that won't parse should not 500 the whole listing.
|
|
78
|
+
*/
|
|
79
|
+
const allWorkspaces = async () => {
|
|
80
|
+
const [root, children] = await Promise.all([
|
|
81
|
+
deps.projects.get(ROOT_KEY).catch(() => null),
|
|
82
|
+
deps.projects.list(),
|
|
83
|
+
]);
|
|
84
|
+
return root ? [root, ...children] : children;
|
|
85
|
+
};
|
|
86
|
+
const read = {
|
|
87
|
+
listProjects: async () => {
|
|
88
|
+
// The root rides along as an ordinary member here so the policy wrapper's
|
|
89
|
+
// `isProjectAllowed` filter covers it for free (a scoped client whose
|
|
90
|
+
// `projects` list doesn't match `""` never sees it). The MCP handler is what
|
|
91
|
+
// splits it back out into its own `root` field — it is not a project.
|
|
92
|
+
const projects = await allWorkspaces();
|
|
93
|
+
return projects.map((p) => ({
|
|
94
|
+
slug: p.slug,
|
|
95
|
+
name: p.name,
|
|
96
|
+
area: p.group && p.group.length > 0 ? p.group : undefined,
|
|
97
|
+
status: p.status,
|
|
98
|
+
}));
|
|
99
|
+
},
|
|
100
|
+
listChats: async (projectSlug) => {
|
|
101
|
+
// ABSENT vs EMPTY (#560). `""` is the root workspace's key: an explicit
|
|
102
|
+
// address that must resolve to the root, not degrade into "every project".
|
|
103
|
+
const targets = projectSlug !== undefined
|
|
104
|
+
? [await deps.projects.get(projectSlug)]
|
|
105
|
+
: await allWorkspaces();
|
|
106
|
+
const chats = [];
|
|
107
|
+
for (const p of targets) {
|
|
108
|
+
const sessions = await deps.herdctl.listSessions(p);
|
|
109
|
+
// Hoisted: every session in this project is keyed by the SAME keeper agent.
|
|
110
|
+
const agent = keeperAgentName(p.slug);
|
|
111
|
+
for (const s of sessions) {
|
|
112
|
+
chats.push({
|
|
113
|
+
project: p.slug,
|
|
114
|
+
sessionId: s.sessionId,
|
|
115
|
+
name: s.customName ?? s.autoName ?? s.sessionId.slice(0, 8),
|
|
116
|
+
updatedAt: s.mtime,
|
|
117
|
+
running: hub.isRunning(s.sessionId),
|
|
118
|
+
// #489: the archived flag the web UI has always had (`chat-dto.ts`) but
|
|
119
|
+
// the MCP surface never reported — so the two disagreed about what "the
|
|
120
|
+
// chat list" is. Cost is nil: ArchiveStore lazy-loads once and caches,
|
|
121
|
+
// so this is a `Set.has()` per chat, not a read per chat.
|
|
122
|
+
archived: await deps.archive.isArchived(agent, s.sessionId),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return chats;
|
|
127
|
+
},
|
|
128
|
+
readChat: async (projectSlug, chatSessionId) => {
|
|
129
|
+
const project = await deps.projects.get(projectSlug);
|
|
130
|
+
const messages = await deps.herdctl.sessionMessages(keeperAgentName(project.slug), chatSessionId);
|
|
131
|
+
return messages.map((m) => ({
|
|
132
|
+
role: m.role,
|
|
133
|
+
text: m.content,
|
|
134
|
+
timestamp: m.timestamp,
|
|
135
|
+
}));
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
if (!includeWrite)
|
|
139
|
+
return { read };
|
|
140
|
+
const driveModeFor = (p) => p.driveMode && isKnownDriveMode(p.driveMode) ? p.driveMode : deps.cfg.driveMode;
|
|
141
|
+
// The child runs in its TARGET project, so its spawn bound comes from THAT
|
|
142
|
+
// project's override (else the instance default), not the parent's (#262) —
|
|
143
|
+
// then narrowed by any caller-scoped cap (never widened).
|
|
144
|
+
const maxSpawnDepthFor = (p) => {
|
|
145
|
+
const resolved = resolveMaxSpawnDepth(p.maxSpawnDepth, deps.cfg.maxSpawnDepth);
|
|
146
|
+
return spawnDepthCap === undefined ? resolved : Math.min(resolved, spawnDepthCap);
|
|
147
|
+
};
|
|
148
|
+
const spawnedChild = childOf(parentProvenance);
|
|
149
|
+
// #290: the SENDER of any message these ops inject is THIS chat. Resolve its
|
|
150
|
+
// display name at injection time (best effort) so the recipient's history can
|
|
151
|
+
// say "↩ sent by <name>" and deep-link back.
|
|
152
|
+
const senderForCurrentChat = async () => {
|
|
153
|
+
const sid = currentSessionId();
|
|
154
|
+
if (!sid)
|
|
155
|
+
return { kind: "agent" };
|
|
156
|
+
let name;
|
|
157
|
+
try {
|
|
158
|
+
const cur = await deps.projects.get(currentProjectSlug);
|
|
159
|
+
const sessions = await deps.herdctl.listSessions(cur);
|
|
160
|
+
const found = sessions.find((s) => s.sessionId === sid);
|
|
161
|
+
name = found?.customName ?? found?.autoName ?? undefined;
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
/* name is best-effort — the link still resolves without it */
|
|
165
|
+
}
|
|
166
|
+
return { kind: "chat", project: currentProjectSlug, sessionId: sid, name };
|
|
167
|
+
};
|
|
168
|
+
/**
|
|
169
|
+
* THIS chat, as the parent edge to record on anything it creates (#509).
|
|
170
|
+
* Deliberately id-only and name-free: unlike the message sender above, a
|
|
171
|
+
* lineage edge is resolved against the live chat list at render time, so
|
|
172
|
+
* caching a display name here would just go stale.
|
|
173
|
+
*
|
|
174
|
+
* Null when there is no calling chat — the external `/mcp` transport binds
|
|
175
|
+
* `currentSessionId: () => null`, and a schedule/hook fire has no parent.
|
|
176
|
+
*/
|
|
177
|
+
const parentRefForCurrentChat = () => {
|
|
178
|
+
const sid = currentSessionId();
|
|
179
|
+
return sid ? { project: currentProjectSlug, sessionId: sid } : undefined;
|
|
180
|
+
};
|
|
181
|
+
const write = {
|
|
182
|
+
currentProjectSlug,
|
|
183
|
+
currentSessionId,
|
|
184
|
+
createChat: async (projectSlug, kickoff, o) => {
|
|
185
|
+
const p = await deps.projects.get(projectSlug);
|
|
186
|
+
const composed = o?.preloadContext
|
|
187
|
+
? await composePreloadedPrompt(projectSlug, kickoff)
|
|
188
|
+
: kickoff;
|
|
189
|
+
const overrideModel = o?.model && isKnownModel(o.model) ? o.model : undefined;
|
|
190
|
+
if (overrideModel)
|
|
191
|
+
await deps.herdctl.ensureAgentModel(p, overrideModel);
|
|
192
|
+
const newId = await startAgentTurn({
|
|
193
|
+
projectSlug,
|
|
194
|
+
agentName: keeperAgentName(projectSlug),
|
|
195
|
+
workingDir: p.workingDir,
|
|
196
|
+
resume: null,
|
|
197
|
+
prompt: composed,
|
|
198
|
+
driveMode: driveModeFor(p),
|
|
199
|
+
fallbackModel: overrideModel ?? p.model,
|
|
200
|
+
origin: spawnedChild.origin,
|
|
201
|
+
depth: spawnedChild.depth,
|
|
202
|
+
parent: parentRefForCurrentChat(),
|
|
203
|
+
maxSpawnDepth: maxSpawnDepthFor(p),
|
|
204
|
+
sender: await senderForCurrentChat(),
|
|
205
|
+
});
|
|
206
|
+
if (o?.name) {
|
|
207
|
+
await deps.herdctl
|
|
208
|
+
.renameSession(keeperAgentName(projectSlug), newId, o.name)
|
|
209
|
+
.catch(() => undefined);
|
|
210
|
+
}
|
|
211
|
+
return { sessionId: newId };
|
|
212
|
+
},
|
|
213
|
+
forkChat: async ({ projectSlug, sourceSessionId, prompt: kickoff, name, model }) => {
|
|
214
|
+
const p = await deps.projects.get(projectSlug);
|
|
215
|
+
if (!(await deps.herdctl.sessionExists(p, sourceSessionId))) {
|
|
216
|
+
throw new Error(`chat not found: ${sourceSessionId} in project ${projectSlug}`);
|
|
217
|
+
}
|
|
218
|
+
const newId = await deps.herdctl.forkSession(p, sourceSessionId, name);
|
|
219
|
+
// Stamp the forked CHILD's provenance here (not via startAgentTurn, which
|
|
220
|
+
// only stamps a brand-new `resume:null` chat): a fork with no kickoff never
|
|
221
|
+
// calls startAgentTurn, so this covers both cases.
|
|
222
|
+
//
|
|
223
|
+
// A fork's parent is the chat it was forked FROM, not the chat that called
|
|
224
|
+
// the tool — a fork belongs under its source in the tree. This is the only
|
|
225
|
+
// record of that edge: forkSession rewrites the source id out of the child's
|
|
226
|
+
// transcript, and a kickoff-less fork injects no message to infer it from.
|
|
227
|
+
await deps.runProvenance
|
|
228
|
+
?.stamp(newId, childOf(parentProvenance, { project: projectSlug, sessionId: sourceSessionId }))
|
|
229
|
+
.catch(() => undefined);
|
|
230
|
+
if (kickoff && kickoff.trim().length > 0) {
|
|
231
|
+
const overrideModel = model && isKnownModel(model) ? model : undefined;
|
|
232
|
+
if (overrideModel)
|
|
233
|
+
await deps.herdctl.ensureAgentModel(p, overrideModel);
|
|
234
|
+
await startAgentTurn({
|
|
235
|
+
projectSlug,
|
|
236
|
+
agentName: keeperAgentName(projectSlug),
|
|
237
|
+
workingDir: p.workingDir,
|
|
238
|
+
resume: newId,
|
|
239
|
+
prompt: forkKickoffPrompt(kickoff),
|
|
240
|
+
driveMode: driveModeFor(p),
|
|
241
|
+
fallbackModel: overrideModel ?? p.model,
|
|
242
|
+
origin: spawnedChild.origin,
|
|
243
|
+
depth: spawnedChild.depth,
|
|
244
|
+
maxSpawnDepth: maxSpawnDepthFor(p),
|
|
245
|
+
sender: await senderForCurrentChat(),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
return { sessionId: newId };
|
|
249
|
+
},
|
|
250
|
+
sendMessage: async (projectSlug, targetSessionId, kickoff) => {
|
|
251
|
+
const p = await deps.projects.get(projectSlug);
|
|
252
|
+
if (!(await deps.herdctl.sessionExists(p, targetSessionId))) {
|
|
253
|
+
throw new Error(`chat not found: ${targetSessionId} in project ${projectSlug}`);
|
|
254
|
+
}
|
|
255
|
+
await startAgentTurn({
|
|
256
|
+
projectSlug,
|
|
257
|
+
agentName: keeperAgentName(projectSlug),
|
|
258
|
+
workingDir: p.workingDir,
|
|
259
|
+
resume: targetSessionId,
|
|
260
|
+
prompt: kickoff,
|
|
261
|
+
driveMode: driveModeFor(p),
|
|
262
|
+
fallbackModel: p.model,
|
|
263
|
+
origin: spawnedChild.origin,
|
|
264
|
+
depth: spawnedChild.depth,
|
|
265
|
+
maxSpawnDepth: maxSpawnDepthFor(p),
|
|
266
|
+
sender: await senderForCurrentChat(),
|
|
267
|
+
});
|
|
268
|
+
},
|
|
269
|
+
setArchived: async (projectSlug, targetSessionId, archived) => {
|
|
270
|
+
await deps.projects.get(projectSlug);
|
|
271
|
+
const changed = await deps.archive.setArchived(keeperAgentName(projectSlug), targetSessionId, archived);
|
|
272
|
+
// Epic G / G1: after the archive COMMITS, emit the `onArchive` lifecycle
|
|
273
|
+
// event (only on a real transition INTO archived) so the dispatcher fires
|
|
274
|
+
// the project's enabled onArchive hooks.
|
|
275
|
+
if (changed && archived) {
|
|
276
|
+
deps.events?.emit("onArchive", { slug: projectSlug, sessionId: targetSessionId });
|
|
277
|
+
}
|
|
278
|
+
},
|
|
279
|
+
// Project provisioning (#467). Behind its OWN gate, separate from the other
|
|
280
|
+
// write tools, because this is the one that creates INSTANCE-level state. The
|
|
281
|
+
// body is deliberately the SAME two calls, in the same order, that
|
|
282
|
+
// `POST /api/projects` makes — store create, then keeper registration — so the
|
|
283
|
+
// REST and MCP paths can't drift: all validation, the #187 repo clone and its
|
|
284
|
+
// rollback live in `ProjectStore.create`.
|
|
285
|
+
projectsMcpEnabled: includeProjects,
|
|
286
|
+
createProject: async (input) => {
|
|
287
|
+
// The handler already validated `status` against PROJECT_STATUSES (the same
|
|
288
|
+
// list ProjectStatus is derived from), so narrowing it here is sound — it's
|
|
289
|
+
// only `string` on the MCP input type to keep that module free of the
|
|
290
|
+
// project layer's types.
|
|
291
|
+
const project = await deps.projects.create({
|
|
292
|
+
...input,
|
|
293
|
+
status: input.status,
|
|
294
|
+
});
|
|
295
|
+
// Mirror the route's non-fatal treatment: the project IS created even if
|
|
296
|
+
// registering its keeper/sweeper agents fails. Unlike the route (which only
|
|
297
|
+
// logs) we report it, since the agent's very next step is usually a
|
|
298
|
+
// `create_chat` into this project, which needs a live keeper.
|
|
299
|
+
let agentRegistered = true;
|
|
300
|
+
try {
|
|
301
|
+
await deps.herdctl.ensureProjectAgent(project);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
agentRegistered = false;
|
|
305
|
+
}
|
|
306
|
+
return {
|
|
307
|
+
slug: project.slug,
|
|
308
|
+
name: project.name,
|
|
309
|
+
dir: project.dir,
|
|
310
|
+
workingDir: project.workingDir,
|
|
311
|
+
repoBacked: project.repoBacked === true,
|
|
312
|
+
...(project.repo ? { repo: project.repo } : {}),
|
|
313
|
+
agentRegistered,
|
|
314
|
+
};
|
|
315
|
+
},
|
|
316
|
+
triggersMcpEnabled: includeTriggers,
|
|
317
|
+
listTriggers: async (projectSlug) => {
|
|
318
|
+
if (!deps.triggers)
|
|
319
|
+
return [];
|
|
320
|
+
const dtos = await deps.triggers.list(projectSlug);
|
|
321
|
+
const p = await deps.projects.get(projectSlug).catch(() => null);
|
|
322
|
+
const runtime = p ? await deps.herdctl.listAgentSchedules(p).catch(() => []) : [];
|
|
323
|
+
const byName = new Map(runtime.map((s) => [s.name, s]));
|
|
324
|
+
return dtos.map((dto) => toSelfMcpTrigger(dto, byName.get(dto.name)));
|
|
325
|
+
},
|
|
326
|
+
setTrigger: async (projectSlug, name, incoming) => {
|
|
327
|
+
if (!deps.triggers)
|
|
328
|
+
throw new Error("trigger management is unavailable");
|
|
329
|
+
const existing = await deps.triggers.get(projectSlug, name).catch(() => null);
|
|
330
|
+
const record = mergeTriggerUpdate(existing, incoming);
|
|
331
|
+
const dto = await deps.triggers.set(projectSlug, name, record);
|
|
332
|
+
const p = await deps.projects.get(projectSlug).catch(() => null);
|
|
333
|
+
const runtime = p ? await deps.herdctl.listAgentSchedules(p).catch(() => []) : [];
|
|
334
|
+
const info = runtime.find((s) => s.name === name);
|
|
335
|
+
return toSelfMcpTrigger(dto, info);
|
|
336
|
+
},
|
|
337
|
+
removeTrigger: async (projectSlug, name) => {
|
|
338
|
+
if (!deps.triggers)
|
|
339
|
+
return false;
|
|
340
|
+
return deps.triggers.remove(projectSlug, name);
|
|
341
|
+
},
|
|
342
|
+
runTrigger: async (projectSlug, name) => {
|
|
343
|
+
if (!deps.triggers)
|
|
344
|
+
return null;
|
|
345
|
+
// Reject the post-turn curator up front with a clear message (the generic
|
|
346
|
+
// fire path can't run it — it has no scoped agent; see fireTrigger).
|
|
347
|
+
const p = await deps.projects.get(projectSlug).catch(() => null);
|
|
348
|
+
const rec = p?.triggers?.[name];
|
|
349
|
+
if (rec && isCuratorTrigger(rec)) {
|
|
350
|
+
throw new Error("the post-turn curator trigger runs automatically after each turn and can't be run on demand");
|
|
351
|
+
}
|
|
352
|
+
return fireTrigger(projectSlug, name);
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
return { read, write };
|
|
356
|
+
}
|
|
357
|
+
/**
|
|
358
|
+
* Wrap built ops so every call is checked against `principal`.
|
|
359
|
+
*
|
|
360
|
+
* Returns the ops UNCHANGED for the internal principal — the keeper path must be
|
|
361
|
+
* byte-for-byte identical to its pre-#312 behaviour, and skipping the wrapper
|
|
362
|
+
* entirely is the most honest way to guarantee that.
|
|
363
|
+
*
|
|
364
|
+
* The write bag is dropped wholesale when the principal is granted no write
|
|
365
|
+
* operation, so a read-only client's write tools are ABSENT rather than
|
|
366
|
+
* present-and-refusing — matching how the existing capability gates behave.
|
|
367
|
+
*/
|
|
368
|
+
export function enforceManagementPolicy(ops, principal) {
|
|
369
|
+
if (isInternalPrincipal(principal))
|
|
370
|
+
return ops;
|
|
371
|
+
const { scope } = principal;
|
|
372
|
+
const read = {
|
|
373
|
+
listProjects: async () => {
|
|
374
|
+
assertOperation(principal, "list_projects");
|
|
375
|
+
// Enumeration filters rather than denies — see the module header.
|
|
376
|
+
return (await ops.read.listProjects()).filter((p) => isProjectAllowed(scope, p.slug));
|
|
377
|
+
},
|
|
378
|
+
listChats: async (projectSlug) => {
|
|
379
|
+
assertOperation(principal, "list_chats");
|
|
380
|
+
if (projectSlug !== undefined)
|
|
381
|
+
assertProject(principal, "list_chats", projectSlug);
|
|
382
|
+
return (await ops.read.listChats(projectSlug)).filter((c) => isProjectAllowed(scope, c.project));
|
|
383
|
+
},
|
|
384
|
+
readChat: async (projectSlug, sessionId) => {
|
|
385
|
+
assertOperation(principal, "read_chat");
|
|
386
|
+
assertProject(principal, "read_chat", projectSlug);
|
|
387
|
+
return ops.read.readChat(projectSlug, sessionId);
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
const grantsAnyWrite = WRITE_OPERATIONS.some((op) => isOperationAllowed(scope, op));
|
|
391
|
+
const grantsAnyTrigger = TRIGGER_OPERATIONS.some((op) => isOperationAllowed(scope, op));
|
|
392
|
+
if (!ops.write || (!grantsAnyWrite && !grantsAnyTrigger))
|
|
393
|
+
return { read };
|
|
394
|
+
const w = ops.write;
|
|
395
|
+
// `async` so a denial surfaces as a REJECTED PROMISE, never a synchronous
|
|
396
|
+
// throw: these callbacks are typed as returning promises, and a caller that
|
|
397
|
+
// only attaches `.catch()` (rather than awaiting inside a try) would otherwise
|
|
398
|
+
// see the error escape as an unhandled exception.
|
|
399
|
+
const guard = async (operation, projectSlug, run) => {
|
|
400
|
+
assertOperation(principal, operation);
|
|
401
|
+
assertProject(principal, operation, projectSlug);
|
|
402
|
+
return run();
|
|
403
|
+
};
|
|
404
|
+
const write = {
|
|
405
|
+
currentProjectSlug: w.currentProjectSlug,
|
|
406
|
+
currentSessionId: w.currentSessionId,
|
|
407
|
+
// Instance-level, so there is no project to scope-check — only the verb.
|
|
408
|
+
projectsMcpEnabled: w.projectsMcpEnabled && isOperationAllowed(scope, "create_project"),
|
|
409
|
+
createProject: (input) => {
|
|
410
|
+
assertOperation(principal, "create_project");
|
|
411
|
+
return w.createProject(input);
|
|
412
|
+
},
|
|
413
|
+
createChat: (projectSlug, prompt, o) => guard("create_chat", projectSlug, () => w.createChat(projectSlug, prompt, o)),
|
|
414
|
+
// NOTE: `fork_chat_batch` is a convenience wrapper that calls this same
|
|
415
|
+
// callback N times, so it is gated on `fork_chat` here AND on
|
|
416
|
+
// `fork_chat_batch` at tool-assembly time (see managementToolFilter).
|
|
417
|
+
forkChat: (args) => guard("fork_chat", args.projectSlug, () => w.forkChat(args)),
|
|
418
|
+
sendMessage: (projectSlug, sessionId, prompt) => guard("send_message", projectSlug, () => w.sendMessage(projectSlug, sessionId, prompt)),
|
|
419
|
+
setArchived: (projectSlug, sessionId, archived) => guard(archived ? "archive_chat" : "unarchive_chat", projectSlug, () => w.setArchived(projectSlug, sessionId, archived)),
|
|
420
|
+
// Only offer the trigger tools when the principal is granted at least one.
|
|
421
|
+
triggersMcpEnabled: w.triggersMcpEnabled && grantsAnyTrigger,
|
|
422
|
+
listTriggers: (projectSlug) => guard("list_triggers", projectSlug, () => w.listTriggers(projectSlug)),
|
|
423
|
+
setTrigger: (projectSlug, name, trigger) => guard("set_trigger", projectSlug, () => w.setTrigger(projectSlug, name, trigger)),
|
|
424
|
+
removeTrigger: (projectSlug, name) => guard("remove_trigger", projectSlug, () => w.removeTrigger(projectSlug, name)),
|
|
425
|
+
runTrigger: (projectSlug, name) => guard("run_trigger", projectSlug, () => w.runTrigger(projectSlug, name)),
|
|
426
|
+
};
|
|
427
|
+
return { read, write };
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* A predicate selecting which MCP tools a principal should even SEE.
|
|
431
|
+
*
|
|
432
|
+
* Tool-list filtering is presentation, not enforcement — the ops wrapper above
|
|
433
|
+
* is what actually refuses a call. But hiding out-of-scope tools means a client
|
|
434
|
+
* never offers its model a verb it cannot use, which avoids a whole class of
|
|
435
|
+
* confusing "the tool exists but always errors" behaviour.
|
|
436
|
+
*/
|
|
437
|
+
export function managementToolFilter(principal) {
|
|
438
|
+
if (isInternalPrincipal(principal))
|
|
439
|
+
return () => true;
|
|
440
|
+
return (toolName) => {
|
|
441
|
+
if (!isOperationAllowed(principal.scope, toolName))
|
|
442
|
+
return false;
|
|
443
|
+
// The batch fan-out executes through `fork_chat`, so offering it without
|
|
444
|
+
// that grant would produce a tool that is present but denied on every call.
|
|
445
|
+
if (toolName === "fork_chat_batch")
|
|
446
|
+
return isOperationAllowed(principal.scope, "fork_chat");
|
|
447
|
+
return true;
|
|
448
|
+
};
|
|
449
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
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
|
+
/** How a principal proved its identity. Transports set this; policy ignores it. */
|
|
28
|
+
export type ManagementCredentialKind = "internal" | "token" | "oauth";
|
|
29
|
+
/**
|
|
30
|
+
* What a principal may reach. Both dimensions are independently constrained:
|
|
31
|
+
* WHICH operations (`allow`/`deny`) and WHICH projects (`projects`/
|
|
32
|
+
* `denyProjects`). A call must satisfy both.
|
|
33
|
+
*/
|
|
34
|
+
export interface ManagementScope {
|
|
35
|
+
/**
|
|
36
|
+
* Project slugs this principal may touch. `["*"]` = every project. Matching
|
|
37
|
+
* is exact-or-`*` (see {@link matchesPattern}); an empty list reaches nothing.
|
|
38
|
+
*/
|
|
39
|
+
projects: string[];
|
|
40
|
+
/**
|
|
41
|
+
* Operation names this principal may invoke. Supports a trailing `*` wildcard
|
|
42
|
+
* (`"list_*"`) and the bare `"*"`. Empty grants nothing.
|
|
43
|
+
*/
|
|
44
|
+
allow: string[];
|
|
45
|
+
/** Operation names explicitly refused. Deny ALWAYS beats allow. */
|
|
46
|
+
deny: string[];
|
|
47
|
+
/** Project slugs explicitly refused. Deny ALWAYS beats allow. */
|
|
48
|
+
denyProjects?: string[];
|
|
49
|
+
/**
|
|
50
|
+
* Recursion bound applied to turns this principal spawns. Absent ⇒ the
|
|
51
|
+
* instance/project default resolves as it does for an internal caller.
|
|
52
|
+
*/
|
|
53
|
+
maxSpawnDepth?: number;
|
|
54
|
+
}
|
|
55
|
+
/** The authenticated caller of a management operation. */
|
|
56
|
+
export interface ManagementPrincipal {
|
|
57
|
+
/**
|
|
58
|
+
* Stable per-client identity — the `managementApi.clients` key for an external
|
|
59
|
+
* caller, or `"internal"` for the in-process keeper path. This is what gets
|
|
60
|
+
* LOGGED and stamped as provenance; the credential itself never is.
|
|
61
|
+
*/
|
|
62
|
+
clientId: string;
|
|
63
|
+
kind: ManagementCredentialKind;
|
|
64
|
+
scope: ManagementScope;
|
|
65
|
+
}
|
|
66
|
+
/** Read-only operations: no state change, no turn started. */
|
|
67
|
+
export declare const READ_OPERATIONS: readonly ["list_projects", "list_chats", "read_chat"];
|
|
68
|
+
/** Chat write operations. The first four START TURNS (see the RCE note above). */
|
|
69
|
+
export declare const WRITE_OPERATIONS: readonly ["create_project", "create_chat", "fork_chat", "send_message", "fork_chat_batch", "archive_chat", "unarchive_chat"];
|
|
70
|
+
/** Unified trigger operations (Epic T / T3). `list_triggers` is the read one. */
|
|
71
|
+
export declare const TRIGGER_OPERATIONS: readonly ["list_triggers", "set_trigger", "remove_trigger", "run_trigger"];
|
|
72
|
+
/** Every operation the management surface knows about. */
|
|
73
|
+
export declare const ALL_OPERATIONS: readonly string[];
|
|
74
|
+
/**
|
|
75
|
+
* Operations that cause a keeper turn to run — directly (`create_chat`,
|
|
76
|
+
* `send_message`, `fork_chat*`, `run_trigger`) or by arming something that will
|
|
77
|
+
* (`set_trigger`). Granting ANY of these to a principal is granting code
|
|
78
|
+
* execution on the host; kept as an explicit set so that fact stays greppable
|
|
79
|
+
* and so docs/UI can warn on it.
|
|
80
|
+
*/
|
|
81
|
+
/**
|
|
82
|
+
* Operations that grant code execution on the host, and so define the risk class
|
|
83
|
+
* a write scope really is. A superset of {@link TURN_SPAWNING_OPERATIONS}:
|
|
84
|
+
* `create_project` starts no turn, but it clones a CALLER-SUPPLIED git URL and
|
|
85
|
+
* creates instance-level state, which belongs in the same warning.
|
|
86
|
+
*/
|
|
87
|
+
export declare const HIGH_RISK_OPERATIONS: readonly string[];
|
|
88
|
+
export declare const TURN_SPAWNING_OPERATIONS: readonly string[];
|
|
89
|
+
/**
|
|
90
|
+
* The DEFAULT scope for a configured client that specifies none — read-only
|
|
91
|
+
* across all projects. Matches the shape written on issue #312 verbatim
|
|
92
|
+
* (`allow: ["list_*", "read_chat"]`), which covers the three read tools plus
|
|
93
|
+
* `list_triggers` while excluding every mutating verb.
|
|
94
|
+
*/
|
|
95
|
+
export declare const DEFAULT_READ_ONLY_SCOPE: ManagementScope;
|
|
96
|
+
/** Unrestricted scope — used ONLY for the in-process keeper principal. */
|
|
97
|
+
export declare const FULL_SCOPE: ManagementScope;
|
|
98
|
+
/**
|
|
99
|
+
* The implicit full-trust principal for the in-process keeper path.
|
|
100
|
+
*
|
|
101
|
+
* The internal caller is bounded by DEPTH (`maxSpawnDepth`, shipped in #278),
|
|
102
|
+
* not by auth+scope — it has no credential because it isn't a distinct identity.
|
|
103
|
+
* Handing it a full scope keeps a single enforcement path (every op checks a
|
|
104
|
+
* principal) while guaranteeing the keeper behaviour is byte-for-byte unchanged:
|
|
105
|
+
* every check below short-circuits to allow.
|
|
106
|
+
*/
|
|
107
|
+
export declare const INTERNAL_PRINCIPAL: ManagementPrincipal;
|
|
108
|
+
/** True for the implicit in-process principal (bounded by depth, not scope). */
|
|
109
|
+
export declare function isInternalPrincipal(principal: ManagementPrincipal): boolean;
|
|
110
|
+
/**
|
|
111
|
+
* Match one pattern against one value. Supports exactly two forms — a bare `"*"`
|
|
112
|
+
* (everything) and a trailing-`*` prefix (`"list_*"`) — plus exact equality.
|
|
113
|
+
*
|
|
114
|
+
* Deliberately NOT a general glob: a security predicate should be trivially
|
|
115
|
+
* auditable by the operator writing it, and the ticket calls for starting with
|
|
116
|
+
* explicit lists and adding globs only as needed. Anything fancier (`?`, `[]`,
|
|
117
|
+
* embedded `*`) is treated as a literal, so a typo'd pattern fails CLOSED
|
|
118
|
+
* (matches nothing) rather than accidentally widening a grant.
|
|
119
|
+
*/
|
|
120
|
+
export declare function matchesPattern(pattern: string, value: string): boolean;
|
|
121
|
+
/**
|
|
122
|
+
* Whether `scope` permits invoking `operation`. Deny beats allow; an operation
|
|
123
|
+
* outside {@link ALL_OPERATIONS} is refused regardless of the allow-list, so a
|
|
124
|
+
* typo in config (or a tool added later that policy hasn't been taught about)
|
|
125
|
+
* can never be reached through a stale `"*"` grant.
|
|
126
|
+
*/
|
|
127
|
+
export declare function isOperationAllowed(scope: ManagementScope, operation: string): boolean;
|
|
128
|
+
/** Whether `scope` permits touching `projectSlug`. Deny beats allow. */
|
|
129
|
+
export declare function isProjectAllowed(scope: ManagementScope, projectSlug: string): boolean;
|
|
130
|
+
/** The concrete operations `scope` grants, in catalogue order. */
|
|
131
|
+
export declare function allowedOperations(scope: ManagementScope): string[];
|
|
132
|
+
/** True when `scope` grants at least one operation that starts a keeper turn. */
|
|
133
|
+
export declare function grantsTurnSpawning(scope: ManagementScope): boolean;
|
|
134
|
+
export declare const OAUTH_SCOPE_READ = "paddock:read";
|
|
135
|
+
export declare const OAUTH_SCOPE_WRITE = "paddock:write";
|
|
136
|
+
/** Every OAuth scope this resource server understands (for discovery). */
|
|
137
|
+
export declare const OAUTH_SCOPES_SUPPORTED: readonly string[];
|
|
138
|
+
/**
|
|
139
|
+
* The coarse OAuth scope an operation needs. Anything that mutates state or
|
|
140
|
+
* starts a turn is `write`; everything else is `read`.
|
|
141
|
+
*/
|
|
142
|
+
export declare function requiredOauthScope(operation: string): string;
|
|
143
|
+
/** The coarse OAuth scopes a fine-grained scope amounts to. */
|
|
144
|
+
export declare function oauthScopesFor(scope: ManagementScope): string[];
|
|
145
|
+
/** True when `scope` grants any operation that amounts to code execution. */
|
|
146
|
+
export declare function grantsCodeExecution(scope: ManagementScope): boolean;
|
|
147
|
+
/**
|
|
148
|
+
* Thrown when policy refuses a call. Carries the structured facts a transport
|
|
149
|
+
* needs to render its own error (MCP `isError` text, or an HTTP 403) and that
|
|
150
|
+
* the server log wants — `clientId` + `operation`, never the credential.
|
|
151
|
+
*/
|
|
152
|
+
export declare class ManagementDeniedError extends Error {
|
|
153
|
+
readonly code: "operation_denied" | "project_denied";
|
|
154
|
+
readonly clientId: string;
|
|
155
|
+
readonly operation: string;
|
|
156
|
+
readonly projectSlug?: string;
|
|
157
|
+
constructor(args: {
|
|
158
|
+
code: "operation_denied" | "project_denied";
|
|
159
|
+
clientId: string;
|
|
160
|
+
operation: string;
|
|
161
|
+
projectSlug?: string;
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
/** Throw unless `principal` may invoke `operation`. */
|
|
165
|
+
export declare function assertOperation(principal: ManagementPrincipal, operation: string): void;
|
|
166
|
+
/**
|
|
167
|
+
* Throw unless `principal` may reach `projectSlug` via `operation`. Callers that
|
|
168
|
+
* ENUMERATE (list_projects/list_chats) must filter with {@link isProjectAllowed}
|
|
169
|
+
* instead — a scoped client listing its two projects should see those two, not a
|
|
170
|
+
* denial. This assert is for calls that name a target explicitly.
|
|
171
|
+
*/
|
|
172
|
+
export declare function assertProject(principal: ManagementPrincipal, operation: string, projectSlug: string): void;
|