* test(#3738): antigravity global skills/agents must resolve under ~/.gemini/config Regression tests (RED first): --skills-root and gsd-tools query surfaces, install-plan dest dirs, and converter skills-path rewrite. * fix(#3738): antigravity global skills/agents install to ~/.gemini/config Antigravity's machine-local discovery scans ~/.gemini/config/{skills,agents}; the configHome (~/.gemini/antigravity) is deprecated for artifacts. Declare the ADR-1239 skills/agents 'home' override on the antigravity global layout — the same mechanism codex uses (.agents) — and divert ~/.claude/skills/ references in converted global content to ~/.gemini/config/skills/. configHome, settings, probe/migration semantics, and the local .agents layout are unchanged. * fix(#3738): retire deprecated configHome artifacts via installer migration 010 Next install converges an existing antigravity install: manifest-managed skills/gsd-*/ and agents/gsd-*.md under the configHome (a location AGY does not scan) are removed — modified files backed up first, unmanifested and non-gsd entries preserved — and now-empty containers retired. Global scope only; the local .agents surface is live. Docs + inventory updated. * fix(#3738): converter sync in bin/install.js, harness emit-root coverage, migration baseline - bin/install.js converter gains the same ~/.claude/skills → ~/.gemini/config/ rewrite as src (ADR-1508 dual copy must stay in sync). - Parity-manifest walk covers home-override emit roots (extraEmitRootsFor) so antigravity's emitted skills/agents stay differential-visible at their new install root; install-tree fixture regen confirms an unchanged key set. - skills-from-commands rule declares the antigravity converter as a runtime-scoped transform; one ack fragment covers the identity-classed workflow whose antigravity copy embeds the old skills path. - Migration 010 checksum baseline + home-override set doc updated; existing tests updated to the #3738 contract (global dest, golden parity via layout dest, integration expectations). * fix(#3738): tolerate an absent extra emit root on baseline-side measurement The base tree's installer predates the home override, so <HOME>/.gemini/config does not exist there; walk() threw ENOENT and the in-job baseline build failed. An absent extra root is the legitimate pre-override shape — skip it. * fix(#3738): review findings — manifest agents root, bare skills-path rewrite, guard comment - writeManifest resolves the agents-kind home override (_kindDestDirSafe), so the manifest records agents at their actual install root and drift detection keeps working (isolated review finding 1, major). - Converter bare forms ~/.claude/skills and $HOME/.claude/skills (no trailing slash) divert to ~/.gemini/config/skills instead of falling through to the retired configHome path (finding 2). - real-home-guard comment updated: antigravity's global agents kind is the first agents-kind home override (finding 3, doc-only). - Regression tests for both behavioral findings. * chore(#3738): changeset fragment (pr number backfilled after PR creation) * chore(#3738): backfill changeset PR number (3921) * fix(#3738): sandbox HOME in tests that install antigravity global artifacts antigravity is the first home-override runtime in the golden-parity and skills-wrapper suites (codex is not in their runtime lists), so those tests never needed HOME sandboxing — the real-home guard now (correctly) refuses their un-sandboxed global installs on CI, where HOME is the passwd home. * fix(#3738): stop the K3 sequential-sandbox env leak; sandbox L2's home-override plans K3's two back-to-back sandboxHome calls leave HOME pointing at the first sandbox once the after-hooks restore (each call saves the env as it found it, so the second saves the first's sandbox as 'original'). On the windows matrix that leaked gsd-k3-qwen-* home into the L2 property, whose antigravity/global run then (correctly) refused via the #3712 real-home guard — antigravity is the runtime that made L2's plan escape into os.homedir(). K3 now manages the env with a single restore; L2 sandboxes HOME per run, mirroring L1. * fix(#3738): L2 property's HOME sandbox must exist on disk The #3712 guard's sandbox exemption fails closed when identify(effectiveHome) is 'absent' — L2 never created its configDir, so on the windows matrix (tmpdir under the real home) the antigravity/global run refused even with HOME sandboxed. Create the per-run sandbox dir and clean it up. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
10
tests/emitted-drift-acks/3738-antigravity-skills-root.json
Normal file
10
tests/emitted-drift-acks/3738-antigravity-skills-root.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$comment": "Hash ack (#2914 fragment). Reason: #3738 moved antigravity's global skills and agents from the configHome (~/.gemini/antigravity, which AGY does not scan) to ~/.gemini/config via the artifactLayout kind `home` override, and the antigravity content converter now rewrites `~/.claude/skills/` and `$HOME/.claude/skills/` references in GLOBAL installs to `~/.gemini/config/skills/` — the directory Antigravity actually discovers (antigravity.google/docs/skills, /docs/subagents; re-verified 2026-08-26). The skills ripple is attributed permanently by the new runtime-scoped `transforms` on the skills-from-commands rule (ANTIGRAVITY_SKILL_TRANSFORM_SRCS), so this fragment covers the ONE remaining converted path that rule cannot reach: gsd-core/workflows/profile-user.md, an identity-classed gsd-core file whose antigravity-emitted copy embeds `$HOME/.claude/skills/gsd-dev-preferences/SKILL.md` in two display strings (the dev-preferences skill location table). Those strings now point at `$HOME/.gemini/config/skills/…`, matching where the installer actually writes the skill post-#3738; the source workflow's claude-form text is deliberately unchanged. The parity-manifest walk was widened the same PR (extraEmitRootsFor, tests/helpers/install-shared.cjs) so the antigravity family's emitted keys stay manifest-visible at their new install root — without that, all 71 skill keys silently left the differential, which is the exact #3547 blind-spot class this gate exists to prevent.",
|
||||
"version": 1,
|
||||
"paths": {
|
||||
"gsd-core/workflows/profile-user.md": {
|
||||
"runtime": "antigravity",
|
||||
"reason": "#3738: the antigravity-emitted copy of this workflow embeds the global dev-preferences skill location in two display strings ('$HOME/.claude/skills/gsd-dev-preferences/SKILL.md'); post-#3738 the installer writes that skill to $HOME/.gemini/config/skills/, so the converter rewrites the strings to the scanned dir and the emitted hash moves with no gsd-core/workflows/profile-user.md source change. The gsd-core-verbatim rule is identity-classed and cannot declare the converter as a transform, so the ripple is acked here; the claude-family copy is byte-identical to base and does not ripple."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1165,12 +1165,29 @@ describe('installRuntimeArtifacts — K3: real install before/after, full recurs
|
||||
for (const runtime of ['claude', 'qwen']) {
|
||||
const dirA = createTempDir(`gsd-k3-${runtime}-a-`);
|
||||
const dirB = createTempDir(`gsd-k3-${runtime}-b-`);
|
||||
t.after(() => { cleanup(dirA); cleanup(dirB); });
|
||||
// Two SEQUENTIAL sandboxes for one test: sandboxHome()'s per-call
|
||||
// t.after hooks each save the env as they found it, so the second call
|
||||
// saves the FIRST sandbox as its "original" — hook ordering then leaves
|
||||
// HOME pointing at dirA after the test, leaking into later tests in the
|
||||
// shard (observed on the windows matrix: a leaked gsd-k3-qwen-* home
|
||||
// made the L2 property's antigravity/global run refuse via the
|
||||
// #3712 real-home guard). Manage the env directly with ONE restore.
|
||||
const savedHome = process.env.HOME;
|
||||
const savedUserProfile = process.env.USERPROFILE;
|
||||
const savedMarker = process.env.GSD_TEST_HOME_SANDBOX;
|
||||
t.after(() => {
|
||||
if (savedHome === undefined) delete process.env.HOME; else process.env.HOME = savedHome;
|
||||
if (savedUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = savedUserProfile;
|
||||
if (savedMarker === undefined) delete process.env.GSD_TEST_HOME_SANDBOX; else process.env.GSD_TEST_HOME_SANDBOX = savedMarker;
|
||||
cleanup(dirA); cleanup(dirB);
|
||||
});
|
||||
|
||||
sandboxHome(t, dirA);
|
||||
installRuntimeArtifacts(runtime, dirA, 'global', RESOLVED_FULL);
|
||||
sandboxHome(t, dirB);
|
||||
installRuntimeArtifacts(runtime, dirB, 'global', RESOLVED_FULL);
|
||||
for (const dir of [dirA, dirB]) {
|
||||
process.env.HOME = dir;
|
||||
process.env.USERPROFILE = dir;
|
||||
process.env.GSD_TEST_HOME_SANDBOX = dir;
|
||||
installRuntimeArtifacts(runtime, dir, 'global', RESOLVED_FULL);
|
||||
}
|
||||
|
||||
const filesA = walkFilesRecursively(dirA);
|
||||
const filesB = walkFilesRecursively(dirB);
|
||||
@@ -1260,17 +1277,36 @@ describe('installRuntimeArtifacts — L2: plan is deterministic (property)', ()
|
||||
};
|
||||
}
|
||||
|
||||
test('plan is deterministic', () => {
|
||||
test('plan is deterministic', (t) => {
|
||||
const runtimes = Object.keys(registry.runtimes);
|
||||
const RUNTIME_ARB = fc.constantFrom(...runtimes);
|
||||
const SCOPE_ARB = fc.constantFrom('global', 'local');
|
||||
let hits = 0;
|
||||
// #3738: the per-run HOME sandbox must EXIST on disk — the #3712 guard's
|
||||
// sandbox exemption fails closed when it cannot stat the effective home
|
||||
// (identify() -> 'absent'), which is exactly what a never-created configDir
|
||||
// gives it on the windows matrix where tmpdir sits under the real home.
|
||||
const createdL2Dirs = [];
|
||||
t.after(() => { for (const d of createdL2Dirs) cleanup(d); });
|
||||
|
||||
fc.assert(
|
||||
fc.property(RUNTIME_ARB, SCOPE_ARB, (runtime, scope) => {
|
||||
// configDir is never created for real — both calls run against fresh,
|
||||
// independent fake adapters, so no real fs cleanup is needed here.
|
||||
const configDir = path.join(os.tmpdir(), `gsd-l2-${runtime}-${crypto.randomUUID()}`);
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
createdL2Dirs.push(configDir);
|
||||
// #3738: a home-override runtime (antigravity → <HOME>/.gemini/config)
|
||||
// resolves its dest from os.homedir(), NOT configDir — sandbox HOME to
|
||||
// configDir for the duration of both calls (mirroring L1 above) so the
|
||||
// plan never escapes into an ambient or leaked home and the #3712
|
||||
// real-home guard stays satisfied on hosts where tmpdir sits under the
|
||||
// real home (windows).
|
||||
const savedL2Home = process.env.HOME;
|
||||
const savedL2UserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = configDir;
|
||||
process.env.USERPROFILE = configDir;
|
||||
try {
|
||||
const planA = installRuntimeArtifacts(runtime, configDir, scope, RESOLVED_CORE, undefined, undefined, { fs: createFakeInstallFs() });
|
||||
const planB = installRuntimeArtifacts(runtime, configDir, scope, RESOLVED_CORE, undefined, undefined, { fs: createFakeInstallFs() });
|
||||
hits++;
|
||||
@@ -1281,6 +1317,10 @@ describe('installRuntimeArtifacts — L2: plan is deterministic (property)', ()
|
||||
`L2 (${runtime}/${scope}): two installs against fresh fake adapters with the same inputs must ` +
|
||||
'yield structurally identical plans (temp-dir names normalized — see normalizePlanForIdempotence)',
|
||||
);
|
||||
} finally {
|
||||
if (savedL2Home === undefined) delete process.env.HOME; else process.env.HOME = savedL2Home;
|
||||
if (savedL2UserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = savedL2UserProfile;
|
||||
}
|
||||
}),
|
||||
{ numRuns: 30, seed: 2874, verbose: true },
|
||||
);
|
||||
|
||||
@@ -27,7 +27,7 @@ const { runNode } = require('./helpers/process-seam.cjs');
|
||||
const { throwIfFailed } = require('./helpers/git-fixture.cjs');
|
||||
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { RUNTIME_META, runMinimalInstall, BUILD_SCRIPT, buildInstallTree } = require('./helpers/install-shared.cjs');
|
||||
const { RUNTIME_META, runMinimalInstall, BUILD_SCRIPT, buildInstallTree, extraEmitRootsFor } = require('./helpers/install-shared.cjs');
|
||||
|
||||
// #3145: class-norm timeout, not a per-suite value — see helpers/timeouts.cjs.
|
||||
const { BUILD_TIMEOUT_MS: BUILD_HOOKS_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
|
||||
@@ -74,7 +74,7 @@ for (const runtime of runtimes) {
|
||||
const { configDir, root } = runMinimalInstall({ runtime, scope: 'global' });
|
||||
let actual;
|
||||
try {
|
||||
actual = buildInstallTree(configDir, root);
|
||||
actual = buildInstallTree(configDir, root, extraEmitRootsFor(runtime, 'global', root));
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
|
||||
@@ -132,7 +132,7 @@ const { installRuntimeArtifacts } = require('../gsd-core/bin/lib/install-engine.
|
||||
const { applySurface } = require('../gsd-core/bin/lib/surface.cjs');
|
||||
const { loadSkillsManifest, resolveProfile } = require('../gsd-core/bin/lib/install-profiles.cjs');
|
||||
const { resolveRuntimeArtifactLayout } = require('../gsd-core/bin/lib/runtime-artifact-layout.cjs');
|
||||
const { cleanup } = require('./helpers.cjs');
|
||||
const { cleanup, sandboxHome } = require('./helpers.cjs');
|
||||
|
||||
const COMMANDS_GSD = path.join(ROOT, 'commands', 'gsd');
|
||||
|
||||
@@ -171,12 +171,22 @@ describe('#1575 — golden-parity: surface path matches install path for descrip
|
||||
test(`${runtime}: surface agents byte-identical to install agents`, (t) => {
|
||||
const configDir = fs.mkdtempSync(path.join(os.tmpdir(), `gsd-1575-${runtime}-`));
|
||||
t.after(() => { try { cleanup(configDir); } catch { /* best-effort */ } });
|
||||
// #3738: antigravity's skills/agents kinds declare a global `home`
|
||||
// override resolved from os.homedir() — sandbox HOME to the configDir
|
||||
// (the #3712 marker real-home-guard needs) so the override resolves
|
||||
// inside the sandbox instead of the runner's real home.
|
||||
sandboxHome(t, configDir);
|
||||
|
||||
// Step 1: install path writes agents
|
||||
installRuntimeArtifacts(runtime, configDir, 'global', parity1575Profile, resolveAttribution1575);
|
||||
|
||||
// Step 2: snapshot agent files
|
||||
const agentsDir = path.join(configDir, 'agents');
|
||||
// Step 2: snapshot agent files at the installer's REAL destination —
|
||||
// honor the kind `home` override (codex → ~/.agents, antigravity →
|
||||
// ~/.gemini/config per #3738) exactly like assertDestWithinConfigHome's
|
||||
// root selection, never assume configDir/agents.
|
||||
const parityLayout = resolveRuntimeArtifactLayout(runtime, configDir, 'global');
|
||||
const parityAgentsKind = parityLayout.kinds.find((k) => k.kind === 'agents');
|
||||
const agentsDir = path.join(parityAgentsKind.home ?? configDir, parityAgentsKind.destSubpath);
|
||||
const installSnap = snapshotAgents(agentsDir);
|
||||
assert.ok(installSnap.size > 0, `${runtime}: install must produce at least one gsd-* agent`);
|
||||
|
||||
|
||||
@@ -160,6 +160,18 @@ const AGENT_TRANSFORM_SRCS = [
|
||||
'src/model-catalog.cts',
|
||||
];
|
||||
|
||||
// #3738: antigravity's global skills pass through the antigravity converter
|
||||
// (src/runtime-artifact-conversion.cts, mirrored hand-authored in bin/install.js
|
||||
// per ADR-1508), whose rewrites — e.g. ~/.claude/skills/ → ~/.gemini/config/skills/
|
||||
// — can move emitted bytes with NO commands/gsd source changing. Declaring the
|
||||
// transform here attributes that ripple class permanently, the same way
|
||||
// AGENT_TRANSFORM_SRCS does for agents; scoped to runtime 'antigravity' so a
|
||||
// converter change never blankets the other skills runtimes' attribution.
|
||||
const ANTIGRAVITY_SKILL_TRANSFORM_SRCS = [
|
||||
'src/runtime-artifact-conversion.cts',
|
||||
'bin/install.js',
|
||||
];
|
||||
|
||||
/**
|
||||
* A `sources` entry ending in `/` is a PREFIX, not a file: it means "any repo path
|
||||
* under this directory legitimately explains this emitted path". Used where an
|
||||
@@ -481,6 +493,9 @@ const PROVENANCE_RULES = [
|
||||
roots: SKILLS_ROOTS,
|
||||
pattern: /^([^/]+)\/SKILL\.md$/,
|
||||
sources: (m) => [`${COMMANDS_SRC}/${stripSkillPrefix(m[1])}.md`],
|
||||
// #3738: see ANTIGRAVITY_SKILL_TRANSFORM_SRCS above — antigravity's skill
|
||||
// bytes are converter-produced, so a converter change explains the ripple.
|
||||
transforms: (_m, ctx) => (ctx.runtime === 'antigravity' ? ANTIGRAVITY_SKILL_TRANSFORM_SRCS : []),
|
||||
},
|
||||
{
|
||||
id: 'skills-nested-from-commands',
|
||||
|
||||
@@ -42,6 +42,7 @@ const {
|
||||
MINIMUM_MANIFEST_FAMILIES,
|
||||
runMinimalInstall,
|
||||
buildParityManifest,
|
||||
extraEmitRootsFor,
|
||||
PKG_VERSION,
|
||||
} = require('./install-shared.cjs');
|
||||
const { mergeAckSources, MAX_ACK_FRAGMENTS } = require('./emitted-diff.cjs');
|
||||
@@ -898,7 +899,12 @@ function currentManifests({ repoRoot } = {}) {
|
||||
for (const { name, runtime, scope } of MANIFEST_FAMILIES) {
|
||||
const { configDir, root } = runMinimalInstall({ runtime, scope, installScript });
|
||||
try {
|
||||
manifests[name] = buildParityManifest(configDir, root, { pkgVersion });
|
||||
manifests[name] = buildParityManifest(configDir, root, {
|
||||
pkgVersion,
|
||||
// #3738: cover home-override emit roots outside configDir (antigravity
|
||||
// → <HOME>/.gemini/config) so the differential keeps seeing them.
|
||||
extraEmitRoots: extraEmitRootsFor(runtime, scope, root),
|
||||
});
|
||||
} finally {
|
||||
cleanup(root);
|
||||
}
|
||||
|
||||
@@ -299,6 +299,32 @@ function walk(dir) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// #3738: runtimes whose GLOBAL artifact layout declares a kind `home` override
|
||||
// that resolves OUTSIDE configDir (antigravity → <HOME>/.gemini/config, the dir
|
||||
// AGY scans for machine-local discovery). The parity walk must cover those
|
||||
// roots too, or every emitted skill/agent silently leaves the manifest the
|
||||
// moment the override appears — exactly the #3547 blind-spot class this
|
||||
// harness exists to prevent (a real install shape the manifest cannot see).
|
||||
// Mirrors the capability registry's artifactLayout `home` fields the same way
|
||||
// RUNTIME_META mirrors configHome; codex's `.agents` override is deliberately
|
||||
// NOT listed here — its skills have never been manifest-covered, and widening
|
||||
// this table for codex is a coverage change unrelated to #3738.
|
||||
const EXTRA_GLOBAL_EMIT_ROOTS = {
|
||||
antigravity: [path.join('.gemini', 'config')],
|
||||
};
|
||||
|
||||
/**
|
||||
* Absolute extra emit-root directories for a runtime/scope pair — the
|
||||
* home-override install roots outside configDir — or [] when none.
|
||||
* @param {string} runtime
|
||||
* @param {string} scope
|
||||
* @param {string} root the sandboxed HOME/temp root the install ran under
|
||||
*/
|
||||
function extraEmitRootsFor(runtime, scope, root) {
|
||||
const suffixes = (scope === 'global' && EXTRA_GLOBAL_EMIT_ROOTS[runtime]) || [];
|
||||
return suffixes.map((suffix) => path.join(root, suffix));
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a deterministic hash-map of all non-volatile files under configDir.
|
||||
*
|
||||
@@ -391,7 +417,29 @@ function collectNormalizedEmittedFiles(configDir, root, opts, callerName) {
|
||||
'Pass the version of the tree that produced the emitted content at configDir.'
|
||||
);
|
||||
}
|
||||
const allFiles = walk(configDir);
|
||||
// #3738: home-override emit roots outside configDir (see
|
||||
// EXTRA_GLOBAL_EMIT_ROOTS). Each extra root's files are keyed rel to THAT
|
||||
// root — the emitted key space is install-location-relative ('skills/…',
|
||||
// 'agents/…'), so the same artifact keeps the same key whether the layout
|
||||
// resolves it under configDir or under the override root. configDir entries
|
||||
// win on collision (a layout would never write both, but a stale leftover
|
||||
// under configDir must not shadow the live emit root).
|
||||
const extraEmitRoots = Array.isArray(opts.extraEmitRoots) ? opts.extraEmitRoots : [];
|
||||
const allFiles = walk(configDir).map((full) => ({ full, relRoot: configDir }));
|
||||
for (const extraRoot of extraEmitRoots) {
|
||||
if (typeof extraRoot !== 'string' || extraRoot.length === 0) {
|
||||
throw new Error(`${callerName}: opts.extraEmitRoots entries must be non-empty absolute paths`);
|
||||
}
|
||||
if (path.resolve(extraRoot) === path.resolve(configDir)) continue;
|
||||
// An absent extra root is a legitimate shape, not an error: the baseline
|
||||
// side measures a BASE tree whose installer may predate the home override
|
||||
// (the artifacts then live under configDir, which IS walked). Only a
|
||||
// root that exists but cannot be read is a failure — walk() surfaces that.
|
||||
let stat;
|
||||
try { stat = fs.statSync(extraRoot); } catch { continue; }
|
||||
if (!stat.isDirectory()) continue;
|
||||
for (const full of walk(extraRoot)) allFiles.push({ full, relRoot: extraRoot });
|
||||
}
|
||||
const unsorted = {};
|
||||
|
||||
// The claude LOCAL install resolves its config dir via realpath, which on macOS
|
||||
@@ -405,11 +453,15 @@ function collectNormalizedEmittedFiles(configDir, root, opts, callerName) {
|
||||
let realRoot = root;
|
||||
try { realRoot = fs.realpathSync(root); } catch { /* root already gone / not resolvable */ }
|
||||
|
||||
for (const full of allFiles) {
|
||||
for (const { full, relRoot } of allFiles) {
|
||||
// Build POSIX-style relative path for cross-platform stability
|
||||
const rel = path.relative(configDir, full).split(path.sep).join('/');
|
||||
const rel = path.relative(relRoot, full).split(path.sep).join('/');
|
||||
|
||||
if (VOLATILE_FILES.has(rel)) continue;
|
||||
// Collision policy stated above: configDir owns the key first; a file in
|
||||
// an extra emit root with an already-claimed rel is the stale-leftover
|
||||
// case, not a second opinion.
|
||||
if (relRoot !== configDir && Object.prototype.hasOwnProperty.call(unsorted, rel)) continue;
|
||||
if (HOOK_CONFIG_FILES.has(path.basename(rel))) continue;
|
||||
if (HOOK_CONFIG_RELATIVE_PATHS.has(rel)) continue;
|
||||
if (EXCLUDED_PREFIXES.some((p) => rel.startsWith(p))) continue;
|
||||
@@ -500,10 +552,15 @@ function buildEmittedSizes(configDir, root, opts = {}) {
|
||||
* normalizes to, never which paths buildParityManifest walks or excludes — so there
|
||||
* is nothing for a caller to pass here, and forwarding one through would only let a
|
||||
* bad version value make a pure file-set query throw for no file-set-shaped reason
|
||||
* (#2891 review FINDING 6; verified no caller passes a third argument —
|
||||
* tests/golden-install-tree.test.cjs, scripts/gen-install-tree-fixtures.cjs). */
|
||||
function buildInstallTree(configDir, root) {
|
||||
return Object.keys(buildParityManifest(configDir, root)).sort();
|
||||
* (#2891 review FINDING 6; verified no caller passes a version argument —
|
||||
* tests/golden-install-tree.test.cjs, scripts/gen-install-tree-fixtures.cjs).
|
||||
* #3738: an OPTIONAL third argument — extraEmitRoots (array, see
|
||||
* extraEmitRootsFor) — is the one non-version thing a file-set query legitimately
|
||||
* needs: the home-override install roots outside configDir. Omitted/null keep the
|
||||
* legacy configDir-only walk, so buildInstallTree(cd, root, null) still equals
|
||||
* buildInstallTree(cd, root). */
|
||||
function buildInstallTree(configDir, root, extraEmitRoots) {
|
||||
return Object.keys(buildParityManifest(configDir, root, { extraEmitRoots })).sort();
|
||||
}
|
||||
|
||||
function simulateHookCopy(hooksSrc, hooksDest) {
|
||||
@@ -752,6 +809,7 @@ module.exports = {
|
||||
buildParityManifest,
|
||||
buildEmittedSizes,
|
||||
buildInstallTree,
|
||||
extraEmitRootsFor,
|
||||
simulateHookCopy,
|
||||
installerEnv,
|
||||
runMinimalInstall,
|
||||
|
||||
@@ -1001,6 +1001,10 @@ describe('skills wrapper threads install scope into converter isGlobal (regressi
|
||||
const globalDir = createTempDir(`gsd-ial-g-${runtime}-`);
|
||||
const localDir = createTempDir(`gsd-ial-l-${runtime}-`);
|
||||
t.after(() => { cleanup(globalDir); cleanup(localDir); });
|
||||
// #3738: antigravity's global skills kind resolves its `home` override
|
||||
// from os.homedir(); sandbox HOME (with the #3712 marker) so the global
|
||||
// install writes inside globalDir instead of the runner's real home.
|
||||
sandboxHome(t, globalDir);
|
||||
|
||||
installRuntimeArtifacts(runtime, globalDir, 'global', RESOLVED_CORE);
|
||||
installRuntimeArtifacts(runtime, localDir, 'local', RESOLVED_CORE);
|
||||
@@ -1009,8 +1013,10 @@ describe('skills wrapper threads install scope into converter isGlobal (regressi
|
||||
const lSkills = resolveRuntimeArtifactLayout(runtime, localDir, 'local').kinds.find(k => k.kind === 'skills');
|
||||
assert.ok(gSkills && lSkills, `${runtime}: must resolve a skills kind for both scopes`);
|
||||
|
||||
const gCombined = readAllSkillMd(path.join(globalDir, gSkills.destSubpath));
|
||||
const lCombined = readAllSkillMd(path.join(localDir, lSkills.destSubpath));
|
||||
// #3738: the global skills tree may live under the kind `home` override
|
||||
// (antigravity → ~/.gemini/config), so honor it like the installer does.
|
||||
const gCombined = readAllSkillMd(path.join(gSkills.home ?? globalDir, gSkills.destSubpath));
|
||||
const lCombined = readAllSkillMd(path.join(lSkills.home ?? localDir, lSkills.destSubpath));
|
||||
|
||||
// Precondition (non-vacuity guard): some core skill carries a ~/.claude
|
||||
// reference, so the GLOBAL install surfaces the global home marker. If this
|
||||
@@ -1108,6 +1114,58 @@ describe('convertClaudeToAntigravityContent bare path replacement (#2418)', () =
|
||||
const count = (result.match(/~\/.gemini\/antigravity\//g) || []).length;
|
||||
assert.strictEqual(count, 1, `Expected exactly 1 replacement, got ${count} in: ${result}`);
|
||||
});
|
||||
|
||||
// #3738: global skills install under ~/.gemini/config/skills (the dir AGY
|
||||
// scans), while gsd-core runtime references stay under configHome. A skills
|
||||
// path must therefore rewrite to the config root, not ~/.gemini/antigravity.
|
||||
test('replaces ~/.claude/skills/ with ~/.gemini/config/skills (#3738)', () => {
|
||||
const input = 'Skill dirs live at `~/.claude/skills/gsd-*/`.';
|
||||
const result = convertClaudeToAntigravityContent(input, true);
|
||||
assert.ok(
|
||||
result.includes('~/.gemini/config/skills/gsd-*/'),
|
||||
`Expected ~/.gemini/config/skills rewrite, got: ${result}`
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('~/.gemini/antigravity/skills'),
|
||||
`Skills must not point at the deprecated dir, got: ${result}`
|
||||
);
|
||||
});
|
||||
|
||||
test('replaces $HOME/.claude/skills/ with $HOME/.gemini/config/skills (#3738)', () => {
|
||||
const input = 'ls $HOME/.claude/skills/';
|
||||
const result = convertClaudeToAntigravityContent(input, true);
|
||||
assert.ok(
|
||||
result.includes('$HOME/.gemini/config/skills/'),
|
||||
`Expected $HOME/.gemini/config/skills rewrite, got: ${result}`
|
||||
);
|
||||
assert.ok(!result.includes('$HOME/.claude/'), `Expected full replacement, got: ${result}`);
|
||||
});
|
||||
|
||||
test('replaces bare ~/.claude/skills (no trailing slash) with ~/.gemini/config/skills (#3738)', () => {
|
||||
const input = 'ls ~/.claude/skills';
|
||||
const result = convertClaudeToAntigravityContent(input, true);
|
||||
assert.ok(
|
||||
result.includes('~/.gemini/config/skills'),
|
||||
`bare skills form must divert to the config root, got: ${result}`
|
||||
);
|
||||
assert.ok(
|
||||
!result.includes('~/.gemini/antigravity/skills'),
|
||||
`bare skills form must not fall through to the retired configHome path, got: ${result}`
|
||||
);
|
||||
});
|
||||
|
||||
test('keeps gsd-core references under ~/.gemini/antigravity when a skills ref is present (#3738)', () => {
|
||||
const input = 'Read ~/.claude/gsd-core/workflows/x.md then list ~/.claude/skills/.';
|
||||
const result = convertClaudeToAntigravityContent(input, true);
|
||||
assert.ok(
|
||||
result.includes('~/.gemini/antigravity/gsd-core/workflows/x.md'),
|
||||
`gsd-core ref must stay under configHome, got: ${result}`
|
||||
);
|
||||
assert.ok(
|
||||
result.includes('~/.gemini/config/skills/'),
|
||||
`skills ref must move to the config root, got: ${result}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('local install', () => {
|
||||
@@ -7606,3 +7664,54 @@ describe('#3719: real global Claude install — agents/*.md @-refs must resolve
|
||||
assert.deepStrictEqual(failures, [], `local install must not leak @$HOME/ or @~/.claude/:\n${failures.join('\n')}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ── #3738: antigravity global artifacts install under ~/.gemini/config ────────
|
||||
describe('#3738: antigravity global artifacts install under ~/.gemini/config', () => {
|
||||
test('global skills and agents dest dirs resolve under <home>/.gemini/config, not configHome', (t) => {
|
||||
const configDir = createTempDir('gsd-3738-antigravity-');
|
||||
t.after(() => cleanup(configDir));
|
||||
sandboxHome(t, configDir);
|
||||
|
||||
const layout = resolveRuntimeArtifactLayout('antigravity', configDir, 'global');
|
||||
const skillsKind = layout.kinds.find(k => k.kind === 'skills');
|
||||
const agentsKind = layout.kinds.find(k => k.kind === 'agents');
|
||||
assert.ok(skillsKind, 'antigravity must have a skills kind');
|
||||
assert.ok(agentsKind, 'antigravity must have an agents kind');
|
||||
const expectedHome = path.join(configDir, '.gemini', 'config');
|
||||
assert.strictEqual(skillsKind.home, expectedHome, 'skills home override must be ~/.gemini/config');
|
||||
assert.strictEqual(agentsKind.home, expectedHome, 'agents home override must be ~/.gemini/config');
|
||||
|
||||
installRuntimeArtifacts('antigravity', configDir, 'global', RESOLVED_CORE);
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(expectedHome, 'skills', 'gsd-help', 'SKILL.md')),
|
||||
'a gsd-* skill must exist under ~/.gemini/config/skills'
|
||||
);
|
||||
const agentsDir = path.join(expectedHome, 'agents');
|
||||
assert.ok(fs.existsSync(agentsDir), '~/.gemini/config/agents must exist');
|
||||
assert.ok(
|
||||
fs.readdirSync(agentsDir).some(n => n.startsWith('gsd-')),
|
||||
'at least one gsd-* agent must exist under ~/.gemini/config/agents'
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(configDir, 'skills')),
|
||||
'no skills dir may be created under the configHome (~/.gemini/antigravity)'
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(configDir, 'agents')),
|
||||
'no agents dir may be created under the configHome (~/.gemini/antigravity)'
|
||||
);
|
||||
});
|
||||
|
||||
test('local (workspace) layout is unchanged: .agents/skills and .agents/agents', (t) => {
|
||||
const configDir = createTempDir('gsd-3738-antigravity-local-');
|
||||
t.after(() => cleanup(configDir));
|
||||
sandboxHome(t, configDir);
|
||||
|
||||
const layout = resolveRuntimeArtifactLayout('antigravity', configDir, 'local');
|
||||
for (const kind of layout.kinds) {
|
||||
assert.strictEqual(kind.home, undefined, `local ${kind.kind} must not carry a home override`);
|
||||
}
|
||||
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1317,7 +1317,7 @@ describe('antigravity local install writes to .agents/ canonical dir (#791)', ()
|
||||
'.agent/ must not be created by a fresh install (new installs use .agents/)');
|
||||
});
|
||||
|
||||
test('global antigravity install still writes to ~/.gemini/antigravity (unchanged)', () => {
|
||||
test('global antigravity install writes skills/agents to ~/.gemini/config, runtime files to the configHome (#3738)', () => {
|
||||
const homeDir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ag-global-'));
|
||||
const savedHome = process.env.HOME;
|
||||
const savedUserProfile = process.env.USERPROFILE;
|
||||
@@ -1332,13 +1332,39 @@ describe('antigravity local install writes to .agents/ canonical dir (#791)', ()
|
||||
result.configDir.startsWith(homeDir),
|
||||
`global antigravity install must go under HOME, got: ${result.configDir}`,
|
||||
);
|
||||
// #3738: Antigravity scans ~/.gemini/config for machine-local discovery,
|
||||
// so skills and agents install under the global layout's home override…
|
||||
const configRoot = path.join(homeDir, '.gemini', 'config');
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(result.configDir, 'skills')),
|
||||
'global antigravity install must create skills/ under ~/.gemini/antigravity',
|
||||
fs.existsSync(path.join(configRoot, 'skills', 'gsd-help', 'SKILL.md')),
|
||||
'global antigravity install must create gsd-* skills under ~/.gemini/config/skills',
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(configRoot, 'agents')),
|
||||
'global antigravity install must create agents/ under ~/.gemini/config',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(result.configDir, 'skills')),
|
||||
'no skills/ may be created under the configHome (~/.gemini/antigravity) — AGY does not scan it (#3738)',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(result.configDir, 'agents')),
|
||||
'no agents/ may be created under the configHome (~/.gemini/antigravity) (#3738)',
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(homeDir, '.agents')),
|
||||
'.agents/ must NOT be created by a global install (global path is ~/.gemini/antigravity)',
|
||||
'.agents/ must NOT be created by a global install (global skills path is ~/.gemini/config)',
|
||||
);
|
||||
// #3738 review finding 1: the manifest must record the agents surface at
|
||||
// its ACTUAL install root — writeManifest resolves the agents-kind home
|
||||
// override, so drift detection sees the files AGY reads.
|
||||
const manifestPath = path.join(result.configDir, 'gsd-file-manifest.json');
|
||||
assert.ok(fs.existsSync(manifestPath), 'global install must write the manifest');
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
const agentKeys = Object.keys(manifest.files).filter((k) => k.startsWith('agents/'));
|
||||
assert.ok(
|
||||
agentKeys.length > 0,
|
||||
`manifest must track agents/ at the home-override root, got keys: ${Object.keys(manifest.files).slice(0, 5).join(', ')}`,
|
||||
);
|
||||
} finally {
|
||||
if (savedHome === undefined) delete process.env.HOME;
|
||||
@@ -6473,6 +6499,10 @@ describe('install.js --skills-root', () => {
|
||||
// #2088 (ADR-1239 upgrade 3): Codex skills resolve to the canonical
|
||||
// $HOME/.agents/skills root (skills-kind home override), not $CODEX_HOME/skills.
|
||||
{ runtime: 'codex', expected: path.join(os.homedir(), '.agents', 'skills') },
|
||||
// #3738: Antigravity global discovery scans ~/.gemini/config/ — skills resolve
|
||||
// to the skills-kind home override, not the configHome (~/.gemini/antigravity)
|
||||
// that still holds settings.json and the gsd-core runtime files.
|
||||
{ runtime: 'antigravity', expected: path.join(os.homedir(), '.gemini', 'config', 'skills') },
|
||||
{ runtime: 'copilot', expected: path.join(os.homedir(), '.copilot', 'skills') },
|
||||
{ runtime: 'cursor', expected: path.join(os.homedir(), '.cursor', 'skills') },
|
||||
{ runtime: 'trae', expected: path.join(os.homedir(), '.trae', 'skills') },
|
||||
@@ -6515,6 +6545,8 @@ describe('#3024: gsd-tools query skills-root', () => {
|
||||
const CASES = [
|
||||
{ runtime: 'claude', expected: path.join(os.homedir(), '.claude', 'skills') },
|
||||
{ runtime: 'codex', expected: path.join(os.homedir(), '.agents', 'skills') },
|
||||
// #3738: the query surface must agree with install.js --skills-root above.
|
||||
{ runtime: 'antigravity', expected: path.join(os.homedir(), '.gemini', 'config', 'skills') },
|
||||
{ runtime: 'cursor', expected: path.join(os.homedir(), '.cursor', 'skills') },
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* TDD tests for installer migration 010:
|
||||
* 2026-08-26-antigravity-retire-confighome-artifacts (#3738)
|
||||
*
|
||||
* Antigravity scans ~/.gemini/config/{skills,agents} for machine-local
|
||||
* discovery; the configHome (~/.gemini/antigravity{,-ide,-cli}) is not
|
||||
* scanned, so pre-#3738 installs placed skills and agents where the runtime
|
||||
* silently ignored them. Since #3738 the global layout installs both kinds
|
||||
* under the .gemini/config home override. This migration converges an
|
||||
* existing install: managed files under the configHome's skills/ and agents/
|
||||
* are removed (or backed up if locally modified), unmanifested files are
|
||||
* preserved, and now-empty container directories are retired. GLOBAL scope
|
||||
* only — the local .agents workspace surface is live and must be untouched.
|
||||
*
|
||||
* Coverage:
|
||||
* 1. only manifested, unmodified skills/ + agents/ -> both trees gone
|
||||
* 2. a manifested skill locally modified -> backed up, not silently deleted
|
||||
* 3. an unmanifested user agent (non-gsd AND gsd-prefixed) -> file AND parent directory preserved
|
||||
* 4. a non-gsd-prefixed entry under skills/ -> untouched, skills/ preserved
|
||||
* 5. claude install with a populated skills/ -> completely untouched (independence)
|
||||
* 6. local scope -> no actions at all
|
||||
* 7. running the migration twice on case 1 -> second run is a clean no-op
|
||||
* 8. a symlink under skills/ pointing outside configDir -> not followed, not deleted through
|
||||
*/
|
||||
|
||||
const { describe, test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const crypto = require('node:crypto');
|
||||
|
||||
const migration = require('../gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs');
|
||||
|
||||
const {
|
||||
classifyArtifact: realClassifyArtifact,
|
||||
readInstallManifest,
|
||||
planInstallerMigrations,
|
||||
applyInstallerMigrationPlan,
|
||||
} = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
||||
const { cleanup, createTempDir } = require('./helpers.cjs');
|
||||
|
||||
function writeFile(root, relPath, content) {
|
||||
const fullPath = path.join(root, relPath);
|
||||
fs.mkdirSync(path.dirname(fullPath), { recursive: true });
|
||||
fs.writeFileSync(fullPath, content, 'utf8');
|
||||
}
|
||||
|
||||
function writeManifest(root, files) {
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'gsd-file-manifest.json'),
|
||||
JSON.stringify(
|
||||
{
|
||||
version: '1.11.0',
|
||||
timestamp: '2026-08-26T00:00:00.000Z',
|
||||
mode: 'full',
|
||||
files,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
|
||||
function hashOf(root, relPath) {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(path.join(root, relPath))).digest('hex');
|
||||
}
|
||||
|
||||
function makePlanCtx(configDir, runtime = 'antigravity', scope = 'global') {
|
||||
const manifest = readInstallManifest(configDir);
|
||||
return {
|
||||
configDir,
|
||||
runtime,
|
||||
scope,
|
||||
classifyArtifact: (relPath) => realClassifyArtifact(configDir, relPath, manifest),
|
||||
};
|
||||
}
|
||||
|
||||
function runFullMigration(configDir, runtime = 'antigravity', scope = 'global') {
|
||||
const plan = planInstallerMigrations({
|
||||
configDir,
|
||||
runtime,
|
||||
scope,
|
||||
migrations: [migration],
|
||||
});
|
||||
assert.deepEqual(plan.blocked, [], 'no action should ever require a prompt or be blocked as unknown');
|
||||
if (plan.actions.length === 0) return plan;
|
||||
applyInstallerMigrationPlan({ configDir, plan });
|
||||
return plan;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('migration 010 metadata', () => {
|
||||
test('exports a single migration object with the required authoring fields', () => {
|
||||
assert.equal(typeof migration, 'object');
|
||||
assert.equal(typeof migration.id, 'string');
|
||||
assert.equal(migration.id, '2026-08-26-antigravity-retire-confighome-artifacts');
|
||||
assert.equal(typeof migration.title, 'string');
|
||||
assert.equal(typeof migration.description, 'string');
|
||||
assert.equal(typeof migration.introducedIn, 'string');
|
||||
assert.deepEqual(migration.runtimes, ['antigravity']);
|
||||
assert.deepEqual(migration.scopes, ['global']);
|
||||
assert.strictEqual(migration.destructive, true);
|
||||
assert.equal(typeof migration.plan, 'function');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Only manifested, unmodified files -> both trees gone
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('migration 010: fully managed configHome skills/ + agents/', () => {
|
||||
test('removes manifested unmodified files and then the emptied directories', (t) => {
|
||||
const dir = createTempDir('gsd-migration-010-');
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFile(dir, 'skills/gsd-help/SKILL.md', '# help\n');
|
||||
writeFile(dir, 'skills/gsd-plan/refs/a.md', '# a\n');
|
||||
writeFile(dir, 'agents/gsd-executor.md', '# executor\n');
|
||||
writeManifest(dir, {
|
||||
'skills/gsd-help/SKILL.md': hashOf(dir, 'skills/gsd-help/SKILL.md'),
|
||||
'skills/gsd-plan/refs/a.md': hashOf(dir, 'skills/gsd-plan/refs/a.md'),
|
||||
'agents/gsd-executor.md': hashOf(dir, 'agents/gsd-executor.md'),
|
||||
});
|
||||
|
||||
const plan = runFullMigration(dir);
|
||||
|
||||
assert.ok(plan.actions.some(a => a.type === 'remove-managed' && a.relPath === 'skills/gsd-help/SKILL.md'));
|
||||
assert.ok(plan.actions.some(a => a.type === 'remove-managed' && a.relPath === 'agents/gsd-executor.md'));
|
||||
assert.ok(!fs.existsSync(path.join(dir, 'skills')), 'emptied skills/ directory must be removed');
|
||||
assert.ok(!fs.existsSync(path.join(dir, 'agents')), 'emptied agents/ directory must be removed');
|
||||
});
|
||||
|
||||
test('second run on the converged install is a clean no-op', (t) => {
|
||||
const dir = createTempDir('gsd-migration-010-idempotent-');
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFile(dir, 'skills/gsd-help/SKILL.md', '# help\n');
|
||||
writeManifest(dir, {
|
||||
'skills/gsd-help/SKILL.md': hashOf(dir, 'skills/gsd-help/SKILL.md'),
|
||||
});
|
||||
runFullMigration(dir);
|
||||
|
||||
const plan = runFullMigration(dir);
|
||||
assert.deepEqual(plan.actions, [], 'nothing left to retire');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. A manifested file locally modified -> backed up, not silently deleted
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('migration 010: locally modified manifested skill', () => {
|
||||
test('backs up the modified file before removing it', (t) => {
|
||||
const dir = createTempDir('gsd-migration-010-modified-');
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFile(dir, 'skills/gsd-help/SKILL.md', '# user-patched managed skill\n');
|
||||
// Manifest records a DIFFERENT hash -> managed-modified.
|
||||
writeManifest(dir, { 'skills/gsd-help/SKILL.md': 'a'.repeat(64) });
|
||||
|
||||
const plan = planInstallerMigrations({ configDir: dir, runtime: 'antigravity', scope: 'global', migrations: [migration] });
|
||||
const fileAction = plan.actions.find((a) => a.relPath === 'skills/gsd-help/SKILL.md');
|
||||
assert.ok(fileAction, 'expected an action for the modified file');
|
||||
assert.equal(fileAction.type, 'backup-and-remove');
|
||||
|
||||
const result = applyInstallerMigrationPlan({ configDir: dir, plan });
|
||||
assert.equal(fs.existsSync(path.join(dir, 'skills/gsd-help/SKILL.md')), false, 'the live modified copy is removed');
|
||||
|
||||
const journal = JSON.parse(fs.readFileSync(path.join(dir, result.journalRelPath), 'utf8'));
|
||||
const journaledFileAction = journal.actions.find((a) => a.relPath === 'skills/gsd-help/SKILL.md');
|
||||
assert.ok(journaledFileAction, 'expected the file action in the journal');
|
||||
assert.ok(journaledFileAction.backupRelPath, 'expected a recorded backup path');
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(dir, journaledFileAction.backupRelPath)),
|
||||
true,
|
||||
'the modified file must be recoverable from its backup',
|
||||
);
|
||||
assert.equal(
|
||||
fs.readFileSync(path.join(dir, journaledFileAction.backupRelPath), 'utf8'),
|
||||
'# user-patched managed skill\n',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 3. Unmanifested user files -> preserved, parent dir preserved
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('migration 010: unmanifested files under retired surfaces', () => {
|
||||
test('preserves an unmanifested gsd-prefixed user agent and the agents/ dir', (t) => {
|
||||
const dir = createTempDir('gsd-migration-010-unknown-');
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFile(dir, 'agents/gsd-my-own.md', '# user-written\n');
|
||||
writeManifest(dir, {});
|
||||
|
||||
const plan = runFullMigration(dir);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(dir, 'agents/gsd-my-own.md')), 'unmanifested file preserved');
|
||||
assert.ok(fs.existsSync(path.join(dir, 'agents')), 'parent dir preserved while it holds a user file');
|
||||
assert.ok(!plan.actions.some(a => a.relPath === 'agents/gsd-my-own.md'), 'no action planned against an unknown file');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 4. Non-gsd entries untouched
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('migration 010: non-gsd entries under skills/', () => {
|
||||
test('never walks or removes non-gsd-prefixed entries', (t) => {
|
||||
const dir = createTempDir('gsd-migration-010-nongsd-');
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFile(dir, 'skills/user-skill/SKILL.md', '# user skill\n');
|
||||
writeManifest(dir, {
|
||||
'skills/user-skill/SKILL.md': hashOf(dir, 'skills/user-skill/SKILL.md'),
|
||||
});
|
||||
|
||||
runFullMigration(dir);
|
||||
|
||||
assert.ok(fs.existsSync(path.join(dir, 'skills/user-skill/SKILL.md')), 'non-gsd entry untouched even when manifested');
|
||||
assert.ok(fs.existsSync(path.join(dir, 'skills')), 'skills/ preserved while it holds a non-gsd entry');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 5. Independence: another runtime is never touched
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('migration 010: runtime independence', () => {
|
||||
test('a claude configDir with a populated skills/ tree is completely untouched', (t) => {
|
||||
const dir = createTempDir('gsd-migration-010-claude-');
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFile(dir, 'skills/gsd-help/SKILL.md', '# help\n');
|
||||
writeFile(dir, 'agents/gsd-executor.md', '# executor\n');
|
||||
writeManifest(dir, {
|
||||
'skills/gsd-help/SKILL.md': hashOf(dir, 'skills/gsd-help/SKILL.md'),
|
||||
'agents/gsd-executor.md': hashOf(dir, 'agents/gsd-executor.md'),
|
||||
});
|
||||
|
||||
const plan = runFullMigration(dir, 'claude');
|
||||
|
||||
assert.deepEqual(plan.actions, [], 'claude never produces retirement actions');
|
||||
assert.ok(fs.existsSync(path.join(dir, 'skills/gsd-help/SKILL.md')));
|
||||
assert.ok(fs.existsSync(path.join(dir, 'agents/gsd-executor.md')));
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 6. Local scope -> no actions (the .agents workspace surface is live)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('migration 010: local scope is out of scope', () => {
|
||||
test('plans no actions for a local-scope install even with gsd artifacts present', (t) => {
|
||||
const dir = createTempDir('gsd-migration-010-local-');
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFile(dir, 'skills/gsd-help/SKILL.md', '# help\n');
|
||||
writeManifest(dir, {
|
||||
'skills/gsd-help/SKILL.md': hashOf(dir, 'skills/gsd-help/SKILL.md'),
|
||||
});
|
||||
|
||||
const ctx = makePlanCtx(dir, 'antigravity', 'local');
|
||||
assert.deepEqual(migration.plan(ctx), [], 'local scope must never retire these surfaces');
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 7. Symlink escape is not followed
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('migration 010: symlinked entries are not followed', () => {
|
||||
test('a symlinked gsd- dir under skills/ pointing outside configDir is not traversed', (t) => {
|
||||
const outside = createTempDir('gsd-migration-010-outside-');
|
||||
t.after(() => cleanup(outside));
|
||||
const dir = createTempDir('gsd-migration-010-symlink-');
|
||||
t.after(() => cleanup(dir));
|
||||
|
||||
writeFile(outside, 'SKILL.md', '# outside\n');
|
||||
fs.mkdirSync(path.join(dir, 'skills'), { recursive: true });
|
||||
fs.symlinkSync(outside, path.join(dir, 'skills/gsd-link'));
|
||||
|
||||
const ctx = makePlanCtx(dir);
|
||||
const actions = migration.plan(ctx);
|
||||
|
||||
assert.ok(!actions.some(a => a.relPath.includes('gsd-link')), 'no action planned through the symlink');
|
||||
assert.ok(fs.existsSync(outside), 'the symlink target is untouched');
|
||||
});
|
||||
});
|
||||
@@ -271,6 +271,14 @@ function assertFreshInstallContract(runtime, targetDir) {
|
||||
// the skill dir under that sandboxed home instead of under targetDir.
|
||||
const codexSandboxHome = path.join(path.dirname(targetDir), 'home');
|
||||
assertHasGsdDirectory(path.join(codexSandboxHome, '.agents'), 'skills');
|
||||
} else if (runtime === 'antigravity') {
|
||||
// #3738: Antigravity's machine-local discovery scans ~/.gemini/config, so
|
||||
// global skills install under the sandboxed home's .gemini/config root
|
||||
// (kind `home` override), NOT `<config-dir>/skills`. Same sandboxed-HOME
|
||||
// reasoning as the codex branch above.
|
||||
const agySandboxHome = path.join(path.dirname(targetDir), 'home');
|
||||
assertHasGsdDirectory(path.join(agySandboxHome, '.gemini', 'config'), 'skills');
|
||||
assertHasGsdDirectory(path.join(agySandboxHome, '.gemini', 'config'), 'agents');
|
||||
} else {
|
||||
// Pre-#3562: codex was special-cased to expect zero gsd-* skill dirs
|
||||
// (assumption: Codex auto-discovers from workflows). That assumption
|
||||
@@ -389,10 +397,20 @@ function assertFreshInstallContract(runtime, targetDir) {
|
||||
}
|
||||
|
||||
if (contract.surface !== 'kimi-skills-agents' && contract.surface !== 'global-artifacts-noop' && contract.surface !== 'plugin-only') {
|
||||
assert.ok(
|
||||
listDirNames(targetDir, 'agents').some((name) => name.startsWith('gsd-')),
|
||||
`${runtime} full install should install agents`
|
||||
);
|
||||
if (runtime === 'antigravity') {
|
||||
// #3738: antigravity agents install under the sandboxed home's
|
||||
// .gemini/config root (see the flat-skills branch above), not targetDir.
|
||||
const agySandboxHomeForAgents = path.join(path.dirname(targetDir), 'home');
|
||||
assert.ok(
|
||||
listDirNames(path.join(agySandboxHomeForAgents, '.gemini', 'config'), 'agents').some((name) => name.startsWith('gsd-')),
|
||||
`${runtime} full install should install agents`
|
||||
);
|
||||
} else {
|
||||
assert.ok(
|
||||
listDirNames(targetDir, 'agents').some((name) => name.startsWith('gsd-')),
|
||||
`${runtime} full install should install agents`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
|
||||
@@ -1694,6 +1694,14 @@ test('shipped installer-migration checksums are locked to a committed baseline (
|
||||
// itself retired via the new remove-empty-dir action.
|
||||
'2026-08-07-pi-retire-reserved-hooks-dir':
|
||||
'sha256:34264415b00e15e5a1691eae3db9bd24dca11e5c04d78358420a7a8adf115f9e',
|
||||
// Migration 010 (NEW, added here per this test's own sanctioned "adding a new
|
||||
// migration" case): retire Antigravity's configHome skills/agents surfaces
|
||||
// (#3738). Antigravity scans ~/.gemini/config for global discovery, so
|
||||
// pre-#3738 artifacts sat in a dir the runtime never read; since #3738 both
|
||||
// kinds install under the .gemini/config home override and this migration
|
||||
// converges upgraded installs. Global scope only — local .agents is live.
|
||||
'2026-08-26-antigravity-retire-confighome-artifacts':
|
||||
'sha256:52764c43418accdc26c89482b1a46bf9ebcde1c1d9a80e302971a7b068a3be3c',
|
||||
};
|
||||
|
||||
const { DEFAULT_MIGRATIONS_DIR, migrationChecksum: computeChecksum } = require('../gsd-core/bin/lib/installer-migrations.cjs');
|
||||
|
||||
@@ -1180,13 +1180,14 @@ describe('skills-kind destination parity: installer vs surface-apply (#2911)', (
|
||||
|
||||
test('registry home-override discrimination report (#2911)', () => {
|
||||
const overrides = runtimesWithHomeOverride();
|
||||
// Stated per the brief: at time of writing only codex/global has a `home`
|
||||
// override, so this parity test discriminates on exactly one runtime/scope
|
||||
// pair. This assertion documents that fact and fails loudly if the set
|
||||
// ever changes shape unexpectedly empty (a discrimination-less parity
|
||||
// test would be silently vacuous).
|
||||
// Stated per the brief: at time of writing only codex/global had a `home`
|
||||
// override; #3738 added antigravity/global (skills AND agents →
|
||||
// ~/.gemini/config, the dir AGY scans). This parity test discriminates on
|
||||
// exactly these runtime/scope pairs. This assertion documents that fact and
|
||||
// fails loudly if the set ever changes shape unexpectedly empty (a
|
||||
// discrimination-less parity test would be silently vacuous).
|
||||
assert.ok(overrides.length > 0, 'expected at least one runtime/scope with a home override (codex/global)');
|
||||
assert.deepStrictEqual(overrides, ['codex/global'], `home-override set changed — update this test's documentation. Found: ${overrides.join(', ')}`);
|
||||
assert.deepStrictEqual(overrides, ['antigravity/global', 'codex/global'], `home-override set changed — update this test's documentation. Found: ${overrides.join(', ')}`);
|
||||
});
|
||||
|
||||
for (const scope of ['global', 'local']) {
|
||||
|
||||
Reference in New Issue
Block a user