@edspencer/paddock 0.61.0 → 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.
Files changed (93) hide show
  1. package/README.md +36 -4
  2. package/package.json +1 -1
  3. package/packages/server/dist/agent-errors.d.ts +41 -0
  4. package/packages/server/dist/agent-errors.js +127 -0
  5. package/packages/server/dist/app.js +57 -9
  6. package/packages/server/dist/claude-credentials.d.ts +116 -0
  7. package/packages/server/dist/claude-credentials.js +103 -0
  8. package/packages/server/dist/claude-home.d.ts +164 -25
  9. package/packages/server/dist/claude-home.js +528 -77
  10. package/packages/server/dist/claude-instructions.d.ts +121 -0
  11. package/packages/server/dist/claude-instructions.js +114 -0
  12. package/packages/server/dist/claude-mcp.d.ts +146 -0
  13. package/packages/server/dist/claude-mcp.js +367 -0
  14. package/packages/server/dist/claude-settings.d.ts +159 -0
  15. package/packages/server/dist/claude-settings.js +159 -0
  16. package/packages/server/dist/cli/args.d.ts +1 -1
  17. package/packages/server/dist/cli/args.js +39 -5
  18. package/packages/server/dist/cli/paddock.js +30 -6
  19. package/packages/server/dist/config.d.ts +166 -30
  20. package/packages/server/dist/config.js +106 -26
  21. package/packages/server/dist/herdctl-agent-config.d.ts +11 -1
  22. package/packages/server/dist/herdctl-agent-config.js +53 -7
  23. package/packages/server/dist/herdctl-agent-names.d.ts +11 -0
  24. package/packages/server/dist/herdctl-agent-names.js +31 -0
  25. package/packages/server/dist/herdctl.d.ts +69 -13
  26. package/packages/server/dist/herdctl.js +70 -18
  27. package/packages/server/dist/instance-config.js +36 -0
  28. package/packages/server/dist/mcp-servers.d.ts +168 -0
  29. package/packages/server/dist/mcp-servers.js +305 -0
  30. package/packages/server/dist/routes/chats.js +10 -4
  31. package/packages/server/dist/sweep.js +13 -1
  32. package/packages/server/dist/transcripts.d.ts +62 -24
  33. package/packages/server/dist/transcripts.js +141 -28
  34. package/packages/server/package.json +1 -1
  35. package/packages/web/dist/assets/{InstanceConfigPage-CzMqTdUU.js → InstanceConfigPage-Ca905oey.js} +1 -1
  36. package/packages/web/dist/assets/{MarkdownRenderer-YSEZHWDe.js → MarkdownRenderer-ChlC1ezH.js} +1 -1
  37. package/packages/web/dist/assets/{ProjectRedirect-DLWxZbqK.js → ProjectRedirect-CslqbhUb.js} +1 -1
  38. package/packages/web/dist/assets/{ProjectView-BFX8ff2n.js → ProjectView-DkNdilGo.js} +3 -3
  39. package/packages/web/dist/assets/{ProjectsGrid-DLOeY_ww.js → ProjectsGrid-BrlvLc-Y.js} +1 -1
  40. package/packages/web/dist/assets/{arc-BwC1ik-b.js → arc-CGqCI5nQ.js} +1 -1
  41. package/packages/web/dist/assets/{architectureDiagram-3BPJPVTR-CsVvXQWw.js → architectureDiagram-3BPJPVTR-Cp4KTxE2.js} +1 -1
  42. package/packages/web/dist/assets/{blockDiagram-GPEHLZMM-DXQk2bIa.js → blockDiagram-GPEHLZMM-yFxaTv04.js} +1 -1
  43. package/packages/web/dist/assets/{c4Diagram-AAUBKEIU-DXiA26It.js → c4Diagram-AAUBKEIU-59lhWjRt.js} +1 -1
  44. package/packages/web/dist/assets/channel-CZs6Z1i5.js +2 -0
  45. package/packages/web/dist/assets/{chunk-2J33WTMH-Dt5nC02A.js → chunk-2J33WTMH-BrpXGnJg.js} +1 -1
  46. package/packages/web/dist/assets/{chunk-4BX2VUAB-DcjnWvl6.js → chunk-4BX2VUAB-UxBDb5pi.js} +1 -1
  47. package/packages/web/dist/assets/{chunk-55IACEB6-CRa3LkU4.js → chunk-55IACEB6-D4ZJyUHW.js} +1 -1
  48. package/packages/web/dist/assets/{chunk-727SXJPM-H3V-11qu.js → chunk-727SXJPM-DWdPj_8E.js} +1 -1
  49. package/packages/web/dist/assets/{chunk-AQP2D5EJ-DPCrENYV.js → chunk-AQP2D5EJ-BIpNbflY.js} +1 -1
  50. package/packages/web/dist/assets/{chunk-FMBD7UC4-C4UO0ZTc.js → chunk-FMBD7UC4-f6iJDfdI.js} +1 -1
  51. package/packages/web/dist/assets/{chunk-ND2GUHAM-BtI37CnI.js → chunk-ND2GUHAM-Bz3TKIJk.js} +1 -1
  52. package/packages/web/dist/assets/{chunk-QZHKN3VN-C5bTaK5j.js → chunk-QZHKN3VN-buQmkafK.js} +1 -1
  53. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-CrbEUUCd.js +2 -0
  54. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-CrbEUUCd.js +2 -0
  55. package/packages/web/dist/assets/{core-DB3cpm5N.js → core-TyFI6p0n.js} +1 -1
  56. package/packages/web/dist/assets/{cose-bilkent-S5V4N54A-q-cjTZO4.js → cose-bilkent-S5V4N54A-CIkdi0XF.js} +1 -1
  57. package/packages/web/dist/assets/{dagre-BM42HDAG-DYEV5HFo.js → dagre-BM42HDAG-BnLJCfNs.js} +1 -1
  58. package/packages/web/dist/assets/{diagram-2AECGRRQ-D16XCw78.js → diagram-2AECGRRQ-D0OqfDB9.js} +1 -1
  59. package/packages/web/dist/assets/{diagram-5GNKFQAL-CeztryYE.js → diagram-5GNKFQAL-DQ2spGrK.js} +1 -1
  60. package/packages/web/dist/assets/{diagram-KO2AKTUF-D5SLIMDS.js → diagram-KO2AKTUF-RDIVQ_S3.js} +1 -1
  61. package/packages/web/dist/assets/{diagram-LMA3HP47-Bw-sVPxM.js → diagram-LMA3HP47-DKoTfh2C.js} +1 -1
  62. package/packages/web/dist/assets/{diagram-OG6HWLK6-DsRMITgx.js → diagram-OG6HWLK6-6k0OuSAL.js} +1 -1
  63. package/packages/web/dist/assets/{erDiagram-TEJ5UH35-T8Qqc_ku.js → erDiagram-TEJ5UH35-B-oQaYAC.js} +1 -1
  64. package/packages/web/dist/assets/{flowDiagram-I6XJVG4X-Cyx47UWU.js → flowDiagram-I6XJVG4X-B7qpYe9C.js} +1 -1
  65. package/packages/web/dist/assets/{format-D2I0duZa.js → format-PHdEBNOi.js} +1 -1
  66. package/packages/web/dist/assets/{ganttDiagram-6RSMTGT7-DxYJmG9o.js → ganttDiagram-6RSMTGT7-SfBsqMmx.js} +1 -1
  67. package/packages/web/dist/assets/{gitGraphDiagram-PVQCEYII-COK5YOTD.js → gitGraphDiagram-PVQCEYII-CuM4Rl_i.js} +1 -1
  68. package/packages/web/dist/assets/{index-wBXQMkGI.js → index-D_1ogxg1.js} +2 -2
  69. package/packages/web/dist/assets/{infoDiagram-5YYISTIA-BamRN_Nn.js → infoDiagram-5YYISTIA-Dq-32Oj4.js} +1 -1
  70. package/packages/web/dist/assets/{ishikawaDiagram-YF4QCWOH-DuOHdpEH.js → ishikawaDiagram-YF4QCWOH-BLFHCj_o.js} +1 -1
  71. package/packages/web/dist/assets/{journeyDiagram-JHISSGLW-Bri7GKTW.js → journeyDiagram-JHISSGLW-CHP-Yu63.js} +1 -1
  72. package/packages/web/dist/assets/{kanban-definition-UN3LZRKU-BdkgUnzI.js → kanban-definition-UN3LZRKU-DRuehSqn.js} +1 -1
  73. package/packages/web/dist/assets/{linear-DOcNPCpB.js → linear-Bwz2vnH7.js} +1 -1
  74. package/packages/web/dist/assets/{mermaid.core-D9SbVBpN.js → mermaid.core-CeMMnCip.js} +4 -4
  75. package/packages/web/dist/assets/{mindmap-definition-RKZ34NQL-CVmLm7zn.js → mindmap-definition-RKZ34NQL-CEdqeoRW.js} +1 -1
  76. package/packages/web/dist/assets/{pieDiagram-4H26LBE5-D0N10eVU.js → pieDiagram-4H26LBE5-BdDXBx87.js} +1 -1
  77. package/packages/web/dist/assets/{quadrantDiagram-W4KKPZXB-B5gnYdTa.js → quadrantDiagram-W4KKPZXB-Bovd1Zhx.js} +1 -1
  78. package/packages/web/dist/assets/{requirementDiagram-4Y6WPE33-CwtxezNZ.js → requirementDiagram-4Y6WPE33-DiyrKnJ2.js} +1 -1
  79. package/packages/web/dist/assets/{sankeyDiagram-5OEKKPKP-D79O3cux.js → sankeyDiagram-5OEKKPKP-BuX7TRVw.js} +1 -1
  80. package/packages/web/dist/assets/{sequenceDiagram-3UESZ5HK-xhKcI6tI.js → sequenceDiagram-3UESZ5HK-BjetZWJo.js} +1 -1
  81. package/packages/web/dist/assets/{stateDiagram-AJRCARHV-DBYMAjcX.js → stateDiagram-AJRCARHV-4AUBV6dF.js} +1 -1
  82. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-Cq84MOtb.js +2 -0
  83. package/packages/web/dist/assets/{timeline-definition-PNZ67QCA-e-MYF4lm.js → timeline-definition-PNZ67QCA-BpQ8g8Bm.js} +1 -1
  84. package/packages/web/dist/assets/{vennDiagram-CIIHVFJN-B8UsvRDM.js → vennDiagram-CIIHVFJN-Cj99BWER.js} +1 -1
  85. package/packages/web/dist/assets/{wardley-L42UT6IY-1ucACjTl.js → wardley-L42UT6IY-S7AGTOWw.js} +1 -1
  86. package/packages/web/dist/assets/{wardleyDiagram-YWT4CUSO-DJufLd_u.js → wardleyDiagram-YWT4CUSO-LNXkUA7S.js} +1 -1
  87. package/packages/web/dist/assets/{xychartDiagram-2RQKCTM6-qqaZNlu9.js → xychartDiagram-2RQKCTM6-DGdTh4Xk.js} +1 -1
  88. package/packages/web/dist/index.html +1 -1
  89. package/packages/web/dist/sw.js +1 -1
  90. package/packages/web/dist/assets/channel-DDgTXrGP.js +0 -2
  91. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-DU1kd_Vu.js +0 -2
  92. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-DU1kd_Vu.js +0 -2
  93. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CZtr9Jvq.js +0 -2
