@indigoai-us/hq-cli 5.50.0 → 5.50.2

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 (46) hide show
  1. package/dist/commands/mcp-registration.d.ts +905 -0
  2. package/dist/commands/mcp-registration.js +2001 -0
  3. package/dist/commands/mcp-status.d.ts +130 -0
  4. package/dist/commands/mcp-status.js +406 -0
  5. package/dist/commands/onboard-warning.d.ts +7 -0
  6. package/dist/commands/onboard-warning.js +14 -0
  7. package/dist/commands/onboard.js +5 -5
  8. package/dist/commands/pack-install.d.ts +74 -0
  9. package/dist/commands/pack-install.js +493 -14
  10. package/dist/commands/packs.js +42 -4
  11. package/dist/commands/pkg-install.js +5 -2
  12. package/dist/index.js +7 -2
  13. package/dist/types.d.ts +26 -1
  14. package/dist/utils/contribution-table.d.ts +103 -0
  15. package/dist/utils/contribution-table.js +65 -0
  16. package/dist/utils/pack-contributions.d.ts +93 -10
  17. package/dist/utils/pack-contributions.js +140 -48
  18. package/dist/utils/secrets-cache.d.ts +9 -0
  19. package/dist/utils/secrets-cache.js +24 -2
  20. package/dist/utils/version-gate.d.ts +40 -1
  21. package/dist/utils/version-gate.js +91 -20
  22. package/package.json +3 -2
  23. package/scripts/generate-scan-packages-table.mjs +113 -0
  24. package/src/commands/mcp-registration.test.ts +2787 -0
  25. package/src/commands/mcp-registration.ts +2612 -0
  26. package/src/commands/mcp-status.test.ts +483 -0
  27. package/src/commands/mcp-status.ts +575 -0
  28. package/src/commands/mcp-status.us011.test.ts +243 -0
  29. package/src/commands/onboard-warning.test.ts +26 -0
  30. package/src/commands/onboard-warning.ts +12 -0
  31. package/src/commands/onboard.ts +4 -7
  32. package/src/commands/pack-install.test.ts +733 -0
  33. package/src/commands/pack-install.ts +582 -13
  34. package/src/commands/packs.ts +45 -1
  35. package/src/commands/pkg-install.ts +4 -1
  36. package/src/index.ts +6 -0
  37. package/src/types.ts +28 -9
  38. package/src/utils/contribution-table.ts +83 -0
  39. package/src/utils/pack-contributions.test.ts +310 -25
  40. package/src/utils/pack-contributions.ts +194 -47
  41. package/src/utils/secrets-cache.ts +22 -0
  42. package/src/utils/version-gate.test.ts +122 -0
  43. package/src/utils/version-gate.ts +109 -13
  44. package/test/e2e/smoke-install-mcp.sh +113 -0
  45. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +1 -0
  46. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +11 -0
