Skip to content
Open
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
1 change: 1 addition & 0 deletions cmd/amika/sandbox/command.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func New() *cobra.Command {
sandboxCreateCmd.Flags().Bool("no-setup", false, "Skip the setup script (uses a no-op script instead)")
sandboxCreateCmd.Flags().String("branch", "", "Check out this git branch, or create it if it doesn't exist.")
sandboxCreateCmd.Flags().String("new-branch", "", "Create a new git branch. With --branch, starts from that branch; otherwise starts from the current checkout.")
sandboxCreateCmd.Flags().Bool("no-claude-config", false, "Do not mount the ~/.claude/ directory into the sandbox")
sandboxDeleteCmd.Flags().Bool("force", false, "Skip confirmation prompt")
sandboxDeleteCmd.Flags().Bool("delete-volumes", false, "Also delete associated volumes that are no longer referenced")
sandboxDeleteCmd.Flags().Bool("keep-volumes", false, "Keep associated volumes even when only this sandbox references them")
Expand Down
2 changes: 2 additions & 0 deletions cmd/amika/sandbox/sandbox_create.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,10 +82,12 @@ var sandboxCreateCmd = &cobra.Command{

branchFlag, _ := cmd.Flags().GetString("branch")
newBranchFlag, _ := cmd.Flags().GetString("new-branch")
noClaudeConfig, _ := cmd.Flags().GetBool("no-claude-config")
collected, err := collectMounts(mountStrs, volumeStrs, portStrs, portHostIP,
gitPath, gitFlagChanged, noClean,
setupScript, cmd.Flags().Changed("setup-script"),
noSetup,
noClaudeConfig,
branchFlag, newBranchFlag)
if err != nil {
return err
Expand Down
9 changes: 8 additions & 1 deletion cmd/amika/sandbox/sandbox_create_materialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ func collectMounts(
setupScript string,
setupScriptFlagChanged bool,
noSetup bool,
noClaudeConfig bool,
branch string,
newBranch string,
) (collectedMounts, error) {
Expand Down Expand Up @@ -158,7 +159,13 @@ func collectMounts(
}

if homeDir, err := os.UserHomeDir(); err == nil {
agentMounts := agentconfig.RWCopyMounts(agentconfig.AllMounts(homeDir))
var specs []agentconfig.MountSpec
if noClaudeConfig {
specs = agentconfig.AllMountsWithoutClaudeConfig(homeDir)
} else {
specs = agentconfig.AllMounts(homeDir)
}
agentMounts := agentconfig.RWCopyMounts(specs)
mounts = append(mounts, agentMounts...)
}

Expand Down
62 changes: 60 additions & 2 deletions internal/agentconfig/agentconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ package agentconfig
import (
"os"
"path/filepath"
"strings"

"github.com/gofixpoint/amika/internal/auth"
"github.com/gofixpoint/amika/internal/sandbox"
Expand All @@ -13,6 +14,9 @@ import (
// containerHome is the home directory inside preset container images.
const containerHome = "/home/amika"

// claudeConfigDir is the relative path of the Claude configuration directory.
const claudeConfigDir = ".claude"

// MountSpec describes a host path to mount into a container.
type MountSpec struct {
HostPath string // absolute path on host
Expand Down Expand Up @@ -41,11 +45,36 @@ func CodexMounts(homeDir string) []MountSpec {
return fileMounts(homeDir, auth.CodexCredentialPaths())
}

// ClaudeConfigDirMount returns a mount spec for the ~/.claude/ directory if it
// exists under homeDir. Returns nil when the directory is absent.
func ClaudeConfigDirMount(homeDir string) *MountSpec {
return dirMount(homeDir, claudeConfigDir)
}

// AllMounts returns mount specs for all supported coding agent configurations
// that exist under homeDir.
// that exist under homeDir. When the ~/.claude/ directory exists it is mounted
// as a whole and individual file mounts inside it are omitted to avoid overlaps.
func AllMounts(homeDir string) []MountSpec {
var specs []MountSpec
specs = append(specs, ClaudeMounts(homeDir)...)
claudeDir := ClaudeConfigDirMount(homeDir)
if claudeDir != nil {
specs = append(specs, *claudeDir)
// Individual credential files inside .claude/ are already covered by
// the directory mount — only keep top-level file mounts.
specs = append(specs, filterOutSubpaths(ClaudeMounts(homeDir), claudeConfigDir+"/")...)
} else {
specs = append(specs, ClaudeMounts(homeDir)...)
}
specs = append(specs, OpenCodeMounts(homeDir)...)
specs = append(specs, CodexMounts(homeDir)...)
return specs
}

// AllMountsWithoutClaudeConfig returns mount specs for all supported coding
// agent configurations except the Claude config directory and Claude credential
// files. Use this when the --no-claude-config flag is set.
func AllMountsWithoutClaudeConfig(homeDir string) []MountSpec {
var specs []MountSpec
specs = append(specs, OpenCodeMounts(homeDir)...)
specs = append(specs, CodexMounts(homeDir)...)
return specs
Expand All @@ -65,6 +94,35 @@ func RWCopyMounts(specs []MountSpec) []sandbox.MountBinding {
return mounts
}

// dirMount returns a MountSpec for relDir if it exists as a directory under
// homeDir. Returns nil when the path is absent or is a regular file.
func dirMount(homeDir, relDir string) *MountSpec {
full := filepath.Join(homeDir, relDir)
info, err := os.Stat(full)
if err != nil || !info.IsDir() {
return nil
}
return &MountSpec{
HostPath: full,
ContainerPath: containerHome + "/" + relDir,
IsDir: true,
}
}

// filterOutSubpaths returns specs whose container paths do not fall inside
// containerHome + "/" + prefix. This prevents double-mounting files that are
// already covered by a parent directory mount.
func filterOutSubpaths(specs []MountSpec, prefix string) []MountSpec {
full := containerHome + "/" + prefix
var out []MountSpec
for _, s := range specs {
if !strings.HasPrefix(s.ContainerPath, full) {
out = append(out, s)
}
}
return out
}

// fileMounts stats each relative path under homeDir and returns a MountSpec
// for every path that exists as a regular file.
func fileMounts(homeDir string, relPaths []string) []MountSpec {
Expand Down
142 changes: 142 additions & 0 deletions internal/agentconfig/agentconfig_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,148 @@ func TestAllMounts_Combined(t *testing.T) {
}
}

func TestClaudeConfigDirMount_Exists(t *testing.T) {
home := t.TempDir()
if err := os.Mkdir(filepath.Join(home, ".claude"), 0755); err != nil {
t.Fatal(err)
}

spec := ClaudeConfigDirMount(home)
if spec == nil {
t.Fatal("expected non-nil MountSpec")
}
if spec.ContainerPath != "/home/amika/.claude" {
t.Errorf("ContainerPath = %q, want /home/amika/.claude", spec.ContainerPath)
}
if spec.HostPath != filepath.Join(home, ".claude") {
t.Errorf("HostPath = %q, want %q", spec.HostPath, filepath.Join(home, ".claude"))
}
if !spec.IsDir {
t.Error("IsDir = false, want true")
}
}

func TestClaudeConfigDirMount_NotExists(t *testing.T) {
home := t.TempDir()
spec := ClaudeConfigDirMount(home)
if spec != nil {
t.Fatalf("expected nil, got %+v", spec)
}
}

func TestClaudeConfigDirMount_FileNotDir(t *testing.T) {
home := t.TempDir()
// Create .claude as a regular file — should return nil.
if err := os.WriteFile(filepath.Join(home, ".claude"), []byte("{}"), 0644); err != nil {
t.Fatal(err)
}

spec := ClaudeConfigDirMount(home)
if spec != nil {
t.Fatalf("expected nil when .claude is a file, got %+v", spec)
}
}

func TestAllMounts_WithClaudeDir(t *testing.T) {
home := t.TempDir()

// Create .claude/ directory AND a credential file inside it.
writeFixtureFile(t, home, filepath.Join(".claude", ".credentials.json"))
// Also create a top-level Claude credential file.
writeFixtureFile(t, home, ".claude.json")

specs := AllMounts(home)

// Expect: .claude dir mount + .claude.json file mount (but NOT .claude/.credentials.json).
wantContainers := map[string]bool{
"/home/amika/.claude": true, // directory mount
"/home/amika/.claude.json": true, // top-level file, not inside .claude/
}
for _, s := range specs {
if !wantContainers[s.ContainerPath] {
t.Errorf("unexpected ContainerPath %q", s.ContainerPath)
}
delete(wantContainers, s.ContainerPath)
}
for path := range wantContainers {
t.Errorf("missing expected ContainerPath %q", path)
}

// Verify the directory mount has IsDir=true.
for _, s := range specs {
if s.ContainerPath == "/home/amika/.claude" && !s.IsDir {
t.Error(".claude mount should have IsDir=true")
}
}
}

func TestAllMounts_WithoutClaudeDir(t *testing.T) {
home := t.TempDir()

// Only create individual credential files, no .claude/ directory.
writeFixtureFile(t, home, ".claude.json")
writeFixtureFile(t, home, ".claude-oauth-credentials.json")

specs := AllMounts(home)
if len(specs) != 2 {
t.Fatalf("expected 2 specs, got %d", len(specs))
}

wantContainers := map[string]bool{
"/home/amika/.claude.json": true,
"/home/amika/.claude-oauth-credentials.json": true,
}
for _, s := range specs {
if !wantContainers[s.ContainerPath] {
t.Errorf("unexpected ContainerPath %q", s.ContainerPath)
}
if s.IsDir {
t.Errorf("IsDir = true for %q, want false", s.ContainerPath)
}
delete(wantContainers, s.ContainerPath)
}
}

func TestFilterOutSubpaths(t *testing.T) {
specs := []MountSpec{
{ContainerPath: "/home/amika/.claude.json"},
{ContainerPath: "/home/amika/.claude/.credentials.json"},
{ContainerPath: "/home/amika/.claude-oauth-credentials.json"},
}

filtered := filterOutSubpaths(specs, ".claude/")
if len(filtered) != 2 {
t.Fatalf("expected 2 specs after filtering, got %d", len(filtered))
}
for _, s := range filtered {
if s.ContainerPath == "/home/amika/.claude/.credentials.json" {
t.Error("should have filtered out .claude/.credentials.json")
}
}
}

func TestAllMountsWithoutClaudeConfig(t *testing.T) {
home := t.TempDir()

writeFixtureFile(t, home, ".claude.json")
writeFixtureFile(t, home, filepath.Join(".claude", ".credentials.json"))
writeFixtureFile(t, home, filepath.Join(".local", "share", "opencode", "auth.json"))
writeFixtureFile(t, home, filepath.Join(".codex", "auth.json"))

specs := AllMountsWithoutClaudeConfig(home)
if len(specs) != 2 {
t.Fatalf("expected 2 specs, got %d", len(specs))
}

for _, s := range specs {
if s.ContainerPath == "/home/amika/.claude.json" ||
s.ContainerPath == "/home/amika/.claude/.credentials.json" ||
s.ContainerPath == "/home/amika/.claude" {
t.Errorf("should not include Claude path %q", s.ContainerPath)
}
}
}

func TestRWCopyMounts(t *testing.T) {
specs := []MountSpec{
{HostPath: "/home/user/.claude.json", ContainerPath: "/home/amika/.claude.json", IsDir: false},
Expand Down