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

@@ -0,0 +1,211 @@
/**
* Installer migration: retire Antigravity's configHome skills/agents surfaces (#3738).
*
* Antigravity's machine-local discovery scans ~/.gemini/config/{skills,agents}
* (antigravity.google/docs/skills, /docs/subagents); the configHome
* (~/.gemini/antigravity{,-ide,-cli}) is not scanned for global artifacts, so
* skills installed there are silently ignored. Since #3738 the global layout
* declares a `home: ".gemini/config"` override for both kinds, and new
* installs land in the scanned dir. This migration converges an existing
* installation: manifest-managed files under the configHome's skills/ and
* agents/ subtrees are removed (modified ones backed up first), unmanifested
* files are preserved, and now-empty container directories are retired.
*
* Scope is GLOBAL only: the local workspace layout (.agents/skills,
* .agents/agents) is the documented, still-live surface and is never touched.
*
* Is the action safe in non-interactive install?
* Yes. Every emitted action type (`remove-managed`, `backup-and-remove`,
* `remove-empty-dir`) is non-interactive and journaled; none requires a
* user choice, and unknown files never produce an action.
*
* See docs/installer-migrations.md#shipped-migrations and the antigravity row
* of docs/installer-migrations.md#runtime-configuration-contract-registry.
*/
import fs from 'node:fs';
import path from 'node:path';
interface ClassifiedArtifact {
classification: string;
[key: string]: unknown;
}
type ActionType = 'remove-managed' | 'backup-and-remove' | 'remove-empty-dir';
interface MigrationAction {
type: ActionType;
relPath: string;
reason: string;
ownershipEvidence: string;
classification?: string;
originalHash?: string | null;
currentHash?: string | null;
}
interface MigrationPlanContext {
configDir: string;
runtime: string | null;
scope: string;
classifyArtifact(relPath: string): ClassifiedArtifact;
}
interface InstallerMigration {
id: string;
title: string;
description: string;
introducedIn: string;
runtimes: string[];
scopes: string[];
destructive: boolean;
plan: (ctx: MigrationPlanContext) => MigrationAction[];
}
/** The two retired configHome-relative artifact subpaths. */
const RETIRED_SUBPATHS = ['skills', 'agents'] as const;
/** Only GSD-owned top-level entries are retirement candidates. */
const GSD_PREFIX = 'gsd-';
const FILE_REASON =
'Antigravity scans ~/.gemini/config/{skills,agents} for global discovery and does not scan the ' +
'configHome, so these artifacts are invisible to the runtime (#3738); the same artifacts reinstall ' +
"under the global layout's .gemini/config home override";
const FILE_OWNERSHIP_EVIDENCE =
'pre-#3738 antigravity installs record skills/gsd-*/** and agents/gsd-*.md keys in ' +
'gsd-file-manifest.json at the configHome root; the migration only walks gsd--prefixed entries ' +
'under the two retired subpaths';
const DIR_REASON =
'the retired container directory is removed only once every GSD-owned entry inside it is gone; a ' +
'directory that still holds an unmanifested user file — or any file-level action that failed to ' +
'apply — is left in place by the emptiness re-check';
const DIR_OWNERSHIP_EVIDENCE =
'skills/ and agents/ at the antigravity configHome root are GSD-installed container directories ' +
'from the pre-#3738 layout; removal is gated on emptiness by the shared remove-empty-dir action';
/**
* Recursively collect files and directories under `root/relDir`, never
* following a symlink (whether it names a file or a directory) and never
* emitting a path that resolves outside `baseResolved`. Mirrors the traversal
* guard in migration 009 (`walkPiHooksTree`).
*/
function walkTree(root: string, relDir: string, baseResolved: string, files: string[], dirs: string[]): void {
const dir = path.join(root, relDir);
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
// Never follow a symlink into or through: it must not be traversed,
// hashed, or removed, regardless of what it points at.
if (entry.isSymbolicLink()) continue;
const relPath = path.posix.join(relDir, entry.name);
const resolved = path.resolve(root, relPath);
if (resolved !== baseResolved && !resolved.startsWith(baseResolved + path.sep)) continue;
if (entry.isDirectory()) {
dirs.push(relPath);
walkTree(root, relPath, baseResolved, files, dirs);
} else if (entry.isFile()) {
files.push(relPath);
}
}
}
const migration: InstallerMigration = {
id: '2026-08-26-antigravity-retire-confighome-artifacts',
title: "Retire Antigravity's configHome skills/agents surfaces",
description:
'Remove manifest-managed skills/gsd-*/ and agents/gsd-*.md under the Antigravity configHome ' +
'(~/.gemini/antigravity{,-ide,-cli}) — a location Antigravity does not scan for global discovery — ' +
'and retire the now-empty container directories, now that the global layout installs both kinds ' +
'under the ~/.gemini/config home override (#3738). Global scope only; the local .agents workspace ' +
'surface is untouched.',
introducedIn: '1.11.0',
runtimes: ['antigravity'],
scopes: ['global'],
destructive: true,
plan: (ctx: MigrationPlanContext): MigrationAction[] => {
// Defense in depth ahead of the framework's own runtimes/scope filters:
// a claude/kimi/etc. skills/ or agents/ directory is a live install
// surface, never a retirement target, and so is antigravity's LOCAL
// .agents/skills layout.
if (ctx.runtime !== 'antigravity') return [];
if (ctx.scope !== 'global') return [];
const actions: MigrationAction[] = [];
for (const subpath of RETIRED_SUBPATHS) {
const surfaceRoot = path.join(ctx.configDir, subpath);
let rootLstat: fs.Stats;
try {
rootLstat = fs.lstatSync(surfaceRoot);
} catch {
continue; // absent -> nothing to retire for this surface, idempotent
}
// Never follow a symlinked surface root: walking through it could plan
// actions against paths outside the config directory entirely.
if (rootLstat.isSymbolicLink() || !rootLstat.isDirectory()) continue;
const baseResolved = path.resolve(ctx.configDir);
const files: string[] = [];
const dirs: string[] = [];
try {
const entries = fs.readdirSync(surfaceRoot, { withFileTypes: true });
for (const entry of entries) {
if (entry.isSymbolicLink() || !entry.name.startsWith(GSD_PREFIX)) continue;
const relPath = path.posix.join(subpath, entry.name);
if (entry.isDirectory()) {
dirs.push(relPath);
walkTree(ctx.configDir, relPath, baseResolved, files, dirs);
} else if (entry.isFile()) {
files.push(relPath);
}
}
} catch {
// Unreadable directory: nothing safe to plan for this surface.
continue;
}
for (const relPath of files) {
const { classification } = ctx.classifyArtifact(relPath);
if (classification === 'managed-pristine') {
actions.push({ type: 'remove-managed', relPath, reason: FILE_REASON, ownershipEvidence: FILE_OWNERSHIP_EVIDENCE });
} else if (classification === 'managed-modified') {
actions.push({ type: 'backup-and-remove', relPath, reason: FILE_REASON, ownershipEvidence: FILE_OWNERSHIP_EVIDENCE });
}
// 'unknown' (user-added, not manifest-recorded): no action, preserved.
// 'missing' / 'managed-missing': impossible here — relPath was just
// discovered by walking the live filesystem, so it currently exists.
}
// Deepest directories first, so a child has already been evaluated
// (and possibly removed) before its parent's own emptiness is
// re-checked by the executor. The surface root itself is appended
// last, unconditionally: the executor's own emptiness re-check is what
// actually decides whether it goes, not this ordering.
const orderedDirs = [...dirs].sort((a, b) => b.split('/').length - a.split('/').length);
orderedDirs.push(subpath);
for (const relPath of orderedDirs) {
actions.push({
type: 'remove-empty-dir',
relPath,
reason: DIR_REASON,
ownershipEvidence: DIR_OWNERSHIP_EVIDENCE,
// Declared, not derived: classifyArtifact() hashes file contents
// via sha256File(), which throws EISDIR against a directory path.
// These relPaths name directories, so classification is stated
// directly (never 'unknown', so the planner's
// unknown-classification block never fires for them).
classification: 'managed-pristine',
originalHash: null,
currentHash: null,
});
}
}
return actions;
},
};
export = migration;

