fix(#3738): antigravity global skills/agents install to ~/.gemini/config (#3921)

* 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:
Tom Boucher
2026-08-27 02:24:03 -04:00
committed by GitHub
parent e20744eacb
commit 39673ae9ff
29 changed files with 936 additions and 55 deletions

View File

@@ -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,