@indigoai-us/hq-cli 5.77.13 → 5.78.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (307) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
  3. package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
  4. package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
  5. package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
  6. package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
  7. package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
  8. package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
  9. package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
  10. package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
  11. package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
  12. package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
  13. package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
  14. package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
  15. package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
  16. package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
  17. package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
  18. package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
  19. package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
  20. package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
  21. package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
  22. package/assets/scaffold/core/scripts/worktree.sh +136 -0
  23. package/dist/commands/agents.js +28 -12
  24. package/dist/commands/api-keys.js +100 -20
  25. package/dist/commands/core.d.ts +90 -0
  26. package/dist/commands/core.js +279 -0
  27. package/dist/commands/files-browse.d.ts +21 -0
  28. package/dist/commands/files-browse.js +145 -24
  29. package/dist/commands/files.js +33 -7
  30. package/dist/commands/members.js +16 -5
  31. package/dist/commands/pack-install.d.ts +14 -0
  32. package/dist/commands/pack-install.js +51 -2
  33. package/dist/commands/pkg-install.js +43 -0
  34. package/dist/commands/run.js +8 -0
  35. package/dist/commands/secrets.js +125 -65
  36. package/dist/main.js +6 -0
  37. package/dist/utils/api-key-command-gate.d.ts +45 -0
  38. package/dist/utils/api-key-command-gate.js +51 -0
  39. package/dist/utils/hq-roots.d.ts +91 -0
  40. package/dist/utils/hq-roots.js +212 -0
  41. package/dist/utils/resolve-vault-credential.d.ts +63 -3
  42. package/dist/utils/resolve-vault-credential.js +152 -6
  43. package/dist/utils/run-bundled-script.d.ts +66 -0
  44. package/dist/utils/run-bundled-script.js +80 -0
  45. package/dist/utils/vault-api.d.ts +21 -0
  46. package/dist/utils/vault-api.js +74 -3
  47. package/package.json +7 -1
  48. package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
  49. package/.github/workflows/ci.yml +0 -46
  50. package/.github/workflows/publish.yml +0 -36
  51. package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
  52. package/e2e/cli.test.ts +0 -128
  53. package/eslint.config.js +0 -37
  54. package/pnpm-workspace.yaml +0 -4
  55. package/scripts/chmod-bins.mjs +0 -49
  56. package/scripts/generate-dsn.mjs +0 -26
  57. package/scripts/generate-scan-packages-table.mjs +0 -113
  58. package/scripts/smoke-sources-signals.sh +0 -103
  59. package/src/bin/hq-auth-refresh.ts +0 -43
  60. package/src/cli-version.ts +0 -13
  61. package/src/commands/__fixtures__/make-tar.ts +0 -126
  62. package/src/commands/_patterns.ts +0 -10
  63. package/src/commands/add.ts +0 -75
  64. package/src/commands/agents.test.ts +0 -527
  65. package/src/commands/agents.ts +0 -923
  66. package/src/commands/api-keys.test.ts +0 -291
  67. package/src/commands/api-keys.ts +0 -382
  68. package/src/commands/artifact-verify.test.ts +0 -177
  69. package/src/commands/auth.test.ts +0 -82
  70. package/src/commands/auth.ts +0 -213
  71. package/src/commands/billing.test.ts +0 -158
  72. package/src/commands/billing.ts +0 -146
  73. package/src/commands/channels.test.ts +0 -74
  74. package/src/commands/channels.ts +0 -91
  75. package/src/commands/cloud-demote.test.ts +0 -401
  76. package/src/commands/cloud-demote.ts +0 -278
  77. package/src/commands/cloud-provision.test.ts +0 -1117
  78. package/src/commands/cloud-provision.ts +0 -942
  79. package/src/commands/cloud.pull-all.test.ts +0 -711
  80. package/src/commands/cloud.pull-per-company.test.ts +0 -188
  81. package/src/commands/cloud.push-all.test.ts +0 -485
  82. package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
  83. package/src/commands/cloud.selectors.test.ts +0 -208
  84. package/src/commands/cloud.status.test.ts +0 -160
  85. package/src/commands/cloud.test.ts +0 -41
  86. package/src/commands/cloud.ts +0 -2130
  87. package/src/commands/company.test.ts +0 -202
  88. package/src/commands/company.ts +0 -148
  89. package/src/commands/creators.test.ts +0 -60
  90. package/src/commands/creators.ts +0 -117
  91. package/src/commands/crm.test.ts +0 -179
  92. package/src/commands/crm.ts +0 -236
  93. package/src/commands/db-migrate.ts +0 -55
  94. package/src/commands/db-provision.ts +0 -114
  95. package/src/commands/db-sql.ts +0 -124
  96. package/src/commands/db-status.ts +0 -108
  97. package/src/commands/db.ts +0 -26
  98. package/src/commands/dm.test.ts +0 -748
  99. package/src/commands/dm.ts +0 -1226
  100. package/src/commands/feedback.test.ts +0 -413
  101. package/src/commands/feedback.ts +0 -169
  102. package/src/commands/files-browse.test.ts +0 -1249
  103. package/src/commands/files-browse.ts +0 -1506
  104. package/src/commands/files-delete.test.ts +0 -484
  105. package/src/commands/files-recovery.test.ts +0 -370
  106. package/src/commands/files.test.ts +0 -722
  107. package/src/commands/files.ts +0 -1446
  108. package/src/commands/group-grants.test.ts +0 -330
  109. package/src/commands/group-grants.ts +0 -455
  110. package/src/commands/groups.test.ts +0 -44
  111. package/src/commands/groups.ts +0 -368
  112. package/src/commands/integrations.test.ts +0 -633
  113. package/src/commands/integrations.ts +0 -507
  114. package/src/commands/list.ts +0 -85
  115. package/src/commands/login.ts +0 -52
  116. package/src/commands/logout.ts +0 -30
  117. package/src/commands/marketplace-install.test.ts +0 -543
  118. package/src/commands/marketplace-security.test.ts +0 -646
  119. package/src/commands/mcp-registration.test.ts +0 -2787
  120. package/src/commands/mcp-registration.ts +0 -2612
  121. package/src/commands/mcp-status.test.ts +0 -483
  122. package/src/commands/mcp-status.ts +0 -575
  123. package/src/commands/mcp-status.us011.test.ts +0 -243
  124. package/src/commands/meetings.test.ts +0 -522
  125. package/src/commands/meetings.ts +0 -789
  126. package/src/commands/members.test.ts +0 -1474
  127. package/src/commands/members.ts +0 -1181
  128. package/src/commands/onboard-identity-guard.test.ts +0 -99
  129. package/src/commands/onboard-identity-guard.ts +0 -103
  130. package/src/commands/onboard-join.test.ts +0 -96
  131. package/src/commands/onboard-join.ts +0 -101
  132. package/src/commands/onboard-warning.test.ts +0 -26
  133. package/src/commands/onboard-warning.ts +0 -12
  134. package/src/commands/onboard.ts +0 -267
  135. package/src/commands/outposts-heartbeat.test.ts +0 -299
  136. package/src/commands/outposts-heartbeat.ts +0 -310
  137. package/src/commands/outposts-replica-sync.test.ts +0 -272
  138. package/src/commands/outposts-self-deploy.test.ts +0 -243
  139. package/src/commands/outposts.test.ts +0 -1039
  140. package/src/commands/outposts.ts +0 -1935
  141. package/src/commands/pack-install-secret-authorization.test.ts +0 -115
  142. package/src/commands/pack-install.test.ts +0 -1469
  143. package/src/commands/pack-install.ts +0 -2247
  144. package/src/commands/pack-update-cache.test.ts +0 -149
  145. package/src/commands/packs.test.ts +0 -88
  146. package/src/commands/packs.ts +0 -622
  147. package/src/commands/people.test.ts +0 -807
  148. package/src/commands/people.ts +0 -422
  149. package/src/commands/pkg-install.dispatch.test.ts +0 -71
  150. package/src/commands/pkg-install.ts +0 -215
  151. package/src/commands/pkg-list.test.ts +0 -79
  152. package/src/commands/pkg-list.ts +0 -13
  153. package/src/commands/pkg-remove.ts +0 -71
  154. package/src/commands/pkg-update.ts +0 -189
  155. package/src/commands/publish.test.ts +0 -650
  156. package/src/commands/publish.ts +0 -580
  157. package/src/commands/reindex.test.ts +0 -259
  158. package/src/commands/reindex.ts +0 -274
  159. package/src/commands/rescue.test.ts +0 -39
  160. package/src/commands/rescue.ts +0 -210
  161. package/src/commands/run.env-local.test.ts +0 -88
  162. package/src/commands/run.test.ts +0 -45
  163. package/src/commands/run.ts +0 -184
  164. package/src/commands/safe-extract.test.ts +0 -459
  165. package/src/commands/safe-extract.ts +0 -444
  166. package/src/commands/secrets-scope.test.ts +0 -56
  167. package/src/commands/secrets-scope.ts +0 -32
  168. package/src/commands/secrets.parse-destination.test.ts +0 -38
  169. package/src/commands/secrets.test.ts +0 -2037
  170. package/src/commands/secrets.ts +0 -2240
  171. package/src/commands/signals.ts +0 -351
  172. package/src/commands/skill.test.ts +0 -770
  173. package/src/commands/skill.ts +0 -796
  174. package/src/commands/sources.ts +0 -362
  175. package/src/commands/sync-mode.test.ts +0 -366
  176. package/src/commands/sync-mode.ts +0 -387
  177. package/src/commands/sync-narrow.test.ts +0 -633
  178. package/src/commands/sync-narrow.ts +0 -555
  179. package/src/commands/sync.ts +0 -165
  180. package/src/commands/team-sync.ts +0 -628
  181. package/src/commands/update.ts +0 -71
  182. package/src/commands/whoami.ts +0 -83
  183. package/src/commands/workers.test.ts +0 -158
  184. package/src/commands/workers.ts +0 -301
  185. package/src/index.test.ts +0 -32
  186. package/src/index.ts +0 -20
  187. package/src/lib/db/control-plane.test.ts +0 -59
  188. package/src/lib/db/control-plane.ts +0 -113
  189. package/src/lib/db/local.test.ts +0 -81
  190. package/src/lib/db/local.ts +0 -148
  191. package/src/lib/db/migrate.test.ts +0 -133
  192. package/src/lib/db/migrate.ts +0 -137
  193. package/src/lib/db/paths.test.ts +0 -112
  194. package/src/lib/db/paths.ts +0 -128
  195. package/src/lib/db/remote-engine.test.ts +0 -44
  196. package/src/lib/db/remote-engine.ts +0 -148
  197. package/src/lib/db/remote-sql.test.ts +0 -32
  198. package/src/lib/db/remote-sql.ts +0 -62
  199. package/src/lib/db/sql.test.ts +0 -106
  200. package/src/lib/db/sql.ts +0 -192
  201. package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
  202. package/src/lib/local-tree-diff.test.ts +0 -301
  203. package/src/lib/local-tree-diff.ts +0 -362
  204. package/src/lib/narrow-hint-banner.test.ts +0 -235
  205. package/src/lib/narrow-hint-banner.ts +0 -212
  206. package/src/lib/plan-limit-nag.test.ts +0 -317
  207. package/src/lib/plan-limit-nag.ts +0 -264
  208. package/src/main.ts +0 -371
  209. package/src/node-preflight.test.ts +0 -60
  210. package/src/node-preflight.ts +0 -67
  211. package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
  212. package/src/outpost/session-heartbeat-guard.test.ts +0 -105
  213. package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
  214. package/src/outpost/session-heartbeat-publisher.ts +0 -186
  215. package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
  216. package/src/outpost/session-heartbeat.test.ts +0 -459
  217. package/src/outpost/session-heartbeat.ts +0 -877
  218. package/src/packaging.test.ts +0 -109
  219. package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
  220. package/src/run/discover-schemas.test.ts +0 -153
  221. package/src/run/discover-schemas.ts +0 -79
  222. package/src/run/hq-plugin.test.ts +0 -371
  223. package/src/run/hq-plugin.ts +0 -277
  224. package/src/run/varlock-shape.test.ts +0 -57
  225. package/src/schemas/hq-package.schema.json +0 -155
  226. package/src/sentry-before-send.test.ts +0 -359
  227. package/src/sentry-before-send.ts +0 -221
  228. package/src/sentry-epipe.test.ts +0 -37
  229. package/src/sentry-release.test.ts +0 -54
  230. package/src/sentry.test.ts +0 -24
  231. package/src/sentry.ts +0 -54
  232. package/src/strategies/link.ts +0 -62
  233. package/src/strategies/merge.ts +0 -142
  234. package/src/types.ts +0 -146
  235. package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
  236. package/src/utils/auth-error.test.ts +0 -40
  237. package/src/utils/auth-error.ts +0 -42
  238. package/src/utils/billing-gate.test.ts +0 -95
  239. package/src/utils/billing-gate.ts +0 -228
  240. package/src/utils/breadcrumb-buffer.ts +0 -18
  241. package/src/utils/cli-telemetry.test.ts +0 -153
  242. package/src/utils/cli-telemetry.ts +0 -61
  243. package/src/utils/cognito-session.machine.test.ts +0 -109
  244. package/src/utils/cognito-session.refresh.test.ts +0 -84
  245. package/src/utils/cognito-session.test.ts +0 -328
  246. package/src/utils/cognito-session.ts +0 -448
  247. package/src/utils/company-selection-error.test.ts +0 -42
  248. package/src/utils/company-selection-error.ts +0 -45
  249. package/src/utils/contribution-table.ts +0 -83
  250. package/src/utils/environmental-error.test.ts +0 -45
  251. package/src/utils/environmental-error.ts +0 -39
  252. package/src/utils/epipe.test.ts +0 -28
  253. package/src/utils/epipe.ts +0 -29
  254. package/src/utils/expected-cli-error.test.ts +0 -28
  255. package/src/utils/expected-cli-error.ts +0 -39
  256. package/src/utils/feedback-diagnostics.test.ts +0 -183
  257. package/src/utils/feedback-diagnostics.ts +0 -123
  258. package/src/utils/feedback-screenshots.test.ts +0 -134
  259. package/src/utils/feedback-screenshots.ts +0 -124
  260. package/src/utils/feedback-versions.test.ts +0 -98
  261. package/src/utils/feedback-versions.ts +0 -68
  262. package/src/utils/git.ts +0 -74
  263. package/src/utils/integrity.ts +0 -59
  264. package/src/utils/intercepted-process-exit.test.ts +0 -37
  265. package/src/utils/intercepted-process-exit.ts +0 -36
  266. package/src/utils/login-provider.test.ts +0 -42
  267. package/src/utils/login-provider.ts +0 -30
  268. package/src/utils/manifest.test.ts +0 -167
  269. package/src/utils/manifest.ts +0 -131
  270. package/src/utils/pack-contributions.test.ts +0 -603
  271. package/src/utils/pack-contributions.ts +0 -450
  272. package/src/utils/people.ts +0 -215
  273. package/src/utils/registry-client.ts +0 -204
  274. package/src/utils/registry.ts +0 -90
  275. package/src/utils/resolve-vault-credential.test.ts +0 -69
  276. package/src/utils/resolve-vault-credential.ts +0 -60
  277. package/src/utils/sandbox-runner-client.test.ts +0 -279
  278. package/src/utils/sandbox-runner-client.ts +0 -260
  279. package/src/utils/secrets-cache.ts +0 -183
  280. package/src/utils/sentry-identity.ts +0 -45
  281. package/src/utils/settle-with-timeout.test.ts +0 -21
  282. package/src/utils/settle-with-timeout.ts +0 -22
  283. package/src/utils/user-yaml-error.test.ts +0 -24
  284. package/src/utils/user-yaml-error.ts +0 -30
  285. package/src/utils/vault-api.test.ts +0 -536
  286. package/src/utils/vault-api.ts +0 -382
  287. package/src/utils/version-check.test.ts +0 -176
  288. package/src/utils/version-check.ts +0 -155
  289. package/src/utils/version-gate.test.ts +0 -1024
  290. package/src/utils/version-gate.ts +0 -702
  291. package/test/commands/db-tenant-isolation.test.ts +0 -94
  292. package/test/commands/db.test.ts +0 -85
  293. package/test/commands/signals.test.ts +0 -207
  294. package/test/commands/sources.test.ts +0 -231
  295. package/test/e2e/smoke-install-mcp.sh +0 -113
  296. package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
  297. package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
  298. package/test/fixtures/signals/action_item/sample.md +0 -16
  299. package/test/fixtures/signals/summary/sample.md +0 -12
  300. package/test/fixtures/sources/meetings/sample.md +0 -25
  301. package/test/helpers/cli-runner.ts +0 -150
  302. package/test/helpers/s3-list-mock.ts +0 -79
  303. package/test/helpers/vault-service-mock.ts +0 -295
  304. package/test/sources-signals/smoke.test.ts +0 -228
  305. package/tsconfig.json +0 -20
  306. package/vitest.config.ts +0 -11
  307. package/vitest.e2e.config.ts +0 -7
@@ -1,2787 +0,0 @@
1
- /**
2
- * US-006 — safe-write core for user-global config merges.
3
- *
4
- * CRITICAL TEST SAFETY: every test runs against an ISOLATED tmpdir `home`
5
- * (`mkFakeHome()` -> a fresh `os.tmpdir()` subtree) passed explicitly via the
6
- * injectable `env: { home }` parameter. NOTHING here reads, writes, backs up, or
7
- * locks the developer's real ~/.claude.json, ~/.mcp.json, or ~/.codex/config.toml.
8
- * No test mutates `process.env.HOME`; isolation is by injection, not by env.
9
- */
10
-
11
- import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
12
-
13
- // US-013 fault-injection seam: ESM forbids vi.spyOn on `fs` namespace exports, so
14
- // we mock the `fs` module with a FAITHFUL PASS-THROUGH that only deviates when a
15
- // hoisted control flag is set. Every existing test runs against the real fs (all
16
- // flags default to off); each US-013 fault test flips one flag inside a try/finally
17
- // and resets it, so the mock never affects any other test. `__fsFault` is declared
18
- // via vi.hoisted so the (hoisted) vi.mock factory can close over it.
19
- const __fsFault = vi.hoisted(() => ({
20
- /** Throw ENOSPC at renameSync when the destination ends with this suffix ('' = off). */
21
- failRenameSuffix: '' as string,
22
- /** Throw EIO at the FIRST fsyncSync call, then pass through ('' = off). */
23
- failFirstFsync: false,
24
- /** When set, the verify-time re-read of a path ending with this suffix returns the
25
- * poisoned bytes instead of the real file (drives a verify-after-write failure
26
- * whose content would leak if the error echoed it). '' = off. */
27
- poisonReadSuffix: '' as string,
28
- /** The bytes poisonReadSuffix returns. */
29
- poisonReadBytes: '' as string,
30
- /** Only poison reads AFTER a write has happened (so the initial read parses). */
31
- writeSeen: false,
32
- }));
33
-
34
- vi.mock('fs', async (importOriginal) => {
35
- const actual = await importOriginal<typeof import('fs')>();
36
- return {
37
- ...actual,
38
- default: actual,
39
- renameSync: (from: fs.PathLike, to: fs.PathLike) => {
40
- if (__fsFault.failRenameSuffix && String(to).endsWith(__fsFault.failRenameSuffix)) {
41
- const e = new Error('ENOSPC: no space left on device, rename') as NodeJS.ErrnoException;
42
- e.code = 'ENOSPC';
43
- throw e;
44
- }
45
- return actual.renameSync(from, to);
46
- },
47
- fsyncSync: (fd: number) => {
48
- if (__fsFault.failFirstFsync) {
49
- __fsFault.failFirstFsync = false;
50
- const e = new Error('EIO: i/o error, fsync') as NodeJS.ErrnoException;
51
- e.code = 'EIO';
52
- throw e;
53
- }
54
- return actual.fsyncSync(fd);
55
- },
56
- writeSync: ((fd: number, ...rest: unknown[]) => {
57
- __fsFault.writeSeen = true;
58
- return (actual.writeSync as (f: number, ...r: unknown[]) => number)(fd, ...rest);
59
- }) as typeof actual.writeSync,
60
- readFileSync: ((p: fs.PathOrFileDescriptor, opts?: unknown) => {
61
- if (
62
- __fsFault.poisonReadSuffix &&
63
- __fsFault.writeSeen &&
64
- String(p).endsWith(__fsFault.poisonReadSuffix)
65
- ) {
66
- return __fsFault.poisonReadBytes as never;
67
- }
68
- return (actual.readFileSync as (q: unknown, o?: unknown) => unknown)(p, opts) as never;
69
- }) as typeof actual.readFileSync,
70
- };
71
- });
72
-
73
- import * as fs from 'fs';
74
- import * as os from 'os';
75
- import * as path from 'path';
76
- import { parse as parseToml } from 'smol-toml';
77
-
78
- import {
79
- // primitives under test
80
- writeConfigAtomic,
81
- readConfigDoc,
82
- acquireLock,
83
- releaseLock,
84
- lockPathFor,
85
- backupConfig,
86
- restoreFromBackup,
87
- atomicReplace,
88
- realpathOrSelf,
89
- rescueStamp,
90
- isCodexInstalled,
91
- codexHome,
92
- resolveEnv,
93
- jsonFormat,
94
- // error taxonomy
95
- ConfigParseError,
96
- ConfigPermissionError,
97
- McpNameCollisionError,
98
- PartialRegistrationError,
99
- CodexNotInstalledError,
100
- McpManifestError,
101
- NotImplementedError,
102
- McpRegistrationError,
103
- registerMcpServers,
104
- // US-007 — Claude/JSON emit surface
105
- registerServer,
106
- registerClaudeServer,
107
- buildClaudeServerDef,
108
- mergeClaudeServer,
109
- serverDefsEqual,
110
- assertValidServerName,
111
- resolveSecretRefs,
112
- redactSecrets,
113
- hasSecretRef,
114
- claudeConfigFormat,
115
- claudeConfigPath,
116
- MCP_SERVER_NAME_RE,
117
- HQ_PACK_PROVENANCE_KEY,
118
- SECRET_REDACTION,
119
- // US-008 — Codex/TOML emit surface
120
- registerCodexServer,
121
- buildCodexServerDef,
122
- buildCodexToolsTable,
123
- mergeCodexServer,
124
- appendCodexTable,
125
- serializeOneCodexServer,
126
- codexConfigFormat,
127
- codexConfigPath,
128
- CODEX_MCP_SERVERS_KEY,
129
- // US-009 — uninstall un-registration surface
130
- unregisterServer,
131
- unregisterClaudeServer,
132
- unregisterCodexServer,
133
- unregisterMcpServers,
134
- removeClaudeServer,
135
- removeCodexServer,
136
- claudeRemovalFormat,
137
- codexRemovalFormat,
138
- // US-013 — unhappy-path regression surface
139
- sanitizeParserMessage,
140
- mcpRegistryLogPath,
141
- backupRoot,
142
- type ConfigFormat,
143
- type SafeWriteEnv,
144
- type McpManifest,
145
- type SecretResolver,
146
- type CodexTomlDoc,
147
- } from './mcp-registration.js';
148
-
149
- // ---------------------------------------------------------------------------
150
- // Isolated-home sandbox + a representative Claude-style JSON format.
151
- // ---------------------------------------------------------------------------
152
-
153
- let home: string;
154
- let env: SafeWriteEnv;
155
-
156
- function mkFakeHome(): string {
157
- // realpathSync so macOS /tmp -> /private/tmp doesn't break path equality.
158
- return fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hq-mcp-test-')));
159
- }
160
-
161
- beforeEach(() => {
162
- home = mkFakeHome();
163
- env = { home };
164
- });
165
-
166
- afterEach(() => {
167
- fs.rmSync(home, { recursive: true, force: true });
168
- });
169
-
170
- /** A Claude-shaped format: doc is `{ mcpServers: { <name>: def } }`. */
171
- const claudeFormat: ConfigFormat<Record<string, unknown>> = {
172
- parse: jsonFormat.parse,
173
- serialize: jsonFormat.serialize,
174
- emptyDoc: () => ({ mcpServers: {} }),
175
- assertEntry(doc, name) {
176
- const servers = (doc as { mcpServers?: Record<string, unknown> }).mcpServers;
177
- if (!servers || typeof servers !== 'object' || !(name in servers)) {
178
- throw new Error(`mcpServers.${name} missing`);
179
- }
180
- },
181
- };
182
-
183
- /** A merge fn that inserts/updates one server, with collision semantics. */
184
- function mergeServer(name: string, def: Record<string, unknown>) {
185
- return (doc: Record<string, unknown>): Record<string, unknown> => {
186
- const servers = { ...((doc.mcpServers as Record<string, unknown>) ?? {}) };
187
- const existing = servers[name];
188
- if (existing !== undefined) {
189
- if (JSON.stringify(existing) === JSON.stringify(def)) {
190
- return doc; // idempotent: equal def => no change.
191
- }
192
- throw new McpNameCollisionError(`server "${name}" already defined differently`);
193
- }
194
- servers[name] = def;
195
- return { ...doc, mcpServers: servers };
196
- };
197
- }
198
-
199
- const claudePath = () => path.join(home, '.claude.json');
200
-
201
- // ---------------------------------------------------------------------------
202
- // Error taxonomy
203
- // ---------------------------------------------------------------------------
204
-
205
- describe('US-006: named error taxonomy (no bare catch-all)', () => {
206
- it('every error class is an McpRegistrationError with a stable code', () => {
207
- const cases: [McpRegistrationError, string][] = [
208
- [new ConfigParseError('x'), 'ConfigParseError'],
209
- [new ConfigPermissionError('x'), 'ConfigPermissionError'],
210
- [new McpNameCollisionError('x'), 'McpNameCollisionError'],
211
- [new CodexNotInstalledError('x'), 'CodexNotInstalledError'],
212
- [new McpManifestError('x'), 'McpManifestError'],
213
- [new PartialRegistrationError('x'), 'PartialRegistrationError'],
214
- ];
215
- for (const [err, code] of cases) {
216
- expect(err).toBeInstanceOf(McpRegistrationError);
217
- expect(err).toBeInstanceOf(Error);
218
- expect(err.code).toBe(code);
219
- expect(err.name).toBe(code);
220
- }
221
- });
222
-
223
- it('instanceof discriminates concrete subclasses', () => {
224
- expect(new ConfigParseError('x')).toBeInstanceOf(ConfigParseError);
225
- expect(new ConfigParseError('x')).not.toBeInstanceOf(ConfigPermissionError);
226
- });
227
-
228
- it('NotImplementedError remains an exported, well-formed named error (Codex emit lands US-008)', () => {
229
- // US-007 wired the Claude/JSON emitter, so registerMcpServers no longer throws
230
- // NotImplemented; but the class is retained for the Codex seam (US-008) and any
231
- // future not-yet-wired emitter, and must keep its stable code + attribution.
232
- const err = new NotImplementedError('codex emit pending', 'US-008');
233
- expect(err).toBeInstanceOf(Error);
234
- expect(err.code).toBe('NotImplemented');
235
- expect(err.story).toBe('US-008');
236
- expect(err.name).toBe('NotImplementedError');
237
- });
238
-
239
- it('registerMcpServers without a manifest loader fails loud with a named McpManifestError', () => {
240
- // US-007: the seam is real now. Without payload context (no loadManifest) it is
241
- // a programmer error, surfaced as a named error — never a silent no-op, never a
242
- // NotImplemented throw — and it touches NO config (the check precedes all IO).
243
- expect(() => registerMcpServers('hq-pack-test', ['vyg'])).toThrow(McpManifestError);
244
- try {
245
- registerMcpServers('hq-pack-test', ['vyg']);
246
- } catch (e) {
247
- expect((e as McpManifestError).code).toBe('McpManifestError');
248
- expect((e as Error).message).toMatch(/loadManifest/);
249
- }
250
- });
251
- });
252
-
253
- // ---------------------------------------------------------------------------
254
- // resolveEnv + base-path injection
255
- // ---------------------------------------------------------------------------
256
-
257
- describe('US-006: injectable base paths (test isolation requirement)', () => {
258
- it('resolveEnv uses the injected home, not os.homedir()', () => {
259
- expect(resolveEnv({ home }).home).toBe(home);
260
- expect(resolveEnv({ home }).home).not.toBe(os.homedir());
261
- });
262
-
263
- it('isCodexInstalled is false when ~/.codex is absent and never fabricates it', () => {
264
- expect(isCodexInstalled(env)).toBe(false);
265
- expect(fs.existsSync(codexHome(env))).toBe(false); // not created as a side effect.
266
- });
267
-
268
- it('isCodexInstalled is true only when ~/.codex is a real directory', () => {
269
- fs.mkdirSync(codexHome(env), { recursive: true });
270
- expect(isCodexInstalled(env)).toBe(true);
271
- });
272
- });
273
-
274
- // ---------------------------------------------------------------------------
275
- // Read path — the SHADOW cases (§6.2)
276
- // ---------------------------------------------------------------------------
277
-
278
- describe('US-006: read path shadow cases', () => {
279
- it('SHADOW A — ENOENT yields the empty doc, existed=false', () => {
280
- const res = readConfigDoc(claudePath(), claudeFormat);
281
- expect(res.existed).toBe(false);
282
- expect(res.doc).toEqual({ mcpServers: {} });
283
- });
284
-
285
- it('SHADOW B — 0-byte file yields the empty doc', () => {
286
- fs.writeFileSync(claudePath(), '');
287
- const res = readConfigDoc(claudePath(), claudeFormat);
288
- expect(res.existed).toBe(false);
289
- expect(res.doc).toEqual({ mcpServers: {} });
290
- });
291
-
292
- it('SHADOW B — whitespace-only file yields the empty doc', () => {
293
- fs.writeFileSync(claudePath(), ' \n\t \n');
294
- const res = readConfigDoc(claudePath(), claudeFormat);
295
- expect(res.existed).toBe(false);
296
- });
297
-
298
- it('SHADOW C — non-empty parse error throws ConfigParseError (never regenerate)', () => {
299
- fs.writeFileSync(claudePath(), '{ this is not json ');
300
- expect(() => readConfigDoc(claudePath(), claudeFormat)).toThrow(ConfigParseError);
301
- });
302
-
303
- it('SHADOW C — a top-level JSON array is a parse error (must be an object)', () => {
304
- fs.writeFileSync(claudePath(), '[1,2,3]');
305
- expect(() => readConfigDoc(claudePath(), claudeFormat)).toThrow(ConfigParseError);
306
- });
307
-
308
- it('SHADOW D — a directory at the target path throws ConfigPermissionError', () => {
309
- fs.mkdirSync(claudePath()); // reading a dir as a file => EISDIR.
310
- expect(() => readConfigDoc(claudePath(), claudeFormat)).toThrow(ConfigPermissionError);
311
- });
312
-
313
- it('a valid non-empty file parses with existed=true', () => {
314
- fs.writeFileSync(claudePath(), JSON.stringify({ mcpServers: { figma: { type: 'http' } } }));
315
- const res = readConfigDoc(claudePath(), claudeFormat);
316
- expect(res.existed).toBe(true);
317
- expect(res.doc).toEqual({ mcpServers: { figma: { type: 'http' } } });
318
- });
319
- });
320
-
321
- // ---------------------------------------------------------------------------
322
- // Atomic write + realpath/symlink discipline
323
- // ---------------------------------------------------------------------------
324
-
325
- describe('US-006: atomic write + realpath/symlink', () => {
326
- it('atomicReplace writes mode 0600 and leaves no temp litter', () => {
327
- const f = path.join(home, 'cfg.json');
328
- atomicReplace(f, '{"a":1}\n');
329
- expect(fs.readFileSync(f, 'utf-8')).toBe('{"a":1}\n');
330
- expect(fs.statSync(f).mode & 0o777).toBe(0o600);
331
- expect(fs.readdirSync(home).filter((n) => n.includes('.tmp.'))).toEqual([]);
332
- });
333
-
334
- it('realpathOrSelf resolves a symlink to its target file', () => {
335
- const realFile = path.join(home, 'real.json');
336
- const link = path.join(home, 'link.json');
337
- fs.writeFileSync(realFile, '{}');
338
- fs.symlinkSync(realFile, link);
339
- expect(realpathOrSelf(link)).toBe(realFile);
340
- });
341
-
342
- it('realpathOrSelf on a BROKEN link returns the dangling target, never the link', () => {
343
- const missing = path.join(home, 'missing.json');
344
- const link = path.join(home, 'broken.json');
345
- fs.symlinkSync(missing, link);
346
- // Resolves to the (absent) target, so a write lands on the target — not the link.
347
- expect(realpathOrSelf(link)).toBe(missing);
348
- expect(fs.lstatSync(link).isSymbolicLink()).toBe(true); // link untouched.
349
- });
350
-
351
- it('realpathOrSelf follows a partially-broken symlink CHAIN to the final target', () => {
352
- const missing = path.join(home, 'final-missing.json');
353
- const link2 = path.join(home, 'link2.json');
354
- const link1 = path.join(home, 'link1.json');
355
- fs.symlinkSync(missing, link2); // link2 -> missing (dangling)
356
- fs.symlinkSync(link2, link1); // link1 -> link2 -> missing
357
- // Must resolve all the way to the final dangling target, NOT stop at link2.
358
- expect(realpathOrSelf(link1)).toBe(missing);
359
- expect(fs.lstatSync(link1).isSymbolicLink()).toBe(true);
360
- expect(fs.lstatSync(link2).isSymbolicLink()).toBe(true);
361
- });
362
-
363
- it('realpathOrSelf does not infinite-loop on a cyclic broken chain', () => {
364
- const a = path.join(home, 'cyc-a.json');
365
- const b = path.join(home, 'cyc-b.json');
366
- fs.symlinkSync(b, a); // a -> b
367
- fs.symlinkSync(a, b); // b -> a (cycle)
368
- // Depth-guarded: returns SOME path without hanging or throwing.
369
- expect(() => realpathOrSelf(a)).not.toThrow();
370
- });
371
-
372
- it('writes through a ~/.mcp.json-style symlink onto the RESOLVED file, never the link', () => {
373
- const realFile = path.join(home, '.mcp.real.json');
374
- const link = path.join(home, '.mcp.json');
375
- fs.writeFileSync(realFile, JSON.stringify({ mcpServers: { figma: { type: 'http' } } }));
376
- fs.symlinkSync(realFile, link);
377
-
378
- writeConfigAtomic({
379
- target: link,
380
- pack: 'hq-pack-test',
381
- format: claudeFormat,
382
- merge: mergeServer('vyg', { type: 'http', url: 'https://mcp.vyg.app' }),
383
- assertName: 'vyg',
384
- env,
385
- });
386
-
387
- // The link is STILL a symlink (not replaced by a regular file).
388
- expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
389
- // The resolved file carries both the pre-existing and the new server.
390
- const after = JSON.parse(fs.readFileSync(realFile, 'utf-8'));
391
- expect(after.mcpServers.figma).toEqual({ type: 'http' });
392
- expect(after.mcpServers.vyg).toEqual({ type: 'http', url: 'https://mcp.vyg.app' });
393
- });
394
- });
395
-
396
- // ---------------------------------------------------------------------------
397
- // Backup + restore (reuses the hq rescue pattern)
398
- // ---------------------------------------------------------------------------
399
-
400
- describe('US-006: backup + restore', () => {
401
- it('rescueStamp is a UTC dash-time ISO-ish stamp', () => {
402
- expect(rescueStamp(new Date(Date.UTC(2026, 5, 19, 1, 13, 59)))).toBe('2026-06-19T01-13-59Z');
403
- });
404
-
405
- it('backs up into ~/.hq/backups/mcp/<stamp>-<pack>/ before write, mode 0600', () => {
406
- const f = claudePath();
407
- fs.writeFileSync(f, JSON.stringify({ mcpServers: { figma: {} } }));
408
- const dir = backupConfig(env, f, 'hq-pack-test', '2026-06-19T00-00-00Z');
409
- expect(dir).toBe(path.join(home, '.hq', 'backups', 'mcp', '2026-06-19T00-00-00Z-hq-pack-test'));
410
- const copy = path.join(dir, '.claude.json');
411
- expect(JSON.parse(fs.readFileSync(copy, 'utf-8'))).toEqual({ mcpServers: { figma: {} } });
412
- expect(fs.statSync(copy).mode & 0o777).toBe(0o600);
413
- });
414
-
415
- it('an absent target backs up an .absent marker; restore unlinks the target', () => {
416
- const f = claudePath();
417
- const dir = backupConfig(env, f, 'hq-pack-test', '2026-06-19T00-00-00Z');
418
- expect(fs.existsSync(path.join(dir, '.absent'))).toBe(true);
419
- // Create the file, then restore-from-absent removes it again.
420
- fs.writeFileSync(f, '{}');
421
- restoreFromBackup(f, dir);
422
- expect(fs.existsSync(f)).toBe(false);
423
- });
424
-
425
- it('restore brings back the exact pre-write bytes', () => {
426
- const f = claudePath();
427
- const original = JSON.stringify({ mcpServers: { figma: { type: 'http' } } }, null, 2) + '\n';
428
- fs.writeFileSync(f, original);
429
- const dir = backupConfig(env, f, 'p', '2026-06-19T00-00-00Z');
430
- fs.writeFileSync(f, '{"corrupted":true}');
431
- restoreFromBackup(f, dir);
432
- expect(fs.readFileSync(f, 'utf-8')).toBe(original);
433
- });
434
- });
435
-
436
- // ---------------------------------------------------------------------------
437
- // Advisory lock
438
- // ---------------------------------------------------------------------------
439
-
440
- describe('US-006: advisory O_EXCL lock', () => {
441
- it('lockPathFor collapses a leading dot: ~/.claude.json -> .claude.json.hqlock', () => {
442
- expect(lockPathFor(path.join(home, '.claude.json'))).toBe(
443
- path.join(home, '.claude.json.hqlock'),
444
- );
445
- expect(lockPathFor(path.join(home, 'plain.json'))).toBe(
446
- path.join(home, '.plain.json.hqlock'),
447
- );
448
- });
449
-
450
- it('acquire creates the lockfile; release removes it; release is idempotent', () => {
451
- const f = claudePath();
452
- const lock = acquireLock(f, { waitMs: 200 });
453
- expect(fs.existsSync(lock.lockPath)).toBe(true);
454
- releaseLock(lock);
455
- expect(fs.existsSync(lock.lockPath)).toBe(false);
456
- expect(() => releaseLock(lock)).not.toThrow(); // idempotent.
457
- });
458
-
459
- it('a held lock (alive PID) makes a second acquire ABORT rather than steal', () => {
460
- const f = claudePath();
461
- const first = acquireLock(f, { waitMs: 100 });
462
- // Second acquire sees a live holder (this very PID) and times out -> abort.
463
- expect(() =>
464
- acquireLock(f, { waitMs: 80, pollMs: 10, isPidAlive: () => true }),
465
- ).toThrow(ConfigPermissionError);
466
- releaseLock(first);
467
- });
468
-
469
- it('a stale lock (dead PID, old mtime) is broken and re-acquired', () => {
470
- const f = claudePath();
471
- const lp = lockPathFor(f);
472
- // Forge a stale lock: a dead PID, mtime far in the past.
473
- fs.writeFileSync(lp, '999999\n2000-01-01T00:00:00Z\n', { mode: 0o600 });
474
- const old = new Date(Date.now() - 60_000);
475
- fs.utimesSync(lp, old, old);
476
- const lock = acquireLock(f, { waitMs: 200, staleMs: 1000, isPidAlive: () => false });
477
- expect(fs.existsSync(lock.lockPath)).toBe(true);
478
- releaseLock(lock);
479
- });
480
-
481
- it('a fresh lock with a dead PID is NOT stolen (conservative TOCTOU guard)', () => {
482
- const f = claudePath();
483
- const lp = lockPathFor(f);
484
- fs.writeFileSync(lp, '999999\n', { mode: 0o600 }); // fresh mtime, dead PID.
485
- expect(() =>
486
- acquireLock(f, { waitMs: 60, pollMs: 10, staleMs: 30_000, isPidAlive: () => false }),
487
- ).toThrow(ConfigPermissionError);
488
- fs.unlinkSync(lp);
489
- });
490
-
491
- it('stale-break is identity-safe: a fresh lock swapped in mid-decision is NOT unlinked', () => {
492
- // Forge a stale lock, then make `now()` advance so the SAME lock looks stale,
493
- // but have `isPidAlive` swap the lockfile for a NEW one right at the liveness
494
- // check — emulating a third contender replacing it between our stat and unlink.
495
- const f = claudePath();
496
- const lp = lockPathFor(f);
497
- fs.writeFileSync(lp, '999999\nstale\n', { mode: 0o600 });
498
- const past = new Date(Date.now() - 60_000);
499
- fs.utimesSync(lp, past, past);
500
-
501
- let swapped = false;
502
- const acq = () =>
503
- acquireLock(f, {
504
- waitMs: 40,
505
- pollMs: 10,
506
- staleMs: 1000,
507
- isPidAlive: () => {
508
- // On the first liveness probe, replace the stale lock with a fresh one
509
- // (different inode) before the breaker can unlink it.
510
- if (!swapped) {
511
- swapped = true;
512
- fs.unlinkSync(lp);
513
- fs.writeFileSync(lp, `${process.pid}\nfresh\n`, { mode: 0o600 });
514
- }
515
- return false; // the (forged dead) PID is reported dead.
516
- },
517
- });
518
- // The breaker must NOT unlink the fresh replacement (inode differs) -> it waits
519
- // and aborts; the fresh lock survives.
520
- expect(acq).toThrow(ConfigPermissionError);
521
- expect(fs.existsSync(lp)).toBe(true);
522
- // The surviving lock is the FRESH replacement, not the stale one we judged.
523
- // Assert by CONTENT, not inode: Linux reuses a just-freed inode on the next
524
- // create, so the fresh lock can share the stale lock's inode number — only the
525
- // body (pid + "fresh") reliably distinguishes them across platforms.
526
- expect(fs.readFileSync(lp, 'utf-8')).toContain('fresh');
527
- fs.unlinkSync(lp);
528
- });
529
- });
530
-
531
- // ---------------------------------------------------------------------------
532
- // writeConfigAtomic — full path
533
- // ---------------------------------------------------------------------------
534
-
535
- describe('US-006: writeConfigAtomic merge semantics', () => {
536
- it('inserts a new server into a fresh (ENOENT) config and creates a backup', () => {
537
- const res = writeConfigAtomic({
538
- target: claudePath(),
539
- pack: 'hq-pack-test',
540
- format: claudeFormat,
541
- merge: mergeServer('vyg', { type: 'http', url: 'https://mcp.vyg.app' }),
542
- assertName: 'vyg',
543
- env,
544
- });
545
- expect(res.changed).toBe(true);
546
- const doc = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
547
- expect(doc.mcpServers.vyg).toEqual({ type: 'http', url: 'https://mcp.vyg.app' });
548
- expect(fs.existsSync(res.backupDir)).toBe(true);
549
- });
550
-
551
- it('PRESERVES existing non-HQ servers byte-for-byte (merge, never overwrite)', () => {
552
- fs.writeFileSync(
553
- claudePath(),
554
- JSON.stringify({
555
- mcpServers: {
556
- figma: { type: 'http', url: 'http://localhost:3845/mcp' },
557
- paper: { type: 'stdio', command: 'npx', args: ['mcp-remote'] },
558
- },
559
- }),
560
- );
561
- writeConfigAtomic({
562
- target: claudePath(),
563
- pack: 'hq-pack-test',
564
- format: claudeFormat,
565
- merge: mergeServer('vyg', { type: 'http', url: 'https://mcp.vyg.app' }),
566
- assertName: 'vyg',
567
- env,
568
- });
569
- const doc = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
570
- expect(doc.mcpServers.figma).toEqual({ type: 'http', url: 'http://localhost:3845/mcp' });
571
- expect(doc.mcpServers.paper).toEqual({ type: 'stdio', command: 'npx', args: ['mcp-remote'] });
572
- expect(doc.mcpServers.vyg).toBeDefined();
573
- });
574
-
575
- it('is idempotent — re-registering the same def is a no-op (changed:false)', () => {
576
- const args = {
577
- target: claudePath(),
578
- pack: 'hq-pack-test',
579
- format: claudeFormat,
580
- merge: mergeServer('vyg', { type: 'http', url: 'https://mcp.vyg.app' }),
581
- assertName: 'vyg',
582
- env,
583
- };
584
- const first = writeConfigAtomic(args);
585
- expect(first.changed).toBe(true);
586
- const before = fs.readFileSync(claudePath(), 'utf-8');
587
- const second = writeConfigAtomic(args);
588
- expect(second.changed).toBe(false);
589
- expect(fs.readFileSync(claudePath(), 'utf-8')).toBe(before); // file untouched.
590
- });
591
-
592
- it('a differently-defined collision aborts with McpNameCollisionError, writes nothing', () => {
593
- fs.writeFileSync(
594
- claudePath(),
595
- JSON.stringify({ mcpServers: { vyg: { type: 'http', url: 'https://OLD.example' } } }, null, 2) + '\n',
596
- );
597
- const before = fs.readFileSync(claudePath(), 'utf-8');
598
- expect(() =>
599
- writeConfigAtomic({
600
- target: claudePath(),
601
- pack: 'hq-pack-test',
602
- format: claudeFormat,
603
- merge: mergeServer('vyg', { type: 'http', url: 'https://NEW.example' }),
604
- assertName: 'vyg',
605
- env,
606
- }),
607
- ).toThrow(McpNameCollisionError);
608
- expect(fs.readFileSync(claudePath(), 'utf-8')).toBe(before); // untouched.
609
- });
610
-
611
- it('releases the lock even when the merge aborts', () => {
612
- fs.writeFileSync(
613
- claudePath(),
614
- JSON.stringify({ mcpServers: { vyg: { type: 'http', url: 'https://OLD' } } }),
615
- );
616
- try {
617
- writeConfigAtomic({
618
- target: claudePath(),
619
- pack: 'p',
620
- format: claudeFormat,
621
- merge: mergeServer('vyg', { type: 'http', url: 'https://NEW' }),
622
- assertName: 'vyg',
623
- env,
624
- });
625
- } catch {
626
- /* expected collision */
627
- }
628
- expect(fs.existsSync(lockPathFor(claudePath()))).toBe(false); // released in finally.
629
- });
630
-
631
- it('verify-after-write failure restores from backup and raises PartialRegistrationError', () => {
632
- // A pathological format whose merge "succeeds" but assertEntry never confirms
633
- // the entry — simulates a torn/partial write that verify must catch.
634
- const original = JSON.stringify({ mcpServers: { figma: {} } }, null, 2) + '\n';
635
- fs.writeFileSync(claudePath(), original);
636
- const brokenVerify: ConfigFormat<Record<string, unknown>> = {
637
- ...claudeFormat,
638
- assertEntry() {
639
- throw new Error('entry never confirmed (simulated torn write)');
640
- },
641
- };
642
- expect(() =>
643
- writeConfigAtomic({
644
- target: claudePath(),
645
- pack: 'p',
646
- format: brokenVerify,
647
- merge: mergeServer('vyg', { type: 'http', url: 'https://mcp.vyg.app' }),
648
- assertName: 'vyg',
649
- env,
650
- }),
651
- ).toThrow(PartialRegistrationError);
652
- // Original restored byte-for-byte.
653
- expect(fs.readFileSync(claudePath(), 'utf-8')).toBe(original);
654
- });
655
-
656
- it('on verify failure the PartialRegistrationError carries the backupDir for recovery', () => {
657
- const original = JSON.stringify({ mcpServers: { figma: {} } }, null, 2) + '\n';
658
- fs.writeFileSync(claudePath(), original);
659
- const brokenVerify: ConfigFormat<Record<string, unknown>> = {
660
- ...claudeFormat,
661
- assertEntry() {
662
- throw new Error('simulated torn write');
663
- },
664
- };
665
- try {
666
- writeConfigAtomic({
667
- target: claudePath(),
668
- pack: 'p',
669
- format: brokenVerify,
670
- merge: mergeServer('vyg', { type: 'http', url: 'https://mcp.vyg.app' }),
671
- assertName: 'vyg',
672
- env,
673
- });
674
- throw new Error('should have thrown');
675
- } catch (e) {
676
- expect(e).toBeInstanceOf(PartialRegistrationError);
677
- const pe = e as PartialRegistrationError;
678
- expect(pe.backupDir).toBeDefined();
679
- expect(fs.existsSync(pe.backupDir!)).toBe(true);
680
- // Message reflects a SUCCESSFUL restore (the happy restore path).
681
- expect(pe.message).toMatch(/restored from backup/);
682
- }
683
- });
684
- });
685
-
686
- // ---------------------------------------------------------------------------
687
- // e2eTests from the PRD (US-006)
688
- // ---------------------------------------------------------------------------
689
-
690
- describe('US-006 e2e: PRD acceptance scenarios', () => {
691
- it('e2e #1 — malformed existing config raises ConfigParseError and writes nothing', () => {
692
- const f = claudePath();
693
- const malformed = '{ "mcpServers": { "figma": } <- hand-broken';
694
- fs.writeFileSync(f, malformed);
695
- expect(() =>
696
- writeConfigAtomic({
697
- target: f,
698
- pack: 'hq-pack-test',
699
- format: claudeFormat,
700
- merge: mergeServer('vyg', { type: 'http', url: 'https://mcp.vyg.app' }),
701
- assertName: 'vyg',
702
- env,
703
- }),
704
- ).toThrow(ConfigParseError);
705
- // ORIGINAL preserved exactly — never regenerated from a template.
706
- expect(fs.readFileSync(f, 'utf-8')).toBe(malformed);
707
- });
708
-
709
- it('e2e #2 — a write interrupted mid-flight leaves the original intact + a backup exists', () => {
710
- // Simulate ENOSPC/SIGINT mid-write: the merge runs (so the backup is taken),
711
- // then atomicReplace fails. We trigger the failure deterministically with a
712
- // format whose serialize throws AFTER the backup but BEFORE the rename.
713
- const f = claudePath();
714
- const original = JSON.stringify({ mcpServers: { figma: { type: 'http' } } }, null, 2) + '\n';
715
- fs.writeFileSync(f, original);
716
-
717
- let backupSeen: string | undefined;
718
- const explodeOnWrite: ConfigFormat<Record<string, unknown>> = {
719
- ...claudeFormat,
720
- serialize(doc) {
721
- // First call (reading current) is fine; the merged serialize explodes,
722
- // mimicking ENOSPC at the write boundary. Detect the merged doc by the
723
- // presence of the new server.
724
- const servers = (doc as { mcpServers?: Record<string, unknown> }).mcpServers ?? {};
725
- if ('vyg' in servers) throw new Error('ENOSPC: no space left on device (simulated)');
726
- return jsonFormat.serialize(doc);
727
- },
728
- };
729
-
730
- expect(() =>
731
- writeConfigAtomic({
732
- target: f,
733
- pack: 'hq-pack-test',
734
- format: explodeOnWrite,
735
- merge: (doc) => {
736
- // capture that a backup was already taken before any write attempt
737
- backupSeen = path.join(home, '.hq', 'backups', 'mcp');
738
- return mergeServer('vyg', { type: 'http', url: 'https://mcp.vyg.app' })(doc);
739
- },
740
- assertName: 'vyg',
741
- env,
742
- }),
743
- ).toThrow(/ENOSPC/);
744
-
745
- // ORIGINAL file intact (the rename never happened).
746
- expect(fs.readFileSync(f, 'utf-8')).toBe(original);
747
- // A backup exists under ~/.hq/backups/mcp/.
748
- expect(backupSeen && fs.existsSync(backupSeen)).toBe(true);
749
- const backups = fs.readdirSync(path.join(home, '.hq', 'backups', 'mcp'));
750
- expect(backups.length).toBeGreaterThan(0);
751
- const copy = path.join(path.join(home, '.hq', 'backups', 'mcp'), backups[0]!, '.claude.json');
752
- expect(fs.readFileSync(copy, 'utf-8')).toBe(original);
753
- // No temp litter left behind.
754
- expect(fs.readdirSync(home).filter((n) => n.includes('.tmp.'))).toEqual([]);
755
- // Lock released.
756
- expect(fs.existsSync(lockPathFor(f))).toBe(false);
757
- });
758
-
759
- it('e2e #3 — a concurrent register holding the lock makes the second wait/abort, first write preserved', () => {
760
- const f = claudePath();
761
- fs.writeFileSync(f, JSON.stringify({ mcpServers: { figma: { type: 'http' } } }));
762
-
763
- // Writer 1 takes the lock and holds it (simulating an in-flight register).
764
- const held = acquireLock(f, { waitMs: 100 });
765
-
766
- // Writer 2 tries to register while the lock is held by a LIVE holder -> abort.
767
- expect(() =>
768
- writeConfigAtomic({
769
- target: f,
770
- pack: 'hq-pack-2',
771
- format: claudeFormat,
772
- merge: mergeServer('two', { type: 'http', url: 'https://two.example' }),
773
- assertName: 'two',
774
- env,
775
- lock: { waitMs: 60, pollMs: 10, isPidAlive: () => true },
776
- }),
777
- ).toThrow(ConfigPermissionError);
778
-
779
- // Writer 1's intended state is preserved — writer 2 never clobbered the file.
780
- const doc = JSON.parse(fs.readFileSync(f, 'utf-8'));
781
- expect(doc.mcpServers.figma).toBeDefined();
782
- expect(doc.mcpServers.two).toBeUndefined(); // the aborted writer wrote nothing.
783
-
784
- // Now writer 1 releases; a fresh register succeeds (no lost lock).
785
- releaseLock(held);
786
- const ok = writeConfigAtomic({
787
- target: f,
788
- pack: 'hq-pack-2',
789
- format: claudeFormat,
790
- merge: mergeServer('two', { type: 'http', url: 'https://two.example' }),
791
- assertName: 'two',
792
- env,
793
- lock: { waitMs: 100 },
794
- });
795
- expect(ok.changed).toBe(true);
796
- expect(JSON.parse(fs.readFileSync(f, 'utf-8')).mcpServers.two).toBeDefined();
797
- });
798
- });
799
-
800
- // ---------------------------------------------------------------------------
801
- // Desired-state convergence (re-run safe from any state, incl. PARTIAL)
802
- // ---------------------------------------------------------------------------
803
-
804
- describe('US-006: desired-state convergence', () => {
805
- it('re-running after a successful register converges to the same state (idempotent)', () => {
806
- const args = {
807
- target: claudePath(),
808
- pack: 'hq-pack-test',
809
- format: claudeFormat,
810
- merge: mergeServer('vyg', { type: 'http', url: 'https://mcp.vyg.app' }),
811
- assertName: 'vyg',
812
- env,
813
- };
814
- writeConfigAtomic(args);
815
- const a = fs.readFileSync(claudePath(), 'utf-8');
816
- writeConfigAtomic(args);
817
- writeConfigAtomic(args);
818
- expect(fs.readFileSync(claudePath(), 'utf-8')).toBe(a);
819
- });
820
-
821
- it('a PARTIAL state (server present in one surface only) self-heals on the missing surface', () => {
822
- // Surface A already has the server; surface B does not. Re-running registers
823
- // B without disturbing A — each surface is evaluated independently.
824
- const a = path.join(home, '.claude.json');
825
- const b = path.join(home, '.mcp.json');
826
- const def = { type: 'http', url: 'https://mcp.vyg.app' };
827
- fs.writeFileSync(a, JSON.stringify({ mcpServers: { vyg: def } }, null, 2) + '\n');
828
- // B is absent (the partial surface).
829
- const reA = writeConfigAtomic({
830
- target: a, pack: 'p', format: claudeFormat, merge: mergeServer('vyg', def), assertName: 'vyg', env,
831
- });
832
- const reB = writeConfigAtomic({
833
- target: b, pack: 'p', format: claudeFormat, merge: mergeServer('vyg', def), assertName: 'vyg', env,
834
- });
835
- expect(reA.changed).toBe(false); // A already converged.
836
- expect(reB.changed).toBe(true); // B healed.
837
- expect(JSON.parse(fs.readFileSync(b, 'utf-8')).mcpServers.vyg).toEqual(def);
838
- });
839
- });
840
-
841
- // ===========================================================================
842
- // US-007 — Claude/JSON merge emit (consumes the US-006 safe-write core).
843
- // ===========================================================================
844
-
845
- /** A representative http manifest with a secret-by-reference Authorization header. */
846
- const VYG_MANIFEST: McpManifest = {
847
- type: 'http',
848
- url: 'https://mcp.vyg.app',
849
- headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' },
850
- };
851
-
852
- /** Seed ~/.claude.json with the user's real-world non-HQ servers (figma + paper). */
853
- function seedClaudeWithFigmaPaper(): string {
854
- const f = claudePath();
855
- const doc = {
856
- // a top-level non-mcp key that MUST survive untouched
857
- userID: 'u-123',
858
- mcpServers: {
859
- figma: { type: 'http', url: 'http://localhost:3845/mcp' },
860
- paper: { type: 'stdio', command: '/opt/homebrew/bin/npx', args: ['mcp-remote', 'http://127.0.0.1:29979/mcp'] },
861
- },
862
- };
863
- fs.writeFileSync(f, JSON.stringify(doc, null, 2) + '\n');
864
- return f;
865
- }
866
-
867
- describe('US-007: server name validation (^[a-z0-9_-]+$ before any JSON-key interpolation)', () => {
868
- it('accepts valid names', () => {
869
- for (const n of ['vyg', 'vyg-shopify', 'vyg_internal', 'a1', 'x-9_z']) {
870
- expect(() => assertValidServerName(n)).not.toThrow();
871
- expect(MCP_SERVER_NAME_RE.test(n)).toBe(true);
872
- }
873
- });
874
-
875
- it('rejects names with uppercase, dots, slashes, spaces, or injection chars', () => {
876
- for (const n of ['VYG', 'vyg.shop', 'a/b', 'a b', 'a"]', '__proto__.x', 'a\nb', '']) {
877
- expect(() => assertValidServerName(n)).toThrow(McpManifestError);
878
- }
879
- });
880
-
881
- it('registerClaudeServer rejects a bad name BEFORE touching any config (no IO, no backup)', () => {
882
- expect(() =>
883
- registerClaudeServer({ name: 'BAD NAME', manifest: { type: 'http', url: 'https://x' }, pack: 'p', env }),
884
- ).toThrow(McpManifestError);
885
- // No file, no backup dir created.
886
- expect(fs.existsSync(claudePath())).toBe(false);
887
- expect(fs.existsSync(path.join(home, '.hq', 'backups', 'mcp'))).toBe(false);
888
- });
889
- });
890
-
891
- describe('US-007: ${secret:} resolution at emit (resolve-only, redact, never echo)', () => {
892
- it('resolves a referenced secret and records it in the redaction sink', () => {
893
- const sink = new Set<string>();
894
- const out = resolveSecretRefs('Bearer ${secret:VYG_API_KEY}', () => 'FAKE-RESOLVED-SECRET-A', sink);
895
- expect(out).toBe('Bearer FAKE-RESOLVED-SECRET-A');
896
- expect(sink.has('FAKE-RESOLVED-SECRET-A')).toBe(true);
897
- });
898
-
899
- it('passes a no-reference string through untouched', () => {
900
- const sink = new Set<string>();
901
- expect(resolveSecretRefs('http://localhost/mcp', () => null, sink)).toBe('http://localhost/mcp');
902
- expect(sink.size).toBe(0);
903
- });
904
-
905
- it('throws McpManifestError when a referenced secret cannot be resolved (refuse half-resolved)', () => {
906
- const sink = new Set<string>();
907
- expect(() => resolveSecretRefs('Bearer ${secret:MISSING}', () => null, sink)).toThrow(McpManifestError);
908
- });
909
-
910
- it('hasSecretRef detects a reference', () => {
911
- expect(hasSecretRef('Bearer ${secret:VYG_API_KEY}')).toBe(true);
912
- expect(hasSecretRef('Bearer literal-token')).toBe(false);
913
- });
914
-
915
- it('redactSecrets scrubs every resolved plaintext (longest-first, no partial unmask)', () => {
916
- const secrets = new Set(['abc', 'abcdef']);
917
- expect(redactSecrets('x abcdef y abc z', secrets)).toBe(`x ${SECRET_REDACTION} y ${SECRET_REDACTION} z`);
918
- });
919
-
920
- it('buildClaudeServerDef substitutes the secret into the header (the runtime needs the real value)', () => {
921
- const sink = new Set<string>();
922
- const def = buildClaudeServerDef(VYG_MANIFEST, 'hq-pack-vyg', () => 'FAKE-RESOLVED-SECRET-B', sink);
923
- expect((def.headers as Record<string, string>).Authorization).toBe('Bearer FAKE-RESOLVED-SECRET-B');
924
- expect(sink.has('FAKE-RESOLVED-SECRET-B')).toBe(true);
925
- });
926
- });
927
-
928
- describe('US-007: provenance stamp (_hqPack so US-009 uninstall finds only our entries)', () => {
929
- it('every emitted def carries _hqPack = the pack name', () => {
930
- const def = buildClaudeServerDef(VYG_MANIFEST, 'hq-pack-vyg-shopify', () => 'k', new Set());
931
- expect(def[HQ_PACK_PROVENANCE_KEY]).toBe('hq-pack-vyg-shopify');
932
- });
933
-
934
- it('the stamp lands in the written ~/.claude.json entry', () => {
935
- registerClaudeServer({
936
- name: 'vyg',
937
- manifest: { type: 'http', url: 'https://mcp.vyg.app' },
938
- pack: 'hq-pack-vyg-shopify',
939
- env,
940
- });
941
- const doc = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
942
- expect(doc.mcpServers.vyg._hqPack).toBe('hq-pack-vyg-shopify');
943
- });
944
-
945
- it('serverDefsEqual ignores _hqPack provenance + key order when comparing definitions', () => {
946
- const a = { type: 'http', url: 'https://x', _hqPack: 'p1' };
947
- const b = { url: 'https://x', type: 'http' }; // no stamp, different key order
948
- expect(serverDefsEqual(a, b)).toBe(true);
949
- const c = { type: 'http', url: 'https://DIFFERENT', _hqPack: 'p1' };
950
- expect(serverDefsEqual(a, c)).toBe(false);
951
- });
952
- });
953
-
954
- describe('US-007: registerClaudeServer — single PINNED surface merge', () => {
955
- it('writes ONE mcpServers entry into top-level ~/.claude.json via the safe-write core', () => {
956
- const res = registerClaudeServer({
957
- name: 'vyg',
958
- manifest: VYG_MANIFEST,
959
- pack: 'hq-pack-vyg',
960
- resolveSecret: () => 'FAKE-RESOLVED-KEY',
961
- env,
962
- });
963
- expect(res.changed).toBe(true);
964
- const doc = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
965
- expect(doc.mcpServers.vyg.type).toBe('http');
966
- expect(doc.mcpServers.vyg.url).toBe('https://mcp.vyg.app');
967
- expect(doc.mcpServers.vyg.headers.Authorization).toBe('Bearer FAKE-RESOLVED-KEY');
968
- });
969
-
970
- it('creates mcpServers on a fresh {} ~/.claude.json without rewriting other top-level keys', () => {
971
- fs.writeFileSync(claudePath(), JSON.stringify({ userID: 'u-1', tipsHistory: { a: 1 } }, null, 2) + '\n');
972
- registerClaudeServer({ name: 'vyg', manifest: { type: 'http', url: 'https://mcp.vyg.app' }, pack: 'p', env });
973
- const doc = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
974
- expect(doc.userID).toBe('u-1'); // untouched
975
- expect(doc.tipsHistory).toEqual({ a: 1 }); // untouched
976
- expect(doc.mcpServers.vyg).toBeDefined();
977
- });
978
-
979
- it('the secret value is REDACTED from a verify-failure error message (never echoed)', () => {
980
- // Force a verify failure by injecting an env whose home flips after write is
981
- // impossible cheaply; instead drive redaction via a manifest whose secret would
982
- // appear in an error. Use a collision path that echoes content is not available,
983
- // so assert redaction directly: a collision error never contains the secret.
984
- seedClaudeWithFigmaPaper();
985
- const doc = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
986
- doc.mcpServers.vyg = { type: 'http', url: 'https://OLD.example', _hqPack: 'p' };
987
- fs.writeFileSync(claudePath(), JSON.stringify(doc, null, 2) + '\n');
988
- try {
989
- registerClaudeServer({
990
- name: 'vyg',
991
- manifest: { type: 'http', url: 'https://NEW.example', headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' } },
992
- pack: 'p',
993
- resolveSecret: () => 'FAKE-SECRET-MUST-NOT-LEAK',
994
- env,
995
- });
996
- throw new Error('should have thrown a collision');
997
- } catch (e) {
998
- expect(e).toBeInstanceOf(McpNameCollisionError);
999
- expect((e as Error).message).not.toContain('FAKE-SECRET-MUST-NOT-LEAK');
1000
- }
1001
- });
1002
- });
1003
-
1004
- describe('US-007 e2e: PRD acceptance scenarios', () => {
1005
- it('e2e #1 — clean ~/.claude.json (figma+paper): new server added, figma+paper byte-identical', () => {
1006
- const f = seedClaudeWithFigmaPaper();
1007
- const before = JSON.parse(fs.readFileSync(f, 'utf-8'));
1008
- const figmaBytes = JSON.stringify(before.mcpServers.figma);
1009
- const paperBytes = JSON.stringify(before.mcpServers.paper);
1010
-
1011
- registerClaudeServer({
1012
- name: 'vyg',
1013
- manifest: VYG_MANIFEST,
1014
- pack: 'hq-pack-vyg',
1015
- resolveSecret: () => 'FAKE-RESOLVED-KEY',
1016
- env,
1017
- });
1018
-
1019
- const after = JSON.parse(fs.readFileSync(f, 'utf-8'));
1020
- // New server present.
1021
- expect(after.mcpServers.vyg.url).toBe('https://mcp.vyg.app');
1022
- // figma + paper byte-identical (deep-equal of the serialized form).
1023
- expect(JSON.stringify(after.mcpServers.figma)).toBe(figmaBytes);
1024
- expect(JSON.stringify(after.mcpServers.paper)).toBe(paperBytes);
1025
- // The unrelated top-level key survives too.
1026
- expect(after.userID).toBe('u-123');
1027
- });
1028
-
1029
- it('e2e #2 — already-registered: re-run adds NO duplicate (idempotent no-op)', () => {
1030
- const f = seedClaudeWithFigmaPaper();
1031
- const args = {
1032
- name: 'vyg',
1033
- manifest: VYG_MANIFEST,
1034
- pack: 'hq-pack-vyg',
1035
- resolveSecret: () => 'FAKE-RESOLVED-KEY',
1036
- env,
1037
- };
1038
- const first = registerClaudeServer(args);
1039
- expect(first.changed).toBe(true);
1040
- const afterFirst = fs.readFileSync(f, 'utf-8');
1041
-
1042
- const second = registerClaudeServer(args);
1043
- expect(second.changed).toBe(false); // no-op
1044
- expect(fs.readFileSync(f, 'utf-8')).toBe(afterFirst); // byte-for-byte unchanged
1045
-
1046
- // Exactly one vyg entry (no duplicate keys possible in JSON, but assert count of servers).
1047
- const doc = JSON.parse(afterFirst);
1048
- expect(Object.keys(doc.mcpServers).sort()).toEqual(['figma', 'paper', 'vyg']);
1049
- });
1050
-
1051
- it('e2e #3 — collision with a differently-defined server: abort without overwriting', () => {
1052
- const f = seedClaudeWithFigmaPaper();
1053
- // Pre-existing vyg with a DIFFERENT definition (user-owned, no provenance).
1054
- const doc = JSON.parse(fs.readFileSync(f, 'utf-8'));
1055
- doc.mcpServers.vyg = { type: 'http', url: 'https://USER-OWNED.example' };
1056
- const original = JSON.stringify(doc, null, 2) + '\n';
1057
- fs.writeFileSync(f, original);
1058
-
1059
- expect(() =>
1060
- registerClaudeServer({
1061
- name: 'vyg',
1062
- manifest: { type: 'http', url: 'https://mcp.vyg.app' },
1063
- pack: 'hq-pack-vyg',
1064
- env,
1065
- }),
1066
- ).toThrow(McpNameCollisionError);
1067
-
1068
- // File untouched — the user's vyg definition is intact, figma/paper intact.
1069
- expect(fs.readFileSync(f, 'utf-8')).toBe(original);
1070
- });
1071
-
1072
- it('e2e #3b — re-registering an IDENTICAL def (provenance-ignored) is a no-op, not a collision', () => {
1073
- const f = seedClaudeWithFigmaPaper();
1074
- // Pre-existing vyg with the SAME meaningful def but NO provenance stamp.
1075
- const doc = JSON.parse(fs.readFileSync(f, 'utf-8'));
1076
- doc.mcpServers.vyg = { type: 'http', url: 'https://mcp.vyg.app' };
1077
- fs.writeFileSync(f, JSON.stringify(doc, null, 2) + '\n');
1078
-
1079
- const res = registerClaudeServer({
1080
- name: 'vyg',
1081
- manifest: { type: 'http', url: 'https://mcp.vyg.app' },
1082
- pack: 'hq-pack-vyg',
1083
- env,
1084
- });
1085
- // Same meaningful def -> no-op (does NOT abort, does NOT re-stamp).
1086
- expect(res.changed).toBe(false);
1087
- const after = JSON.parse(fs.readFileSync(f, 'utf-8'));
1088
- expect(after.mcpServers.vyg._hqPack).toBeUndefined();
1089
- });
1090
- });
1091
-
1092
- describe('US-007/US-008: registerServer fan-out (Claude always; Codex when ~/.codex exists)', () => {
1093
- it('registers the Claude surface and SKIPS Codex when ~/.codex is absent', () => {
1094
- const result = registerServer({
1095
- name: 'vyg',
1096
- manifest: VYG_MANIFEST,
1097
- pack: 'hq-pack-vyg',
1098
- resolveSecret: () => 'FAKE-RESOLVED-KEY',
1099
- env,
1100
- });
1101
- expect(result.claude.changed).toBe(true);
1102
- expect('skipped' in result.codex && result.codex.skipped).toBe(true);
1103
- expect((result.codex as { reason: string }).reason).toMatch(/Codex/);
1104
- // ~/.codex was NOT fabricated.
1105
- expect(fs.existsSync(codexHome(env))).toBe(false);
1106
- });
1107
-
1108
- it('US-008: registers BOTH surfaces when ~/.codex EXISTS (Codex no longer skipped)', () => {
1109
- fs.mkdirSync(codexHome(env), { recursive: true });
1110
- const result = registerServer({
1111
- name: 'vyg',
1112
- manifest: VYG_MANIFEST,
1113
- pack: 'hq-pack-vyg',
1114
- resolveSecret: () => 'FAKE-RESOLVED-KEY',
1115
- env,
1116
- });
1117
- expect(result.claude.changed).toBe(true);
1118
- // Codex is now a real surface result (not a skip).
1119
- expect('skipped' in result.codex).toBe(false);
1120
- expect((result.codex as { changed: boolean }).changed).toBe(true);
1121
- // Codex config WAS written with the new table.
1122
- const tomlPath = path.join(codexHome(env), 'config.toml');
1123
- expect(fs.existsSync(tomlPath)).toBe(true);
1124
- const doc = parseToml(fs.readFileSync(tomlPath, 'utf-8')) as Record<string, any>;
1125
- expect(doc.mcp_servers.vyg.url).toBe('https://mcp.vyg.app');
1126
- expect(doc.mcp_servers.vyg.headers.Authorization).toBe('Bearer FAKE-RESOLVED-KEY');
1127
- });
1128
- });
1129
-
1130
- describe('US-007: registerMcpServers (the pack-install routing seam)', () => {
1131
- it('fans each declared server out via a caller-supplied manifest loader', () => {
1132
- const manifests: Record<string, McpManifest> = {
1133
- vyg: VYG_MANIFEST,
1134
- };
1135
- const results = registerMcpServers('hq-pack-vyg', ['vyg'], {
1136
- loadManifest: (n) => manifests[n]!,
1137
- resolveSecret: () => 'FAKE-RESOLVED-KEY',
1138
- env,
1139
- });
1140
- expect(results).toHaveLength(1);
1141
- expect(results[0]!.claude.changed).toBe(true);
1142
- const doc = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
1143
- expect(doc.mcpServers.vyg._hqPack).toBe('hq-pack-vyg');
1144
- expect(doc.mcpServers.vyg.headers.Authorization).toBe('Bearer FAKE-RESOLVED-KEY');
1145
- });
1146
-
1147
- it('validates each server name before loading its manifest (injection guard)', () => {
1148
- const loader: SecretResolver = () => 'k';
1149
- expect(() =>
1150
- registerMcpServers('p', ['BAD/NAME'], { loadManifest: () => VYG_MANIFEST, resolveSecret: loader, env }),
1151
- ).toThrow(McpManifestError);
1152
- });
1153
- });
1154
-
1155
- // ---------------------------------------------------------------------------
1156
- // US-012: HQ_DISABLE_MCP_REGISTRATION kill-switch
1157
- //
1158
- // With HQ_DISABLE_MCP_REGISTRATION=1, registerMcpServers short-circuits ALL MCP
1159
- // registration: NO server is written to the sandbox ~/.claude.json (or ~/.codex),
1160
- // a one-line skip NOTICE is produced on stderr, and the function returns cleanly
1161
- // with empty results. The env is read off process.env (NOT the SafeWriteEnv home
1162
- // injector), so we set/unset it around each test and restore in afterEach. As
1163
- // always, the sandbox `home` tmpdir means the real ~/.claude.json is never touched.
1164
- // ---------------------------------------------------------------------------
1165
-
1166
- describe('US-012: HQ_DISABLE_MCP_REGISTRATION kill-switch', () => {
1167
- let priorFlag: string | undefined;
1168
-
1169
- beforeEach(() => {
1170
- priorFlag = process.env.HQ_DISABLE_MCP_REGISTRATION;
1171
- });
1172
-
1173
- afterEach(() => {
1174
- // Restore the original env so no test leaks the kill-switch to its siblings.
1175
- if (priorFlag === undefined) {
1176
- delete process.env.HQ_DISABLE_MCP_REGISTRATION;
1177
- } else {
1178
- process.env.HQ_DISABLE_MCP_REGISTRATION = priorFlag;
1179
- }
1180
- });
1181
-
1182
- it('skips ALL registration: no server written, empty results, one stderr notice', () => {
1183
- process.env.HQ_DISABLE_MCP_REGISTRATION = '1';
1184
- const writes: string[] = [];
1185
- const spy = vi
1186
- .spyOn(process.stderr, 'write')
1187
- .mockImplementation((chunk: string | Uint8Array): boolean => {
1188
- writes.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf-8'));
1189
- return true;
1190
- });
1191
- try {
1192
- const results = registerMcpServers('hq-pack-vyg', ['vyg'], {
1193
- loadManifest: () => VYG_MANIFEST,
1194
- resolveSecret: () => 'FAKE-RESOLVED-KEY',
1195
- env,
1196
- });
1197
- // Returns cleanly with empty results — the "no servers registered" semantic.
1198
- expect(results).toEqual([]);
1199
- // No Claude config was written (kill-switch fired before any emit).
1200
- expect(fs.existsSync(claudePath())).toBe(false);
1201
- // Exactly one skip NOTICE, naming the kill-switch.
1202
- const notices = writes.filter((w) => /HQ_DISABLE_MCP_REGISTRATION/.test(w));
1203
- expect(notices).toHaveLength(1);
1204
- expect(notices[0]).toMatch(/MCP registration skipped \(HQ_DISABLE_MCP_REGISTRATION=1\)/);
1205
- } finally {
1206
- spy.mockRestore();
1207
- }
1208
- });
1209
-
1210
- it('leaves a PRE-EXISTING claude config unchanged (no new server entry merged)', () => {
1211
- // Seed the sandbox with the user's real-world non-HQ servers, then assert the
1212
- // kill-switch leaves the file byte-for-byte unchanged (no vyg entry added).
1213
- const f = seedClaudeWithFigmaPaper();
1214
- const before = fs.readFileSync(f, 'utf-8');
1215
- process.env.HQ_DISABLE_MCP_REGISTRATION = '1';
1216
- const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
1217
- try {
1218
- const results = registerMcpServers('hq-pack-vyg', ['vyg'], {
1219
- loadManifest: () => VYG_MANIFEST,
1220
- resolveSecret: () => 'FAKE-RESOLVED-KEY',
1221
- env,
1222
- });
1223
- expect(results).toEqual([]);
1224
- const after = fs.readFileSync(f, 'utf-8');
1225
- expect(after).toBe(before);
1226
- const doc = JSON.parse(after);
1227
- expect(doc.mcpServers.vyg).toBeUndefined();
1228
- // Untouched non-HQ servers survive.
1229
- expect(doc.mcpServers.figma).toBeDefined();
1230
- expect(doc.mcpServers.paper).toBeDefined();
1231
- } finally {
1232
- spy.mockRestore();
1233
- }
1234
- });
1235
-
1236
- it('fires the kill-switch BEFORE the loadManifest guard (operator never sees a manifest error)', () => {
1237
- // An operator who set the kill-switch must NOT hit a spurious McpManifestError
1238
- // even when no loadManifest is supplied — the operator condition wins.
1239
- process.env.HQ_DISABLE_MCP_REGISTRATION = '1';
1240
- const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
1241
- try {
1242
- const results = registerMcpServers('hq-pack-vyg', ['vyg']);
1243
- expect(results).toEqual([]);
1244
- } finally {
1245
- spy.mockRestore();
1246
- }
1247
- });
1248
-
1249
- it('does NOT fire when the flag is unset/other value (normal registration proceeds)', () => {
1250
- delete process.env.HQ_DISABLE_MCP_REGISTRATION;
1251
- const results = registerMcpServers('hq-pack-vyg', ['vyg'], {
1252
- loadManifest: () => VYG_MANIFEST,
1253
- resolveSecret: () => 'FAKE-RESOLVED-KEY',
1254
- env,
1255
- });
1256
- expect(results).toHaveLength(1);
1257
- expect(results[0]!.claude.changed).toBe(true);
1258
- const doc = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
1259
- expect(doc.mcpServers.vyg._hqPack).toBe('hq-pack-vyg');
1260
- });
1261
- });
1262
-
1263
- describe('US-007: claudeConfigFormat + path point at the PINNED single surface', () => {
1264
- it('claudeConfigPath resolves to ~/.claude.json under the injected home', () => {
1265
- expect(claudeConfigPath(env)).toBe(path.join(home, '.claude.json'));
1266
- });
1267
-
1268
- it('claudeConfigFormat.assertEntry requires mcpServers.<name>', () => {
1269
- expect(() => claudeConfigFormat.assertEntry({ mcpServers: { vyg: {} } }, 'vyg')).not.toThrow();
1270
- expect(() => claudeConfigFormat.assertEntry({ mcpServers: {} }, 'vyg')).toThrow();
1271
- expect(() => claudeConfigFormat.assertEntry({}, 'vyg')).toThrow();
1272
- });
1273
-
1274
- it('claudeConfigFormat.emptyDoc is {} (does NOT presume an mcpServers key)', () => {
1275
- expect(claudeConfigFormat.emptyDoc()).toEqual({});
1276
- });
1277
- });
1278
-
1279
- // ===========================================================================
1280
- // US-008 — Codex/TOML merge emit (consumes the US-006 safe-write core).
1281
- //
1282
- // CRITICAL TEST SAFETY (unchanged from US-006/007): every test runs against the
1283
- // isolated tmpdir `home` and NEVER touches the developer's real
1284
- // ~/.codex/config.toml. The Codex emit is gated on isCodexInstalled(env), so we
1285
- // must `mkdir ~/.codex` under the fake home for the emitter to act.
1286
- // ===========================================================================
1287
-
1288
- /** Path to the sandbox ~/.codex/config.toml. */
1289
- const codexPath = () => codexConfigPath(env);
1290
-
1291
- /** Create the sandbox ~/.codex dir so the emitter does not first-class-skip. */
1292
- function mkCodexHome(): void {
1293
- fs.mkdirSync(codexHome(env), { recursive: true });
1294
- }
1295
-
1296
- /**
1297
- * Seed ~/.codex/config.toml with a top-of-file comment, a `superhuman` server
1298
- * (with an `.env` sub-table) and a `node_repl` server (with two
1299
- * `.tools.<t>.approval_mode` sub-tables) — the exact fixture from PRD e2e #1.
1300
- */
1301
- function seedCodexSuperhumanNodeRepl(): string {
1302
- mkCodexHome();
1303
- const toml = `# Codex global config — hand-edited, DO NOT CLOBBER
1304
- # (this comment block must survive a register)
1305
-
1306
- model = "gpt-5"
1307
-
1308
- [mcp_servers.superhuman]
1309
- command = "npx"
1310
- args = ["-y", "superhuman-mcp"]
1311
-
1312
- [mcp_servers.superhuman.env]
1313
- SUPERHUMAN_TOKEN = "tok-abc"
1314
-
1315
- [mcp_servers.node_repl]
1316
- command = "node"
1317
- args = ["repl.js"]
1318
-
1319
- [mcp_servers.node_repl.tools.execute]
1320
- approval_mode = "always"
1321
-
1322
- [mcp_servers.node_repl.tools.read]
1323
- approval_mode = "never"
1324
- `;
1325
- fs.writeFileSync(codexPath(), toml);
1326
- return codexPath();
1327
- }
1328
-
1329
- describe('US-008: Codex config path + format point at the GLOBAL Codex surface', () => {
1330
- it('codexConfigPath is ~/.codex/config.toml (NOT a project file)', () => {
1331
- expect(codexConfigPath(env)).toBe(path.join(home, '.codex', 'config.toml'));
1332
- });
1333
-
1334
- it('codexConfigFormat.assertEntry requires mcp_servers.<name>', () => {
1335
- const present: CodexTomlDoc = { value: { mcp_servers: { vyg: {} } }, originalText: null };
1336
- const absent: CodexTomlDoc = { value: { mcp_servers: {} }, originalText: null };
1337
- expect(() => codexConfigFormat.assertEntry(present, 'vyg')).not.toThrow();
1338
- expect(() => codexConfigFormat.assertEntry(absent, 'vyg')).toThrow();
1339
- expect(() => codexConfigFormat.assertEntry({ value: {}, originalText: null }, 'vyg')).toThrow();
1340
- });
1341
-
1342
- it('codexConfigFormat.parse round-trips valid TOML and rejects malformed input', () => {
1343
- const ok = codexConfigFormat.parse('[mcp_servers.x]\ntype = "http"\nurl = "https://x"\n');
1344
- expect((ok.value.mcp_servers as any).x.url).toBe('https://x');
1345
- // Malformed TOML must throw (the substrate maps this to ConfigParseError).
1346
- expect(() => codexConfigFormat.parse('this = = is [[[ not toml')).toThrow();
1347
- });
1348
-
1349
- it('CODEX_MCP_SERVERS_KEY is the mcp_servers super-table', () => {
1350
- expect(CODEX_MCP_SERVERS_KEY).toBe('mcp_servers');
1351
- });
1352
- });
1353
-
1354
- describe('US-008: buildCodexServerDef (transport passthrough, secrets, provenance, approval_mode)', () => {
1355
- it('passes transport fields through and resolves ${secret:} into headers', () => {
1356
- const sink = new Set<string>();
1357
- const def = buildCodexServerDef(VYG_MANIFEST, 'hq-pack-vyg', () => 'FAKE-CODEX-KEY', sink);
1358
- expect(def.type).toBe('http');
1359
- expect(def.url).toBe('https://mcp.vyg.app');
1360
- expect((def.headers as Record<string, string>).Authorization).toBe('Bearer FAKE-CODEX-KEY');
1361
- expect(sink.has('FAKE-CODEX-KEY')).toBe(true);
1362
- expect(def[HQ_PACK_PROVENANCE_KEY]).toBe('hq-pack-vyg');
1363
- });
1364
-
1365
- it('writes the per-tool approval_mode from the manifest where present', () => {
1366
- const manifest: McpManifest = {
1367
- type: 'http',
1368
- url: 'https://mcp.vyg.app',
1369
- tools: {
1370
- execute_query: { approval_mode: 'always' },
1371
- get_brand_info: { approval_mode: 'never' },
1372
- no_mode_tool: { something: 'else' }, // no approval_mode -> omitted
1373
- },
1374
- };
1375
- const def = buildCodexServerDef(manifest, 'p', () => 'k', new Set());
1376
- const tools = def.tools as Record<string, { approval_mode?: string }>;
1377
- expect(tools.execute_query.approval_mode).toBe('always');
1378
- expect(tools.get_brand_info.approval_mode).toBe('never');
1379
- expect(tools.no_mode_tool).toBeUndefined(); // only approval_mode tools emitted
1380
- });
1381
-
1382
- it('omits the tools table entirely when no tool declares an approval_mode', () => {
1383
- expect(buildCodexToolsTable(undefined)).toBeUndefined();
1384
- expect(buildCodexToolsTable({})).toBeUndefined();
1385
- expect(buildCodexToolsTable({ t: { something: 'x' } as any })).toBeUndefined();
1386
- const def = buildCodexServerDef({ type: 'http', url: 'https://x' }, 'p', () => 'k', new Set());
1387
- expect(def.tools).toBeUndefined();
1388
- });
1389
- });
1390
-
1391
- describe('US-008: appendCodexTable / serializeOneCodexServer (comment-preserving emit)', () => {
1392
- it('serializeOneCodexServer emits a single [mcp_servers.<name>] table', () => {
1393
- const out = serializeOneCodexServer('vyg', { type: 'http', url: 'https://x' });
1394
- expect(out).toContain('[mcp_servers.vyg]');
1395
- expect(out).toContain('url = "https://x"');
1396
- // Round-trips back to a value.
1397
- const re = parseToml(out) as any;
1398
- expect(re.mcp_servers.vyg.url).toBe('https://x');
1399
- });
1400
-
1401
- it('appendCodexTable preserves the original bytes verbatim and adds the new table', () => {
1402
- const original = '# my comment\n[mcp_servers.a]\ntype = "http"\nurl = "https://a"\n';
1403
- const out = appendCodexTable(original, 'b', { type: 'http', url: 'https://b' });
1404
- // Original comment + table preserved byte-for-byte at the head.
1405
- expect(out.startsWith('# my comment\n[mcp_servers.a]')).toBe(true);
1406
- expect(out).toContain('# my comment');
1407
- // New table appended, whole thing re-parses with BOTH servers.
1408
- const re = parseToml(out) as any;
1409
- expect(Object.keys(re.mcp_servers).sort()).toEqual(['a', 'b']);
1410
- expect(re.mcp_servers.a.url).toBe('https://a'); // untouched
1411
- expect(re.mcp_servers.b.url).toBe('https://b'); // added
1412
- });
1413
-
1414
- it('appendCodexTable on whitespace-only original emits just the block', () => {
1415
- const out = appendCodexTable(' \n\n', 'x', { type: 'http', url: 'https://x' });
1416
- expect(out.trimStart().startsWith('[mcp_servers.x]')).toBe(true);
1417
- expect(parseToml(out)).toBeTruthy();
1418
- });
1419
- });
1420
-
1421
- describe('US-008: mergeCodexServer (insert / idempotent no-op / collision)', () => {
1422
- it('inserts an absent server and records a pending append', () => {
1423
- const doc: CodexTomlDoc = { value: {}, originalText: '# c\n' };
1424
- const next = mergeCodexServer('vyg', { type: 'http', url: 'https://x' })(doc);
1425
- expect((next.value.mcp_servers as any).vyg.url).toBe('https://x');
1426
- // serialize must append (comment preserved).
1427
- const text = codexConfigFormat.serialize(next);
1428
- expect(text).toContain('# c');
1429
- expect(text).toContain('[mcp_servers.vyg]');
1430
- });
1431
-
1432
- it('is an idempotent no-op when the server is already present with an equal def', () => {
1433
- const def = { type: 'http', url: 'https://x', _hqPack: 'p' };
1434
- const original = serializeOneCodexServer('vyg', def);
1435
- const doc: CodexTomlDoc = { value: parseToml(original) as any, originalText: original };
1436
- const next = mergeCodexServer('vyg', { type: 'http', url: 'https://x' })(doc);
1437
- // Unchanged doc -> serialize reproduces the original text exactly.
1438
- expect(codexConfigFormat.serialize(next)).toBe(original);
1439
- });
1440
-
1441
- it('throws McpNameCollisionError on a differently-defined existing server', () => {
1442
- const original = serializeOneCodexServer('vyg', { type: 'http', url: 'https://OLD' });
1443
- const doc: CodexTomlDoc = { value: parseToml(original) as any, originalText: original };
1444
- expect(() => mergeCodexServer('vyg', { type: 'http', url: 'https://NEW' })(doc)).toThrow(
1445
- McpNameCollisionError,
1446
- );
1447
- });
1448
- });
1449
-
1450
- describe('US-008: registerCodexServer — Codex-absent skip + name validation', () => {
1451
- it('FIRST-CLASS SKIP when ~/.codex is absent (no crash, no fabrication)', () => {
1452
- const res = registerCodexServer({ name: 'vyg', manifest: VYG_MANIFEST, pack: 'p', resolveSecret: () => 'k', env });
1453
- expect('skipped' in res && res.skipped).toBe(true);
1454
- expect((res as { reason: string }).reason).toMatch(/Codex runtime absent/);
1455
- // ~/.codex was NOT created.
1456
- expect(fs.existsSync(codexHome(env))).toBe(false);
1457
- expect(fs.existsSync(codexPath())).toBe(false);
1458
- });
1459
-
1460
- it('rejects a bad server name BEFORE any IO (no backup, no write)', () => {
1461
- mkCodexHome();
1462
- expect(() =>
1463
- registerCodexServer({ name: 'BAD NAME', manifest: VYG_MANIFEST, pack: 'p', env }),
1464
- ).toThrow(McpManifestError);
1465
- expect(fs.existsSync(codexPath())).toBe(false);
1466
- expect(fs.existsSync(path.join(home, '.hq', 'backups', 'mcp'))).toBe(false);
1467
- });
1468
-
1469
- it('writes ONE [mcp_servers.<name>] table into a fresh config.toml when ~/.codex exists', () => {
1470
- mkCodexHome();
1471
- const res = registerCodexServer({
1472
- name: 'vyg',
1473
- manifest: VYG_MANIFEST,
1474
- pack: 'hq-pack-vyg',
1475
- resolveSecret: () => 'FAKE-CODEX-KEY',
1476
- env,
1477
- });
1478
- expect('skipped' in res).toBe(false);
1479
- expect((res as { changed: boolean }).changed).toBe(true);
1480
- const doc = parseToml(fs.readFileSync(codexPath(), 'utf-8')) as any;
1481
- expect(doc.mcp_servers.vyg.type).toBe('http');
1482
- expect(doc.mcp_servers.vyg.url).toBe('https://mcp.vyg.app');
1483
- expect(doc.mcp_servers.vyg.headers.Authorization).toBe('Bearer FAKE-CODEX-KEY');
1484
- expect(doc.mcp_servers.vyg._hqPack).toBe('hq-pack-vyg');
1485
- });
1486
-
1487
- it('the secret value is REDACTED from a collision-error message (never echoed)', () => {
1488
- mkCodexHome();
1489
- // Pre-existing differently-defined vyg forces a collision on re-register.
1490
- fs.writeFileSync(codexPath(), serializeOneCodexServer('vyg', { type: 'http', url: 'https://OLD' }));
1491
- try {
1492
- registerCodexServer({
1493
- name: 'vyg',
1494
- manifest: { type: 'http', url: 'https://NEW', headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' } },
1495
- pack: 'p',
1496
- resolveSecret: () => 'FAKE-CODEX-SECRET-MUST-NOT-LEAK',
1497
- env,
1498
- });
1499
- throw new Error('should have thrown a collision');
1500
- } catch (e) {
1501
- expect(e).toBeInstanceOf(McpNameCollisionError);
1502
- expect((e as Error).message).not.toContain('FAKE-CODEX-SECRET-MUST-NOT-LEAK');
1503
- }
1504
- });
1505
-
1506
- it('idempotent per surface: re-register is a byte-for-byte no-op', () => {
1507
- mkCodexHome();
1508
- const args = {
1509
- name: 'vyg',
1510
- manifest: VYG_MANIFEST,
1511
- pack: 'hq-pack-vyg',
1512
- resolveSecret: () => 'FAKE-CODEX-KEY',
1513
- env,
1514
- };
1515
- const first = registerCodexServer(args);
1516
- expect((first as { changed: boolean }).changed).toBe(true);
1517
- const afterFirst = fs.readFileSync(codexPath(), 'utf-8');
1518
- const second = registerCodexServer(args);
1519
- expect((second as { changed: boolean }).changed).toBe(false);
1520
- expect(fs.readFileSync(codexPath(), 'utf-8')).toBe(afterFirst);
1521
- });
1522
-
1523
- it('FAIL-CLOSED on a malformed existing config.toml (ConfigParseError, file preserved)', () => {
1524
- mkCodexHome();
1525
- const garbage = 'this is = = not [[[ valid toml at all\n';
1526
- fs.writeFileSync(codexPath(), garbage);
1527
- expect(() =>
1528
- registerCodexServer({ name: 'vyg', manifest: VYG_MANIFEST, pack: 'p', resolveSecret: () => 'k', env }),
1529
- ).toThrow(ConfigParseError);
1530
- // The original malformed file is left untouched (never clobbered/regenerated).
1531
- expect(fs.readFileSync(codexPath(), 'utf-8')).toBe(garbage);
1532
- });
1533
-
1534
- it('writes per-tool approval_mode into the emitted table', () => {
1535
- mkCodexHome();
1536
- registerCodexServer({
1537
- name: 'vyg',
1538
- manifest: {
1539
- type: 'http',
1540
- url: 'https://mcp.vyg.app',
1541
- tools: { execute_query: { approval_mode: 'always' } },
1542
- },
1543
- pack: 'p',
1544
- env,
1545
- });
1546
- const doc = parseToml(fs.readFileSync(codexPath(), 'utf-8')) as any;
1547
- expect(doc.mcp_servers.vyg.tools.execute_query.approval_mode).toBe('always');
1548
- });
1549
- });
1550
-
1551
- describe('US-008 e2e: PRD acceptance scenarios', () => {
1552
- it('e2e #1 — superhuman + node_repl tables: new table added; existing tables + approval_mode sub-tables preserved', () => {
1553
- const f = seedCodexSuperhumanNodeRepl();
1554
- const before = fs.readFileSync(f, 'utf-8');
1555
-
1556
- const result = registerServer({
1557
- name: 'vyg_shopify',
1558
- manifest: {
1559
- type: 'http',
1560
- url: 'https://mcp.vyg.app',
1561
- headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' },
1562
- tools: { execute_query: { approval_mode: 'always' } },
1563
- },
1564
- pack: 'hq-pack-vyg-shopify',
1565
- resolveSecret: () => 'FAKE-RESOLVED-KEY',
1566
- env,
1567
- });
1568
-
1569
- // Both surfaces acted on (Claude registered, Codex registered).
1570
- expect(result.claude.changed).toBe(true);
1571
- expect('skipped' in result.codex).toBe(false);
1572
- expect((result.codex as { changed: boolean }).changed).toBe(true);
1573
-
1574
- const after = fs.readFileSync(f, 'utf-8');
1575
- // The hand-edited comment block survived (comment preservation).
1576
- expect(after).toContain('# Codex global config — hand-edited, DO NOT CLOBBER');
1577
- // The original superhuman + node_repl blocks are preserved byte-for-byte at the head.
1578
- expect(after.startsWith(before.replace(/\s*$/, ''))).toBe(true);
1579
-
1580
- const doc = parseToml(after) as any;
1581
- // New table added.
1582
- expect(doc.mcp_servers.vyg_shopify.url).toBe('https://mcp.vyg.app');
1583
- expect(doc.mcp_servers.vyg_shopify.headers.Authorization).toBe('Bearer FAKE-RESOLVED-KEY');
1584
- expect(doc.mcp_servers.vyg_shopify.tools.execute_query.approval_mode).toBe('always');
1585
- // Existing tables preserved.
1586
- expect(doc.mcp_servers.superhuman.command).toBe('npx');
1587
- // Existing .env sub-table preserved.
1588
- expect(doc.mcp_servers.superhuman.env.SUPERHUMAN_TOKEN).toBe('tok-abc');
1589
- // Existing .tools.<t>.approval_mode sub-tables preserved.
1590
- expect(doc.mcp_servers.node_repl.tools.execute.approval_mode).toBe('always');
1591
- expect(doc.mcp_servers.node_repl.tools.read.approval_mode).toBe('never');
1592
- // No secret on disk anywhere except the resolved header (which the runtime needs).
1593
- expect(after).not.toContain('${secret:VYG_API_KEY}'); // reference resolved at emit
1594
- });
1595
-
1596
- it('e2e #2 — no ~/.codex dir: Codex emit skipped cleanly and Claude still registers', () => {
1597
- // No mkCodexHome() — ~/.codex is absent.
1598
- const result = registerServer({
1599
- name: 'vyg',
1600
- manifest: VYG_MANIFEST,
1601
- pack: 'hq-pack-vyg',
1602
- resolveSecret: () => 'FAKE-RESOLVED-KEY',
1603
- env,
1604
- });
1605
- // Claude registered.
1606
- expect(result.claude.changed).toBe(true);
1607
- const claudeDoc = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
1608
- expect(claudeDoc.mcpServers.vyg.url).toBe('https://mcp.vyg.app');
1609
- // Codex skipped cleanly, ~/.codex NOT fabricated.
1610
- expect('skipped' in result.codex && result.codex.skipped).toBe(true);
1611
- expect(fs.existsSync(codexHome(env))).toBe(false);
1612
- expect(fs.existsSync(codexPath())).toBe(false);
1613
- });
1614
-
1615
- it('e2e — registerMcpServers fans out to Codex too when ~/.codex exists', () => {
1616
- mkCodexHome();
1617
- const manifests: Record<string, McpManifest> = { vyg: VYG_MANIFEST };
1618
- const results = registerMcpServers('hq-pack-vyg', ['vyg'], {
1619
- loadManifest: (n) => manifests[n]!,
1620
- resolveSecret: () => 'FAKE-RESOLVED-KEY',
1621
- env,
1622
- });
1623
- expect(results).toHaveLength(1);
1624
- expect((results[0]!.codex as { changed: boolean }).changed).toBe(true);
1625
- const doc = parseToml(fs.readFileSync(codexPath(), 'utf-8')) as any;
1626
- expect(doc.mcp_servers.vyg._hqPack).toBe('hq-pack-vyg');
1627
- });
1628
- });
1629
-
1630
- // ===========================================================================
1631
- // US-009 — Uninstall un-registration (provenance-scoped, no leak).
1632
- //
1633
- // CRITICAL TEST SAFETY (unchanged): every test runs against the isolated tmpdir
1634
- // `home` and NEVER touches the developer's real ~/.claude.json / ~/.codex.
1635
- //
1636
- // The mirror of register: removal is PROVENANCE-SCOPED — only entries stamped with
1637
- // THIS pack's `_hqPack` are removed; a same-named entry that is unstamped (user) or
1638
- // stamped by another pack is SKIPPED-AND-WARNED, never deleted.
1639
- // ===========================================================================
1640
-
1641
- describe('US-009: removeClaudeServer merge fn (provenance-scoped, surgical)', () => {
1642
- it('removes ONLY the matching-provenance entry, preserving siblings + top-level keys', () => {
1643
- const doc = {
1644
- userID: 'u-1', // a non-mcp top-level key that MUST survive
1645
- mcpServers: {
1646
- figma: { type: 'http', url: 'https://figma' }, // user-created (no stamp)
1647
- vyg: { type: 'http', url: 'https://vyg', _hqPack: 'hq-pack-vyg' }, // ours
1648
- },
1649
- };
1650
- const next = removeClaudeServer('vyg', 'hq-pack-vyg')(doc);
1651
- expect((next.mcpServers as any).vyg).toBeUndefined(); // removed
1652
- expect((next.mcpServers as any).figma.url).toBe('https://figma'); // sibling preserved
1653
- expect((next as any).userID).toBe('u-1'); // top-level key preserved
1654
- });
1655
-
1656
- it('is an idempotent no-op when the server is ABSENT (returns the doc unchanged)', () => {
1657
- const doc = { mcpServers: { figma: { type: 'http', url: 'https://figma' } } };
1658
- expect(removeClaudeServer('vyg', 'hq-pack-vyg')(doc)).toBe(doc); // same ref => unchanged
1659
- });
1660
-
1661
- it('SKIPS (leaves untouched) an entry whose provenance does NOT match this pack', () => {
1662
- const doc = { mcpServers: { vyg: { type: 'http', url: 'https://vyg', _hqPack: 'OTHER-PACK' } } };
1663
- expect(removeClaudeServer('vyg', 'hq-pack-vyg')(doc)).toBe(doc); // foreign => unchanged
1664
- });
1665
-
1666
- it('SKIPS (leaves untouched) an UNSTAMPED (user-created) same-named entry — never bare name-match', () => {
1667
- const doc = { mcpServers: { vyg: { type: 'http', url: 'https://user-vyg' } } };
1668
- expect(removeClaudeServer('vyg', 'hq-pack-vyg')(doc)).toBe(doc); // unstamped => unchanged
1669
- });
1670
-
1671
- it('leaves an empty mcpServers:{} after removing the last entry (round-trips, idempotent)', () => {
1672
- const doc = { mcpServers: { vyg: { type: 'http', _hqPack: 'p' } } };
1673
- const next = removeClaudeServer('vyg', 'p')(doc);
1674
- expect(next.mcpServers).toEqual({});
1675
- });
1676
- });
1677
-
1678
- describe('US-009: claudeRemovalFormat.assertEntry asserts ABSENCE (inverted verify)', () => {
1679
- it('throws iff the entry is STILL present after removal; passes when absent', () => {
1680
- expect(() => claudeRemovalFormat.assertEntry({ mcpServers: { vyg: {} } }, 'vyg')).toThrow();
1681
- expect(() => claudeRemovalFormat.assertEntry({ mcpServers: {} }, 'vyg')).not.toThrow();
1682
- expect(() => claudeRemovalFormat.assertEntry({}, 'vyg')).not.toThrow();
1683
- });
1684
- });
1685
-
1686
- describe('US-009: unregisterClaudeServer — provenance-scoped removal via writeConfigAtomic', () => {
1687
- it('removes our stamped entry and preserves the user\'s sibling + top-level keys byte-for-byte', () => {
1688
- // Seed a user figma/paper config, then register our vyg into it.
1689
- seedClaudeWithFigmaPaper();
1690
- registerClaudeServer({ name: 'vyg', manifest: { type: 'http', url: 'https://vyg' }, pack: 'hq-pack-vyg', env });
1691
- const beforeUserBlock = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
1692
- expect(beforeUserBlock.mcpServers.vyg._hqPack).toBe('hq-pack-vyg');
1693
-
1694
- const res = unregisterClaudeServer({ name: 'vyg', pack: 'hq-pack-vyg', env });
1695
- expect(res.outcome).toBe('removed');
1696
- expect(res.changed).toBe(true);
1697
-
1698
- const after = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
1699
- expect(after.mcpServers.vyg).toBeUndefined(); // ours removed
1700
- expect(after.mcpServers.figma.url).toBe('http://localhost:3845/mcp'); // user sibling intact
1701
- expect(after.mcpServers.paper.command).toBe('/opt/homebrew/bin/npx'); // user sibling intact
1702
- expect(after.userID).toBe('u-123'); // unrelated top-level key intact
1703
- });
1704
-
1705
- it('SKIPS-AND-WARNS on a same-named entry the user owns (unstamped) — never deletes it', () => {
1706
- // The user has their OWN `vyg` server (no _hqPack). Uninstalling our pack must NOT touch it.
1707
- const doc = { mcpServers: { vyg: { type: 'http', url: 'https://user-owned-vyg' } } };
1708
- fs.writeFileSync(claudePath(), JSON.stringify(doc, null, 2) + '\n');
1709
- const before = fs.readFileSync(claudePath(), 'utf-8');
1710
-
1711
- const res = unregisterClaudeServer({ name: 'vyg', pack: 'hq-pack-vyg', env });
1712
- expect(res.outcome).toBe('skipped-foreign');
1713
- expect(res.changed).toBe(false);
1714
- expect(res.reason).toMatch(/not owned by pack/);
1715
- // File untouched byte-for-byte.
1716
- expect(fs.readFileSync(claudePath(), 'utf-8')).toBe(before);
1717
- });
1718
-
1719
- it('is a clean no-op (absent) when ~/.claude.json does not exist (never created)', () => {
1720
- const res = unregisterClaudeServer({ name: 'vyg', pack: 'hq-pack-vyg', env });
1721
- expect(res.outcome).toBe('absent');
1722
- expect(res.changed).toBe(false);
1723
- expect(fs.existsSync(claudePath())).toBe(false); // not fabricated
1724
- });
1725
-
1726
- it('is idempotent — a second uninstall of an already-removed server is a clean no-op', () => {
1727
- registerClaudeServer({ name: 'vyg', manifest: { type: 'http', url: 'https://vyg' }, pack: 'p', env });
1728
- expect(unregisterClaudeServer({ name: 'vyg', pack: 'p', env }).outcome).toBe('removed');
1729
- const second = unregisterClaudeServer({ name: 'vyg', pack: 'p', env });
1730
- expect(second.outcome).toBe('absent');
1731
- expect(second.changed).toBe(false);
1732
- });
1733
- });
1734
-
1735
- describe('US-009: removeCodexServer + codexRemovalFormat (TOML, surgical, comment-drop on removal)', () => {
1736
- it('removes our table, preserving sibling tables + .tools.<t>.approval_mode sub-tables (DATA)', () => {
1737
- seedCodexSuperhumanNodeRepl();
1738
- // Register our own stamped server alongside the user's superhuman/node_repl.
1739
- registerCodexServer({
1740
- name: 'vyg',
1741
- manifest: { type: 'http', url: 'https://vyg' },
1742
- pack: 'hq-pack-vyg',
1743
- env,
1744
- });
1745
- const withVyg = parseToml(fs.readFileSync(codexPath(), 'utf-8')) as any;
1746
- expect(withVyg.mcp_servers.vyg._hqPack).toBe('hq-pack-vyg');
1747
-
1748
- const res = unregisterCodexServer({ name: 'vyg', pack: 'hq-pack-vyg', env });
1749
- expect('skipped' in res).toBe(false);
1750
- expect((res as any).outcome).toBe('removed');
1751
-
1752
- const after = parseToml(fs.readFileSync(codexPath(), 'utf-8')) as any;
1753
- expect(after.mcp_servers.vyg).toBeUndefined(); // ours removed
1754
- // Sibling tables + their sub-tables preserved as DATA across the re-serialize.
1755
- expect(after.mcp_servers.superhuman.command).toBe('npx');
1756
- expect(after.mcp_servers.superhuman.env.SUPERHUMAN_TOKEN).toBe('tok-abc');
1757
- expect(after.mcp_servers.node_repl.tools.execute.approval_mode).toBe('always');
1758
- expect(after.mcp_servers.node_repl.tools.read.approval_mode).toBe('never');
1759
- });
1760
-
1761
- it('SKIPS-AND-WARNS a foreign-stamped table and reproduces the file byte-for-byte (comments intact)', () => {
1762
- mkCodexHome();
1763
- // A table stamped by ANOTHER pack, plus a leading comment.
1764
- const original = '# keep this comment\n[mcp_servers.vyg]\ntype = "http"\nurl = "https://vyg"\n_hqPack = "OTHER-PACK"\n';
1765
- fs.writeFileSync(codexPath(), original);
1766
-
1767
- const res = unregisterCodexServer({ name: 'vyg', pack: 'hq-pack-vyg', env });
1768
- expect('skipped' in res).toBe(false);
1769
- expect((res as any).outcome).toBe('skipped-foreign');
1770
- // No write => original bytes (and the comment) preserved exactly.
1771
- expect(fs.readFileSync(codexPath(), 'utf-8')).toBe(original);
1772
- });
1773
-
1774
- it('removeCodexServer is an idempotent no-op when the table is absent (originalText round-trips)', () => {
1775
- const original = '# c\n[mcp_servers.other]\ntype = "http"\nurl = "https://o"\n';
1776
- const doc: CodexTomlDoc = { value: parseToml(original) as any, originalText: original };
1777
- const next = removeCodexServer('vyg', 'p')(doc);
1778
- expect(codexRemovalFormat.serialize(next)).toBe(original); // unchanged => byte-for-byte
1779
- });
1780
-
1781
- it('codexRemovalFormat.assertEntry asserts ABSENCE (inverted verify)', () => {
1782
- const present: CodexTomlDoc = { value: { mcp_servers: { vyg: {} } }, originalText: null };
1783
- const absent: CodexTomlDoc = { value: { mcp_servers: {} }, originalText: null };
1784
- expect(() => codexRemovalFormat.assertEntry(present, 'vyg')).toThrow();
1785
- expect(() => codexRemovalFormat.assertEntry(absent, 'vyg')).not.toThrow();
1786
- });
1787
- });
1788
-
1789
- describe('US-009: unregisterCodexServer — Codex-absent first-class skip + idempotency', () => {
1790
- it('FIRST-CLASS SKIP when ~/.codex is absent (no crash, no fabrication)', () => {
1791
- const res = unregisterCodexServer({ name: 'vyg', pack: 'p', env });
1792
- expect('skipped' in res && res.skipped).toBe(true);
1793
- expect(fs.existsSync(codexHome(env))).toBe(false); // not fabricated
1794
- });
1795
-
1796
- it('is a clean no-op (absent) when ~/.codex exists but config.toml does not', () => {
1797
- mkCodexHome();
1798
- const res = unregisterCodexServer({ name: 'vyg', pack: 'p', env });
1799
- expect('skipped' in res).toBe(false);
1800
- expect((res as any).outcome).toBe('absent');
1801
- expect(fs.existsSync(codexPath())).toBe(false); // not fabricated
1802
- });
1803
- });
1804
-
1805
- describe('US-009: unregisterServer fan-out + unregisterMcpServers seam', () => {
1806
- it('unregisterServer removes from BOTH surfaces when Codex is present', () => {
1807
- seedClaudeWithFigmaPaper();
1808
- mkCodexHome();
1809
- registerServer({ name: 'vyg', manifest: { type: 'http', url: 'https://vyg' }, pack: 'hq-pack-vyg', env });
1810
- expect(JSON.parse(fs.readFileSync(claudePath(), 'utf-8')).mcpServers.vyg).toBeDefined();
1811
- expect((parseToml(fs.readFileSync(codexPath(), 'utf-8')) as any).mcp_servers.vyg).toBeDefined();
1812
-
1813
- const res = unregisterServer({ name: 'vyg', pack: 'hq-pack-vyg', env });
1814
- expect(res.claude.outcome).toBe('removed');
1815
- expect('skipped' in res.codex).toBe(false);
1816
- expect((res.codex as any).outcome).toBe('removed');
1817
- // Both surfaces no longer carry vyg; the user's figma sibling survives.
1818
- expect(JSON.parse(fs.readFileSync(claudePath(), 'utf-8')).mcpServers.vyg).toBeUndefined();
1819
- expect(JSON.parse(fs.readFileSync(claudePath(), 'utf-8')).mcpServers.figma).toBeDefined();
1820
- expect((parseToml(fs.readFileSync(codexPath(), 'utf-8')) as any).mcp_servers.vyg).toBeUndefined();
1821
- });
1822
-
1823
- it('unregisterMcpServers fans out over multiple names in order (no manifest loader needed)', () => {
1824
- registerClaudeServer({ name: 'vyg', manifest: { type: 'http', url: 'https://vyg' }, pack: 'p', env });
1825
- registerClaudeServer({ name: 'shopify', manifest: { type: 'http', url: 'https://shop' }, pack: 'p', env });
1826
- const results = unregisterMcpServers('p', ['vyg', 'shopify'], { env });
1827
- expect(results).toHaveLength(2);
1828
- expect(results[0]!.claude.outcome).toBe('removed');
1829
- expect(results[1]!.claude.outcome).toBe('removed');
1830
- const after = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
1831
- expect(after.mcpServers).toEqual({}); // both removed
1832
- });
1833
- });
1834
-
1835
- describe('US-009 e2e: PRD acceptance scenarios', () => {
1836
- it('e2e #1 — installed pack removed; a differently-named user server preserved byte-for-byte', () => {
1837
- // A co-located config: the user owns `figma`/`paper` (no stamp); we install `vyg`.
1838
- seedClaudeWithFigmaPaper();
1839
- registerClaudeServer({ name: 'vyg', manifest: { type: 'http', url: 'https://mcp.vyg.app' }, pack: 'hq-pack-vyg', env });
1840
-
1841
- // Snapshot the user's own (figma/paper) entries to prove byte-for-byte preservation.
1842
- const userBefore = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
1843
- const figmaBefore = JSON.stringify(userBefore.mcpServers.figma);
1844
- const paperBefore = JSON.stringify(userBefore.mcpServers.paper);
1845
-
1846
- const res = unregisterMcpServers('hq-pack-vyg', ['vyg'], { env });
1847
- expect(res[0]!.claude.outcome).toBe('removed'); // ONLY our provenance-stamped entry removed
1848
-
1849
- const after = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
1850
- expect(after.mcpServers.vyg).toBeUndefined(); // pack entry gone
1851
- // The user's differently-named servers are preserved byte-for-byte.
1852
- expect(JSON.stringify(after.mcpServers.figma)).toBe(figmaBefore);
1853
- expect(JSON.stringify(after.mcpServers.paper)).toBe(paperBefore);
1854
- expect(after.userID).toBe('u-123');
1855
- });
1856
-
1857
- it('e2e #1 (strengthened) — pack A removed; SAME-NAMED user server + pack-B server BOTH preserved byte-for-byte', () => {
1858
- // The exact PRD scenario: an installed pack PLUS the user's OWN same-named server,
1859
- // PLUS a server installed by a DIFFERENT pack 'B'. Uninstalling pack A must remove
1860
- // ONLY A's provenance-stamped entry and leave the user's + B's untouched byte-for-byte.
1861
- //
1862
- // Names in play (all in ONE ~/.claude.json):
1863
- // - `vyg` : stamped `_hqPack: 'hq-pack-A'` -> A owns it, MUST be removed.
1864
- // - `vyg_user` : user-created, NO `_hqPack` -> the user's own same-shape
1865
- // server under a different name; MUST survive byte-for-byte.
1866
- // - `vyg`-collision is impossible (one JSON key), so the "same-named user server"
1867
- // guarantee is exercised separately below via the foreign-skip e2e. Here we also
1868
- // stamp a B-owned server under its own name to prove cross-pack isolation.
1869
- // - `shopify` : stamped `_hqPack: 'hq-pack-B'` -> B owns it, MUST survive.
1870
- // - `figma` : user-created (no stamp) -> MUST survive byte-for-byte.
1871
- const doc = {
1872
- userID: 'u-xyz', // an unrelated top-level key that MUST survive untouched
1873
- tipsHistory: { seen: ['a', 'b'] }, // another untouched top-level key
1874
- mcpServers: {
1875
- figma: { type: 'http', url: 'http://localhost:3845/mcp' }, // user, unstamped
1876
- vyg_user: { type: 'http', url: 'https://user-owned.example' }, // user, unstamped
1877
- vyg: { type: 'http', url: 'https://mcp.vyg.app', _hqPack: 'hq-pack-A' }, // A owns
1878
- shopify: { type: 'http', url: 'https://mcp.shopify.app', _hqPack: 'hq-pack-B' }, // B owns
1879
- },
1880
- };
1881
- fs.writeFileSync(claudePath(), JSON.stringify(doc, null, 2) + '\n');
1882
-
1883
- // Snapshot every entry we expect to survive, to prove BYTE-FOR-BYTE preservation.
1884
- const before = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
1885
- const figmaBytes = JSON.stringify(before.mcpServers.figma);
1886
- const vygUserBytes = JSON.stringify(before.mcpServers.vyg_user);
1887
- const shopifyBytes = JSON.stringify(before.mcpServers.shopify);
1888
-
1889
- // Uninstall ONLY pack A.
1890
- const res = unregisterMcpServers('hq-pack-A', ['vyg'], { env });
1891
- expect(res[0]!.claude.outcome).toBe('removed'); // A's stamped entry removed
1892
-
1893
- const after = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
1894
- // A's entry is GONE.
1895
- expect(after.mcpServers.vyg).toBeUndefined();
1896
- // The user's own server + pack B's server are preserved BYTE-FOR-BYTE.
1897
- expect(JSON.stringify(after.mcpServers.figma)).toBe(figmaBytes);
1898
- expect(JSON.stringify(after.mcpServers.vyg_user)).toBe(vygUserBytes);
1899
- expect(JSON.stringify(after.mcpServers.shopify)).toBe(shopifyBytes);
1900
- // B's provenance stamp is intact (we did not strip a foreign pack's marker).
1901
- expect(after.mcpServers.shopify._hqPack).toBe('hq-pack-B');
1902
- // Surrounding top-level keys are preserved untouched.
1903
- expect(after.userID).toBe('u-xyz');
1904
- expect(after.tipsHistory).toEqual({ seen: ['a', 'b'] });
1905
- // Exactly the expected key-set remains (A's `vyg` dropped, the other three kept).
1906
- expect(Object.keys(after.mcpServers).sort()).toEqual(['figma', 'shopify', 'vyg_user']);
1907
- });
1908
-
1909
- it('e2e #1 (foreign skip-and-warn) — a SAME-NAMED user server is NOT deleted; outcome reports skipped-foreign', () => {
1910
- // The precise PRD guarantee: "ONLY the pack's provenance-stamped entry is removed
1911
- // and the user's is preserved." Here the user owns a server under the SAME name the
1912
- // pack would target, but it carries NO `_hqPack` (and we also exercise a DIFFERENT
1913
- // pack's stamp). Uninstalling pack A must skip-and-warn, never bare name-match delete.
1914
- const doc = {
1915
- mcpServers: {
1916
- // user owns `vyg` (no provenance) — must survive
1917
- vyg: { type: 'http', url: 'https://user-owned-vyg.example' },
1918
- // a sibling that pack B owns under the SAME name a different uninstall might hit
1919
- other: { type: 'http', url: 'https://b.example', _hqPack: 'hq-pack-B' },
1920
- },
1921
- };
1922
- const original = JSON.stringify(doc, null, 2) + '\n';
1923
- fs.writeFileSync(claudePath(), original);
1924
-
1925
- // Uninstall pack A, which declares a `vyg` server — but the on-disk `vyg` is the
1926
- // user's, so it is NOT ours to remove.
1927
- const res = unregisterMcpServers('hq-pack-A', ['vyg'], { env });
1928
- expect(res[0]!.claude.outcome).toBe('skipped-foreign'); // warn, never removed
1929
- expect(res[0]!.claude.changed).toBe(false);
1930
- expect(res[0]!.claude.reason).toMatch(/not owned by pack/);
1931
-
1932
- // The whole file is untouched byte-for-byte — the user's vyg and B's `other` survive.
1933
- expect(fs.readFileSync(claudePath(), 'utf-8')).toBe(original);
1934
- });
1935
-
1936
- it('e2e (AC3 backup proof) — a real removal goes through writeConfigAtomic: a backup dir appears under ~/.hq/backups/mcp/', () => {
1937
- // AC3: removal must go through the safe-write core, which takes a backup BEFORE the
1938
- // write. We prove the removal PATH produced a backup (not re-testing the core's
1939
- // internals — US-006 owns those) by asserting a per-pack backup dir materializes.
1940
- seedClaudeWithFigmaPaper();
1941
- registerClaudeServer({ name: 'vyg', manifest: { type: 'http', url: 'https://vyg' }, pack: 'hq-pack-vyg', env });
1942
-
1943
- const backupRoot = path.join(home, '.hq', 'backups', 'mcp');
1944
- const res = unregisterServer({ name: 'vyg', pack: 'hq-pack-vyg', env });
1945
- expect(res.claude.outcome).toBe('removed');
1946
-
1947
- // A backup directory now exists under ~/.hq/backups/mcp/, stamped with the pack.
1948
- expect(fs.existsSync(backupRoot)).toBe(true);
1949
- const backups = fs.readdirSync(backupRoot);
1950
- expect(backups.length).toBeGreaterThan(0);
1951
- // At least one backup dir is attributed to THIS pack (proves it went through the
1952
- // safe-write core's backup step on the removal write).
1953
- expect(backups.some((b) => b.endsWith('-hq-pack-vyg'))).toBe(true);
1954
- // The backup captured the PRE-removal bytes (vyg still present in the backed-up copy).
1955
- const packBackup = backups.find((b) => b.endsWith('-hq-pack-vyg'))!;
1956
- const backedUp = JSON.parse(
1957
- fs.readFileSync(path.join(backupRoot, packBackup, '.claude.json'), 'utf-8'),
1958
- );
1959
- expect(backedUp.mcpServers.vyg).toBeDefined(); // backup is the pre-write state
1960
- });
1961
-
1962
- it('e2e (idempotency) — uninstall twice: the second run is a clean absent no-op that writes nothing new', () => {
1963
- seedClaudeWithFigmaPaper();
1964
- registerClaudeServer({ name: 'vyg', manifest: { type: 'http', url: 'https://vyg' }, pack: 'hq-pack-vyg', env });
1965
-
1966
- // First uninstall removes our entry.
1967
- const first = unregisterMcpServers('hq-pack-vyg', ['vyg'], { env });
1968
- expect(first[0]!.claude.outcome).toBe('removed');
1969
- const afterFirst = fs.readFileSync(claudePath(), 'utf-8');
1970
-
1971
- // Second uninstall: server already gone → absent, changed:false, byte-for-byte equal.
1972
- const second = unregisterMcpServers('hq-pack-vyg', ['vyg'], { env });
1973
- expect(second[0]!.claude.outcome).toBe('absent');
1974
- expect(second[0]!.claude.changed).toBe(false);
1975
- expect(fs.readFileSync(claudePath(), 'utf-8')).toBe(afterFirst); // nothing new written
1976
- });
1977
-
1978
- it('e2e (Codex TOML surgical) — removes the pack table; sibling server + its .tools.<t>.approval_mode survive (DATA preserved)', () => {
1979
- // The Codex mirror of e2e #1: seed config.toml with the user's superhuman + node_repl
1980
- // (node_repl carries two .tools.<t>.approval_mode sub-tables), install OUR vyg, then
1981
- // uninstall. Our table must go; the OTHER servers' tables + approval_mode sub-tables
1982
- // must survive as DATA (comments may be dropped on a real removal — documented limit).
1983
- seedCodexSuperhumanNodeRepl();
1984
- registerCodexServer({ name: 'vyg', manifest: { type: 'http', url: 'https://vyg' }, pack: 'hq-pack-vyg', env });
1985
- expect((parseToml(fs.readFileSync(codexPath(), 'utf-8')) as any).mcp_servers.vyg._hqPack).toBe('hq-pack-vyg');
1986
-
1987
- const res = unregisterServer({ name: 'vyg', pack: 'hq-pack-vyg', env });
1988
- expect('skipped' in res.codex).toBe(false);
1989
- expect((res.codex as { outcome: string }).outcome).toBe('removed');
1990
-
1991
- const after = parseToml(fs.readFileSync(codexPath(), 'utf-8')) as any;
1992
- expect(after.mcp_servers.vyg).toBeUndefined(); // ours removed
1993
- // Sibling server + its .env sub-table preserved as DATA.
1994
- expect(after.mcp_servers.superhuman.command).toBe('npx');
1995
- expect(after.mcp_servers.superhuman.env.SUPERHUMAN_TOKEN).toBe('tok-abc');
1996
- // The OTHER server's .tools.<t>.approval_mode sub-tables survive byte-for-byte (DATA).
1997
- expect(after.mcp_servers.node_repl.tools.execute.approval_mode).toBe('always');
1998
- expect(after.mcp_servers.node_repl.tools.read.approval_mode).toBe('never');
1999
- });
2000
-
2001
- it('e2e (Codex TOML no-op) — a foreign/absent removal leaves config.toml byte-for-byte (comments incl.)', () => {
2002
- // The documented-limitation flip side of the surgical test: a NO-OP removal (the
2003
- // server is foreign-stamped, so NOT ours) takes NO write, so the original bytes —
2004
- // INCLUDING comments — are preserved exactly. (Only a REAL removal drops comments.)
2005
- mkCodexHome();
2006
- const original =
2007
- '# hand-edited Codex config — comments MUST survive a no-op uninstall\n' +
2008
- 'model = "gpt-5"\n\n' +
2009
- '[mcp_servers.vyg]\n' +
2010
- 'type = "http"\n' +
2011
- 'url = "https://vyg"\n' +
2012
- '_hqPack = "hq-pack-OTHER"\n'; // owned by a DIFFERENT pack
2013
- fs.writeFileSync(codexPath(), original);
2014
-
2015
- // Uninstall pack A: the on-disk vyg is foreign → skip-and-warn, no write.
2016
- const res = unregisterServer({ name: 'vyg', pack: 'hq-pack-A', env });
2017
- expect('skipped' in res.codex).toBe(false);
2018
- expect((res.codex as { outcome: string }).outcome).toBe('skipped-foreign');
2019
- // Byte-for-byte preserved, comments intact.
2020
- expect(fs.readFileSync(codexPath(), 'utf-8')).toBe(original);
2021
- });
2022
-
2023
- it('e2e #2 — uninstall on a Codex-less host: Claude entry removed, Codex skipped cleanly (no crash, no fabricated ~/.codex)', () => {
2024
- // No mkCodexHome() — ~/.codex is absent.
2025
- registerClaudeServer({ name: 'vyg', manifest: VYG_MANIFEST, pack: 'hq-pack-vyg', resolveSecret: () => 'k', env });
2026
-
2027
- const res = unregisterServer({ name: 'vyg', pack: 'hq-pack-vyg', env });
2028
- // Claude entry removed.
2029
- expect(res.claude.outcome).toBe('removed');
2030
- expect(JSON.parse(fs.readFileSync(claudePath(), 'utf-8')).mcpServers.vyg).toBeUndefined();
2031
- // Codex skipped cleanly; ~/.codex NOT fabricated.
2032
- expect('skipped' in res.codex && res.codex.skipped).toBe(true);
2033
- expect(fs.existsSync(codexHome(env))).toBe(false);
2034
- expect(fs.existsSync(codexPath())).toBe(false);
2035
- });
2036
- });
2037
-
2038
- // ===========================================================================
2039
- // US-013 — REGRESSION SUITE: the unhappy paths (the M1 gate).
2040
- //
2041
- // US-006..US-012 shipped the dual-runtime MCP registration substrate with
2042
- // HAPPY-path coverage. US-013 adds the FAILURE-PATH coverage the review demanded:
2043
- // crash/partial-write atomicity, refuse-don't-clobber on malformed config, name
2044
- // injection, symlink+0600 preservation, the SECRET-SENTINEL no-leak grep (incl.
2045
- // the CONFIRMED smol-toml ConfigParseError source-context leak — found + fixed),
2046
- // Codex-absent host, per-surface heal after a partial register, old-CLI/new-host
2047
- // skew, version-floor refusal, and the install prompt content assertion.
2048
- //
2049
- // CRITICAL TEST SAFETY (unchanged): every test runs against the isolated tmpdir
2050
- // `home` via the shared `env: { home }` injection and the beforeEach/afterEach
2051
- // sandbox — NOTHING here touches the developer's real ~/.claude.json,
2052
- // ~/.mcp.json, or ~/.codex/config.toml.
2053
- //
2054
- // SENTINEL CHOICE: header secrets use `HQ_SECRET_SENTINEL_*` plaintexts that do
2055
- // NOT match common secret-scanner patterns (NOT `sk-...`, NOT a `Bearer `-prefixed
2056
- // token) so the pre-commit detect-secrets hook does not flag the test fixtures.
2057
- // ===========================================================================
2058
-
2059
- /** A secret-scanner-safe sentinel for the value being REGISTERED in this op. */
2060
- const SENTINEL_REGISTERING = 'HQ_SECRET_SENTINEL_d4e5f6a7b8c9';
2061
- /** A DISTINCT sentinel standing in for a PRE-EXISTING on-disk secret (the leak driver). */
2062
- const SENTINEL_PREEXISTING = 'HQ_SECRET_SENTINEL_PREEXISTING_9a8b7c6d5e4f';
2063
-
2064
- /** A manifest whose Authorization header resolves to the registering sentinel. */
2065
- const SENTINEL_MANIFEST: McpManifest = {
2066
- type: 'http',
2067
- url: 'https://mcp.vyg.app',
2068
- // The `${secret:}` ref resolves to SENTINEL_REGISTERING via the injected resolver.
2069
- headers: { Authorization: 'Token ${secret:VYG_API_KEY}' },
2070
- };
2071
-
2072
- /** Resolve VYG_API_KEY -> the registering sentinel; everything else -> null. */
2073
- const sentinelResolver: SecretResolver = (n) =>
2074
- n === 'VYG_API_KEY' ? SENTINEL_REGISTERING : null;
2075
-
2076
- // ---------------------------------------------------------------------------
2077
- // Criterion 1 — Crash/partial-write atomicity per file (kill-mid-write, ENOSPC).
2078
- //
2079
- // Inject a write failure DURING the atomic write for BOTH the Claude (JSON) and
2080
- // Codex (TOML) surfaces and assert: (a) the ORIGINAL file is intact byte-for-byte,
2081
- // (b) a backup exists under ~/.hq/backups/mcp/. We mock the fs layer (rename /
2082
- // fsync / write) with vi.spyOn — we NEVER actually fill the disk.
2083
- // ---------------------------------------------------------------------------
2084
-
2085
- describe('US-013: crash/partial-write atomicity (ENOSPC mid-write) — Claude surface', () => {
2086
- afterEach(() => {
2087
- __fsFault.failRenameSuffix = '';
2088
- __fsFault.failFirstFsync = false;
2089
- });
2090
-
2091
- it('a rename failure mid-write leaves ~/.claude.json byte-for-byte intact + a backup exists', () => {
2092
- const original = seedClaudeWithFigmaPaper();
2093
- const beforeBytes = fs.readFileSync(original, 'utf-8');
2094
-
2095
- // Inject an ENOSPC at the atomic rename (the commit step of atomicReplace).
2096
- __fsFault.failRenameSuffix = '.claude.json';
2097
- try {
2098
- expect(() =>
2099
- registerClaudeServer({
2100
- name: 'vyg',
2101
- manifest: SENTINEL_MANIFEST,
2102
- pack: 'hq-pack-vyg',
2103
- resolveSecret: sentinelResolver,
2104
- env,
2105
- stamp: '2026-06-19T00-00-00Z',
2106
- }),
2107
- ).toThrow(); // the injected ENOSPC propagates (the write could not commit).
2108
- } finally {
2109
- __fsFault.failRenameSuffix = '';
2110
- }
2111
-
2112
- // ORIGINAL preserved byte-for-byte — the crash left the file wholly intact.
2113
- expect(fs.readFileSync(original, 'utf-8')).toBe(beforeBytes);
2114
- // The pre-write backup exists under ~/.hq/backups/mcp/.
2115
- const backupDir = path.join(backupRoot(env), '2026-06-19T00-00-00Z-hq-pack-vyg');
2116
- expect(fs.existsSync(backupDir)).toBe(true);
2117
- expect(fs.readFileSync(path.join(backupDir, '.claude.json'), 'utf-8')).toBe(beforeBytes);
2118
- // No temp litter left behind by the failed write.
2119
- expect(fs.readdirSync(home).filter((n) => n.includes('.tmp.'))).toEqual([]);
2120
- });
2121
-
2122
- it('an fsync failure mid-write also aborts cleanly with the original intact', () => {
2123
- const original = seedClaudeWithFigmaPaper();
2124
- const beforeBytes = fs.readFileSync(original, 'utf-8');
2125
-
2126
- // Fail at the durability fsync of the temp file (before the rename commits).
2127
- __fsFault.failFirstFsync = true;
2128
- try {
2129
- expect(() =>
2130
- registerClaudeServer({ name: 'vyg', manifest: SENTINEL_MANIFEST, pack: 'p', resolveSecret: sentinelResolver, env }),
2131
- ).toThrow();
2132
- } finally {
2133
- __fsFault.failFirstFsync = false;
2134
- }
2135
-
2136
- // Original wholly intact; no torn write.
2137
- expect(fs.readFileSync(original, 'utf-8')).toBe(beforeBytes);
2138
- expect(fs.readdirSync(home).filter((n) => n.includes('.tmp.'))).toEqual([]);
2139
- });
2140
- });
2141
-
2142
- describe('US-013: crash/partial-write atomicity (ENOSPC mid-write) — Codex surface', () => {
2143
- afterEach(() => {
2144
- __fsFault.failRenameSuffix = '';
2145
- });
2146
-
2147
- it('a rename failure mid-write leaves ~/.codex/config.toml byte-for-byte intact + a backup exists', () => {
2148
- const target = seedCodexSuperhumanNodeRepl();
2149
- const beforeBytes = fs.readFileSync(target, 'utf-8');
2150
-
2151
- __fsFault.failRenameSuffix = 'config.toml';
2152
- try {
2153
- expect(() =>
2154
- registerCodexServer({
2155
- name: 'vyg',
2156
- manifest: SENTINEL_MANIFEST,
2157
- pack: 'hq-pack-vyg',
2158
- resolveSecret: sentinelResolver,
2159
- env,
2160
- stamp: '2026-06-19T00-00-00Z',
2161
- }),
2162
- ).toThrow();
2163
- } finally {
2164
- __fsFault.failRenameSuffix = '';
2165
- }
2166
-
2167
- // ORIGINAL config.toml preserved byte-for-byte (comments + every table).
2168
- expect(fs.readFileSync(target, 'utf-8')).toBe(beforeBytes);
2169
- const backupDir = path.join(backupRoot(env), '2026-06-19T00-00-00Z-hq-pack-vyg');
2170
- expect(fs.existsSync(backupDir)).toBe(true);
2171
- expect(fs.readFileSync(path.join(backupDir, 'config.toml'), 'utf-8')).toBe(beforeBytes);
2172
- expect(fs.readdirSync(codexHome(env)).filter((n) => n.includes('.tmp.'))).toEqual([]);
2173
- });
2174
- });
2175
-
2176
- // ---------------------------------------------------------------------------
2177
- // Criterion 2 — Malformed existing config -> refuse-don't-clobber (BOTH surfaces).
2178
- //
2179
- // A non-empty UNPARSEABLE ~/.claude.json AND a non-empty UNPARSEABLE
2180
- // ~/.codex/config.toml each cause ConfigParseError and the ORIGINAL bytes are
2181
- // preserved (nothing written, never regenerated-from-template).
2182
- // ---------------------------------------------------------------------------
2183
-
2184
- describe('US-013: malformed existing config -> refuse-don\'t-clobber', () => {
2185
- it('Claude: an unparseable ~/.claude.json raises ConfigParseError and is preserved byte-for-byte', () => {
2186
- const f = claudePath();
2187
- const malformed = '{ "mcpServers": { "figma": <-- hand-broken, not JSON\n';
2188
- fs.writeFileSync(f, malformed);
2189
-
2190
- expect(() =>
2191
- registerClaudeServer({ name: 'vyg', manifest: VYG_MANIFEST, pack: 'p', resolveSecret: () => 'k', env }),
2192
- ).toThrow(ConfigParseError);
2193
-
2194
- // Untouched — never regenerated from a template, never clobbered.
2195
- expect(fs.readFileSync(f, 'utf-8')).toBe(malformed);
2196
- });
2197
-
2198
- it('Codex: an unparseable ~/.codex/config.toml raises ConfigParseError and is preserved byte-for-byte', () => {
2199
- mkCodexHome();
2200
- const f = codexPath();
2201
- const malformed = 'this is = = not [[[ valid toml at all\n';
2202
- fs.writeFileSync(f, malformed);
2203
-
2204
- expect(() =>
2205
- registerCodexServer({ name: 'vyg', manifest: VYG_MANIFEST, pack: 'p', resolveSecret: () => 'k', env }),
2206
- ).toThrow(ConfigParseError);
2207
-
2208
- expect(fs.readFileSync(f, 'utf-8')).toBe(malformed);
2209
- });
2210
-
2211
- it('the register fan-out aborts the WHOLE op on a malformed Claude config (no Codex write either)', () => {
2212
- mkCodexHome(); // Codex present, but Claude is emitted FIRST and aborts.
2213
- const cf = claudePath();
2214
- const malformed = 'not json {{{';
2215
- fs.writeFileSync(cf, malformed);
2216
-
2217
- expect(() =>
2218
- registerServer({ name: 'vyg', manifest: VYG_MANIFEST, pack: 'p', resolveSecret: () => 'k', env }),
2219
- ).toThrow(ConfigParseError);
2220
-
2221
- // Claude untouched; Codex config never created (the abort came before the Codex arm).
2222
- expect(fs.readFileSync(cf, 'utf-8')).toBe(malformed);
2223
- expect(fs.existsSync(codexPath())).toBe(false);
2224
- });
2225
- });
2226
-
2227
- // ---------------------------------------------------------------------------
2228
- // Criterion 3 — Server-name TOML-header / JSON-key INJECTION.
2229
- //
2230
- // Names carrying injection payloads must be REJECTED by assertValidServerName
2231
- // (charset ^[a-z0-9_-]+$) BEFORE any interpolation/IO, and a rejected name must
2232
- // write NOTHING to either config.
2233
- // ---------------------------------------------------------------------------
2234
-
2235
- describe('US-013: server-name injection is rejected before any interpolation/IO', () => {
2236
- const INJECTION_NAMES = [
2237
- 'evil"]\n[mcp_servers.injected', // TOML table-header injection
2238
- 'a.b', // dotted key (TOML sub-table / JSON traversal)
2239
- 'x][y', // bracket injection
2240
- '"; rm -rf', // shell-ish + quote
2241
- 'a\nb', // newline
2242
- 'a]b',
2243
- 'a[b',
2244
- 'a"b',
2245
- 'a=b',
2246
- 'has space',
2247
- 'UPPER',
2248
- '__proto__', // prototype-pollution-shaped (also has no dots, but uppercase-free; still — underscores allowed, so this one IS valid charset-wise)
2249
- ];
2250
-
2251
- it('assertValidServerName rejects every injection payload (charset gate)', () => {
2252
- for (const n of INJECTION_NAMES) {
2253
- if (n === '__proto__') {
2254
- // `__proto__` is charset-valid (lowercase + underscores) — it is NOT an
2255
- // injection at the NAME-charset layer. Documented here so the list is honest:
2256
- // the JSON-key safety for `__proto__` is handled by object-spread merge
2257
- // semantics, not the name gate. Assert it PASSES the charset gate.
2258
- expect(() => assertValidServerName(n)).not.toThrow();
2259
- continue;
2260
- }
2261
- expect(() => assertValidServerName(n)).toThrow(McpManifestError);
2262
- }
2263
- });
2264
-
2265
- it('a TOML-header-injection name writes NOTHING to either config (Codex present)', () => {
2266
- mkCodexHome();
2267
- const evil = 'evil"]\n[mcp_servers.injected';
2268
- expect(() =>
2269
- registerServer({ name: evil, manifest: VYG_MANIFEST, pack: 'p', resolveSecret: () => 'k', env }),
2270
- ).toThrow(McpManifestError);
2271
-
2272
- // Neither config created, no backup taken — the gate is BEFORE all IO.
2273
- expect(fs.existsSync(claudePath())).toBe(false);
2274
- expect(fs.existsSync(codexPath())).toBe(false);
2275
- expect(fs.existsSync(backupRoot(env))).toBe(false);
2276
- });
2277
-
2278
- it('registerMcpServers rejects an injection name at the seam (before loadManifest is even called for it)', () => {
2279
- let loaded = false;
2280
- expect(() =>
2281
- registerMcpServers('p', ['x][y'], {
2282
- loadManifest: () => {
2283
- loaded = true;
2284
- return VYG_MANIFEST;
2285
- },
2286
- env,
2287
- }),
2288
- ).toThrow(McpManifestError);
2289
- expect(loaded).toBe(false); // name validated before the manifest is loaded.
2290
- expect(fs.existsSync(claudePath())).toBe(false);
2291
- });
2292
- });
2293
-
2294
- // ---------------------------------------------------------------------------
2295
- // Criterion 4 — ~/.mcp.json symlink + 0600 preservation.
2296
- //
2297
- // Seed ~/.mcp.json as a SYMLINK to a real file, register, and assert: (a) the
2298
- // rename lands on the REALPATH target (the symlink stays a symlink), (b) the
2299
- // resolved file's mode stays 0600.
2300
- // ---------------------------------------------------------------------------
2301
-
2302
- describe('US-013: ~/.mcp.json symlink + 0600 mode preservation', () => {
2303
- it('registers THROUGH the symlink onto the realpath and keeps the link a symlink at 0600', () => {
2304
- const realFile = path.join(home, '.mcp.real.json');
2305
- const link = path.join(home, '.mcp.json');
2306
- fs.writeFileSync(
2307
- realFile,
2308
- JSON.stringify({ mcpServers: { figma: { type: 'http', url: 'http://localhost:3845/mcp' } } }, null, 2) + '\n',
2309
- { mode: 0o600 },
2310
- );
2311
- fs.chmodSync(realFile, 0o600);
2312
- fs.symlinkSync(realFile, link);
2313
-
2314
- writeConfigAtomic({
2315
- target: link,
2316
- pack: 'hq-pack-test',
2317
- format: claudeFormat,
2318
- merge: mergeServer('vyg', { type: 'http', url: 'https://mcp.vyg.app' }),
2319
- assertName: 'vyg',
2320
- env,
2321
- });
2322
-
2323
- // (a) The link is STILL a symlink (the rename did NOT replace it with a file).
2324
- expect(fs.lstatSync(link).isSymbolicLink()).toBe(true);
2325
- expect(fs.realpathSync(link)).toBe(realFile);
2326
- // The resolved file carries both servers (merge landed on the realpath).
2327
- const after = JSON.parse(fs.readFileSync(realFile, 'utf-8'));
2328
- expect(after.mcpServers.figma).toBeDefined();
2329
- expect(after.mcpServers.vyg).toEqual({ type: 'http', url: 'https://mcp.vyg.app' });
2330
- // (b) The RESOLVED file's mode is preserved at 0600 after the atomic replace.
2331
- expect(fs.statSync(realFile).mode & 0o777).toBe(0o600);
2332
- });
2333
- });
2334
-
2335
- // ---------------------------------------------------------------------------
2336
- // Criterion 5 — header-value-NEVER-in-stdout: the SECRET-SENTINEL grep test, and
2337
- // the CONFIRMED smol-toml ConfigParseError source-context leak (found + FIXED).
2338
- //
2339
- // THE most important test. A header resolves to a known SENTINEL plaintext. We
2340
- // capture EVERYTHING (return value, error message, audit log, stdout, stderr) and
2341
- // assert the sentinel appears in EXACTLY ONE place: the runtime config files (the
2342
- // documented accepted cleartext-at-rest risk). It must NEVER appear in a return
2343
- // value, an error message, the audit log, stdout, or stderr.
2344
- // ---------------------------------------------------------------------------
2345
-
2346
- /** Run `fn`, capturing all stdout/stderr/console output as one string. */
2347
- function captureAllOutput(fn: () => void): { out: string; thrown: Error | null } {
2348
- const chunks: string[] = [];
2349
- const sink = (c: unknown): boolean => {
2350
- chunks.push(typeof c === 'string' ? c : String(c));
2351
- return true;
2352
- };
2353
- const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(sink as never);
2354
- const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(sink as never);
2355
- const logSpy = vi.spyOn(console, 'log').mockImplementation((...a) => chunks.push(a.join(' ')));
2356
- const errSpy = vi.spyOn(console, 'error').mockImplementation((...a) => chunks.push(a.join(' ')));
2357
- const warnSpy = vi.spyOn(console, 'warn').mockImplementation((...a) => chunks.push(a.join(' ')));
2358
- let thrown: Error | null = null;
2359
- try {
2360
- fn();
2361
- } catch (e) {
2362
- thrown = e as Error;
2363
- } finally {
2364
- stdoutSpy.mockRestore();
2365
- stderrSpy.mockRestore();
2366
- logSpy.mockRestore();
2367
- errSpy.mockRestore();
2368
- warnSpy.mockRestore();
2369
- }
2370
- return { out: chunks.join(''), thrown };
2371
- }
2372
-
2373
- describe('US-013: secret sentinel appears ONLY in the runtime config, never in output/logs/errors', () => {
2374
- it('register (Claude + Codex): sentinel ONLY in config files; absent from return, audit log, stdout, stderr', () => {
2375
- mkCodexHome();
2376
- let result: ReturnType<typeof registerServer> | undefined;
2377
- const { out, thrown } = captureAllOutput(() => {
2378
- result = registerServer({
2379
- name: 'vyg',
2380
- manifest: SENTINEL_MANIFEST,
2381
- pack: 'hq-pack-vyg',
2382
- resolveSecret: sentinelResolver,
2383
- env,
2384
- });
2385
- });
2386
- expect(thrown).toBeNull();
2387
-
2388
- // 1. The sentinel IS in the runtime config files (accepted cleartext-at-rest).
2389
- const claudeRaw = fs.readFileSync(claudePath(), 'utf-8');
2390
- const codexRaw = fs.readFileSync(codexPath(), 'utf-8');
2391
- expect(claudeRaw).toContain(SENTINEL_REGISTERING);
2392
- expect(codexRaw).toContain(SENTINEL_REGISTERING);
2393
-
2394
- // 2. The sentinel is NOT in the return value (JSON-serialized).
2395
- expect(JSON.stringify(result)).not.toContain(SENTINEL_REGISTERING);
2396
-
2397
- // 3. The sentinel is NOT in the audit log.
2398
- const log = fs.readFileSync(mcpRegistryLogPath(env), 'utf-8');
2399
- expect(log).not.toContain(SENTINEL_REGISTERING);
2400
- // Sanity: the audit log DID record the register (target is the url, not a header).
2401
- expect(log).toContain('https://mcp.vyg.app');
2402
-
2403
- // 4. The sentinel is NOT in ANY captured stdout/stderr/console output.
2404
- expect(out).not.toContain(SENTINEL_REGISTERING);
2405
- });
2406
-
2407
- it('a verify-after-write failure error message is redacted of the registering sentinel', () => {
2408
- // Force verify-after-write to fail by poisoning the verify re-read so it returns
2409
- // garbage that EMBEDS the registering sentinel. A naive verify-error echo would
2410
- // leak it; registerCodexServer's catch runs redactSecrets(secretSink), and the
2411
- // registering sentinel IS in secretSink, so it must be scrubbed from message+out.
2412
- mkCodexHome();
2413
- __fsFault.writeSeen = false;
2414
- __fsFault.poisonReadSuffix = 'config.toml';
2415
- __fsFault.poisonReadBytes = `garbage ${SENTINEL_REGISTERING} not real toml = =`;
2416
- let thrown: Error | null = null;
2417
- let out = '';
2418
- try {
2419
- const cap = captureAllOutput(() => {
2420
- registerCodexServer({
2421
- name: 'vyg',
2422
- manifest: SENTINEL_MANIFEST,
2423
- pack: 'p',
2424
- resolveSecret: sentinelResolver,
2425
- env,
2426
- });
2427
- });
2428
- thrown = cap.thrown;
2429
- out = cap.out;
2430
- } finally {
2431
- __fsFault.poisonReadSuffix = '';
2432
- __fsFault.poisonReadBytes = '';
2433
- __fsFault.writeSeen = false;
2434
- }
2435
-
2436
- // It threw (verify failed on the poisoned re-read) and the message is redacted.
2437
- expect(thrown).not.toBeNull();
2438
- expect(thrown!.message).not.toContain(SENTINEL_REGISTERING);
2439
- expect(out).not.toContain(SENTINEL_REGISTERING);
2440
- });
2441
- });
2442
-
2443
- describe('US-013 ADVERSARIAL: smol-toml ConfigParseError source-context leak (FOUND + FIXED)', () => {
2444
- it('sanitizeParserMessage strips the smol-toml numbered source-context snippet', () => {
2445
- // The exact shape smol-toml emits: a structural reason, a blank line, then
2446
- // numbered source lines (which embed file bytes incl. secrets) + a caret.
2447
- const raw =
2448
- 'Invalid TOML document: only letter, numbers, dashes and underscores are allowed in keys\n\n' +
2449
- `3: token = "${SENTINEL_PREEXISTING}"\n` +
2450
- '4: broken line here = = [[[ not toml\n' +
2451
- ' ^\n';
2452
- const sanitized = sanitizeParserMessage(raw);
2453
- // The structural reason survives; the secret-bearing source snippet does NOT.
2454
- expect(sanitized).toContain('Invalid TOML document');
2455
- expect(sanitized).not.toContain(SENTINEL_PREEXISTING);
2456
- expect(sanitized).not.toMatch(/^\s*\d+:\s/m); // no numbered source line survived.
2457
- });
2458
-
2459
- it('registerCodexServer on a malformed-but-SECRET-bearing config does NOT leak the pre-existing on-disk secret', () => {
2460
- // Seed ~/.codex/config.toml with a PRE-EXISTING secret on a line, and make the
2461
- // file malformed NEAR it so smol-toml's error embeds that source line. The
2462
- // pre-existing secret is a DISTINCT sentinel from the one being registered, so
2463
- // the register-path redactSecrets(secretSink) CANNOT scrub it (secretSink holds
2464
- // only the currently-registering secret). Before the fix this leaked; now the
2465
- // ConfigParseError message is sanitized at the construction site.
2466
- mkCodexHome();
2467
- const malformedWithSecret =
2468
- '[mcp_servers.existing]\n' +
2469
- 'type = "http"\n' +
2470
- `token = "${SENTINEL_PREEXISTING}"\n` +
2471
- 'broken line here = = [[[ not toml\n';
2472
- fs.writeFileSync(codexPath(), malformedWithSecret);
2473
-
2474
- let caught: Error | null = null;
2475
- const { out } = captureAllOutput(() => {
2476
- try {
2477
- registerCodexServer({
2478
- name: 'vyg',
2479
- manifest: SENTINEL_MANIFEST,
2480
- pack: 'p',
2481
- resolveSecret: sentinelResolver,
2482
- env,
2483
- });
2484
- } catch (e) {
2485
- caught = e as Error;
2486
- }
2487
- });
2488
-
2489
- // It correctly ABORTED with the named ConfigParseError (never clobbered).
2490
- expect(caught).toBeInstanceOf(ConfigParseError);
2491
- // THE LEAK ASSERTION: the pre-existing on-disk secret must NOT appear in the
2492
- // error message (nor in any emitted output).
2493
- expect(caught!.message).not.toContain(SENTINEL_PREEXISTING);
2494
- expect(out).not.toContain(SENTINEL_PREEXISTING);
2495
- // The structural reason is still useful (it names the parse failure).
2496
- expect(caught!.message).toMatch(/not valid and would be clobbered/);
2497
- // The file is preserved byte-for-byte (refuse-don't-clobber still holds).
2498
- expect(fs.readFileSync(codexPath(), 'utf-8')).toBe(malformedWithSecret);
2499
- });
2500
-
2501
- it('CONTRAST — JSON.parse errors never embed file content (Claude surface was already safe)', () => {
2502
- // Documents the asymmetry the fix is scoped to: JSON.parse does NOT echo source
2503
- // bytes, so a malformed-but-secret-bearing ~/.claude.json never leaked. We still
2504
- // assert it to lock the behavior.
2505
- const malformedWithSecret =
2506
- `{ "mcpServers": { "existing": { "token": "${SENTINEL_PREEXISTING}" } } broken`;
2507
- fs.writeFileSync(claudePath(), malformedWithSecret);
2508
- let caught: Error | null = null;
2509
- try {
2510
- registerClaudeServer({ name: 'vyg', manifest: SENTINEL_MANIFEST, pack: 'p', resolveSecret: sentinelResolver, env });
2511
- } catch (e) {
2512
- caught = e as Error;
2513
- }
2514
- expect(caught).toBeInstanceOf(ConfigParseError);
2515
- expect(caught!.message).not.toContain(SENTINEL_PREEXISTING);
2516
- });
2517
- });
2518
-
2519
- // ---------------------------------------------------------------------------
2520
- // Criterion 6 — Codex-absent host (register + uninstall).
2521
- //
2522
- // No ~/.codex dir -> register wires Claude and FIRST-CLASS-SKIPS Codex (no crash,
2523
- // no fabrication); unregister likewise removes Claude and skips Codex. ~/.codex is
2524
- // never created.
2525
- // ---------------------------------------------------------------------------
2526
-
2527
- describe('US-013: Codex-absent host first-class skip (register + uninstall, never fabricates ~/.codex)', () => {
2528
- it('register: Claude wired, Codex skipped, ~/.codex never created', () => {
2529
- const result = registerServer({
2530
- name: 'vyg',
2531
- manifest: SENTINEL_MANIFEST,
2532
- pack: 'hq-pack-vyg',
2533
- resolveSecret: sentinelResolver,
2534
- env,
2535
- });
2536
- expect(result.claude.changed).toBe(true);
2537
- expect('skipped' in result.codex && result.codex.skipped).toBe(true);
2538
- expect(fs.existsSync(codexHome(env))).toBe(false);
2539
- expect(fs.existsSync(codexPath())).toBe(false);
2540
- });
2541
-
2542
- it('uninstall: Claude removed, Codex skipped, ~/.codex still never created', () => {
2543
- registerServer({ name: 'vyg', manifest: SENTINEL_MANIFEST, pack: 'hq-pack-vyg', resolveSecret: sentinelResolver, env });
2544
- expect(fs.existsSync(codexHome(env))).toBe(false); // confirm still absent post-register.
2545
-
2546
- const res = unregisterServer({ name: 'vyg', pack: 'hq-pack-vyg', env });
2547
- expect(res.claude.outcome).toBe('removed');
2548
- expect('skipped' in res.codex && res.codex.skipped).toBe(true);
2549
- expect(fs.existsSync(codexHome(env))).toBe(false);
2550
- expect(fs.existsSync(codexPath())).toBe(false);
2551
- });
2552
- });
2553
-
2554
- // ---------------------------------------------------------------------------
2555
- // Criterion 7 — partial state (Claude written, Codex emit threw) -> per-surface
2556
- // heal on re-run. Drive a partial state, re-run, assert ONLY the missing Codex
2557
- // surface is healed (Claude entry unchanged, no duplicate).
2558
- // ---------------------------------------------------------------------------
2559
-
2560
- describe('US-013: per-surface heal after a partial register (Codex failed, Claude succeeded)', () => {
2561
- afterEach(() => {
2562
- __fsFault.failRenameSuffix = '';
2563
- });
2564
-
2565
- it('re-running after a Codex-only failure self-heals ONLY the Codex surface', () => {
2566
- mkCodexHome();
2567
- const args = {
2568
- name: 'vyg',
2569
- manifest: SENTINEL_MANIFEST,
2570
- pack: 'hq-pack-vyg',
2571
- resolveSecret: sentinelResolver,
2572
- env,
2573
- };
2574
-
2575
- // First run: make the CODEX write fail (rename throws) AFTER Claude is written.
2576
- // registerServer emits Claude first, then Codex — so Claude commits, Codex throws.
2577
- __fsFault.failRenameSuffix = 'config.toml';
2578
- expect(() => registerServer(args)).toThrow(); // Codex arm threw.
2579
- __fsFault.failRenameSuffix = '';
2580
-
2581
- // PARTIAL state: Claude has vyg, Codex does NOT.
2582
- const claudeAfter1 = JSON.parse(fs.readFileSync(claudePath(), 'utf-8'));
2583
- expect(claudeAfter1.mcpServers.vyg).toBeDefined();
2584
- expect(fs.existsSync(codexPath())).toBe(false);
2585
- const claudeBytesAfter1 = fs.readFileSync(claudePath(), 'utf-8');
2586
-
2587
- // Re-run with the Codex write now allowed to succeed (desired-state convergence).
2588
- const healed = registerServer(args);
2589
-
2590
- // Claude is an idempotent NO-OP (unchanged, no duplicate); Codex is now healed.
2591
- expect(healed.claude.changed).toBe(false);
2592
- expect(fs.readFileSync(claudePath(), 'utf-8')).toBe(claudeBytesAfter1);
2593
- expect('skipped' in healed.codex).toBe(false);
2594
- expect((healed.codex as { changed: boolean }).changed).toBe(true);
2595
- const codexDoc = parseToml(fs.readFileSync(codexPath(), 'utf-8')) as any;
2596
- expect(codexDoc.mcp_servers.vyg.url).toBe('https://mcp.vyg.app');
2597
- // Claude still has exactly ONE vyg entry (no duplication across the re-run).
2598
- expect(Object.keys(JSON.parse(fs.readFileSync(claudePath(), 'utf-8')).mcpServers)).toEqual(['vyg']);
2599
- });
2600
- });
2601
-
2602
- // ---------------------------------------------------------------------------
2603
- // Criterion 8 — old-CLI/new-host skew -> CLEAR named error (not a TypeError).
2604
- //
2605
- // A new-host config shape the old CLI can't read (an unknown `contributes` key the
2606
- // old CONTRIBUTION_TABLE doesn't have — e.g. a future `mcp`-adjacent key) must
2607
- // surface a CLEAR named error BEFORE the unguarded payloadFor() lookup that would
2608
- // otherwise TypeError on an undefined table row. The guard lives in
2609
- // validateManifest (pack-install) — `contributes.<key> is not a known contribution
2610
- // type` — which is reached before payloadFor.
2611
- // ---------------------------------------------------------------------------
2612
-
2613
- describe('US-013: old-CLI/new-host skew surfaces a clear error, never a raw TypeError', () => {
2614
- it('an unknown contributes key (new-host shape) is rejected with a clear named error before payloadFor TypeErrors', async () => {
2615
- const { validateManifest } = await import('./pack-install.js');
2616
- const { mkdtempSync, writeFileSync } = fs;
2617
- const dir = fs.realpathSync(mkdtempSync(path.join(os.tmpdir(), 'hq-skew-')));
2618
- try {
2619
- // A pack manifest that uses a contributes key this (old) CLI's table lacks.
2620
- writeFileSync(
2621
- path.join(dir, 'package.yaml'),
2622
- [
2623
- 'name: hq-pack-future',
2624
- 'version: 1.0.0',
2625
- 'publisher: "@acme"',
2626
- 'access: public',
2627
- 'requires:',
2628
- ' hqCore: ">=15.1.0"',
2629
- 'contributes:',
2630
- ' futuristic_surface:', // NOT in CONTRIBUTION_TABLE on this CLI
2631
- ' - thing',
2632
- ].join('\n'),
2633
- );
2634
- let caught: Error | null = null;
2635
- try {
2636
- validateManifest(dir, '15.1.0');
2637
- } catch (e) {
2638
- caught = e as Error;
2639
- }
2640
- expect(caught).not.toBeNull();
2641
- // It is a CLEAR, structural message — NOT a raw "Cannot read properties of
2642
- // undefined" TypeError from an unguarded table lookup.
2643
- expect(caught!.message).toMatch(/not a known contribution type/);
2644
- expect(caught!.message).not.toMatch(/Cannot read propert|is not a function|undefined is not/);
2645
- expect(caught).not.toBeInstanceOf(TypeError);
2646
- } finally {
2647
- fs.rmSync(dir, { recursive: true, force: true });
2648
- }
2649
- });
2650
- });
2651
-
2652
- // ---------------------------------------------------------------------------
2653
- // Criterion 9 — version-floor refusal (MCP pack on a too-old host).
2654
- //
2655
- // The US-012 floor lives in pack-install.test.ts (validateManifest `does not
2656
- // satisfy pack requirement`). We add ONE focused US-013 assertion here to keep the
2657
- // gate self-contained (NOT a wholesale duplicate): a `>15.0.16`/`>=15.1.0` pack on
2658
- // an older host is refused with a floor message, NOT a TypeError.
2659
- // ---------------------------------------------------------------------------
2660
-
2661
- describe('US-013: MCP-pack version-floor refusal on a too-old host (not a TypeError)', () => {
2662
- it('a pack requiring >=15.1.0 is refused on a 15.0.16 host with a clear floor message', async () => {
2663
- const { validateManifest } = await import('./pack-install.js');
2664
- const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hq-floor-')));
2665
- try {
2666
- // A minimal MCP-bearing pack with a real mcp manifest so validate gets that far.
2667
- fs.mkdirSync(path.join(dir, 'mcp'), { recursive: true });
2668
- fs.writeFileSync(
2669
- path.join(dir, 'mcp', 'vyg.json'),
2670
- JSON.stringify({ type: 'http', url: 'https://mcp.vyg.app', headers: { Authorization: 'Bearer ${secret:VYG_API_KEY}' } }),
2671
- );
2672
- fs.writeFileSync(
2673
- path.join(dir, 'package.yaml'),
2674
- [
2675
- 'name: hq-pack-vyg',
2676
- 'version: 1.0.0',
2677
- 'publisher: "@acme"',
2678
- 'access: public',
2679
- 'requires:',
2680
- ' hqCore: ">=15.1.0"',
2681
- 'contributes:',
2682
- ' mcp:',
2683
- ' - vyg',
2684
- ].join('\n'),
2685
- );
2686
- let caught: Error | null = null;
2687
- try {
2688
- validateManifest(dir, '15.0.16');
2689
- } catch (e) {
2690
- caught = e as Error;
2691
- }
2692
- expect(caught).not.toBeNull();
2693
- expect(caught!.message).toMatch(/does not satisfy pack requirement/);
2694
- expect(caught!.message).toMatch(/>=15\.1\.0/);
2695
- expect(caught).not.toBeInstanceOf(TypeError);
2696
- } finally {
2697
- fs.rmSync(dir, { recursive: true, force: true });
2698
- }
2699
- });
2700
- });
2701
-
2702
- // ---------------------------------------------------------------------------
2703
- // Criterion 10 — install-prompt content: the FULL url appears, NO secret appears.
2704
- //
2705
- // US-010 (pack-install renderMcpServerLine / confirmMcp) already covers this. We
2706
- // add a sentinel-grep variant against renderMcpServerLine to STRENGTHEN the gate:
2707
- // the rendered line shows the full url and never the resolved secret value.
2708
- // ---------------------------------------------------------------------------
2709
-
2710
- describe('US-013: install trust prompt shows the full url and never a secret (sentinel grep)', () => {
2711
- it('renderMcpServerLine discloses the url and redacts the header value, even a literal sentinel', async () => {
2712
- const { renderMcpServerLine } = await import('./pack-install.js');
2713
- const dir = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'hq-prompt-')));
2714
- try {
2715
- fs.mkdirSync(path.join(dir, 'mcp'), { recursive: true });
2716
- // A manifest with a LITERAL sentinel in the header value (worst case — even a
2717
- // non-${secret:} literal must be scrubbed by the defense-in-depth redaction).
2718
- fs.writeFileSync(
2719
- path.join(dir, 'mcp', 'vyg.json'),
2720
- JSON.stringify({
2721
- type: 'http',
2722
- url: 'https://mcp.vyg.app/sentinel-path',
2723
- headers: { Authorization: `Token ${SENTINEL_REGISTERING}` },
2724
- }),
2725
- );
2726
- const line = renderMcpServerLine(dir, 'vyg');
2727
- // FULL url disclosed verbatim (typosquat eyeballing).
2728
- expect(line).toContain('https://mcp.vyg.app/sentinel-path');
2729
- // The header KEY may show, but the sentinel VALUE must be redacted out.
2730
- expect(line).not.toContain(SENTINEL_REGISTERING);
2731
- } finally {
2732
- fs.rmSync(dir, { recursive: true, force: true });
2733
- }
2734
- });
2735
- });
2736
-
2737
- // ---------------------------------------------------------------------------
2738
- // PLUS — consolidated happy-path smoke (register->both, idempotent, collision,
2739
- // uninstall->both). The full per-story coverage lives in US-007/008/009; this is a
2740
- // single end-to-end smoke that the gate stays green, not a 100-line duplicate.
2741
- // ---------------------------------------------------------------------------
2742
-
2743
- describe('US-013: happy-path smoke (register->both runtimes, idempotent, collision, uninstall->both)', () => {
2744
- it('register fans out to both runtimes; re-install is a no-op; uninstall removes from both', () => {
2745
- mkCodexHome();
2746
- const args = {
2747
- name: 'vyg',
2748
- manifest: SENTINEL_MANIFEST,
2749
- pack: 'hq-pack-vyg',
2750
- resolveSecret: sentinelResolver,
2751
- env,
2752
- };
2753
-
2754
- // register -> both runtimes.
2755
- const r1 = registerServer(args);
2756
- expect(r1.claude.changed).toBe(true);
2757
- expect((r1.codex as { changed: boolean }).changed).toBe(true);
2758
- expect(JSON.parse(fs.readFileSync(claudePath(), 'utf-8')).mcpServers.vyg).toBeDefined();
2759
- expect((parseToml(fs.readFileSync(codexPath(), 'utf-8')) as any).mcp_servers.vyg).toBeDefined();
2760
-
2761
- // idempotent re-install -> byte-for-byte no-op on both.
2762
- const claudeBytes = fs.readFileSync(claudePath(), 'utf-8');
2763
- const codexBytes = fs.readFileSync(codexPath(), 'utf-8');
2764
- const r2 = registerServer(args);
2765
- expect(r2.claude.changed).toBe(false);
2766
- expect((r2.codex as { changed: boolean }).changed).toBe(false);
2767
- expect(fs.readFileSync(claudePath(), 'utf-8')).toBe(claudeBytes);
2768
- expect(fs.readFileSync(codexPath(), 'utf-8')).toBe(codexBytes);
2769
-
2770
- // collision (def differs) -> abort, no clobber.
2771
- expect(() =>
2772
- registerServer({
2773
- ...args,
2774
- manifest: { type: 'http', url: 'https://DIFFERENT.example' },
2775
- resolveSecret: () => null,
2776
- }),
2777
- ).toThrow(McpNameCollisionError);
2778
- expect(fs.readFileSync(claudePath(), 'utf-8')).toBe(claudeBytes); // unchanged.
2779
-
2780
- // uninstall -> removed from both.
2781
- const u = unregisterServer({ name: 'vyg', pack: 'hq-pack-vyg', env });
2782
- expect(u.claude.outcome).toBe('removed');
2783
- expect((u.codex as { outcome: string }).outcome).toBe('removed');
2784
- expect(JSON.parse(fs.readFileSync(claudePath(), 'utf-8')).mcpServers.vyg).toBeUndefined();
2785
- expect((parseToml(fs.readFileSync(codexPath(), 'utf-8')) as any).mcp_servers?.vyg).toBeUndefined();
2786
- });
2787
- });