View File

@@ -37,7 +37,7 @@
* a present escape: `installOpencodeFamilySkills`, which sits behind the
* combined-family early return and honors `skillsKindEntry.home` (no
* combined-family runtime declares one), and `installAgentsKindStandalone`, which
* honors `agentsKindEntry.home` and prunes it (no agents kind declares one).
* honors `agentsKindEntry.home` and prunes it (antigravity's global agents kind declares one since #3738; it was the first agents-kind override).
* The sixth is `migrateLegacyDevPreferencesToSkill`, which resolves the skills
* kind's `home` and writes `SKILL.md` under it. It CREATES rather than prunes,
* which is why the first pass of this fix missed it, and it runs from

View File

@@ -925,7 +925,8 @@ function buildKimiAgentArtifacts({
/**
* Apply Antigravity-specific content conversion — path replacement + command name conversion.
* Path mappings depend on install mode:
* Global: ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agents/
* Global: ~/.claude/skills/ → ~/.gemini/config/skills/ (#3738),
* ~/.claude/ → ~/.gemini/antigravity/, ./.claude/ → ./.agents/
* Local: ~/.claude/ → .agents/, ./.claude/ → ./.agents/
* Applied to ALL Antigravity content (skills, agents, engine files).
* @param {string} content - Source content to convert
@@ -934,6 +935,18 @@ function buildKimiAgentArtifacts({
function convertClaudeToAntigravityContent(content, isGlobal = false) {
let c = content;
if (isGlobal) {
// #3738: global skills install under ~/.gemini/config/skills (the dir AGY
// scans for global discovery), so skills-path references must divert there
// — BEFORE the configHome rewrite below, which is correct for gsd-core
// runtime-file references (settings, workflows, VERSION) but wrong for the
// skills dir itself.
c = c.replace(/\$HOME\/\.claude\/skills\//g, '$HOME/.gemini/config/skills/');
c = c.replace(/~\/\.claude\/skills\//g, '~/.gemini/config/skills/');
// Bare skills form (no trailing slash) — must also precede the generic
// slash rule, which would otherwise divert it to the retired configHome
// path ($HOME/.gemini/antigravity/skills).
c = c.replace(/\$HOME\/\.claude\/skills\b/g, '$HOME/.gemini/config/skills');
c = c.replace(/~\/\.claude\/skills\b/g, '~/.gemini/config/skills');
c = c.replace(/\$HOME\/\.claude\//g, '$HOME/.gemini/antigravity/');
c = c.replace(/~\/\.claude\//g, '~/.gemini/antigravity/');
// Bare form (no trailing slash) — must come after slash form to avoid double-replace