@indigoai-us/hq-cli 5.49.0 → 5.50.1

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