Compare commits

...

1 Commits

Author SHA1 Message Date
Jiang Bohan
8d775d8166 fix(agent): mirror $HOME/.claude.json into isolated config dir (MUL-2661)
PR #3200 introduced per-agent `skills_local=ignore` isolation that
mirrors the host's Claude config dir into a per-task scratch dir,
omitting `skills/` to keep broken local skills out of the CLI's
discovery path. The mirror walks entries inside `hostConfigDir`
(default: `$HOME/.claude/`), but Claude Code's default layout stores
its main config — login state, project history — at
`$HOME/.claude.json`, a *sibling* of `~/.claude/` rather than inside
it. Once `CLAUDE_CONFIG_DIR=$ISOLATED` is set, the CLI looks for
`$ISOLATED/.claude.json`, finds only `backups/.claude.json.backup.*`
(those live inside `~/.claude/` and DO get mirrored), and exits with:

  Claude configuration file not found at: …/.claude.json
  Not logged in · Please run /login

— so every agent with `skills_local=ignore` on a host using the
default Claude layout dies on the first turn. Flipping the toggle back
to "merge" restores the host CLAUDE_CONFIG_DIR and recovers the agent;
that's the workaround Bohan flagged in MUL-2661.

Fix: after the existing `mirrorHostClaudeExceptSkills`, run a new
`mirrorHostClaudeJSONIfMissing` that pulls `$HOME/.claude.json` into
the scratch dir as `.claude.json` when (a) the dest doesn't already
have one and (b) the host source dir is the default `$HOME/.claude/`.
The custom-CLAUDE_CONFIG_DIR path is left alone because a pinned
custom dir is expected to be self-contained — silently borrowing
`$HOME/.claude.json` from a different account would mask credential
drift.

The helper goes through `createFileLink`, so it inherits the same
symlink → junction → hardlink → copy fallback chain the rest of the
mirror uses on Windows-without-Developer-Mode hosts.

Tests:
- `TestMirrorHostClaudeJSONIfMissing_DefaultLayoutMirrorsParentFile`
  covers the happy path with an injected `homeDir`/`fileLink`.
- `TestMirrorHostClaudeJSONIfMissing_AlreadyPresentNoop` asserts a
  pre-existing dest `.claude.json` (from a custom CLAUDE_CONFIG_DIR
  mirror) is not overwritten.
- `TestMirrorHostClaudeJSONIfMissing_CustomHostDirSkipped` locks in
  the custom-host-dir gate.
- `TestMirrorHostClaudeJSONIfMissing_MissingSourceNoop` documents the
  env-var-auth-only / fresh-install case.
- `TestClaudeExecuteIsolatesProvidesClaudeJSONFromHome` is the
  end-to-end MUL-2661 regression: a fake `\$HOME` with the default
  split layout, `skills_local=ignore`, fake claude binary that prints
  whatever `.claude.json` reaches the scratch dir. Asserts the file
  rides through. Verified the test fails (with the documented
  MUL-2661 error message) when the new mirror call is removed.

Verification:
- `go test ./pkg/agent/...` green (full agent suite).
- `GOOS=windows GOARCH=amd64 go vet ./pkg/agent/...` clean.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-26 13:45:01 +08:00
2 changed files with 268 additions and 0 deletions

View File