@@ -36,6 +36,11 @@ import { DEFAULT_RECOVERY } from "./recovery-config.js";
36
36
  import { DEFAULT_ATTACHMENTS, sanitizeAllowedTypes } from "./attachments-config.js";
37
37
  import { DEFAULT_CURATION } from "./curation-config.js";
38
38
  import { DEFAULT_ENVIRONMENT_PROMPT } from "./environment-prompt.js";
39
+ import { DEFAULT_TRANSCRIPTS_MODE } from "./transcripts.js";
40
+ import { DEFAULT_CREDENTIALS_MODE } from "./claude-credentials.js";
41
+ import { DEFAULT_INSTRUCTIONS_MODE } from "./claude-instructions.js";
42
+ import { DEFAULT_HOOKS_MODE } from "./claude-settings.js";
43
+ import { DEFAULT_MCP_SERVERS_MODE } from "./claude-mcp.js";
39
44
  /** Groups the Settings screen renders, in display order. */
40
45
  export const GROUPS = [
41
46
  { id: "curation", label: "Curation", description: "Per-file token budgets the post-turn sweeper keeps its curated files under." },
@@ -219,6 +224,37 @@ export const FIELDS = [
219
224
  { key: "stateDir", group: "advanced", label: "State dir", type: "string", envVars: ["PADDOCK_STATE_DIR"], default: null, editable: false },
220
225
  { key: "herdctlConfigPath", group: "advanced", label: "herdctl config path", type: "string", envVars: ["PADDOCK_HERDCTL_CONFIG"], default: null, editable: false },
221
226
  { key: "webDist", group: "advanced", label: "Web dist", type: "string", envVars: ["PADDOCK_WEB_DIST"], default: null, editable: false },
227
+ // What this instance shares with the host's Claude Code (#691). READ-ONLY on
228
+ // purpose: `host` means paddock writes to the user's real transcript files,
229
+ // and the symlinks that implement it are planted at agent-registration time —
230
+ // a toggle that silently does nothing until the next boot would be worse than
231
+ // no toggle. It is surfaced because "what is this instance sharing?" should be
232
+ // answerable without reading a YAML file.
233
+ { key: "claude.transcripts", group: "advanced", label: "Transcripts", help: "own = Paddock's own, in each project's .chats/; host = your ~/.claude transcripts, shared live.", type: "string", envVars: ["PADDOCK_CLAUDE_TRANSCRIPTS"], default: DEFAULT_TRANSCRIPTS_MODE, editable: false },
234
+ // Read-only for the same reason, plus one of its own: the secure-storage
235
+ // variable it sets is read by Claude Code when a turn's process starts, so a
236
+ // live toggle would apply to some turns and not others.
237
+ { key: "claude.credentials", group: "advanced", label: "Credentials", help: "host = this machine's Claude Code login (macOS Keychain, or your ~/.claude/.credentials.json); own = only a login of this instance's.", type: "string", envVars: ["PADDOCK_CLAUDE_CREDENTIALS"], default: DEFAULT_CREDENTIALS_MODE, editable: false },
238
+ // Read-only for the same reasons. `hooks` is the one worth finding here even
239
+ // though it cannot be changed here: "does this instance run the shell commands
240
+ // my ~/.claude/settings.json binds to tool use?" is a question with a security
241
+ // answer, and it should be readable without opening a YAML file.
242
+ { key: "claude.instructions", group: "advanced", label: "Instructions", help: "own = this instance's own only; host = your ~/.claude CLAUDE.md, agents/, commands/ and plugins/ as well.", type: "string", envVars: ["PADDOCK_CLAUDE_INSTRUCTIONS"], default: DEFAULT_INSTRUCTIONS_MODE, editable: false },
243
+ { key: "claude.hooks", group: "advanced", label: "Hooks", help: "own = your ~/.claude/settings.json hooks do NOT run here (its other keys still apply); host = they do.", type: "string", envVars: ["PADDOCK_CLAUDE_HOOKS"], default: DEFAULT_HOOKS_MODE, editable: false },
244
+ // The fifth lever, which step 5 shipped without surfacing here. Read-only like
245
+ // its four siblings, and worth the row for the same reason `hooks` is: an MCP
246
+ // server is a process this instance spawns, so "is this instance running my
247
+ // machine's MCP servers?" should be answerable without opening a YAML file.
248
+ //
249
+ // NOTE what is deliberately absent: the top-level `mcpServers:` block (#691
250
+ // step 6), which declares servers rather than borrowing them. Its values hold
251
+ // resolved credentials — an MCP server's `env` is where a stdio server's API
252
+ // token lives — and every field in this table is serialised verbatim into the
253
+ // GET response, so a row for it would publish tokens to any authenticated UI
254
+ // user. There is no redacting variant of `FieldSpec`, and inventing one to
255
+ // display a server list is not worth the leak surface: the boot log already
256
+ // names every declared server, secret-free, via `describeServer`.
257
+ { key: "claude.mcpServers", group: "advanced", label: "MCP servers", help: "own = only the servers Paddock provides itself; host = the ones declared in your ~/.claude.json as well.", type: "string", envVars: ["PADDOCK_CLAUDE_MCP_SERVERS"], default: DEFAULT_MCP_SERVERS_MODE, editable: false },
222
258
  // Auth: read-only in v1 (misconfig can lock everyone out — issue #385). Only
223
259
  // the mode is surfaced; JWT/JWKS internals stay out of the API.
224
260
  { key: "auth.mode", group: "advanced", label: "Auth mode", type: "string", envVars: ["PADDOCK_AUTH_MODE"], default: "none", editable: false, sensitive: true },
@@ -0,0 +1,168 @@
1
+ /**
2
+ * The top-level `mcpServers:` block of `paddock.config.yaml` (#691 step 6):
3
+ * MCP servers a user declares **to paddock itself**.
4
+ *
5
+ * ## Why this is not another `claude:` key
6
+ *
7
+ * The five `claude:` levers all answer one question — *whose X does this instance
8
+ * use?* — and every one of them has a `host` side that borrows something the
9
+ * machine already has. `claude.mcpServers: host` therefore only helps a user who
10
+ * has already run `claude mcp add` on this box. Someone standing up paddock in a
11
+ * container, who wants Notion inside it, has nothing to borrow; the question they
12
+ * are asking is *"this instance should have this server"*, which is a declaration
13
+ * rather than an inheritance. So it is a sibling of `claude:`, not a member.
14
+ *
15
+ * ## Everything below the parse is shared with step 5
16
+ *
17
+ * `claude-mcp.ts` already built the hard half: the shape herdctl can carry, the
18
+ * per-working-directory merge, and — the trap — widening the keeper's
19
+ * `allowed_tools` by one `mcp__<name>__*` pattern per server, without which an
20
+ * attached server has every call auto-denied with no prompt and no log line. A
21
+ * declared server needs all of it identically, so this module produces
22
+ * {@link McpServerDefs} and hands them to {@link McpSources.declared}. The only
23
+ * thing that is genuinely new here is validation and secrets.
24
+ *
25
+ * ## Precedence
26
+ *
27
+ * `host` < declared < paddock's own. A name declared here beats the same name
28
+ * inherited from `~/.claude.json`, because this file is a statement about THIS
29
+ * instance and the other is ambient machine state. Paddock's own servers
30
+ * (`send_file`, the self-management tools, the browser server) still win, for the
31
+ * reason they already won over `host`: their configuration is box-specific and
32
+ * they are wired up by a different mechanism entirely. A collision with the
33
+ * browser server is warned about by name at boot rather than being silent; a
34
+ * collision with an INJECTED server is refused outright, because those are
35
+ * materialised as an HTTP bridge under the same `mcp__<name>__*` namespace and
36
+ * two servers claiming one namespace has no defined winner.
37
+ *
38
+ * ## Secrets, which is the real work here
39
+ *
40
+ * This is the first place in #691 where a user types a credential into paddock's
41
+ * own config file, and that file is git-tracked (and readable through the
42
+ * Settings screen's config path). So this module borrows the rule
43
+ * `management-config.ts` already sets for management tokens: **`env:VAR_NAME` is
44
+ * a reference, resolved from the environment at boot.** Here it is accepted
45
+ * anywhere a string is (`command`, each `args` entry, each `env` value, `url`),
46
+ * so there is one rule to learn rather than a list of blessed fields.
47
+ *
48
+ * Unlike a management token, an MCP `env` entry is not necessarily secret
49
+ * (`NOTION_VERSION: "2022-06-28"` is not), so an inline value is a WARNING and
50
+ * not an error — but the warning is emitted for any key that looks like a
51
+ * credential, and nothing in this module ever prints a value. Every diagnostic
52
+ * names keys, variable names and server names only; a `url` is reported with its
53
+ * query string and userinfo stripped, because that is where an API key rides.
54
+ * {@link describeServer} is the single place a server is rendered for a human,
55
+ * so there is one function to audit rather than a scattering of template strings.
56
+ *
57
+ * The resolved values do land in the frozen `PaddockConfig` (as management tokens
58
+ * already do). They must never reach an API response: `instance-config.ts`
59
+ * publishes only the fields in its own `FIELDS` table, and this block is
60
+ * deliberately absent from it.
61
+ *
62
+ * ## Validation is strict, and drops rather than degrades
63
+ *
64
+ * Step 5 passes a host server it cannot carry faithfully, with a warning, on the
65
+ * grounds that a user who declared a server elsewhere should get it plus a
66
+ * warning rather than nothing plus a warning. That reasoning inverts here,
67
+ * because the user typed this file: an unusable declaration is a mistake they can
68
+ * fix, and a `headers:` block that is silently dropped is an authentication
69
+ * failure that looks like a broken server. So anything that cannot be carried is
70
+ * an ERROR and that server is not attached — including an unknown key, which is
71
+ * how a typo (`arg:` for `args:`) otherwise becomes a server that starts wrong.
72
+ *
73
+ * Never a boot failure, though: one bad server drops itself and the rest attach,
74
+ * matching `resolveManagementApiConfig`. A typo in a capability must not take an
75
+ * instance down.
76
+ */
77
+ import type { McpServerDefs, McpServerDef } from "./claude-mcp.js";
78
+ /** On-disk shape of one entry of the `mcpServers:` block. Every field optional. */
79
+ export interface McpServerConfigFile {
80
+ /** Executable for a stdio server. Mutually exclusive with {@link url}. */
81
+ command?: string;
82
+ /** Arguments for {@link command}. */
83
+ args?: string[];
84
+ /** Environment for the server process. Values may be `env:VAR_NAME`. */
85
+ env?: Record<string, string>;
86
+ /** Endpoint for a streamable-HTTP server. Mutually exclusive with {@link command}. */
87
+ url?: string;
88
+ /** Accepted only as `stdio`/`http`, and only when it agrees with the above. */
89
+ type?: string;
90
+ /** Present only in a MISCONFIGURED file: the engine cannot carry headers. */
91
+ headers?: Record<string, string>;
92
+ }
93
+ /** On-disk shape of the whole block: a map of server name → declaration. */
94
+ export type McpServersConfigFile = Record<string, McpServerConfigFile | null | undefined>;
95
+ /** Outcome of resolving the block, with everything worth telling the operator. */
96
+ export interface DeclaredMcpResolution {
97
+ /** The servers that will be attached to every keeper. */
98
+ servers: McpServerDefs;
99
+ /** Declarations that could not be carried (logged at error level, server dropped). */
100
+ errors: string[];
101
+ /** Advice and fail-closed drops (logged at warn level). */
102
+ warnings: string[];
103
+ }
104
+ /**
105
+ * The prefix that makes a string an environment reference rather than a literal.
106
+ * Same spelling as `managementApi.clients.<id>.auth.ref`, deliberately: an
107
+ * operator should have to learn it once.
108
+ */
109
+ export declare const ENV_REF_PREFIX = "env:";
110
+ /**
111
+ * Server names paddock materialises itself, which a declaration may not take.
112
+ *
113
+ * Kept as literals rather than imported from `send-file-mcp.ts`/`self-mcp.ts` so
114
+ * config resolution stays free of the runtime modules — `mcp-servers-reserved`
115
+ * in the unit tests asserts the two spellings still agree.
116
+ */
117
+ export declare const RESERVED_MCP_SERVER_NAMES: readonly string[];
118
+ /**
119
+ * A URL with everything credential-shaped removed — no query string, no
120
+ * fragment, no `user:pass@`. Used for every log line that mentions a `url`,
121
+ * because a remote MCP endpoint's API key is conventionally a query parameter.
122
+ * An unparseable URL is reported as its scheme only rather than echoed.
123
+ */
124
+ export declare function redactUrl(raw: string): string;
125
+ /**
126
+ * A server rendered for a human, carrying no secret. THE chokepoint: every
127
+ * notice, warning and error about a declared server goes through here, so
128
+ * auditing "can a token reach a log line?" is auditing one function.
129
+ *
130
+ * Args and env values are counted, never printed — an API key is as likely to be
131
+ * `--token=…` in `args` as it is to be an `env` value. The command is reduced to
132
+ * its basename for the same reason, which the leak test in
133
+ * `test/unit/mcp-servers.test.ts` is what forced: a full path is more useful in a
134
+ * log, but the rule "nothing from the block is echoed" is only worth having if it
135
+ * has no exceptions to remember. The server's own name is printed alongside and
136
+ * is what actually identifies it.
137
+ */
138
+ export declare function describeServer(name: string, def: McpServerDef): string;
139
+ /**
140
+ * Resolve the whole `mcpServers:` block against `env`.
141
+ *
142
+ * Never throws. A declaration paddock cannot carry is reported and dropped so one
143
+ * typo cannot take an instance down — the same posture as
144
+ * `resolveManagementApiConfig`, and for the same reason: this is a capability,
145
+ * and losing a capability is better than losing the instance.
146
+ */
147
+ export declare function resolveDeclaredMcpServers(file: McpServersConfigFile | undefined, env: Record<string, string | undefined>): DeclaredMcpResolution;
148
+ /** A line for the boot log, at a level (mirrors `HostMcpNotice`). */
149
+ export interface DeclaredMcpNotice {
150
+ level: "info" | "warn";
151
+ message: string;
152
+ }
153
+ /**
154
+ * What to say at boot about the declared servers — the answer to "what does this
155
+ * instance actually have?", which should be readable off the log rather than
156
+ * inferred from a YAML file plus an environment.
157
+ *
158
+ * Takes the already-resolved servers, so it cannot see a raw declaration and
159
+ * therefore cannot leak one; every line it produces goes through
160
+ * {@link describeServer}.
161
+ */
162
+ export declare function declaredMcpNotices(opts: {
163
+ servers: McpServerDefs;
164
+ /** Names inherited from `~/.claude.json`, for the shadowing notice. */
165
+ hostNames?: readonly string[];
166
+ /** Whether paddock's own browser server is attached (it wins a name clash). */
167
+ browserMcp?: boolean;
168
+ }): DeclaredMcpNotice[];
@@ -0,0 +1,305 @@
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(`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
+ return `${name} (${parts.join(", ")})`;
75
+ }
76
+ /**
77
+ * Resolve a string leaf: `env:VAR_NAME` reads `VAR_NAME`, anything else is a
78
+ * literal.
79
+ *
80
+ * An unset or blank variable FAILS the leaf, which drops the whole server. That
81
+ * is the fail-closed direction here for the same reason it is in
82
+ * `management-config.ts`: a server started without the credential it was
83
+ * declared with either fails every call confusingly or — worse for something
84
+ * that talks to a third party — connects unauthenticated.
85
+ */
86
+ function resolveLeaf(raw, env, where) {
87
+ if (!raw.startsWith(ENV_REF_PREFIX))
88
+ return { ok: true, value: raw };
89
+ const varName = raw.slice(ENV_REF_PREFIX.length).trim();
90
+ if (varName.length === 0)
91
+ return { ok: false, error: `${where}: \`env:\` names no variable` };
92
+ const value = env[varName];
93
+ if (value === undefined || value.trim().length === 0) {
94
+ return {
95
+ ok: false,
96
+ error: `${where}: environment variable ${varName} is unset or empty`,
97
+ };
98
+ }
99
+ return { ok: true, value };
100
+ }
101
+ /**
102
+ * Validate + resolve one declaration into the shape herdctl's `McpServerSchema`
103
+ * carries, or explain why it cannot be.
104
+ *
105
+ * The rejections are the interesting part, and each is a thing step 5 discovered
106
+ * the engine strips silently:
107
+ *
108
+ * - **`headers`** — there is no field for them, so a bearer-authenticated server
109
+ * would arrive unauthenticated AND miss its stored OAuth token, which is keyed
110
+ * on a hash that includes the headers. Step 5 passes such a host server with a
111
+ * warning; a hand-written declaration gets an error instead, because the user
112
+ * typed the header and can act on being told it does nothing.
113
+ * - **`type: sse`** — every `url` is mapped to `type: "http"` downstream, so an
114
+ * sse server is silently downgraded, with the same OAuth-key consequence.
115
+ * - **an unknown key** — the only defence against `arg:`/`envs:`/`commands:`,
116
+ * which would otherwise yield a server that starts with the wrong argv.
117
+ */
118
+ function narrowDeclaration(name, raw, env) {
119
+ const where = `mcpServers.${name}`;
120
+ const errors = [];
121
+ const warnings = [];
122
+ const fail = (msg) => ({
123
+ errors: [...errors, `${msg} — not attached`],
124
+ warnings,
125
+ });
126
+ if (!NAME_RE.test(name)) {
127
+ return fail(`${where}: a server name may contain only letters, digits, \`_\` and \`-\` (its tools are ` +
128
+ `named mcp__${name}__<tool>, and the allowlist pattern is built from that)`);
129
+ }
130
+ if (RESERVED_MCP_SERVER_NAMES.includes(name)) {
131
+ return fail(`${where}: "${name}" is reserved for a server paddock injects itself, and two servers ` +
132
+ `cannot share the mcp__${name}__* namespace`);
133
+ }
134
+ if (!isRecord(raw))
135
+ return fail(`${where}: is not a mapping`);
136
+ const unknown = Object.keys(raw).filter((k) => !KNOWN_KEYS.has(k));
137
+ if (unknown.length > 0) {
138
+ return fail(`${where}: unrecognised key(s) ${unknown.join(", ")} (supported: command, args, env, url) ` +
139
+ `— a mistyped key would otherwise be dropped without a trace`);
140
+ }
141
+ if (raw.headers !== undefined) {
142
+ const keys = isRecord(raw.headers) ? Object.keys(raw.headers) : [];
143
+ return fail(`${where}: \`headers\`${keys.length > 0 ? ` (${keys.join(", ")})` : ""} cannot be ` +
144
+ `carried — the engine's MCP schema has no field for them, so the server would arrive ` +
145
+ `unauthenticated and its stored OAuth token (keyed on a hash that includes the headers) ` +
146
+ `would not be found either`);
147
+ }
148
+ const hasCommand = typeof raw.command === "string" && raw.command.trim().length > 0;
149
+ const hasUrl = typeof raw.url === "string" && raw.url.trim().length > 0;
150
+ if (hasCommand && hasUrl) {
151
+ return fail(`${where}: declares both a \`command\` and a \`url\`; a server is one or the other`);
152
+ }
153
+ if (!hasCommand && !hasUrl) {
154
+ return fail(`${where}: declares neither a \`command\` nor a \`url\`, so there is nothing to start`);
155
+ }
156
+ if (raw.type !== undefined) {
157
+ const type = String(raw.type).trim().toLowerCase();
158
+ if (type === "sse") {
159
+ return fail(`${where}: \`type: sse\` cannot be carried — every \`url\` server is connected to as ` +
160
+ `streamable HTTP downstream, so an sse server would be silently downgraded (and its ` +
161
+ `stored OAuth token, keyed on a hash that includes the type, not found). Declare it ` +
162
+ `as \`http\` if the server supports it`);
163
+ }
164
+ const expected = hasUrl ? "http" : "stdio";
165
+ if (type !== expected) {
166
+ return fail(`${where}: \`type: ${type}\` disagrees with the declaration (expected ${expected})`);
167
+ }
168
+ }
169
+ const server = {};
170
+ if (hasCommand) {
171
+ const res = resolveLeaf(raw.command.trim(), env, `${where}.command`);
172
+ if (!res.ok)
173
+ return { errors, warnings: [...warnings, `${res.error} — server not attached`] };
174
+ server.command = res.value;
175
+ }
176
+ if (hasUrl) {
177
+ const res = resolveLeaf(raw.url.trim(), env, `${where}.url`);
178
+ if (!res.ok)
179
+ return { errors, warnings: [...warnings, `${res.error} — server not attached`] };
180
+ server.url = res.value;
181
+ // A key in the query string is the usual way a remote MCP endpoint is
182
+ // authenticated now that `headers` cannot be carried, and this file is
183
+ // git-tracked. Say so once; never echo the query itself.
184
+ if (!raw.url.startsWith(ENV_REF_PREFIX)) {
185
+ let parsed;
186
+ try {
187
+ parsed = new URL(server.url);
188
+ }
189
+ catch {
190
+ /* an unparseable url is the engine's problem, not a secrets one */
191
+ }
192
+ if (parsed && (parsed.search || parsed.username || parsed.password)) {
193
+ warnings.push(`${where}.url: carries a query string or userinfo, which is where an API key usually ` +
194
+ `rides. This file is git-tracked — prefer \`url: ${ENV_REF_PREFIX}VAR_NAME\` and ` +
195
+ `keep the value in the environment`);
196
+ }
197
+ }
198
+ }
199
+ if (raw.args !== undefined) {
200
+ if (!Array.isArray(raw.args) || !raw.args.every((a) => typeof a === "string")) {
201
+ return fail(`${where}.args: must be a list of strings`);
202
+ }
203
+ const args = [];
204
+ for (const [i, arg] of raw.args.entries()) {
205
+ const res = resolveLeaf(arg, env, `${where}.args[${i}]`);
206
+ if (!res.ok)
207
+ return { errors, warnings: [...warnings, `${res.error} — server not attached`] };
208
+ args.push(res.value);
209
+ }
210
+ if (args.length > 0)
211
+ server.args = args;
212
+ }
213
+ if (raw.env !== undefined) {
214
+ if (!isRecord(raw.env))
215
+ return fail(`${where}.env: must be a mapping of NAME to value`);
216
+ const resolved = {};
217
+ for (const [key, value] of Object.entries(raw.env)) {
218
+ if (typeof value !== "string") {
219
+ return fail(`${where}.env.${key}: must be a string (values are passed to a process verbatim)`);
220
+ }
221
+ const res = resolveLeaf(value, env, `${where}.env.${key}`);
222
+ if (!res.ok)
223
+ return { errors, warnings: [...warnings, `${res.error} — server not attached`] };
224
+ resolved[key] = res.value;
225
+ if (!value.startsWith(ENV_REF_PREFIX) && SECRET_ISH_KEY_RE.test(key)) {
226
+ warnings.push(`${where}.env.${key}: looks like a credential and is written into the config file ` +
227
+ `itself, which is git-tracked — prefer \`${key}: ${ENV_REF_PREFIX}VAR_NAME\` and set ` +
228
+ `the value in the environment`);
229
+ }
230
+ }
231
+ if (Object.keys(resolved).length > 0)
232
+ server.env = resolved;
233
+ }
234
+ return { server, errors, warnings };
235
+ }
236
+ /**
237
+ * Resolve the whole `mcpServers:` block against `env`.
238
+ *
239
+ * Never throws. A declaration paddock cannot carry is reported and dropped so one
240
+ * typo cannot take an instance down — the same posture as
241
+ * `resolveManagementApiConfig`, and for the same reason: this is a capability,
242
+ * and losing a capability is better than losing the instance.
243
+ */
244
+ export function resolveDeclaredMcpServers(file, env) {
245
+ const servers = {};
246
+ const errors = [];
247
+ const warnings = [];
248
+ if (file === undefined || file === null)
249
+ return { servers, errors, warnings };
250
+ if (!isRecord(file)) {
251
+ errors.push("mcpServers: must be a mapping of server name to declaration — ignored");
252
+ return { servers, errors, warnings };
253
+ }
254
+ for (const [rawName, decl] of Object.entries(file)) {
255
+ const name = rawName.trim();
256
+ if (name.length === 0) {
257
+ errors.push("mcpServers: a server name is empty — not attached");
258
+ continue;
259
+ }
260
+ const res = narrowDeclaration(name, decl, env);
261
+ errors.push(...res.errors);
262
+ warnings.push(...res.warnings);
263
+ if (res.server)
264
+ servers[name] = res.server;
265
+ }
266
+ return { servers, errors, warnings };
267
+ }
268
+ /**
269
+ * What to say at boot about the declared servers — the answer to "what does this
270
+ * instance actually have?", which should be readable off the log rather than
271
+ * inferred from a YAML file plus an environment.
272
+ *
273
+ * Takes the already-resolved servers, so it cannot see a raw declaration and
274
+ * therefore cannot leak one; every line it produces goes through
275
+ * {@link describeServer}.
276
+ */
277
+ export function declaredMcpNotices(opts) {
278
+ const names = Object.keys(opts.servers);
279
+ if (names.length === 0)
280
+ return [];
281
+ const notices = [
282
+ {
283
+ level: "info",
284
+ message: `MCP servers declared by this instance (\`mcpServers:\`): ` +
285
+ `${names.map((n) => describeServer(n, opts.servers[n])).join("; ")}. ` +
286
+ `Every project's keeper gets all of them.`,
287
+ },
288
+ ];
289
+ const shadowed = names.filter((n) => opts.hostNames?.includes(n));
290
+ if (shadowed.length > 0) {
291
+ notices.push({
292
+ level: "info",
293
+ message: `${shadowed.join(", ")} ${shadowed.length === 1 ? "is" : "are"} also declared in your ` +
294
+ `~/.claude.json (\`claude.mcpServers: host\`); this instance's own declaration wins.`,
295
+ });
296
+ }
297
+ if (opts.browserMcp && names.includes("playwright")) {
298
+ notices.push({
299
+ level: "warn",
300
+ message: `mcpServers.playwright collides with paddock's own browser server, which WINS — your ` +
301
+ `declaration is not attached. Set \`browserMcp: false\` to use yours instead.`,
302
+ });
303
+ }
304
+ return notices;
305
+ }
@@ -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): removes its transcript JSONL and clears any archived/starred flag so a future session id can't inherit it. Responds 200 with `{ ok, removed }`.",
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
- if (gone)
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);
@@ -38,6 +38,7 @@
38
38
  import { promises as fs } from "node:fs";
