Skip to content

fix(runtime): never source credentials from cue's own runtime dir - #137

Merged
NagyVikt merged 1 commit into
mainfrom
agent/claude/credentials-source-not-cue-runtime-2026-08-07-13-00
Aug 7, 2026
Merged

fix(runtime): never source credentials from cue's own runtime dir#137
NagyVikt merged 1 commit into
mainfrom
agent/claude/credentials-source-not-cue-runtime-2026-08-07-13-00

Conversation

@NagyVikt

@NagyVikt NagyVikt commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Test plan

  • verified locally

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
NagyVikt marked this pull request as ready for review August 7, 2026 11:12
@NagyVikt
NagyVikt merged commit f119554 into main Aug 7, 2026
6 checks passed

@NagyVikt NagyVikt left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛡️ 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;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);
});
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
});
});
// 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);
});
});

NagyVikt added a commit that referenced this pull request Aug 7, 2026
…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>
NagyVikt added a commit that referenced this pull request Aug 7, 2026
…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>
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