@@ -752,6 +752,20 @@ func newIsolatedClaudeConfigDir(taskCwd, hostConfigDir string, logger *slog.Logg
"error", err,
)
}
// Claude Code's default layout (no CLAUDE_CONFIG_DIR set) stores the
// main config — login state, projects history, recent sessions — at
// `$HOME/.claude.json`, a *sibling* of `~/.claude/` rather than
// inside it. The mirror above only walks entries under
// `hostConfigDir`, so on default hosts the scratch dir never gets
// `.claude.json` and the CLI exits with `Claude configuration file
// not found … Not logged in · Please run /login` (MUL-2661).
if err := mirrorHostClaudeJSONIfMissing(hostConfigDir, dir); err != nil && logger != nil {
logger.Warn("claude: mirror host .claude.json failed",
"source", hostConfigDir,
"dest", dir,
"error", err,
)
}
}
cleanup := func() {
@@ -811,6 +825,54 @@ func mirrorHostClaudeExceptSkillsWith(
return firstErr
}
// mirrorHostClaudeJSONIfMissing links `$HOME/.claude.json` into destDir as
// `.claude.json` when it is not already there. Claude Code's default layout
// (no CLAUDE_CONFIG_DIR set) stores the main config — login state, project
// history — at `$HOME/.claude.json`, a *sibling* of `~/.claude/`, not inside
// it. Without this passthrough, isolating CLAUDE_CONFIG_DIR strands the CLI
// in a dir without `.claude.json` and it bails with
// `Claude configuration file not found … Not logged in · Please run /login`
// (MUL-2661 regression).
//
// No-op when:
// - destDir already has `.claude.json` (mirrored from inside a custom
// CLAUDE_CONFIG_DIR by mirrorHostClaudeExceptSkills);
// - hostConfigDir is not the default `$HOME/.claude` — a custom
// CLAUDE_CONFIG_DIR is expected to be self-contained, and silently
// merging `$HOME/.claude.json` from a different account would mask
// credential drift;
// - `$HOME/.claude.json` does not exist (env-var-auth-only or fresh
// install).
func mirrorHostClaudeJSONIfMissing(hostConfigDir, destDir string) error {
return mirrorHostClaudeJSONIfMissingWith(hostConfigDir, destDir, os.UserHomeDir, createFileLink)
}
// mirrorHostClaudeJSONIfMissingWith is the testable seam behind
// mirrorHostClaudeJSONIfMissing. Tests inject homeDir / fileLink so they can
// exercise the precedence rules without mutating the process environment.
func mirrorHostClaudeJSONIfMissingWith(
hostConfigDir, destDir string,
homeDir func() (string, error),
fileLink func(src, dst string) error,
) error {
dst := filepath.Join(destDir, ".claude.json")
if _, err := os.Lstat(dst); err == nil {
return nil
}
home, err := homeDir()
if err != nil || home == "" {
return nil
}
if hostConfigDir != filepath.Join(home, ".claude") {
return nil
}
src := filepath.Join(home, ".claude.json")
if _, err := os.Stat(src); err != nil {
return nil
}
return fileLink(src, dst)
}
// copyFile copies the bytes of src into dst with a fresh file. Used as the
// last-resort fallback inside createFileLink on Windows when both symlink
// and hardlink are unavailable. Kept in the platform-agnostic file so the

View File

@@ -1301,6 +1301,212 @@ func TestCopyFileRoundTrip(t *testing.T) {
}
}
// TestMirrorHostClaudeJSONIfMissing_DefaultLayoutMirrorsParentFile covers
// the MUL-2661 regression: Claude Code's default layout stores `.claude.json`
// at `$HOME/.claude.json`, a sibling of `~/.claude/`. The isolation mirror
// must pull that file into the scratch dir or the CLI exits with
// `Not logged in · Please run /login` on the first turn after the operator
// opts into `ignore` mode.
func TestMirrorHostClaudeJSONIfMissing_DefaultLayoutMirrorsParentFile(t *testing.T) {
t.Parallel()
fakeHome := t.TempDir()
if err := os.MkdirAll(filepath.Join(fakeHome, ".claude"), 0o755); err != nil {
t.Fatalf("seed fake ~/.claude: %v", err)
}
if err := os.WriteFile(filepath.Join(fakeHome, ".claude.json"), []byte(`{"loggedIn":true}`), 0o600); err != nil {
t.Fatalf("seed fake $HOME/.claude.json: %v", err)
}
dest := t.TempDir()
homeDir := func() (string, error) { return fakeHome, nil }
if err := mirrorHostClaudeJSONIfMissingWith(filepath.Join(fakeHome, ".claude"), dest, homeDir, os.Symlink); err != nil {
t.Fatalf("mirror: %v", err)
}
got, err := os.ReadFile(filepath.Join(dest, ".claude.json"))
if err != nil {
t.Fatalf("read mirrored .claude.json: %v", err)
}
if string(got) != `{"loggedIn":true}` {
t.Fatalf("mirrored content drifted, got %q", got)
}
}
// TestMirrorHostClaudeJSONIfMissing_AlreadyPresentNoop documents that a
// `.claude.json` already in destDir (mirrored from inside a custom
// CLAUDE_CONFIG_DIR by the main mirror loop) wins over the parent-level
// `$HOME/.claude.json`. Re-linking would silently overwrite the
// operator-pinned credentials with the default-account ones.
func TestMirrorHostClaudeJSONIfMissing_AlreadyPresentNoop(t *testing.T) {
t.Parallel()
fakeHome := t.TempDir()
if err := os.WriteFile(filepath.Join(fakeHome, ".claude.json"), []byte(`{"loggedIn":"home"}`), 0o600); err != nil {
t.Fatalf("seed home .claude.json: %v", err)
}
dest := t.TempDir()
if err := os.WriteFile(filepath.Join(dest, ".claude.json"), []byte(`{"loggedIn":"custom"}`), 0o600); err != nil {
t.Fatalf("seed existing dest .claude.json: %v", err)
}
called := false
fileLink := func(src, dst string) error {
called = true
return nil
}
homeDir := func() (string, error) { return fakeHome, nil }
if err := mirrorHostClaudeJSONIfMissingWith(filepath.Join(fakeHome, ".claude"), dest, homeDir, fileLink); err != nil {
t.Fatalf("mirror: %v", err)
}
if called {
t.Fatal("fileLink must not be invoked when dest already has .claude.json")
}
got, _ := os.ReadFile(filepath.Join(dest, ".claude.json"))
if string(got) != `{"loggedIn":"custom"}` {
t.Fatalf("existing dest .claude.json was overwritten, got %q", got)
}
}
// TestMirrorHostClaudeJSONIfMissing_CustomHostDirSkipped guards the
// operator-pinned CLAUDE_CONFIG_DIR contract. A custom dir is expected to be
// self-contained; pulling in `$HOME/.claude.json` on top would silently merge
// a different account's login state.
func TestMirrorHostClaudeJSONIfMissing_CustomHostDirSkipped(t *testing.T) {
t.Parallel()
fakeHome := t.TempDir()
if err := os.WriteFile(filepath.Join(fakeHome, ".claude.json"), []byte(`{"loggedIn":"home"}`), 0o600); err != nil {
t.Fatalf("seed home .claude.json: %v", err)
}
customHost := t.TempDir()
dest := t.TempDir()
called := false
fileLink := func(src, dst string) error {
called = true
return nil
}
homeDir := func() (string, error) { return fakeHome, nil }
if err := mirrorHostClaudeJSONIfMissingWith(customHost, dest, homeDir, fileLink); err != nil {
t.Fatalf("mirror: %v", err)
}
if called {
t.Fatal("fileLink must not be invoked when host dir is custom (not default $HOME/.claude)")
}
if _, err := os.Lstat(filepath.Join(dest, ".claude.json")); !os.IsNotExist(err) {
t.Fatalf("dest .claude.json should remain absent, stat err=%v", err)
}
}
// TestMirrorHostClaudeJSONIfMissing_MissingSourceNoop documents that a host
// with no `$HOME/.claude.json` (fresh install, env-var-auth-only setup) is a
// supported state. The mirror is a no-op and the scratch dir's lack of
// `.claude.json` is left to the CLI to handle (it will surface its own
// "not logged in" error, but the daemon does not pretend a file exists).
func TestMirrorHostClaudeJSONIfMissing_MissingSourceNoop(t *testing.T) {
t.Parallel()
fakeHome := t.TempDir()
dest := t.TempDir()
called := false
fileLink := func(src, dst string) error {
called = true
return nil
}
homeDir := func() (string, error) { return fakeHome, nil }
if err := mirrorHostClaudeJSONIfMissingWith(filepath.Join(fakeHome, ".claude"), dest, homeDir, fileLink); err != nil {
t.Fatalf("mirror: %v", err)
}
if called {
t.Fatal("fileLink must not be invoked when $HOME/.claude.json is absent")
}
}
// TestClaudeExecuteIsolatesProvidesClaudeJSONFromHome is the end-to-end
// MUL-2661 regression: an agent opted into `skills_local=ignore` on a host
// that uses Claude Code's default layout ($HOME/.claude.json sibling of
// ~/.claude/) must still start successfully. Before the fix, the scratch
// CLAUDE_CONFIG_DIR was missing `.claude.json` and the CLI exited with
// `Not logged in · Please run /login` on the first turn.
func TestClaudeExecuteIsolatesProvidesClaudeJSONFromHome(t *testing.T) {
// NOT parallel — t.Setenv mutates global env (HOME + CLAUDE_CONFIG_DIR).
if runtime.GOOS == "windows" {
t.Skip("shell-script fixture is POSIX-only")
}
// Synthesize Claude Code's default split layout under a fake $HOME:
// $FAKE_HOME/.claude/ — settings, agents, plugins, etc.
// $FAKE_HOME/.claude.json — main config (login state)
fakeHome := t.TempDir()
if err := os.MkdirAll(filepath.Join(fakeHome, ".claude"), 0o755); err != nil {
t.Fatalf("seed fake ~/.claude: %v", err)
}
if err := os.WriteFile(filepath.Join(fakeHome, ".claude", "settings.json"), []byte(`{}`), 0o644); err != nil {
t.Fatalf("seed fake settings.json: %v", err)
}
const expectedConfig = "logged-in-default-layout"
if err := os.WriteFile(filepath.Join(fakeHome, ".claude.json"), []byte(expectedConfig), 0o600); err != nil {
t.Fatalf("seed fake $HOME/.claude.json: %v", err)
}
t.Setenv("HOME", fakeHome)
// Ensure the host has no CLAUDE_CONFIG_DIR override — the regression
// only manifests in the default split layout.
t.Setenv("CLAUDE_CONFIG_DIR", "")
// Fake claude binary that echoes the .claude.json it reads from the
// scratch CLAUDE_CONFIG_DIR. Before the fix this echoed "MISSING"
// because the mirror skipped the sibling file.
fakePath := filepath.Join(t.TempDir(), "claude")
script := "#!/bin/sh\n" +
"cat >/dev/null\n" +
"cfg=$(cat \"$CLAUDE_CONFIG_DIR/.claude.json\" 2>/dev/null || echo MISSING)\n" +
"printf '%s\\n' \"{\\\"type\\\":\\\"system\\\",\\\"session_id\\\":\\\"sess\\\"}\"\n" +
"printf '%s\\n' \"{\\\"type\\\":\\\"result\\\",\\\"subtype\\\":\\\"success\\\",\\\"is_error\\\":false,\\\"session_id\\\":\\\"sess\\\",\\\"result\\\":\\\"$cfg\\\"}\"\n"
writeTestExecutable(t, fakePath, []byte(script))
backend, err := New("claude", Config{ExecutablePath: fakePath, Logger: slog.Default()})
if err != nil {
t.Fatalf("new claude backend: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
session, err := backend.Execute(ctx, "ignored", ExecOptions{
Cwd: t.TempDir(),
Timeout: 5 * time.Second,
SkillsLocal: "ignore",
})
if err != nil {
t.Fatalf("execute: %v", err)
}
go func() {
for range session.Messages {
}
}()
select {
case result := <-session.Result:
if result.Status != "completed" {
t.Fatalf("expected completed, got %q (err=%q)", result.Status, result.Error)
}
got := strings.TrimSpace(result.Output)
if got == "MISSING" {
t.Fatalf("MUL-2661 regression: .claude.json was not mirrored into the isolated dir")
}
if got != expectedConfig {
t.Fatalf("expected $HOME/.claude.json mirrored, got %q", got)
}
case <-time.After(10 * time.Second):
t.Fatal("timeout waiting for result")
}
}
// TestClaudeExecuteIsolatesUsesCustomEnvSource confirms the runtime mirrors
// from the agent's custom_env CLAUDE_CONFIG_DIR — the exact bug Elon's
// review flagged: when an operator pins CLAUDE_CONFIG_DIR via custom_env,