* 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:
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user