Skip to content

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
danielmiessler:mainfrom
Drizzt321:fix/pai-dir-env-resolution
Open

fix: resolve path env vars (PAI_DIR/PROJECTS_DIR/CLAUDE_CONFIG_DIR) through paths.ts — settings.json env values are not shell-expanded#1270
Drizzt321 wants to merge 2 commits into
danielmiessler:mainfrom
Drizzt321:fix/pai-dir-env-resolution

Conversation

@Drizzt321
Copy link
Copy Markdown

@Drizzt321 Drizzt321 commented May 16, 2026

Description

Summary

settings.json env values are applied to the process environment as literal strings
Claude Code does not shell-expand them. PAI_DIR="${HOME}/.claude/PAI" is therefore stored
verbatim. ~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(). PAI memory/state/learning writes are misrouted into
whatever directory the session was launched from.

This PR routes all such consumers through the existing hooks/lib/paths.ts helpers
(getPaiDir / getClaudeDir / new getProjectsDir), makes getClaudeDir() honor the
official CLAUDE_CONFIG_DIR override, and adds a fail-fast guard so a non-absolute resolved
path throws instead of silently misrouting.

It also redefines PAI_DIR as a subpath of Claude home and changes the shipped
settings.json value PAI_DIR=${HOME}/.claude/PAIPAI_DIR=PAI. getPaiDir() becomes
join(getClaudeDir(), PAI_DIR ?? 'PAI'). Rationale: this makes "PAI data lives inside Claude
home" a structural invariant rather than a coincidence of an env value, so the official
CLAUDE_CONFIG_DIR override propagates to PAI data automatically (multi-account works
end-to-end), and the maintainer's PAI_DIR use (swapping PAI dirs) is preserved via relative
subpaths (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_DIR value
with the new resolver produces ~/.claude/${HOME}/.claude/PAI. Existing installs must update
env.PAI_DIR to PAI (or remove it; the default is PAI). This is a deliberate redesign of
the PAI_DIR contract; the maintainer should weigh the precedence/migration approach and
is invited to comment
— an alternative (keep PAI_DIR absolute-or-expanded, accept that
CLAUDE_CONFIG_DIR only governs PAI data via the unset-fallback) is viable and lower-churn
but 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:

$ env | grep -E 'HOME=|PAI_DIR='
HOME=/Users/you                 # shell-expanded (set by the launching shell)
PAI_DIR=${HOME}/.claude/PAI     # LITERAL — Claude Code did not expand it
$ bun -e 'import {isAbsolute} from "path"; console.log(isAbsolute(process.env.PAI_DIR))'
false                           # → path.join rebases this onto process.cwd()

The mechanism (env-block no-expand; Node path POSIX semantics identical on macOS/Linux) is
platform-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

  • Empirical: live session env shows HOME expanded but PAI_DIR literal (above).
  • Node behavior: path.isAbsolute("${HOME}/...") === false; path.join/resolve rebase
    on process.cwd() — documented Node path behavior, identical on Darwin and Linux.
  • Documentation asymmetry (not an affirmative claim that env is non-expanding — the docs
    are silent on env):
    Claude Code documents ${VAR}/~ expansion as opt-in for
    specific settings
    — HTTP hook headers ($VAR/${VAR} interpolation, hooks.md),
    autoMemoryDirectory (~/-prefix, settings.md). The env block has no such language.
    Expansion is opt-in per-setting; env is not opted in. Empirically confirmed (above).
  • Regression provenance: v4.0.3 was internally consistent (PAI_DIR=${HOME}/.claude,
    MEMORY at ~/.claude/MEMORY, all fallbacks ~/.claude, masked by unconditional
    chdir(~/.claude)). v5.0.0 moved MEMORY under PAI/ and updated the env value to
    ${HOME}/.claude/PAI but 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.ts launcher so that instead of always
chdir-ing to ~/.claude on startup, it starts in ~/scratch-workspace (or the current
directory 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: with
cwd always pinned to ~/.claude, the misrouted relative path resolved to
~/.claude/${HOME}/.claude/PAI/... — still wrong, but buried inside ~/.claude where it
went unnoticed. The moment startup cwd is anything other than ~/.claude, the misrouted
writes 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.ts helpers rather
than reading process.env.* raw:

  • getClaudeDir() — honors CLAUDE_CONFIG_DIR (official override), else ~/.claude.
  • getPaiDir()join(getClaudeDir(), PAI_DIR ?? 'PAI'). PAI_DIR is redefined as a
    subpath
    of Claude home; no absolute branch. CLAUDE_CONFIG_DIR therefore propagates to
    PAI data automatically.
  • getProjectsDir() — new; PROJECTS_DIR expanded, default ~/Projects.
  • All three hard-throw on a non-absolute resolved path (fail loud, not silent-misroute).
  • getProjectsDir's PROJECTS_DIR still uses expandPath (it is not redefined as a
    subpath — it points outside Claude home by nature; only PAI_DIR becomes a subpath).

Coupled settings.json change: env.PAI_DIR ${HOME}/.claude/PAIPAI. Breaking: the
resolver and settings change must land together; existing installs migrate PAI_DIR to PAI
(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_DIR raw and silently rebased onto cwd
— the visible symptom was statusline LEARNING signals going blank when sessions launch from
a working dir outside ~/.claude/.

  • New hooks/lib/paths.sh — sourceable bash mirror of paths.ts. Exports 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.
  • Sourcing convention used everywhere including inside hooks/:
    . "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/hooks/lib/paths.sh" — the bootstrap line itself
    encodes the same fallback contract the helper implements.
  • Consumers converted: PAI/statusline-command.sh (bootstrap, ALGO_VERSION first rung,
    projects glob, credentials read), PAI/PULSE/manage.sh (PULSE_DIR), and
    PAI/PULSE/MenuBar/install.sh (logs path).
  • assert_absolute prints to stderr and returns 1 (caller decides exit) — matches TS
    assertAbsolute throw 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) contradicts getEnvPath()'s declared canonical .env
    location, plus a stale duplicate ~/.config/PAI/.env. Different severity (credential
    resolution). Separate PR.
  • 12 CLAUDE_DIR consts + process.env.HOME concat siblings are correct today but bypass
    the helper / CLAUDE_CONFIG_DIR — behavior-neutral consistency refactor. Separate PR.
  • PAI_DIRPAI_HOME rename (the name invites the "which dir? cwd?" confusion that
    caused the v5 mismigration). Deferred.

Observations for maintainer consideration (not changed by this PR)

  1. CLAUDE.md path rule ("Use ${PAI_DIR}, ${HOME} … never ${HOME}/."): the
    ${HOME} vs ${HOME}/ trailing-slash distinction is irrelevant to path.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.ts helpers; never read process.env.* raw
    for paths.
  2. Optional installer hardening: PAI-Install/engine/config-gen.ts could emit
    pre-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.
  3. skills/_PAI/ phantom (NOTE D): skills/_PAI/ is referenced by live code
    (IntegrityMaintenance.tsCreateUpdate.ts, spawned via SystemIntegrity handler)
    and ≥3 docs/skills, but is absent from the release tree. The
    IntegrityMaintenanceCreateUpdate background spawn fails silently against the missing
    file. Likely a release-scrub or packaging gap rather than an intentional removal — flagged
    for the maintainer; unrelated to this PR's bug.
  4. From the bash-side companion, two items deferred: (a) the statusline's ALGO_VERSION
    hardened 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 question
    already covered in Out of scope above (PAI_CONFIG_DIR bullet).

Testing

  • grep -rn 'process\.env\.PAI_DIR\s*\(\|\|\|??\)' hooks/ PAI/ → only paths.ts:33 (the
    one correct consumer feeding expandPath).
  • Helper smoke tests: getPaiDir()/getClaudeDir()/getProjectsDir() resolve absolute for
    ${HOME}/$HOME/~/absolute input forms; throw on non-absolute.
  • Live: launch from a non-~/.claude cwd, trigger a memory-writing hook, confirm no
    ${HOME}/ directory is created and writes land in the real PAI dir.
  • Typecheck clean.
  • Bash side: helper smoke tests (function exports, expand_path / get_claude_dir /
    get_pai_dir against CLAUDE_CONFIG_DIR override and PAI_DIR=PAI), live statusline
    render from non-~/.claude cwd, idempotent re-source, all four edited shell scripts
    syntax-check clean.

Drizzt321 added 2 commits May 15, 2026 21:24
…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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant