From a296c9f50b4dffe8a7e2ca5820ab384752d7ba3e Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Wed, 1 Jul 2026 22:00:40 +0200 Subject: [PATCH 1/2] Claude Code .claude.json from HOME mounted correctly --- .../agents/requirements-claude-code.md | 18 ++-- internal/agents/claude/claude.go | 48 +++------- internal/agents/claude/claude_test.go | 95 ++++++++++++++++++- internal/agents/claude/integration_test.go | 2 + internal/cmd/root.go | 15 ++- internal/docker/integration_test.go | 71 ++++++++++++++ 6 files changed, 200 insertions(+), 49 deletions(-) diff --git a/.kiro/specs/bootstrap-ai-coding/agents/requirements-claude-code.md b/.kiro/specs/bootstrap-ai-coding/agents/requirements-claude-code.md index d4ed5e5..cb301b0 100644 --- a/.kiro/specs/bootstrap-ai-coding/agents/requirements-claude-code.md +++ b/.kiro/specs/bootstrap-ai-coding/agents/requirements-claude-code.md @@ -40,7 +40,7 @@ Claude Code is Anthropic's AI coding agent. It is the first and default agent mo 2. THE Claude Code module SHALL declare `/.claude` as its Credential_Volume mount path inside the Container. 3. THE Credential_Volume SHALL be a bind-mount so that authentication tokens written inside the Container are immediately persisted to the Host Credential_Store. 4. Authentication tokens persisted in the Host Credential_Store SHALL be available in future Sessions without re-authentication. -5. NOTE: Claude Code also stores onboarding state in `~/.claude.json` (outside the credential directory). See Requirement CC-8 for how this is handled via symlink and host-side synchronisation. +5. NOTE: Claude Code also stores global configuration (onboarding state, MCP servers, preferences) in `~/.claude.json` (outside the credential directory). See Requirement CC-8 for how this is handled via a read-only bind-mount from the host. --- @@ -96,17 +96,19 @@ Claude Code is Anthropic's AI coding agent. It is the first and default agent mo --- -### Requirement CC-8: Onboarding State Synchronisation +### Requirement CC-8: Onboarding & Configuration State via Read-Only Bind-Mount -**User Story:** As a developer, I want my Claude Code onboarding state to persist across container recreations, so I am not prompted to complete the onboarding flow every time the container is rebuilt. +**User Story:** As a developer, I want my Claude Code global configuration (onboarding state, MCP servers, preferences) to be visible inside the container without needing to rebuild, so that host-side changes propagate immediately. #### Acceptance Criteria -1. Claude Code stores its onboarding state (including `hasCompletedOnboarding`) in `~/.claude.json` on the Host — a file in the home directory root, separate from the `~/.claude/` credential directory. -2. THE Claude Code module SHALL create a symlink inside the Container at `/.claude.json` pointing to `/.claude/claude.json`, so that Claude Code reads and writes its onboarding state through the bind-mounted Credential_Volume. -3. THE Claude Code module SHALL implement the `CredentialPreparer` interface. Its `PrepareCredentials` method SHALL copy `~/.claude.json` from the Host home directory into the Credential_Store as `claude.json`, but only when the source file exists and is newer than the destination (or the destination is absent). -4. THE combination of the symlink (inside the container) and the host-side copy (before mount) SHALL ensure that a single bind-mount on `~/.claude/` persists both OAuth tokens and onboarding state across container rebuilds and restarts. -5. IF `~/.claude.json` does not exist on the Host (first-time user), THE `PrepareCredentials` method SHALL silently skip the copy without error. +1. Claude Code stores its global configuration (including `hasCompletedOnboarding` and MCP server definitions) in `~/.claude.json` on the Host — a file in the home directory root, separate from the `~/.claude/` credential directory. +2. THE Claude Code module SHALL implement the `AdditionalMounter` interface by providing an `AdditionalMounts(homeDir string) []docker.Mount` method. +3. WHEN `~/.claude.json` exists on the Host, THE `AdditionalMounts` method SHALL return a slice containing a single `docker.Mount` with `HostPath` set to the absolute path of Host `~/.claude.json`, `ContainerPath` set to `/.claude.json`, and `ReadOnly` set to `true`. +4. THE mount SHALL be read-only — the container cannot modify the host file. +5. WHEN `~/.claude.json` does not exist on the Host (first-time user or file not yet created), THE `AdditionalMounts` method SHALL return an empty slice (graceful skip, no error). +6. THE Claude Code module SHALL NOT implement the `CredentialPreparer` interface and SHALL NOT copy `~/.claude.json` into the Credential_Store. +7. THE Claude Code module SHALL NOT create a symlink at `/.claude.json` during image build. --- diff --git a/internal/agents/claude/claude.go b/internal/agents/claude/claude.go index c8c3699..bb29bf0 100644 --- a/internal/agents/claude/claude.go +++ b/internal/agents/claude/claude.go @@ -34,16 +34,6 @@ func (a *claudeAgent) Install(b *docker.DockerfileBuilder) { } b.Run("npm install -g --no-fund --no-audit @anthropic-ai/claude-code") - // Symlink ~/.claude.json into the credential mount directory so that a single - // bind-mount on ~/.claude/ persists both OAuth tokens (.credentials.json) and - // onboarding state (claude.json). Without this, Claude Code triggers the full - // login/onboarding flow on every container start. - b.Run(fmt.Sprintf( - "ln -sf %s/claude.json %s/.claude.json", - filepath.Join(b.HomeDir(), ".claude"), - b.HomeDir(), - )) - // Copy host user's Claude Code memory (CLAUDE.md) into the image so that // global instructions are available even before the bind-mount overlays. // The bind-mount at runtime will take precedence, but this ensures the @@ -106,38 +96,24 @@ func (a *claudeAgent) HasCredentials(storePath string) (bool, error) { return true, nil } -// PrepareCredentials copies ~/.claude.json into the credential store as -// claude.json (if it exists and the destination is absent or older). -// Inside the container a symlink at ~/.claude.json points to this file, -// so the bind-mount on ~/.claude/ covers both OAuth tokens and onboarding state. -func (a *claudeAgent) PrepareCredentials(storePath string) error { +// AdditionalMounts returns the read-only bind-mount for ~/.claude.json. +// If the file does not exist on the host, the mount is omitted. +func (a *claudeAgent) AdditionalMounts(homeDir string) []docker.Mount { home, err := os.UserHomeDir() if err != nil { - return nil // best-effort; skip if we can't determine home - } - src := filepath.Join(home, ".claude.json") - dst := filepath.Join(storePath, "claude.json") - - srcInfo, err := os.Stat(src) - if err != nil { - // Source doesn't exist — nothing to sync (first-time user). return nil } - - // Only copy if destination is missing or older than source. - dstInfo, err := os.Stat(dst) - if err == nil && !dstInfo.ModTime().Before(srcInfo.ModTime()) { - return nil // destination is up-to-date - } - - data, err := os.ReadFile(src) - if err != nil { - return fmt.Errorf("reading %s: %w", src, err) + src := filepath.Join(home, ".claude.json") + if _, err := os.Stat(src); err != nil { + return nil // file absent or unreadable — skip gracefully } - if err := os.WriteFile(dst, data, 0o600); err != nil { - return fmt.Errorf("writing %s: %w", dst, err) + return []docker.Mount{ + { + HostPath: src, + ContainerPath: filepath.Join(homeDir, ".claude.json"), + ReadOnly: true, + }, } - return nil } func (a *claudeAgent) HealthCheck(ctx context.Context, c *docker.Client, containerID string, username string) error { diff --git a/internal/agents/claude/claude_test.go b/internal/agents/claude/claude_test.go index 62d66de..c8d6653 100644 --- a/internal/agents/claude/claude_test.go +++ b/internal/agents/claude/claude_test.go @@ -313,12 +313,12 @@ func TestClaudeInstallNodeAlreadyInstalled(t *testing.T) { require.Contains(t, content, "curl ca-certificates git", "must always install curl, ca-certificates, git") - // Should have added exactly 3 lines (apt-get prereqs + npm install + symlink) + // Should have added exactly 2 lines (apt-get prereqs + npm install) // plus optionally 1 more if ~/.claude/CLAUDE.md exists on the host (memory injection) linesAfter := len(b.Lines()) added := linesAfter - linesBefore - require.True(t, added == 3 || added == 4, - "must add 3 RUN steps (prereqs + npm + symlink) plus optionally 1 memory injection step, got %d", added) + require.True(t, added == 2 || added == 3, + "must add 2 RUN steps (prereqs + npm) plus optionally 1 memory injection step, got %d", added) } // --------------------------------------------------------------------------- @@ -337,6 +337,63 @@ func TestSummaryInfoReturnsNil(t *testing.T) { require.Nil(t, info) } +// --------------------------------------------------------------------------- +// AdditionalMounts tests +// --------------------------------------------------------------------------- + +// TestClaudeAdditionalMountsFileExists verifies that when ~/.claude.json exists +// on the host, AdditionalMounts returns a single read-only mount with the correct paths. +// Validates: 1.1, 1.2, 1.4, 5.1 +func TestClaudeAdditionalMountsFileExists(t *testing.T) { + a, err := agent.Lookup(constants.ClaudeCodeAgentName) + require.NoError(t, err) + + tmpDir := t.TempDir() + claudeJSON := filepath.Join(tmpDir, ".claude.json") + err = os.WriteFile(claudeJSON, []byte(`{"mcpServers":{}}`), 0o600) + require.NoError(t, err) + + t.Setenv("HOME", tmpDir) + + mounter, ok := a.(agent.AdditionalMounter) + require.True(t, ok, "claude agent must implement agent.AdditionalMounter") + + mounts := mounter.AdditionalMounts("/home/testuser") + require.Len(t, mounts, 1, "must return exactly 1 mount when ~/.claude.json exists") + require.Equal(t, claudeJSON, mounts[0].HostPath) + require.Equal(t, "/home/testuser/.claude.json", mounts[0].ContainerPath) + require.True(t, mounts[0].ReadOnly, "mount must be read-only") +} + +// TestClaudeAdditionalMountsFileAbsent verifies that when ~/.claude.json does not +// exist on the host, AdditionalMounts returns an empty slice. +// Validates: 1.3, 5.1 +func TestClaudeAdditionalMountsFileAbsent(t *testing.T) { + a, err := agent.Lookup(constants.ClaudeCodeAgentName) + require.NoError(t, err) + + tmpDir := t.TempDir() + // No .claude.json created in tmpDir + t.Setenv("HOME", tmpDir) + + mounter, ok := a.(agent.AdditionalMounter) + require.True(t, ok, "claude agent must implement agent.AdditionalMounter") + + mounts := mounter.AdditionalMounts("/home/testuser") + require.Empty(t, mounts, "must return empty slice when ~/.claude.json does not exist") +} + +// TestClaudeDoesNotImplementCredentialPreparer verifies that the Claude agent +// no longer satisfies agent.CredentialPreparer after the symlink removal. +// Validates: 3.3 +func TestClaudeDoesNotImplementCredentialPreparer(t *testing.T) { + a, err := agent.Lookup(constants.ClaudeCodeAgentName) + require.NoError(t, err) + + _, ok := a.(agent.CredentialPreparer) + require.False(t, ok, "claude agent must NOT implement agent.CredentialPreparer") +} + // --------------------------------------------------------------------------- // Property 57: Agent ContainerMountPath uses runtime-provided home directory // --------------------------------------------------------------------------- @@ -367,3 +424,35 @@ func TestPropertyAgentContainerMountPathUsesRuntimeHomeDir(t *testing.T) { } }) } + +// --------------------------------------------------------------------------- +// Feature: claude-json-readonly-mount, Property 1: AdditionalMounts returns 0 or 1 read-only mounts +// --------------------------------------------------------------------------- + +// Feature: claude-json-readonly-mount, Property 1: AdditionalMounts returns 0 or 1 read-only mounts +func TestPropertyAdditionalMountsReturnsZeroOrOneReadOnlyMounts(t *testing.T) { + rapid.Check(t, func(t *rapid.T) { + homeDir := rapid.StringMatching(`/[a-z][a-z0-9]*(/[a-z][a-z0-9]*)*`).Draw(t, "homeDir") + + a, err := agent.Lookup(constants.ClaudeCodeAgentName) + require.NoError(t, err) + + mounter, ok := a.(agent.AdditionalMounter) + require.True(t, ok) + + mounts := mounter.AdditionalMounts(homeDir) + + // Property 1: slice length is 0 or 1 + require.True(t, len(mounts) == 0 || len(mounts) == 1, + "AdditionalMounts must return 0 or 1 elements, got %d", len(mounts)) + + if len(mounts) == 1 { + // Property 2: mount is read-only + require.True(t, mounts[0].ReadOnly, + "mount must be read-only") + // Property 3: ContainerPath is deterministic + require.Equal(t, filepath.Join(homeDir, ".claude.json"), mounts[0].ContainerPath, + "ContainerPath must be filepath.Join(homeDir, \".claude.json\")") + } + }) +} diff --git a/internal/agents/claude/integration_test.go b/internal/agents/claude/integration_test.go index 4461510..eb568e5 100644 --- a/internal/agents/claude/integration_test.go +++ b/internal/agents/claude/integration_test.go @@ -32,6 +32,8 @@ var ( sharedClient *docker.Client sharedImageTag string sharedUsername string + sharedHomeDir string + sharedClaudeJSON string // path to the temp .claude.json mounted into the container ) // TestMain gates the integration suite behind an explicit consent prompt, diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 6b7120a..64f7f97 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -726,9 +726,20 @@ func runStart(c *dockerpkg.Client, projectPath string, enabledAgents []agent.Age // Check if the agent declares additional mounts (e.g. OpenCode config store). if mounter, ok := s.a.(agent.AdditionalMounter); ok { for _, extra := range mounter.AdditionalMounts(info.HomeDir) { - if err := datadir.EnsureCredentialDir(extra.HostPath); err != nil { - return fmt.Errorf("ensuring additional credential dir for %s: %w", s.a.ID(), err) + // Only ensure directory creation for directory mounts. + // File mounts (e.g. ~/.claude.json RO) must not trigger MkdirAll. + if fi, err := os.Stat(extra.HostPath); err == nil && fi.IsDir() { + if err := datadir.EnsureCredentialDir(extra.HostPath); err != nil { + return fmt.Errorf("ensuring additional credential dir for %s: %w", s.a.ID(), err) + } + } else if err != nil && !extra.ReadOnly { + // Path doesn't exist and mount is read-write: create as directory. + if err := datadir.EnsureCredentialDir(extra.HostPath); err != nil { + return fmt.Errorf("ensuring additional credential dir for %s: %w", s.a.ID(), err) + } } + // If path doesn't exist and mount is read-only, skip — the agent's + // AdditionalMounts should have omitted it, but don't create garbage. mounts = append(mounts, extra) } } diff --git a/internal/docker/integration_test.go b/internal/docker/integration_test.go index 43c2ae3..e2e0f19 100644 --- a/internal/docker/integration_test.go +++ b/internal/docker/integration_test.go @@ -1277,3 +1277,74 @@ func TestAFindConflictingUserPullsImageIfAbsent(t *testing.T) { require.NoError(t, err, "base image should be present locally after FindConflictingUser pulls it") } + +// ---------------------------------------------------------------------------- +// TestReadOnlyFileMountIsReadableButNotWritable +// Validates: CC-8 (read-only bind-mount of ~/.claude.json) — core mount plumbing +// ---------------------------------------------------------------------------- + +func TestReadOnlyFileMountIsReadableButNotWritable(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker not available") + } + + buildSharedImage(t) + + ctx := context.Background() + + projectDir := t.TempDir() + dirName := filepath.Base(projectDir) + + // Create a temporary file to mount read-only into the container. + hostFile := filepath.Join(t.TempDir(), "config.json") + err := os.WriteFile(hostFile, []byte(`{"test":"read-only-mount"}`), 0o644) + require.NoError(t, err, "creating host file for RO mount") + + port, err := findFreePort() + require.NoError(t, err, "finding free port") + + containerName := constants.ContainerNamePrefix + sanitize(dirName) + "-ro" + containerFilePath := filepath.Join(sharedHostInfo.HomeDir, ".config-test.json") + + spec := docker.ContainerSpec{ + Name: containerName, + ImageTag: sharedImageTag, + Mounts: []docker.Mount{ + {HostPath: projectDir, ContainerPath: constants.WorkspaceMountPath}, + {HostPath: hostFile, ContainerPath: containerFilePath, ReadOnly: true}, + }, + SSHPort: port, + Labels: map[string]string{"bac.managed": "true"}, + HostInfo: sharedHostInfo, + HostNetworkOff: true, + } + + _, err = docker.CreateContainer(ctx, sharedClient, spec) + require.NoError(t, err, "creating container with RO file mount") + + err = docker.StartContainer(ctx, sharedClient, containerName) + require.NoError(t, err, "starting container with RO file mount") + + t.Cleanup(func() { + cleanCtx := context.Background() + _ = docker.StopContainer(cleanCtx, sharedClient, containerName) + _ = docker.RemoveContainer(cleanCtx, sharedClient, containerName) + }) + + err = docker.WaitForSSH(ctx, "127.0.0.1", port, 60*time.Second) + require.NoError(t, err, "waiting for SSH to be ready") + + // Verify the file is readable inside the container. + exitCode, err := docker.ExecInContainer(ctx, sharedClient, containerName, []string{ + "cat", containerFilePath, + }) + require.NoError(t, err, "exec cat on RO-mounted file") + require.Equal(t, 0, exitCode, "expected RO-mounted file to be readable") + + // Verify writes are rejected (read-only filesystem). + exitCode, err = docker.ExecInContainer(ctx, sharedClient, containerName, []string{ + "bash", "-c", fmt.Sprintf("echo 'write attempt' > %s", containerFilePath), + }) + require.NoError(t, err, "exec write attempt on RO-mounted file") + require.NotEqual(t, 0, exitCode, "expected write to RO-mounted file to fail") +} From 7e4b17d55e4babdea56c9ae594e6bf6640479bff Mon Sep 17 00:00:00 2001 From: Jan Kubalek Date: Wed, 1 Jul 2026 22:12:33 +0200 Subject: [PATCH 2/2] update CR findings --- internal/agents/claude/claude.go | 5 +++-- internal/docker/integration_test.go | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/internal/agents/claude/claude.go b/internal/agents/claude/claude.go index bb29bf0..ff7e81f 100644 --- a/internal/agents/claude/claude.go +++ b/internal/agents/claude/claude.go @@ -104,8 +104,9 @@ func (a *claudeAgent) AdditionalMounts(homeDir string) []docker.Mount { return nil } src := filepath.Join(home, ".claude.json") - if _, err := os.Stat(src); err != nil { - return nil // file absent or unreadable — skip gracefully + info, err := os.Stat(src) + if err != nil || !info.Mode().IsRegular() { + return nil // absent, unreadable, or not a regular file — skip gracefully } return []docker.Mount{ { diff --git a/internal/docker/integration_test.go b/internal/docker/integration_test.go index e2e0f19..ad31d24 100644 --- a/internal/docker/integration_test.go +++ b/internal/docker/integration_test.go @@ -1322,15 +1322,15 @@ func TestReadOnlyFileMountIsReadableButNotWritable(t *testing.T) { _, err = docker.CreateContainer(ctx, sharedClient, spec) require.NoError(t, err, "creating container with RO file mount") - err = docker.StartContainer(ctx, sharedClient, containerName) - require.NoError(t, err, "starting container with RO file mount") - t.Cleanup(func() { cleanCtx := context.Background() _ = docker.StopContainer(cleanCtx, sharedClient, containerName) _ = docker.RemoveContainer(cleanCtx, sharedClient, containerName) }) + err = docker.StartContainer(ctx, sharedClient, containerName) + require.NoError(t, err, "starting container with RO file mount") + err = docker.WaitForSSH(ctx, "127.0.0.1", port, 60*time.Second) require.NoError(t, err, "waiting for SSH to be ready")