@edspencer/paddock 0.61.1 → 0.63.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 +65 -16
- package/package.json +2 -2
- package/packages/server/dist/app.js +48 -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 +158 -0
- package/packages/server/dist/claude-mcp.js +389 -0
- package/packages/server/dist/claude-plugins.d.ts +131 -0
- package/packages/server/dist/claude-plugins.js +394 -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 +46 -19
- 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 +17 -1
- package/packages/server/dist/herdctl-agent-config.js +69 -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 +75 -13
- package/packages/server/dist/herdctl.js +77 -18
- package/packages/server/dist/instance-config.js +36 -0
- package/packages/server/dist/mcp-servers.d.ts +187 -0
- package/packages/server/dist/mcp-servers.js +399 -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-CCk2hVT9.js} +1 -1
- package/packages/web/dist/assets/{MarkdownRenderer-DfD_UtqU.js → MarkdownRenderer-B6gST_0U.js} +1 -1
- package/packages/web/dist/assets/{ProjectRedirect-1EXdHXmO.js → ProjectRedirect-BCmKT4mf.js} +1 -1
- package/packages/web/dist/assets/{ProjectView-DHb5vNUp.js → ProjectView-CCN98MWu.js} +3 -3
- package/packages/web/dist/assets/{ProjectsGrid-Br-EhLZE.js → ProjectsGrid-CYzuN_yD.js} +1 -1
- package/packages/web/dist/assets/{arc-C2Jekujs.js → arc-BxvtMCZQ.js} +1 -1
- package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CflCA-em.js → architectureDiagram-3BPJPVTR-Bp6ffJ_u.js} +1 -1
- package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-Ds7GLNnC.js → blockDiagram-GPEHLZMM-BIY2X8nW.js} +1 -1
- package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-D4WyGO6I.js → c4Diagram-AAUBKEIU-Br9stEJS.js} +1 -1
- package/packages/web/dist/assets/channel-CMdgNeFH.js +2 -0
- package/packages/web/dist/assets/{chunk-2J33WTMH-tQunO_M5.js → chunk-2J33WTMH-Djc7uqMV.js} +1 -1
- package/packages/web/dist/assets/{chunk-4BX2VUAB-DT5dOWd2.js → chunk-4BX2VUAB-yEVMLksS.js} +1 -1
- package/packages/web/dist/assets/{chunk-55IACEB6-CfTUqUEF.js → chunk-55IACEB6-tJaEdPZg.js} +1 -1
- package/packages/web/dist/assets/{chunk-727SXJPM-sK5bOAA0.js → chunk-727SXJPM-bWEqh672.js} +1 -1
- package/packages/web/dist/assets/{chunk-AQP2D5EJ-DcMgVe-P.js → chunk-AQP2D5EJ-B8U9fB-T.js} +1 -1
- package/packages/web/dist/assets/{chunk-FMBD7UC4-In3h_-7v.js → chunk-FMBD7UC4-BeEITYig.js} +1 -1
- package/packages/web/dist/assets/{chunk-ND2GUHAM-DuaIeRaC.js → chunk-ND2GUHAM-L2rpLaoK.js} +1 -1
- package/packages/web/dist/assets/{chunk-QZHKN3VN-DeT5mR5T.js → chunk-QZHKN3VN-qqzPMZL_.js} +1 -1
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU4HSESS.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU4HSESS.js +2 -0
- package/packages/web/dist/assets/{core-DgOgLvnM.js → core-CSaNbmLO.js} +1 -1
- package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-CyO-rznJ.js → cose-bilkent-S5V4N54A-BRF7Nech.js} +1 -1
- package/packages/web/dist/assets/{dagre-BM42HDAG-B_9Ihzgn.js → dagre-BM42HDAG-D10rJWOR.js} +1 -1
- package/packages/web/dist/assets/{diagram-2AECGRRQ-z5mvZ5YT.js → diagram-2AECGRRQ-DGjT5mAO.js} +1 -1
- package/packages/web/dist/assets/{diagram-5GNKFQAL-Bsz0cgfn.js → diagram-5GNKFQAL-DaH3bxHh.js} +1 -1
- package/packages/web/dist/assets/{diagram-KO2AKTUF-B29k5f4q.js → diagram-KO2AKTUF-QrLW9Wn6.js} +1 -1
- package/packages/web/dist/assets/{diagram-LMA3HP47-CFrwsHoI.js → diagram-LMA3HP47-BX15wgYN.js} +1 -1
- package/packages/web/dist/assets/{diagram-OG6HWLK6-D9Sjc2HR.js → diagram-OG6HWLK6-DXQ_p63s.js} +1 -1
- package/packages/web/dist/assets/{erDiagram-TEJ5UH35-C3vbkD7M.js → erDiagram-TEJ5UH35-C_ioRruE.js} +1 -1
- package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-DkMIgntd.js → flowDiagram-I6XJVG4X-7d-xLXLM.js} +1 -1
- package/packages/web/dist/assets/{format-BFDqyFuw.js → format-BSDZezwD.js} +1 -1
- package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-nvHanjpW.js → ganttDiagram-6RSMTGT7-DZYx5xcj.js} +1 -1
- package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-DbCNfOu7.js → gitGraphDiagram-PVQCEYII-dimo-osm.js} +1 -1
- package/packages/web/dist/assets/{index-DGCs1YIO.js → index-CUnFKMga.js} +2 -2
- package/packages/web/dist/assets/{infoDiagram-5YYISTIA-aJjH9x3D.js → infoDiagram-5YYISTIA-BzApY25x.js} +1 -1
- package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DoifwNnZ.js → ishikawaDiagram-YF4QCWOH-DtX47_9p.js} +1 -1
- package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-Cz9kG9HN.js → journeyDiagram-JHISSGLW-C7fITKg1.js} +1 -1
- package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BoXnfr7A.js → kanban-definition-UN3LZRKU-BOrWLf_W.js} +1 -1
- package/packages/web/dist/assets/{linear-BlTTO9yR.js → linear-DuyAaBu6.js} +1 -1
- package/packages/web/dist/assets/{mermaid.core-YAG8eSPQ.js → mermaid.core-BQlzuVvO.js} +4 -4
- package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-DtUBKfkr.js → mindmap-definition-RKZ34NQL-CmP748B5.js} +1 -1
- package/packages/web/dist/assets/{pieDiagram-4H26LBE5-B3Rwpmy0.js → pieDiagram-4H26LBE5-CRd5Pdfq.js} +1 -1
- package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-B8eJtTyk.js → quadrantDiagram-W4KKPZXB-DpJm0Yw_.js} +1 -1
- package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CpAluiY9.js → requirementDiagram-4Y6WPE33-B0o2PRMC.js} +1 -1
- package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-Cl_Rd1tN.js → sankeyDiagram-5OEKKPKP-CBi3Ek3v.js} +1 -1
- package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-CRpqKSGm.js → sequenceDiagram-3UESZ5HK-C1stxfQg.js} +1 -1
- package/packages/web/dist/assets/{stateDiagram-AJRCARHV-h3h8gXv_.js → stateDiagram-AJRCARHV-lCkiETWK.js} +1 -1
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-ZgEZRNqp.js +2 -0
- package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-PVr5V_pG.js → timeline-definition-PNZ67QCA-D3DkWJMC.js} +1 -1
- package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-De-4NPAX.js → vennDiagram-CIIHVFJN-CO2-yC7K.js} +1 -1
- package/packages/web/dist/assets/{wardley-L42UT6IY-Dtv10D2d.js → wardley-L42UT6IY-Dfjp0kqX.js} +1 -1
- package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-p2fJuQ6C.js → wardleyDiagram-YWT4CUSO-CIl0OPwS.js} +1 -1
- package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-DyYF-k92.js → xychartDiagram-2RQKCTM6-CuvlQuup.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,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host Claude Code **plugins**, handed to each keeper as `agent.plugins` (#700).
|
|
3
|
+
*
|
|
4
|
+
* The half of MCP inheritance that `claude-mcp.ts` structurally could not reach.
|
|
5
|
+
* A plugin bundles commands, agents, skills, hooks and — the reported symptom —
|
|
6
|
+
* MCP servers, and none of it is declared in `~/.claude.json`, so reading that
|
|
7
|
+
* file finds nothing however carefully it is read.
|
|
8
|
+
*
|
|
9
|
+
* ## Why the symlink was not enough
|
|
10
|
+
*
|
|
11
|
+
* `plugins/` IS bridged into paddock's home (under `claude.instructions: host`,
|
|
12
|
+
* see `claude-home.ts`) and the SDK really does auto-discover plugins under
|
|
13
|
+
* `join(CLAUDE_CONFIG_DIR, "plugins")` — two facts that together made this look
|
|
14
|
+
* fixed for months. It is not, because discovery of an *installed* plugin is
|
|
15
|
+
* driven by the `enabledPlugins` key, and `enabledPlugins` lives in the **user**
|
|
16
|
+
* settings source, which herdctl does not load: every agent with a working
|
|
17
|
+
* directory is invoked with `setting_sources: ["project"]`. So the plugin is
|
|
18
|
+
* found and then never enabled, silently.
|
|
19
|
+
*
|
|
20
|
+
* herdctl 5.32.0 (herdctl#446, closing herdctl#444) adds the channel this needs:
|
|
21
|
+
* an optional `plugins` array on the agent config, translated to the SDK's own
|
|
22
|
+
* `plugins` option and to `--plugin-dir` on the CLI runtime. A plugin passed that
|
|
23
|
+
* way is a *session* plugin, and — read out of the CLI bundle rather than
|
|
24
|
+
* assumed — a session plugin's enablement is
|
|
25
|
+
* `enabledPlugins[`${name}@inline`] ?? manifest.defaultEnabled !== false`, i.e.
|
|
26
|
+
* **enabled by default**, with no settings-source grant required. That is why the
|
|
27
|
+
* passthrough works where the symlink did not, and why herdctl#446 was right to
|
|
28
|
+
* refuse to widen `setting_sources` instead.
|
|
29
|
+
*
|
|
30
|
+
* ## Which lever gates this, which is NOT the one #700 assumes
|
|
31
|
+
*
|
|
32
|
+
* #700 (and the brief written from it) scope plugin enumeration to
|
|
33
|
+
* `claude.mcpServers: host`. That is half right, and shipping it that way would
|
|
34
|
+
* have punched a hole through a different lever:
|
|
35
|
+
*
|
|
36
|
+
* - `plugins/` is bridged by **`claude.instructions`**, alongside `CLAUDE.md`,
|
|
37
|
+
* `agents/` and `commands/` — because most of a plugin IS instructions. Under
|
|
38
|
+
* `instructions: own` paddock withdraws that symlink and prints a notice saying
|
|
39
|
+
* the host's plugins are not loaded. Passing them anyway because a *different*
|
|
40
|
+
* key says `host` would contradict a notice paddock itself emits.
|
|
41
|
+
* - the MCP servers inside a plugin are what `claude.mcpServers` is about.
|
|
42
|
+
*
|
|
43
|
+
* So both levers apply, to the two halves of a plugin, and the SDK has exactly
|
|
44
|
+
* the flag needed to split them: `skipMcpDiscovery` loads a plugin's
|
|
45
|
+
* skills/hooks/agents/commands but does NOT read its `.mcp.json` or manifest
|
|
46
|
+
* `mcpServers`. Hence {@link enumerateHostPlugins}:
|
|
47
|
+
*
|
|
48
|
+
* | `instructions` | `mcpServers` | result |
|
|
49
|
+
* |---|---|---|
|
|
50
|
+
* | `host` | `host` | plugins passed whole |
|
|
51
|
+
* | `host` | `own` | plugins passed with `skipMcpDiscovery: true` |
|
|
52
|
+
* | `own` | *any* | no plugins, and a notice saying which key turns them on |
|
|
53
|
+
*
|
|
54
|
+
* ## The allowlist, which is where this gets uncomfortable
|
|
55
|
+
*
|
|
56
|
+
* Both runtimes auto-deny any tool missing from an explicit `allowed_tools`, with
|
|
57
|
+
* no prompt and nothing in the logs — the trap `claude-mcp.ts` documents at
|
|
58
|
+
* length. A plugin-provided MCP server needs the same widening, so paddock has to
|
|
59
|
+
* know its tool prefix *before* the plugin has ever run.
|
|
60
|
+
*
|
|
61
|
+
* It is knowable, from the CLI bundle: a plugin's servers are registered under
|
|
62
|
+
* `` `plugin:${pluginName}:${serverName}` ``, and a server name is normalised
|
|
63
|
+
* with `[^a-zA-Z0-9_-] → _` before it becomes a tool prefix. So the pattern is
|
|
64
|
+
* `mcp__plugin_<plugin>_<server>__*` — matching the SDK's own documented example,
|
|
65
|
+
* `mcp__plugin_documents_docs__doc_export`. {@link pluginMcpToolPattern} is that
|
|
66
|
+
* one line, and {@link readPluginServerNames} recovers `<server>` by reading the
|
|
67
|
+
* two places a plugin declares servers statically: `<dir>/.mcp.json` and an
|
|
68
|
+
* inline `mcpServers` object in `<dir>/.claude-plugin/plugin.json`.
|
|
69
|
+
*
|
|
70
|
+
* What it CANNOT recover is a manifest whose `mcpServers` is a string or an array
|
|
71
|
+
* of strings — a pointer to another file, or to an MCPB source the CLI downloads.
|
|
72
|
+
* Resolving a same-directory JSON pointer is easy and is done; anything else is
|
|
73
|
+
* recorded as a caveat and warned about at boot, naming the plugin, because the
|
|
74
|
+
* failure it produces otherwise is the silent one: the plugin loads, its server
|
|
75
|
+
* connects, and every tool call is denied with no log line. Better a warning that
|
|
76
|
+
* says "add `mcp__plugin_x_y__*` yourself" than a mystery.
|
|
77
|
+
*
|
|
78
|
+
* ## What is read, and what is not verified
|
|
79
|
+
*
|
|
80
|
+
* Enumeration is from `<pluginRoot>/installed_plugins.json`, the CLI's own
|
|
81
|
+
* registry of installs (v2: `{plugins: {"<name>@<marketplace>": [{installPath,
|
|
82
|
+
* scope, …}]}}`; v1 keyed the same ids to a single record, whose path was derived
|
|
83
|
+
* as `<pluginRoot>/cache/<marketplace>/<name>/<version>`). Both are read. The
|
|
84
|
+
* host's `settings.json` `enabledPlugins` is consulted only to VETO — an id set
|
|
85
|
+
* to `false` is skipped — because a session plugin's own default is "enabled",
|
|
86
|
+
* so honouring the veto is the only thing that can respect a host's `/plugin
|
|
87
|
+
* disable`.
|
|
88
|
+
*
|
|
89
|
+
* None of this was validated against a machine with plugins installed: this box
|
|
90
|
+
* is Linux with an empty plugin root, and no test here proves a plugin *loads*.
|
|
91
|
+
* Every path, file name and naming rule above comes from reading the bundled CLI
|
|
92
|
+
* (`node_modules/@anthropic-ai/claude-agent-sdk-linux-x64/claude`), and the tests
|
|
93
|
+
* assert what config paddock produces from a planted directory tree, which is a
|
|
94
|
+
* strictly weaker claim. A `marketplace`-installed plugin loading via
|
|
95
|
+
* `type: "local"` remains unverified end-to-end and needs a real host.
|
|
96
|
+
*/
|
|
97
|
+
import { promises as fs } from "node:fs";
|
|
98
|
+
import path from "node:path";
|
|
99
|
+
/** Nothing inherited: `instructions: own`, and where tests start. */
|
|
100
|
+
export const EMPTY_HOST_PLUGINS = Object.freeze({
|
|
101
|
+
plugins: [],
|
|
102
|
+
toolPatterns: [],
|
|
103
|
+
caveats: [],
|
|
104
|
+
});
|
|
105
|
+
/** The plugin root, `join(CLAUDE_CONFIG_DIR, "plugins")` in the CLI's terms. */
|
|
106
|
+
export function hostPluginRoot(legacyClaudeHome) {
|
|
107
|
+
return path.join(legacyClaudeHome, "plugins");
|
|
108
|
+
}
|
|
109
|
+
/** The CLI's own registry of what is installed, under {@link hostPluginRoot}. */
|
|
110
|
+
export const INSTALLED_PLUGINS_FILE = "installed_plugins.json";
|
|
111
|
+
/** A plugin's manifest, relative to its directory. */
|
|
112
|
+
export const PLUGIN_MANIFEST = path.join(".claude-plugin", "plugin.json");
|
|
113
|
+
/** A plugin's out-of-manifest MCP declaration, relative to its directory. */
|
|
114
|
+
export const PLUGIN_MCP_FILE = ".mcp.json";
|
|
115
|
+
/**
|
|
116
|
+
* The CLI's server-name normalisation: `[^a-zA-Z0-9_-] → _`, applied to the whole
|
|
117
|
+
* `plugin:<plugin>:<server>` name before it becomes a `mcp__…__` prefix. Applied
|
|
118
|
+
* to the joined string, not to the parts, so a `.` in a plugin name normalises
|
|
119
|
+
* the same way the separators do.
|
|
120
|
+
*/
|
|
121
|
+
function normalizeServerName(name) {
|
|
122
|
+
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* The allowlist pattern for one server of one plugin.
|
|
126
|
+
*
|
|
127
|
+
* Deliberately not `mcpToolPattern` from `claude-mcp.ts`: the name a plugin's
|
|
128
|
+
* server is registered under is not the name it is declared under, and that
|
|
129
|
+
* derivation is the whole content of this function.
|
|
130
|
+
*/
|
|
131
|
+
export function pluginMcpToolPattern(pluginName, serverName) {
|
|
132
|
+
return `mcp__${normalizeServerName(`plugin:${pluginName}:${serverName}`)}__*`;
|
|
133
|
+
}
|
|
134
|
+
function isRecord(v) {
|
|
135
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
136
|
+
}
|
|
137
|
+
async function readJson(file) {
|
|
138
|
+
try {
|
|
139
|
+
return JSON.parse(await fs.readFile(file, "utf8"));
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
return undefined;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* The server names one `.mcp.json`-shaped document declares.
|
|
147
|
+
*
|
|
148
|
+
* Claude Code accepts both `{mcpServers: {…}}` and a bare map at the top level,
|
|
149
|
+
* so both are read — the CLI does `parsed.mcpServers || parsed`.
|
|
150
|
+
*/
|
|
151
|
+
function serverNamesOf(doc) {
|
|
152
|
+
if (!isRecord(doc))
|
|
153
|
+
return [];
|
|
154
|
+
const servers = isRecord(doc.mcpServers) ? doc.mcpServers : doc;
|
|
155
|
+
return Object.keys(servers).filter((k) => isRecord(servers[k]));
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Read one plugin directory's declared MCP server names.
|
|
159
|
+
*
|
|
160
|
+
* Mirrors the CLI's own discovery order — `<dir>/.mcp.json` first, then whatever
|
|
161
|
+
* `manifest.mcpServers` adds — with the one deliberate gap described in the
|
|
162
|
+
* module doc: a manifest that points at an MCPB source (or at any path outside
|
|
163
|
+
* its own directory) is recorded in {@link PluginServerNames.unresolved} rather
|
|
164
|
+
* than fetched. Paddock does not download anything to build an allowlist.
|
|
165
|
+
*/
|
|
166
|
+
export async function readPluginServerNames(dir) {
|
|
167
|
+
const manifest = await readJson(path.join(dir, PLUGIN_MANIFEST));
|
|
168
|
+
const pluginName = isRecord(manifest) && typeof manifest.name === "string" && manifest.name.trim() !== ""
|
|
169
|
+
? manifest.name.trim()
|
|
170
|
+
: path.basename(dir);
|
|
171
|
+
const servers = new Set(serverNamesOf(await readJson(path.join(dir, PLUGIN_MCP_FILE))));
|
|
172
|
+
const unresolved = [];
|
|
173
|
+
const declared = isRecord(manifest) ? manifest.mcpServers : undefined;
|
|
174
|
+
// A string entry is a path relative to the plugin directory (or an MCPB
|
|
175
|
+
// source, which looks the same and is not resolvable without downloading it).
|
|
176
|
+
// Resolve it only when it stays inside the directory and parses as JSON;
|
|
177
|
+
// anything else is a gap paddock reports rather than guesses at.
|
|
178
|
+
const resolveRef = async (ref) => {
|
|
179
|
+
const target = path.resolve(dir, ref);
|
|
180
|
+
if (target !== dir && !target.startsWith(dir + path.sep)) {
|
|
181
|
+
unresolved.push(ref);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
const names = serverNamesOf(await readJson(target));
|
|
185
|
+
if (names.length === 0)
|
|
186
|
+
unresolved.push(ref);
|
|
187
|
+
else
|
|
188
|
+
for (const n of names)
|
|
189
|
+
servers.add(n);
|
|
190
|
+
};
|
|
191
|
+
if (typeof declared === "string")
|
|
192
|
+
await resolveRef(declared);
|
|
193
|
+
else if (Array.isArray(declared)) {
|
|
194
|
+
for (const entry of declared) {
|
|
195
|
+
if (typeof entry === "string")
|
|
196
|
+
await resolveRef(entry);
|
|
197
|
+
else
|
|
198
|
+
for (const n of serverNamesOf(entry))
|
|
199
|
+
servers.add(n);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
else if (isRecord(declared)) {
|
|
203
|
+
for (const n of serverNamesOf(declared))
|
|
204
|
+
servers.add(n);
|
|
205
|
+
}
|
|
206
|
+
return { pluginName, servers: [...servers], unresolved };
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Parse `installed_plugins.json`, v1 or v2. Pure, so the whole selection is
|
|
210
|
+
* testable from a string.
|
|
211
|
+
*
|
|
212
|
+
* v2 keys each `<name>@<marketplace>` id to an ARRAY of installations (one per
|
|
213
|
+
* scope), each carrying an explicit `installPath`. v1 keyed it to a single record
|
|
214
|
+
* with no path, which the CLI derives as
|
|
215
|
+
* `<root>/cache/<marketplace>/<name>/<version>` with each segment sanitised
|
|
216
|
+
* `[^a-zA-Z0-9\-_] → -` (and the version segment additionally allowing `.`).
|
|
217
|
+
*
|
|
218
|
+
* Fails soft in every direction: this is a file paddock does not own and must
|
|
219
|
+
* never turn into a boot failure.
|
|
220
|
+
*/
|
|
221
|
+
export function parseInstalledPlugins(raw, root) {
|
|
222
|
+
let parsed;
|
|
223
|
+
try {
|
|
224
|
+
parsed = JSON.parse(raw);
|
|
225
|
+
}
|
|
226
|
+
catch {
|
|
227
|
+
return [];
|
|
228
|
+
}
|
|
229
|
+
if (!isRecord(parsed) || !isRecord(parsed.plugins))
|
|
230
|
+
return [];
|
|
231
|
+
const out = [];
|
|
232
|
+
for (const [id, entry] of Object.entries(parsed.plugins)) {
|
|
233
|
+
if (Array.isArray(entry)) {
|
|
234
|
+
for (const install of entry) {
|
|
235
|
+
if (isRecord(install) && typeof install.installPath === "string" && install.installPath) {
|
|
236
|
+
out.push({ id, installPath: install.installPath });
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
else if (isRecord(entry)) {
|
|
241
|
+
const derived = legacyInstallPath(root, id, entry.version);
|
|
242
|
+
if (derived)
|
|
243
|
+
out.push({ id, installPath: derived });
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
/** The v1 path derivation, kept beside its only caller. */
|
|
249
|
+
function legacyInstallPath(root, id, version) {
|
|
250
|
+
if (typeof version !== "string" || version === "")
|
|
251
|
+
return undefined;
|
|
252
|
+
const at = id.lastIndexOf("@");
|
|
253
|
+
const name = at > 0 ? id.slice(0, at) : id;
|
|
254
|
+
const marketplace = at > 0 ? id.slice(at + 1) : "unknown";
|
|
255
|
+
const seg = (s) => s.replace(/[^a-zA-Z0-9\-_]/g, "-");
|
|
256
|
+
let ver = version.replace(/[^a-zA-Z0-9\-_.]/g, "-");
|
|
257
|
+
if (ver === "." || ver === "..")
|
|
258
|
+
ver = "-";
|
|
259
|
+
return path.join(root, "cache", seg(marketplace || "unknown"), seg(name), ver);
|
|
260
|
+
}
|
|
261
|
+
/** Ids the host has explicitly turned off, from its `settings.json`. */
|
|
262
|
+
function disabledIds(settings) {
|
|
263
|
+
const out = new Set();
|
|
264
|
+
if (!isRecord(settings) || !isRecord(settings.enabledPlugins))
|
|
265
|
+
return out;
|
|
266
|
+
for (const [id, value] of Object.entries(settings.enabledPlugins)) {
|
|
267
|
+
// Anything that is not an explicit `false` counts as enabled: the key also
|
|
268
|
+
// takes an extended object form carrying a version constraint, and a version
|
|
269
|
+
// constraint is not a disable.
|
|
270
|
+
if (value === false)
|
|
271
|
+
out.add(id.toLowerCase());
|
|
272
|
+
}
|
|
273
|
+
return out;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* Enumerate the host's installed plugin directories. Pure apart from reading the
|
|
277
|
+
* plugin tree, and takes the two files' contents rather than paths so the whole
|
|
278
|
+
* selection is testable without a home directory.
|
|
279
|
+
*/
|
|
280
|
+
export async function enumerateHostPlugins(opts) {
|
|
281
|
+
const caveats = [];
|
|
282
|
+
if (opts.installed === undefined)
|
|
283
|
+
return { plugins: [], toolPatterns: [], caveats };
|
|
284
|
+
const disabled = disabledIds(opts.settings);
|
|
285
|
+
const plugins = [];
|
|
286
|
+
const toolPatterns = new Set();
|
|
287
|
+
// One plugin can be recorded under several scopes, and two ids can in principle
|
|
288
|
+
// resolve to one directory. `--plugin-dir` the same path twice and the CLI loads
|
|
289
|
+
// it twice under one name, so collapse them here.
|
|
290
|
+
const seen = new Set();
|
|
291
|
+
for (const install of parseInstalledPlugins(opts.installed, opts.root)) {
|
|
292
|
+
if (disabled.has(install.id.toLowerCase()))
|
|
293
|
+
continue;
|
|
294
|
+
if (seen.has(install.installPath))
|
|
295
|
+
continue;
|
|
296
|
+
seen.add(install.installPath);
|
|
297
|
+
// A registry entry whose directory is gone is the normal aftermath of a
|
|
298
|
+
// hand-deleted plugin. The SDK would log "Plugin path does not exist" and
|
|
299
|
+
// carry on, but saying it here is what makes an empty `plugins` explicable.
|
|
300
|
+
try {
|
|
301
|
+
if (!(await fs.stat(install.installPath)).isDirectory())
|
|
302
|
+
throw new Error("not a directory");
|
|
303
|
+
}
|
|
304
|
+
catch {
|
|
305
|
+
caveats.push({
|
|
306
|
+
name: install.id,
|
|
307
|
+
kind: "dropped",
|
|
308
|
+
reason: `its recorded install directory (${install.installPath}) is missing`,
|
|
309
|
+
});
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
plugins.push(opts.mcp
|
|
313
|
+
? { type: "local", path: install.installPath }
|
|
314
|
+
: { type: "local", path: install.installPath, skipMcpDiscovery: true });
|
|
315
|
+
if (!opts.mcp)
|
|
316
|
+
continue;
|
|
317
|
+
const { pluginName, servers, unresolved } = await readPluginServerNames(install.installPath);
|
|
318
|
+
for (const server of servers)
|
|
319
|
+
toolPatterns.add(pluginMcpToolPattern(pluginName, server));
|
|
320
|
+
if (unresolved.length > 0) {
|
|
321
|
+
caveats.push({
|
|
322
|
+
name: install.id,
|
|
323
|
+
kind: "degraded",
|
|
324
|
+
reason: `its manifest points \`mcpServers\` at ${unresolved.join(", ")} rather than declaring ` +
|
|
325
|
+
`them inline, so paddock cannot name its servers — the plugin is attached, but any ` +
|
|
326
|
+
`tool it provides will be auto-denied with no prompt until you add the matching ` +
|
|
327
|
+
`\`mcp__plugin_${normalizeServerName(pluginName)}_<server>__*\` to the keeper's ` +
|
|
328
|
+
`allowed tools`,
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return { plugins, toolPatterns: [...toolPatterns], caveats };
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* Read the host's plugin registry — once, at boot — and say what came of it.
|
|
336
|
+
*
|
|
337
|
+
* Under `instructions: own` nothing is opened at all, for the same reason
|
|
338
|
+
* `loadHostMcpSource` does not open `.claude.json` under `mcpServers: own`: "own
|
|
339
|
+
* everywhere means nothing outside the data dir is read" is a guarantee that has
|
|
340
|
+
* to be true in the place someone would check it.
|
|
341
|
+
*/
|
|
342
|
+
export async function loadHostPlugins(cfg) {
|
|
343
|
+
const notices = [];
|
|
344
|
+
const mcp = cfg.claude.mcpServers === "host";
|
|
345
|
+
if (cfg.claude.instructions !== "host") {
|
|
346
|
+
if (mcp) {
|
|
347
|
+
// The one combination that silently does less than a user asked for, so it
|
|
348
|
+
// is the one that gets a line naming the other key.
|
|
349
|
+
notices.push({
|
|
350
|
+
level: "info",
|
|
351
|
+
message: "`claude.mcpServers: host` does not reach MCP servers provided by a Claude Code " +
|
|
352
|
+
"plugin: a plugin is mostly commands, agents and skills, so loading one is a " +
|
|
353
|
+
"`claude.instructions` decision. Set `claude.instructions: host` (or " +
|
|
354
|
+
"PADDOCK_CLAUDE_INSTRUCTIONS=host) as well to inherit your plugins.",
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
return { source: EMPTY_HOST_PLUGINS, notices };
|
|
358
|
+
}
|
|
359
|
+
const root = hostPluginRoot(cfg.legacyClaudeHome);
|
|
360
|
+
const file = path.join(root, INSTALLED_PLUGINS_FILE);
|
|
361
|
+
let installed;
|
|
362
|
+
try {
|
|
363
|
+
installed = await fs.readFile(file, "utf8");
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
// Not a warning: no plugins installed is the overwhelmingly common case, and
|
|
367
|
+
// this file does not exist until the first `claude plugin install`.
|
|
368
|
+
return { source: EMPTY_HOST_PLUGINS, notices };
|
|
369
|
+
}
|
|
370
|
+
const settings = await readJson(path.join(cfg.legacyClaudeHome, "settings.json"));
|
|
371
|
+
const source = await enumerateHostPlugins({ root, installed, settings, mcp });
|
|
372
|
+
if (source.plugins.length > 0) {
|
|
373
|
+
notices.push({
|
|
374
|
+
level: "info",
|
|
375
|
+
message: `Claude plugins: host (\`claude.instructions: host\`) — ${source.plugins.length} ` +
|
|
376
|
+
`installed plugin${source.plugins.length === 1 ? "" : "s"} attached to every keeper` +
|
|
377
|
+
(mcp
|
|
378
|
+
? source.toolPatterns.length > 0
|
|
379
|
+
? `, including their MCP servers (${source.toolPatterns.join(", ")})`
|
|
380
|
+
: ", including their MCP servers (none declared)"
|
|
381
|
+
: ". Their MCP servers are NOT attached (`claude.mcpServers: own`)") +
|
|
382
|
+
".",
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
for (const caveat of source.caveats) {
|
|
386
|
+
notices.push({
|
|
387
|
+
level: "warn",
|
|
388
|
+
message: caveat.kind === "dropped"
|
|
389
|
+
? `Claude plugin "${caveat.name}" was NOT attached: ${caveat.reason}.`
|
|
390
|
+
: `Claude plugin "${caveat.name}" is attached but incomplete: ${caveat.reason}.`,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
return { source, notices };
|
|
394
|
+
}
|
|
@@ -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;
|