fix(#3023): stage pi's shared hook bundle outside pi's reserved hooks/ directory (#3175)

* test(#3023): failing-first guard — pi must not stage hooks in its reserved dir

pi reserves <configDir>/hooks as its deprecated extension location and warns
on every startup when it exists. Assert a pi install stages the shared hook
bundle under gsd-hooks/ instead, manifests it there, and never creates hooks/.

Also adds pi to the local-scope dir table in install-shared.cjs: pi was in
RUNTIME_META but not LOCAL_DIR_NAME, so scope:'local' resolved
path.join(root, undefined) and no local pi install could be exercised.

Fails before the fix. Verified via the remote runner.

* fix(#3023): stage pi's shared hook bundle outside pi's reserved hooks/ dir

pi reserves <configDir>/hooks as its now-deprecated extension location and
warns on every startup when that directory merely exists — checkDeprecatedExtensionDirs()
guards the warning with a bare existsSync(), unlike its tools/ sibling. GSD staged
its shared hook bundle exactly there, and pi's advised remediation (move it to
extensions/) would break the adapter's paths and expose GSD's .js helpers to pi's
extension auto-discovery.

The bundle directory name is now runtime-descriptor-driven: hostBehaviors
.sharedHooksDirName, defaulting to 'hooks' so all 18 other runtimes are
byte-identical. pi sets 'gsd-hooks'. The name is validated as a single path
segment — separators, dot-only segments, trailing dots, absolute paths, NUL,
and Windows reserved device names all fall back to the default, because the
value is joined onto a user's config root and written to.

Renamed in place rather than relocated: hook scripts resolve siblings via
__dirname/.., so a depth change would silently break them.

- install / uninstall / manifest sites all read the resolved name
- pi/gsd.cjs probes gsd-hooks then hooks, so dev checkouts and half-upgraded
  trees still resolve; the never-throws contract is preserved
- new migration 009 retires the legacy pi hooks/ dir on upgrade, using a new
  non-recursive remove-empty-dir engine primitive (rmdirSync only,
  symlink-refusing, containment-guarded); ADR-0008 amended accordingly
- fixes two latent name-dependencies the rename exposed: the stale-hook scan
  and the injection scanner's self-exclusion both hardcoded 'hooks'

Verified on the remote runner.

Closes #3023

* fix(#3023): close review findings and align emitted provenance with the rename

Adversarial review found two defects, and the remote runner found four
failure clusters. All fixed here.

Review BLOCKER — detect-custom-files was blind to the renamed bundle.
GSD_PREFIX_MANAGED_DIRS in gsd-tools.cjs hardcoded 'hooks', so for pi the
whole gsd-hooks/ tree was invisible to the custom-file scan and user-added
files there were never backed up before the next update's clean-install wipe.
The dir set now resolves via the .gsd-runtime marker plus the shipped
capability registry (never bin/install.js, which is not shipped into installed
trees), and falls back to scanning every known candidate when the runtime
cannot be determined — over-scanning is safe, under-scanning is the data loss.

Review MAJOR — the pi adapter bound to an empty bundle. resolveSharedHooksDir
accepted any directory, so an interrupted install left gsd-hooks/ winning over
a fully-staged legacy hooks/ and every hook silently no-opped. A candidate now
qualifies only if it is non-empty.

Remote-runner clusters:
- emitted-provenance had no rule for the gsd-hooks/ family; added two pi-scoped
  rules pointing at the same sources the existing hooks/ rules use. The table is
  total, so an unattributed family is a hard failure by design.
- pi tests in install-minimal-hooks and the install integration suite asserted
  the old layout; updated to derive the dir name from the descriptor rather than
  hardcoding either name.
- 19 unrelated-looking failures on node22 only were a leaked fs mock: t.after()
  runs in registration order, cleanup was registered before mock.restoreAll(),
  and node22's JS rimraf calls the public fs.rmdirSync while node24's native
  path does not — so the EACCES stub leaked process-wide on one lane. Restore
  now runs first.

Verified on the remote runner.

* fix(#3023): honor PI_CODING_AGENT_DIR, ack the rename ripple, fix expandTilde

pi resolves its agent dir as PI_CODING_AGENT_DIR ?? ~/<CONFIG_DIR_NAME>/agent
(packages/coding-agent/src/config.ts). GSD's pi descriptor declared an empty
configHome.env, so a user with that variable set had GSD installed where pi
never looks. Added the env name; the dot-home-nested resolver already handled
the override, so no resolver logic changed.

Also fixes expandTilde in the shared runtime-homes resolver, found while adding
that: it hardcoded os.homedir() and ignored the opts.home every caller threads,
so EVERY runtime's tilde-valued env override (claude, antigravity, windsurf, pi)
silently resolved against the real home. That is a correctness bug and a
test-escape hazard — a sandboxed test asserting on a tilde override reached the
developer's actual home directory. Now threaded through every branch; behavior
with no injected home is unchanged.

Adds the emitted-drift ack fragment for the 58 pi paths whose emitted location
moved with the rename. The provenance rules satisfy the totality gate; the
differential gate needs the ack because the hook sources are byte-unchanged —
only the installer's target directory moved. The two hook files this branch
genuinely edits stay attributed and are not double-acked.

Note on piConfig.configDir: it is read from pi's OWN installed package.json
(getPackageDir walks up from pi's __dirname), alongside piConfig.name — a
white-label setting for a redistributed pi fork, not a per-project user setting.
Documented accordingly rather than treated as an unsupported override.

Verified on the remote runner.

* fix(#3023): reject blank env overrides, pin adapter/descriptor parity

Three review findings, all fixed.

A whitespace-only config-dir override was accepted verbatim: the guard was
`if (val)`, falsy only for the empty string, so PI_CODING_AGENT_DIR='   '
resolved to a literal three-space directory name instead of falling back to the
descriptor default. Fixed across every env-consuming branch — dot-home,
dot-home-nested, all three xdg steps, and generic-agents-root — not just pi's.
Non-blank values are still never trimmed, so '~/My Agent Dir' keeps working.

pi/gsd.cjs's probe list and the descriptor were two independent sources of truth
for the bundle directory name; a future rename would have desynced them silently
and left every pi hook quiet with no error. The probe list stays deliberate — it
must resolve in a dev checkout and a half-upgraded tree, where the registry's
answer would be wrong — so this adds the parity assertion the repo's
generative-fix-divergence rule calls for: the descriptor value must be the FIRST
candidate, and the default must remain present.

Changeset body rewritten to cover the two later user-facing fixes it had not
caught up with.

Verified on the remote runner.

* chore(#3023): backfill changeset PR number

* fix(#3023): anchor injection-scan patterns and fix a macOS detection hole

CI's security job flagged CONTEXT.md:124 — pre-existing prose reading 'not the
same fact as a genuinely empty or absent one'. The match was the 'act as a'
INSIDE 'f-act as a': the pattern had no left word boundary, so any word ending
in act tripped it (fact, impact, contract, artifact, interact, redact,
abstract). My four-line CONTEXT.md edit dragged the latent false positive into
this PR because the scan is diff-scoped by file but reads whole files. Anchored
with (^|[^[:alnum:]]) rather than rewording maintainer-owned prose, which would
have left the class alive for the next PR touching any file saying 'fact as a'.

Auditing the rest of the list for the same class surfaced a real detection hole:
the eval/exec/Function patterns matched a quote via \x27, a GNU-grep-only hex
escape. BSD/macOS grep reads it as four literal characters, so single-quoted
eval('...')/exec('...') payloads were NEVER detected there while passing on
GNU-grep CI. Replaced with a literal apostrophe class.

Boundaries were added only where a real word-suffix collision exists; exec,
jailbreak, developer mode and the role-manipulation family were audited and
deliberately left unanchored. 22 new cases cover both directions — the false
positives now scan clean, and every real payload still fires, including the
quote/punctuation/start-of-line boundary forms.

Also builds this branch's injection test fixture at runtime instead of carrying
the literal phrase, so the payload keeps its teeth without tripping the scan.

Verified on the remote runner.

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-07 13:41:21 -04:00
committed by GitHub
parent 86f72a57c3
commit 27aa40f65e
38 changed files with 3397 additions and 525 deletions

View File

@@ -122,7 +122,9 @@ export function validateInstallerMigrationActions(actions: unknown, migration: M
// Ownership and runtime-contract evidence are required by
// docs/installer-migrations.md#action-types and
// docs/adr/0008-installer-migration-module.md#runtime-contract-decision.
if (actType === 'remove-managed' || actType === 'rewrite-json') {
// `remove-empty-dir` carries the same evidence bar as `remove-managed`: it is
// still a destructive removal, just of a directory node instead of a file.
if (actType === 'remove-managed' || actType === 'rewrite-json' || actType === 'remove-empty-dir') {
requireActionEvidence(act, 'ownershipEvidence', migration);
}
if (actType === 'rewrite-json') {

View File

@@ -135,6 +135,7 @@ function installerMigrationActionLabel(action: MigrationAction | null | undefine
if (action.type === 'record-baseline') return 'recorded';
if (action.type === 'baseline-preserve-user') return 'preserved';
if (action.type === 'preserve-user') return 'preserved';
if (action.type === 'remove-empty-dir') return 'removed';
if (action.type === 'prompt-user') return 'blocked';
return 'skipped';
}

View File

@@ -65,6 +65,75 @@ function sha256Text(value: string): string {
* the correct outcome — refusing to proceed beats silently copying referent
* bytes.
*/
/**
* Evaluate and, if safe, perform a `remove-empty-dir` action against `fullPath`.
*
* This is deliberately WEAKER than a recursive directory-removal primitive
* (which 003's docblock records as an intentional absence in the ADR-0008
* design): it only ever calls `fs.rmdirSync` — never `fs.rmSync`, never
* `{ recursive: true }`, never `{ force: true }` — so a non-empty directory
* fails the underlying syscall rather than being swept. The emptiness check
* immediately above the call is what turns that failure mode into a
* deliberate, non-error "left in place" outcome instead of surfacing ENOTEMPTY.
*
* Guards, in order:
* - lstat (not stat): a symlinked directory is refused outright, never
* followed. A missing target is reported distinctly so callers can tell
* "nothing was ever there" from "something was there and is left alone".
* - must actually be a directory (not a file masquerading under the relPath).
* - containment: the REALPATH of the target must resolve strictly inside the
* REALPATH of configDir — never equal to it (removing the config root
* itself is never in scope) and never escaping it (e.g. via an ancestor
* symlink the lstat check alone would not catch).
* - emptiness, re-checked here rather than trusted from planning time: a
* directory that still holds any entry (managed-but-undeleted, unknown,
* or created between plan and apply) is left in place. This is reported
* as 'skipped-not-empty', a successful no-op, not a failure.
*
* Any unexpected error along the way (EACCES, EBUSY, a race that removes the
* target between the lstat and the rmdir, etc.) degrades to 'left-in-place'.
* This action must never throw out of the executor, matching every sibling
* action type's failure posture.
*/
function evaluateRemoveEmptyDir(configDir: string, fullPath: string): string {
let stat: fs.Stats;
try {
stat = fs.lstatSync(fullPath);
} catch {
return 'missing';
}
if (stat.isSymbolicLink()) return 'left-in-place';
if (!stat.isDirectory()) return 'left-in-place';
let resolvedRoot: string;
let resolvedTarget: string;
try {
resolvedRoot = fs.realpathSync(configDir);
resolvedTarget = fs.realpathSync(fullPath);
} catch {
return 'left-in-place';
}
if (resolvedTarget === resolvedRoot || !resolvedTarget.startsWith(resolvedRoot + path.sep)) {
// Refuses both "target IS configDir" and "target escaped configDir".
return 'left-in-place';
}
let entries: string[];
try {
entries = fs.readdirSync(fullPath);
} catch {
return 'left-in-place';
}
if (entries.length > 0) return 'skipped-not-empty';
try {
fs.rmdirSync(fullPath);
return 'removed';
} catch {
return 'left-in-place';
}
}
function copyPreservingSymlink(srcPath: string, destPath: string): void {
if (fs.lstatSync(srcPath).isSymbolicLink()) {
// symlinkSync fails with EEXIST on an occupied path, so clear it first.
@@ -760,7 +829,8 @@ function applyInstallerMigrationPlan({
action.type !== 'backup-and-remove' &&
action.type !== 'rewrite-json' &&
action.type !== 'record-baseline' &&
action.type !== 'baseline-preserve-user'
action.type !== 'baseline-preserve-user' &&
action.type !== 'remove-empty-dir'
) {
throw new Error(`unsupported migration action type: ${action.type}`);
}
@@ -776,6 +846,19 @@ function applyInstallerMigrationPlan({
continue;
}
if (action.type === 'remove-empty-dir') {
// Directory actions never enter the file-copy/rollback machinery below:
// there is nothing to snapshot-and-restore for a directory node itself
// (its former CONTENTS were already snapshotted by their own file-level
// actions before this one runs), and rollback of a removed empty
// directory is simply re-creating it, which the rollback path below
// does not model. Non-recursive by construction (evaluateRemoveEmptyDir
// only ever calls fs.rmdirSync), so there is nothing destructive to undo
// beyond an mkdir the next install/migration run will happily redo.
journal.actions.push(journalAction(action, evaluateRemoveEmptyDir(configDir, fullPath)));
continue;
}
const rollbackPath = path.join(rollbackRoot, normalized);
fs.mkdirSync(path.dirname(rollbackPath), { recursive: true });
copyPreservingSymlink(fullPath, rollbackPath);
@@ -1008,6 +1091,7 @@ export = {
applyInstallerMigrationPlan,
classifyArtifact,
discoverInstallerMigrations,
evaluateRemoveEmptyDir,
migrationChecksum,
planInstallerMigrations,
readInstallManifest,

View File

@@ -0,0 +1,241 @@
/**
* Installer migration: retire pi's legacy `<piConfigDir>/hooks/` directory
* after GSD's shared hook bundle moved to `<piConfigDir>/gsd-hooks/` (#3023).
*
* What old artifact is being retired?
* `hooks/` (and its `hooks/lib/` subdirectory) at the pi config root. pi
* reserves that exact name as its own deprecated extension directory and
* warns on every startup whenever it exists — pi's
* `checkDeprecatedExtensionDirs()` fires on mere PATH EXISTENCE, not on the
* directory having contents (unlike the sibling `tools/` check, which does
* `readdir` first). GSD used to install its shared hook bundle at exactly
* that reserved path, so every pi install carried the warning permanently.
* The fix moved the install target to `gsd-hooks/`
* (`hostBehaviors.sharedHooksDirName`), but an EXISTING install that
* upgrades still has the old `hooks/` tree sitting on disk — nothing
* removes it on its own, so the warning would persist forever without this
* migration.
*
* How do we prove it is GSD-owned?
* Per file, by manifest membership — the same `classifyArtifact()` check
* every other migration in this directory uses. Pre-#3023 pi installs
* record the shared hook bundle under `hooks/…` keys in
* `gsd-file-manifest.json`; the new install target writes `gsd-hooks/…`
* keys instead, which this migration structurally never sees because it
* only ever walks the `hooks/` subtree.
*
* What happens if the user modified it?
* `backup-and-remove` instead of `remove-managed`, so a locally patched
* hook script is recoverable from the backup rather than silently
* destroyed — mirrors migration 006.
*
* What happens to files the manifest never recorded?
* Nothing. An unmanifested file under `hooks/` (classification `unknown`)
* is left exactly where it is, and — because its presence keeps the
* directory non-empty — it also keeps the directory itself from being
* retired. That is a deliberate consequence of directory removal being
* gated on emptiness, not a special case.
*
* What happens to the directory itself?
* `hooks/lib/` and then `hooks/` each get a `remove-empty-dir` action (see
* `evaluateRemoveEmptyDir` in `../installer-migrations.cts`). That action
* only ever calls `fs.rmdirSync` — never a recursive removal — and
* re-checks emptiness immediately before doing so, so a directory that
* still holds anything (an unmanifested file, or a file-level action that
* failed to apply) is left in place rather than assumed empty. Actions are
* emitted deepest-first (`hooks/lib` before `hooks`) so the parent has a
* chance to become empty in the same pass.
*
* What happens if it is missing?
* No actions. A fresh post-#3023 pi install never creates `hooks/` at all,
* and an already-migrated install has nothing left to retire — both plan
* empty, so the migration is idempotent.
*
* What runtime and scope does it affect?
* pi only, global and local. No other runtime's install is affected:
* `hostBehaviors.sharedHooksDirName` defaults to `'hooks'` for every other
* runtime, and none of them reserve that name the way pi does, so a
* claude/kimi/opencode/etc. `hooks/` directory is a live, in-use install
* surface that must never be touched here. The runtime check is the FIRST
* thing `plan()` does, ahead of even checking whether the directory exists,
* as defense in depth beyond the `runtimes: ['pi']` record-level filter the
* framework itself already enforces.
*
* 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, the pi row of
* docs/installer-migrations.md#runtime-configuration-contract-registry, and
* the 2026-08-07 amendment to docs/adr/0008-installer-migration-module.md.
*/
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;
classifyArtifact(relPath: string): ClassifiedArtifact;
}
interface InstallerMigration {
id: string;
title: string;
description: string;
introducedIn: string;
runtimes: string[];
scopes: string[];
destructive: boolean;
plan: (ctx: MigrationPlanContext) => MigrationAction[];
}
/** pi's reserved (and, pre-#3023, GSD-populated) legacy hook directory name. */
const HOOKS_DIR = 'hooks';
const FILE_REASON =
"pi's startup check warns whenever hooks/ exists (checkDeprecatedExtensionDirs), and GSD's shared " +
'hook bundle now installs at gsd-hooks/ instead (#3023), so the legacy files are superseded';
const FILE_OWNERSHIP_EVIDENCE =
'pre-#3023 pi installs record the shared hook bundle under hooks/… keys in gsd-file-manifest.json; ' +
'the new install target is gsd-hooks/…, which this migration never touches because it only walks the ' +
'hooks/ subtree';
const DIR_REASON =
"pi's checkDeprecatedExtensionDirs() warns on hooks/'s mere existence, not its contents (#3023); the " +
'reserved container is retired once every GSD-owned entry inside it is gone';
const DIR_OWNERSHIP_EVIDENCE =
'hooks/ and hooks/lib/ are GSD-installed container directories under the pi config root (the pre-#3023 ' +
'default of hostBehaviors.sharedHooksDirName); removal is gated on emptiness by the shared ' +
'remove-empty-dir action, so a directory that still holds an unmanifested user file — or any file-level ' +
'action that failed to apply — is left in place rather than assumed empty';
/**
* Recursively collect files and directories under `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 003 (`walkLegacyFiles`).
*/
function walkPiHooksTree(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);
walkPiHooksTree(root, relPath, baseResolved, files, dirs);
} else if (entry.isFile()) {
files.push(relPath);
}
}
}
const migration: InstallerMigration = {
id: '2026-08-07-pi-retire-reserved-hooks-dir',
title: "Retire pi's reserved hooks/ directory",
description:
'Remove manifest-managed files under <piConfigDir>/hooks/ and, once empty, the directory itself ' +
'(and its hooks/lib/ subdirectory), now that the shared hook bundle installs at gsd-hooks/ instead. pi ' +
'reserves hooks/ as its own deprecated extension directory and warns on every startup while it exists (#3023).',
introducedIn: '1.9.2',
runtimes: ['pi'],
scopes: ['global', 'local'],
destructive: true,
plan: (ctx: MigrationPlanContext): MigrationAction[] => {
// Defense in depth ahead of the framework's own runtimes filter: a
// claude/kimi/opencode/etc. hooks/ directory is a live install surface,
// never a retirement target.
if (ctx.runtime !== 'pi') return [];
const hooksRoot = path.join(ctx.configDir, HOOKS_DIR);
let rootLstat: fs.Stats;
try {
rootLstat = fs.lstatSync(hooksRoot);
} catch {
return []; // absent -> nothing to retire, idempotent
}
// Never follow a symlinked hooks/ root: walking through it could plan
// actions against paths outside the pi config directory entirely.
if (rootLstat.isSymbolicLink()) return [];
if (!rootLstat.isDirectory()) return [];
const baseResolved = path.resolve(ctx.configDir);
const files: string[] = [];
const dirs: string[] = [];
try {
walkPiHooksTree(ctx.configDir, HOOKS_DIR, baseResolved, files, dirs);
} catch {
// Unreadable directory: nothing safe to plan.
return [];
}
const actions: MigrationAction[] = [];
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. `hooks/` itself is appended last, unconditionally: the
// executor's own emptiness re-check is what actually decides whether it
// goes, not this ordering — this ordering only gives it the chance to.
const orderedDirs = [...dirs].sort((a, b) => b.split('/').length - a.split('/').length);
orderedDirs.push(HOOKS_DIR);
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) rather than routed through the file
// classifier.
classification: 'managed-pristine',
originalHash: null,
currentHash: null,
});
}
return actions;
},
};
export = migration;

View File

@@ -35,14 +35,36 @@ import path from 'node:path';
import fs from 'node:fs';
/**
* Expand a leading ~ to os.homedir().
* Expand a leading ~ to the given home directory (defaults to os.homedir()).
* Every call site inside resolveConfigHomeFromDescriptor threads its
* resolved `home` local through here so an injected opts.home (used by
* hermetic tests) is honored instead of silently falling back to the real
* home directory.
*/
function expandTilde(p: string): string {
function expandTilde(p: string, home: string = os.homedir()): string {
if (!p) return p;
if (p.startsWith('~/') || p === '~') return path.join(os.homedir(), p.slice(1));
if (p.startsWith('~/') || p === '~') return path.join(home, p.slice(1));
return p;
}
/**
* True when `val` is a usable env-var override: a real string that contains
* at least one non-whitespace character. Every env-override consumption site
* in resolveConfigHomeFromDescriptor gates on this instead of a bare truthy
* check, so `FOO_DIR=''` (empty), `FOO_DIR` unset (`undefined`), and
* `FOO_DIR=' '` (whitespace-only — e.g. from a shell templating bug that
* leaves a variable substitution blank but quoted) all fall back to the
* descriptor default identically. Deliberately does NOT trim: a value that
* merely has leading/trailing whitespace around otherwise-real content (or
* interior whitespace, e.g. `~/My Agent Dir`) is passed through byte-for-byte
* unchanged, exactly as this module already treats every other env-var
* override (no site here or elsewhere in this file trims a path value) — so
* default behavior for every non-whitespace value is unaffected by this guard.
*/
function hasNonBlankOverride(val: string | undefined): val is string {
return typeof val === 'string' && val.trim() !== '';
}
export interface ResolveAntigravityOpts {
env?: Record<string, string | undefined>;
home?: string;
@@ -177,7 +199,7 @@ export function resolveConfigHomeFromDescriptor(
// First env var that is set wins
for (const varName of configHome.env) {
const val = env[varName];
if (val) return expandTilde(val);
if (hasNonBlankOverride(val)) return expandTilde(val, home);
}
return path.join(home, configHome.name);
}
@@ -185,8 +207,8 @@ export function resolveConfigHomeFromDescriptor(
case 'dot-home-nested': {
// env override
const nestedEnv0Val = env[configHome.env[0]];
if (configHome.env[0] && nestedEnv0Val) {
return expandTilde(nestedEnv0Val);
if (configHome.env[0] && hasNonBlankOverride(nestedEnv0Val)) {
return expandTilde(nestedEnv0Val, home);
}
const base = path.join(home, configHome.parent);
if (configHome.probe && configHome.probe.length > 0) {
@@ -218,18 +240,18 @@ export function resolveConfigHomeFromDescriptor(
case 'xdg': {
// env[0]: direct override dir
const xdgEnv0Val = env[configHome.env[0]];
if (configHome.env[0] && xdgEnv0Val) {
return expandTilde(xdgEnv0Val);
if (configHome.env[0] && hasNonBlankOverride(xdgEnv0Val)) {
return expandTilde(xdgEnv0Val, home);
}
// env[1]: FILE path → dirname
const xdgEnv1Val = env[configHome.env[1]];
if (configHome.env[1] && xdgEnv1Val) {
return path.dirname(expandTilde(xdgEnv1Val));
if (configHome.env[1] && hasNonBlankOverride(xdgEnv1Val)) {
return path.dirname(expandTilde(xdgEnv1Val, home));
}
// env[2]: XDG_CONFIG_HOME → subdir
const xdgEnv2Val = env[configHome.env[2]];
if (configHome.env[2] && xdgEnv2Val) {
return path.join(expandTilde(xdgEnv2Val), configHome.name);
if (configHome.env[2] && hasNonBlankOverride(xdgEnv2Val)) {
return path.join(expandTilde(xdgEnv2Val, home), configHome.name);
}
return path.join(home, '.config', configHome.name);
}
@@ -237,31 +259,22 @@ export function resolveConfigHomeFromDescriptor(
case 'generic-agents-root': {
// env override
const garEnv0Val = env[configHome.env[0]];
if (configHome.env[0] && garEnv0Val) {
return expandTilde(garEnv0Val);
if (configHome.env[0] && hasNonBlankOverride(garEnv0Val)) {
return expandTilde(garEnv0Val, home);
}
// probe each candidate; return first where probeExists subpath exists
for (const candidate of configHome.probe) {
const resolved = expandTildeWithHome(candidate, home);
const resolved = expandTilde(candidate, home);
if (existsSyncFn(path.join(resolved, configHome.probeExists))) {
return resolved;
}
}
// fallback: first probe candidate
return expandTildeWithHome(configHome.probe[0], home);
return expandTilde(configHome.probe[0], home);
}
}
}
/**
* Expand ~ using an explicit home directory (for hermetic testing).
*/
function expandTildeWithHome(p: string, home: string): string {
if (!p) return p;
if (p.startsWith('~/') || p === '~') return path.join(home, p.slice(1));
return p;
}
/**
* Resolve Antigravity global config dir across 1.x and 2.x layouts.
*