39
39
  import path from "node:path";
40
40
  import { keeperAgentName } from "./herdctl.js";
41
+ import { classifyAgentError } from "./agent-errors.js";
41
42
  import { DEFAULT_CURATION, resolveCurationConfig, } from "./curation-config.js";
42
43
  import { curatorTriggerOf, triggerPromptFileAbsPath, } from "./trigger-config.js";
43
44
  const STATE_FILE = "sweep-state.json";
@@ -204,7 +205,18 @@ export class SweepService {
204
205
  lastSweptSessionMtime: wm.lastSweptSessionMtime,
205
206
  lastSweptAt: Date.now(),
206
207
  });
207
- this.log.error({ err, slug }, "sweep: sweeper run errored (non-fatal)");
208
+ // #684: this is a best-effort background task whose failure is already
209
+ // labelled non-fatal, so a recognised cause gets ONE line at warn — no
210
+ // `err`, because pino serialising it is what put four copies of the
211
+ // curator system prompt on a first-time user's terminal. An unrecognised
212
+ // failure keeps the full object: that is the case somebody needs it.
213
+ const cause = classifyAgentError(err);
214
+ if (cause === undefined) {
215
+ this.log.error({ err, slug }, "sweep: sweeper run errored (non-fatal)");
216
+ }
217
+ else {
218
+ this.log.warn({ slug }, `sweep: skipped, ${cause} (non-fatal — chat is unaffected)`);
219
+ }
208
220
  }
209
221
  finally {
210
222
  this.running.delete(slug);