Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-08-07
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# agent-claude-credentials-source-not-cue-runtime-2026-08-07-13-00 (minimal / T1)

Branch: `agent/claude/credentials-source-not-cue-runtime-2026-08-07-13-00`

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`, …), every one pointing at its own
path, and a runtime that reported `Not logged in · Please run /login` until the
next launch rewrote `.credentials.json`.

## Cause

`pickClaudeCredentialsSource()` returned `process.env.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 therefore ran with
`credentialsSource === runtimeDir`.

`materializeRuntime()` step 5 (`overlaySourceState`) symlinks every entry cue
does not manage from `credentialsSource` into `tmpDir`. With source == the dir
being rebuilt, each link was written as `<runtimeDir>/<name>`. Step 6 then
renames `tmpDir` onto `runtimeDir` — and every link now points at its own new
path. `.credentials.json` survived only because step 6 explicitly moves it from
the old runtime.

## Change

One guard, as an exported pure helper matching the file's existing style
(`isRuntimeAgent`, `runtimeAgentSubdir`, `runtimeDirFor`):

- `isCueRuntimeDir(dir, runtimeRoot?)` — resolves both sides and tests for the
root itself or a `root + sep` prefix, so a sibling like `runtime-backup/`
stays usable.
- `pickClaudeCredentialsSource()` — an explicit `CLAUDE_CONFIG_DIR` still wins
(that is how authmux hands cue a per-account config), but not when it is
cue's own runtime dir. Falling through reaches the existing `~/.claude` /
authmux ladder, i.e. a source outside the dir being rebuilt.

## Verification

- `bun run typecheck` — clean.
- `bun run lint` — 6 warnings, all pre-existing (`ai.ts`, `evolve.ts`,
`shell.test.ts`, `runtime-materializer.ts`); zero in the touched files.
- `bun test src/lib/runtime-install.test.ts` — 21 pass / 0 fail (8 new).
- `bun test` (full), branch vs base in the same worktree and same shell:
- branch: 3033 pass / 33 fail / 3112 run
- base: 3024 pass / 34 fail / 3104 run
- Failing-set diff: **zero new failures**. The one name that differs
(`cue score > --all shows all profiles ranked`) passes 2/2 in isolation on
BOTH branch and base — full-suite flake, not something this change fixed.
Not claiming it as a fix.
- Behavioural before/after on the real resolver, driven with the exact env a
nested launch inherits (`XDG_CONFIG_HOME=/tmp/probe-cfg`,
`CLAUDE_CONFIG_DIR=$XDG_CONFIG_HOME/cue/runtime/some+profile/claude`):
- before: `nested -> /tmp/probe-cfg/cue/runtime/some+profile/claude` (the
self-overlay that caused the 69 loops)
- after: `nested -> /home/deadpool/.claude`
- both: `authmux -> /home/deadpool/.claude-account2` (per-account dir still
wins, unchanged)

## Notes

Real-world trigger: cue #132 let nested non-TTY launches proceed past the
picker instead of erroring out. That unmasked this — before #132 the nested
launch died before it ever materialized.

The damage on the live profile was repaired out-of-band by re-pointing all 69
loops at their `~/.claude` counterparts; no data was lost, since the loops were
symlinks and every target existed at the source.

## Cleanup

- [ ] Run: `gx branch finish --branch agent/claude/credentials-source-not-cue-runtime-2026-08-07-13-00 --base main --via-pr --gate-review --review-provider claude --wait-for-merge --cleanup`
- [ ] Record PR URL + `MERGED` state in the completion handoff.
- [ ] Confirm sandbox worktree is gone (`git worktree list`, `git branch -a`).
42 changes: 42 additions & 0 deletions src/lib/runtime-install.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, test, expect } from "bun:test";
import {
isCueRuntimeDir,
isRuntimeAgent,
runtimeAgentSubdir,
runtimeDirFor,
Expand Down Expand Up @@ -68,3 +69,44 @@ describe("runtimeDirFor", () => {
expect(result).toBe("/tmp/runtime/core+skill-writer/claude");
});
});

describe("isCueRuntimeDir", () => {
const root = "/tmp/cfg/runtime";

test("true for a materialized profile runtime — what a nested launch inherits", () => {
expect(isCueRuntimeDir(runtimeDirFor("core", "claude-code", root), root)).toBe(true);
});

test("true for the runtime root itself", () => {
expect(isCueRuntimeDir(root, root)).toBe(true);
});

test("true for a codex runtime", () => {
expect(isCueRuntimeDir(runtimeDirFor("core", "codex", root), root)).toBe(true);
});

test("false for ~/.claude", () => {
expect(isCueRuntimeDir("/home/u/.claude", root)).toBe(false);
});

test("false for an authmux per-account config dir", () => {
expect(isCueRuntimeDir("/home/u/.claude-account2", root)).toBe(false);
});

// A path prefix is not a path component: `/tmp/cfg/runtime-backup` is a
// sibling of the runtime root, not inside it, and must stay usable as a
// credentials source.
test("false for a sibling whose name merely starts with the root", () => {
expect(isCueRuntimeDir("/tmp/cfg/runtime-backup/claude", root)).toBe(false);
});

test("normalizes traversal before comparing", () => {
expect(isCueRuntimeDir("/tmp/cfg/runtime/core/../core/claude", root)).toBe(true);
expect(isCueRuntimeDir("/tmp/cfg/runtime/../.claude", root)).toBe(false);
});

test("tolerates a trailing separator on either side", () => {
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);
});
});

30 changes: 28 additions & 2 deletions src/lib/runtime-install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import { existsSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join, resolve } from "node:path";
import { join, resolve, sep } from "node:path";
import { homedir } from "node:os";

import type { AgentKind, ResolvedProfile } from "../../profiles/_types";
Expand Down Expand Up @@ -89,8 +89,34 @@ export async function readUserAgentMemory(agent: RuntimeAgent): Promise<string>
}
}

/**
* True when `dir` sits inside cue's own runtime tree
* (`<configDir>/runtime/<profile>/<agent>`).
*
* Exists to keep that tree out of {@link pickClaudeCredentialsSource}. cue
* points `CLAUDE_CONFIG_DIR` at the runtime dir when it launches an agent, so
* every process spawned inside a cue session inherits it — and a nested launch
* that took it as the credentials SOURCE would overlay a runtime dir onto
* itself: `overlaySourceState()` links each unmanaged entry to
* `<runtimeDir>/<name>`, then the atomic tmp→runtimeDir rename leaves every one
* of those links pointing at its own path. Observed 2026-08-07 on a live
* profile: 69 self-referential symlinks (`sessions/`, `projects/`,
* `history.jsonl`, …), all unreadable, and a runtime that reported "Not logged
* in" until the next launch rewrote `.credentials.json`.
*/
export function isCueRuntimeDir(dir: string, runtimeRoot = join(configDir(), "runtime")): boolean {
const target = resolve(dir);
const root = resolve(runtimeRoot);
return target === root || target.startsWith(root + sep);
}

export async function pickClaudeCredentialsSource(): Promise<string> {
if (process.env.CLAUDE_CONFIG_DIR) return process.env.CLAUDE_CONFIG_DIR;
// An explicit CLAUDE_CONFIG_DIR wins — that is how authmux hands cue a
// per-account config — but not when it is cue's own runtime dir, which is
// 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.


const homeClaude = join(homedir(), ".claude");
if (existsSync(join(homeClaude, ".credentials.json"))) return homeClaude;
Expand Down
Loading