@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,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The prefix that makes a string an environment reference rather than a literal.
|
|
3
|
+
* Same spelling as `managementApi.clients.<id>.auth.ref`, deliberately: an
|
|
4
|
+
* operator should have to learn it once.
|
|
5
|
+
*/
|
|
6
|
+
export const ENV_REF_PREFIX = "env:";
|
|
7
|
+
/**
|
|
8
|
+
* Server names paddock materialises itself, which a declaration may not take.
|
|
9
|
+
*
|
|
10
|
+
* Kept as literals rather than imported from `send-file-mcp.ts`/`self-mcp.ts` so
|
|
11
|
+
* config resolution stays free of the runtime modules — `mcp-servers-reserved`
|
|
12
|
+
* in the unit tests asserts the two spellings still agree.
|
|
13
|
+
*/
|
|
14
|
+
export const RESERVED_MCP_SERVER_NAMES = ["paddock", "paddock_manage"];
|
|
15
|
+
/**
|
|
16
|
+
* `mcp__<server>__<tool>` is the tool name both runtimes match against, and the
|
|
17
|
+
* allowlist pattern is built from it by string concatenation, so a name with a
|
|
18
|
+
* separator or whitespace in it produces a pattern that matches nothing. Claude
|
|
19
|
+
* Code uses the same character class for a server name.
|
|
20
|
+
*/
|
|
21
|
+
const NAME_RE = /^[A-Za-z0-9_-]+$/;
|
|
22
|
+
/** Keys of a declaration this module understands. Anything else is a typo. */
|
|
23
|
+
const KNOWN_KEYS = new Set(["command", "args", "env", "url", "type", "headers"]);
|
|
24
|
+
/**
|
|
25
|
+
* Env keys whose INLINE value is probably a credential. Deliberately a name
|
|
26
|
+
* heuristic and never a value one: matching on the value would mean reading it
|
|
27
|
+
* closely enough to describe it, and this module's whole posture is that it
|
|
28
|
+
* never looks at a value it is not about to hand to a server process.
|
|
29
|
+
*/
|
|
30
|
+
const SECRET_ISH_KEY_RE = /(TOKEN|SECRET|KEY|PASSWORD|PASSWD|CREDENTIAL|AUTH|PAT|COOKIE)/i;
|
|
31
|
+
function isRecord(v) {
|
|
32
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* A URL with everything credential-shaped removed — no query string, no
|
|
36
|
+
* fragment, no `user:pass@`. Used for every log line that mentions a `url`,
|
|
37
|
+
* because a remote MCP endpoint's API key is conventionally a query parameter.
|
|
38
|
+
* An unparseable URL is reported as its scheme only rather than echoed.
|
|
39
|
+
*/
|
|
40
|
+
export function redactUrl(raw) {
|
|
41
|
+
try {
|
|
42
|
+
const u = new URL(raw);
|
|
43
|
+
const auth = u.username || u.password ? "<redacted>@" : "";
|
|
44
|
+
return `${u.protocol}//${auth}${u.host}${u.pathname}${u.search ? "?<redacted>" : ""}`;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return "<unparseable url>";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A server rendered for a human, carrying no secret. THE chokepoint: every
|
|
52
|
+
* notice, warning and error about a declared server goes through here, so
|
|
53
|
+
* auditing "can a token reach a log line?" is auditing one function.
|
|
54
|
+
*
|
|
55
|
+
* Args and env values are counted, never printed — an API key is as likely to be
|
|
56
|
+
* `--token=…` in `args` as it is to be an `env` value. The command is reduced to
|
|
57
|
+
* its basename for the same reason, which the leak test in
|
|
58
|
+
* `test/unit/mcp-servers.test.ts` is what forced: a full path is more useful in a
|
|
59
|
+
* log, but the rule "nothing from the block is echoed" is only worth having if it
|
|
60
|
+
* has no exceptions to remember. The server's own name is printed alongside and
|
|
61
|
+
* is what actually identifies it.
|
|
62
|
+
*/
|
|
63
|
+
export function describeServer(name, def) {
|
|
64
|
+
const parts = [];
|
|
65
|
+
if (def.command)
|
|
66
|
+
parts.push(`stdio: ${def.command.split(/[\\/]/).pop()}`);
|
|
67
|
+
if (def.url)
|
|
68
|
+
parts.push(`${def.type ?? "http"}: ${redactUrl(def.url)}`);
|
|
69
|
+
if (def.args?.length)
|
|
70
|
+
parts.push(`${def.args.length} args`);
|
|
71
|
+
const envCount = Object.keys(def.env ?? {}).length;
|
|
72
|
+
if (envCount > 0)
|
|
73
|
+
parts.push(`${envCount} env ${envCount === 1 ? "entry" : "entries"}`);
|
|
74
|
+
const headerCount = Object.keys(def.headers ?? {}).length;
|
|
75
|
+
if (headerCount > 0)
|
|
76
|
+
parts.push(`${headerCount} ${headerCount === 1 ? "header" : "headers"}`);
|
|
77
|
+
return `${name} (${parts.join(", ")})`;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Resolve a string leaf: `env:VAR_NAME` reads `VAR_NAME`, anything else is a
|
|
81
|
+
* literal.
|
|
82
|
+
*
|
|
83
|
+
* An unset or blank variable FAILS the leaf, which drops the whole server. That
|
|
84
|
+
* is the fail-closed direction here for the same reason it is in
|
|
85
|
+
* `management-config.ts`: a server started without the credential it was
|
|
86
|
+
* declared with either fails every call confusingly or — worse for something
|
|
87
|
+
* that talks to a third party — connects unauthenticated.
|
|
88
|
+
*/
|
|
89
|
+
function resolveLeaf(raw, env, where) {
|
|
90
|
+
if (!raw.startsWith(ENV_REF_PREFIX))
|
|
91
|
+
return { ok: true, value: raw };
|
|
92
|
+
const varName = raw.slice(ENV_REF_PREFIX.length).trim();
|
|
93
|
+
if (varName.length === 0)
|
|
94
|
+
return { ok: false, error: `${where}: \`env:\` names no variable` };
|
|
95
|
+
const value = env[varName];
|
|
96
|
+
if (value === undefined || value.trim().length === 0) {
|
|
97
|
+
return {
|
|
98
|
+
ok: false,
|
|
99
|
+
error: `${where}: environment variable ${varName} is unset or empty`,
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
return { ok: true, value };
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Validate + resolve one declaration into the shape herdctl's `McpServerSchema`
|
|
106
|
+
* carries, or explain why it cannot be.
|
|
107
|
+
*
|
|
108
|
+
* The one rejection left that is about the engine rather than about the file is
|
|
109
|
+
* **an unknown key** — the only defence against `arg:`/`envs:`/`commands:`, which
|
|
110
|
+
* would otherwise yield a server that starts with the wrong argv.
|
|
111
|
+
*
|
|
112
|
+
* `headers` and `type: sse` were rejections too until herdctl 5.32.0, because
|
|
113
|
+
* the schema had no field for either: a bearer-authenticated server arrived
|
|
114
|
+
* unauthenticated and missed its stored OAuth token (keyed on a hash that
|
|
115
|
+
* includes the headers and the type), and an `sse` url was silently connected to
|
|
116
|
+
* as HTTP. Both are now carried verbatim, so both are accepted — `headers`
|
|
117
|
+
* through the same `env:VAR` resolution as `env`, and `type` checked only for
|
|
118
|
+
* agreeing with the declaration (`stdio` for a `command`, `http`/`sse` for a
|
|
119
|
+
* `url`).
|
|
120
|
+
*/
|
|
121
|
+
function narrowDeclaration(name, raw, env) {
|
|
122
|
+
const where = `mcpServers.${name}`;
|
|
123
|
+
const errors = [];
|
|
124
|
+
const warnings = [];
|
|
125
|
+
const fail = (msg) => ({
|
|
126
|
+
errors: [...errors, `${msg} — not attached`],
|
|
127
|
+
warnings,
|
|
128
|
+
});
|
|
129
|
+
if (!NAME_RE.test(name)) {
|
|
130
|
+
return fail(`${where}: a server name may contain only letters, digits, \`_\` and \`-\` (its tools are ` +
|
|
131
|
+
`named mcp__${name}__<tool>, and the allowlist pattern is built from that)`);
|
|
132
|
+
}
|
|
133
|
+
if (RESERVED_MCP_SERVER_NAMES.includes(name)) {
|
|
134
|
+
return fail(`${where}: "${name}" is reserved for a server paddock injects itself, and two servers ` +
|
|
135
|
+
`cannot share the mcp__${name}__* namespace`);
|
|
136
|
+
}
|
|
137
|
+
if (!isRecord(raw))
|
|
138
|
+
return fail(`${where}: is not a mapping`);
|
|
139
|
+
const unknown = Object.keys(raw).filter((k) => !KNOWN_KEYS.has(k));
|
|
140
|
+
if (unknown.length > 0) {
|
|
141
|
+
return fail(`${where}: unrecognised key(s) ${unknown.join(", ")} ` +
|
|
142
|
+
`(supported: command, args, env, url, type, headers) ` +
|
|
143
|
+
`— a mistyped key would otherwise be dropped without a trace`);
|
|
144
|
+
}
|
|
145
|
+
const hasCommand = typeof raw.command === "string" && raw.command.trim().length > 0;
|
|
146
|
+
const hasUrl = typeof raw.url === "string" && raw.url.trim().length > 0;
|
|
147
|
+
if (hasCommand && hasUrl) {
|
|
148
|
+
return fail(`${where}: declares both a \`command\` and a \`url\`; a server is one or the other`);
|
|
149
|
+
}
|
|
150
|
+
if (!hasCommand && !hasUrl) {
|
|
151
|
+
return fail(`${where}: declares neither a \`command\` nor a \`url\`, so there is nothing to start`);
|
|
152
|
+
}
|
|
153
|
+
const server = {};
|
|
154
|
+
if (raw.type !== undefined) {
|
|
155
|
+
const type = String(raw.type).trim().toLowerCase();
|
|
156
|
+
// `sse` joined `http` here in herdctl 5.32.0; an explicit type now wins over
|
|
157
|
+
// the bare-`url` ⇒ `http` inference rather than being stripped on the way
|
|
158
|
+
// down. It still has to agree with the rest of the declaration, because a
|
|
159
|
+
// `type` that disagrees is a typo and starting the wrong transport is a
|
|
160
|
+
// confusing failure rather than a loud one.
|
|
161
|
+
const expected = hasUrl ? ["http", "sse"] : ["stdio"];
|
|
162
|
+
if (!expected.includes(type)) {
|
|
163
|
+
return fail(`${where}: \`type: ${type}\` disagrees with the declaration ` +
|
|
164
|
+
`(expected ${expected.join(" or ")})`);
|
|
165
|
+
}
|
|
166
|
+
server.type = type;
|
|
167
|
+
}
|
|
168
|
+
if (hasCommand) {
|
|
169
|
+
const res = resolveLeaf(raw.command.trim(), env, `${where}.command`);
|
|
170
|
+
if (!res.ok)
|
|
171
|
+
return { errors, warnings: [...warnings, `${res.error} — server not attached`] };
|
|
172
|
+
server.command = res.value;
|
|
173
|
+
}
|
|
174
|
+
if (hasUrl) {
|
|
175
|
+
const res = resolveLeaf(raw.url.trim(), env, `${where}.url`);
|
|
176
|
+
if (!res.ok)
|
|
177
|
+
return { errors, warnings: [...warnings, `${res.error} — server not attached`] };
|
|
178
|
+
server.url = res.value;
|
|
179
|
+
// A key in the query string is one of the two ways a remote MCP endpoint is
|
|
180
|
+
// authenticated (the other, `headers`, is carryable again since #700), and
|
|
181
|
+
// this file is git-tracked. Say so once; never echo the query itself.
|
|
182
|
+
if (!raw.url.startsWith(ENV_REF_PREFIX)) {
|
|
183
|
+
let parsed;
|
|
184
|
+
try {
|
|
185
|
+
parsed = new URL(server.url);
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
/* an unparseable url is the engine's problem, not a secrets one */
|
|
189
|
+
}
|
|
190
|
+
if (parsed && (parsed.search || parsed.username || parsed.password)) {
|
|
191
|
+
warnings.push(`${where}.url: carries a query string or userinfo, which is where an API key usually ` +
|
|
192
|
+
`rides. This file is git-tracked — prefer \`url: ${ENV_REF_PREFIX}VAR_NAME\` and ` +
|
|
193
|
+
`keep the value in the environment`);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (raw.args !== undefined) {
|
|
198
|
+
if (!Array.isArray(raw.args) || !raw.args.every((a) => typeof a === "string")) {
|
|
199
|
+
return fail(`${where}.args: must be a list of strings`);
|
|
200
|
+
}
|
|
201
|
+
const args = [];
|
|
202
|
+
for (const [i, arg] of raw.args.entries()) {
|
|
203
|
+
const res = resolveLeaf(arg, env, `${where}.args[${i}]`);
|
|
204
|
+
if (!res.ok)
|
|
205
|
+
return { errors, warnings: [...warnings, `${res.error} — server not attached`] };
|
|
206
|
+
args.push(res.value);
|
|
207
|
+
}
|
|
208
|
+
if (args.length > 0)
|
|
209
|
+
server.args = args;
|
|
210
|
+
}
|
|
211
|
+
if (raw.env !== undefined) {
|
|
212
|
+
if (!isRecord(raw.env))
|
|
213
|
+
return fail(`${where}.env: must be a mapping of NAME to value`);
|
|
214
|
+
const resolved = {};
|
|
215
|
+
for (const [key, value] of Object.entries(raw.env)) {
|
|
216
|
+
if (typeof value !== "string") {
|
|
217
|
+
return fail(`${where}.env.${key}: must be a string (values are passed to a process verbatim)`);
|
|
218
|
+
}
|
|
219
|
+
const res = resolveLeaf(value, env, `${where}.env.${key}`);
|
|
220
|
+
if (!res.ok)
|
|
221
|
+
return { errors, warnings: [...warnings, `${res.error} — server not attached`] };
|
|
222
|
+
resolved[key] = res.value;
|
|
223
|
+
if (!value.startsWith(ENV_REF_PREFIX) && SECRET_ISH_KEY_RE.test(key)) {
|
|
224
|
+
warnings.push(`${where}.env.${key}: looks like a credential and is written into the config file ` +
|
|
225
|
+
`itself, which is git-tracked — prefer \`${key}: ${ENV_REF_PREFIX}VAR_NAME\` and set ` +
|
|
226
|
+
`the value in the environment`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (Object.keys(resolved).length > 0)
|
|
230
|
+
server.env = resolved;
|
|
231
|
+
}
|
|
232
|
+
if (raw.headers !== undefined) {
|
|
233
|
+
if (!hasUrl) {
|
|
234
|
+
return fail(`${where}.headers: only a \`url\` server can carry headers`);
|
|
235
|
+
}
|
|
236
|
+
if (!isRecord(raw.headers)) {
|
|
237
|
+
return fail(`${where}.headers: must be a mapping of header name to value`);
|
|
238
|
+
}
|
|
239
|
+
const resolved = {};
|
|
240
|
+
for (const [key, value] of Object.entries(raw.headers)) {
|
|
241
|
+
if (typeof value !== "string") {
|
|
242
|
+
return fail(`${where}.headers.${key}: must be a string`);
|
|
243
|
+
}
|
|
244
|
+
const res = resolveLeaf(value, env, `${where}.headers.${key}`);
|
|
245
|
+
if (!res.ok)
|
|
246
|
+
return { errors, warnings: [...warnings, `${res.error} — server not attached`] };
|
|
247
|
+
resolved[key] = res.value;
|
|
248
|
+
// Same heuristic as `env`, one rule to learn — and `Authorization` is the
|
|
249
|
+
// usual reason a header is declared at all, so this fires on the common
|
|
250
|
+
// case rather than the exotic one.
|
|
251
|
+
if (!value.startsWith(ENV_REF_PREFIX) && SECRET_ISH_KEY_RE.test(key)) {
|
|
252
|
+
warnings.push(`${where}.headers.${key}: looks like a credential and is written into the config file ` +
|
|
253
|
+
`itself, which is git-tracked — prefer \`${key}: ${ENV_REF_PREFIX}VAR_NAME\` and set ` +
|
|
254
|
+
`the value in the environment`);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (Object.keys(resolved).length > 0)
|
|
258
|
+
server.headers = resolved;
|
|
259
|
+
}
|
|
260
|
+
return { server, errors, warnings };
|
|
261
|
+
}
|
|
262
|
+
/**
|
|
263
|
+
* Resolve the whole `mcpServers:` block against `env`.
|
|
264
|
+
*
|
|
265
|
+
* Never throws. A declaration paddock cannot carry is reported and dropped so one
|
|
266
|
+
* typo cannot take an instance down — the same posture as
|
|
267
|
+
* `resolveManagementApiConfig`, and for the same reason: this is a capability,
|
|
268
|
+
* and losing a capability is better than losing the instance.
|
|
269
|
+
*/
|
|
270
|
+
export function resolveDeclaredMcpServers(file, env) {
|
|
271
|
+
const servers = {};
|
|
272
|
+
const errors = [];
|
|
273
|
+
const warnings = [];
|
|
274
|
+
if (file === undefined || file === null)
|
|
275
|
+
return { servers, errors, warnings };
|
|
276
|
+
if (!isRecord(file)) {
|
|
277
|
+
errors.push("mcpServers: must be a mapping of server name to declaration — ignored");
|
|
278
|
+
return { servers, errors, warnings };
|
|
279
|
+
}
|
|
280
|
+
for (const [rawName, decl] of Object.entries(file)) {
|
|
281
|
+
const name = rawName.trim();
|
|
282
|
+
if (name.length === 0) {
|
|
283
|
+
errors.push("mcpServers: a server name is empty — not attached");
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
const res = narrowDeclaration(name, decl, env);
|
|
287
|
+
errors.push(...res.errors);
|
|
288
|
+
warnings.push(...res.warnings);
|
|
289
|
+
if (res.server)
|
|
290
|
+
servers[name] = res.server;
|
|
291
|
+
}
|
|
292
|
+
return { servers, errors, warnings };
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* The one way a declared credential leaves paddock, said out loud.
|
|
296
|
+
*
|
|
297
|
+
* Everything else in this module is about keeping a resolved value out of the
|
|
298
|
+
* places paddock controls — the log, an error, the Settings API. It does that
|
|
299
|
+
* completely, and it is still not the whole story, because of what herdctl does
|
|
300
|
+
* with the record afterwards:
|
|
301
|
+
*
|
|
302
|
+
* ```js
|
|
303
|
+
* // @herdctl/core, runner/runtime/cli-runtime.js
|
|
304
|
+
* const mcpServers = transformMcpServers(options.agent.mcp_servers);
|
|
305
|
+
* const mcpConfig = JSON.stringify({ mcpServers });
|
|
306
|
+
* args.push("--mcp-config", mcpConfig); // ← env values and all
|
|
307
|
+
* ```
|
|
308
|
+
*
|
|
309
|
+
* A process ARGUMENT is not private on Linux: `/proc/<pid>/cmdline` is
|
|
310
|
+
* world-readable by default (no `hidepid`), and `ps` prints it. So on the CLI
|
|
311
|
+
* runtime every declared server's `env` — and, since #700 made the field
|
|
312
|
+
* carryable, its `headers` — is legible to any local user for the lifetime of
|
|
313
|
+
* each `claude` invocation. Observed, not
|
|
314
|
+
* inferred: `test/integration/declared-mcp-argv.test.ts` drives a real turn and
|
|
315
|
+
* reads the token back out of the spawned process's argv.
|
|
316
|
+
*
|
|
317
|
+
* The SDK runtime does NOT do this. It hands the same record to the SDK
|
|
318
|
+
* in-process, and the stdio server it spawns receives the value in its
|
|
319
|
+
* environment, where `/proc/<pid>/environ` is owner-only — which is exactly what
|
|
320
|
+
* Claude Code itself does, so it is not a regression to fix here.
|
|
321
|
+
*
|
|
322
|
+
* Which runtime runs is `driveMode`: `session` (the default) is the SDK,
|
|
323
|
+
* `batch` is the CLI. Hence a WARNING on an instance that is on `batch`, and an
|
|
324
|
+
* informational note otherwise — because a single project can pin `driveMode:
|
|
325
|
+
* batch` for itself and bring the exposure back with it.
|
|
326
|
+
*
|
|
327
|
+
* Paddock cannot close this from here: the fix is upstream (the Claude CLI's
|
|
328
|
+
* `--mcp-config` also accepts a file path, which is not readable from another
|
|
329
|
+
* process's argv). Refusing to attach the server instead would break the feature
|
|
330
|
+
* for the deployments most likely to need it, so what this does is refuse to be
|
|
331
|
+
* silent — the same posture step 5 takes towards the fields the engine's schema
|
|
332
|
+
* cannot carry.
|
|
333
|
+
*/
|
|
334
|
+
function argvExposure(names, driveMode) {
|
|
335
|
+
const batch = driveMode === "batch";
|
|
336
|
+
return {
|
|
337
|
+
level: batch ? "warn" : "info",
|
|
338
|
+
message: `${names.join(", ")} ${names.length === 1 ? "declares" : "declare"} \`env\` values or ` +
|
|
339
|
+
`\`headers\`, and ` +
|
|
340
|
+
`under \`driveMode: batch\` the engine passes the whole server definition to \`claude\` ` +
|
|
341
|
+
`as a \`--mcp-config\` COMMAND-LINE argument — where any local process can read it via ` +
|
|
342
|
+
`/proc/<pid>/cmdline. ` +
|
|
343
|
+
(batch
|
|
344
|
+
? `This instance is on \`driveMode: batch\`, so that applies to every turn. Prefer ` +
|
|
345
|
+
`\`driveMode: session\` (the default) for a server that holds a credential.`
|
|
346
|
+
: `This instance is on \`driveMode: session\`, which passes them in-process instead — ` +
|
|
347
|
+
`but a project that pins \`driveMode: batch\` brings the exposure back.`),
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* What to say at boot about the declared servers — the answer to "what does this
|
|
352
|
+
* instance actually have?", which should be readable off the log rather than
|
|
353
|
+
* inferred from a YAML file plus an environment.
|
|
354
|
+
*
|
|
355
|
+
* Takes the already-resolved servers, so it cannot see a raw declaration and
|
|
356
|
+
* therefore cannot leak one; every line it produces goes through
|
|
357
|
+
* {@link describeServer}.
|
|
358
|
+
*/
|
|
359
|
+
export function declaredMcpNotices(opts) {
|
|
360
|
+
const names = Object.keys(opts.servers);
|
|
361
|
+
if (names.length === 0)
|
|
362
|
+
return [];
|
|
363
|
+
const notices = [
|
|
364
|
+
{
|
|
365
|
+
level: "info",
|
|
366
|
+
message: `MCP servers declared by this instance (\`mcpServers:\`): ` +
|
|
367
|
+
`${names.map((n) => describeServer(n, opts.servers[n])).join("; ")}. ` +
|
|
368
|
+
`Every project's keeper gets all of them.`,
|
|
369
|
+
},
|
|
370
|
+
];
|
|
371
|
+
const shadowed = names.filter((n) => opts.hostNames?.includes(n));
|
|
372
|
+
if (shadowed.length > 0) {
|
|
373
|
+
notices.push({
|
|
374
|
+
level: "info",
|
|
375
|
+
message: `${shadowed.join(", ")} ${shadowed.length === 1 ? "is" : "are"} also declared in your ` +
|
|
376
|
+
`~/.claude.json (\`claude.mcpServers: host\`); this instance's own declaration wins.`,
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
// `headers` joined `env` here in #700: herdctl 5.32.0 carries them, so an
|
|
380
|
+
// `Authorization` bearer now rides in the same `--mcp-config` argv element that
|
|
381
|
+
// #702 found an `env` token in — and a bearer is the likelier long-lived
|
|
382
|
+
// credential of the two. A server declared with headers and no env would
|
|
383
|
+
// otherwise be the one case this warning missed.
|
|
384
|
+
const exposed = names.filter((n) => Object.keys(opts.servers[n].env ?? {}).length > 0 ||
|
|
385
|
+
Object.keys(opts.servers[n].headers ?? {}).length > 0);
|
|
386
|
+
if (exposed.length > 0 && opts.driveMode !== undefined) {
|
|
387
|
+
// The one place a declared credential escapes paddock, and it escapes
|
|
388
|
+
// downstream of every rule this module enforces — see {@link argvExposure}.
|
|
389
|
+
notices.push(argvExposure(exposed, opts.driveMode));
|
|
390
|
+
}
|
|
391
|
+
if (opts.browserMcp && names.includes("playwright")) {
|
|
392
|
+
notices.push({
|
|
393
|
+
level: "warn",
|
|
394
|
+
message: `mcpServers.playwright collides with paddock's own browser server, which WINS — your ` +
|
|
395
|
+
`declaration is not attached. Set \`browserMcp: false\` to use yours instead.`,
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
return notices;
|
|
399
|
+
}
|
|
@@ -584,7 +584,7 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
584
584
|
schema: {
|
|
585
585
|
tags: ["Chats"],
|
|
586
586
|
summary: "Delete a project chat",
|
|
587
|
-
description: "Deletes a project chat (session)
|
|
587
|
+
description: "Deletes a project chat (session) and clears any archived/starred flag so a future session id can't inherit it. Responds 200 with `{ ok, removed, retained }`. In a Claude home Paddock owns, the transcript JSONL is unlinked (`removed: true`). In a home it does NOT own the transcript is the user's own terminal `claude` history — Paddock plants no symlink there (#682), so there is no copy to delete — and the session is RELEASED instead: it leaves the chat list, the file stays on disk, and `retained` is true (#689).",
|
|
588
588
|
params: {
|
|
589
589
|
type: "object",
|
|
590
590
|
properties: {
|
|
@@ -605,7 +605,7 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
605
605
|
try {
|
|
606
606
|
const agent = await agentForSlug(req.params.slug);
|
|
607
607
|
await cleanupAttachments(agent, req.params.sessionId);
|
|
608
|
-
const removed = await herdctl.deleteSession(agent, req.params.sessionId);
|
|
608
|
+
const { removed, retained } = await herdctl.deleteSession(agent, req.params.sessionId);
|
|
609
609
|
// Drop any archived/starred/unread/detached flag so a future session id
|
|
610
610
|
// can't inherit it. (Read-state watermarks, other users' unread overrides,
|
|
611
611
|
// provenance and any queued message are deliberately NOT swept here — that
|
|
@@ -619,7 +619,7 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
619
619
|
// has to be cleared on the same path — otherwise a recycled session id
|
|
620
620
|
// silently starts life detached from a parent it never had.
|
|
621
621
|
await parentDetach.setDetached(agent, req.params.sessionId, false).catch(() => undefined);
|
|
622
|
-
return reply.code(200).send({ ok: true, removed });
|
|
622
|
+
return reply.code(200).send({ ok: true, removed, retained });
|
|
623
623
|
}
|
|
624
624
|
catch (err) {
|
|
625
625
|
return sendProjectError(reply, err);
|
|
@@ -1208,7 +1208,13 @@ export function registerChatWorkspaceRoutes(app, ctx) {
|
|
|
1208
1208
|
try {
|
|
1209
1209
|
await cleanupAttachments(agent, sessionId);
|
|
1210
1210
|
const gone = await herdctl.deleteSession(agent, sessionId);
|
|
1211
|
-
|
|
1211
|
+
// `retained` counts as success here, and the distinction matters for
|
|
1212
|
+
// the flag-clearing below. These two buckets are "no longer in the
|
|
1213
|
+
// chat list" vs "still in it": a RELEASED chat (#689) left the list
|
|
1214
|
+
// even though its transcript survives on disk, so its flags must be
|
|
1215
|
+
// cleared like any other departure. Only a genuine failure — nothing
|
|
1216
|
+
// on disk to act on — belongs in `failed`.
|
|
1217
|
+
if (gone.removed || gone.retained)
|
|
1212
1218
|
removed.push(sessionId);
|
|
1213
1219
|
else
|
|
1214
1220
|
failed.push(sessionId);
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whose transcripts this instance uses — the `claude.transcripts` config key.
|
|
3
|
+
*
|
|
4
|
+
* `own` = paddock's, inside the data dir / project. `host` = this machine's
|
|
5
|
+
* Claude Code, shared live. See the module doc for the mechanism.
|
|
6
|
+
*/
|
|
7
|
+
export type TranscriptsMode = "own" | "host";
|
|
8
|
+
/** Isolation is the default: nothing outside the data dir is written (#691). */
|
|
9
|
+
export declare const DEFAULT_TRANSCRIPTS_MODE: TranscriptsMode;
|
|
10
|
+
/** Type guard, so an unknown config value falls back instead of failing a boot. */
|
|
11
|
+
export declare function isKnownTranscriptsMode(value: string): value is TranscriptsMode;
|
|
1
12
|
/**
|
|
2
13
|
* Encode a working directory the way Claude Code names its transcript dir.
|
|
3
14
|
* For paths under 200 chars this is just the non-alphanumeric→'-' replacement
|
|
@@ -8,7 +19,44 @@ export declare function encodeProjectDir(dir: string): string;
|
|
|
8
19
|
/** Absolute path to the project's local transcript directory. */
|
|
9
20
|
export declare function projectChatsDir(projectDir: string): string;
|
|
10
21
|
/**
|
|
11
|
-
*
|
|
22
|
+
* The transcript folder a Claude home holds for `workingDir` —
|
|
23
|
+
* `<home>/projects/<encoded-cwd>`.
|
|
24
|
+
*
|
|
25
|
+
* The LITERAL path, before any symlink is followed, which is what makes it worth
|
|
26
|
+
* naming: it is the string Claude Code builds its session file AND its agent
|
|
27
|
+
* memory dir (`<here>/memory`) out of, and the agent harness refuses to write to
|
|
28
|
+
* any path with a `.claude` component in it (#690). Under paddock's own home it
|
|
29
|
+
* never has one, in either {@link TranscriptsMode}.
|
|
30
|
+
*/
|
|
31
|
+
export declare function encodedTranscriptDir(claudeHome: string, workingDir: string): string;
|
|
32
|
+
/**
|
|
33
|
+
* Where a project's transcripts are planted, and where they point.
|
|
34
|
+
*
|
|
35
|
+
* `path` is the Claude home to plant the redirect symlink in, and it is REQUIRED
|
|
36
|
+
* — no default (#620). Callers must pass `cfg.claudeHome`: the engine is
|
|
37
|
+
* constructed with that same resolved value, and a symlink planted in a
|
|
38
|
+
* different home than the one the engine reads is exactly the "chats list but
|
|
39
|
+
* open empty" failure (#588). It used to default to the env-derived home for
|
|
40
|
+
* callers with no config, and `ensureSweeperHome` silently took that default —
|
|
41
|
+
* so the sweeper's symlink landed in a different home than every other agent's.
|
|
42
|
+
* Making the parameter required is what stops that class of bug at the type
|
|
43
|
+
* level.
|
|
44
|
+
*
|
|
45
|
+
* Since #691 paddock ALWAYS owns `path`, so the old `owned` flag is gone: the
|
|
46
|
+
* question it used to answer ("may we write here?") now has one answer, and the
|
|
47
|
+
* question that actually varies — whose transcripts these are — is
|
|
48
|
+
* {@link transcripts}.
|
|
49
|
+
*/
|
|
50
|
+
export interface ClaudeHomeTarget {
|
|
51
|
+
/** Absolute path to paddock's own Claude home (`cfg.claudeHome`). */
|
|
52
|
+
path: string;
|
|
53
|
+
/** Whose transcripts this instance uses (`cfg.claude.transcripts`). */
|
|
54
|
+
transcripts: TranscriptsMode;
|
|
55
|
+
/** The user's own `~/.claude` — the store under `transcripts: "host"`. */
|
|
56
|
+
userHome: string;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Ensure the project's transcript store exists and that Claude Code's encoded
|
|
12
60
|
* transcript path for `workingDir` is a symlink pointing at it (migrating an
|
|
13
61
|
* existing real transcript dir in the process). Safe to call on every agent
|
|
14
62
|
* registration. Never throws — a failure here must not block agent registration
|
|
@@ -21,38 +69,21 @@ export declare function projectChatsDir(projectDir: string): string;
|
|
|
21
69
|
* store stays in the project's metadata dir (the data repo) — so transcripts
|
|
22
70
|
* never pollute the external repo's working tree — hence the two are split.
|
|
23
71
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* different home than the one the engine reads is exactly the "chats list but
|
|
28
|
-
* open empty" failure (#588). It used to default to the env-derived home for
|
|
29
|
-
* callers with no config, and `ensureSweeperHome` silently took that default —
|
|
30
|
-
* so the sweeper's symlink landed in a different home than every other agent's.
|
|
31
|
-
* Making the parameter required is what stops that class of bug at the type
|
|
32
|
-
* level.
|
|
33
|
-
*
|
|
34
|
-
* `home.owned` gates EVERY write into the home (#620, #682). Two of them:
|
|
72
|
+
* The store is `<chatsHostDir>/.chats/` under `transcripts: "own"` and the user's
|
|
73
|
+
* own `~/.claude/projects/<encoded-cwd>/` under `transcripts: "host"`; everything
|
|
74
|
+
* below is the same code either way.
|
|
35
75
|
*
|
|
36
|
-
*
|
|
37
|
-
* files and then REMOVES the originals. That is correct self-healing inside a
|
|
38
|
-
* home paddock owns, where such a directory can only be paddock's own doing.
|
|
39
|
-
* Inside the user's `~/.claude` it is somebody else's data — a `claude` CLI
|
|
40
|
-
* session run in a directory paddock happens to also manage — so we leave it
|
|
41
|
-
* strictly alone and let the SDK keep writing there.
|
|
42
|
-
* - Planting the redirect symlink at a path that does not exist yet. Not
|
|
43
|
-
* destructive on the day it happens, which is why it was missed, but it
|
|
44
|
-
* silently claims where the user's FUTURE sessions for that directory get
|
|
45
|
-
* written (#682). See the branch itself for the full argument.
|
|
76
|
+
* ## The one write into `~/.claude`, and why it is not the #682 mistake
|
|
46
77
|
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
78
|
+
* Under `host` this creates `~/.claude/projects/<encoded-cwd>/` if it is missing,
|
|
79
|
+
* because a symlink to a non-existent directory is not something Claude Code can
|
|
80
|
+
* `mkdir -p` through. That is a directory Claude Code itself would create the
|
|
81
|
+
* first time the user ran `claude` there, made under a config key whose entire
|
|
82
|
+
* meaning is "share the host's transcripts". #682 was the opposite: planting a
|
|
83
|
+
* link that silently REDIRECTED the user's future sessions into paddock's store
|
|
84
|
+
* on an instance that had asked for no such thing. Under `own` — the default —
|
|
85
|
+
* nothing under `~/.claude` is created, read or written at all.
|
|
49
86
|
*/
|
|
50
|
-
export interface ClaudeHomeTarget {
|
|
51
|
-
/** Absolute path to the Claude home to plant the redirect symlink in. */
|
|
52
|
-
path: string;
|
|
53
|
-
/** Whether paddock owns this home — see `PaddockConfig.ownsClaudeHome`. */
|
|
54
|
-
owned: boolean;
|
|
55
|
-
}
|
|
56
87
|
export declare function ensureProjectChats(workingDir: string, chatsHostDir: string, home: ClaudeHomeTarget): Promise<void>;
|
|
57
88
|
/**
|
|
58
89
|
* Read a session's FIRST user message text, untruncated, straight from its
|