fix(runtime): never source credentials from cue's own runtime dir - #137
Conversation
A nested `cue launch` of the profile already running destroyed its own runtime dir: 69 self-referential symlinks — sessions/, projects/, history.jsonl, keybindings.json, .session-stats.json and the rest, each pointing at its own path — and a runtime that answered "Not logged in · Please run /login" until the next launch rewrote .credentials.json. pickClaudeCredentialsSource() returned CLAUDE_CONFIG_DIR unconditionally. cue points that variable at <configDir>/runtime/<profile>/claude when it launches an agent, so every process spawned inside a cue session inherits it, and a nested launch ran with credentialsSource === runtimeDir. overlaySourceState() then linked each unmanaged entry to <runtimeDir>/<name>, and step 6's tmp→runtimeDir rename left every link pointing at itself. .credentials.json survived only because step 6 moves it across explicitly. An explicit CLAUDE_CONFIG_DIR still wins — that is how authmux hands cue a per-account config — but not when it names cue's own runtime tree. Falling through reaches the existing ~/.claude / authmux ladder, i.e. a source outside the dir being rebuilt. The check is an exported pure helper, matching this file's style, and compares resolved paths on a `root + sep` boundary so a sibling like runtime-backup/ stays usable. cue #132 is what unmasked this: before it, a nested non-TTY launch died at the picker and never reached the materializer. Verified with the exact env a nested launch inherits — before: `nested -> /tmp/probe-cfg/cue/runtime/some+profile/claude`; after: `nested -> ~/.claude`; `authmux -> ~/.claude-account2` unchanged on both. Full suite branch 33 fail vs base 34 in the same shell, failing-set diff empty (the one differing name passes 2/2 in isolation on both sides — flake, not a fix).
NagyVikt
left a comment
There was a problem hiding this comment.
🛡️ GitGuardex code-assist
2 finding(s) — 🟡 1 medium · 🔵 1 low
✅ Merge gate: pass — no blocking findings (blocks on high/critical).
| Severity | Location | Finding |
|---|---|---|
| 🟡 medium | src/lib/runtime-install.ts:119 |
Rejecting every CLAUDE_CONFIG_DIR under the runtime tree silently switches Claude accounts for a nested launch that started under an |
| 🔵 low | src/lib/runtime-install.test.ts:112 |
The wiring the PR actually fixes is untested — every new test targets the pure isCueRuntimeDir predicate, none exercises |
Provider claude · commit 264a1e0
| // what a nested launch inherits. Falling through to the real config keeps | ||
| // the overlay sourced from outside the dir being rebuilt. | ||
| const envConfigDir = process.env.CLAUDE_CONFIG_DIR; | ||
| if (envConfigDir && !isCueRuntimeDir(envConfigDir)) return envConfigDir; |
There was a problem hiding this comment.
Important
🟡 MEDIUM · correctness
Rejecting every CLAUDE_CONFIG_DIR under the runtime tree silently switches Claude accounts for a nested launch that started under an authmux per-account session. Concretely: CLAUDE_CONFIG_DIR=~/.claude-accounts/account2 cue launch claude resolves accountTag=account2 (src/commands/launch.ts:1970) and execs the child with CLAUDE_CONFIG_DIR=/core@account2/claude.
Why this matters
A nested launch inside that session now hits this guard, falls through, and returns ~/.claude — account1's token — while the outer session runs account2. Before this change that nested launch was NOT the self-overlay case the PR targets: authmuxAccountTag() returns undefined for a runtime path (launch.ts:1531-1549), so runtimeKey is core, the target dir (/core/claude) differs from the source (/core@account2/claude), and the overlay correctly inherited account2's credentials. So the broad guard is wider than the bug: the corruption only occurs when source === the dir being rebuilt (same profile, no account tag), and the account case pays for it. Narrow it to the dir this launch will actually write — compute runtimeKey before resolveClaudeCredentialsSource() and compare against runtimeDirFor(runtimeKey, agent) — or propagate the true source down to nested launches (e.g. a CUE_CREDENTIALS_SOURCE env var set alongside CLAUDE_CONFIG_DIR at exec) so the fallback keeps the outer session's account.
| expect(isCueRuntimeDir("/tmp/cfg/runtime/core/claude/", root)).toBe(true); | ||
| expect(isCueRuntimeDir("/tmp/cfg/runtime/core/claude", `${root}/`)).toBe(true); | ||
| }); | ||
| }); |
There was a problem hiding this comment.
Note
🔵 LOW · tests
The wiring the PR actually fixes is untested — every new test targets the pure isCueRuntimeDir predicate, none exercises pickClaudeCredentialsSource. Deleting the !isCueRuntimeDir(envConfigDir) clause at src/lib/runtime-install.ts:119 leaves all 21 tests green, so the regression can silently return. The notes confirm the end-to-end behaviour was only checked by a manual probe script, which does not run in CI.
Why this matters
Two cases are worth locking: a runtime-dir CLAUDE_CONFIG_DIR is not returned, and a non-runtime (authmux per-account) CLAUDE_CONFIG_DIR is still returned verbatim.
| }); | |
| }); | |
| // Locks the wiring, not just the predicate: the guard only helps if | |
| // pickClaudeCredentialsSource actually consults it. | |
| describe("pickClaudeCredentialsSource", () => { | |
| async function pickWith(configDirEnv: string, xdgEnv: string): Promise<string> { | |
| const prev = { ccd: process.env.CLAUDE_CONFIG_DIR, xdg: process.env.XDG_CONFIG_HOME }; | |
| process.env.CLAUDE_CONFIG_DIR = configDirEnv; | |
| process.env.XDG_CONFIG_HOME = xdgEnv; | |
| try { | |
| const { pickClaudeCredentialsSource } = await import("./runtime-install"); | |
| return await pickClaudeCredentialsSource(); | |
| } finally { | |
| if (prev.ccd === undefined) delete process.env.CLAUDE_CONFIG_DIR; | |
| else process.env.CLAUDE_CONFIG_DIR = prev.ccd; | |
| if (prev.xdg === undefined) delete process.env.XDG_CONFIG_HOME; | |
| else process.env.XDG_CONFIG_HOME = prev.xdg; | |
| } | |
| } | |
| test("refuses a CLAUDE_CONFIG_DIR pointing at cue's own runtime", async () => { | |
| const runtime = "/tmp/cue-creds-probe/cue/runtime/core/claude"; | |
| expect(await pickWith(runtime, "/tmp/cue-creds-probe")).not.toBe(runtime); | |
| }); | |
| test("still honors an explicit per-account CLAUDE_CONFIG_DIR", async () => { | |
| const account = "/tmp/cue-creds-probe/.claude-account2"; | |
| expect(await pickWith(account, "/tmp/cue-creds-probe")).toBe(account); | |
| }); | |
| }); |
…139) #137 refused any CLAUDE_CONFIG_DIR under cue's runtime tree. That is wider than the bug: the corruption needs source === the dir being rebuilt, and under an authmux account the two differ. The outer session execs the child with <runtime>/<profile>@account2/claude; the nested launch's own target is <runtime>/<profile>/claude, because authmuxAccountTag() returns undefined for a runtime path. Source never equalled target there — and that overlay is the only thing carrying account2's credentials into the child. #137 rejected it and fell back to ~/.claude, silently running the nested agent as account1. Reproduced against main: `B authmux run -> ~/.claude`. isSelfOverlaySource() replaces isCueRuntimeDir(): an exact resolved-path comparison against the runtime dir this launch will write, threaded through pickClaudeCredentialsSource / resolveClaudeCredentialsSource. Callers that are not rebuilding a runtime omit it and keep the plain CLAUDE_CONFIG_DIR answer. launch.ts resolves accountTag/runtimeKey before the credentials source — a pure reorder, since authmuxAccountTag(ccd, homedir()) never depended on it. Also closes #137's second finding: its tests all targeted the pure predicate, so deleting the guard kept them green. The new pickClaudeCredentialsSource block drives CLAUDE_CONFIG_DIR directly and is mutation-checked — replacing the guarded return with an unconditional one fails "refuses CLAUDE_CONFIG_DIR when it is the dir being rebuilt". Probe across all three shapes: self-overlay -> ~/.claude (the #137 fix, kept); authmux-nested -> the account2 runtime (regression undone); plain authmux dir -> unchanged. Full suite 33 fail on branch and base, failing sets byte-identical. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
…140) #139 made the self-overlay guard opt-in via options.runtimeDir. launch.ts passes it; `cue install` and `cue sync` do not — and both rebuild runtimes through prepareRuntime(). So either command run from inside a cue session still took CLAUDE_CONFIG_DIR, its own runtime dir, as the overlay source and reproduced #137's self-referential symlinks. The guard was off exactly where the materialization happens. Both callers now pass the dir they are about to write: runtimeDirFor(profile.name, agent) in install.ts, matching prepareRuntime's own `runtimeKey ?? profile.name` default, and runtimeDirFor(key, agent) in sync.ts, where `key` is already the runtimeKey passed two lines below. This was raised on #139 and committed there as 91c6601d, but never reached the remote before that PR merged — main got the narrowing alone. Confirmed on origin/main afterwards: isSelfOverlaySource present, `runtimeDir:` in install.ts/sync.ts absent. Cherry-picked here onto the post-#139 main. Also carries the comment answering #139's LOW: the wiring test asserts not.toBe(target) rather than a concrete fall-through path because os.homedir() reads the passwd entry, not $HOME, so a temp-HOME fixture does not pin the branch (measured — it still resolved the real ~/.claude). No fall-through branch can return the target, so the assertion holds anywhere and stays mutation-proof. Full suite 33 fail on branch and base, failing sets identical both ways. Co-authored-by: NagyVikt <nagy.viktordp@gmail.com>
Summary
Test plan