Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions .kiro/specs/bootstrap-ai-coding/agents/requirements-claude-code.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Container_User_Home>/.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.

---

Expand Down Expand Up @@ -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 `<Container_User_Home>/.claude.json` pointing to `<Container_User_Home>/.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 `<homeDir>/.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 `<Container_User_Home>/.claude.json` during image build.

---

Expand Down
49 changes: 13 additions & 36 deletions internal/agents/claude/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -106,38 +96,25 @@ 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")
info, err := os.Stat(src)
if err != nil || !info.Mode().IsRegular() {
return nil // absent, unreadable, or not a regular file — 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 {
Expand Down
95 changes: 92 additions & 3 deletions internal/agents/claude/claude_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

// ---------------------------------------------------------------------------
Expand All @@ -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
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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\")")
}
})
}
2 changes: 2 additions & 0 deletions internal/agents/claude/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
15 changes: 13 additions & 2 deletions internal/cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
Expand Down
71 changes: 71 additions & 0 deletions internal/docker/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

t.Cleanup(func() {
cleanCtx := context.Background()
_ = docker.StopContainer(cleanCtx, sharedClient, containerName)
_ = docker.RemoveContainer(cleanCtx, sharedClient, containerName)
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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")

// 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")
}
Loading