Skip to content

Tools resolve .env to $LIFEOS_CONFIG_DIR/.env — the path StatusLine.sh says was "removed everywhere else" #1490

Description

@christauff

Summary

LIFEOS_StatusLine.sh already states the rule, in shipped code:

# Source .env for API keys. Canonical location is $HOME/.claude/.env (which is
# typically a symlink to $HOME/.config/LIFEOS/.env). The historical $HOME/.claude/LIFEOS/.env
# path is wrong and has been removed everywhere else — do not reintroduce it.

LifeOS/install/LIFEOS/LIFEOS_StatusLine.sh:134-136

It has not been removed everywhere else. Seven files still compute exactly that path.

const envPath = process.env.LIFEOS_CONFIG_DIR
  ? join(process.env.LIFEOS_CONFIG_DIR, '.env')   // -> ~/.claude/LIFEOS/.env   (nothing creates this)
  : join(homedir(), '.claude', '.env')            // -> ~/.claude/.env          (correct, but unreachable)

settings.system.json:9 sets LIFEOS_CONFIG_DIR="$HOME/.claude/LIFEOS" — that variable names the LIFEOS directory, not the directory holding .env. So the ternary always takes the first branch, and the correct path sitting right there in the else is dead code.

Why this surfaces now, and why nobody has reported it

Until #1451 (merged 2026-07-09), LIFEOS_CONFIG_DIR was injected as the literal, unexpanded string $HOME/.claude/LIFEOS. The file read therefore failed against a garbage path for everyone, and every tool silently fell through to process.env. #1451 correctly expanded the variable — which made the wrong-but-now-real path ~/.claude/LIFEOS/.env reachable for the first time. The bug in its current form is five days old.

It is also masked, which is why it survives in a mature package. Every affected tool checks process.env first:

const API_KEY = process.env.GROK_API_KEY || process.env.XAI_API_KEY || env.GROK_API_KEY || env.XAI_API_KEY

so anyone who exports keys in their shell never touches the broken branch. And bun auto-loads ./.env from the current working directory, so a tool run from ~/.claude picks the key up by accident and appears to work — its own path resolution never actually reads the file.

It only bites when a user (a) keeps keys in ~/.claude/.env as the docs instruct, and (b) runs a tool from any cwd other than ~/.claude — which is what happens whenever a skill or subagent runs from a project directory.

Severity: latent, not critical. Filing it because it contradicts the project's own stated doctrine and because the template case below regenerates it.

Reproduce

On main (HEAD 03a969d), with XAI_API_KEY set in ~/.claude/.env:

cd ~/.claude
bun LIFEOS/TOOLS/Grok.ts --web-only "say OK"
# -> OK                    (bun auto-loaded ./.env from cwd; the tool's own resolution never ran)

cd /tmp
bun ~/.claude/LIFEOS/TOOLS/Grok.ts --web-only "say OK"
# -> Error: GROK_API_KEY (or XAI_API_KEY) not set in ~/.claude/.env

The error message names the very file it declined to read.

Affected files (all at HEAD)

File Line
LIFEOS/TOOLS/Grok.ts 65
LIFEOS/TOOLS/GrokAudit.ts 110
LIFEOS/TOOLS/PerplexitySearch.ts 49
LIFEOS/TOOLS/YouTubeApi.ts 54
LIFEOS/TOOLS/llcli/llcli.ts 70
skills/AudioEditor/Tools/Polish.ts 40
skills/CreateCLI/Workflows/CreateCli.md 205

CreateCli.md is the one that matters most: it is the template new CLIs are generated from, so the bug is generative — every tool created from it inherits the wrong path. Its fallback is wrong in a third way (~/.claude/LifeOS/.env), and the parser it teaches uses .split('=')[1], which truncates any value containing = (e.g. a base64 key with padding).

Two things I want to raise honestly, not hide

  1. DOCUMENTATION/Tools/Containment.md:84 says LIFEOS/.env and ~/.config/LIFEOS/.env "are symlinks to" the canonical file. I could find no code that creates those symlinks — no symlinkSync or ln -s in the tree targets .env, and install.sh contains zero .env references. If that symlink is meant to be created by the installer, then this issue is really "the installer doesn't create it," and the fix is different. Happy to be corrected here.

  2. The same file says "no central env helper exists by design." A shared loader is the obvious fix for seven copies of the same broken expression, but I don't want to fight a deliberate architectural choice. If you'd rather each tool keep its own parser, the minimal fix is to repoint all seven at ~/.claude/.env and leave it there — say which you prefer.

Proposed fix

Either (a) repoint the seven sites at ~/.claude/.env, or (b) a single shared LIFEOS/TOOLS/EnvLoader.ts the tools import.

I prototyped (b) and then put the prototype through five independent review passes across three model families (three Anthropic, one xAI, one OpenAI). That was instructive: a correct resolver turned out to need far more than the one-line path swap this looks like at first. Each of the properties below started as a bug the reviews found in an earlier version of my own fix:

  • Canonical must win. Putting $LIFEOS_CONFIG_DIR/.env first lets a stale or planted ~/.claude/LIFEOS/.env silently override live secrets — and that directory is agent-writable and git-tracked. Support it only as a lowest-precedence gap-filler.
  • Merge on truthiness, not key presence. XAI_API_KEY= (a half-edited file) parses to ''; if that empty string claims the merge slot it blocks the real value — resurrecting the "API key not set" symptom through the fix itself.
  • CLAUDE_CONFIG_DIR replaces the root; it must not gap-fill. An isolated test/tenant root that inherits missing keys from ~/.claude/.env or from a still-set LIFEOS_CONFIG_DIR silently burns production secrets against production accounts.
  • Path-selecting env vars are as untrusted as secret values. bun autoloads a cwd ./.env into process.env before the tool runs, so a hostile repo can set CLAUDE_CONFIG_DIR/LIFEOS_CONFIG_DIR there and redirect the loader at an attacker-controlled .env. Filtering secret values doesn't catch this — the redirect is in file selection, upstream of any value.
  • Every consumer must actually route through the resolver. Several tools kept a local process.env.X || env.X, so the shared defenses didn't reach them. A shared helper only helps the tools that call it.
  • Absolute candidates only; return a copy, not the live cache; use Object.hasOwn not in; strip inline # comments before they reach an Authorization header. Each of these was a live defect at some point in the prototype.

That list is the actual point. A correct resolver needs ~9 subtle properties — which is precisely the argument against seven hand-rolled copies. Seven tools each re-implementing this by hand guarantees seven subtly-divergent, subtly-broken versions, which is what the codebase has today (and what my own first sweep reproduced — I initially missed llcli and Polish).

I recognise this pushes against Containment.md's "no central env helper exists by design." If you'd rather keep that design, (a) is viable — but then the six properties above have to be duplicated correctly into each of the seven sites, and the CreateCLI template that mints new ones. I have (b) implemented and tested against every property above (23 tests) and can open a PR if the approach is welcome. Your call on (a) vs (b).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions