@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.
- package/CHANGELOG.md +34 -0
- package/assets/scaffold/core/scripts/archive-old-threads.sh +86 -0
- package/assets/scaffold/core/scripts/backfill-company-skill-mirrors.sh +73 -0
- package/assets/scaffold/core/scripts/backfill-workspace-mirror.sh +51 -0
- package/assets/scaffold/core/scripts/codex-skill-bridge.sh +550 -0
- package/assets/scaffold/core/scripts/detect-stale-core-policy-mirror.sh +185 -0
- package/assets/scaffold/core/scripts/hq-status-summary.sh +187 -0
- package/assets/scaffold/core/scripts/ontology-readme-drift.sh +264 -0
- package/assets/scaffold/core/scripts/qmd-reindex-after-sync.sh +93 -0
- package/assets/scaffold/core/scripts/rebuild-all-indexes.sh +69 -0
- package/assets/scaffold/core/scripts/rebuild-companies-index.sh +206 -0
- package/assets/scaffold/core/scripts/rebuild-company-knowledge-index.sh +127 -0
- package/assets/scaffold/core/scripts/rebuild-orchestrator-index.sh +86 -0
- package/assets/scaffold/core/scripts/rebuild-projects-index.sh +105 -0
- package/assets/scaffold/core/scripts/rebuild-public-knowledge-index.sh +83 -0
- package/assets/scaffold/core/scripts/rebuild-reports-index.sh +114 -0
- package/assets/scaffold/core/scripts/rebuild-social-drafts-index.sh +135 -0
- package/assets/scaffold/core/scripts/rebuild-threads-index.sh +82 -0
- package/assets/scaffold/core/scripts/rebuild-workers-index.sh +97 -0
- package/assets/scaffold/core/scripts/resize-screenshot.sh +47 -0
- package/assets/scaffold/core/scripts/token-usage-report.sh +254 -0
- package/assets/scaffold/core/scripts/worktree.sh +136 -0
- package/dist/commands/agents.js +28 -12
- package/dist/commands/api-keys.js +100 -20
- package/dist/commands/core.d.ts +90 -0
- package/dist/commands/core.js +279 -0
- package/dist/commands/files-browse.d.ts +21 -0
- package/dist/commands/files-browse.js +145 -24
- package/dist/commands/files.js +33 -7
- package/dist/commands/members.js +16 -5
- package/dist/commands/pack-install.d.ts +14 -0
- package/dist/commands/pack-install.js +51 -2
- package/dist/commands/pkg-install.js +43 -0
- package/dist/commands/run.js +8 -0
- package/dist/commands/secrets.js +125 -65
- package/dist/main.js +6 -0
- package/dist/utils/api-key-command-gate.d.ts +45 -0
- package/dist/utils/api-key-command-gate.js +51 -0
- package/dist/utils/hq-roots.d.ts +91 -0
- package/dist/utils/hq-roots.js +212 -0
- package/dist/utils/resolve-vault-credential.d.ts +63 -3
- package/dist/utils/resolve-vault-credential.js +152 -6
- package/dist/utils/run-bundled-script.d.ts +66 -0
- package/dist/utils/run-bundled-script.js +80 -0
- package/dist/utils/vault-api.d.ts +21 -0
- package/dist/utils/vault-api.js +74 -3
- package/package.json +7 -1
- package/.claude/policies/hq-cli-interactive-reads-use-sts-vend.md +0 -49
- package/.github/workflows/ci.yml +0 -46
- package/.github/workflows/publish.yml +0 -36
- package/.github/workflows/scripts/smoke-test-pkg.sh +0 -97
- package/e2e/cli.test.ts +0 -128
- package/eslint.config.js +0 -37
- package/pnpm-workspace.yaml +0 -4
- package/scripts/chmod-bins.mjs +0 -49
- package/scripts/generate-dsn.mjs +0 -26
- package/scripts/generate-scan-packages-table.mjs +0 -113
- package/scripts/smoke-sources-signals.sh +0 -103
- package/src/bin/hq-auth-refresh.ts +0 -43
- package/src/cli-version.ts +0 -13
- package/src/commands/__fixtures__/make-tar.ts +0 -126
- package/src/commands/_patterns.ts +0 -10
- package/src/commands/add.ts +0 -75
- package/src/commands/agents.test.ts +0 -527
- package/src/commands/agents.ts +0 -923
- package/src/commands/api-keys.test.ts +0 -291
- package/src/commands/api-keys.ts +0 -382
- package/src/commands/artifact-verify.test.ts +0 -177
- package/src/commands/auth.test.ts +0 -82
- package/src/commands/auth.ts +0 -213
- package/src/commands/billing.test.ts +0 -158
- package/src/commands/billing.ts +0 -146
- package/src/commands/channels.test.ts +0 -74
- package/src/commands/channels.ts +0 -91
- package/src/commands/cloud-demote.test.ts +0 -401
- package/src/commands/cloud-demote.ts +0 -278
- package/src/commands/cloud-provision.test.ts +0 -1117
- package/src/commands/cloud-provision.ts +0 -942
- package/src/commands/cloud.pull-all.test.ts +0 -711
- package/src/commands/cloud.pull-per-company.test.ts +0 -188
- package/src/commands/cloud.push-all.test.ts +0 -485
- package/src/commands/cloud.scope-excluded-warning.test.ts +0 -22
- package/src/commands/cloud.selectors.test.ts +0 -208
- package/src/commands/cloud.status.test.ts +0 -160
- package/src/commands/cloud.test.ts +0 -41
- package/src/commands/cloud.ts +0 -2130
- package/src/commands/company.test.ts +0 -202
- package/src/commands/company.ts +0 -148
- package/src/commands/creators.test.ts +0 -60
- package/src/commands/creators.ts +0 -117
- package/src/commands/crm.test.ts +0 -179
- package/src/commands/crm.ts +0 -236
- package/src/commands/db-migrate.ts +0 -55
- package/src/commands/db-provision.ts +0 -114
- package/src/commands/db-sql.ts +0 -124
- package/src/commands/db-status.ts +0 -108
- package/src/commands/db.ts +0 -26
- package/src/commands/dm.test.ts +0 -748
- package/src/commands/dm.ts +0 -1226
- package/src/commands/feedback.test.ts +0 -413
- package/src/commands/feedback.ts +0 -169
- package/src/commands/files-browse.test.ts +0 -1249
- package/src/commands/files-browse.ts +0 -1506
- package/src/commands/files-delete.test.ts +0 -484
- package/src/commands/files-recovery.test.ts +0 -370
- package/src/commands/files.test.ts +0 -722
- package/src/commands/files.ts +0 -1446
- package/src/commands/group-grants.test.ts +0 -330
- package/src/commands/group-grants.ts +0 -455
- package/src/commands/groups.test.ts +0 -44
- package/src/commands/groups.ts +0 -368
- package/src/commands/integrations.test.ts +0 -633
- package/src/commands/integrations.ts +0 -507
- package/src/commands/list.ts +0 -85
- package/src/commands/login.ts +0 -52
- package/src/commands/logout.ts +0 -30
- package/src/commands/marketplace-install.test.ts +0 -543
- package/src/commands/marketplace-security.test.ts +0 -646
- package/src/commands/mcp-registration.test.ts +0 -2787
- package/src/commands/mcp-registration.ts +0 -2612
- package/src/commands/mcp-status.test.ts +0 -483
- package/src/commands/mcp-status.ts +0 -575
- package/src/commands/mcp-status.us011.test.ts +0 -243
- package/src/commands/meetings.test.ts +0 -522
- package/src/commands/meetings.ts +0 -789
- package/src/commands/members.test.ts +0 -1474
- package/src/commands/members.ts +0 -1181
- package/src/commands/onboard-identity-guard.test.ts +0 -99
- package/src/commands/onboard-identity-guard.ts +0 -103
- package/src/commands/onboard-join.test.ts +0 -96
- package/src/commands/onboard-join.ts +0 -101
- package/src/commands/onboard-warning.test.ts +0 -26
- package/src/commands/onboard-warning.ts +0 -12
- package/src/commands/onboard.ts +0 -267
- package/src/commands/outposts-heartbeat.test.ts +0 -299
- package/src/commands/outposts-heartbeat.ts +0 -310
- package/src/commands/outposts-replica-sync.test.ts +0 -272
- package/src/commands/outposts-self-deploy.test.ts +0 -243
- package/src/commands/outposts.test.ts +0 -1039
- package/src/commands/outposts.ts +0 -1935
- package/src/commands/pack-install-secret-authorization.test.ts +0 -115
- package/src/commands/pack-install.test.ts +0 -1469
- package/src/commands/pack-install.ts +0 -2247
- package/src/commands/pack-update-cache.test.ts +0 -149
- package/src/commands/packs.test.ts +0 -88
- package/src/commands/packs.ts +0 -622
- package/src/commands/people.test.ts +0 -807
- package/src/commands/people.ts +0 -422
- package/src/commands/pkg-install.dispatch.test.ts +0 -71
- package/src/commands/pkg-install.ts +0 -215
- package/src/commands/pkg-list.test.ts +0 -79
- package/src/commands/pkg-list.ts +0 -13
- package/src/commands/pkg-remove.ts +0 -71
- package/src/commands/pkg-update.ts +0 -189
- package/src/commands/publish.test.ts +0 -650
- package/src/commands/publish.ts +0 -580
- package/src/commands/reindex.test.ts +0 -259
- package/src/commands/reindex.ts +0 -274
- package/src/commands/rescue.test.ts +0 -39
- package/src/commands/rescue.ts +0 -210
- package/src/commands/run.env-local.test.ts +0 -88
- package/src/commands/run.test.ts +0 -45
- package/src/commands/run.ts +0 -184
- package/src/commands/safe-extract.test.ts +0 -459
- package/src/commands/safe-extract.ts +0 -444
- package/src/commands/secrets-scope.test.ts +0 -56
- package/src/commands/secrets-scope.ts +0 -32
- package/src/commands/secrets.parse-destination.test.ts +0 -38
- package/src/commands/secrets.test.ts +0 -2037
- package/src/commands/secrets.ts +0 -2240
- package/src/commands/signals.ts +0 -351
- package/src/commands/skill.test.ts +0 -770
- package/src/commands/skill.ts +0 -796
- package/src/commands/sources.ts +0 -362
- package/src/commands/sync-mode.test.ts +0 -366
- package/src/commands/sync-mode.ts +0 -387
- package/src/commands/sync-narrow.test.ts +0 -633
- package/src/commands/sync-narrow.ts +0 -555
- package/src/commands/sync.ts +0 -165
- package/src/commands/team-sync.ts +0 -628
- package/src/commands/update.ts +0 -71
- package/src/commands/whoami.ts +0 -83
- package/src/commands/workers.test.ts +0 -158
- package/src/commands/workers.ts +0 -301
- package/src/index.test.ts +0 -32
- package/src/index.ts +0 -20
- package/src/lib/db/control-plane.test.ts +0 -59
- package/src/lib/db/control-plane.ts +0 -113
- package/src/lib/db/local.test.ts +0 -81
- package/src/lib/db/local.ts +0 -148
- package/src/lib/db/migrate.test.ts +0 -133
- package/src/lib/db/migrate.ts +0 -137
- package/src/lib/db/paths.test.ts +0 -112
- package/src/lib/db/paths.ts +0 -128
- package/src/lib/db/remote-engine.test.ts +0 -44
- package/src/lib/db/remote-engine.ts +0 -148
- package/src/lib/db/remote-sql.test.ts +0 -32
- package/src/lib/db/remote-sql.ts +0 -62
- package/src/lib/db/sql.test.ts +0 -106
- package/src/lib/db/sql.ts +0 -192
- package/src/lib/hq-cloud-dep.smoke.test.ts +0 -75
- package/src/lib/local-tree-diff.test.ts +0 -301
- package/src/lib/local-tree-diff.ts +0 -362
- package/src/lib/narrow-hint-banner.test.ts +0 -235
- package/src/lib/narrow-hint-banner.ts +0 -212
- package/src/lib/plan-limit-nag.test.ts +0 -317
- package/src/lib/plan-limit-nag.ts +0 -264
- package/src/main.ts +0 -371
- package/src/node-preflight.test.ts +0 -60
- package/src/node-preflight.ts +0 -67
- package/src/outpost/session-heartbeat-bounds.test.ts +0 -195
- package/src/outpost/session-heartbeat-guard.test.ts +0 -105
- package/src/outpost/session-heartbeat-publisher.test.ts +0 -178
- package/src/outpost/session-heartbeat-publisher.ts +0 -186
- package/src/outpost/session-heartbeat-retain-guard.test.ts +0 -126
- package/src/outpost/session-heartbeat.test.ts +0 -459
- package/src/outpost/session-heartbeat.ts +0 -877
- package/src/packaging.test.ts +0 -109
- package/src/run/__fixtures__/discover-schemas/example.env.schema +0 -4
- package/src/run/discover-schemas.test.ts +0 -153
- package/src/run/discover-schemas.ts +0 -79
- package/src/run/hq-plugin.test.ts +0 -371
- package/src/run/hq-plugin.ts +0 -277
- package/src/run/varlock-shape.test.ts +0 -57
- package/src/schemas/hq-package.schema.json +0 -155
- package/src/sentry-before-send.test.ts +0 -359
- package/src/sentry-before-send.ts +0 -221
- package/src/sentry-epipe.test.ts +0 -37
- package/src/sentry-release.test.ts +0 -54
- package/src/sentry.test.ts +0 -24
- package/src/sentry.ts +0 -54
- package/src/strategies/link.ts +0 -62
- package/src/strategies/merge.ts +0 -142
- package/src/types.ts +0 -146
- package/src/utils/__fixtures__/scan-packages.generated-block.sh +0 -23
- package/src/utils/auth-error.test.ts +0 -40
- package/src/utils/auth-error.ts +0 -42
- package/src/utils/billing-gate.test.ts +0 -95
- package/src/utils/billing-gate.ts +0 -228
- package/src/utils/breadcrumb-buffer.ts +0 -18
- package/src/utils/cli-telemetry.test.ts +0 -153
- package/src/utils/cli-telemetry.ts +0 -61
- package/src/utils/cognito-session.machine.test.ts +0 -109
- package/src/utils/cognito-session.refresh.test.ts +0 -84
- package/src/utils/cognito-session.test.ts +0 -328
- package/src/utils/cognito-session.ts +0 -448
- package/src/utils/company-selection-error.test.ts +0 -42
- package/src/utils/company-selection-error.ts +0 -45
- package/src/utils/contribution-table.ts +0 -83
- package/src/utils/environmental-error.test.ts +0 -45
- package/src/utils/environmental-error.ts +0 -39
- package/src/utils/epipe.test.ts +0 -28
- package/src/utils/epipe.ts +0 -29
- package/src/utils/expected-cli-error.test.ts +0 -28
- package/src/utils/expected-cli-error.ts +0 -39
- package/src/utils/feedback-diagnostics.test.ts +0 -183
- package/src/utils/feedback-diagnostics.ts +0 -123
- package/src/utils/feedback-screenshots.test.ts +0 -134
- package/src/utils/feedback-screenshots.ts +0 -124
- package/src/utils/feedback-versions.test.ts +0 -98
- package/src/utils/feedback-versions.ts +0 -68
- package/src/utils/git.ts +0 -74
- package/src/utils/integrity.ts +0 -59
- package/src/utils/intercepted-process-exit.test.ts +0 -37
- package/src/utils/intercepted-process-exit.ts +0 -36
- package/src/utils/login-provider.test.ts +0 -42
- package/src/utils/login-provider.ts +0 -30
- package/src/utils/manifest.test.ts +0 -167
- package/src/utils/manifest.ts +0 -131
- package/src/utils/pack-contributions.test.ts +0 -603
- package/src/utils/pack-contributions.ts +0 -450
- package/src/utils/people.ts +0 -215
- package/src/utils/registry-client.ts +0 -204
- package/src/utils/registry.ts +0 -90
- package/src/utils/resolve-vault-credential.test.ts +0 -69
- package/src/utils/resolve-vault-credential.ts +0 -60
- package/src/utils/sandbox-runner-client.test.ts +0 -279
- package/src/utils/sandbox-runner-client.ts +0 -260
- package/src/utils/secrets-cache.ts +0 -183
- package/src/utils/sentry-identity.ts +0 -45
- package/src/utils/settle-with-timeout.test.ts +0 -21
- package/src/utils/settle-with-timeout.ts +0 -22
- package/src/utils/user-yaml-error.test.ts +0 -24
- package/src/utils/user-yaml-error.ts +0 -30
- package/src/utils/vault-api.test.ts +0 -536
- package/src/utils/vault-api.ts +0 -382
- package/src/utils/version-check.test.ts +0 -176
- package/src/utils/version-check.ts +0 -155
- package/src/utils/version-gate.test.ts +0 -1024
- package/src/utils/version-gate.ts +0 -702
- package/test/commands/db-tenant-isolation.test.ts +0 -94
- package/test/commands/db.test.ts +0 -85
- package/test/commands/signals.test.ts +0 -207
- package/test/commands/sources.test.ts +0 -231
- package/test/e2e/smoke-install-mcp.sh +0 -113
- package/test/fixtures/hq-pack-smoke-mcp/mcp/smoke-http.json +0 -1
- package/test/fixtures/hq-pack-smoke-mcp/package.yaml +0 -11
- package/test/fixtures/signals/action_item/sample.md +0 -16
- package/test/fixtures/signals/summary/sample.md +0 -12
- package/test/fixtures/sources/meetings/sample.md +0 -25
- package/test/helpers/cli-runner.ts +0 -150
- package/test/helpers/s3-list-mock.ts +0 -79
- package/test/helpers/vault-service-mock.ts +0 -295
- package/test/sources-signals/smoke.test.ts +0 -228
- package/tsconfig.json +0 -20
- package/vitest.config.ts +0 -11
- 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
|
-
});
|