diff --git a/.changeset/jolly-newts-click.md b/.changeset/jolly-newts-click.md new file mode 100644 index 000000000..6b11c4f79 --- /dev/null +++ b/.changeset/jolly-newts-click.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 3921 +--- +**Antigravity global skills and agents now install to `~/.gemini/config/`** — the directory Antigravity actually scans for machine-local discovery, so installed skills are no longer silently ignored at startup. Upgrading an existing install automatically removes the old artifacts from the deprecated `~/.gemini/antigravity` location (modified files are backed up; user-authored files are preserved). (#3738) diff --git a/.gitignore b/.gitignore index dceaf5ada..1db4b3803 100644 --- a/.gitignore +++ b/.gitignore @@ -194,6 +194,7 @@ build/ /gsd-core/bin/lib/installer-migrations/006-pi-extension-cjs-to-js.cjs /gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs /gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs /gsd-core/bin/lib/observability/logger.cjs /gsd-core/bin/lib/active-workstream-store.cjs /gsd-core/bin/lib/adr-parser.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 753c1584e..20aba7c84 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -301,7 +301,7 @@ Module owning install-time staging and content-rewrite selection for a pre-resol Narrow, enumerated fs seam for the `installRuntimeArtifacts` call tree (`src/install-engine.cts`) — lands ADR-58's never-shipped `cleanup` rollout step (`registry → adapter → helpers → cleanup`, #2874, epic #2866 Phase 5). `installRuntimeArtifacts` now returns the executed plan it ran (`{ runtime, scope, kinds: [{kind, sourceDir, destDir, preserved}], cleanup: [{dir, ok}], postSteps }`) instead of `void`, including on the `combinedFamilyInstall` (OpenCode/Kilo) early-return path — no path may return `undefined` after this phase. Failure is unchanged: stage/rewrite errors still throw rather than becoming an `ok:false` value, so a caller cannot read success-shaped data off a failure path. Delivery is an ambient single mutable adapter (`current`), swapped for the duration of one synchronous install via `withInstallFs(deps.fs, fn)` and always restored in a `finally` — a `deps` parameter threaded through every function on the call tree (`install-profiles.cts`, `runtime-artifact-conversion.cts`, `commonjs-marker.cts`, `installer-migrations.cts`'s two reachable entry points) was rejected as a dozen+-site touch for no behavioral gain over the ambient swap, extending rather than replacing `createRuntimeArtifactInstallPlan`'s existing `deps` bag precedent (Runtime Artifact Install Plan Module). An injected `deps.fs` is a PARTIAL adapter merged over real `node:fs`; any method it omits — except `realpathSync`, the one method documented to degrade gracefully — now THROWS immediately if actually called, naming the missing method, rather than silently resolving to the real filesystem (#2875 defect fix: the prior silent fall-through let a fake adapter missing e.g. `rmSync` perform real destructive IO unnoticed). **Routes destination IO only, by design**: every write/probe against the install destination (copies, removals, snapshot/restore of preserved skill dirs, the manifest read) is fake-able; locating this package's own source tree (`findInstallSourceRoot`/`findAgentsSourceRoot`'s walk-up-from-`__dirname`, `readGsdCommandNames`) stays real and unrouted — a destination-fake's store starts empty and was never seeded with the repo's own paths, so routing that lookup would make every fake-adapter install throw instead of staging. The symlink-escape guard (`hasExistingSymlinkBetween`) and `assertDestWithinConfigHome` keep their REFUSAL DECISIONS outside this seam — only their `existsSync`/`lstatSync`/`realpathSync` probes route through it, so an injected fake can change what a probe observes but never flip the security decision itself. Writes remain byte-identical to pre-#2874 (AC4/AC5); existing `void`-ignoring callers (`bin/install.js`) are unaffected. Source: `gsd-core/bin/lib/install-fs-adapter.cjs` (generated from `src/install-fs-adapter.cts`). See Runtime Artifact Install Plan Module, ADR-58. ### Real Home Guard Module -Refuses an in-process install whose destination would land in the developer's REAL home while a Node test runner is in scope (#3712, ADR-1239/#2088 territory). Interface: `assertTestHomeSandboxed(operation, runtime, kinds, deps?)` (throws), `isTestHomeGuardRefusal(err)`, `SANDBOX_MARKER`. Exists because a runtime kind may declare a global `home` override resolved from `os.homedir()` rather than from the caller's `configDir` — codex's skills kind (`home: ".agents"`) is the only live case — so sandboxing `configDir`/`targetDir` does NOT contain it, and `assertDestWithinConfigHome` (Runtime Artifact Install Plan Module) structurally cannot see the class: that gate confines a `destSubpath` to whatever root it is handed, and here the root IS the escaped home. The observed failure was silent destruction — a test file that sandboxed only `targetDir` pruned all 71 `gsd-*` skills from a real `~/.agents/skills` via `_removeGsdEntries` while the suite exited 0 and the manifest still reported a healthy install. **Six writers** resolve a kind `home` and then write or destroy under it and therefore carry a call: `installRuntimeArtifacts`, `uninstallRuntimeArtifacts` (Install Engine Module), `applySurface` (Surface Module), and `migrateLegacyDevPreferencesToSkill` — which CREATES rather than prunes, which is why it was missed on the first pass, and which runs from `_runLegacyInstallMigrations` BEFORE `installRuntimeArtifacts`' own assertion, so it guards the destination it already resolved rather than resolving a second time (generative-fix divergence) — plus `installOpencodeFamilySkills` and `installAgentsKindStandalone`, guarded against a future descriptor change rather than a present escape since no combined-family or agents kind declares a `home` today. Each of the four reachable writers carries an optional `deps: { os?, env? }` tail parameter — the guard's trigger condition is "HOME equals the passwd home", which cannot be simulated without pointing at the developer's real home — so `tests/install-write-confinement.test.cjs` drives the REAL entrypoint for each one and deleting any guard call site turns a row red. `migrateLegacyDevPreferencesToSkill` guards only when `target.hasHomeOverride` — that same resolution's own answer to "did the skills kind declare a `home`?", never inferred from `installRoot !== targetDir`, which is FALSE whenever the override resolves onto `targetDir` itself (a configDir of `$HOME/.agents`, exactly where codex's override points); both directions of that condition are pinned, so widening it to refuse ordinary confined migrations fails a row too. **Gated on `NODE_TEST_CONTEXT`** (set by `node --test`), never on `GSD_TEST_MODE` — several in-process test files, including the one that caused #3712, never set the latter — so ordinary installs outside a Node test context are untouched and codex still installs to `$HOME/.agents` normally. **The predicate asks about the DESTINATION, not about HOME state**, and about the path the write RESOLVES to rather than how it is spelled: a stale layout captured before `sandboxHome()` still names the real `~/.agents` while HOME is sandboxed, so a HOME-state check would wave it through, and an aliased `/.agents` symlink or junction would otherwise redirect an allowed path into the real home. Canonicalization itself fails CLOSED: a component that cannot be resolved (`EACCES`/`EPERM` on a directory whose mode changed, `ELOOP` on a symlink cycle, `EIO` on a failing mount) is REFUSED rather than falling back to the lexical spelling — that fallback was a second, unnamed fail-open, and precisely the ALLOW an unresolvable alias needs, since the sandbox spelling then satisfies the nested-sandbox exemption (Codex review of #3725). Only `ENOENT`/`ENOTDIR` walk up, matching `identify`'s own errno split rather than inventing a second one: both mean "no such path as named", which is the ordinary shape of a destination no install has created yet. A destination inside the real home is exempted only when all three hold — HOME differs from the passwd home by filesystem identity, the passwd home is not itself beneath that HOME (`/Users`, `C:\Users`), and the destination resolves beneath it. That exemption is not a softening: on Windows `os.tmpdir()` is under `%USERPROFILE%`, so EVERY test sandbox is a descendant of the real home and containment alone cannot separate the safe case from the dangerous one — without it the whole Windows matrix refuses. Every containment decision compares by filesystem identity (`st_dev`+`st_ino`), never pathname (the one pathname comparison in the module is `sameDirectory`'s equality shortcut on the marker branch, described below): `path.resolve` resolves neither symlinks nor case and `realpath` returns a canonical pathname two routes to one directory can disagree on. **Fails CLOSED**, with one named exception: where no passwd entry is readable (some CI images) it falls back to a path-valued marker set by `sandboxHome()`/`installSpawnEnv()` in `tests/helpers.cjs`, which must identify, must equal the home in effect, AND must contain every resolved destination. All three are load-bearing: matching HOME attests that a caller sandboxed HOME and says nothing about where these destinations resolve, so on its own the marker waved through the very stale-layout shape the primary branch refuses; and `sameDirectory`'s pathname-equality shortcut can answer yes for two identical UNidentifiable paths, which is not enough to place a destination against. It remains a deliberate weakening, since nothing on such a host can contradict a marker naming the real home. `installSpawnEnv` re-points the marker at an explicitly overridden HOME, so a spawn that supplies its own sandbox is not refused by a marker still naming the helper's default one. Refusals are STAMPED so `bin/install.js` can rethrow them without running `_codexPreConfigRollback()`, which deletes and recreates every snapshotted `gsd-*` directory in the resolved skills root — otherwise the guard's own refusal would provoke the mutation it exists to prevent. **Known limits, stated rather than implied**: a subordinate bind mount of the real `~/.agents` into the sandbox is not closed (a bind mount is not a link, so realpath keeps the mount-point spelling; closing it needs non-portable mount-table introspection), and a cross-process TOCTOU swap between check and write is out of reach. Source: `gsd-core/bin/lib/real-home-guard.cjs` (generated from `src/real-home-guard.cts`). See Install Engine Module, Surface Module, Runtime Artifact Install Plan Module, Runtime Artifact Layout Module. +Refuses an in-process install whose destination would land in the developer's REAL home while a Node test runner is in scope (#3712, ADR-1239/#2088 territory). Interface: `assertTestHomeSandboxed(operation, runtime, kinds, deps?)` (throws), `isTestHomeGuardRefusal(err)`, `SANDBOX_MARKER`. Exists because a runtime kind may declare a global `home` override resolved from `os.homedir()` rather than from the caller's `configDir` — codex's skills kind (`home: ".agents"`) and, since #3738, antigravity's global skills AND agents kinds (`home: ".gemini/config"`, the dir AGY scans for global discovery; configHome stays `~/.gemini/antigravity` for settings/runtime files) are the live cases — so sandboxing `configDir`/`targetDir` does NOT contain it, and `assertDestWithinConfigHome` (Runtime Artifact Install Plan Module) structurally cannot see the class: that gate confines a `destSubpath` to whatever root it is handed, and here the root IS the escaped home. The observed failure was silent destruction — a test file that sandboxed only `targetDir` pruned all 71 `gsd-*` skills from a real `~/.agents/skills` via `_removeGsdEntries` while the suite exited 0 and the manifest still reported a healthy install. **Six writers** resolve a kind `home` and then write or destroy under it and therefore carry a call: `installRuntimeArtifacts`, `uninstallRuntimeArtifacts` (Install Engine Module), `applySurface` (Surface Module), and `migrateLegacyDevPreferencesToSkill` — which CREATES rather than prunes, which is why it was missed on the first pass, and which runs from `_runLegacyInstallMigrations` BEFORE `installRuntimeArtifacts`' own assertion, so it guards the destination it already resolved rather than resolving a second time (generative-fix divergence) — plus `installOpencodeFamilySkills` and `installAgentsKindStandalone`, guarded against a future descriptor change rather than a present escape since no combined-family or agents kind declares a `home` today. Each of the four reachable writers carries an optional `deps: { os?, env? }` tail parameter — the guard's trigger condition is "HOME equals the passwd home", which cannot be simulated without pointing at the developer's real home — so `tests/install-write-confinement.test.cjs` drives the REAL entrypoint for each one and deleting any guard call site turns a row red. `migrateLegacyDevPreferencesToSkill` guards only when `target.hasHomeOverride` — that same resolution's own answer to "did the skills kind declare a `home`?", never inferred from `installRoot !== targetDir`, which is FALSE whenever the override resolves onto `targetDir` itself (a configDir of `$HOME/.agents`, exactly where codex's override points); both directions of that condition are pinned, so widening it to refuse ordinary confined migrations fails a row too. **Gated on `NODE_TEST_CONTEXT`** (set by `node --test`), never on `GSD_TEST_MODE` — several in-process test files, including the one that caused #3712, never set the latter — so ordinary installs outside a Node test context are untouched and codex still installs to `$HOME/.agents` normally. **The predicate asks about the DESTINATION, not about HOME state**, and about the path the write RESOLVES to rather than how it is spelled: a stale layout captured before `sandboxHome()` still names the real `~/.agents` while HOME is sandboxed, so a HOME-state check would wave it through, and an aliased `/.agents` symlink or junction would otherwise redirect an allowed path into the real home. Canonicalization itself fails CLOSED: a component that cannot be resolved (`EACCES`/`EPERM` on a directory whose mode changed, `ELOOP` on a symlink cycle, `EIO` on a failing mount) is REFUSED rather than falling back to the lexical spelling — that fallback was a second, unnamed fail-open, and precisely the ALLOW an unresolvable alias needs, since the sandbox spelling then satisfies the nested-sandbox exemption (Codex review of #3725). Only `ENOENT`/`ENOTDIR` walk up, matching `identify`'s own errno split rather than inventing a second one: both mean "no such path as named", which is the ordinary shape of a destination no install has created yet. A destination inside the real home is exempted only when all three hold — HOME differs from the passwd home by filesystem identity, the passwd home is not itself beneath that HOME (`/Users`, `C:\Users`), and the destination resolves beneath it. That exemption is not a softening: on Windows `os.tmpdir()` is under `%USERPROFILE%`, so EVERY test sandbox is a descendant of the real home and containment alone cannot separate the safe case from the dangerous one — without it the whole Windows matrix refuses. Every containment decision compares by filesystem identity (`st_dev`+`st_ino`), never pathname (the one pathname comparison in the module is `sameDirectory`'s equality shortcut on the marker branch, described below): `path.resolve` resolves neither symlinks nor case and `realpath` returns a canonical pathname two routes to one directory can disagree on. **Fails CLOSED**, with one named exception: where no passwd entry is readable (some CI images) it falls back to a path-valued marker set by `sandboxHome()`/`installSpawnEnv()` in `tests/helpers.cjs`, which must identify, must equal the home in effect, AND must contain every resolved destination. All three are load-bearing: matching HOME attests that a caller sandboxed HOME and says nothing about where these destinations resolve, so on its own the marker waved through the very stale-layout shape the primary branch refuses; and `sameDirectory`'s pathname-equality shortcut can answer yes for two identical UNidentifiable paths, which is not enough to place a destination against. It remains a deliberate weakening, since nothing on such a host can contradict a marker naming the real home. `installSpawnEnv` re-points the marker at an explicitly overridden HOME, so a spawn that supplies its own sandbox is not refused by a marker still naming the helper's default one. Refusals are STAMPED so `bin/install.js` can rethrow them without running `_codexPreConfigRollback()`, which deletes and recreates every snapshotted `gsd-*` directory in the resolved skills root — otherwise the guard's own refusal would provoke the mutation it exists to prevent. **Known limits, stated rather than implied**: a subordinate bind mount of the real `~/.agents` into the sandbox is not closed (a bind mount is not a link, so realpath keeps the mount-point spelling; closing it needs non-portable mount-table introspection), and a cross-process TOCTOU swap between check and write is out of reach. Source: `gsd-core/bin/lib/real-home-guard.cjs` (generated from `src/real-home-guard.cts`). See Install Engine Module, Surface Module, Runtime Artifact Install Plan Module, Runtime Artifact Layout Module. ### User Artifact Staging Module Durable, on-disk staging for `USER_OWNED_ARTIFACTS` (Install Engine Module's `preserveUserArtifacts`/`restoreUserArtifacts` callers) across the preserve → wipe → restore window, closing #1874-F19: an in-memory-only `Map` held across a wipe is silently discarded on process death (Ctrl-C, OOM, a converter throw mid-copy), losing the user's file permanently (#2875, epic #2866 Phase 6, governed by ADR-3574). Interface: `stageUserArtifacts(destDir, fileNames, stagingRoot) -> StagedUserArtifacts`, `restoreStagedUserArtifacts(destDir, staged)`, `discardStagedUserArtifacts(staged)`, `recoverOrphanedUserArtifacts(stagingRoot, configDir) -> RecoveryResult` — four operations rather than two because call sites genuinely differ (one defers to a migration helper instead of restoring; another restores only on migration FAILURE). Synchronous only, every fs call routed through `installFs()` (Install Fs Adapter Module), which now GUARDS a partial injected adapter: any method the partial omits — except the one documented degrade-to-real-fs exception, `realpathSync` — throws immediately if actually called, instead of silently falling through to real fs (#2875 defect fix). Staging layout is fixed by convention — `/.gsd-staging/user-artifacts//{record.json,files/}` — a sibling of every wipe target this phase's four call sites wipe, so staging survives all of them while resolving inside `configDir`; `record.json` is written AFTER every file copy lands, never before, so a half-written staging directory (crash mid-copy) has no record and is never mistaken for a complete one. All staged/restored/recovered names are FLAT (no path separator of either platform's flavor) — matching every real caller's actual usage and rejected the same way traversal/NUL-byte names already were. **Durability alone is not the fix**: a staged copy nothing ever reads back is bytes-safe but user-visibly lost — the #1879-F15 inert-fix failure mode — so `recoverOrphanedUserArtifacts` is wired at the START of `bin/install.js`'s `install()` and `uninstall()`, before the ordinary preserve step, for every runtime; this is the only production entry point that makes recovery reachable rather than merely callable. Its "never throws" contract is enforced with a per-file try/catch (one bad name is reported via `skipped` and the batch continues) wrapped in a per-entry try/catch (one bad batch is reported and the next staging entry is still attempted) — an earlier version left `mkdirSync`/the symlink-safe copy/the final cleanup `rmSync` unguarded, so a single unrecoverable entry (e.g. a directory unexpectedly staged where a file was expected, or `symlinkSync` throwing `EPERM` for an unprivileged Windows user) threw out of the function entirely — before that entry was ever cleaned up — permanently bricking every future install/uninstall (#2875 defect fix). **Carries NO policy** (same discipline as the Install Fs Adapter Module): every path this module writes, and every path recovery reads OUT of an on-disk record before writing to it (attacker-influenceable the moment an install runs on a shared machine), is re-resolved through the SAME `assertDestWithinConfigHome` (Runtime Artifact Install Plan Module) every other write on this call tree uses, THEN through the SAME `hasExistingSymlinkBetween` (Install Engine Module) `_copyStaged`/`migrateLegacyDevPreferencesToSkill` apply to their own writes — never reimplemented, and required lazily (call-time, not module-load-time) specifically to avoid a real circular require with Install Engine Module, which imports this module statically. Lexical confinement (`assertDestWithinConfigHome`) alone cannot see a symlinked ANCESTOR directory between `configDir` and a recorded `destDir`; the `hasExistingSymlinkBetween` re-check closes that gap (#2875 defect fix). `recoverOrphanedUserArtifacts` takes `configDir` as a REQUIRED, EXPLICIT parameter — it is never derived from `stagingRoot`'s own path shape, which would rest the confinement guarantee on a naming convention rather than an explicit caller-supplied value. Never overwrites something already present at the recovered destination, decided by `lstatSync` rather than `existsSync` — `existsSync` FOLLOWS symlinks and reports `false` for a DANGLING one, so it cannot see a dangling symlink an attacker planted at the destination to redirect the eventual `copyFileSync`/`symlinkSync` outside `configDir`; `restoreStagedUserArtifacts` applies the same `lstatSync`-based refusal before writing (#2875 defect fix — both were previously `existsSync`-based). Symlink-safe: staged files copy via Installer Migration Module's `copyPreservingSymlink` (itself newly routed through `installFs()` this phase, all five of its fs calls), which never dereferences a symlink — a managed path replaced by a link to (e.g.) `~/.ssh/id_rsa` cannot have the referent's bytes copied into the staging tree or back out of it; a consumer that reads a staged copy's CONTENT back (rather than re-copying it) must separately check for a staged symlink before `readFileSync`, or it will follow the link and read the referent (Install Engine Module's `_runLegacyInstallMigrations` applies this guard). Staging failure is a HARD throw (not swallowed) so a caller cannot proceed to wipe the source directory having staged nothing — worse than no staging at all; recovery and restore/discard degrade instead (missing files, missing staging root, malformed records are all "nothing to do", never a crash). `stagingRoot` confinement against `configDir`, and the symlinked-staging-root refusal (`hasExistingSymlinkBetween`), are both call-site responsibilities (Install Engine Module's `_resolveUserArtifactStagingRoot`, mirrored locally in `bin/install.js`) — this module accepts no `configDir` parameter to `stageUserArtifacts` and cannot perform that outer check itself. **Known limitation, documented rather than closed**: concurrent installs targeting the SAME `destDir` are not safe against each other — the staging key is `sha256(destDir)`, and both the stage-time entryDir-clear and the recovery-time end-of-batch cleanup unconditionally `rmSync` an `entryDir` they did not necessarily create, so two processes racing the same `destDir` can have one wipe the other's in-flight or just-committed batch; closing this fully needs either a cross-process lock (its own crash-safety design surface) or a guarantee installs never run concurrently against one `configDir`, neither of which this module can decide unilaterally. Explicitly out of scope: fsync durability (crash-safe against process death only, not power loss); routing the raw-`fs` uninstall wipe at Install Engine Module's `_runLegacyUninstallCleanup` (only the staging call itself routes through `installFs()` there — the surrounding wipe stays unrouted, matching Phase 5's deliberate exclusion of the uninstall tree). Source: `gsd-core/bin/lib/user-artifact-staging.cjs` (generated from `src/user-artifact-staging.cts`). See Install Engine Module, Install Fs Adapter Module, Runtime Artifact Install Plan Module, Installer Migration Module, ADR-3574. diff --git a/bin/install.js b/bin/install.js index 934e41fc8..9f73e8467 100755 --- a/bin/install.js +++ b/bin/install.js @@ -680,6 +680,21 @@ function _kindDestDir(layout, kindName, targetDir) { return path.join(kind.home || targetDir, kind.destSubpath); } +/** + * #3738: scope-aware, layout-resolving wrapper over _kindDestDir for callers + * that have (runtime, configDir, scope) rather than a resolved Layout — the + * writeManifest agents surface being the first. Never throws: a runtime whose + * layout cannot be resolved (unknown id, descriptor error) keeps the caller's + * own fallback rather than losing the manifest. + */ +function _kindDestDirSafe(runtime, configDir, scope, kindName) { + try { + return _kindDestDir(resolveRuntimeArtifactLayout(runtime, configDir, scope), kindName, configDir); + } catch { + return null; + } +} + /** * #3664 — warn (never refuse) when `--config-dir` points the install at a * directory whose agent destination already holds FOREIGN (non-GSD) agent @@ -2331,7 +2346,8 @@ function convertClaudeAgentToCopilotAgent(content, isGlobal = false) { /** * 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 @@ -2340,6 +2356,19 @@ function convertClaudeAgentToCopilotAgent(content, isGlobal = false) { 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. Mirrors src/runtime-artifact-conversion.cts (ADR-1508 + // keeps bin/install.js hand-authored; the two copies must stay in sync). + 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 @@ -9636,7 +9665,14 @@ function writeManifest(configDir, runtime = DEFAULT_RUNTIME, options = {}) { const resolvedScope = options.scope === 'local' ? 'local' : 'global'; const codexSkillsDir = _resolveSkillsRootDir(runtime, configDir, resolvedScope); const codexSkillsManifestPrefix = _hostBehaviors(runtime).skillsManifestPrefix || 'skills/'; - const agentsDir = path.join(configDir, 'agents'); + // #3738: resolve the ACTUAL agents-install dir honoring an agents-kind `home` + // override (antigravity global → $HOME/.gemini/config/agents), mirroring + // _resolveSkillsRootDir for skills. Hardcoding configDir/agents left the + // manifest blind to the whole agents surface the moment the override landed — + // no drift detection, no patch backup. Falls back to /agents. + const agentsDir = _kindDestDirSafe(runtime, configDir, resolvedScope, 'agents') + || _kindDestDirSafe(runtime, configDir, resolvedScope, 'kimi-agents') + || path.join(configDir, 'agents'); const manifest = { // Schema version of this DOCUMENT (#2872) — distinct from `version` // below, which is the GSD package version. Absent ⇒ a pre-#2872 (v1) diff --git a/capabilities/antigravity/capability.json b/capabilities/antigravity/capability.json index 7967770c5..22520e83c 100644 --- a/capabilities/antigravity/capability.json +++ b/capabilities/antigravity/capability.json @@ -3,7 +3,7 @@ "role": "runtime", "version": "1.11.0", "title": "Antigravity", - "description": "Google Antigravity IDE — nested under ~/.gemini/antigravity; probed across 1.x and 2.x layouts; Gemini hook event dialect; flat skill layout; tier-1 support.", + "description": "Google Antigravity IDE — config/settings home nested under ~/.gemini/antigravity (probed across 1.x and 2.x layouts); global skills/agents install under ~/.gemini/config, the dir AGY scans for global discovery (#3738); Gemini hook event dialect; flat skill layout; tier-1 support.", "tier": "core", "requires": [], "engines": { @@ -34,7 +34,8 @@ "prefix": "gsd-", "nesting": "flat", "recursive": false, - "converter": "convertClaudeCommandToAntigravitySkill" + "converter": "convertClaudeCommandToAntigravitySkill", + "home": ".gemini/config" }, { "kind": "agents", @@ -42,7 +43,8 @@ "prefix": "gsd-", "nesting": "flat", "recursive": false, - "converter": "convertClaudeAgentToAntigravityAgent" + "converter": "convertClaudeAgentToAntigravityAgent", + "home": ".gemini/config" } ], "local": [ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index a9d5579ea..5439b8d87 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -688,7 +688,7 @@ Equivalent paths for other runtimes: - **Kimi CLI:** first-existing generic global root (`~/.config/agents/` recommended, then `~/.agents/` if its `skills/` directory already exists); local install is deferred and guarded - **Codex:** `~/.codex/` global or `./.codex/` local - **Copilot:** `~/.copilot/` global or `./.github/` local -- **Antigravity:** auto-detected global root (`~/.gemini/antigravity/`, `~/.gemini/antigravity-ide/`, or `~/.gemini/antigravity-cli/`) or `./.agent/` local +- **Antigravity:** auto-detected global root (`~/.gemini/antigravity/`, `~/.gemini/antigravity-ide/`, or `~/.gemini/antigravity-cli/`) for settings and runtime files; global skills/agents under `~/.gemini/config/` (the machine-local discovery dir, #3738) or `./.agent/` local - **Cursor:** `~/.cursor/` global or `./.cursor/` local - **Windsurf/Devin Desktop:** `~/.codeium/windsurf/` global config or `./.windsurf/` local workflows - **Augment Code:** `~/.augment/` global or `./.augment/` local @@ -952,7 +952,7 @@ The migration-specific ownership and source snapshots live in | Kimi CLI | First-existing generic root: `~/.config/agents` recommended, then `~/.agents` when `~/.agents/skills` exists and `~/.config/agents/skills` does not | Deferred and guarded | `skills/gsd-*/SKILL.md` (flat) invoked as `/skill:gsd-*` | `agents/gsd.yaml`, `agents/gsd.md`, and `agents/subagents/gsd-*` YAML/prompt pairs | Explicit `kimi --agent-file /agents/gsd.yaml`; no GSD hooks or statusline | | Codex | `~/.codex` | `./.codex` | `skills/gsd-*/SKILL.md` (flat) | `agents/` source markdown plus per-agent TOML (Codex auto-discovers each `agents/gsd-*.toml`; this is the sole canonical role registration, #2406) | `config.toml` bare `[agents]` dispatch-tuning scalar (`max_depth`, no per-role `[agents.gsd-*]` tables), `[features].hooks` (canonical; legacy alias `codex_hooks` is recognized and migrated forward on reinstall, #3566), and hook tables | | GitHub Copilot | `~/.copilot` | `./.github` | `skills/gsd-*/SKILL.md` (flat), `copilot-instructions.md`, and `AGENTS.md` (repo root, local) | `.agent.md` files | Self-contained `sessionStart` hook (`hooks/gsd-session.json`, inline `command` type); no statusline | -| Antigravity | auto-detected: `~/.gemini/antigravity`, `~/.gemini/antigravity-ide`, or `~/.gemini/antigravity-cli` | `./.agent` | `skills/gsd-*/SKILL.md` (flat, #1614) | `agents/gsd-*.md` | Gemini-style `settings.json` hook entries when installed by GSD | +| Antigravity | auto-detected: `~/.gemini/antigravity`, `~/.gemini/antigravity-ide`, or `~/.gemini/antigravity-cli` | `./.agent` | `~/.gemini/config/skills/gsd-*/SKILL.md` (flat, #1614; global home override #3738) | `~/.gemini/config/agents/gsd-*.md` (#3738) | Gemini-style `settings.json` hook entries when installed by GSD | | Cursor | `~/.cursor` | `./.cursor` | `skills/gsd-*/SKILL.md` (flat) | `agents/gsd-*.md` | Rule references under `rules/`; `hooks.json` with sessionStart context injection and postToolUse STATE.md monitor (#777) | | Windsurf | `~/.codeium/windsurf` config | `./.windsurf` | `workflows/gsd-*.md` slash-command workflows | No custom-agent artifact surface | No GSD hooks | | Augment Code | `~/.augment` | `./.augment` | `skills/gsd-ns-*/SKILL.md` (6 routers) + `skills/gsd-ns-*/skills//SKILL.md` (nested concretes) | `agents/gsd-*.md` | No GSD hooks or statusline | diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 0af02c692..25ee9a21e 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -425,6 +425,7 @@ "installer-migrations/007-retire-config-root-commonjs-marker.cjs", "installer-migrations/008-cursor-retire-commands-surface.cjs", "installer-migrations/009-pi-retire-reserved-hooks-dir.cjs", + "installer-migrations/010-antigravity-retire-confighome-artifacts.cjs", "intel-command-router.cjs", "intel.cjs", "io.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 6266ef432..dc9b112fd 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -453,6 +453,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `installer-migrations/007-retire-config-root-commonjs-marker.cjs` | Installer migration: retires the config-root `{"type":"commonjs"}` marker that pre-#2544 installs wrote over `/package.json` | | `installer-migrations/008-cursor-retire-commands-surface.cjs` | Installer migration: retires Cursor's duplicate `commands/` surface now that skills are the sole workflow surface (#2644) | | `installer-migrations/009-pi-retire-reserved-hooks-dir.cjs` | Installer migration: retires pi's legacy `hooks/` directory after GSD's shared hook bundle moved to `gsd-hooks/` (#3023) | +| `installer-migrations/010-antigravity-retire-confighome-artifacts.cjs` | Installer migration: retires Antigravity's configHome `skills/`/`agents/` surfaces after the global layout moved to the `~/.gemini/config` home override (#3738) | | `active-workstream-store.cjs` | Workstream source precedence and selection (CLI `--ws` > `GSD_WORKSTREAM` env > stored pointer); name validation and environment propagation | | `adapter-declarative.cjs` | Declarative host-integration adapter — projects workflow artifacts through the install engine for hosts that declare a descriptor-driven surface (#1680) | | `adapter-imperative.cjs` | Imperative host-integration adapter — binds the composed capability registry in-process for hosts that drive emission themselves (#1680) | diff --git a/docs/how-to/install-on-your-runtime.md b/docs/how-to/install-on-your-runtime.md index 6086c9e7c..78a5a0a71 100644 --- a/docs/how-to/install-on-your-runtime.md +++ b/docs/how-to/install-on-your-runtime.md @@ -439,7 +439,7 @@ Skills land in `~/.augment/skills/` and slash command definitions land in `~/.au npx @opengsd/gsd-core@latest --antigravity --global ``` -The installer auto-detects the Antigravity config directory (`~/.gemini/antigravity`, `~/.gemini/antigravity-ide`, or `~/.gemini/antigravity-cli`). Uses Gemini-compatible settings policy. +The installer auto-detects the Antigravity config directory (`~/.gemini/antigravity`, `~/.gemini/antigravity-ide`, or `~/.gemini/antigravity-cli`). Uses Gemini-compatible settings policy. Global skills and agents install under `~/.gemini/config/skills/` and `~/.gemini/config/agents/` — the directories Antigravity scans for machine-local discovery (#3738); the config directory above holds settings and GSD's runtime files. **Override the install directory:** diff --git a/docs/installer-migrations.md b/docs/installer-migrations.md index 884178cf4..9caa74d5f 100644 --- a/docs/installer-migrations.md +++ b/docs/installer-migrations.md @@ -443,7 +443,7 @@ for the new shape before changing migration behavior. | Kimi CLI | Agent Skills in `skills/gsd-*/SKILL.md`; explicit custom agent YAML/prompt artifacts in `agents/gsd.yaml`, `agents/gsd.md`, and `agents/subagents/gsd-*`; `gsd-core/` payload files referenced by generated skills; manifest, pristine, local-patch, and migration journal files from the normal installer safety pipeline | Global `KIMI_CONFIG_DIR`, explicit `--config-dir`, or first-existing generic skills root: `~/.config/agents` when `~/.config/agents/skills` exists or no generic skills root exists yet, otherwise `~/.agents` when `~/.agents/skills` exists and `~/.config/agents/skills` does not; `KIMI_CONFIG_DIR` and `--config-dir` are GSD write-location overrides and arbitrary roots require Kimi-side `--skills-dir` or `extra_skill_dirs` configuration for skill discovery; local `--kimi --local` is guarded and writes no project-level artifacts | GSD owns only generated `skills/gsd-*`, `agents/gsd.*`, `agents/subagents/gsd-*`, installed `gsd-core/` payload files, and manifest/preservation/migration records. GSD does not own Kimi config files, hooks, settings, rules, statusline, update-banner registration, or non-GSD Kimi skills/agents. Reinstall/update must preserve locally modified generated Kimi artifacts through manifest-backed `gsd-local-patches/`; uninstall removes only GSD-owned Kimi artifacts and preserves non-GSD user content. | [Agent Skills](https://moonshotai.github.io/kimi-cli/en/customization/skills.html), [Agents and Subagents](https://moonshotai.github.io/kimi-cli/en/customization/agents.html), [Tools](https://moonshotai.github.io/kimi-code/en/reference/tools.html); docs checked 2026-06-07 | | Codex | Skills in `skills/gsd-*/SKILL.md`; agents as source markdown plus per-agent TOML in `agents/` (Codex auto-discovers each standalone `agents/gsd-*.toml` — that is the sole role-registration source, #2406); bare `[agents]` dispatch-tuning scalar and hooks in `config.toml` | Global `CODEX_HOME` or `~/.codex`; local `./.codex` | GSD owns generated skills, generated agent TOML, the managed bare `[agents]` scalar table (`max_depth`; no `[agents.gsd-*]` role sections — those were a duplicate registration removed in #2406), `[features].hooks` when added by GSD (canonical; legacy alias `codex_hooks` is recognized and migrated forward, #3566), and GSD hook entries | [Codex config schema](https://developers.openai.com/codex/config-schema.json), [Codex developer docs](https://developers.openai.com/codex/); docs not versioned, checked 2026-05-15; installer compatibility sentinel: Codex 0.130.0 features.hooks key (legacy `codex_hooks` recognized) | | GitHub Copilot | Skills in `skills/gsd-*/SKILL.md`; agents as `.agent.md`; repository instructions in `copilot-instructions.md` | Global `COPILOT_CONFIG_DIR`, `COPILOT_HOME`, or `~/.copilot`; local `./.github` | GSD owns generated skill/agent files and GSD-authored instruction files; no hook/statusline ownership | [Repository custom instructions](https://docs.github.com/en/copilot/how-tos/configure-custom-instructions/add-repository-instructions), [Copilot CLI custom instructions](https://docs.github.com/en/copilot/how-tos/copilot-cli/add-custom-instructions); GitHub Docs product docs, checked 2026-05-11 | -| Antigravity | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; Gemini-style `settings.json` hooks when installed by GSD | Global `ANTIGRAVITY_CONFIG_DIR` or `~/.gemini/antigravity`; local `./.agents` (canonical, #791) or `./.agent` (legacy, recognized for backward-compat) | GSD owns generated skills/agents/hooks and GSD settings entries only | Public Antigravity install/config docs for this file layout were not stable or complete as of 2026-05-11; installer compatibility therefore uses GSD's Gemini-compatible settings policy, documented shim baseline. Fresh installs write to `.agents/` (the Google-Codelabs-documented form); existing `.agent/` installs continue to be detected and served. | +| Antigravity | Global skills in `~/.gemini/config/skills/gsd-*/SKILL.md` and agents in `~/.gemini/config/agents/` (the machine-local discovery dir, #3738; pre-#3738 installs wrote them under the configHome and migration 010 retires that spot); Gemini-style `settings.json` hooks at the configHome when installed by GSD | Global `ANTIGRAVITY_CONFIG_DIR` or `~/.gemini/antigravity`; local `./.agents` (canonical, #791) or `./.agent` (legacy, recognized for backward-compat) | GSD owns generated skills/agents/hooks and GSD settings entries only | Public Antigravity install/config docs for this file layout were not stable or complete as of 2026-05-11; installer compatibility therefore uses GSD's Gemini-compatible settings policy, documented shim baseline. Fresh installs write to `.agents/` (the Google-Codelabs-documented form); existing `.agent/` installs continue to be detected and served. | | Cursor | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/`; rule references under `rules/`; lifecycle hooks via `hooks.json` (sessionStart + postToolUse, #777) | Global `CURSOR_CONFIG_DIR` or `~/.cursor`; local `./.cursor` | GSD owns generated skills/agents, GSD rule files or references, and GSD-managed `hooks.json` entries (sentinel `gsd-managed:true`); no statusline ownership | [Cursor rules](https://docs.cursor.com/context/rules); [Cursor hooks](https://docs.cursor.com/context/hooks); docs not versioned, checked 2026-06-07 | | Windsurf / Devin Desktop | Local slash-command workflows in `workflows/gsd-*.md`; no custom-agent artifact surface | Local workflow directory `./.windsurf/workflows`; global workflow install is intentionally a no-op | GSD owns generated local workflow files only; no hook/statusline ownership | Windsurf workflows are the documented `/` command surface. Workspace workflows live under `.windsurf/workflows/*.md`; global workflow locations are outside GSD's normal user-owned runtime config directory and are not written by the GSD installer. | | Augment Code | Skills in `skills/gsd-*/SKILL.md`; agents in `agents/` | Global `AUGMENT_CONFIG_DIR` or `~/.augment`; local `./.augment` | GSD owns generated skills/agents only; no hook/statusline ownership | [Augment Agent Skills](https://docs.augmentcode.com/cli/skills), [Augment IDE skills](https://docs.augmentcode.com/using-augment/skills); IDE skills public beta in VS Code 0.789.0+, checked 2026-05-11 | @@ -580,6 +580,7 @@ Each row corresponds to one migration record in `src/installer-migrations/`. | `2026-07-28-retire-config-root-commonjs-marker` | `007-retire-config-root-commonjs-marker.cts` | 1.8.0 | global, local | Yes | Removes `/package.json` when it is exactly the `{"type":"commonjs"}` marker pre-#2544 installs wrote there. #2544 moved that marker into the directories GSD fills (`hooks/`, and the native plugin dir), so an upgraded install would otherwise keep both and stay pinned to CommonJS at a config root GSD no longer writes. Ownership is proven by exact content match, not the manifest (the marker was never manifest-recorded) — a `package.json` with any other content is left untouched, with no backup-and-remove branch. All runtimes; kimi's root marker lives outside `configDir` and is retired by the installer instead. | | `2026-07-29-cursor-retire-commands-surface` | `008-cursor-retire-commands-surface.cts` | 1.8.1 | global, local | Yes | Removes manifest-managed `commands/gsd-*.md` files from Cursor installs. Cursor already exposes the corresponding skills in the slash menu and to contextual model invocation, so the command copies produced duplicate entries (#2644). Modified files are backed up; unmanifested files are preserved. | | `2026-08-07-pi-retire-reserved-hooks-dir` | `009-pi-retire-reserved-hooks-dir.cts` | 1.9.2 | global, local | Yes | Removes manifest-managed files under pi's legacy `hooks/` directory and, once empty, the directory itself (and `hooks/lib/`), now that the shared hook bundle installs at `gsd-hooks/` instead. pi's `checkDeprecatedExtensionDirs()` warns on `hooks/`'s mere existence, not its contents, so an emptied shell would keep warning forever without the new `remove-empty-dir` action (#3023). Modified files are backed up; unmanifested files are preserved and keep the directory alive. pi only. | +| `2026-08-26-antigravity-retire-confighome-artifacts` | `010-antigravity-retire-confighome-artifacts.cts` | 1.11.0 | global | Yes | Removes manifest-managed `skills/gsd-*/` and `agents/gsd-*.md` under the Antigravity configHome (`~/.gemini/antigravity{,-ide,-cli}`) and, once empty, the container directories. Antigravity's machine-local discovery scans `~/.gemini/config/{skills,agents}` and does not scan the configHome, so pre-#3738 artifacts were silently ignored; since #3738 the global layout installs both kinds under the `.gemini/config` home override. Modified files are backed up; unmanifested and non-`gsd-` files are preserved and keep their directory alive. Global scope only — the local `.agents` workspace surface is live. Antigravity only. | ## Prior Art diff --git a/eslint.config.mjs b/eslint.config.mjs index 41f7eb845..e66eed4fc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -209,6 +209,9 @@ export default tseslint.config( // 009 also imports node builtins (fs, path) like 007, so tsc emits the // same `__importDefault` helper. ADR-457: the linted source is the .cts. 'gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs', + // 010 also imports node builtins (fs, path) like 007/009, so tsc emits + // the same `__importDefault` helper. ADR-457: the linted source is the .cts. + 'gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs', 'gsd-core/bin/lib/observability/logger.cjs', 'gsd-core/bin/lib/active-workstream-store.cjs', 'gsd-core/bin/lib/adr-parser.cjs', diff --git a/gsd-core/bin/lib/capability-registry.cjs b/gsd-core/bin/lib/capability-registry.cjs index 65425559b..98efa468d 100644 --- a/gsd-core/bin/lib/capability-registry.cjs +++ b/gsd-core/bin/lib/capability-registry.cjs @@ -97,7 +97,7 @@ const capabilities = { "role": "runtime", "version": "1.11.0", "title": "Antigravity", - "description": "Google Antigravity IDE — nested under ~/.gemini/antigravity; probed across 1.x and 2.x layouts; Gemini hook event dialect; flat skill layout; tier-1 support.", + "description": "Google Antigravity IDE — config/settings home nested under ~/.gemini/antigravity (probed across 1.x and 2.x layouts); global skills/agents install under ~/.gemini/config, the dir AGY scans for global discovery (#3738); Gemini hook event dialect; flat skill layout; tier-1 support.", "tier": "core", "requires": [], "engines": { @@ -128,7 +128,8 @@ const capabilities = { "prefix": "gsd-", "nesting": "flat", "recursive": false, - "converter": "convertClaudeCommandToAntigravitySkill" + "converter": "convertClaudeCommandToAntigravitySkill", + "home": ".gemini/config" }, { "kind": "agents", @@ -136,7 +137,8 @@ const capabilities = { "prefix": "gsd-", "nesting": "flat", "recursive": false, - "converter": "convertClaudeAgentToAntigravityAgent" + "converter": "convertClaudeAgentToAntigravityAgent", + "home": ".gemini/config" } ], "local": [ @@ -5247,7 +5249,7 @@ const runtimes = { "role": "runtime", "version": "1.11.0", "title": "Antigravity", - "description": "Google Antigravity IDE — nested under ~/.gemini/antigravity; probed across 1.x and 2.x layouts; Gemini hook event dialect; flat skill layout; tier-1 support.", + "description": "Google Antigravity IDE — config/settings home nested under ~/.gemini/antigravity (probed across 1.x and 2.x layouts); global skills/agents install under ~/.gemini/config, the dir AGY scans for global discovery (#3738); Gemini hook event dialect; flat skill layout; tier-1 support.", "tier": "core", "requires": [], "engines": { @@ -5278,7 +5280,8 @@ const runtimes = { "prefix": "gsd-", "nesting": "flat", "recursive": false, - "converter": "convertClaudeCommandToAntigravitySkill" + "converter": "convertClaudeCommandToAntigravitySkill", + "home": ".gemini/config" }, { "kind": "agents", @@ -5286,7 +5289,8 @@ const runtimes = { "prefix": "gsd-", "nesting": "flat", "recursive": false, - "converter": "convertClaudeAgentToAntigravityAgent" + "converter": "convertClaudeAgentToAntigravityAgent", + "home": ".gemini/config" } ], "local": [ diff --git a/scripts/gen-install-tree-fixtures.cjs b/scripts/gen-install-tree-fixtures.cjs index 3c00fca97..719d04704 100644 --- a/scripts/gen-install-tree-fixtures.cjs +++ b/scripts/gen-install-tree-fixtures.cjs @@ -21,7 +21,7 @@ const ROOT = path.resolve(__dirname, '..'); // buildParityManifest) is the canonical single source of truth in // tests/helpers/install-shared.cjs (issue #2266/#2267) — this generator does // not keep its own inline copy of the walk/exclusion logic. -const { runMinimalInstall, RUNTIME_META, buildInstallTree, BUILD_SCRIPT } = require(path.join(ROOT, 'tests', 'helpers', 'install-shared.cjs')); +const { runMinimalInstall, RUNTIME_META, buildInstallTree, extraEmitRootsFor, BUILD_SCRIPT } = require(path.join(ROOT, 'tests', 'helpers', 'install-shared.cjs')); const FIXTURE_DIR = path.join(ROOT, 'tests', 'fixtures', 'install-tree'); function cleanup(root) { @@ -51,7 +51,7 @@ for (const runtime of targets) { 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); } diff --git a/src/installer-migrations/010-antigravity-retire-confighome-artifacts.cts b/src/installer-migrations/010-antigravity-retire-confighome-artifacts.cts new file mode 100644 index 000000000..e06d52814 --- /dev/null +++ b/src/installer-migrations/010-antigravity-retire-confighome-artifacts.cts @@ -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; diff --git a/src/real-home-guard.cts b/src/real-home-guard.cts index 8fabeb233..ef9a871db 100644 --- a/src/real-home-guard.cts +++ b/src/real-home-guard.cts @@ -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 diff --git a/src/runtime-artifact-conversion.cts b/src/runtime-artifact-conversion.cts index 08d8293dc..7addddf27 100644 --- a/src/runtime-artifact-conversion.cts +++ b/src/runtime-artifact-conversion.cts @@ -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 diff --git a/tests/emitted-drift-acks/3738-antigravity-skills-root.json b/tests/emitted-drift-acks/3738-antigravity-skills-root.json new file mode 100644 index 000000000..712bed1c5 --- /dev/null +++ b/tests/emitted-drift-acks/3738-antigravity-skills-root.json @@ -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." + } + } +} diff --git a/tests/executed-plan.test.cjs b/tests/executed-plan.test.cjs index fe6a76ea8..22a9a6ec0 100644 --- a/tests/executed-plan.test.cjs +++ b/tests/executed-plan.test.cjs @@ -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 → /.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 }, ); diff --git a/tests/golden-install-tree.test.cjs b/tests/golden-install-tree.test.cjs index 8d072aff6..d2f9c2e13 100644 --- a/tests/golden-install-tree.test.cjs +++ b/tests/golden-install-tree.test.cjs @@ -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); } diff --git a/tests/golden-parity-single-source.test.cjs b/tests/golden-parity-single-source.test.cjs index 7aab56eb7..5ce206d82 100644 --- a/tests/golden-parity-single-source.test.cjs +++ b/tests/golden-parity-single-source.test.cjs @@ -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`); diff --git a/tests/helpers/emitted-provenance.cjs b/tests/helpers/emitted-provenance.cjs index 5394bbbbb..0e7861fdc 100644 --- a/tests/helpers/emitted-provenance.cjs +++ b/tests/helpers/emitted-provenance.cjs @@ -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', diff --git a/tests/helpers/emitted-runtime.cjs b/tests/helpers/emitted-runtime.cjs index 05fadaedd..e8956c817 100644 --- a/tests/helpers/emitted-runtime.cjs +++ b/tests/helpers/emitted-runtime.cjs @@ -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 + // → /.gemini/config) so the differential keeps seeing them. + extraEmitRoots: extraEmitRootsFor(runtime, scope, root), + }); } finally { cleanup(root); } diff --git a/tests/helpers/install-shared.cjs b/tests/helpers/install-shared.cjs index b93215b9f..8550f339d 100644 --- a/tests/helpers/install-shared.cjs +++ b/tests/helpers/install-shared.cjs @@ -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 → /.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, diff --git a/tests/install-runtime-artifacts.test.cjs b/tests/install-runtime-artifacts.test.cjs index 0ec45478b..3305ef1ba 100644 --- a/tests/install-runtime-artifacts.test.cjs +++ b/tests/install-runtime-artifacts.test.cjs @@ -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 /.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`); + } + + }); +}); diff --git a/tests/install.test.cjs b/tests/install.test.cjs index 515b89a69..8264d31ba 100644 --- a/tests/install.test.cjs +++ b/tests/install.test.cjs @@ -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') }, ]; diff --git a/tests/installer-migration-antigravity-retire-confighome-artifacts.test.cjs b/tests/installer-migration-antigravity-retire-confighome-artifacts.test.cjs new file mode 100644 index 000000000..3b42a3985 --- /dev/null +++ b/tests/installer-migration-antigravity-retire-confighome-artifacts.test.cjs @@ -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'); + }); +}); diff --git a/tests/installer-migration-install.integration.test.cjs b/tests/installer-migration-install.integration.test.cjs index 89a6489fb..ff1c84b2f 100644 --- a/tests/installer-migration-install.integration.test.cjs +++ b/tests/installer-migration-install.integration.test.cjs @@ -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 `/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( diff --git a/tests/installer-migrations.test.cjs b/tests/installer-migrations.test.cjs index a30c23cda..b1061e34a 100644 --- a/tests/installer-migrations.test.cjs +++ b/tests/installer-migrations.test.cjs @@ -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'); diff --git a/tests/runtime-artifact-layout-surface.test.cjs b/tests/runtime-artifact-layout-surface.test.cjs index bdc8a4c71..baf0e70bf 100644 --- a/tests/runtime-artifact-layout-surface.test.cjs +++ b/tests/runtime-artifact-layout-surface.test.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']) {