-
Notifications
You must be signed in to change notification settings - Fork 1
fix(runtime): never source credentials from cue's own runtime dir #137
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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`). |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: Why this mattersA 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: |
||
|
|
||
| const homeClaude = join(homedir(), ".claude"); | ||
| if (existsSync(join(homeClaude, ".credentials.json"))) return homeClaude; | ||
|
|
||
There was a problem hiding this comment.
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
isCueRuntimeDirpredicate, none exercisespickClaudeCredentialsSource. 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.