fix: resolve path env vars (PAI_DIR/PROJECTS_DIR/CLAUDE_CONFIG_DIR) through paths.ts — settings.json env values are not shell-expanded#1270
Open
Drizzt321 wants to merge 2 commits into
Conversation
…s are not shell-expanded
settings.json `env` values are applied to the process environment as literal
strings; Claude Code does not shell-expand them. PAI shipped
`PAI_DIR="${HOME}/.claude/PAI"` (literal) and ~33 hooks/tools read
`process.env.PAI_DIR` raw with a `|| join(...)` fallback that never fires (the
literal is truthy), producing a non-absolute path that `path.join` silently
rebases onto `process.cwd()` — scattering PAI memory/state writes into whatever
directory the session was launched from. This is a v5.0.0 regression from an
incomplete restructure (MEMORY moved under PAI/, only 25/33 consumer fallbacks
migrated).
Changes:
- hooks/lib/paths.ts: getClaudeDir() honors CLAUDE_CONFIG_DIR; getPaiDir() is
now join(getClaudeDir(), PAI_DIR ?? 'PAI') — PAI_DIR redefined as a SUBPATH
of Claude home (no absolute branch), so CLAUDE_CONFIG_DIR propagates to PAI
data automatically; new getProjectsDir(); all three hard-throw on a
non-absolute resolved path (assertAbsolute).
- settings.json: env.PAI_DIR "${HOME}/.claude/PAI" -> "PAI" (coupled with the
subpath redesign; breaking — must land together) + schema doc updated.
- ~35 consumers migrated from raw process.env reads to the helpers
(getPaiDir/getClaudeDir/getProjectsDir) per each call site's true root.
Breaking: the resolver and settings.json change are coupled. Maintainer is
invited to weigh the precedence/migration approach (see PR description).
Out of scope (separate follow-ups): PAI_CONFIG_DIR, the ~12 CLAUDE_DIR consts,
PAI_DIR->PAI_HOME rename, the pre-existing skills/_PAI/ phantom (NOTE D).
Bash-side mirror of hooks/lib/paths.ts (env-fix from 77ba719). Shell scripts had the same hazard the TS fix addressed: settings.json env values are literal strings, and a non-absolute PAI_DIR silently rebased onto cwd — so running PAI from a working dir outside ~/.claude/ broke statusline LEARNING signals, PULSE path resolution, and any consumer relying on $HOME/.claude assumptions. New file: hooks/lib/paths.sh — sourceable helper exporting expand_path, assert_absolute, get_claude_dir, get_pai_dir, get_projects_dir, get_settings_path, get_env_path, pai_path, get_hooks_dir, get_skills_dir, get_memory_dir. Single-form bootstrap convention used everywhere including inside hooks/: . "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/hooks/lib/paths.sh" The source line itself implements the same CLAUDE_CONFIG_DIR fallback contract the helper defines. Consumers converted: - PAI/statusline-command.sh: replace inline PAI_DIR/CLAUDE_HOME resolution with helper calls; convert ALGO_VERSION first-rung, projects glob (multi-account correctness), and credentials read (multi-account correctness) to helper-resolved paths. Hardened fallback ladder rungs 2-4 left intact — multi-source diversity is the point. - PAI/PULSE/manage.sh: replace hardcoded $HOME/.claude/PAI/PULSE with $(pai_path PULSE). - PAI/PULSE/MenuBar/install.sh: replace hardcoded log path with $(pai_path PULSE logs). assert_absolute prints to stderr and returns 1 (caller decides exit), matching paths.ts's assertAbsolute throw semantics but bash-idiomatic. Idempotent sourcing guard via _PAI_PATHS_SH_LOADED. Verified: 10/10 tests pass (function exports, default-env resolution, PAI_DIR=PAI match settings.json, CLAUDE_CONFIG_DIR override probe, assert_absolute surface-check, live statusline LEARNING render from non-claude CWD, both PULSE path resolutions, syntax checks, idempotent re-source). Notes for follow-up (not in this commit): - Statusline fallback line ~66 hardcodes /Users/ (macOS only); Linux hook-spawn with broken env would benefit from a parallel /home/ rung. - Line 107 PAI_CONFIG_DIR/.env predates this PR's scope (env path canonicalization deferred to separate PR per env-fix spec ITEM 3).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Description
Summary
settings.json
envvalues are applied to the process environment as literal strings —Claude Code does not shell-expand them.
PAI_DIR="${HOME}/.claude/PAI"is therefore storedverbatim. ~33 hooks/tools read
process.env.PAI_DIRraw with a|| join(...)fallback thatnever fires (the literal is truthy), producing a non-absolute path that
path.joinsilently rebases onto
process.cwd(). PAI memory/state/learning writes are misrouted intowhatever directory the session was launched from.
This PR routes all such consumers through the existing
hooks/lib/paths.tshelpers(
getPaiDir/getClaudeDir/ newgetProjectsDir), makesgetClaudeDir()honor theofficial
CLAUDE_CONFIG_DIRoverride, and adds a fail-fast guard so a non-absolute resolvedpath throws instead of silently misrouting.
It also redefines
PAI_DIRas a subpath of Claude home and changes the shippedsettings.json value
PAI_DIR=${HOME}/.claude/PAI→PAI_DIR=PAI.getPaiDir()becomesjoin(getClaudeDir(), PAI_DIR ?? 'PAI'). Rationale: this makes "PAI data lives inside Claudehome" a structural invariant rather than a coincidence of an env value, so the official
CLAUDE_CONFIG_DIRoverride propagates to PAI data automatically (multi-account worksend-to-end), and the maintainer's
PAI_DIRuse (swapping PAI dirs) is preserved via relativesubpaths (e.g.
PAI_DIR=experiments/PAI-v2), always rooted in Claude home.This is a breaking change (not backward compatible): the resolver change and the
settings.json change are coupled and must land together — an old absolute
PAI_DIRvaluewith the new resolver produces
~/.claude/${HOME}/.claude/PAI. Existing installs must updateenv.PAI_DIRtoPAI(or remove it; the default isPAI). This is a deliberate redesign ofthe
PAI_DIRcontract; the maintainer should weigh the precedence/migration approach andis invited to comment — an alternative (keep
PAI_DIRabsolute-or-expanded, accept thatCLAUDE_CONFIG_DIRonly governs PAI data via the unset-fallback) is viable and lower-churnbut leaves multi-account incomplete for PAI data. We propose the subpath redesign as the
more-correct end state; open to the alternative if preferred.
Reproduction (platform-independent — runnable on macOS too)
In any Claude Code session launched with PAI's shipped settings.json:
The mechanism (env-block no-expand; Node
pathPOSIX semantics identical on macOS/Linux) isplatform-independent. The defect exists on macOS too; it is merely masked when the working
directory happens to be
~/.claude(the misrouted relative path lands harmlessly inside~/.claude/${HOME}/...). It becomes visible whenever the cwd is anything else.Evidence
HOMEexpanded butPAI_DIRliteral (above).path.isAbsolute("${HOME}/...") === false;path.join/resolverebaseon
process.cwd()— documented Nodepathbehavior, identical on Darwin and Linux.are silent on
env): Claude Code documents${VAR}/~expansion as opt-in forspecific settings — HTTP hook headers (
$VAR/${VAR}interpolation,hooks.md),autoMemoryDirectory(~/-prefix,settings.md). Theenvblock has no such language.Expansion is opt-in per-setting;
envis not opted in. Empirically confirmed (above).PAI_DIR=${HOME}/.claude,MEMORYat~/.claude/MEMORY, all fallbacks~/.claude, masked by unconditionalchdir(~/.claude)). v5.0.0 movedMEMORYunderPAI/and updated the env value to${HOME}/.claude/PAIbut only migrated 25/33 consumer fallbacks — 8 left stale at~/.claude. This is a v5.0.0 regression from an incomplete restructure.How this was discovered (context — NOT part of this PR)
This surfaced via an unrelated local modification (not proposed here, not a dependency of
this fix): a downstream user changed their
pai.tslauncher so that instead of alwayschdir-ing to~/.claudeon startup, it starts in~/scratch-workspace(or the currentdirectory when that directory has applicable write permissions in settings). That change is
sound and orthogonal — it does not introduce the bug. Upstream's unconditional
process.chdir(CLAUDE_DIR)was, in effect, accidentally masking this latent defect: withcwd always pinned to
~/.claude, the misrouted relative path resolved to~/.claude/${HOME}/.claude/PAI/...— still wrong, but buried inside~/.claudewhere itwent unnoticed. The moment startup cwd is anything other than
~/.claude, the misroutedwrites land in the working directory (e.g. an unrelated project repo), making the
pre-existing v5 regression visible.
The takeaway for review: this PR neither contains nor requires any launcher/CWD change.
The CWD change was merely the lens that exposed a bug already present in upstream v5.0.0. The
fix (consumer-side resolution via
paths.ts) is correct regardless of working directory —which is precisely the property the codebase should have and currently does not.
Approach
All affected consumers are routed through the existing
hooks/lib/paths.tshelpers ratherthan reading
process.env.*raw:getClaudeDir()— honorsCLAUDE_CONFIG_DIR(official override), else~/.claude.getPaiDir()—join(getClaudeDir(), PAI_DIR ?? 'PAI').PAI_DIRis redefined as asubpath of Claude home; no absolute branch.
CLAUDE_CONFIG_DIRtherefore propagates toPAI data automatically.
getProjectsDir()— new;PROJECTS_DIRexpanded, default~/Projects.getProjectsDir'sPROJECTS_DIRstill usesexpandPath(it is not redefined as asubpath — it points outside Claude home by nature; only
PAI_DIRbecomes a subpath).Coupled settings.json change:
env.PAI_DIR${HOME}/.claude/PAI→PAI. Breaking: theresolver and settings change must land together; existing installs migrate
PAI_DIRtoPAI(or remove it). Not retroactive without the settings change. See the diff for per-file
changes.
Bash-side companion
Same bug class affected shell scripts that read
$PAI_DIRraw and silently rebased onto cwd— the visible symptom was statusline LEARNING signals going blank when sessions launch from
a working dir outside
~/.claude/.hooks/lib/paths.sh— sourceable bash mirror ofpaths.ts. Exportsexpand_path,assert_absolute,get_claude_dir,get_pai_dir,get_projects_dir,get_settings_path,get_env_path,pai_path,get_hooks_dir,get_skills_dir,get_memory_dir.hooks/:. "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/hooks/lib/paths.sh"— the bootstrap line itselfencodes the same fallback contract the helper implements.
PAI/statusline-command.sh(bootstrap,ALGO_VERSIONfirst rung,projects glob, credentials read),
PAI/PULSE/manage.sh(PULSE_DIR), andPAI/PULSE/MenuBar/install.sh(logs path).assert_absoluteprints to stderr and returns 1 (caller decides exit) — matches TSassertAbsolutethrow semantics but bash-idiomatic. Idempotent re-source via_PAI_PATHS_SH_LOADED.Out of scope (intentional)
Separate follow-ups, not bundled to keep this reviewable:
PAI_CONFIG_DIR(6 consumers) contradictsgetEnvPath()'s declared canonical.envlocation, plus a stale duplicate
~/.config/PAI/.env. Different severity (credentialresolution). Separate PR.
CLAUDE_DIRconsts +process.env.HOMEconcat siblings are correct today but bypassthe helper /
CLAUDE_CONFIG_DIR— behavior-neutral consistency refactor. Separate PR.PAI_DIR→PAI_HOMErename (the name invites the "which dir? cwd?" confusion thatcaused the v5 mismigration). Deferred.
Observations for maintainer consideration (not changed by this PR)
${PAI_DIR},${HOME}… never${HOME}/."): the${HOME}vs${HOME}/trailing-slash distinction is irrelevant topath.join/resolve(only naive string-concat, and
//is POSIX-harmless), and its premise (that${HOME}works in settings.json) is incorrect. Suggest restating as the real contract: env values
are literal; resolve path env vars via
paths.tshelpers; never readprocess.env.*rawfor paths.
PAI-Install/engine/config-gen.tscould emitpre-expanded absolute paths into the generated per-user settings.json (never the
template) as defense-in-depth. Non-substitutive — consumer-side resolution is the fix.
skills/_PAI/phantom (NOTE D):skills/_PAI/is referenced by live code(
IntegrityMaintenance.ts→CreateUpdate.ts, spawned viaSystemIntegrityhandler)and ≥3 docs/skills, but is absent from the release tree. The
IntegrityMaintenance→CreateUpdatebackground spawn fails silently against the missingfile. Likely a release-scrub or packaging gap rather than an intentional removal — flagged
for the maintainer; unrelated to this PR's bug.
ALGO_VERSIONhardened fallback ladder hardcodes
/Users/$(id -un)/...(macOS-only); a parallel/home/rung would extend the same broken-env hardening to Linux hosts. (b)PAI_CONFIG_DIR/.env(statusline line 107) follows the canonical-env-path questionalready covered in Out of scope above (
PAI_CONFIG_DIRbullet).Testing
grep -rn 'process\.env\.PAI_DIR\s*\(\|\|\|??\)' hooks/ PAI/→ onlypaths.ts:33(theone correct consumer feeding
expandPath).getPaiDir()/getClaudeDir()/getProjectsDir()resolve absolute for${HOME}/$HOME/~/absolute input forms; throw on non-absolute.~/.claudecwd, trigger a memory-writing hook, confirm no${HOME}/directory is created and writes land in the real PAI dir.expand_path/get_claude_dir/get_pai_diragainstCLAUDE_CONFIG_DIRoverride andPAI_DIR=PAI), live statuslinerender from non-
~/.claudecwd, idempotent re-source, all four edited shell scriptssyntax-check clean.