diff --git a/openspec/changes/agent-claude-credentials-source-not-cue-runtime-2026-08-07-13-00/.openspec.yaml b/openspec/changes/agent-claude-credentials-source-not-cue-runtime-2026-08-07-13-00/.openspec.yaml new file mode 100644 index 00000000..878dc315 --- /dev/null +++ b/openspec/changes/agent-claude-credentials-source-not-cue-runtime-2026-08-07-13-00/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-07 diff --git a/openspec/changes/agent-claude-credentials-source-not-cue-runtime-2026-08-07-13-00/notes.md b/openspec/changes/agent-claude-credentials-source-not-cue-runtime-2026-08-07-13-00/notes.md new file mode 100644 index 00000000..58c0927b --- /dev/null +++ b/openspec/changes/agent-claude-credentials-source-not-cue-runtime-2026-08-07-13-00/notes.md @@ -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 `/runtime//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 `/`. 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`). diff --git a/src/lib/runtime-install.test.ts b/src/lib/runtime-install.test.ts index c8f63ff2..5582c859 100644 --- a/src/lib/runtime-install.test.ts +++ b/src/lib/runtime-install.test.ts @@ -1,5 +1,6 @@ import { describe, test, expect } from "bun:test"; import { + isCueRuntimeDir, isRuntimeAgent, runtimeAgentSubdir, runtimeDirFor, @@ -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); + }); +}); diff --git a/src/lib/runtime-install.ts b/src/lib/runtime-install.ts index 8366c3d6..3cd10405 100644 --- a/src/lib/runtime-install.ts +++ b/src/lib/runtime-install.ts @@ -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"; @@ -89,8 +89,34 @@ export async function readUserAgentMemory(agent: RuntimeAgent): Promise } } +/** + * True when `dir` sits inside cue's own runtime tree + * (`/runtime//`). + * + * 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 + * `/`, 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 { - 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; const homeClaude = join(homedir(), ".claude"); if (existsSync(join(homeClaude, ".credentials.json"))) return homeClaude;