@@ -0,0 +1,2001 @@
1
+ /**
2
+ * MCP registration — the safe-write substrate for user-global config merges (US-006).
3
+ *
4
+ * Symlink contributions (workers, knowledge, skills, commands, hooks, policies,
5
+ * scripts) reach the host via a single `ln -s` into a well-known directory. The
6
+ * `mcp` key is DIFFERENT: it is `wire: 'merge'` in the single-source
7
+ * `CONTRIBUTION_TABLE`, meaning its per-server manifests are MERGED into the
8
+ * shared Claude (JSON) + Codex (TOML) agent configs rather than symlinked. A
9
+ * merge contribution must NEVER be symlinked — doing so would dump raw manifest
10
+ * JSON into a host directory instead of registering the server.
11
+ *
12
+ * This module ships the CRASH-SAFE, REVERSIBLE config-write CORE that every
13
+ * merge emitter (US-007 Claude/JSON, US-008 Codex/TOML, US-009 uninstall) builds
14
+ * on. Editing a user's `~/.claude.json` / `~/.mcp.json` / `~/.codex/config.toml`
15
+ * is the single highest-danger surface in the project: those files hold the
16
+ * user's OWN non-HQ servers (figma, paper, superhuman, vyg-internal). A botched
17
+ * write corrupts them and `hq rescue` cannot un-corrupt a user config (it rolls
18
+ * back the HQ tree, not `~/`). So the only remediation is the backup this module
19
+ * takes BEFORE the first byte is written.
20
+ *
21
+ * The substrate is format-agnostic: callers pass a `parse`/`serialize`/`assertEntry`
22
+ * triple ({@link ConfigFormat}) so the SAME read->lock->merge->write->verify
23
+ * machinery drives both the JSON (Claude) and TOML (Codex) emitters without
24
+ * duplicating the dangerous parts. {@link writeConfigAtomic} is the entry point.
25
+ *
26
+ * Safe-write algorithm (per the review-report §6.2 data-flow), run inside an
27
+ * advisory O_EXCL lock that is held across read->merge->write:
28
+ *
29
+ * [0] acquire O_EXCL lock (~/.<file>.hqlock, stale-timeout + PID)
30
+ * [1] backup target -> ~/.hq/backups/mcp/<iso>-<pack>/ (mode 0600), MANDATORY,
31
+ * before the first write byte (reuses the hq rescue backup pattern)
32
+ * [2] read(target): ENOENT -> empty doc; 0-byte/whitespace -> empty doc;
33
+ * non-empty parse error -> ConfigParseError ABORT (write nothing, NEVER
34
+ * regenerate-from-template); other read error -> ConfigPermissionError ABORT
35
+ * [3] re-read INSIDE the lock (never carry a pre-lock parse) — closes the
36
+ * concurrent-write race
37
+ * [4..6] caller's merge fn transforms the doc; serialize via the format
38
+ * [7] write temp file IN THE SAME DIR (mode 0600) -> fsync -> rename onto
39
+ * REALPATH(target). For the ~/.mcp.json symlink: realpath FIRST, rename
40
+ * onto the RESOLVED file, NEVER replace the link.
41
+ * [8] verify: re-read + re-parse + assert entry present; on failure restore
42
+ * from the [1] backup and raise PartialRegistrationError
43
+ * [9] release lock (finally)
44
+ *
45
+ * Desired-state-convergence semantics: re-running is safe from ANY state,
46
+ * including PARTIAL — the merge is idempotent per surface, the backup is
47
+ * timestamped (never overwritten), and a crash leaves the original intact.
48
+ *
49
+ * US-007 (Claude/JSON) and US-008 (Codex/TOML) fill in the per-runtime EMITTERS
50
+ * on top of this core; {@link registerServer} fans one per-server manifest out
51
+ * across BOTH surfaces (Claude always; Codex when `~/.codex` exists, else a
52
+ * first-class skip), and {@link registerMcpServers} is the pack-install routing
53
+ * seam over it.
54
+ */
55
+
56
+ !function(){try{var e="undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof globalThis?globalThis:"undefined"!=typeof self?self:{},n=(new e.Error).stack;n&&(e._sentryDebugIds=e._sentryDebugIds||{},e._sentryDebugIds[n]="d4f678fe-c88f-5d08-b6bc-1e591ac8c4b6")}catch(e){}}();
57
+ import * as crypto from 'crypto';
58
+ import * as fs from 'fs';
59
+ import * as os from 'os';
60
+ import * as path from 'path';
61
+ import { parse as parseToml, stringify as stringifyToml } from 'smol-toml';
62
+ // ---------------------------------------------------------------------------
63
+ // Named error classes (no bare catch-all anywhere in this module).
64
+ //
65
+ // Every failure mode the safe-write core can hit gets a distinct, instanceof-
66
+ // checkable class with a stable `code`. The wider MCP feature surface
67
+ // (US-007..US-013) also throws these, so they are all declared here once.
68
+ // ---------------------------------------------------------------------------
69
+ /** Base for every MCP-registration error; carries a stable machine-checkable `code`. */
70
+ export class McpRegistrationError extends Error {
71
+ constructor(message) {
72
+ super(message);
73
+ this.name = new.target.name;
74
+ // Restore the prototype chain across the TS->ES* `extends Error` downlevel so
75
+ // `instanceof` holds on the concrete subclass.
76
+ Object.setPrototypeOf(this, new.target.prototype);
77
+ }
78
+ }
79
+ /**
80
+ * A NON-EMPTY config file did not parse (hand-edited / corrupted). The safe-write
81
+ * core ABORTS on this — it writes NOTHING and NEVER regenerates from a template,
82
+ * so a user's figma/superhuman/paper entries can never be clobbered by a
83
+ * regenerate-on-parse-error path.
84
+ */
85
+ export class ConfigParseError extends McpRegistrationError {
86
+ code = 'ConfigParseError';
87
+ }
88
+ /**
89
+ * A read error that is NOT ENOENT (e.g. EACCES, broken symlink, EISDIR). The
90
+ * safe-write core ABORTS — it does not guess, does not fabricate, does not
91
+ * overwrite.
92
+ */
93
+ export class ConfigPermissionError extends McpRegistrationError {
94
+ code = 'ConfigPermissionError';
95
+ }
96
+ /**
97
+ * A server name is already present in the target config with a DIFFERENT
98
+ * definition than the one being merged (def-equal = idempotent no-op;
99
+ * def-differs = this error). Emitters (US-007/008) raise it; declared here so
100
+ * the whole feature shares one taxonomy.
101
+ */
102
+ export class McpNameCollisionError extends McpRegistrationError {
103
+ code = 'McpNameCollisionError';
104
+ }
105
+ /**
106
+ * The Codex runtime is absent (`~/.codex` missing). This is a FIRST-CLASS SKIP,
107
+ * not a crash and NEVER a mkdir-p-fabricate. Thrown only where a caller needs to
108
+ * distinguish "Codex not installed" from a real error; the substrate exposes
109
+ * {@link codexHome} + {@link codexConfigPath} so emitters can branch on absence
110
+ * without ever creating the directory.
111
+ */
112
+ export class CodexNotInstalledError extends McpRegistrationError {
113
+ code = 'CodexNotInstalledError';
114
+ }
115
+ /** A per-server MCP manifest failed shape/transport validation. */
116
+ export class McpManifestError extends McpRegistrationError {
117
+ code = 'McpManifestError';
118
+ }
119
+ /**
120
+ * Verify-after-write found the merged entry MISSING or the file unreadable after
121
+ * the atomic rename — a partial/torn registration. The substrate restores the
122
+ * target from the pre-write backup before raising this, so the file is left in
123
+ * its original state, never a half-written one.
124
+ */
125
+ export class PartialRegistrationError extends McpRegistrationError {
126
+ code = 'PartialRegistrationError';
127
+ /** The backup directory the target was (or could be) restored from. */
128
+ backupDir;
129
+ constructor(message, backupDir) {
130
+ super(message);
131
+ this.backupDir = backupDir;
132
+ }
133
+ }
134
+ /**
135
+ * Thrown by not-yet-implemented seams (the US-007/US-008 Claude/Codex emitters).
136
+ * Carries a stable `code` so callers/tests can identify it without string-
137
+ * matching the message, and a `story` for at-a-glance attribution. KEPT from the
138
+ * US-005 seam: the merge EMITTERS are still pending after US-006 (which ships the
139
+ * safe-write substrate beneath them).
140
+ */
141
+ export class NotImplementedError extends Error {
142
+ /** Stable, machine-checkable discriminator. */
143
+ code = 'NotImplemented';
144
+ /** The PRD story that lands the real implementation. */
145
+ story;
146
+ constructor(message, story = 'US-007') {
147
+ super(message);
148
+ this.name = 'NotImplementedError';
149
+ this.story = story;
150
+ Object.setPrototypeOf(this, NotImplementedError.prototype);
151
+ }
152
+ }
153
+ /** Resolve the env, defaulting `home` to the real `os.homedir()` for production callers. */
154
+ export function resolveEnv(env) {
155
+ const home = env?.home ?? os.homedir();
156
+ if (!home) {
157
+ throw new ConfigPermissionError('cannot resolve a home directory for MCP config writes (os.homedir() returned empty)');
158
+ }
159
+ return { home };
160
+ }
161
+ /** `~/.hq/backups/mcp` — the timestamped backup root (reuses the hq rescue location). */
162
+ export function backupRoot(env) {
163
+ return path.join(env.home, '.hq', 'backups', 'mcp');
164
+ }
165
+ // ---------------------------------------------------------------------------
166
+ // Append-only audit log (US-011).
167
+ //
168
+ // Every register AND every unregister appends exactly ONE JSONL line PER SURFACE
169
+ // acted on to `~/.hq/logs/mcp-registry.log`. The log is the durable provenance
170
+ // trail a corrupted/PARTIAL config can be reconstructed from: each line names the
171
+ // action, pack, server, transport, target, the file written, and a sha256 of the
172
+ // whole config file CONTENTS before/after the write (prevHash/newHash) — which
173
+ // proves a mutation WITHOUT leaking content. No Bearer/secret VALUE ever appears
174
+ // in a line (the `target` field is a url or command, never a header).
175
+ // ---------------------------------------------------------------------------
176
+ /** `~/.hq/logs/mcp-registry.log` — the append-only JSONL audit log (mirrors backupRoot style). */
177
+ export function mcpRegistryLogPath(env) {
178
+ return path.join(env.home, '.hq', 'logs', 'mcp-registry.log');
179
+ }
180
+ /**
181
+ * sha256 (hex) of a file's CONTENTS, or '' when the file is absent/unreadable.
182
+ * Hashing the WHOLE file is intentional — it proves a mutation happened without
183
+ * leaking any content into the audit log.
184
+ */
185
+ export function hashFileContents(target) {
186
+ try {
187
+ const data = fs.readFileSync(realpathOrSelf(target));
188
+ return crypto.createHash('sha256').update(data).digest('hex');
189
+ }
190
+ catch {
191
+ // ENOENT (and any read failure) => no hash. Best-effort by design.
192
+ return '';
193
+ }
194
+ }
195
+ /**
196
+ * Append EXACTLY ONE JSON line (`JSON.stringify(entry) + '\n'`) to
197
+ * `~/.hq/logs/mcp-registry.log`, creating `~/.hq/logs/` as needed. BEST-EFFORT /
198
+ * NEVER-THROWS: a logging failure must NEVER break (or corrupt) the real
199
+ * register/unregister operation, so every error is swallowed. The `target` field
200
+ * is defensively passed through {@link redactSecrets} against the supplied
201
+ * `secrets` set (normally empty — `target` is a url/command, never a header).
202
+ *
203
+ * @param env the resolved env (tests pass a tmpdir home).
204
+ * @param entry the {@link AuditLogEntry} to append.
205
+ * @param secrets resolved secret plaintexts to defensively redact from `target`.
206
+ */
207
+ export function appendAuditLog(env, entry, secrets = []) {
208
+ try {
209
+ const safeTarget = redactSecrets(entry.target, secrets);
210
+ const line = `${JSON.stringify({ ...entry, target: safeTarget })}\n`;
211
+ const logPath = mcpRegistryLogPath(env);
212
+ fs.mkdirSync(path.dirname(logPath), { recursive: true });
213
+ // 0600 file mode (nice-to-have): the audit log records pack/server/file paths.
214
+ fs.appendFileSync(logPath, line, { mode: 0o600 });
215
+ }
216
+ catch {
217
+ // Best-effort: a broken log must never corrupt the real operation. Swallow.
218
+ }
219
+ }
220
+ /** `~/.codex` — its ABSENCE is a first-class skip signal (never fabricated). */
221
+ export function codexHome(env) {
222
+ return path.join(env.home, '.codex');
223
+ }
224
+ /** `~/.codex/config.toml` — the GLOBAL Codex MCP config (not the project file). */
225
+ export function codexConfigPath(env) {
226
+ return path.join(codexHome(env), 'config.toml');
227
+ }
228
+ /** True iff the Codex runtime is installed (`~/.codex` exists). Never creates it. */
229
+ export function isCodexInstalled(env) {
230
+ try {
231
+ return fs.statSync(codexHome(env)).isDirectory();
232
+ }
233
+ catch {
234
+ // ENOENT (and any stat failure) => treat as not installed; never fabricate.
235
+ return false;
236
+ }
237
+ }
238
+ /** `~/.claude.json` — the PINNED single Claude surface (top-level mcpServers). */
239
+ export function claudeConfigPath(env) {
240
+ return path.join(env.home, '.claude.json');
241
+ }
242
+ /** `~/.mcp.json` — the project-pinned Claude surface (a symlink on this host). */
243
+ export function mcpJsonPath(env) {
244
+ return path.join(env.home, '.mcp.json');
245
+ }
246
+ /**
247
+ * Built-in JSON {@link ConfigFormat} for the Claude surfaces. Pretty-prints with
248
+ * a trailing newline; an empty doc is `{}`. Parsing the empty string is the
249
+ * caller's responsibility (the core normalizes blank files to `emptyDoc` BEFORE
250
+ * calling `parse`), so `parse` here always sees non-empty text.
251
+ */
252
+ export const jsonFormat = {
253
+ parse(text) {
254
+ const v = JSON.parse(text);
255
+ if (v === null || typeof v !== 'object' || Array.isArray(v)) {
256
+ throw new Error('top-level JSON value must be an object');
257
+ }
258
+ return v;
259
+ },
260
+ serialize(doc) {
261
+ return `${JSON.stringify(doc, null, 2)}\n`;
262
+ },
263
+ emptyDoc() {
264
+ return {};
265
+ },
266
+ assertEntry() {
267
+ // Default: presence is asserted by the caller-supplied format for a real
268
+ // emitter. The base JSON format is structure-only; emitters (US-007) pass a
269
+ // format whose assertEntry checks `doc.mcpServers[name]`.
270
+ throw new Error('jsonFormat.assertEntry is a placeholder — emitters supply a real one');
271
+ },
272
+ };
273
+ /**
274
+ * Read + parse a config file, normalizing the SHADOW cases (review-report §6.2):
275
+ *
276
+ * - SHADOW A: ENOENT -> { doc: emptyDoc, existed: false } (fresh host / Codex absent)
277
+ * - SHADOW B: 0-byte / whitespace-only -> { doc: emptyDoc, existed: false }
278
+ * - SHADOW C: NON-EMPTY + parse error -> throw {@link ConfigParseError} (ABORT,
279
+ * write nothing, never regenerate-from-template)
280
+ * - SHADOW D: any OTHER read error (EACCES, EISDIR, broken symlink) ->
281
+ * throw {@link ConfigPermissionError} (ABORT)
282
+ *
283
+ * @param target the config file path (a symlink is read THROUGH transparently)
284
+ * @param format the {@link ConfigFormat} (parser + empty-doc factory)
285
+ */
286
+ export function readConfigDoc(target, format) {
287
+ let text;
288
+ try {
289
+ text = fs.readFileSync(target, 'utf-8');
290
+ }
291
+ catch (e) {
292
+ const err = e;
293
+ if (err.code === 'ENOENT') {
294
+ // SHADOW A: fresh host / Codex absent — a new, empty container.
295
+ return { doc: format.emptyDoc(), existed: false };
296
+ }
297
+ // SHADOW D: EACCES / EISDIR / ELOOP (broken symlink) / anything else — ABORT.
298
+ throw new ConfigPermissionError(`cannot read config ${target}: ${err.code ?? 'read error'} (${err.message})`);
299
+ }
300
+ // SHADOW B: 0-byte or whitespace-only — normalize to the empty doc.
301
+ if (text.trim().length === 0) {
302
+ return { doc: format.emptyDoc(), existed: false };
303
+ }
304
+ // SHADOW C: non-empty but unparseable — ABORT, never clobber.
305
+ try {
306
+ return { doc: format.parse(text), existed: true };
307
+ }
308
+ catch (e) {
309
+ // SECURITY (US-013, HIGH-finding fix): a parser's error message can embed the
310
+ // OFFENDING SOURCE LINES of the file (a context snippet). `smol-toml` does
311
+ // EXACTLY this — `Invalid TOML document: <reason>\n\nN: <source line>\n ^`.
312
+ // Those embedded source lines can contain arbitrary on-disk bytes, INCLUDING a
313
+ // PRE-EXISTING user secret we cannot enumerate (it is not one of the secrets we
314
+ // are registering, so the register-path `redactSecrets(e.message, secretSink)`
315
+ // cannot scrub it — `secretSink` holds only the CURRENTLY-registering secrets).
316
+ // This message propagates to stderr/logs, so echoing it verbatim leaks the
317
+ // on-disk secret. We sanitize at the construction site: keep only the
318
+ // STRUCTURAL reason (parser error type/position), drop the quoted source-context
319
+ // snippet entirely. Secrecy WINS over diagnostics. (JSON.parse does not echo
320
+ // file content, so the Claude surface was already safe; this hardens BOTH.)
321
+ throw new ConfigParseError(`refusing to write: existing config ${target} is not valid and would be clobbered ` +
322
+ `(parse failed: ${sanitizeParserMessage(e.message)}). Fix or remove the file ` +
323
+ 'by hand; HQ will not regenerate it from a template.');
324
+ }
325
+ }
326
+ /**
327
+ * Strip a parser error message down to its STRUCTURAL reason, dropping any embedded
328
+ * SOURCE-CONTEXT lines so on-disk file bytes (which may include an unenumerable
329
+ * pre-existing secret) can never leak into a {@link ConfigParseError} message.
330
+ *
331
+ * `smol-toml` formats its error as:
332
+ *
333
+ * Invalid TOML document: <structural reason>
334
+ * <blank line>
335
+ * <N>: <verbatim source line> ← can contain a secret
336
+ * <M>: <verbatim source line> ← can contain a secret
337
+ * ^ ← caret pointer
338
+ *
339
+ * We keep ONLY the lines up to the first blank line (the structural reason, e.g.
340
+ * `Invalid TOML document: only letter, numbers, dashes and underscores are allowed
341
+ * in keys`) and discard the source snippet + caret. As belt-and-suspenders, any
342
+ * surviving line that still looks like quoted content or a `Bearer <token>` value
343
+ * is redacted, so even a parser whose reason inlines a value (rather than a
344
+ * separate snippet block) cannot leak. The result never contains raw file content.
345
+ */
346
+ export function sanitizeParserMessage(message) {
347
+ // 1. Cut at the first blank line: smol-toml's source-context snippet (and caret)
348
+ // always follows a blank-line separator, so everything before it is the
349
+ // structural reason and everything after is verbatim file bytes.
350
+ const beforeBlank = message.split(/\n[ \t]*\n/, 1)[0] ?? message;
351
+ // 2. Additionally drop any line that looks like a quoted source line
352
+ // (`N: ...` numbered context) in case a parser omits the blank separator.
353
+ const structural = beforeBlank
354
+ .split('\n')
355
+ .filter((line) => !/^\s*\d+:\s/.test(line) && line.trim() !== '^')
356
+ .join(' ')
357
+ .trim();
358
+ // 3. Belt-and-suspenders: redact any `Bearer <token>` or quoted-string-shaped
359
+ // substring that survived, so a value inlined into the reason cannot leak.
360
+ const redactedBearer = structural.replace(/\bBearer\s+\S+/gi, `Bearer ${SECRET_REDACTION}`);
361
+ const redactedQuoted = redactedBearer.replace(/"[^"]*"/g, `"${SECRET_REDACTION}"`);
362
+ return redactedQuoted.length > 0 ? redactedQuoted : 'parse error (details withheld)';
363
+ }
364
+ // ---------------------------------------------------------------------------
365
+ // Advisory O_EXCL lock (held across read->merge->write).
366
+ // ---------------------------------------------------------------------------
367
+ /** Default stale-lock timeout — a lock older than this whose PID is dead is broken. */
368
+ export const DEFAULT_LOCK_STALE_MS = 30_000;
369
+ /** Default total time to wait for a held lock before giving up. */
370
+ export const DEFAULT_LOCK_WAIT_MS = 10_000;
371
+ /** The lockfile path for a target: `~/.<basename>.hqlock` (a sibling, hidden). */
372
+ export function lockPathFor(target) {
373
+ const dir = path.dirname(target);
374
+ const base = path.basename(target);
375
+ // `~/.claude.json` -> `~/..claude.json.hqlock` is ugly; collapse a leading dot
376
+ // so the lock for a dotfile reads `~/.claude.json.hqlock`.
377
+ const stem = base.startsWith('.') ? base.slice(1) : base;
378
+ return path.join(dir, `.${stem}.hqlock`);
379
+ }
380
+ /** Default PID-liveness check (Unix `kill(pid, 0)` semantics via `process.kill`). */
381
+ function defaultIsPidAlive(pid) {
382
+ if (!Number.isInteger(pid) || pid <= 0)
383
+ return false;
384
+ try {
385
+ process.kill(pid, 0);
386
+ return true;
387
+ }
388
+ catch (e) {
389
+ // ESRCH = no such process (dead); EPERM = alive but not ours (still alive).
390
+ return e.code === 'EPERM';
391
+ }
392
+ }
393
+ /**
394
+ * Acquire an advisory O_EXCL lock for `target`, held across the whole
395
+ * read->merge->write cycle so a concurrent register cannot interleave and lose a
396
+ * write. Behavior:
397
+ *
398
+ * - `O_EXCL | O_CREAT` create-or-fail; on EEXIST another holder exists.
399
+ * - A held lock whose PID is DEAD and whose mtime is older than `staleMs` is
400
+ * broken (unlinked) and re-acquired.
401
+ * - A held lock whose PID is ALIVE (or fresh) is waited on up to `waitMs`; if
402
+ * it never frees, this throws (the concurrent register ABORTS rather than
403
+ * racing — the first writer's work is preserved).
404
+ *
405
+ * The lockfile body is the holder's PID + an ISO timestamp (diagnostic).
406
+ */
407
+ export function acquireLock(target, opts = {}) {
408
+ const lockPath = lockPathFor(target);
409
+ const staleMs = opts.staleMs ?? DEFAULT_LOCK_STALE_MS;
410
+ const waitMs = opts.waitMs ?? DEFAULT_LOCK_WAIT_MS;
411
+ const pollMs = opts.pollMs ?? 50;
412
+ const isPidAlive = opts.isPidAlive ?? defaultIsPidAlive;
413
+ const now = opts.now ?? Date.now;
414
+ const deadline = now() + waitMs;
415
+ const body = `${process.pid}\n${new Date().toISOString()}\n`;
416
+ // Spin until we win the O_EXCL create, break a stale lock, or time out.
417
+ for (;;) {
418
+ try {
419
+ const fd = fs.openSync(lockPath, 'wx', 0o600); // wx = O_CREAT|O_EXCL|O_WRONLY
420
+ try {
421
+ fs.writeSync(fd, body);
422
+ }
423
+ finally {
424
+ fs.closeSync(fd);
425
+ }
426
+ return { lockPath };
427
+ }
428
+ catch (e) {
429
+ const err = e;
430
+ if (err.code !== 'EEXIST') {
431
+ // A real failure creating the lock (e.g. EACCES on the dir) — surface it.
432
+ throw new ConfigPermissionError(`cannot acquire lock ${lockPath}: ${err.code ?? 'open error'} (${err.message})`);
433
+ }
434
+ // EEXIST: a lock is held. Decide stale-break vs wait.
435
+ if (tryBreakStaleLock(lockPath, staleMs, isPidAlive, now)) {
436
+ continue; // broke it — retry the create immediately.
437
+ }
438
+ if (now() >= deadline) {
439
+ const holder = readLockHolder(lockPath);
440
+ throw new ConfigPermissionError(`config ${target} is locked by ${holder} and did not release within ${waitMs}ms; ` +
441
+ 'aborting rather than racing a concurrent registration (the in-flight write is preserved).');
442
+ }
443
+ sleepMs(pollMs);
444
+ }
445
+ }
446
+ }
447
+ /** Release a held lock. Idempotent — a missing lockfile is not an error. */
448
+ export function releaseLock(lock) {
449
+ try {
450
+ fs.unlinkSync(lock.lockPath);
451
+ }
452
+ catch (e) {
453
+ if (e.code !== 'ENOENT')
454
+ throw e;
455
+ }
456
+ }
457
+ /** Read the PID written into a lockfile, or NaN if unreadable/garbage. */
458
+ function readLockPid(lockPath) {
459
+ try {
460
+ const first = fs.readFileSync(lockPath, 'utf-8').split('\n', 1)[0]?.trim() ?? '';
461
+ return Number.parseInt(first, 10);
462
+ }
463
+ catch {
464
+ return Number.NaN;
465
+ }
466
+ }
467
+ /** A human-readable "pid N (since …)" description of the current lock holder. */
468
+ function readLockHolder(lockPath) {
469
+ try {
470
+ const [pidLine, tsLine] = fs.readFileSync(lockPath, 'utf-8').split('\n');
471
+ return `pid ${pidLine?.trim() || '?'}${tsLine ? ` (since ${tsLine.trim()})` : ''}`;
472
+ }
473
+ catch {
474
+ return 'an unknown process';
475
+ }
476
+ }
477
+ /**
478
+ * If the lock at `lockPath` is stale (holder PID dead AND mtime older than
479
+ * `staleMs`), unlink it and return true. Otherwise return false. A dead PID with
480
+ * a FRESH mtime is NOT broken (the holder may have just started and not yet
481
+ * recorded a live PID under our liveness check), guarding against TOCTOU.
482
+ */
483
+ function tryBreakStaleLock(lockPath, staleMs, isPidAlive, now) {
484
+ let st;
485
+ let originalBody;
486
+ try {
487
+ st = fs.statSync(lockPath);
488
+ // Capture the exact bytes we are judging. The lock body (pid + ISO timestamp)
489
+ // is unique per acquisition — a reuse-proof identity for THIS lock. Inode
490
+ // numbers alone are NOT: Linux recycles a just-freed inode on the next create,
491
+ // so an inode-only recheck can mistake a fresh lock for the stale one we
492
+ // inspected and unlink it (passes on macOS, fails on Linux CI).
493
+ originalBody = fs.readFileSync(lockPath, 'utf-8');
494
+ }
495
+ catch {
496
+ // Vanished between EEXIST and here — the create will now succeed.
497
+ return true;
498
+ }
499
+ const age = now() - st.mtimeMs;
500
+ const pid = readLockPid(lockPath);
501
+ const holderAlive = Number.isFinite(pid) && isPidAlive(pid);
502
+ if (holderAlive)
503
+ return false; // live holder — must wait, never steal.
504
+ if (age < staleMs)
505
+ return false; // dead PID but fresh — be conservative, wait.
506
+ // Dead holder + stale mtime: break it — but IDENTITY-SAFELY. `unlinkSync(path)`
507
+ // is not atomic with the stat/read that justified breaking it: between them a
508
+ // third contender may have removed the stale lock and created a FRESH, valid
509
+ // one at the same path (and, on Linux, the same recycled inode). Re-read and
510
+ // require BOTH (inode+dev) AND the exact body to still match before unlinking,
511
+ // so we only ever remove the precise stale lock we inspected — never an
512
+ // unrelated fresh one. (The O_EXCL re-create is still the true mutual-exclusion
513
+ // guard; this just prevents a spurious break of someone else's lock.)
514
+ try {
515
+ const recheck = fs.statSync(lockPath);
516
+ const currentBody = fs.readFileSync(lockPath, 'utf-8');
517
+ if (recheck.ino !== st.ino ||
518
+ recheck.dev !== st.dev ||
519
+ currentBody !== originalBody) {
520
+ // The lock at this path is no longer the one we judged stale — leave it.
521
+ return false;
522
+ }
523
+ fs.unlinkSync(lockPath);
524
+ }
525
+ catch (e) {
526
+ // ENOENT => already gone (a concurrent breaker won); the create will succeed.
527
+ if (e.code !== 'ENOENT')
528
+ return false;
529
+ }
530
+ return true;
531
+ }
532
+ /** Busy-sleep for `ms` (small poll intervals only — bounded by the wait deadline). */
533
+ function sleepMs(ms) {
534
+ const end = Date.now() + ms;
535
+ while (Date.now() < end) {
536
+ // Atomics.wait on a throwaway buffer yields a real, CPU-cheap sleep without
537
+ // making the function async (the lock loop is synchronous by design).
538
+ try {
539
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, Math.max(1, end - Date.now()));
540
+ return;
541
+ }
542
+ catch {
543
+ // SharedArrayBuffer unavailable — fall back to a tight spin (rare).
544
+ }
545
+ }
546
+ }
547
+ // ---------------------------------------------------------------------------
548
+ // Backup (reuses the hq rescue backup pattern: ~/.hq/backups/<stamp>-<tag>/).
549
+ // ---------------------------------------------------------------------------
550
+ /** UTC `YYYY-MM-DDTHH-MM-SSZ` (dash-separated) — the hq rescue timestamp format. */
551
+ export function rescueStamp(d = new Date()) {
552
+ const p = (n, w = 2) => String(n).padStart(w, '0');
553
+ return (`${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())}` +
554
+ `T${p(d.getUTCHours())}-${p(d.getUTCMinutes())}-${p(d.getUTCSeconds())}Z`);
555
+ }
556
+ /**
557
+ * Back up `target` into `~/.hq/backups/mcp/<iso>-<pack>/` BEFORE the first write
558
+ * byte, mirroring the hq rescue snapshot. The backup copy is mode 0600. Returns
559
+ * the backup DIRECTORY (the restore target for verify-after-write).
560
+ *
561
+ * If `target` does not exist (fresh host), the backup dir is still created (so
562
+ * the restore path is always valid) but holds a `.absent` marker instead of a
563
+ * copy — restoring "absent" means unlinking the target.
564
+ *
565
+ * The backup is taken on the REALPATH of the target so a symlink (`~/.mcp.json`)
566
+ * is backed up by its resolved contents, never the link.
567
+ */
568
+ export function backupConfig(env, target, pack, stamp = rescueStamp()) {
569
+ const safePack = pack.replace(/[^a-zA-Z0-9._@-]/g, '_') || 'pack';
570
+ const dir = path.join(backupRoot(env), `${stamp}-${safePack}`);
571
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
572
+ const real = realpathOrSelf(target);
573
+ const dest = path.join(dir, path.basename(real));
574
+ try {
575
+ const data = fs.readFileSync(real);
576
+ fs.writeFileSync(dest, data, { mode: 0o600 });
577
+ }
578
+ catch (e) {
579
+ if (e.code === 'ENOENT') {
580
+ // Nothing to copy — record absence so restore knows to unlink.
581
+ fs.writeFileSync(path.join(dir, '.absent'), `${real}\n`, { mode: 0o600 });
582
+ }
583
+ else {
584
+ throw new ConfigPermissionError(`cannot back up ${real} before write: ${e.message}`);
585
+ }
586
+ }
587
+ return dir;
588
+ }
589
+ /**
590
+ * Restore `target` from a {@link backupConfig} directory. If the backup recorded
591
+ * absence (the `.absent` marker), the target is unlinked (returning it to "did
592
+ * not exist"); otherwise the backed-up bytes are written back atomically onto
593
+ * the realpath. Used by verify-after-write on failure.
594
+ */
595
+ export function restoreFromBackup(target, backupDir) {
596
+ const real = realpathOrSelf(target);
597
+ const absentMarker = path.join(backupDir, '.absent');
598
+ if (fs.existsSync(absentMarker)) {
599
+ try {
600
+ fs.unlinkSync(real);
601
+ }
602
+ catch (e) {
603
+ if (e.code !== 'ENOENT')
604
+ throw e;
605
+ }
606
+ return;
607
+ }
608
+ const src = path.join(backupDir, path.basename(real));
609
+ const data = fs.readFileSync(src);
610
+ atomicReplace(real, data);
611
+ }
612
+ // ---------------------------------------------------------------------------
613
+ // Atomic write (temp-in-same-dir + fsync + rename onto REALPATH).
614
+ // ---------------------------------------------------------------------------
615
+ /**
616
+ * Resolve `p` to its canonical real path. If `p` (or a path component) does not
617
+ * exist, fall back to `p` unchanged (a brand-new file has no realpath yet but we
618
+ * still write at its intended location). A symlink whose TARGET is missing
619
+ * (broken link) resolves the link itself to its dangling target so we never
620
+ * replace the link.
621
+ */
622
+ export function realpathOrSelf(p, _depth = 0) {
623
+ try {
624
+ return fs.realpathSync(p);
625
+ }
626
+ catch {
627
+ // The file itself may not exist yet, but if it's a symlink we still want to
628
+ // write onto its TARGET, never replace the link. Resolve the link target by
629
+ // hand when the final component is a symlink with a dangling target.
630
+ //
631
+ // Depth guard: a CYCLIC broken chain (a -> b -> a) would otherwise recurse
632
+ // forever and blow the stack. Cap at a generous hop count and, on overflow,
633
+ // fall back to canonicalizing the literal path's parent.
634
+ if (_depth < 40) {
635
+ try {
636
+ const lst = fs.lstatSync(p);
637
+ if (lst.isSymbolicLink()) {
638
+ const linkTarget = fs.readlinkSync(p);
639
+ const resolved = path.isAbsolute(linkTarget)
640
+ ? linkTarget
641
+ : path.join(path.dirname(p), linkTarget);
642
+ // RECURSE so an N-hop (possibly partially-broken) chain is followed all
643
+ // the way to the final real target — we must rename onto that target,
644
+ // never onto an intermediate link.
645
+ return realpathOrSelf(resolved, _depth + 1);
646
+ }
647
+ }
648
+ catch {
649
+ // Not a symlink (or unreadable) — write at the literal path.
650
+ }
651
+ }
652
+ return realpathParentOf(p);
653
+ }
654
+ }
655
+ /** Canonicalize a path's PARENT dir (which must exist) and re-append the basename. */
656
+ function realpathParentOf(p) {
657
+ try {
658
+ const parent = fs.realpathSync(path.dirname(p));
659
+ return path.join(parent, path.basename(p));
660
+ }
661
+ catch {
662
+ return p;
663
+ }
664
+ }
665
+ /**
666
+ * Atomically replace `realTarget` with `data`: write a temp file IN THE SAME DIR
667
+ * (mode 0600), fsync the FILE, rename onto `realTarget`, then fsync the
668
+ * DIRECTORY. The temp lives in the same directory so the rename is a same-
669
+ * filesystem atomic operation — a crash either leaves the original wholly intact
670
+ * or the new file wholly in place, never a torn write. The trailing directory
671
+ * fsync persists the rename itself: without it, a power-loss can lose the rename
672
+ * metadata even though the file bytes were flushed, silently reverting the write.
673
+ *
674
+ * `realTarget` MUST already be a realpath (caller resolves symlinks first) so we
675
+ * rename onto the RESOLVED file and NEVER replace a symlink with a regular file.
676
+ */
677
+ export function atomicReplace(realTarget, data) {
678
+ const dir = path.dirname(realTarget);
679
+ const tmp = path.join(dir, `.${path.basename(realTarget)}.tmp.${process.pid}.${Date.now()}`);
680
+ let fd;
681
+ try {
682
+ fd = fs.openSync(tmp, 'wx', 0o600); // O_CREAT|O_EXCL — never clobber a stray temp.
683
+ fs.writeSync(fd, typeof data === 'string' ? Buffer.from(data, 'utf-8') : data);
684
+ fs.fsyncSync(fd); // durability: flush bytes before the rename commits.
685
+ fs.closeSync(fd);
686
+ fd = undefined;
687
+ fs.renameSync(tmp, realTarget); // atomic same-FS commit.
688
+ fsyncDir(dir); // persist the rename metadata so a crash can't lose it.
689
+ }
690
+ catch (e) {
691
+ // Clean up the temp on any failure so a crashed write leaves no litter.
692
+ if (fd !== undefined) {
693
+ try {
694
+ fs.closeSync(fd);
695
+ }
696
+ catch {
697
+ /* already closed */
698
+ }
699
+ }
700
+ try {
701
+ fs.unlinkSync(tmp);
702
+ }
703
+ catch {
704
+ /* temp may not exist */
705
+ }
706
+ throw e;
707
+ }
708
+ }
709
+ /**
710
+ * fsync a DIRECTORY so a rename into it is durable. Best-effort: some platforms
711
+ * (notably Windows, and some network filesystems) reject `fsync` on a directory
712
+ * fd with EISDIR/EBADF/EPERM/EINVAL — in that case the rename is still committed
713
+ * to the FS as far as the OS allows, so we swallow the error rather than abort an
714
+ * otherwise-good write. Never throws.
715
+ */
716
+ function fsyncDir(dir) {
717
+ let dirFd;
718
+ try {
719
+ dirFd = fs.openSync(dir, 'r');
720
+ fs.fsyncSync(dirFd);
721
+ }
722
+ catch {
723
+ // Directory fsync unsupported on this platform/FS — best-effort only.
724
+ }
725
+ finally {
726
+ if (dirFd !== undefined) {
727
+ try {
728
+ fs.closeSync(dirFd);
729
+ }
730
+ catch {
731
+ /* already closed */
732
+ }
733
+ }
734
+ }
735
+ }
736
+ /**
737
+ * Crash-safe, reversible, lock-guarded merge-write of one entry into a
738
+ * user-global agent config. Implements the full review-report §6.2 algorithm:
739
+ * lock -> backup -> read (shadow-normalized) -> RE-READ in lock -> merge ->
740
+ * serialize -> atomic temp+fsync+rename onto realpath -> verify -> restore on
741
+ * failure. Releases the lock in `finally`.
742
+ *
743
+ * Idempotent: if `merge` returns a doc byte-identical (post-serialize) to the
744
+ * current file, no write happens (`changed: false`) — re-runs from ANY state,
745
+ * including PARTIAL, are safe.
746
+ *
747
+ * @throws ConfigParseError existing non-empty file is unparseable (ABORT, nothing written)
748
+ * @throws ConfigPermissionError read/lock failure other than ENOENT (ABORT)
749
+ * @throws PartialRegistrationError verify-after-write failed; target restored from backup
750
+ * @throws (whatever `merge` throws, e.g. McpNameCollisionError) — aborts before any write
751
+ */
752
+ export function writeConfigAtomic(opts) {
753
+ const env = resolveEnv(opts.env);
754
+ const { target, pack, format, merge, assertName } = opts;
755
+ // [0] acquire the advisory lock — held across read->merge->write.
756
+ const lock = acquireLock(target, opts.lock);
757
+ try {
758
+ // [1] backup BEFORE the first write byte (mandatory; the only remediation).
759
+ const backupDir = backupConfig(env, target, pack, opts.stamp);
760
+ // [2]+[3] read INSIDE the lock (never carry a pre-lock parse). The shadow
761
+ // cases (ENOENT/blank/parse-error/permission) are handled by readConfigDoc.
762
+ const { doc: current } = readConfigDoc(target, format);
763
+ const currentText = format.serialize(current);
764
+ // [4..6] merge + serialize. A throw here (e.g. collision) aborts cleanly —
765
+ // nothing has been written, the lock releases in finally, backup is harmless.
766
+ const next = merge(current);
767
+ const nextText = format.serialize(next);
768
+ // Idempotent no-op: identical post-serialize output => do not touch the file.
769
+ // (Still validates the entry is present so a PARTIAL prior state self-heals.)
770
+ if (nextText === currentText) {
771
+ // The entry must actually be present for a true no-op; if the merge claims
772
+ // no change yet the entry is absent, the doc is inconsistent — surface it
773
+ // rather than silently "succeeding".
774
+ assertEntryOrThrow(format, next, assertName, target, backupDir);
775
+ return { changed: false, backupDir, realTarget: realpathOrSelf(target) };
776
+ }
777
+ // [7] atomic write onto the REALPATH (resolve the ~/.mcp.json symlink first,
778
+ // rename onto the resolved file, NEVER replace the link).
779
+ const realTarget = realpathOrSelf(target);
780
+ // The resolved parent must exist for the temp+rename; create it for a fresh
781
+ // NON-symlink target whose dir is missing (e.g. a brand-new file). We never
782
+ // mkdir for a Codex-absent host — that's gated upstream by isCodexInstalled.
783
+ fs.mkdirSync(path.dirname(realTarget), { recursive: true });
784
+ atomicReplace(realTarget, nextText);
785
+ // [8] verify-after-write: re-read, re-parse, assert the entry is present. On
786
+ // ANY failure, restore from the [1] backup and raise PartialRegistrationError.
787
+ try {
788
+ const reread = readConfigDoc(realTarget, format);
789
+ format.assertEntry(reread.doc, assertName);
790
+ }
791
+ catch (verifyErr) {
792
+ const restored = restoreSafely(realTarget, backupDir);
793
+ const tail = restored
794
+ ? `restored from backup ${backupDir}`
795
+ : `RESTORE FAILED — file is in the HQ-written state; the original is recoverable ` +
796
+ `at ${backupDir}`;
797
+ throw new PartialRegistrationError(`verify-after-write failed for ${realTarget} (entry "${assertName}" not confirmed: ` +
798
+ `${verifyErr.message}); ${tail}`, backupDir);
799
+ }
800
+ return { changed: true, backupDir, realTarget };
801
+ }
802
+ finally {
803
+ // [9] always release the lock, even on abort/throw.
804
+ releaseLock(lock);
805
+ }
806
+ }
807
+ /** assertEntry wrapper that maps a verify failure to a restore + PartialRegistrationError. */
808
+ function assertEntryOrThrow(format, doc, name, target, backupDir) {
809
+ try {
810
+ format.assertEntry(doc, name);
811
+ }
812
+ catch (e) {
813
+ throw new PartialRegistrationError(`post-merge invariant failed for ${target}: entry "${name}" is not present in a ` +
814
+ `supposedly-unchanged config (${e.message})`, backupDir);
815
+ }
816
+ }
817
+ /**
818
+ * Restore from backup. Returns true iff the restore succeeded; on failure returns
819
+ * false WITHOUT throwing, so the caller can report accurately (the file is left in
820
+ * the HQ-written state, the backupDir remains on disk for manual recovery, and we
821
+ * never mask the PartialRegistrationError with a secondary restore error).
822
+ */
823
+ function restoreSafely(realTarget, backupDir) {
824
+ try {
825
+ restoreFromBackup(realTarget, backupDir);
826
+ return true;
827
+ }
828
+ catch {
829
+ return false;
830
+ }
831
+ }
832
+ // ===========================================================================
833
+ // US-007 — Claude/JSON merge emit (consumes the US-006 safe-write core).
834
+ //
835
+ // This is the FIRST of the two per-runtime emitters that {@link registerServer}
836
+ // fans a single per-server manifest out onto. It takes one pack's per-server MCP
837
+ // manifest ({type,url,headers,…} — already shape-validated by US-005's
838
+ // `validateMcpManifest`), resolves any `${secret:NAME}` header references AT EMIT
839
+ // (never persisting the reference, never echoing the resolved value), stamps a
840
+ // `_hqPack` provenance marker so US-009 uninstall can find ONLY our entries, and
841
+ // merges exactly ONE `mcpServers.<name>` entry into the PINNED single Claude
842
+ // surface (top-level `~/.claude.json` `mcpServers`) via {@link writeConfigAtomic}.
843
+ //
844
+ // The Codex/TOML emitter is US-008; this story does NOT implement it. The
845
+ // {@link registerServer} fan-out wires the Claude arm and leaves the Codex arm a
846
+ // FIRST-CLASS SKIP (the substrate's {@link isCodexInstalled} branch) — never a
847
+ // crash, never a fabricated `~/.codex`. So a re-run after US-008 lands simply
848
+ // gains the second surface; US-007 already self-heals per surface (US-006
849
+ // desired-state-convergence).
850
+ //
851
+ // SECRET SAFETY (the hard rule from the PRD authModel / US-002 AC):
852
+ // - `${secret:NAME}` resolves ONLY here, at emit, via the injected
853
+ // {@link SecretResolver} (production = the AES-256-GCM `secrets-cache.ts`).
854
+ // - The RESOLVED value lands in `~/.claude.json` — a 0600 user-global file that
855
+ // is NOT synced/committed — because the runtime needs the real header to work.
856
+ // - The resolved value is REDACTED from every return value and never echoed; we
857
+ // never write the literal back to a synced artifact (the pack repo keeps only
858
+ // the `${secret:}` reference). {@link redactSecrets} enforces the no-echo rule
859
+ // on anything a caller might log.
860
+ // ===========================================================================
861
+ /** Server-name charset gate: load-bearing as the `mcp__<name>__*` tool namespace. */
862
+ export const MCP_SERVER_NAME_RE = /^[a-z0-9_-]+$/;
863
+ /** The provenance key stamped onto every HQ-registered server (US-009 uninstall scope). */
864
+ export const HQ_PACK_PROVENANCE_KEY = '_hqPack';
865
+ /** Matches every `${secret:NAME}` reference in a string (global, for replace). */
866
+ const SECRET_REF_RE = /\$\{secret:([A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]+)*)\}/g;
867
+ /** A single `${secret:NAME}` for membership tests (non-global). */
868
+ const SECRET_REF_TEST_RE = /\$\{secret:[A-Z][A-Z0-9_]*(?:\/[A-Z][A-Z0-9_]+)*\}/;
869
+ /** True iff `value` contains at least one `${secret:NAME}` reference. */
870
+ export function hasSecretRef(value) {
871
+ return SECRET_REF_TEST_RE.test(value);
872
+ }
873
+ /**
874
+ * Replace every `${secret:NAME}` in `value` with its resolved plaintext, tracking
875
+ * each resolved literal so the caller can REDACT it from any output. Throws
876
+ * {@link McpManifestError} if a referenced secret cannot be resolved (we refuse to
877
+ * emit a half-resolved header — better a clear failure than a silently-broken
878
+ * server). A string with no reference passes through untouched.
879
+ *
880
+ * @param value the header/env value (e.g. `"Bearer ${secret:VYG_API_KEY}"`)
881
+ * @param resolve the {@link SecretResolver}
882
+ * @param secretSink accumulates every resolved plaintext (for redaction); may be
883
+ * the SAME set across many values so one `redactSecrets` call
884
+ * scrubs them all.
885
+ */
886
+ export function resolveSecretRefs(value, resolve, secretSink) {
887
+ return value.replace(SECRET_REF_RE, (_match, name) => {
888
+ const resolved = resolve(name);
889
+ if (resolved === null || resolved === undefined) {
890
+ throw new McpManifestError(`cannot resolve \${secret:${name}} at emit — the secret is not in the cache ` +
891
+ '(mint it via the pack onboarding / `hq run`, then re-install). HQ refuses to ' +
892
+ 'write a half-resolved header.');
893
+ }
894
+ if (resolved.length > 0)
895
+ secretSink.add(resolved);
896
+ return resolved;
897
+ });
898
+ }
899
+ /** The redaction marker substituted for any resolved secret value in output. */
900
+ export const SECRET_REDACTION = '«redacted»';
901
+ /**
902
+ * Scrub every resolved secret plaintext out of `text`, replacing it with
903
+ * {@link SECRET_REDACTION}. Use on ANYTHING that might be logged/echoed/returned
904
+ * after an emit — the no-echo rule is absolute. Longest-first replacement avoids a
905
+ * shorter secret unmasking a longer one that contains it.
906
+ */
907
+ export function redactSecrets(text, secrets) {
908
+ let out = text;
909
+ const sorted = [...secrets].filter((s) => s.length > 0).sort((a, b) => b.length - a.length);
910
+ for (const s of sorted) {
911
+ out = out.split(s).join(SECRET_REDACTION);
912
+ }
913
+ return out;
914
+ }
915
+ /**
916
+ * The Claude `ConfigFormat` for the top-level `~/.claude.json` surface: the doc is
917
+ * the whole `~/.claude.json` object, the entry we assert is `mcpServers.<name>`.
918
+ * Reuses {@link jsonFormat}'s parse/serialize (object-rooted JSON, pretty-printed
919
+ * with a trailing newline) so existing servers — and every UNRELATED top-level key
920
+ * in `~/.claude.json` (projects, userID, tipsHistory, …) — round-trip byte-for-byte.
921
+ */
922
+ export const claudeConfigFormat = {
923
+ parse: jsonFormat.parse,
924
+ serialize: jsonFormat.serialize,
925
+ // A fresh `~/.claude.json` is just `{}` — we do NOT presume an `mcpServers` key;
926
+ // the merge fn creates it on demand so we never rewrite a user's whole file.
927
+ emptyDoc: () => ({}),
928
+ assertEntry(doc, name) {
929
+ const servers = doc.mcpServers;
930
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers) || !(name in servers)) {
931
+ throw new Error(`mcpServers.${name} not present after write`);
932
+ }
933
+ },
934
+ };
935
+ /**
936
+ * Build the Claude server definition emitted into `mcpServers.<name>` from a
937
+ * manifest: pass the transport fields through, resolve `${secret:}` in every
938
+ * header/env value (recording the plaintexts in `secretSink` for redaction), and
939
+ * stamp `_hqPack` provenance. The output object's key order is deterministic so a
940
+ * re-run produces a byte-identical def (idempotency depends on stable serialize).
941
+ */
942
+ export function buildClaudeServerDef(manifest, pack, resolve, secretSink) {
943
+ const def = { type: manifest.type };
944
+ if (manifest.url !== undefined)
945
+ def.url = manifest.url;
946
+ if (manifest.command !== undefined)
947
+ def.command = manifest.command;
948
+ if (manifest.args !== undefined)
949
+ def.args = manifest.args;
950
+ if (manifest.headers !== undefined) {
951
+ def.headers = resolveStringMap(manifest.headers, resolve, secretSink);
952
+ }
953
+ if (manifest.env !== undefined) {
954
+ def.env = resolveStringMap(manifest.env, resolve, secretSink);
955
+ }
956
+ if (manifest.tools !== undefined)
957
+ def.tools = manifest.tools;
958
+ // Provenance LAST so it is visibly an HQ annotation; US-009 keys off it.
959
+ def[HQ_PACK_PROVENANCE_KEY] = pack;
960
+ return def;
961
+ }
962
+ /** Resolve `${secret:}` in every value of a string map, recording plaintexts. */
963
+ function resolveStringMap(map, resolve, secretSink) {
964
+ const out = {};
965
+ for (const [k, v] of Object.entries(map)) {
966
+ out[k] = resolveSecretRefs(v, resolve, secretSink);
967
+ }
968
+ return out;
969
+ }
970
+ /**
971
+ * Compare two server defs for COLLISION purposes, ignoring the `_hqPack`
972
+ * provenance stamp (a user-created server has no stamp; an HQ re-install carries
973
+ * one — neither difference is a real definitional conflict). Returns true iff the
974
+ * transport-relevant content is identical. Used to distinguish def-equal (no-op)
975
+ * from def-differs (abort) when a name is already present.
976
+ */
977
+ export function serverDefsEqual(a, b) {
978
+ return canonicalJson(stripProvenance(a)) === canonicalJson(stripProvenance(b));
979
+ }
980
+ /** A shallow copy of an object with the `_hqPack` key removed (for equality). */
981
+ function stripProvenance(v) {
982
+ if (v === null || typeof v !== 'object' || Array.isArray(v))
983
+ return v;
984
+ const { [HQ_PACK_PROVENANCE_KEY]: _omit, ...rest } = v;
985
+ return rest;
986
+ }
987
+ /** Order-insensitive JSON for object keys (so key order never spoofs a collision). */
988
+ function canonicalJson(v) {
989
+ return JSON.stringify(v, (_k, val) => {
990
+ if (val && typeof val === 'object' && !Array.isArray(val)) {
991
+ const obj = val;
992
+ return Object.keys(obj)
993
+ .sort()
994
+ .reduce((acc, k) => {
995
+ acc[k] = obj[k];
996
+ return acc;
997
+ }, {});
998
+ }
999
+ return val;
1000
+ });
1001
+ }
1002
+ /**
1003
+ * The merge fn for the Claude surface: insert/converge `mcpServers.<name> = def`.
1004
+ *
1005
+ * - name ABSENT -> insert (creating `mcpServers` if needed).
1006
+ * - name PRESENT, def-equal (provenance-ignored) -> NO-OP (idempotent re-install
1007
+ * / PARTIAL self-heal). The provenance stamp is (re)applied so an entry the
1008
+ * user happened to define identically becomes HQ-owned only when we truly own
1009
+ * it — but byte-equality of the rest means {@link writeConfigAtomic} writes
1010
+ * nothing unless the stamp itself is the only delta, which we treat as a no-op
1011
+ * by returning the original doc.
1012
+ * - name PRESENT, def-DIFFERS -> {@link McpNameCollisionError} ABORT.
1013
+ *
1014
+ * Never touches any OTHER server entry or any other top-level key.
1015
+ */
1016
+ export function mergeClaudeServer(name, def) {
1017
+ return (doc) => {
1018
+ const servers = { ...(doc.mcpServers ?? {}) };
1019
+ const existing = servers[name];
1020
+ if (existing !== undefined) {
1021
+ if (serverDefsEqual(existing, def)) {
1022
+ // Idempotent: the meaningful definition matches. Return the doc UNCHANGED
1023
+ // (do not re-stamp) so a re-install is a true byte-for-byte no-op.
1024
+ return doc;
1025
+ }
1026
+ throw new McpNameCollisionError(`MCP server "${name}" is already defined in ~/.claude.json with a different ` +
1027
+ 'definition — refusing to overwrite. Remove or rename the existing server, ' +
1028
+ 'or uninstall the conflicting pack, then re-install.');
1029
+ }
1030
+ servers[name] = def;
1031
+ return { ...doc, mcpServers: servers };
1032
+ };
1033
+ }
1034
+ /** Default resolver: there is NO secret context, so any `${secret:}` ref is an error. */
1035
+ const noSecretResolver = () => null;
1036
+ /**
1037
+ * Validate a server name against {@link MCP_SERVER_NAME_RE} BEFORE it is ever
1038
+ * interpolated into a JSON key (or, in US-008, a TOML header). A bad name is an
1039
+ * injection vector (the name is load-bearing as the tool namespace), so this is a
1040
+ * hard pre-flight gate. Throws {@link McpManifestError}.
1041
+ */
1042
+ export function assertValidServerName(name) {
1043
+ if (typeof name !== 'string' || !MCP_SERVER_NAME_RE.test(name)) {
1044
+ throw new McpManifestError(`invalid MCP server name ${JSON.stringify(name)} — must match ${MCP_SERVER_NAME_RE} ` +
1045
+ '(lowercase letters, digits, underscore, hyphen). The name is the `mcp__<name>__*` ' +
1046
+ 'tool namespace and a JSON/TOML key, so it is charset-validated before any interpolation.');
1047
+ }
1048
+ }
1049
+ /**
1050
+ * Emit ONE server into the PINNED single Claude surface (top-level
1051
+ * `~/.claude.json` `mcpServers`) via {@link writeConfigAtomic}. Resolves
1052
+ * `${secret:}` headers at emit, stamps provenance, and is idempotent per surface
1053
+ * (re-install no-ops; a PARTIAL state self-heals). Existing servers and every
1054
+ * unrelated `~/.claude.json` key are preserved byte-for-byte by the merge.
1055
+ *
1056
+ * @returns the surface result (changed?, realTarget). Any error message the caller
1057
+ * surfaces MUST be passed through {@link redactSecrets} — the resolved
1058
+ * secret plaintexts are collected internally and never returned.
1059
+ * @throws McpManifestError invalid server name or unresolvable `${secret:}`
1060
+ * @throws McpNameCollisionError the name exists with a different definition
1061
+ * @throws ConfigParseError / ConfigPermissionError / PartialRegistrationError (from the core)
1062
+ */
1063
+ export function registerClaudeServer(opts) {
1064
+ assertValidServerName(opts.name);
1065
+ const resolve = opts.resolveSecret ?? noSecretResolver;
1066
+ const secretSink = new Set();
1067
+ // Resolve secrets + build the def BEFORE entering the lock so a resolution
1068
+ // failure aborts without taking a backup or touching the file.
1069
+ const def = buildClaudeServerDef(opts.manifest, opts.pack, resolve, secretSink);
1070
+ try {
1071
+ const res = writeConfigAtomic({
1072
+ target: claudeConfigPath2(opts.env),
1073
+ pack: opts.pack,
1074
+ format: claudeConfigFormat,
1075
+ merge: mergeClaudeServer(opts.name, def),
1076
+ assertName: opts.name,
1077
+ env: opts.env,
1078
+ lock: opts.lock,
1079
+ stamp: opts.stamp,
1080
+ });
1081
+ return { changed: res.changed, realTarget: res.realTarget };
1082
+ }
1083
+ catch (e) {
1084
+ // Redact any resolved secret that might have leaked into an error message
1085
+ // (e.g. a verify error echoing file content). Re-throw the SAME class.
1086
+ if (secretSink.size > 0 && e instanceof Error) {
1087
+ e.message = redactSecrets(e.message, secretSink);
1088
+ }
1089
+ throw e;
1090
+ }
1091
+ }
1092
+ /**
1093
+ * Resolve the Claude target path from a (partial) env, mirroring
1094
+ * {@link claudeConfigPath} but tolerating an undefined env (production default).
1095
+ */
1096
+ function claudeConfigPath2(env) {
1097
+ return claudeConfigPath(resolveEnv(env));
1098
+ }
1099
+ /**
1100
+ * Fan ONE server out across the per-runtime surfaces. US-007 wires the Claude/JSON
1101
+ * arm ({@link registerClaudeServer}); US-008 wires the Codex/TOML arm
1102
+ * ({@link registerCodexServer}). Each arm registers ONE server into its surface via
1103
+ * {@link writeConfigAtomic} and is idempotent per surface (re-install no-ops; a
1104
+ * PARTIAL state — one runtime wired, the other not — self-heals on re-run).
1105
+ *
1106
+ * The Codex arm is a FIRST-CLASS SKIP when the Codex runtime is absent (`~/.codex`
1107
+ * missing): no crash, no fabricated directory. So a Claude-only host still registers
1108
+ * Claude cleanly, and a re-run after a Codex install fills the second surface.
1109
+ *
1110
+ * ORDERING: Claude is emitted FIRST. If the Codex emit then throws, Claude is
1111
+ * already durably registered and a re-run self-heals only the missing Codex surface
1112
+ * (desired-state convergence) — the user is never left with NOTHING wired.
1113
+ *
1114
+ * Pre-flight collision spanning BOTH targets is enforced PER SURFACE by each
1115
+ * emitter's merge (def-equal = no-op, def-differs = {@link McpNameCollisionError}).
1116
+ */
1117
+ export function registerServer(opts) {
1118
+ // Name validation is the very first gate (before any interpolation / IO).
1119
+ assertValidServerName(opts.name);
1120
+ const env = resolveEnv(opts.env);
1121
+ const audit = opts.audit ?? true;
1122
+ const transport = opts.manifest.type;
1123
+ const target = manifestTarget(opts.manifest);
1124
+ const claudeTarget = claudeConfigPath(env);
1125
+ const codexTarget = codexConfigPath(env);
1126
+ // Claude FIRST so a Codex failure never leaves the user with nothing wired.
1127
+ // Capture prevHash BEFORE the emit (which does the write internally), newHash
1128
+ // AFTER (off the realTarget the surface reports). An emit THROW still logs a
1129
+ // line with result:'error' so the failed attempt is auditable, then re-throws.
1130
+ const claudePrevHash = audit ? hashFileContents(claudeTarget) : '';
1131
+ let claude;
1132
+ try {
1133
+ claude = registerClaudeServer(opts);
1134
+ }
1135
+ catch (e) {
1136
+ if (audit) {
1137
+ appendAuditLog(env, {
1138
+ ts: new Date().toISOString(),
1139
+ action: 'register',
1140
+ pack: opts.pack,
1141
+ server: opts.name,
1142
+ transport,
1143
+ target,
1144
+ file: claudeTarget,
1145
+ prevHash: claudePrevHash,
1146
+ newHash: hashFileContents(claudeTarget),
1147
+ result: 'error',
1148
+ });
1149
+ }
1150
+ throw e;
1151
+ }
1152
+ if (audit) {
1153
+ appendAuditLog(env, {
1154
+ ts: new Date().toISOString(),
1155
+ action: 'register',
1156
+ pack: opts.pack,
1157
+ server: opts.name,
1158
+ transport,
1159
+ target,
1160
+ file: claude.realTarget,
1161
+ prevHash: claudePrevHash,
1162
+ newHash: hashFileContents(claude.realTarget),
1163
+ result: claude.changed ? 'registered' : 'noop',
1164
+ });
1165
+ }
1166
+ // Codex/TOML emit (US-008). A Codex-absent host is a first-class skip — never a
1167
+ // crash, never a fabricated ~/.codex. The emitter resolves the same manifest +
1168
+ // secrets and merges ONE [mcp_servers.<name>] table into ~/.codex/config.toml.
1169
+ const codexPrevHash = audit ? hashFileContents(codexTarget) : '';
1170
+ let codex;
1171
+ try {
1172
+ codex = registerCodexServer(opts);
1173
+ }
1174
+ catch (e) {
1175
+ if (audit) {
1176
+ appendAuditLog(env, {
1177
+ ts: new Date().toISOString(),
1178
+ action: 'register',
1179
+ pack: opts.pack,
1180
+ server: opts.name,
1181
+ transport,
1182
+ target,
1183
+ file: codexTarget,
1184
+ prevHash: codexPrevHash,
1185
+ newHash: hashFileContents(codexTarget),
1186
+ result: 'error',
1187
+ });
1188
+ }
1189
+ throw e;
1190
+ }
1191
+ if (audit) {
1192
+ const skipped = 'skipped' in codex && codex.skipped === true;
1193
+ appendAuditLog(env, {
1194
+ ts: new Date().toISOString(),
1195
+ action: 'register',
1196
+ pack: opts.pack,
1197
+ server: opts.name,
1198
+ transport,
1199
+ target,
1200
+ file: skipped ? codexTarget : codex.realTarget,
1201
+ prevHash: codexPrevHash,
1202
+ newHash: skipped ? codexPrevHash : hashFileContents(codex.realTarget),
1203
+ result: skipped
1204
+ ? 'skipped'
1205
+ : codex.changed
1206
+ ? 'registered'
1207
+ : 'noop',
1208
+ });
1209
+ }
1210
+ return { claude, codex };
1211
+ }
1212
+ /**
1213
+ * Derive the audit-log `target` field from a manifest: the url for http/sse, or
1214
+ * `command` (plus space-joined args) for stdio. NEVER a header/secret. Returns ''
1215
+ * when neither a url nor a command is present.
1216
+ */
1217
+ export function manifestTarget(manifest) {
1218
+ if (manifest.type === 'http' || manifest.type === 'sse') {
1219
+ return manifest.url ?? '';
1220
+ }
1221
+ // stdio (or anything else): command + args.
1222
+ if (manifest.command !== undefined) {
1223
+ const args = manifest.args && manifest.args.length > 0 ? ` ${manifest.args.join(' ')}` : '';
1224
+ return `${manifest.command}${args}`;
1225
+ }
1226
+ return manifest.url ?? '';
1227
+ }
1228
+ /**
1229
+ * Register one pack's MCP servers into the shared agent configs (the public seam
1230
+ * `pack-install` routes `wire:'merge'` keys to). For each declared server name it
1231
+ * loads + shape-checks the manifest, then fans out via {@link registerServer}.
1232
+ *
1233
+ * US-007 wires the Claude/JSON surface; the Codex/TOML surface (US-008) is a
1234
+ * first-class skip until that story lands. Manifests are loaded with a caller-
1235
+ * supplied loader so this stays decoupled from `pack-install`'s payload layout and
1236
+ * fully testable in isolation.
1237
+ *
1238
+ * @param pkg the pack name (e.g. `hq-pack-vyg-shopify`) — stamped as provenance
1239
+ * @param names the bare server names from `contributes.mcp`
1240
+ * @param options manifest loader + secret resolver + injectable env (all optional;
1241
+ * without a loader this throws, since US-007 has no payload-dir context)
1242
+ * @returns one {@link RegisterServerResult} per server, in `names` order
1243
+ */
1244
+ export function registerMcpServers(pkg, names, options) {
1245
+ // OPERATOR KILL-SWITCH (US-012): HQ_DISABLE_MCP_REGISTRATION=1 short-circuits ALL
1246
+ // MCP registration — no server is written to either runtime config — while symlink
1247
+ // contributions for OTHER keys still wire (this only short-circuits the mcp MERGE
1248
+ // path, never the symlink path, which lives in pack-install/pack-contributions).
1249
+ //
1250
+ // ORDERING: this is checked FIRST, BEFORE the loadManifest programmer-error guard
1251
+ // below. The kill-switch is a USER/OPERATOR condition; the missing-loadManifest
1252
+ // throw is a PROGRAMMER error. An operator who set the kill-switch must never hit a
1253
+ // spurious McpManifestError, so the operator path wins. We read process.env directly
1254
+ // (not options.env — that's the SafeWriteEnv home-path injector, NOT process env);
1255
+ // tests set/unset process.env.HQ_DISABLE_MCP_REGISTRATION around the call.
1256
+ //
1257
+ // Returns an empty RegisterServerResult[] (`[]`) — the correct "no servers
1258
+ // registered" semantic — so callers (pack-install) consume it gracefully. The skip
1259
+ // NOTICE is emitted exactly once per registerMcpServers call.
1260
+ if (process.env.HQ_DISABLE_MCP_REGISTRATION === '1') {
1261
+ process.stderr.write('MCP registration skipped (HQ_DISABLE_MCP_REGISTRATION=1)\n');
1262
+ return [];
1263
+ }
1264
+ const loadManifest = options?.loadManifest;
1265
+ if (!loadManifest) {
1266
+ // Without a manifest loader there is no payload context to emit from. This is
1267
+ // a programmer error at the call site, not a user-facing condition — keep it a
1268
+ // named error so callers (pack-install) wire the loader explicitly.
1269
+ throw new McpManifestError('registerMcpServers requires a manifest loader (options.loadManifest) to resolve ' +
1270
+ 'each declared contributes.mcp server to its mcp/<name>.json definition');
1271
+ }
1272
+ return names.map((name) => {
1273
+ assertValidServerName(name);
1274
+ const manifest = loadManifest(name);
1275
+ return registerServer({
1276
+ name,
1277
+ manifest,
1278
+ pack: pkg,
1279
+ resolveSecret: options?.resolveSecret,
1280
+ env: options?.env,
1281
+ lock: options?.lock,
1282
+ stamp: options?.stamp,
1283
+ });
1284
+ });
1285
+ }
1286
+ /** The Codex `[mcp_servers]` super-table key (the global Codex MCP registry). */
1287
+ export const CODEX_MCP_SERVERS_KEY = 'mcp_servers';
1288
+ /**
1289
+ * Serialize EXACTLY ONE server table (`[mcp_servers.<name>]` + its sub-tables)
1290
+ * by wrapping the def under `mcp_servers.<name>` and round-tripping it through
1291
+ * smol-toml. Used both for the append path (preserve-comments) and for the
1292
+ * collision/equality comparison. The output is deterministic for a given def so a
1293
+ * re-install produces byte-identical text (idempotency depends on it).
1294
+ */
1295
+ export function serializeOneCodexServer(name, def) {
1296
+ return stringifyToml({ [CODEX_MCP_SERVERS_KEY]: { [name]: def } });
1297
+ }
1298
+ /**
1299
+ * The Codex `ConfigFormat`. `parse` round-trips the file through smol-toml
1300
+ * (throwing `TomlError` on malformed NON-empty input → the substrate maps it to
1301
+ * {@link ConfigParseError}, FAIL-CLOSED). `serialize` is comment-preserving: when
1302
+ * the doc carries `originalText` (the file pre-existed) it APPENDS the new
1303
+ * `mcp_servers.<name>` table to the original bytes rather than re-serializing the
1304
+ * whole value; only when there is no original (`null`) does it serialize the full
1305
+ * value from scratch. `assertEntry` checks `mcp_servers.<name>` is present.
1306
+ *
1307
+ * Because `serialize` needs the merged value to know WHICH server to append, the
1308
+ * merge fn ({@link mergeCodexServer}) records the just-added server on the doc; the
1309
+ * append path reads it back. For an idempotent no-op (server already present,
1310
+ * def-equal) the merge returns the doc UNCHANGED with no pending append, so
1311
+ * `serialize` reproduces the original text exactly (no write happens).
1312
+ */
1313
+ export const codexConfigFormat = {
1314
+ parse(text) {
1315
+ // smol-toml throws TomlError on malformed input; the substrate's readConfigDoc
1316
+ // wraps this throw into ConfigParseError and ABORTS (fail-closed, file kept).
1317
+ const value = parseToml(text);
1318
+ return { value, originalText: text };
1319
+ },
1320
+ serialize(doc) {
1321
+ const pending = doc[PENDING_APPEND];
1322
+ if (doc.originalText !== null && pending) {
1323
+ // APPEND path: keep the original bytes verbatim (comments + every existing
1324
+ // table/sub-table preserved), then append the one new server table.
1325
+ return appendCodexTable(doc.originalText, pending.name, pending.def);
1326
+ }
1327
+ if (doc.originalText !== null && !pending) {
1328
+ // No-op (idempotent / no pending add): reproduce the original byte-for-byte
1329
+ // so writeConfigAtomic sees an unchanged file and writes nothing.
1330
+ return doc.originalText;
1331
+ }
1332
+ // FRESH file (no original to preserve): serialize the whole value. If there is a
1333
+ // pending add, fold it into the value first so a from-scratch write includes it.
1334
+ const value = pending
1335
+ ? withServer(doc.value, pending.name, pending.def)
1336
+ : doc.value;
1337
+ return stringifyToml(value);
1338
+ },
1339
+ emptyDoc() {
1340
+ return { value: {}, originalText: null };
1341
+ },
1342
+ assertEntry(doc, name) {
1343
+ const servers = doc.value[CODEX_MCP_SERVERS_KEY];
1344
+ if (!servers || typeof servers !== 'object' || Array.isArray(servers) || !(name in servers)) {
1345
+ throw new Error(`mcp_servers.${name} not present after write`);
1346
+ }
1347
+ },
1348
+ };
1349
+ /** Symbol carrying the pending append on a {@link CodexTomlDoc} (merge → serialize). */
1350
+ const PENDING_APPEND = Symbol('hqPendingCodexAppend');
1351
+ /** Return a copy of `value` with `mcp_servers.<name> = def` set (used for fresh-file serialize). */
1352
+ function withServer(value, name, def) {
1353
+ const servers = { ...(value[CODEX_MCP_SERVERS_KEY] ?? {}) };
1354
+ servers[name] = def;
1355
+ return { ...value, [CODEX_MCP_SERVERS_KEY]: servers };
1356
+ }
1357
+ /**
1358
+ * Append a `[mcp_servers.<name>]` table to existing TOML text, preserving the
1359
+ * original bytes (and thus all comments + existing tables). Ensures exactly one
1360
+ * blank-line separator between the original content and the appended block so the
1361
+ * result is well-formed regardless of the original's trailing whitespace.
1362
+ */
1363
+ export function appendCodexTable(originalText, name, def) {
1364
+ const block = serializeOneCodexServer(name, def);
1365
+ const trimmed = originalText.replace(/\s*$/, '');
1366
+ if (trimmed.length === 0) {
1367
+ // Original was whitespace-only — emit just the block (no leading blank lines).
1368
+ return block.endsWith('\n') ? block : `${block}\n`;
1369
+ }
1370
+ const body = block.endsWith('\n') ? block : `${block}\n`;
1371
+ return `${trimmed}\n\n${body}`;
1372
+ }
1373
+ /**
1374
+ * Build the Codex server table emitted as `[mcp_servers.<name>]`: pass the
1375
+ * transport fields through, resolve `${secret:}` in every header/env value
1376
+ * (recording plaintexts in `secretSink` for redaction), stamp `_hqPack` provenance,
1377
+ * and write the per-tool `approval_mode` from the manifest where present (the Codex-
1378
+ * specific field — `[mcp_servers.<name>.tools.<t>]` with `approval_mode = "…"`).
1379
+ *
1380
+ * Key insertion order is deterministic so a re-install serializes byte-identically.
1381
+ */
1382
+ export function buildCodexServerDef(manifest, pack, resolve, secretSink) {
1383
+ const def = { type: manifest.type };
1384
+ if (manifest.url !== undefined)
1385
+ def.url = manifest.url;
1386
+ if (manifest.command !== undefined)
1387
+ def.command = manifest.command;
1388
+ if (manifest.args !== undefined)
1389
+ def.args = manifest.args;
1390
+ if (manifest.headers !== undefined) {
1391
+ def.headers = resolveStringMap(manifest.headers, resolve, secretSink);
1392
+ }
1393
+ if (manifest.env !== undefined) {
1394
+ def.env = resolveStringMap(manifest.env, resolve, secretSink);
1395
+ }
1396
+ // Per-tool approval_mode (Codex-specific): manifest `tools.<t>.approval_mode`
1397
+ // → `[mcp_servers.<name>.tools.<t>] approval_mode = "…"`. Only emit a tools
1398
+ // sub-table when at least one tool actually declares an approval_mode, so we
1399
+ // never write an empty `[…tools]` table.
1400
+ const tools = buildCodexToolsTable(manifest.tools);
1401
+ if (tools !== undefined)
1402
+ def.tools = tools;
1403
+ // Provenance LAST so it reads as an HQ annotation; US-009 uninstall keys off it.
1404
+ def[HQ_PACK_PROVENANCE_KEY] = pack;
1405
+ return def;
1406
+ }
1407
+ /**
1408
+ * Extract the per-tool `approval_mode` sub-table from a manifest's `tools` map.
1409
+ * The manifest `tools` is `Record<tool, { approval_mode?: string, … }>`; we emit
1410
+ * `{ <tool>: { approval_mode } }` ONLY for tools that declare an approval_mode
1411
+ * (the field the Codex emitter is responsible for). Returns `undefined` when no
1412
+ * tool declares one (so no empty tools table is written).
1413
+ */
1414
+ export function buildCodexToolsTable(tools) {
1415
+ if (!tools || typeof tools !== 'object')
1416
+ return undefined;
1417
+ const out = {};
1418
+ for (const [tool, spec] of Object.entries(tools)) {
1419
+ const mode = spec?.approval_mode;
1420
+ if (typeof mode === 'string') {
1421
+ out[tool] = { approval_mode: mode };
1422
+ }
1423
+ }
1424
+ return Object.keys(out).length > 0 ? out : undefined;
1425
+ }
1426
+ /**
1427
+ * The merge fn for the Codex surface: insert/converge `mcp_servers.<name> = def`.
1428
+ *
1429
+ * - name ABSENT → insert (creating `mcp_servers` if needed) and record a
1430
+ * PENDING append so {@link codexConfigFormat.serialize} can preserve comments
1431
+ * by appending to the original bytes.
1432
+ * - name PRESENT, def-equal (provenance-ignored) → NO-OP (idempotent re-install /
1433
+ * PARTIAL self-heal): return the doc UNCHANGED with no pending append, so the
1434
+ * original text round-trips byte-for-byte.
1435
+ * - name PRESENT, def-DIFFERS → {@link McpNameCollisionError} ABORT (never mutate
1436
+ * an existing table — which is also what keeps the comment-preserving append
1437
+ * sound: we only ever ADD).
1438
+ *
1439
+ * Never touches any OTHER server table or any other top-level table.
1440
+ */
1441
+ export function mergeCodexServer(name, def) {
1442
+ return (doc) => {
1443
+ const servers = doc.value[CODEX_MCP_SERVERS_KEY] ?? {};
1444
+ const existing = servers[name];
1445
+ if (existing !== undefined) {
1446
+ if (serverDefsEqual(existing, def)) {
1447
+ // Idempotent: meaningful def matches. Return UNCHANGED (no pending append)
1448
+ // so serialize reproduces the original text exactly.
1449
+ return doc;
1450
+ }
1451
+ throw new McpNameCollisionError(`MCP server "${name}" is already defined in ~/.codex/config.toml with a different ` +
1452
+ 'definition — refusing to overwrite. Remove or rename the existing server, ' +
1453
+ 'or uninstall the conflicting pack, then re-install.');
1454
+ }
1455
+ // Insert into the value (for verify-after-write) AND record the pending append
1456
+ // (for comment-preserving serialize).
1457
+ const nextValue = withServer(doc.value, name, def);
1458
+ const next = {
1459
+ value: nextValue,
1460
+ originalText: doc.originalText,
1461
+ [PENDING_APPEND]: { name, def },
1462
+ };
1463
+ return next;
1464
+ };
1465
+ }
1466
+ /**
1467
+ * Emit ONE server into the GLOBAL Codex surface (`~/.codex/config.toml`
1468
+ * `[mcp_servers.<name>]`) via {@link writeConfigAtomic}. A FIRST-CLASS SKIP when the
1469
+ * Codex runtime is absent (`~/.codex` missing — never fabricated). Resolves
1470
+ * `${secret:}` headers/env at emit, stamps provenance, writes per-tool
1471
+ * `approval_mode`, and is idempotent per surface. Existing tables + sub-tables (and
1472
+ * comments) are preserved.
1473
+ *
1474
+ * @returns the surface result, or a {@link RegisterSurfaceSkip} (Codex absent).
1475
+ * @throws McpManifestError invalid server name or unresolvable `${secret:}`
1476
+ * @throws McpNameCollisionError the name exists with a different definition
1477
+ * @throws ConfigParseError existing config.toml is malformed (fail-closed)
1478
+ * @throws ConfigPermissionError / PartialRegistrationError (from the core)
1479
+ */
1480
+ export function registerCodexServer(opts) {
1481
+ assertValidServerName(opts.name);
1482
+ const env = resolveEnv(opts.env);
1483
+ // FIRST-CLASS SKIP: no Codex runtime → do not write, do not fabricate ~/.codex.
1484
+ if (!isCodexInstalled(env)) {
1485
+ return {
1486
+ skipped: true,
1487
+ reason: 'Codex runtime absent (~/.codex missing) — first-class skip, Codex not registered',
1488
+ };
1489
+ }
1490
+ const resolve = opts.resolveSecret ?? noSecretResolver;
1491
+ const secretSink = new Set();
1492
+ // Resolve secrets + build the def BEFORE entering the lock so a resolution
1493
+ // failure aborts without taking a backup or touching the file.
1494
+ const def = buildCodexServerDef(opts.manifest, opts.pack, resolve, secretSink);
1495
+ try {
1496
+ const res = writeConfigAtomic({
1497
+ target: codexConfigPath(env),
1498
+ pack: opts.pack,
1499
+ format: codexConfigFormat,
1500
+ merge: mergeCodexServer(opts.name, def),
1501
+ assertName: opts.name,
1502
+ env: opts.env,
1503
+ lock: opts.lock,
1504
+ stamp: opts.stamp,
1505
+ });
1506
+ return { changed: res.changed, realTarget: res.realTarget };
1507
+ }
1508
+ catch (e) {
1509
+ // Redact any resolved secret that could have leaked into an error message.
1510
+ if (secretSink.size > 0 && e instanceof Error) {
1511
+ e.message = redactSecrets(e.message, secretSink);
1512
+ }
1513
+ throw e;
1514
+ }
1515
+ }
1516
+ /**
1517
+ * Read `_hqPack` off a server def (Claude object OR Codex TOML table). Returns the
1518
+ * stamp string, or `undefined` when the entry is unstamped (user-created) — the two
1519
+ * cases the skip-and-warn branch treats identically (anything we do not provably own
1520
+ * is left alone).
1521
+ */
1522
+ function provenanceOf(def) {
1523
+ if (def === null || typeof def !== 'object' || Array.isArray(def))
1524
+ return undefined;
1525
+ const stamp = def[HQ_PACK_PROVENANCE_KEY];
1526
+ return typeof stamp === 'string' ? stamp : undefined;
1527
+ }
1528
+ /**
1529
+ * Classify the entry at a name against the pack being uninstalled. PROVENANCE IS
1530
+ * THE ONLY KEY — never the name alone:
1531
+ * - no entry → `absent` (idempotent no-op);
1532
+ * - entry, stamp===pack → `remove`;
1533
+ * - entry, stamp≠pack OR unstamped → `skip-foreign` (leave it; warn).
1534
+ */
1535
+ function classifyRemoval(existing, pack) {
1536
+ if (existing === undefined)
1537
+ return 'absent';
1538
+ return provenanceOf(existing) === pack ? 'remove' : 'skip-foreign';
1539
+ }
1540
+ // ---------------------------------------------------------------------------
1541
+ // Claude/JSON un-registration.
1542
+ // ---------------------------------------------------------------------------
1543
+ /**
1544
+ * The merge fn for REMOVING one server from the Claude surface, scoped by
1545
+ * provenance. Returns `(doc) => doc'`:
1546
+ *
1547
+ * - `mcpServers.<name>` ABSENT → return the doc UNCHANGED (idempotent no-op).
1548
+ * - present AND `_hqPack === pack` → delete that ONE key from a SHALLOW COPY of
1549
+ * `mcpServers`, preserving every sibling server and every other top-level
1550
+ * `~/.claude.json` key byte-for-byte. If `mcpServers` becomes empty we LEAVE an
1551
+ * empty `mcpServers: {}` (it round-trips cleanly and is idempotent — a second
1552
+ * uninstall sees the name already absent and no-ops; we do NOT delete the
1553
+ * `mcpServers` key itself so the surface shape stays stable across re-runs).
1554
+ * - present AND `_hqPack !== pack` (or unstamped) → return the doc UNCHANGED and
1555
+ * do NOT delete (skip-and-warn; the caller reports it). NEVER a bare name-match
1556
+ * delete.
1557
+ *
1558
+ * Never touches any OTHER server entry or any other top-level key.
1559
+ */
1560
+ export function removeClaudeServer(name, pack) {
1561
+ return (doc) => {
1562
+ const servers = doc.mcpServers ?? undefined;
1563
+ const existing = servers ? servers[name] : undefined;
1564
+ const decision = classifyRemoval(existing, pack);
1565
+ if (decision !== 'remove') {
1566
+ // absent OR skip-foreign → leave the doc untouched (idempotent / foreign-safe).
1567
+ return doc;
1568
+ }
1569
+ // remove: shallow-copy mcpServers, drop ONLY this key, keep every sibling +
1570
+ // every other top-level key. Leave an empty `mcpServers: {}` if it empties out.
1571
+ const nextServers = { ...servers };
1572
+ delete nextServers[name];
1573
+ return { ...doc, mcpServers: nextServers };
1574
+ };
1575
+ }
1576
+ /**
1577
+ * A {@link ConfigFormat} for the Claude REMOVAL path: same parse/serialize/emptyDoc
1578
+ * as {@link claudeConfigFormat}, but `assertEntry` is INVERTED — it throws IFF the
1579
+ * name is STILL present after the write. {@link writeConfigAtomic} runs this in its
1580
+ * verify-after-write step (and in the no-op `nextText === currentText` branch), so a
1581
+ * successful removal verifies the entry is GONE rather than present.
1582
+ */
1583
+ export const claudeRemovalFormat = {
1584
+ parse: claudeConfigFormat.parse,
1585
+ serialize: claudeConfigFormat.serialize,
1586
+ emptyDoc: claudeConfigFormat.emptyDoc,
1587
+ assertEntry(doc, name) {
1588
+ const servers = doc.mcpServers;
1589
+ if (servers && typeof servers === 'object' && !Array.isArray(servers) && name in servers) {
1590
+ throw new Error(`mcpServers.${name} is STILL present after removal`);
1591
+ }
1592
+ },
1593
+ };
1594
+ /**
1595
+ * Classify the CURRENT on-disk entry for a name WITHOUT writing — used to decide the
1596
+ * outcome (`removed` / `absent` / `skipped-foreign`) and to short-circuit the
1597
+ * skip-foreign case (no write, no backup) before entering {@link writeConfigAtomic}.
1598
+ * Reads through the same {@link ConfigFormat} the write path uses so the parse is
1599
+ * consistent (a malformed file surfaces as {@link ConfigParseError} here too — we do
1600
+ * not silently treat an unparseable config as "absent").
1601
+ */
1602
+ function classifyClaudeOnDisk(target, name, pack) {
1603
+ const { doc } = readConfigDoc(target, claudeConfigFormat);
1604
+ const servers = doc.mcpServers ?? undefined;
1605
+ const existing = servers ? servers[name] : undefined;
1606
+ return { decision: classifyRemoval(existing, pack), realTarget: realpathOrSelf(target) };
1607
+ }
1608
+ /**
1609
+ * Un-register ONE server from the PINNED single Claude surface (top-level
1610
+ * `~/.claude.json` `mcpServers`), provenance-scoped, via {@link writeConfigAtomic}.
1611
+ *
1612
+ * - stamp matches this pack → REMOVE (through the safe-write path: backup + atomic
1613
+ * + lock + verify-ABSENT), `outcome: 'removed'`.
1614
+ * - entry absent → clean no-op, `outcome: 'absent'` (no write).
1615
+ * - entry present but foreign/unstamped → SKIP-AND-WARN, `outcome: 'skipped-foreign'`
1616
+ * (no write; the caller surfaces `reason`).
1617
+ *
1618
+ * Tolerates an absent / blank `~/.claude.json` (treated as "nothing to remove" →
1619
+ * `absent`). Idempotent: a second uninstall of the same server sees it already gone
1620
+ * → `absent`.
1621
+ *
1622
+ * @throws ConfigParseError existing non-empty `~/.claude.json` is unparseable (ABORT)
1623
+ * @throws ConfigPermissionError read/lock failure other than ENOENT (ABORT)
1624
+ * @throws PartialRegistrationError verify-after-write found the entry STILL present
1625
+ */
1626
+ export function unregisterClaudeServer(opts) {
1627
+ assertValidServerName(opts.name);
1628
+ const target = claudeConfigPath2(opts.env);
1629
+ // Pre-classify WITHOUT writing so a foreign/absent entry never takes a backup or
1630
+ // touches the file. (writeConfigAtomic re-reads inside the lock; the merge fn
1631
+ // re-applies the SAME provenance policy, so this is a fast-path, not the guard.)
1632
+ const { decision } = classifyClaudeOnDisk(target, opts.name, opts.pack);
1633
+ if (decision === 'skip-foreign') {
1634
+ return {
1635
+ outcome: 'skipped-foreign',
1636
+ changed: false,
1637
+ realTarget: realpathOrSelf(target),
1638
+ reason: `MCP server "${opts.name}" in ~/.claude.json is not owned by pack "${opts.pack}" ` +
1639
+ '(no matching _hqPack provenance) — left in place (skip-and-warn).',
1640
+ };
1641
+ }
1642
+ // absent OR remove → go through the safe-write path with the REMOVAL format (which
1643
+ // verifies ABSENCE). For `absent`, the merge returns the doc unchanged →
1644
+ // writeConfigAtomic's nextText===currentText branch runs assertEntry (already
1645
+ // absent ⇒ passes) ⇒ changed:false. For `remove`, the key is dropped + verified gone.
1646
+ const res = writeConfigAtomic({
1647
+ target,
1648
+ pack: opts.pack,
1649
+ format: claudeRemovalFormat,
1650
+ merge: removeClaudeServer(opts.name, opts.pack),
1651
+ assertName: opts.name,
1652
+ env: opts.env,
1653
+ lock: opts.lock,
1654
+ stamp: opts.stamp,
1655
+ });
1656
+ return {
1657
+ outcome: res.changed ? 'removed' : 'absent',
1658
+ changed: res.changed,
1659
+ realTarget: res.realTarget,
1660
+ };
1661
+ }
1662
+ // ---------------------------------------------------------------------------
1663
+ // Codex/TOML un-registration.
1664
+ // ---------------------------------------------------------------------------
1665
+ /** Symbol marking a pending REMOVAL on a {@link CodexTomlDoc} (merge → serialize). */
1666
+ const PENDING_REMOVE = Symbol('hqPendingCodexRemove');
1667
+ /**
1668
+ * The merge fn for REMOVING one server from the Codex surface, scoped by provenance.
1669
+ *
1670
+ * - `mcp_servers.<name>` ABSENT → return the doc UNCHANGED with NO pending removal,
1671
+ * so {@link codexRemovalFormat.serialize} reproduces `originalText` byte-for-byte
1672
+ * (no write, comments intact) — idempotent no-op.
1673
+ * - present AND `_hqPack === pack` → drop the key from a copy of the parsed `value`
1674
+ * AND record a PENDING_REMOVE marker so `serialize` knows it must RE-SERIALIZE the
1675
+ * value (the append-only path cannot express a removal). Sibling tables and every
1676
+ * `.tools.<t>.approval_mode` sub-table survive (they live in the re-serialized
1677
+ * value); COMMENTS are dropped on this write (documented value-only-TOML limit).
1678
+ * - present AND `_hqPack !== pack` (or unstamped) → return the doc UNCHANGED, no
1679
+ * pending removal (skip-and-warn). NEVER a bare name-match delete.
1680
+ *
1681
+ * Never touches any OTHER server table or any other top-level table.
1682
+ */
1683
+ export function removeCodexServer(name, pack) {
1684
+ return (doc) => {
1685
+ const servers = doc.value[CODEX_MCP_SERVERS_KEY] ?? undefined;
1686
+ const existing = servers ? servers[name] : undefined;
1687
+ const decision = classifyRemoval(existing, pack);
1688
+ if (decision !== 'remove') {
1689
+ // absent OR skip-foreign → unchanged doc, no pending removal → serialize
1690
+ // reproduces originalText byte-for-byte (no write; comments preserved).
1691
+ return doc;
1692
+ }
1693
+ // remove: drop the key from a COPY of the servers table inside a COPY of value.
1694
+ const nextServers = { ...servers };
1695
+ delete nextServers[name];
1696
+ const nextValue = { ...doc.value, [CODEX_MCP_SERVERS_KEY]: nextServers };
1697
+ const next = {
1698
+ value: nextValue,
1699
+ originalText: doc.originalText,
1700
+ [PENDING_REMOVE]: { name },
1701
+ };
1702
+ return next;
1703
+ };
1704
+ }
1705
+ /**
1706
+ * A {@link ConfigFormat} for the Codex REMOVAL path. It REUSES `parse`/`emptyDoc`
1707
+ * from {@link codexConfigFormat} but supplies its OWN `serialize` + an INVERTED
1708
+ * `assertEntry`:
1709
+ *
1710
+ * - `serialize`: when a PENDING_REMOVE is present the key was actually dropped, so
1711
+ * we RE-SERIALIZE the modified `value` via smol-toml `stringifyToml` (this is the
1712
+ * only way to express a removal; it DROPS comments — documented). With NO pending
1713
+ * removal (no-op / foreign) we reproduce `originalText` byte-for-byte (or
1714
+ * `stringifyToml(value)` for a fresh doc with no original), so an unchanged doc
1715
+ * round-trips and `writeConfigAtomic` writes nothing.
1716
+ * - `assertEntry`: throws IFF `mcp_servers.<name>` is STILL present (verify-ABSENT).
1717
+ */
1718
+ export const codexRemovalFormat = {
1719
+ parse: codexConfigFormat.parse,
1720
+ emptyDoc: codexConfigFormat.emptyDoc,
1721
+ serialize(doc) {
1722
+ const pending = doc[PENDING_REMOVE];
1723
+ if (pending) {
1724
+ // A key was actually removed — re-serialize the value (comments are dropped;
1725
+ // sibling tables + .tools.<t>.approval_mode sub-tables are preserved as DATA).
1726
+ return stringifyToml(doc.value);
1727
+ }
1728
+ if (doc.originalText !== null) {
1729
+ // No-op removal (absent / foreign) → reproduce the original bytes exactly.
1730
+ return doc.originalText;
1731
+ }
1732
+ // No original (fresh/empty doc) and nothing pending → serialize the value as-is.
1733
+ return stringifyToml(doc.value);
1734
+ },
1735
+ assertEntry(doc, name) {
1736
+ const servers = doc.value[CODEX_MCP_SERVERS_KEY];
1737
+ if (servers && typeof servers === 'object' && !Array.isArray(servers) && name in servers) {
1738
+ throw new Error(`mcp_servers.${name} is STILL present after removal`);
1739
+ }
1740
+ },
1741
+ };
1742
+ /**
1743
+ * Classify the CURRENT on-disk Codex entry for a name WITHOUT writing — mirror of
1744
+ * {@link classifyClaudeOnDisk}. A malformed config surfaces as {@link ConfigParseError}
1745
+ * (fail-closed; we never treat an unparseable file as "absent").
1746
+ */
1747
+ function classifyCodexOnDisk(target, name, pack) {
1748
+ const { doc } = readConfigDoc(target, codexConfigFormat);
1749
+ const servers = doc.value[CODEX_MCP_SERVERS_KEY] ?? undefined;
1750
+ const existing = servers ? servers[name] : undefined;
1751
+ return { decision: classifyRemoval(existing, pack), realTarget: realpathOrSelf(target) };
1752
+ }
1753
+ /**
1754
+ * Un-register ONE server from the GLOBAL Codex surface (`~/.codex/config.toml`
1755
+ * `[mcp_servers.<name>]`), provenance-scoped, via {@link writeConfigAtomic}. A
1756
+ * FIRST-CLASS SKIP when the Codex runtime is absent (`~/.codex` missing — never
1757
+ * fabricated): nothing to un-register, no crash.
1758
+ *
1759
+ * - stamp matches this pack → REMOVE (safe-write path; verify-ABSENT). The removal
1760
+ * write RE-SERIALIZES the value, which DROPS comments (documented limitation) but
1761
+ * preserves sibling tables + `.tools.<t>.approval_mode` sub-tables.
1762
+ * - entry absent → clean no-op, `outcome: 'absent'` (no write; comments intact).
1763
+ * - entry present but foreign/unstamped → SKIP-AND-WARN, `outcome: 'skipped-foreign'`.
1764
+ *
1765
+ * @returns the surface result, or a {@link UnregisterSurfaceSkip} (Codex absent).
1766
+ * @throws ConfigParseError existing config.toml is malformed (fail-closed)
1767
+ * @throws ConfigPermissionError / PartialRegistrationError (from the core)
1768
+ */
1769
+ export function unregisterCodexServer(opts) {
1770
+ assertValidServerName(opts.name);
1771
+ const env = resolveEnv(opts.env);
1772
+ // FIRST-CLASS SKIP: no Codex runtime → nothing to remove, do not fabricate ~/.codex.
1773
+ if (!isCodexInstalled(env)) {
1774
+ return {
1775
+ skipped: true,
1776
+ reason: 'Codex runtime absent (~/.codex missing) — first-class skip, nothing to un-register',
1777
+ };
1778
+ }
1779
+ const target = codexConfigPath(env);
1780
+ // Pre-classify WITHOUT writing (mirror of the Claude arm).
1781
+ const { decision } = classifyCodexOnDisk(target, opts.name, opts.pack);
1782
+ if (decision === 'skip-foreign') {
1783
+ return {
1784
+ outcome: 'skipped-foreign',
1785
+ changed: false,
1786
+ realTarget: realpathOrSelf(target),
1787
+ reason: `MCP server "${opts.name}" in ~/.codex/config.toml is not owned by pack "${opts.pack}" ` +
1788
+ '(no matching _hqPack provenance) — left in place (skip-and-warn).',
1789
+ };
1790
+ }
1791
+ const res = writeConfigAtomic({
1792
+ target,
1793
+ pack: opts.pack,
1794
+ format: codexRemovalFormat,
1795
+ merge: removeCodexServer(opts.name, opts.pack),
1796
+ assertName: opts.name,
1797
+ env: opts.env,
1798
+ lock: opts.lock,
1799
+ stamp: opts.stamp,
1800
+ });
1801
+ return {
1802
+ outcome: res.changed ? 'removed' : 'absent',
1803
+ changed: res.changed,
1804
+ realTarget: res.realTarget,
1805
+ };
1806
+ }
1807
+ /**
1808
+ * Fan ONE server's un-registration out across BOTH per-runtime surfaces — the mirror
1809
+ * of {@link registerServer}. Claude is inspected first (consistent with register's
1810
+ * ordering, though for removal the ordering is less critical: each surface is
1811
+ * independent and idempotent). The Codex arm is a FIRST-CLASS SKIP when `~/.codex` is
1812
+ * absent. Each arm is provenance-scoped and skip-and-warns on a foreign entry —
1813
+ * neither surface aborts the other.
1814
+ */
1815
+ export function unregisterServer(opts) {
1816
+ assertValidServerName(opts.name);
1817
+ const env = resolveEnv(opts.env);
1818
+ const audit = opts.audit ?? true;
1819
+ const claudeTarget = claudeConfigPath(env);
1820
+ const codexTarget = codexConfigPath(env);
1821
+ // Derive transport/target from the on-disk def BEFORE removal (no manifest at
1822
+ // unregister time). If neither surface has the def, fall back to unknown/'' —
1823
+ // the hashes + action + server + pack are the load-bearing audit fields.
1824
+ const onDisk = audit
1825
+ ? readOnDiskTransportTarget(env, opts.name)
1826
+ : { transport: 'unknown', target: '' };
1827
+ // Claude arm: capture prevHash, emit, log per outcome.
1828
+ const claudePrevHash = audit ? hashFileContents(claudeTarget) : '';
1829
+ let claude;
1830
+ try {
1831
+ claude = unregisterClaudeServer(opts);
1832
+ }
1833
+ catch (e) {
1834
+ if (audit) {
1835
+ appendAuditLog(env, {
1836
+ ts: new Date().toISOString(),
1837
+ action: 'unregister',
1838
+ pack: opts.pack,
1839
+ server: opts.name,
1840
+ transport: onDisk.transport,
1841
+ target: onDisk.target,
1842
+ file: claudeTarget,
1843
+ prevHash: claudePrevHash,
1844
+ newHash: hashFileContents(claudeTarget),
1845
+ result: 'error',
1846
+ });
1847
+ }
1848
+ throw e;
1849
+ }
1850
+ if (audit) {
1851
+ appendAuditLog(env, {
1852
+ ts: new Date().toISOString(),
1853
+ action: 'unregister',
1854
+ pack: opts.pack,
1855
+ server: opts.name,
1856
+ transport: onDisk.transport,
1857
+ target: onDisk.target,
1858
+ file: claude.realTarget,
1859
+ prevHash: claudePrevHash,
1860
+ newHash: hashFileContents(claude.realTarget),
1861
+ result: unregisterResultFor(claude.outcome),
1862
+ });
1863
+ }
1864
+ // Codex arm: capture prevHash, emit, log per outcome (or a skip line).
1865
+ const codexPrevHash = audit ? hashFileContents(codexTarget) : '';
1866
+ let codex;
1867
+ try {
1868
+ codex = unregisterCodexServer(opts);
1869
+ }
1870
+ catch (e) {
1871
+ if (audit) {
1872
+ appendAuditLog(env, {
1873
+ ts: new Date().toISOString(),
1874
+ action: 'unregister',
1875
+ pack: opts.pack,
1876
+ server: opts.name,
1877
+ transport: onDisk.transport,
1878
+ target: onDisk.target,
1879
+ file: codexTarget,
1880
+ prevHash: codexPrevHash,
1881
+ newHash: hashFileContents(codexTarget),
1882
+ result: 'error',
1883
+ });
1884
+ }
1885
+ throw e;
1886
+ }
1887
+ if (audit) {
1888
+ const skipped = 'skipped' in codex && codex.skipped === true;
1889
+ appendAuditLog(env, {
1890
+ ts: new Date().toISOString(),
1891
+ action: 'unregister',
1892
+ pack: opts.pack,
1893
+ server: opts.name,
1894
+ transport: onDisk.transport,
1895
+ target: onDisk.target,
1896
+ file: skipped ? codexTarget : codex.realTarget,
1897
+ prevHash: codexPrevHash,
1898
+ newHash: skipped ? codexPrevHash : hashFileContents(codex.realTarget),
1899
+ result: skipped ? 'skipped' : unregisterResultFor(codex.outcome),
1900
+ });
1901
+ }
1902
+ return { claude, codex };
1903
+ }
1904
+ /** Map an {@link UnregisterOutcome} to the audit-log {@link AuditResult}. */
1905
+ function unregisterResultFor(outcome) {
1906
+ if (outcome === 'removed')
1907
+ return 'unregistered';
1908
+ if (outcome === 'absent')
1909
+ return 'noop';
1910
+ return 'skipped'; // skipped-foreign — the entry was left in place.
1911
+ }
1912
+ /**
1913
+ * Read the transport + target off the on-disk server def (for the audit line at
1914
+ * UNREGISTER time, where there is no manifest). Prefers the Claude def, falls back
1915
+ * to the Codex def, and returns `{ transport:'unknown', target:'' }` when neither
1916
+ * surface has the server or a config is absent/unparseable. NEVER reads or echoes
1917
+ * a header/secret. Best-effort: any error degrades to unknown/'' (never throws).
1918
+ */
1919
+ function readOnDiskTransportTarget(env, name) {
1920
+ // Claude first.
1921
+ try {
1922
+ const { doc } = readConfigDoc(claudeConfigPath(env), claudeConfigFormat);
1923
+ const servers = doc.mcpServers;
1924
+ const def = servers?.[name];
1925
+ const tt = transportTargetOfDef(def);
1926
+ if (tt)
1927
+ return tt;
1928
+ }
1929
+ catch {
1930
+ // Unparseable/absent Claude config — fall through to Codex.
1931
+ }
1932
+ // Codex fallback.
1933
+ if (isCodexInstalled(env)) {
1934
+ try {
1935
+ const { doc } = readConfigDoc(codexConfigPath(env), codexConfigFormat);
1936
+ const servers = doc.value[CODEX_MCP_SERVERS_KEY];
1937
+ const def = servers?.[name];
1938
+ const tt = transportTargetOfDef(def);
1939
+ if (tt)
1940
+ return tt;
1941
+ }
1942
+ catch {
1943
+ // Unparseable/absent Codex config — fall through to unknown.
1944
+ }
1945
+ }
1946
+ return { transport: 'unknown', target: '' };
1947
+ }
1948
+ /**
1949
+ * Extract `{ transport, target }` from a server def object (Claude JSON or Codex
1950
+ * TOML table). `target` is the url (http/sse) or command(+args) (stdio) — NEVER a
1951
+ * header/secret. Returns `null` when `def` is not an object (so the caller can
1952
+ * fall through to the other surface).
1953
+ */
1954
+ function transportTargetOfDef(def) {
1955
+ if (def === null || typeof def !== 'object' || Array.isArray(def))
1956
+ return null;
1957
+ const d = def;
1958
+ const type = typeof d.type === 'string' ? d.type : 'unknown';
1959
+ let target = '';
1960
+ if ((type === 'http' || type === 'sse') && typeof d.url === 'string') {
1961
+ target = d.url;
1962
+ }
1963
+ else if (typeof d.command === 'string') {
1964
+ const args = Array.isArray(d.args) && d.args.length > 0 ? ` ${d.args.join(' ')}` : '';
1965
+ target = `${d.command}${args}`;
1966
+ }
1967
+ else if (typeof d.url === 'string') {
1968
+ target = d.url;
1969
+ }
1970
+ return { transport: type, target };
1971
+ }
1972
+ /**
1973
+ * Un-register one pack's MCP servers from the shared agent configs — the public seam
1974
+ * the UNINSTALL path calls, mirroring {@link registerMcpServers}. Unlike register, it
1975
+ * needs NO manifest loader: removal keys off the server NAME + the pack's PROVENANCE
1976
+ * stamp on the on-disk entry, not the manifest. So the signature is simpler:
1977
+ * `(pkg, names, { env?, lock?, stamp? })`.
1978
+ *
1979
+ * For each name it fans out via {@link unregisterServer} (Claude + Codex), removing
1980
+ * ONLY entries stamped `_hqPack === pkg` and skip-and-warning on any foreign/unstamped
1981
+ * same-named entry. Tolerant of an absent Codex runtime / absent config files, and
1982
+ * idempotent (uninstalling an already-removed pack is a clean no-op).
1983
+ *
1984
+ * @param pkg the pack name being uninstalled (the provenance to match)
1985
+ * @param names the bare server names from `contributes.mcp`
1986
+ * @returns one {@link UnregisterServerResult} per server, in `names` order
1987
+ */
1988
+ export function unregisterMcpServers(pkg, names, options) {
1989
+ return names.map((name) => {
1990
+ assertValidServerName(name);
1991
+ return unregisterServer({
1992
+ name,
1993
+ pack: pkg,
1994
+ env: options?.env,
1995
+ lock: options?.lock,
1996
+ stamp: options?.stamp,
1997
+ });
1998
+ });
1999
+ }
2000
+ //# sourceMappingURL=mcp-registration.js.map
2001
+ //# debugId=d4f678fe-c88f-5d08-b6bc-1e591ac8c4b6