fix(#3547): exercise the real global config-home shape in the install harness (#3567)

* test(#3547): failing-first regression for collapsed global install shape

* fix(#3547): exercise the real global config-home shape in the install harness

* test(#3547): align ripple suites with the real global install shape

* test(#3547): update stale collapsed-shape pins in provenance and migration suites

* fix(#3547): bump emitted-baseline schema version for the real install shape

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-16 01:14:26 -04:00
committed by GitHub
parent c5b83cb050
commit 9448736872
25 changed files with 197 additions and 207 deletions

View File

@@ -323,9 +323,10 @@ test('spot-check: cline rules are code-derived (attributable), not exempt', () =
assert.equal(got.kind, 'code-derived', `${rel} must stay attributable`);
assert.deepEqual(got.sources, [CLINE_BODY_SRC]);
}
const agentsMd = attributeEmittedPath('.agents/AGENTS.md', 'cline');
assert.equal(agentsMd.kind, 'code-derived');
assert.deepEqual(agentsMd.sources, [CLINE_BODY_SRC]);
// #3547 dropped the `.agents/AGENTS.md` spot-check: that path only entered a
// manifest while the harness's collapsed --config-dir walked cline's
// ~/.agents sibling; with the real `.cline` config home it can never occur,
// and the rule was removed by the dead-rule arm.
});
test('spot-check: install-time state is exempt with an empty source list', () => {
@@ -334,7 +335,6 @@ test('spot-check: install-time state is exempt with an empty source list', () =>
['gsd-core/VERSION', 'claude'],
['gsd-core/.gsd-runtime', 'claude'],
['package.json', 'opencode'],
['.gsd/defaults.json', 'opencode'],
['opencode.json', 'opencode'],
]) {
const got = attributeEmittedPath(rel, rt);
@@ -620,7 +620,9 @@ test('emitted paths that could traverse out of the repo are rejected', () => {
assert.throws(() => attributeEmittedPath('', 'claude'), /non-empty string/);
// A dot-prefixed segment is NOT traversal — this must still resolve normally.
assert.doesNotThrow(() => attributeEmittedPath('.gsd/defaults.json', 'opencode'));
// #3547: '.gsd/defaults.json' left the manifests with the collapsed shape; a
// dot-prefixed STILL-EMITTED path serves the same not-traversal example.
assert.doesNotThrow(() => attributeEmittedPath('.gsd-profile', 'opencode'));
});
test('sampleLimit truncation is exact at limit-1 / limit / limit+1', () => {

View File

@@ -1,6 +1,5 @@
[
".gsd-profile",
".gsd/defaults.json",
"agents/gsd-advisor-researcher.md",
"agents/gsd-ai-researcher.md",
"agents/gsd-assumptions-analyzer.md",

View File

@@ -1,6 +1,5 @@
[
".gsd-profile",
".gsd/defaults.json",
"agents/gsd-advisor-researcher.md",
"agents/gsd-ai-researcher.md",
"agents/gsd-assumptions-analyzer.md",

View File

@@ -1,9 +1,7 @@
[
".agents/AGENTS.md",
".clinerules/gsd.md",
".clinerules/hooks/PreToolUse",
".gsd-profile",
".gsd/defaults.json",
"agents/gsd-advisor-researcher.md",
"agents/gsd-ai-researcher.md",
"agents/gsd-assumptions-analyzer.md",

View File

@@ -1,6 +1,5 @@
[
".gsd-profile",
".gsd/defaults.json",
"agents/gsd-advisor-researcher.md",
"agents/gsd-ai-researcher.md",
"agents/gsd-assumptions-analyzer.md",

View File

@@ -1,77 +1,5 @@
[
".agents/skills/gsd-add-tests/SKILL.md",
".agents/skills/gsd-ai-integration-phase/SKILL.md",
".agents/skills/gsd-audit-fix/SKILL.md",
".agents/skills/gsd-audit-milestone/SKILL.md",
".agents/skills/gsd-audit-uat/SKILL.md",
".agents/skills/gsd-autonomous/SKILL.md",
".agents/skills/gsd-capture/SKILL.md",
".agents/skills/gsd-cleanup/SKILL.md",
".agents/skills/gsd-code-review/SKILL.md",
".agents/skills/gsd-complete-milestone/SKILL.md",
".agents/skills/gsd-config/SKILL.md",
".agents/skills/gsd-debug/SKILL.md",
".agents/skills/gsd-discuss-phase/SKILL.md",
".agents/skills/gsd-docs-update/SKILL.md",
".agents/skills/gsd-eval-review/SKILL.md",
".agents/skills/gsd-execute-phase/SKILL.md",
".agents/skills/gsd-explore/SKILL.md",
".agents/skills/gsd-extract-learnings/SKILL.md",
".agents/skills/gsd-fast/SKILL.md",
".agents/skills/gsd-forensics/SKILL.md",
".agents/skills/gsd-graphify/SKILL.md",
".agents/skills/gsd-health/SKILL.md",
".agents/skills/gsd-help/SKILL.md",
".agents/skills/gsd-import/SKILL.md",
".agents/skills/gsd-inbox/SKILL.md",
".agents/skills/gsd-ingest-docs/SKILL.md",
".agents/skills/gsd-manager/SKILL.md",
".agents/skills/gsd-map-codebase/SKILL.md",
".agents/skills/gsd-mempalace-capture/SKILL.md",
".agents/skills/gsd-mempalace-recall/SKILL.md",
".agents/skills/gsd-milestone-summary/SKILL.md",
".agents/skills/gsd-mvp-phase/SKILL.md",
".agents/skills/gsd-new-milestone/SKILL.md",
".agents/skills/gsd-new-project/SKILL.md",
".agents/skills/gsd-next/SKILL.md",
".agents/skills/gsd-ns-context/SKILL.md",
".agents/skills/gsd-ns-ideate/SKILL.md",
".agents/skills/gsd-ns-manage/SKILL.md",
".agents/skills/gsd-ns-project/SKILL.md",
".agents/skills/gsd-ns-review/SKILL.md",
".agents/skills/gsd-ns-workflow/SKILL.md",
".agents/skills/gsd-onboard/SKILL.md",
".agents/skills/gsd-pause-work/SKILL.md",
".agents/skills/gsd-phase/SKILL.md",
".agents/skills/gsd-plan-phase/SKILL.md",
".agents/skills/gsd-plan-review-convergence/SKILL.md",
".agents/skills/gsd-pr-branch/SKILL.md",
".agents/skills/gsd-profile-user/SKILL.md",
".agents/skills/gsd-progress/SKILL.md",
".agents/skills/gsd-quick/SKILL.md",
".agents/skills/gsd-resume-work/SKILL.md",
".agents/skills/gsd-review-backlog/SKILL.md",
".agents/skills/gsd-review/SKILL.md",
".agents/skills/gsd-secure-phase/SKILL.md",
".agents/skills/gsd-settings/SKILL.md",
".agents/skills/gsd-ship/SKILL.md",
".agents/skills/gsd-sketch/SKILL.md",
".agents/skills/gsd-spec-phase/SKILL.md",
".agents/skills/gsd-spike/SKILL.md",
".agents/skills/gsd-stats/SKILL.md",
".agents/skills/gsd-surface/SKILL.md",
".agents/skills/gsd-thread/SKILL.md",
".agents/skills/gsd-ui-phase/SKILL.md",
".agents/skills/gsd-ui-review/SKILL.md",
".agents/skills/gsd-ultraplan-phase/SKILL.md",
".agents/skills/gsd-undo/SKILL.md",
".agents/skills/gsd-update/SKILL.md",
".agents/skills/gsd-validate-phase/SKILL.md",
".agents/skills/gsd-verify-work/SKILL.md",
".agents/skills/gsd-workspace/SKILL.md",
".agents/skills/gsd-workstreams/SKILL.md",
".gsd-profile",
".gsd/defaults.json",
"agents/gsd-advisor-researcher.md",
"agents/gsd-advisor-researcher.toml",
"agents/gsd-ai-researcher.md",

View File

@@ -1,6 +1,5 @@
[
".gsd-profile",
".gsd/defaults.json",
"agents/gsd-advisor-researcher.agent.md",
"agents/gsd-ai-researcher.agent.md",
"agents/gsd-assumptions-analyzer.agent.md",

View File

@@ -1,6 +1,5 @@
[
".gsd-profile",
".gsd/defaults.json",
"agents/gsd-advisor-researcher.md",
"agents/gsd-ai-researcher.md",
"agents/gsd-assumptions-analyzer.md",

View File

@@ -1,6 +1,5 @@
[
".gsd-profile",
".gsd/defaults.json",
"agents/gsd-advisor-researcher.md",
"agents/gsd-ai-researcher.md",
"agents/gsd-assumptions-analyzer.md",

View File

@@ -1,6 +1,5 @@
[
".gsd-profile",
".gsd/defaults.json",
"agents/gsd-advisor-researcher.md",
"agents/gsd-ai-researcher.md",
"agents/gsd-assumptions-analyzer.md",

View File

@@ -1,39 +1,5 @@
[
".gsd-profile",
".gsd/defaults.json",
".kimi-code/hooks/gsd-agent-isolation-guard.js",
".kimi-code/hooks/gsd-check-update-worker.js",
".kimi-code/hooks/gsd-check-update.js",
".kimi-code/hooks/gsd-config-reload.js",
".kimi-code/hooks/gsd-context-monitor.js",
".kimi-code/hooks/gsd-cursor-post-tool.js",
".kimi-code/hooks/gsd-cursor-pre-tool.js",
".kimi-code/hooks/gsd-cursor-session-start.js",
".kimi-code/hooks/gsd-cursor-stop.js",
".kimi-code/hooks/gsd-cursor-subagent-start.js",
".kimi-code/hooks/gsd-cursor-subagent-stop.js",
".kimi-code/hooks/gsd-ensure-canonical-path.js",
".kimi-code/hooks/gsd-graphify-update.sh",
".kimi-code/hooks/gsd-phase-boundary.sh",
".kimi-code/hooks/gsd-prompt-guard.js",
".kimi-code/hooks/gsd-read-guard.js",
".kimi-code/hooks/gsd-read-injection-scanner.js",
".kimi-code/hooks/gsd-session-state.sh",
".kimi-code/hooks/gsd-statusline.js",
".kimi-code/hooks/gsd-update-banner.js",
".kimi-code/hooks/gsd-validate-commit.sh",
".kimi-code/hooks/gsd-windsurf-pre-command.js",
".kimi-code/hooks/gsd-windsurf-pre-write.js",
".kimi-code/hooks/gsd-workflow-guard.js",
".kimi-code/hooks/gsd-worktree-path-guard.js",
".kimi-code/hooks/gsd-write-guard.js",
".kimi-code/hooks/lib/cursor-workspace.js",
".kimi-code/hooks/lib/git-cmd.js",
".kimi-code/hooks/lib/gsd-graphify-rebuild.sh",
".kimi-code/hooks/lib/injection-patterns.js",
".kimi-code/hooks/lib/isolation-sentinel.js",
".kimi-code/hooks/managed-hooks-registry.cjs",
".kimi-code/hooks/package.json",
"agents/gsd-advisor-researcher.md",
"agents/gsd-ai-researcher.md",
"agents/gsd-assumptions-analyzer.md",
@@ -68,6 +34,7 @@
"agents/gsd-ui-researcher.md",
"agents/gsd-user-profiler.md",
"agents/gsd-verifier.md",
"config.toml",
"gsd-core/.gsd-runtime",
"gsd-core/VERSION",
"gsd-core/bin/check-latest-version.cjs",
@@ -402,6 +369,39 @@
"gsd-core/workflows/verify-work.md",
"gsd-core/workflows/verify-work/steps/automated-ui-verification.md",
"gsd-core/workflows/verify-work/steps/mvp-uat-framing.md",
"hooks/gsd-agent-isolation-guard.js",
"hooks/gsd-check-update-worker.js",
"hooks/gsd-check-update.js",
"hooks/gsd-config-reload.js",
"hooks/gsd-context-monitor.js",
"hooks/gsd-cursor-post-tool.js",
"hooks/gsd-cursor-pre-tool.js",
"hooks/gsd-cursor-session-start.js",
"hooks/gsd-cursor-stop.js",
"hooks/gsd-cursor-subagent-start.js",
"hooks/gsd-cursor-subagent-stop.js",
"hooks/gsd-ensure-canonical-path.js",
"hooks/gsd-graphify-update.sh",
"hooks/gsd-phase-boundary.sh",
"hooks/gsd-prompt-guard.js",
"hooks/gsd-read-guard.js",
"hooks/gsd-read-injection-scanner.js",
"hooks/gsd-session-state.sh",
"hooks/gsd-statusline.js",
"hooks/gsd-update-banner.js",
"hooks/gsd-validate-commit.sh",
"hooks/gsd-windsurf-pre-command.js",
"hooks/gsd-windsurf-pre-write.js",
"hooks/gsd-workflow-guard.js",
"hooks/gsd-worktree-path-guard.js",
"hooks/gsd-write-guard.js",
"hooks/lib/cursor-workspace.js",
"hooks/lib/git-cmd.js",
"hooks/lib/gsd-graphify-rebuild.sh",
"hooks/lib/injection-patterns.js",
"hooks/lib/isolation-sentinel.js",
"hooks/managed-hooks-registry.cjs",
"hooks/package.json",
"scripts/changeset/README.md",
"scripts/changeset/cli.cjs",
"scripts/changeset/github-release-notes.cjs",

View File

@@ -1,39 +1,5 @@
[
".gsd-profile",
".gsd/defaults.json",
".kimi/hooks/gsd-agent-isolation-guard.js",
".kimi/hooks/gsd-check-update-worker.js",
".kimi/hooks/gsd-check-update.js",
".kimi/hooks/gsd-config-reload.js",
".kimi/hooks/gsd-context-monitor.js",
".kimi/hooks/gsd-cursor-post-tool.js",
".kimi/hooks/gsd-cursor-pre-tool.js",
".kimi/hooks/gsd-cursor-session-start.js",
".kimi/hooks/gsd-cursor-stop.js",
".kimi/hooks/gsd-cursor-subagent-start.js",
".kimi/hooks/gsd-cursor-subagent-stop.js",
".kimi/hooks/gsd-ensure-canonical-path.js",
".kimi/hooks/gsd-graphify-update.sh",
".kimi/hooks/gsd-phase-boundary.sh",
".kimi/hooks/gsd-prompt-guard.js",
".kimi/hooks/gsd-read-guard.js",
".kimi/hooks/gsd-read-injection-scanner.js",
".kimi/hooks/gsd-session-state.sh",
".kimi/hooks/gsd-statusline.js",
".kimi/hooks/gsd-update-banner.js",
".kimi/hooks/gsd-validate-commit.sh",
".kimi/hooks/gsd-windsurf-pre-command.js",
".kimi/hooks/gsd-windsurf-pre-write.js",
".kimi/hooks/gsd-workflow-guard.js",
".kimi/hooks/gsd-worktree-path-guard.js",
".kimi/hooks/gsd-write-guard.js",
".kimi/hooks/lib/cursor-workspace.js",
".kimi/hooks/lib/git-cmd.js",
".kimi/hooks/lib/gsd-graphify-rebuild.sh",
".kimi/hooks/lib/injection-patterns.js",
".kimi/hooks/lib/isolation-sentinel.js",
".kimi/hooks/managed-hooks-registry.cjs",
".kimi/hooks/package.json",
"agents/gsd.md",
"agents/gsd.yaml",
"agents/subagents/gsd-advisor-researcher.md",

View File

@@ -1,6 +1,5 @@
[
".gsd-profile",
".gsd/defaults.json",
"agents/gsd-advisor-researcher.md",
"agents/gsd-ai-researcher.md",
"agents/gsd-assumptions-analyzer.md",

View File

@@ -1,6 +1,5 @@
[
".gsd-profile",
".gsd/defaults.json",
"extensions/gsd.js",
"extensions/package.json",
"gsd-core/.gsd-runtime",

View File

@@ -1,6 +1,5 @@
[
".gsd-profile",
".gsd/defaults.json",
"agents/gsd-advisor-researcher.md",
"agents/gsd-ai-researcher.md",
"agents/gsd-assumptions-analyzer.md",

View File

@@ -1,6 +1,5 @@
[
".gsd-profile",
".gsd/defaults.json",
"agents/gsd-advisor-researcher.md",
"agents/gsd-ai-researcher.md",
"agents/gsd-assumptions-analyzer.md",

View File

@@ -1,6 +1,5 @@
[
".gsd-profile",
".gsd/defaults.json",
"agents/gsd-advisor-researcher.md",
"agents/gsd-ai-researcher.md",
"agents/gsd-assumptions-analyzer.md",

View File

@@ -1,6 +1,5 @@
[
".gsd-profile",
".gsd/defaults.json",
"agents/gsd-advisor-researcher.md",
"agents/gsd-ai-researcher.md",
"agents/gsd-assumptions-analyzer.md",

View File

@@ -151,3 +151,65 @@ test('install tree — claude (local legacy layout)', async (t) => {
assert.deepEqual(actual, fixture, lines.join('\n'));
}
});
// ─── #3547: the harness's global install must exercise the REAL config-home shape ───
//
// runMinimalInstall's global scope used to pass `--config-dir <root>` where the
// same <root> was also the sandbox HOME — collapsing configDir onto HOME. A real
// global install resolves its config home to a STRICT SUBDIRECTORY of $HOME
// (<HOME>/.claude, <HOME>/.codex, <HOME>/.config/opencode, …), and that is what
// computePathPrefix's `isGlobal && posixTarget.startsWith(posixHome)` branch
// needs to emit `$HOME/<suffix>/`-shaped prefixes. Under the collapsed shape the
// prefix was bare `$HOME/`, so every emitted global artifact referenced
// `$HOME/gsd-core/…` — a path that exists on no real install — and the entire
// emitted-artifact apparatus (ADR-2719 differential, install-tree fixtures,
// 19-family baseline) was structurally blind to drift confined to the real
// global shape (#3544's fix rewrote 54 includes on live installs with ZERO
// manifest/fixture diffs).
test('#3547 global harness install uses the real config-home shape (claude)', async (t) => {
const { configDir, root } = runMinimalInstall({ runtime: 'claude', scope: 'global' });
t.after(() => cleanup(root));
// Strict-subdirectory shape: <root>/.claude, never the collapsed <root>.
assert.equal(configDir, path.join(root, RUNTIME_META.claude.globalSuffix),
'global configDir must be the runtime\'s real global dir under the sandbox HOME');
assert.ok(path.dirname(configDir) === root, 'configDir must be a direct subdirectory of the sandbox HOME');
// The deployed contract: emitted agent text references $HOME/.claude/gsd-core/,
// never the unsuffixed $HOME/gsd-core/ form (posixNormalized on every platform).
const planner = fs.readFileSync(path.join(configDir, 'agents', 'gsd-planner.md'), 'utf-8');
assert.match(planner, /\$HOME\/\.claude\/gsd-core\//,
'emitted agents must carry the real global prefix shape');
assert.doesNotMatch(planner, /\$HOME\/gsd-core\//,
'the collapsed bare-$HOME prefix must not appear in real-shape installs');
});
test('#3547 codex global harness install resolves the .codex subdirectory', async (t) => {
const { configDir, root } = runMinimalInstall({ runtime: 'codex', scope: 'global' });
t.after(() => cleanup(root));
assert.equal(configDir, path.join(root, RUNTIME_META.codex.globalSuffix));
});
test('#3547 opencode global harness install resolves the XDG subdirectory', async (t) => {
const { configDir, root } = runMinimalInstall({ runtime: 'opencode', scope: 'global' });
t.after(() => cleanup(root));
assert.equal(configDir, path.join(root, '.config', 'opencode'));
});
test('#3547 local scope configDir unchanged', async (t) => {
const { configDir, root } = runMinimalInstall({ runtime: 'claude', scope: 'local' });
t.after(() => cleanup(root));
assert.equal(configDir, path.join(root, '.claude'),
'local installs keep the root/<localDir> shape — untouched by #3547');
});
test('#3547 unknown runtime global scope fails loudly', () => {
// grok is in the capability registry but not RUNTIME_META: a silent
// `path.join(root, undefined)` is the #3023 failure mode this guards.
assert.throws(
() => runMinimalInstall({ runtime: 'grok', scope: 'global' }),
/grok|RUNTIME_META/,
'a runtime without a known global dir must fail loudly before any install spawns',
);
});

View File

@@ -47,8 +47,14 @@ const BASELINE_ENV = 'GSD_EMITTED_BASELINE';
/** Default on-disk cache location, relative to the repo root. */
const DEFAULT_CACHE_PATH = '.gsd-cache/emitted-baseline.json';
/** Baseline artifact schema version — pinned so a format change fails loudly. */
const BASELINE_VERSION = 1;
/** Baseline artifact schema version — pinned so a format change fails loudly.
* v2 (#3547): the harness now measures every global family in the REAL
* config-home shape (<root>/<globalSuffix> instead of the collapsed
* <root>), so manifests from a v1 baseline — built with the collapsed
* harness — are not comparable (HOME-level paths present, prefix bytes
* absent). Bumping refuses v1 caches and forces the same-schema fallback
* build; the push-to-next job republishes at v2 when this merges. */
const BASELINE_VERSION = 2;
/**
* Validate a baseline artifact's shape and freshness.

View File

@@ -530,13 +530,17 @@ const PROVENANCE_RULES = [
pattern: /^(gsd\.md|hooks\/PreToolUse)$/,
sources: () => [CLINE_BODY_SRC],
},
{
id: 'agents-md-code-derived',
kind: 'code-derived',
roots: ['.agents'],
pattern: /^AGENTS\.md$/,
sources: () => [CLINE_BODY_SRC],
},
// #3547 removed `agents-md-code-derived` (roots: ['.agents'],
// ^AGENTS\.md$) and `synthesized-gsd-defaults` (^\.gsd/defaults\.json$):
// both paths only appeared in a manifest while the harness collapsed
// configDir onto the sandbox HOME, walking HOME-level siblings
// (cline's ~/.agents/AGENTS.md, every non-Claude runtime's
// ~/.gsd/defaults.json). With the harness installing into each runtime's
// real global subdirectory those files sit outside the walked configDir,
// the rules matched nothing, and the totality guard's dead-rule arm fired
// — exactly as designed. The files are still written, still covered by the
// existence/config suites (kimi-upgrades, codex-config, install suites);
// they are simply no longer manifest members to attribute.
{
id: 'hermes-category-description',
kind: 'code-derived',
@@ -568,13 +572,6 @@ const PROVENANCE_RULES = [
pattern: /^(\.gsd-profile|package\.json|\.kimi(-code)?\/package\.json|gsd-core\/VERSION|gsd-core\/\.gsd-runtime)$/,
sources: () => [],
},
{
id: 'synthesized-gsd-defaults',
kind: 'synthesized',
roots: null,
pattern: /^\.gsd\/defaults\.json$/,
sources: () => [],
},
{
id: 'synthesized-host-config',
kind: 'synthesized',

View File

@@ -191,18 +191,22 @@ const HOOK_CONFIG_FILES = new Set(['settings.json', 'settings.local.json', 'hook
// platform-stable `[features] hooks = true` flag — the real hook commands
// live in Codex's separate hooks.json, already excluded above). Blanket-
// excluding the 'config.toml' basename would silently blind Codex's fixture
// to any future regression there. Kimi's config.toml instead lives OUTSIDE
// its GSD configDir at runtime (resolveKimiHooksTomlDir resolves ~/.kimi, a
// sibling of the configDir ~/.config/agents) — it only appears inside this
// harness's walked tree at all because runMinimalInstall sets HOME to the
// same temp root used as --config-dir, collapsing the two into one directory
// for the isolated test run. So it is excluded by its exact relative path
// under that collapsed root, not by basename.
// Both Kimi products' native config.toml embeds a platform-varying node-runner
// command, so neither belongs in the golden-tracked emitted manifest. kimi-code
// resolves its own root since #2755 — listing only `.kimi/config.toml` here made
// kimi-code's config.toml newly manifest-visible and unattributable.
const HOOK_CONFIG_RELATIVE_PATHS = new Set(['.kimi/config.toml', '.kimi-code/config.toml']);
// to any future regression there — and it would blind kimi-code's too: since
// #3547 the harness installs into each runtime's REAL global subdirectory, so
// kimi-code's hooks config.toml sits at its configDir root (rel `config.toml`)
// and is legitimately manifest-visible. Tracking it is safe now: the
// install-tree fixture carries paths only, and the ADR-2719 differential
// compares base-vs-current on the same machine, so the platform-varying
// node-runner command embedded in the TOML never crosses platforms inside a
// gate (that was a golden-content-era hazard, and the goldens are gone).
// Kimi CLI's config.toml (KIMI_SHARE_DIR root ~/.kimi) lives OUTSIDE its GSD
// configDir (~/.config/agents) and never enters the walk. The pre-#3547
// relative-path exclusions ('.kimi/config.toml', '.kimi-code/config.toml')
// existed only for the collapsed shape — where the walked root was the HOME
// itself and those HOME-level siblings were inside it; with no walker rooting
// at HOME anymore they matched nothing and were removed (#3547). kimi-code
// resolves its own root since #2755.
const HOOK_CONFIG_RELATIVE_PATHS = new Set();
// Path prefixes excluded from the parity manifest. `gsd-core/bin/lib/` holds the
// tsc-built runtime artifacts (compiled from src/*.cts) that the install COPIES
@@ -570,8 +574,31 @@ function runMinimalInstall({ runtime, scope, extraArgs = [], installScript = INS
let cwd = process.cwd();
const args = [installScript, `--${runtime}`];
if (scope === 'global') {
args.push('--global', '--config-dir', root);
configDir = root;
// #3547 — install into the runtime's REAL global config home: the strict
// subdirectory of the sandbox HOME a genuine global install resolves
// (RUNTIME_META.globalSuffix mirrors the registry's getGlobalConfigDir
// for every runtime). The previous `--config-dir <root>` collapsed
// configDir onto HOME, so computePathPrefix emitted bare `$HOME/`
// prefixes and the emitted bytes referenced `$HOME/gsd-core/…` — a path
// no real install produces — leaving every emitted-artifact gate
// (ADR-2719 differential, install-tree fixtures, the 19-family baseline)
// blind to drift confined to the real global shape (#3544 evidence: 54
// includes rewritten on live installs, zero manifest/fixture diffs). The
// explicit flag stays: hermeticity-by-override is immune to ambient
// redirect envs (CI runners export XDG_CONFIG_HOME, which redefines the
// opencode/kilo XDG descriptors' resolution when no explicit dir wins).
const globalMeta = RUNTIME_META[runtime];
if (!globalMeta || !globalMeta.globalSuffix) {
// #3023 lesson: a silent `path.join(root, undefined)` here throws a
// bare TypeError naming neither the runtime nor the map at fault; a
// runtime without a known global home must fail loudly before any
// install spawns.
throw new Error(
`runMinimalInstall: no RUNTIME_META.globalSuffix for runtime "${runtime}" — refusing to guess a global config dir (#3547)`,
);
}
configDir = path.join(root, globalMeta.globalSuffix);
args.push('--global', '--config-dir', configDir);
} else {
args.push('--local');
cwd = root;

View File

@@ -285,11 +285,13 @@ test('UPGRADE 2: a corrupted/undeclared dispatch still fails closed to inline (s
// docs/reference/host-integration-capability-matrix.md's kimi EoS-status
// paragraph) — the installer's deliverable stops at the Agent-tool grant plus
// the negotiated backgroundDispatch axis asserted above.
test('UPGRADE 2 (installer-testable proxy): kimi --global install with subagents present grants kimi_cli.tools.agent:Agent on the root agent', (t) => {
const { root } = runMinimalInstall({ runtime: 'kimi', scope: 'global' });
t.after(() => cleanup(root));
test('UPGRADE 2 (installer-testable proxy): kimi --global install with subagents present grants kimi_cli.tools.agent:Agent on the root agent', (t) => {
const { configDir, root } = runMinimalInstall({ runtime: 'kimi', scope: 'global' });
t.after(() => cleanup(root));
const rootYamlPath = path.join(root, 'agents', 'gsd.yaml');
// #3547 — the root agent lives under the runtime's real global config
// home (<root>/.config/agents), not the sandbox HOME itself.
const rootYamlPath = path.join(configDir, 'agents', 'gsd.yaml');
assert.ok(fs.existsSync(rootYamlPath), 'kimi: agents/gsd.yaml must exist');
const rootYaml = fs.readFileSync(rootYamlPath, 'utf8');
@@ -420,8 +422,11 @@ describe('kimi vs kimi-code hooks-TOML root (#2755)', () => {
assert.ok(hasGsdHooksBlock(path.join(altHome, 'config.toml')),
'KIMI_CODE_HOME must redirect the hooks block');
assert.ok(!fs.existsSync(path.join(root, '.kimi-code')),
'the default kimi-code root must not be used when the env var is set');
// #3547 — <root>/.kimi-code legitimately exists as kimi-code's GSD config
// home in the harness's real global shape; what must NOT happen is the
// DEFAULT hooks root receiving the GSD block while the override is set.
assert.ok(!hasGsdHooksBlock(path.join(root, '.kimi-code', 'config.toml')),
'the default kimi-code hooks root must not receive the GSD hooks block when the env var is set');
assert.ok(!fs.existsSync(path.join(root, '.kimi')),
"Kimi CLI's root must not be touched either");
});
@@ -488,8 +493,10 @@ describe('kimi vs kimi-code hooks-TOML root (#2755)', () => {
assert.ok(hasGsdHooksBlock(path.join(codeAlt, 'config.toml')),
'kimi-code must honor KIMI_CODE_HOME while KIMI_SHARE_DIR is also set');
assert.ok(!fs.existsSync(path.join(root, '.kimi')),
'neither default root may be used when both overrides are set');
assert.ok(!fs.existsSync(path.join(root, '.kimi-code')),
'neither default root may be used when both overrides are set');
'neither default hooks root may be used when both overrides are set');
// #3547 — <root>/.kimi-code is kimi-code's GSD config home now; assert the
// hooks BLOCK stayed off the default root instead of directory absence.
assert.ok(!hasGsdHooksBlock(path.join(root, '.kimi-code', 'config.toml')),
'neither default hooks root may receive the GSD block when both overrides are set');
});
});

View File

@@ -53,12 +53,16 @@ const EXPECTED_COMMAND_DIR = 'commands';
/** Re-run the installer against an EXISTING configDir/root to simulate an
* upgrade/reapply pass (the installer runs its migration planner on every
* invocation — see bin/install.js installAllRuntimes -> install() ->
* runInstallerMigrations, unconditional, not gated on first-install). */
function reinstallOpencode(root, scope = 'global') {
* runInstallerMigrations, unconditional, not gated on first-install).
* #3547 — `configDir` is the runtime's REAL global home (<root>/<globalSuffix>),
* the same shape runMinimalInstall now installs into; reinstalling into the
* bare <root> would target a different config home and never see the
* fabricated legacy state. */
function reinstallOpencode(root, configDir = null, scope = 'global') {
const args = [INSTALL_SCRIPT, '--opencode'];
let cwd = process.cwd();
if (scope === 'global') {
args.push('--global', '--config-dir', root);
args.push('--global', '--config-dir', configDir || root);
} else {
args.push('--local');
cwd = root;
@@ -305,7 +309,7 @@ describe('#2329: upgrading an install with an orphaned command/ dir migrates it
assert.ok(gsdMdFiles(legacyDir).length >= 60, 'sanity: legacyDir must be populated before reinstall');
assert.ok(!fs.existsSync(pluralDir), 'sanity: pluralDir must not exist before reinstall (fabricated pre-fix state)');
const result = reinstallOpencode(root, 'global');
const result = reinstallOpencode(root, configDir, 'global');
assert.strictEqual(
result.status, 0,
`reinstall (upgrade) must exit 0\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
@@ -341,7 +345,7 @@ describe('#2329: upgrading an install with an orphaned command/ dir migrates it
const userFileLegacy = path.join(legacyDir, 'my-notes.md');
fs.writeFileSync(userFileLegacy, userContent, 'utf8');
const result = reinstallOpencode(root, 'global');
const result = reinstallOpencode(root, configDir, 'global');
assert.strictEqual(
result.status, 0,
`reinstall (upgrade) must exit 0\nstdout: ${result.stdout}\nstderr: ${result.stderr}`
@@ -406,7 +410,7 @@ describe('#2329: upgrading an install with an orphaned command/ dir migrates it
`sanity: manifest must record >=60 "command/" keys before reinstall, got ${legacyManifestKeys.length}`
);
const result = reinstallOpencode(root, 'global');
const result = reinstallOpencode(root, configDir, 'global');
assert.strictEqual(
result.status, 0,
`reinstall (upgrade) must exit 0\nstdout: ${result.stdout}\nstderr: ${result.stderr}`

View File

@@ -80,6 +80,12 @@ const RUNTIMES = Object.keys(RUNTIME_META);
* assert success — callers decide (row 36 expects failure). */
function spawnGlobalInstall(installScript, runtime, extraArgs = []) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-2930-dest-${runtime}-`));
// #3547 — same real config-home shape as runMinimalInstall: the strict
// <root>/<globalSuffix> subdirectory, not the collapsed <root>. The size
// comparisons below normalize each side's own root out of the text; a
// collapsed stub against a real-shape install bakes in a `.claude`-style
// suffix delta that has nothing to do with the compose wiring under test.
const configDir = path.join(root, RUNTIME_META[runtime].globalSuffix);
const args = [
'--preserve-symlinks',
'--preserve-symlinks-main',
@@ -87,7 +93,7 @@ function spawnGlobalInstall(installScript, runtime, extraArgs = []) {
`--${runtime}`,
'--global',
'--config-dir',
root,
configDir,
...extraArgs,
];
const seamResult = runNode(args, {
@@ -96,7 +102,7 @@ function spawnGlobalInstall(installScript, runtime, extraArgs = []) {
timeoutMs: INSTALL_TIMEOUT_MS,
});
const result = { status: seamResult.exitCode, stdout: seamResult.stdout, stderr: seamResult.stderr };
return { result, configDir: root, root };
return { result, configDir, root };
}
/** Convert native path separators to POSIX forward slashes, unconditionally