@edspencer/paddock 0.61.1 → 0.62.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 +35 -14
- package/package.json +1 -1
- package/packages/server/dist/app.js +35 -8
- package/packages/server/dist/claude-credentials.d.ts +116 -0
- package/packages/server/dist/claude-credentials.js +103 -0
- package/packages/server/dist/claude-home.d.ts +102 -30
- package/packages/server/dist/claude-home.js +437 -97
- package/packages/server/dist/claude-instructions.d.ts +121 -0
- package/packages/server/dist/claude-instructions.js +114 -0
- package/packages/server/dist/claude-mcp.d.ts +146 -0
- package/packages/server/dist/claude-mcp.js +367 -0
- package/packages/server/dist/claude-settings.d.ts +159 -0
- package/packages/server/dist/claude-settings.js +159 -0
- package/packages/server/dist/cli/args.d.ts +1 -9
- package/packages/server/dist/cli/args.js +36 -18
- package/packages/server/dist/cli/paddock.js +20 -39
- package/packages/server/dist/config.d.ts +166 -30
- package/packages/server/dist/config.js +106 -26
- package/packages/server/dist/herdctl-agent-config.d.ts +11 -1
- package/packages/server/dist/herdctl-agent-config.js +53 -7
- package/packages/server/dist/herdctl-agent-names.d.ts +11 -0
- package/packages/server/dist/herdctl-agent-names.js +31 -0
- package/packages/server/dist/herdctl.d.ts +69 -13
- package/packages/server/dist/herdctl.js +70 -18
- package/packages/server/dist/instance-config.js +36 -0
- package/packages/server/dist/mcp-servers.d.ts +168 -0
- package/packages/server/dist/mcp-servers.js +305 -0
- package/packages/server/dist/routes/chats.js +10 -4
- package/packages/server/dist/transcripts.d.ts +61 -30
- package/packages/server/dist/transcripts.js +141 -46
- package/packages/server/package.json +1 -1
- package/packages/web/dist/assets/{InstanceConfigPage-BgNXqAox.js → InstanceConfigPage-Ca905oey.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-DfD_UtqU.js → MarkdownRenderer-ChlC1ezH.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-1EXdHXmO.js → ProjectRedirect-CslqbhUb.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-DHb5vNUp.js → ProjectView-DkNdilGo.js} +3 -3
- package/packages/web/dist/assets/{ProjectsGrid-Br-EhLZE.js → ProjectsGrid-BrlvLc-Y.js} +1 -1
- package/packages/web/dist/assets/{arc-C2Jekujs.js → arc-CGqCI5nQ.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CflCA-em.js → architectureDiagram-3BPJPVTR-Cp4KTxE2.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Ds7GLNnC.js → blockDiagram-GPEHLZMM-yFxaTv04.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-D4WyGO6I.js → c4Diagram-AAUBKEIU-59lhWjRt.js} +1 -1
- package/packages/web/dist/assets/channel-CZs6Z1i5.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-tQunO_M5.js → chunk-2J33WTMH-BrpXGnJg.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DT5dOWd2.js → chunk-4BX2VUAB-UxBDb5pi.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-CfTUqUEF.js → chunk-55IACEB6-D4ZJyUHW.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-sK5bOAA0.js → chunk-727SXJPM-DWdPj_8E.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DcMgVe-P.js → chunk-AQP2D5EJ-BIpNbflY.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-In3h_-7v.js → chunk-FMBD7UC4-f6iJDfdI.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-DuaIeRaC.js → chunk-ND2GUHAM-Bz3TKIJk.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-DeT5mR5T.js → chunk-QZHKN3VN-buQmkafK.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CrbEUUCd.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CrbEUUCd.js +2 -0
- package/packages/web/dist/assets/{core-DgOgLvnM.js → core-TyFI6p0n.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CyO-rznJ.js → cose-bilkent-S5V4N54A-CIkdi0XF.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-B_9Ihzgn.js → dagre-BM42HDAG-BnLJCfNs.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-z5mvZ5YT.js → diagram-2AECGRRQ-D0OqfDB9.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-Bsz0cgfn.js → diagram-5GNKFQAL-DQ2spGrK.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-B29k5f4q.js → diagram-KO2AKTUF-RDIVQ_S3.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-CFrwsHoI.js → diagram-LMA3HP47-DKoTfh2C.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-D9Sjc2HR.js → diagram-OG6HWLK6-6k0OuSAL.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-C3vbkD7M.js → erDiagram-TEJ5UH35-B-oQaYAC.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DkMIgntd.js → flowDiagram-I6XJVG4X-B7qpYe9C.js} +1 -1
- package/packages/web/dist/assets/{format-BFDqyFuw.js → format-PHdEBNOi.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-nvHanjpW.js → ganttDiagram-6RSMTGT7-SfBsqMmx.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DbCNfOu7.js → gitGraphDiagram-PVQCEYII-CuM4Rl_i.js} +1 -1
- package/packages/web/dist/assets/{index-DGCs1YIO.js → index-D_1ogxg1.js} +2 -2
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-aJjH9x3D.js → infoDiagram-5YYISTIA-Dq-32Oj4.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DoifwNnZ.js → ishikawaDiagram-YF4QCWOH-BLFHCj_o.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-Cz9kG9HN.js → journeyDiagram-JHISSGLW-CHP-Yu63.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BoXnfr7A.js → kanban-definition-UN3LZRKU-DRuehSqn.js} +1 -1
- package/packages/web/dist/assets/{linear-BlTTO9yR.js → linear-Bwz2vnH7.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-YAG8eSPQ.js → mermaid.core-CeMMnCip.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DtUBKfkr.js → mindmap-definition-RKZ34NQL-CEdqeoRW.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-B3Rwpmy0.js → pieDiagram-4H26LBE5-BdDXBx87.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-B8eJtTyk.js → quadrantDiagram-W4KKPZXB-Bovd1Zhx.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CpAluiY9.js → requirementDiagram-4Y6WPE33-DiyrKnJ2.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-Cl_Rd1tN.js → sankeyDiagram-5OEKKPKP-BuX7TRVw.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-CRpqKSGm.js → sequenceDiagram-3UESZ5HK-BjetZWJo.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-h3h8gXv_.js → stateDiagram-AJRCARHV-4AUBV6dF.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Cq84MOtb.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-PVr5V_pG.js → timeline-definition-PNZ67QCA-BpQ8g8Bm.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-De-4NPAX.js → vennDiagram-CIIHVFJN-Cj99BWER.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-Dtv10D2d.js → wardley-L42UT6IY-S7AGTOWw.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-p2fJuQ6C.js → wardleyDiagram-YWT4CUSO-LNXkUA7S.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-DyYF-k92.js → xychartDiagram-2RQKCTM6-DGdTh4Xk.js} +1 -1
- package/packages/web/dist/index.html +1 -1
- package/packages/web/dist/sw.js +1 -1
- package/packages/server/dist/cli/claude-home-choice.d.ts +0 -44
- package/packages/server/dist/cli/claude-home-choice.js +0 -108
- package/packages/web/dist/assets/channel-B1K_gwAH.js +0 -2
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DYQQOKrW.js +0 -2
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DYQQOKrW.js +0 -2
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BKRI9TnH.js +0 -2
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `claude.mcpServers` lever (#691): whose MCP servers this instance's agents
|
|
3
|
+
* get.
|
|
4
|
+
*
|
|
5
|
+
* The last of the five levers, and the only one that is not about a file inside
|
|
6
|
+
* `~/.claude`. Everything else in the `claude:` block is a bridge decision about
|
|
7
|
+
* an entry of the home directory; MCP servers are declared in **`~/.claude.json`**,
|
|
8
|
+
* which is a SIBLING of that directory rather than a member of it. Claude Code
|
|
9
|
+
* resolves it as `join(CLAUDE_CONFIG_DIR ?? homedir(), ".claude.json")` — read
|
|
10
|
+
* out of the SDK bundle, where the two paths sit one expression apart and do not
|
|
11
|
+
* agree:
|
|
12
|
+
*
|
|
13
|
+
* ```js
|
|
14
|
+
* let d = n?.CLAUDE_CONFIG_DIR ?? process.env.CLAUDE_CONFIG_DIR,
|
|
15
|
+
* f = d ?? Dr(sO(), ".claude"); // .credentials.json lives under here
|
|
16
|
+
* … await eV(Dr(f, ".credentials.json"), "utf-8")
|
|
17
|
+
* … await KMe(Dr(d ?? sO(), ".claude.json"), …) // ← homedir, NOT ~/.claude
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* So the moment paddock owns its own home (#620/#691) the user's `.claude.json`
|
|
21
|
+
* stops being on the path, and NO symlink bridge inside the home structurally
|
|
22
|
+
* can reach it. That is why MCP inheritance broke silently, separately from
|
|
23
|
+
* everything else, and why this lever is a read rather than a link.
|
|
24
|
+
*
|
|
25
|
+
* ## Why not just symlink `.claude.json`
|
|
26
|
+
*
|
|
27
|
+
* Because Claude Code WRITES to it — per-project trust, `enabledMcpjsonServers`,
|
|
28
|
+
* onboarding/migration flags, cached tips. Bridging it means paddock's instance
|
|
29
|
+
* mutating the user's real config, which is the exact thing #691 exists to stop.
|
|
30
|
+
* Read it, take the two keys that declare servers, hand those to the runtime.
|
|
31
|
+
*
|
|
32
|
+
* ## The seam, which is not the one #691 names
|
|
33
|
+
*
|
|
34
|
+
* #691 says to pass the servers through herdctl's runner option
|
|
35
|
+
* (`SDKQueryOptions.mcpServers`, merged at `sdk-runtime.js`) and that **"paddock
|
|
36
|
+
* sets none today"**. Both halves are wrong in a way that makes the work
|
|
37
|
+
* smaller, not larger:
|
|
38
|
+
*
|
|
39
|
+
* 1. `SDKQueryOptions` is built BY herdctl (`toSDKOptions`), not by paddock.
|
|
40
|
+
* Paddock never constructs one. The field is populated from
|
|
41
|
+
* `transformMcpServers(agent.mcp_servers)` — i.e. from the AGENT CONFIG.
|
|
42
|
+
* 2. Paddock already sets `mcp_servers`: the headless-Chromium server from
|
|
43
|
+
* `browserMcpServers` (#269) is an external stdio MCP server passed exactly
|
|
44
|
+
* this way, on every keeper and every trigger, whenever `PADDOCK_BROWSER_MCP`
|
|
45
|
+
* is on.
|
|
46
|
+
*
|
|
47
|
+
* `agent.mcp_servers` is also the seam that covers BOTH runtimes for free: the
|
|
48
|
+
* SDK runtime transforms it into `sdkOptions.mcpServers`, and the CLI runtime
|
|
49
|
+
* serialises the same record into `--mcp-config '{"mcpServers":…}'`
|
|
50
|
+
* (`cli-runtime.js`). Passing it as a per-run option would have covered one.
|
|
51
|
+
*
|
|
52
|
+
* `injectedMcpServers` is, as #691 says, the wrong hook — `InjectedMcpServerDef`
|
|
53
|
+
* is `{name, version, tools}` with in-process JS handlers (`send_file`), and both
|
|
54
|
+
* runtimes materialise it as an HTTP bridge. It cannot express a stdio command.
|
|
55
|
+
*
|
|
56
|
+
* ## The allowlist, without which this lever is a no-op
|
|
57
|
+
*
|
|
58
|
+
* Paddock's fleet defaults carry an explicit `allowed_tools` array, and **both
|
|
59
|
+
* runtimes auto-deny any tool not on it, with no prompt** (the lesson `Skill`
|
|
60
|
+
* taught, recorded in `ensureConfigFile`). Tools from an MCP server are named
|
|
61
|
+
* `mcp__<server>__<tool>`, so a host server that is passed but not allowlisted
|
|
62
|
+
* connects and then has every one of its tools denied — an instance that looks
|
|
63
|
+
* configured and does nothing.
|
|
64
|
+
*
|
|
65
|
+
* herdctl handles this for INJECTED servers only ("Auto-add injected MCP tool
|
|
66
|
+
* patterns to allowedTools… only needed when the agent has an explicit
|
|
67
|
+
* allowlist", `cli-runtime.js`); config `mcp_servers` get no such treatment,
|
|
68
|
+
* which is why `mcp__playwright__*` is hard-coded into the defaults today. So
|
|
69
|
+
* this module also produces {@link mcpToolPattern} for each host server, and
|
|
70
|
+
* `buildAgentConfig` widens the keeper's allowlist by exactly those entries —
|
|
71
|
+
* only when there are some, so an instance with no host servers stays
|
|
72
|
+
* byte-identical to before.
|
|
73
|
+
*
|
|
74
|
+
* ## What cannot be carried, honestly
|
|
75
|
+
*
|
|
76
|
+
* herdctl's `McpServerSchema` is `{command?, args?, env?, url?}` and it is a
|
|
77
|
+
* plain `z.object`, so anything else is silently STRIPPED at `addAgent`. Two
|
|
78
|
+
* consequences that matter and that #691 does not mention:
|
|
79
|
+
*
|
|
80
|
+
* - **`headers` is dropped.** A remote MCP server authenticated with a bearer
|
|
81
|
+
* header therefore arrives unauthenticated.
|
|
82
|
+
* - **`type` is dropped**, and `transformMcpServer` maps any `url` to
|
|
83
|
+
* `type: "http"`. An `sse` server is silently downgraded to HTTP.
|
|
84
|
+
*
|
|
85
|
+
* And those two strippings are worse than they look, because of where MCP OAuth
|
|
86
|
+
* tokens turn out to live. #691 listed that as an open question; it is now
|
|
87
|
+
* answered, from the bundled CLI binary rather than by inference:
|
|
88
|
+
*
|
|
89
|
+
* - Tokens are stored under a top-level **`mcpOAuth`** key in the SAME credential
|
|
90
|
+
* store as `claudeAiOauth` — `<securestorage-dir>/.credentials.json` on Linux,
|
|
91
|
+
* the one `Claude Code-credentials` keychain item on darwin. There is no
|
|
92
|
+
* MCP-specific service name and no `mcp-oauth/` directory. The store is
|
|
93
|
+
* resolved by `CLAUDE_SECURESTORAGE_CONFIG_DIR ?? CLAUDE_CONFIG_DIR ?? ~/.claude`,
|
|
94
|
+
* which is exactly the variable `claude.credentials` drives. **So
|
|
95
|
+
* `credentials: host` DOES carry MCP OAuth tokens** — they are not separable
|
|
96
|
+
* from the Anthropic login, in either direction.
|
|
97
|
+
* - But the per-server key is `` `${serverName}|${sha256({type,url,headers}).slice(0,16)}` ``.
|
|
98
|
+
* A dropped `headers`, or an `sse` coerced to `http`, changes that hash and the
|
|
99
|
+
* stored token is simply not found. So the combination that works today is
|
|
100
|
+
* `credentials: host` + `mcpServers: host` for an OAuth server declared with no
|
|
101
|
+
* headers and no `type` disagreement; a header-authenticated or `sse` server
|
|
102
|
+
* arrives looking unauthenticated even though its token is right there.
|
|
103
|
+
*
|
|
104
|
+
* Neither is paddock's to fix here (both need a herdctl schema change). What
|
|
105
|
+
* paddock CAN do is refuse to be silent about it: {@link parseHostMcpConfig}
|
|
106
|
+
* records every key it had to drop and every `sse` it had to coerce, and the
|
|
107
|
+
* boot notice names the server. The server is still passed — this is a
|
|
108
|
+
* capability lever, not a security one, and a user who declared a server should
|
|
109
|
+
* get it plus a warning rather than nothing plus a warning. The one case that IS
|
|
110
|
+
* dropped is a server with neither `command` nor `url`, which cannot be started
|
|
111
|
+
* at all.
|
|
112
|
+
*
|
|
113
|
+
* ## Scope: keepers only
|
|
114
|
+
*
|
|
115
|
+
* The sweeper is tool-less by design and the trigger agents declare their own
|
|
116
|
+
* narrow `allowed_tools` per trigger, so neither would be able to call a host
|
|
117
|
+
* server even if it were attached. Attaching one anyway would spawn a stdio
|
|
118
|
+
* process per fire for nothing. Keepers are what a user means by "my MCP servers
|
|
119
|
+
* in paddock".
|
|
120
|
+
*
|
|
121
|
+
* ## Step 6 landed here, as an addition
|
|
122
|
+
*
|
|
123
|
+
* #691 step 6 — an instance-level `mcpServers:` block in `paddock.config.yaml`
|
|
124
|
+
* for servers a user declares themselves — is `mcp-servers.ts`. It contributes
|
|
125
|
+
* {@link McpSources.declared} and nothing else: the shape, {@link mcpServersFor},
|
|
126
|
+
* and the allowlist widening in `buildAgentConfig` were already source-agnostic
|
|
127
|
+
* and did not change. What it does NOT share is validation posture — a host
|
|
128
|
+
* server paddock cannot carry faithfully is passed with a warning (the user
|
|
129
|
+
* declared it elsewhere, for something else), while a declared one is refused
|
|
130
|
+
* with an error (they typed it here, at us, and can fix it).
|
|
131
|
+
*/
|
|
132
|
+
import { promises as fs } from "node:fs";
|
|
133
|
+
import path from "node:path";
|
|
134
|
+
/** Isolation is the default, as everywhere except `credentials`. */
|
|
135
|
+
export const DEFAULT_MCP_SERVERS_MODE = "own";
|
|
136
|
+
/** Type guard, so an unknown config value falls back instead of failing a boot. */
|
|
137
|
+
export function isKnownMcpServersMode(value) {
|
|
138
|
+
return value === "own" || value === "host";
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* The file MCP servers are declared in — a SIBLING of the Claude home, not an
|
|
142
|
+
* entry inside it. See the module doc for the SDK expression that settles this.
|
|
143
|
+
*/
|
|
144
|
+
export const HOST_MCP_CONFIG_FILE = ".claude.json";
|
|
145
|
+
/**
|
|
146
|
+
* Where the user's own `.claude.json` is.
|
|
147
|
+
*
|
|
148
|
+
* Derived from `cfg.legacyClaudeHome` (always `~/.claude`) rather than read from
|
|
149
|
+
* `os.homedir()` again, so it moves with the one value the rest of the bridge
|
|
150
|
+
* already treats as "the host's Claude Code" — and so a test can point the whole
|
|
151
|
+
* lever at a temp dir by setting that one field.
|
|
152
|
+
*/
|
|
153
|
+
export function hostMcpConfigPath(legacyClaudeHome) {
|
|
154
|
+
return path.join(path.dirname(legacyClaudeHome), HOST_MCP_CONFIG_FILE);
|
|
155
|
+
}
|
|
156
|
+
/** No servers from anywhere: `mcpServers: own` + an empty block, and where tests start. */
|
|
157
|
+
export const EMPTY_MCP_SOURCES = Object.freeze({
|
|
158
|
+
user: {},
|
|
159
|
+
byDir: {},
|
|
160
|
+
declared: {},
|
|
161
|
+
caveats: [],
|
|
162
|
+
});
|
|
163
|
+
/** The allowlist entry that lets a server's tools actually be called. */
|
|
164
|
+
export function mcpToolPattern(name) {
|
|
165
|
+
return `mcp__${name}__*`;
|
|
166
|
+
}
|
|
167
|
+
function isRecord(v) {
|
|
168
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Narrow one declared server to what herdctl can carry, recording what was lost.
|
|
172
|
+
*
|
|
173
|
+
* Returns `undefined` for a server that cannot be started at all — no `command`
|
|
174
|
+
* and no `url` — which is the only case where dropping beats degrading.
|
|
175
|
+
*/
|
|
176
|
+
function narrowServer(name, raw, caveats) {
|
|
177
|
+
if (!isRecord(raw)) {
|
|
178
|
+
caveats.push({ name, kind: "dropped", reason: "its declaration is not a JSON object" });
|
|
179
|
+
return undefined;
|
|
180
|
+
}
|
|
181
|
+
const server = {};
|
|
182
|
+
if (typeof raw.command === "string" && raw.command !== "")
|
|
183
|
+
server.command = raw.command;
|
|
184
|
+
if (Array.isArray(raw.args) && raw.args.every((a) => typeof a === "string")) {
|
|
185
|
+
if (raw.args.length > 0)
|
|
186
|
+
server.args = [...raw.args];
|
|
187
|
+
}
|
|
188
|
+
if (isRecord(raw.env)) {
|
|
189
|
+
const env = {};
|
|
190
|
+
for (const [k, v] of Object.entries(raw.env))
|
|
191
|
+
if (typeof v === "string")
|
|
192
|
+
env[k] = v;
|
|
193
|
+
if (Object.keys(env).length > 0)
|
|
194
|
+
server.env = env;
|
|
195
|
+
}
|
|
196
|
+
if (typeof raw.url === "string" && raw.url !== "")
|
|
197
|
+
server.url = raw.url;
|
|
198
|
+
if (server.command === undefined && server.url === undefined) {
|
|
199
|
+
caveats.push({
|
|
200
|
+
name,
|
|
201
|
+
kind: "dropped",
|
|
202
|
+
reason: "it declares neither a `command` nor a `url`, so there is nothing to start",
|
|
203
|
+
});
|
|
204
|
+
return undefined;
|
|
205
|
+
}
|
|
206
|
+
// The lossy bits. `type` and `headers` are real keys of Claude Code's own MCP
|
|
207
|
+
// config that herdctl's schema has no field for and therefore strips; saying so
|
|
208
|
+
// is the difference between "my Notion server does not work" and "my Notion
|
|
209
|
+
// server's bearer header was dropped, here is the issue to file".
|
|
210
|
+
if (isRecord(raw.headers) && Object.keys(raw.headers).length > 0) {
|
|
211
|
+
caveats.push({
|
|
212
|
+
name,
|
|
213
|
+
kind: "degraded",
|
|
214
|
+
reason: `its \`headers\` (${Object.keys(raw.headers).join(", ")}) cannot be carried — ` +
|
|
215
|
+
`herdctl's MCP schema has no field for them, so the server is passed WITHOUT them, ` +
|
|
216
|
+
`any auth they held is gone, and its stored OAuth token (keyed on a hash that ` +
|
|
217
|
+
`includes the headers) will not be found either`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
if (raw.type === "sse") {
|
|
221
|
+
caveats.push({
|
|
222
|
+
name,
|
|
223
|
+
kind: "degraded",
|
|
224
|
+
reason: 'it is an `sse` server, and herdctl maps every `url` to `type: "http"` — it will ' +
|
|
225
|
+
"be connected to as HTTP, and any OAuth token stored against the `sse` shape " +
|
|
226
|
+
"will not be found",
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return server;
|
|
230
|
+
}
|
|
231
|
+
function serversOf(raw, caveats) {
|
|
232
|
+
if (!isRecord(raw))
|
|
233
|
+
return {};
|
|
234
|
+
const out = {};
|
|
235
|
+
for (const [name, decl] of Object.entries(raw)) {
|
|
236
|
+
const server = narrowServer(name, decl, caveats);
|
|
237
|
+
if (server)
|
|
238
|
+
out[name] = server;
|
|
239
|
+
}
|
|
240
|
+
return out;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Parse a `.claude.json` into the two server scopes. Pure, so the whole
|
|
244
|
+
* selection is testable from a string with no filesystem and no real home.
|
|
245
|
+
*
|
|
246
|
+
* Fails soft in both directions on purpose. Unparseable JSON, a non-object top
|
|
247
|
+
* level, a `projects` value that is not a map — none of them throw, because the
|
|
248
|
+
* user's `.claude.json` is a file paddock does not own, cannot validate and must
|
|
249
|
+
* never turn into a boot failure. What it does instead is return nothing and let
|
|
250
|
+
* the caller say so.
|
|
251
|
+
*/
|
|
252
|
+
export function parseHostMcpConfig(raw) {
|
|
253
|
+
const caveats = [];
|
|
254
|
+
let parsed;
|
|
255
|
+
try {
|
|
256
|
+
parsed = JSON.parse(raw);
|
|
257
|
+
}
|
|
258
|
+
catch {
|
|
259
|
+
return { user: {}, byDir: {}, declared: {}, caveats };
|
|
260
|
+
}
|
|
261
|
+
if (!isRecord(parsed))
|
|
262
|
+
return { user: {}, byDir: {}, declared: {}, caveats };
|
|
263
|
+
const user = serversOf(parsed.mcpServers, caveats);
|
|
264
|
+
const byDir = {};
|
|
265
|
+
if (isRecord(parsed.projects)) {
|
|
266
|
+
for (const [dir, entry] of Object.entries(parsed.projects)) {
|
|
267
|
+
if (!isRecord(entry))
|
|
268
|
+
continue;
|
|
269
|
+
const servers = serversOf(entry.mcpServers, caveats);
|
|
270
|
+
if (Object.keys(servers).length > 0)
|
|
271
|
+
byDir[dir] = servers;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
return { user, byDir, declared: {}, caveats };
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* The servers that apply to one agent's working directory.
|
|
278
|
+
*
|
|
279
|
+
* Precedence, weakest first: host user scope, host directory scope, then this
|
|
280
|
+
* instance's own `mcpServers:` block.
|
|
281
|
+
*
|
|
282
|
+
* Host per-directory wins over host user scope because it is the more specific
|
|
283
|
+
* declaration, and it is the one `claude mcp add` writes by default, so a user
|
|
284
|
+
* who re-declared a server inside a project meant that one. {@link
|
|
285
|
+
* McpSources.declared} wins over both because it is a statement about THIS
|
|
286
|
+
* instance while `~/.claude.json` is ambient machine state — a user who wrote a
|
|
287
|
+
* server into paddock's own config was answering "what should paddock have?",
|
|
288
|
+
* which is a narrower question than "what does this machine have?".
|
|
289
|
+
*
|
|
290
|
+
* The host lookup is exact rather than a prefix walk. `.claude.json`'s `projects`
|
|
291
|
+
* keys are the literal cwd Claude Code was started in, and Claude Code does not
|
|
292
|
+
* inherit a parent directory's entry either — matching that keeps paddock's
|
|
293
|
+
* answer the same as the terminal's, which is the whole point of `host`.
|
|
294
|
+
*/
|
|
295
|
+
export function mcpServersFor(source, workingDir) {
|
|
296
|
+
return { ...source.user, ...(source.byDir[workingDir] ?? {}), ...source.declared };
|
|
297
|
+
}
|
|
298
|
+
/** Every directory-scoped key, for the boot notice. */
|
|
299
|
+
export function hostMcpScopedDirs(source) {
|
|
300
|
+
return Object.keys(source.byDir);
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Read the host's `.claude.json` — once, at boot — and say what came of it.
|
|
304
|
+
*
|
|
305
|
+
* Under `own` the file is not opened at all. That is not an optimisation: "own
|
|
306
|
+
* everywhere means nothing outside the data dir is read" is the guarantee #691
|
|
307
|
+
* exists to make sayable, and a lever that read the file and then discarded it
|
|
308
|
+
* would make the guarantee false in the only place anyone could check.
|
|
309
|
+
*
|
|
310
|
+
* Read once rather than per turn, for the same reason `claude.hooks` materialises
|
|
311
|
+
* its `settings.json` once: a `.claude.json` that grows a server mid-run is picked
|
|
312
|
+
* up at the next restart, and the boot notice is where a user looks to confirm
|
|
313
|
+
* what this instance actually has.
|
|
314
|
+
*/
|
|
315
|
+
export async function loadHostMcpSource(cfg) {
|
|
316
|
+
if (cfg.claude.mcpServers !== "host")
|
|
317
|
+
return { source: EMPTY_MCP_SOURCES, notices: [] };
|
|
318
|
+
const file = hostMcpConfigPath(cfg.legacyClaudeHome);
|
|
319
|
+
const notices = [];
|
|
320
|
+
let raw;
|
|
321
|
+
try {
|
|
322
|
+
raw = await fs.readFile(file, "utf8");
|
|
323
|
+
}
|
|
324
|
+
catch (err) {
|
|
325
|
+
notices.push({
|
|
326
|
+
level: "info",
|
|
327
|
+
message: `\`claude.mcpServers: host\` is set but ${file} could not be read (${String(err)}) — ` +
|
|
328
|
+
`no host MCP servers are attached. That file is where \`claude mcp add\` writes; ` +
|
|
329
|
+
`it does not exist until you have added one.`,
|
|
330
|
+
});
|
|
331
|
+
return { source: EMPTY_MCP_SOURCES, notices };
|
|
332
|
+
}
|
|
333
|
+
const source = parseHostMcpConfig(raw);
|
|
334
|
+
const userNames = Object.keys(source.user);
|
|
335
|
+
const scoped = hostMcpScopedDirs(source);
|
|
336
|
+
const total = userNames.length + scoped.reduce((n, d) => n + Object.keys(source.byDir[d]).length, 0);
|
|
337
|
+
if (total === 0) {
|
|
338
|
+
notices.push({
|
|
339
|
+
level: "info",
|
|
340
|
+
message: `\`claude.mcpServers: host\`: no MCP servers are declared in ${file} ` +
|
|
341
|
+
`(neither at the top level nor under any \`projects\` entry).`,
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
notices.push({
|
|
346
|
+
level: "info",
|
|
347
|
+
message: `Claude MCP servers: host (\`claude.mcpServers: host\`) — ` +
|
|
348
|
+
(userNames.length > 0 ? `user scope: ${userNames.join(", ")}` : "no user-scope servers") +
|
|
349
|
+
(scoped.length > 0
|
|
350
|
+
? `; directory scope: ${scoped
|
|
351
|
+
.map((d) => `${d} (${Object.keys(source.byDir[d]).join(", ")})`)
|
|
352
|
+
.join(", ")}`
|
|
353
|
+
: "") +
|
|
354
|
+
`. A project's keeper gets the user-scope servers plus any scoped to its own ` +
|
|
355
|
+
`working directory.`,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
for (const caveat of source.caveats) {
|
|
359
|
+
notices.push({
|
|
360
|
+
level: "warn",
|
|
361
|
+
message: caveat.kind === "dropped"
|
|
362
|
+
? `MCP server "${caveat.name}" in ${file} was NOT attached: ${caveat.reason}.`
|
|
363
|
+
: `MCP server "${caveat.name}" from ${file} is attached but degraded: ${caveat.reason}.`,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
return { source, notices };
|
|
367
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `claude.hooks` lever (#691): whether this instance runs the host machine's
|
|
3
|
+
* Claude Code hooks.
|
|
4
|
+
*
|
|
5
|
+
* This is the one lever in the `claude:` block that is about code execution
|
|
6
|
+
* rather than data. A hook is a shell command Claude Code runs on `PreToolUse`,
|
|
7
|
+
* `PostToolUse`, `SessionStart` and friends, and since #620 paddock has symlinked
|
|
8
|
+
* the user's `~/.claude/settings.json` into its own home unconditionally — so
|
|
9
|
+
* every hook the user has ever configured fires inside every paddock turn, with
|
|
10
|
+
* no key to turn it off. "Isolate it by just trying paddock" was not true, and
|
|
11
|
+
* this is why.
|
|
12
|
+
*
|
|
13
|
+
* ## Why this cannot be a symlink decision
|
|
14
|
+
*
|
|
15
|
+
* `settings.json` is a mixed bag. It carries `hooks`, and also `permissions`,
|
|
16
|
+
* `model`, `statusLine`, `enabledPlugins`, `env`, `outputStyle` — the things a
|
|
17
|
+
* user configures once and expects everywhere. A symlink is all-or-nothing and
|
|
18
|
+
* the file is not, so `hooks: own` has to leave the rest inherited while dropping
|
|
19
|
+
* one key.
|
|
20
|
+
*
|
|
21
|
+
* So paddock WRITES `<ownHome>/settings.json` itself: parse the user's, drop
|
|
22
|
+
* {@link HOST_ONLY_SETTINGS_KEYS}, serialise the remainder. `hooks: host` is the
|
|
23
|
+
* old symlink, unchanged.
|
|
24
|
+
*
|
|
25
|
+
* ### The staleness this buys, which is real
|
|
26
|
+
*
|
|
27
|
+
* A copy diverges the moment the user edits theirs; a symlink does not — and
|
|
28
|
+
* **that difference is live, not just at the next process start.** Claude Code
|
|
29
|
+
* runs a settings-change watcher (~1s poll + 500ms debounce) that follows the
|
|
30
|
+
* symlink to its real target, clears its whole settings memo and re-applies the
|
|
31
|
+
* result mid-session; verified by mutating a symlink target under a running
|
|
32
|
+
* `query()` and watching `Detected change to … (via symlink target …)` come back.
|
|
33
|
+
* So the honest statement is that a symlink propagates an edit in about a second
|
|
34
|
+
* and the copy propagates it at the next paddock restart. Two things make that
|
|
35
|
+
* an acceptable price rather than a design flaw:
|
|
36
|
+
*
|
|
37
|
+
* 1. **Only users who actually have hooks pay it.** {@link planHostSettings}
|
|
38
|
+
* returns `link` when the user's file has none of the governed keys, which is
|
|
39
|
+
* most files — nothing to filter, so nothing is copied and there is nothing to
|
|
40
|
+
* go stale. The copy exists exactly where filtering is doing work.
|
|
41
|
+
* 2. **It is regenerated on every boot** (`ensureClaudeHome`), so a restart is
|
|
42
|
+
* the whole of the fix, and paddock says so in the boot notice.
|
|
43
|
+
*
|
|
44
|
+
* A file-watcher on the user's file, re-running the filter, would close the gap
|
|
45
|
+
* — and because Claude Code's own watcher fires on content change regardless of
|
|
46
|
+
* who wrote it, paddock re-writing the copy WOULD be picked up live. That is a
|
|
47
|
+
* real option if the cadence ever matters. It is not taken here: it puts a
|
|
48
|
+
* watcher on a path in the user's home, races paddock's own write against the
|
|
49
|
+
* 5s echo-suppression window Claude Code uses for exactly this, and buys a
|
|
50
|
+
* narrower window on a file people edit a handful of times a year.
|
|
51
|
+
*
|
|
52
|
+
* ### Which turns this actually governs today, which is not all of them
|
|
53
|
+
*
|
|
54
|
+
* `<claudeHome>/settings.json` is Claude Code's `userSettings` source, and
|
|
55
|
+
* **herdctl does not load that source on the SDK runtime.** `sdk-adapter.js`
|
|
56
|
+
* defaults `settingSources` to `["project"]` for any agent with a
|
|
57
|
+
* `working_directory` — which is every paddock keeper — so the SDK is invoked
|
|
58
|
+
* with `--setting-sources=project` and never reads this file at all. The CLI
|
|
59
|
+
* runtime passes `--setting-sources` only when an agent declares
|
|
60
|
+
* `setting_sources`, and paddock declares none, so `claude -p` runs on the
|
|
61
|
+
* default `["user","project","local"]` and DOES read it.
|
|
62
|
+
*
|
|
63
|
+
* So the host's hooks execute in the sweeper, in triggers, and in
|
|
64
|
+
* `driveMode: batch` chats — and not, today, in a default SDK chat turn. That
|
|
65
|
+
* makes this lever narrower than #691 assumed, and worth keeping regardless:
|
|
66
|
+
* those paths are real code execution, the asymmetry is a herdctl default rather
|
|
67
|
+
* than a guarantee, and a lever that is correct before the default changes is
|
|
68
|
+
* the only kind worth having. The same caveat applies to the keys it KEEPS —
|
|
69
|
+
* the user's `permissions` and `model` reach the CLI paths and not the SDK ones,
|
|
70
|
+
* which was true before this change too.
|
|
71
|
+
*
|
|
72
|
+
* ## What `own` does NOT stop, and should
|
|
73
|
+
*
|
|
74
|
+
* `hooks` is not the only key in `settings.json` that names a command to run.
|
|
75
|
+
* Read straight out of the SDK's own settings schema
|
|
76
|
+
* (`@anthropic-ai/claude-agent-sdk/sdk.mjs`), these do too:
|
|
77
|
+
*
|
|
78
|
+
* - `apiKeyHelper` — "Path to a script that outputs authentication values"
|
|
79
|
+
* - `awsCredentialExport` — "Path to a script that exports AWS credentials"
|
|
80
|
+
* - `awsAuthRefresh` — "Path to a script that refreshes AWS authentication"
|
|
81
|
+
* - `gcpAuthRefresh` — "Command to refresh GCP authentication"
|
|
82
|
+
* - `proxyAuthHelper` — "Shell command that outputs a Proxy-Authorization header"
|
|
83
|
+
* - `otelHeadersHelper` — "Path to a script that outputs OpenTelemetry headers"
|
|
84
|
+
* - `statusLine`, `subagentStatusLine`, `fileSuggestion` — `{type:"command", …}`
|
|
85
|
+
*
|
|
86
|
+
* #691 scopes this lever to `hooks`, so `hooks` is what {@link
|
|
87
|
+
* HOST_ONLY_SETTINGS_KEYS} contains — widening a default-on security lever past
|
|
88
|
+
* its brief is the maintainer's call, not this module's. It is one line when he
|
|
89
|
+
* makes it, and the trade-off is not obvious in either direction: the first five
|
|
90
|
+
* of those are how a user's login WORKS on a corporate setup, so dropping them
|
|
91
|
+
* under a key named `hooks` would break authentication for exactly the people who
|
|
92
|
+
* did nothing wrong — an argument that they belong under `claude.credentials`
|
|
93
|
+
* rather than here. Until someone decides, `hooks: own` means "no host hooks",
|
|
94
|
+
* not "no host commands", and this comment is the difference.
|
|
95
|
+
*/
|
|
96
|
+
/**
|
|
97
|
+
* Whether this instance runs the host machine's Claude Code hooks — the
|
|
98
|
+
* `claude.hooks` key.
|
|
99
|
+
*
|
|
100
|
+
* `own` (default) = paddock writes a `settings.json` carrying the user's other
|
|
101
|
+
* keys with `hooks` dropped. `host` = the user's `~/.claude/settings.json`
|
|
102
|
+
* symlinked in whole, hooks included, which is what every version before this
|
|
103
|
+
* one did.
|
|
104
|
+
*/
|
|
105
|
+
export type HooksMode = "own" | "host";
|
|
106
|
+
/**
|
|
107
|
+
* Isolation is the default, and here it is the only defensible one: `host` means
|
|
108
|
+
* arbitrary commands from outside the data dir execute inside paddock's turns,
|
|
109
|
+
* and that is not something a user should opt out of after the fact.
|
|
110
|
+
*/
|
|
111
|
+
export declare const DEFAULT_HOOKS_MODE: HooksMode;
|
|
112
|
+
/** Type guard, so an unknown config value falls back instead of failing a boot. */
|
|
113
|
+
export declare function isKnownHooksMode(value: string): value is HooksMode;
|
|
114
|
+
/** The Claude home entry this lever filters. */
|
|
115
|
+
export declare const SETTINGS_ENTRY = "settings.json";
|
|
116
|
+
/**
|
|
117
|
+
* Keys of `settings.json` that `hooks: own` drops. See the module doc for the
|
|
118
|
+
* several others that arguably belong here and are deliberately not.
|
|
119
|
+
*/
|
|
120
|
+
export declare const HOST_ONLY_SETTINGS_KEYS: readonly ["hooks"];
|
|
121
|
+
/**
|
|
122
|
+
* Where paddock records the SHA-256 of each file it generated in its own home,
|
|
123
|
+
* so a later boot can tell its own output from something a human put there.
|
|
124
|
+
*
|
|
125
|
+
* A marker key inside `settings.json` would need no second file, but the schema
|
|
126
|
+
* is Claude Code's and adding a key to it is asking for a validation warning on
|
|
127
|
+
* some future version. A hash sidecar is inert by construction.
|
|
128
|
+
*/
|
|
129
|
+
export declare const GENERATED_MARKER_FILE = ".paddock-generated.json";
|
|
130
|
+
/** What {@link planHostSettings} decided to do with the user's settings file. */
|
|
131
|
+
export type SettingsPlan =
|
|
132
|
+
/** Nothing governed is present: symlink it, exactly as `host` would. */
|
|
133
|
+
{
|
|
134
|
+
action: "link";
|
|
135
|
+
}
|
|
136
|
+
/** Write this content into paddock's home instead of linking. */
|
|
137
|
+
| {
|
|
138
|
+
action: "write";
|
|
139
|
+
content: string;
|
|
140
|
+
dropped: string[];
|
|
141
|
+
}
|
|
142
|
+
/** Unusable: plant nothing, and say why. Fail closed, never link. */
|
|
143
|
+
| {
|
|
144
|
+
action: "skip";
|
|
145
|
+
reason: string;
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* Decide what paddock's own `settings.json` should be, given the user's.
|
|
149
|
+
*
|
|
150
|
+
* Pure, so the whole decision is testable without a filesystem.
|
|
151
|
+
*
|
|
152
|
+
* The `skip` case is the security-relevant one. A `settings.json` that will not
|
|
153
|
+
* parse cannot be filtered, and the safe response is to plant NOTHING rather than
|
|
154
|
+
* fall back to a symlink — a symlink would hand over the hooks this lever exists
|
|
155
|
+
* to withhold, and it would do it precisely when something is already wrong.
|
|
156
|
+
* Claude Code will not be able to read that file either, so the instance loses
|
|
157
|
+
* nothing it would otherwise have had.
|
|
158
|
+
*/
|
|
159
|
+
export declare function planHostSettings(mode: HooksMode, raw: string): SettingsPlan;
|