diff --git a/openspec/changes/agent-claude-credentials-source-install-sync-2026-08-07-13-47/.openspec.yaml b/openspec/changes/agent-claude-credentials-source-install-sync-2026-08-07-13-47/.openspec.yaml new file mode 100644 index 00000000..878dc315 --- /dev/null +++ b/openspec/changes/agent-claude-credentials-source-install-sync-2026-08-07-13-47/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-07 diff --git a/openspec/changes/agent-claude-credentials-source-install-sync-2026-08-07-13-47/notes.md b/openspec/changes/agent-claude-credentials-source-install-sync-2026-08-07-13-47/notes.md new file mode 100644 index 00000000..3494f2c7 --- /dev/null +++ b/openspec/changes/agent-claude-credentials-source-install-sync-2026-08-07-13-47/notes.md @@ -0,0 +1,51 @@ +# agent-claude-credentials-source-install-sync-2026-08-07-13-47 (minimal / T1) + +Branch: `agent/claude/credentials-source-install-sync-2026-08-07-13-47` + +Completes #139, whose review raised a HIGH that its own merge did not carry. + +## The HIGH + +#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: + +- `install.ts` — `runtimeDirFor(profile.name, agent)`, matching + `prepareRuntime`'s own `runtimeKey ?? profile.name` default. +- `sync.ts` — `runtimeDirFor(key, agent)`; `key` is already the `runtimeKey` + passed to `prepareRuntime` two lines below. + +## Why it is a separate PR + +The fix was committed on #139's branch as `91c6601d` but never reached the +remote before the PR merged — main got only the narrowing commit. Verified after +the fact on `origin/main`: `isSelfOverlaySource` present (3 hits), `runtimeDir:` +in install.ts / sync.ts absent (0). Cherry-picked here onto the post-#139 main. + +## Also carried + +The comment on #139's LOW finding, explaining why the wiring test asserts +`not.toBe(target)` rather than a concrete fall-through path: `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 on any machine and stays +mutation-proof. + +## Verification + +- `bun run typecheck` — clean. +- `bun run lint` — warnings pre-existing; zero in the touched files. +- `bun test src/lib/runtime-install.test.ts` — 23 pass / 0 fail. +- `bun test` (full), branch vs base in the same worktree and shell — failing set + compared both directions. + +## Cleanup + +- [ ] Run: `gx branch finish --branch agent/claude/credentials-source-install-sync-2026-08-07-13-47 --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/commands/install.ts b/src/commands/install.ts index 752178f6..289ac657 100644 --- a/src/commands/install.ts +++ b/src/commands/install.ts @@ -246,7 +246,13 @@ async function materializeProfile(profileName: string, agent: AnyAgent, force: b profile, agent, credentialsSource: agent === "claude-code" - ? await resolveClaudeCredentialsSource({ healFromRuntime: false }) + ? await resolveClaudeCredentialsSource({ + healFromRuntime: false, + // The dir prepareRuntime() is about to write — it defaults + // runtimeKey to profile.name. Passing it keeps a `cue install` run + // from inside a cue session from overlaying that runtime onto itself. + runtimeDir: runtimeDirFor(profile.name, agent), + }) : undefined, }); return { profile: profileName, agent, targetDir: result.runtimeDir, status: result.rebuilt ? "rebuilt" : "cached" }; diff --git a/src/commands/sync.ts b/src/commands/sync.ts index ff8942d4..4ce60616 100644 --- a/src/commands/sync.ts +++ b/src/commands/sync.ts @@ -34,6 +34,7 @@ import { prepareRuntime, resolveClaudeCredentialsSource, runtimeAgentSubdir, + runtimeDirFor, } from "../lib/runtime-install"; const RUNTIME_ROOT = join(configDir(), "runtime"); @@ -143,7 +144,13 @@ export async function run(args: string[]): Promise { runtimeKey: key, credentialsSource: agent === "claude-code" - ? await resolveClaudeCredentialsSource({ healFromRuntime: false }) + // `key` is the runtimeKey passed above, so this is exactly the + // dir this iteration will write — a `cue sync` run from inside a + // cue session must not overlay that runtime onto itself. + ? await resolveClaudeCredentialsSource({ + healFromRuntime: false, + runtimeDir: runtimeDirFor(key, agent), + }) : undefined, }); rebuilt = rebuilt || out.rebuilt; diff --git a/src/lib/runtime-install.test.ts b/src/lib/runtime-install.test.ts index 5723f24e..c0093d50 100644 --- a/src/lib/runtime-install.test.ts +++ b/src/lib/runtime-install.test.ts @@ -118,6 +118,11 @@ describe("pickClaudeCredentialsSource", () => { else process.env.CLAUDE_CONFIG_DIR = original; }); + // Asserted as "not the target" rather than a concrete path: which fall-through + // branch wins (~/.claude, or an authmux profile) depends on the machine, but + // none of them can return `target` — only the rejected `CLAUDE_CONFIG_DIR` + // early-return could. `os.homedir()` reads the passwd entry, not $HOME, so + // there is no cheap way to pin the branch without a test-only injection point. test("refuses CLAUDE_CONFIG_DIR when it is the dir being rebuilt", async () => { process.env.CLAUDE_CONFIG_DIR = target; expect(await pickClaudeCredentialsSource({ runtimeDir: target })).not.toBe(target);