diff --git a/docs/persistence.md b/docs/persistence.md index 4c78275..a4976c3 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -50,6 +50,13 @@ Extracted runtime asset entries are reproducible cache data. Deleting them is safe, and Enclave extracts the current entry again on the next run. Enclave does not garbage collect entries for older binaries yet. +Everything under the cache root is disposable: deleting it only costs +performance. Package caches are recreated empty on the next session start, as +ordinary host bind mounts that the container backend may create when the +source is missing from its view (relevant on Docker Desktop for macOS, whose +VM can briefly report a freshly recreated cache directory as nonexistent). No +required runtime file is bind-mounted from the cache tree. + The paths above use the Linux (XDG) layout. On macOS the same data lives under the standard Apple locations, in a reverse-DNS application directory: config and state under `~/Library/Application Support/org.eclipse.enclave/` diff --git a/docs/runtime/stores.md b/docs/runtime/stores.md index 159817f..5f23803 100644 --- a/docs/runtime/stores.md +++ b/docs/runtime/stores.md @@ -156,10 +156,13 @@ Environment variables consumed by the entrypoint: After the container exits, the host-side Go code reconciles auth files from the config store to the shared auth store. The reconcile runs `auth-reconcile.sh` inside a short-lived helper container (sharing semantics with the entrypoint), -with the config and auth store **directories bind-mounted**. Most tools remain -**additive only**: files are copied when the shared auth destination is missing. -Claude `.credentials.json` uses `claudeAiOauth.expiresAt` so a token refresh -stranded as a real config-store file can replace stale shared auth. +with the config and auth store **directories bind-mounted**. The script itself +is read and retained when the backend is initialized, then inlined into the +helper command rather than bind-mounted. Reconciliation therefore remains +independent of the extracted asset cache for the lifetime of the process. Most +tools remain **additive only**: files are copied when the shared auth destination +is missing. Claude `.credentials.json` uses `claudeAiOauth.expiresAt` so a token +refresh stranded as a real config-store file can replace stale shared auth. This makes new credentials (e.g. a fresh OAuth token obtained during the session) available to other projects on the next run. Background and GUI sessions diff --git a/extensions/features/node-dev/feature-entrypoint.d/setup.sh b/extensions/features/node-dev/feature-entrypoint.d/setup.sh index 849f5b4..1990e42 100644 --- a/extensions/features/node-dev/feature-entrypoint.d/setup.sh +++ b/extensions/features/node-dev/feature-entrypoint.d/setup.sh @@ -113,16 +113,23 @@ fi # Seed cache mount with build-time default version. # Copies missing versions from the image snapshot into the persistent cache # so that image upgrades make the new default available immediately. +# Best-effort: the versions cache is a disposable mount that may be +# unwritable (e.g. recreated root-owned by the daemon); a session must still +# start on the image snapshot, only without persisting Node installs. if [ -d "$HOME/.nvm/versions-default/node" ]; then - mkdir -p "$HOME/.nvm/versions/node" - for _enclave_ver in "$HOME/.nvm/versions-default/node"/*/; do - [ -d "$_enclave_ver" ] || continue - _enclave_base="$(basename "$_enclave_ver")" - if [ ! -d "$HOME/.nvm/versions/node/$_enclave_base" ]; then - cp -a "$_enclave_ver" "$HOME/.nvm/versions/node/$_enclave_base" - fi - done - unset _enclave_ver _enclave_base + if mkdir -p "$HOME/.nvm/versions/node" 2>/dev/null; then + for _enclave_ver in "$HOME/.nvm/versions-default/node"/*/; do + [ -d "$_enclave_ver" ] || continue + _enclave_base="$(basename "$_enclave_ver")" + if [ ! -d "$HOME/.nvm/versions/node/$_enclave_base" ]; then + cp -a "$_enclave_ver" "$HOME/.nvm/versions/node/$_enclave_base" 2>/dev/null || \ + echo "Warning: failed to seed Node.js $_enclave_base into the nvm version cache" + fi + done + unset _enclave_ver _enclave_base + else + echo "Warning: nvm version cache at $HOME/.nvm/versions is not writable; Node.js installs will not persist" + fi fi _enclave_node_target="" diff --git a/internal/backend/docker/authsync.go b/internal/backend/docker/authsync.go index a4ad946..2619cca 100644 --- a/internal/backend/docker/authsync.go +++ b/internal/backend/docker/authsync.go @@ -146,12 +146,11 @@ func (b *Backend) syncSharedAuthStores(ctx context.Context, helperImage string, if len(validated) == 0 { return nil } - scriptPath := strings.TrimSpace(b.opts.ReconcileScriptPath) - if scriptPath == "" { - return fmt.Errorf("auth reconcile script path is empty") + if b.reconcileScriptErr != nil { + return b.reconcileScriptErr } - if _, err := os.Stat(scriptPath); err != nil { - return fmt.Errorf("auth reconcile script %s: %w", scriptPath, err) + if strings.TrimSpace(b.reconcileScript) == "" { + return fmt.Errorf("auth reconcile script is empty") } image := model.AlpineImage @@ -171,8 +170,8 @@ func (b *Backend) syncSharedAuthStores(ctx context.Context, helperImage string, return err } - cmd := sharedAuthSyncCommand("/auth-reconcile.sh", tool, validated, b.chownSpec(), "/config", "/auth") - hostConfig := sharedAuthSyncHostConfig(configDir, authDir, scriptPath, util.IsSELinuxEnforcing()) + cmd := sharedAuthSyncCommand(b.reconcileScript, tool, validated, b.chownSpec(), "/config", "/auth") + hostConfig := sharedAuthSyncHostConfig(configDir, authDir, util.IsSELinuxEnforcing()) return hoststore.WithLock(b.opts.Host.Home, authDir, func() error { return dockercmd.Run(ctx, &dockercmd.ContainerConfig{ @@ -185,12 +184,12 @@ func (b *Backend) syncSharedAuthStores(ctx context.Context, helperImage string, } // sharedAuthSyncHostConfig builds the reconcile helper's host config: the -// config store read-only, the shared auth store writable, and the reconcile -// script itself. Bind mounts are relabeled the same way session-container -// mounts are when SELinux is enforcing; without the relabel the helper cannot -// even source the script ("cannot open /auth-reconcile.sh: Permission -// denied"). -func sharedAuthSyncHostConfig(configDir string, authDir string, scriptPath string, selinuxEnforcing bool) *dockercmd.HostConfig { +// config store read-only and the shared auth store writable. The reconcile +// script is deliberately not mounted; its content travels in the helper +// command. Bind mounts are relabeled the same way session-container mounts are +// when SELinux is enforcing; without the relabel the helper cannot read the +// stores ("Permission denied"). +func sharedAuthSyncHostConfig(configDir string, authDir string, selinuxEnforcing bool) *dockercmd.HostConfig { hostConfig := &dockercmd.HostConfig{ AutoRemove: true, Mounts: []dockercmd.Mount{ @@ -205,23 +204,20 @@ func sharedAuthSyncHostConfig(configDir string, authDir string, scriptPath strin Source: authDir, Target: "/auth", }, - { - Type: dockercmd.MountTypeBind, - Source: scriptPath, - Target: "/auth-reconcile.sh", - ReadOnly: true, - }, }, } applySELinuxMounts(hostConfig, selinuxEnforcing) return hostConfig } -func sharedAuthSyncCommand(scriptPath string, tool string, authFiles []string, chown string, configRoot string, authRoot string) string { +// sharedAuthSyncCommand assembles the helper container's shell command: the +// inlined reconcile script (a POSIX function library) followed by the +// enclave_sync_shared_auth invocation. +func sharedAuthSyncCommand(script string, tool string, authFiles []string, chown string, configRoot string, authRoot string) string { var cmd strings.Builder cmd.WriteString("set -e\n") - fmt.Fprintf(&cmd, ". %s\n", util.ShellQuote(scriptPath)) - cmd.WriteString("enclave_sync_shared_auth") + cmd.WriteString(script) + cmd.WriteString("\nenclave_sync_shared_auth") for _, arg := range append([]string{tool, configRoot, authRoot, chown, "0"}, authFiles...) { fmt.Fprintf(&cmd, " %s", util.ShellQuote(arg)) } diff --git a/internal/backend/docker/authsync_test.go b/internal/backend/docker/authsync_test.go index 99aa309..71fc71e 100644 --- a/internal/backend/docker/authsync_test.go +++ b/internal/backend/docker/authsync_test.go @@ -13,6 +13,7 @@ import ( "os/exec" "path/filepath" "strconv" + "strings" "testing" "enclave/internal/backend" @@ -23,6 +24,39 @@ import ( const claudeCredentialsFile = ".credentials.json" +func TestNewSnapshotsAuthReconcileScript(t *testing.T) { + root := t.TempDir() + home := filepath.Join(root, "home") + if err := os.MkdirAll(home, 0o700); err != nil { + t.Fatalf("create home: %v", err) + } + scriptPath := filepath.Join(root, "assets", "runtime-assets", "auth-reconcile.sh") + if err := os.MkdirAll(filepath.Dir(scriptPath), 0o700); err != nil { + t.Fatalf("create asset directory: %v", err) + } + const script = "enclave_sync_shared_auth() { :; }\n" + if err := os.WriteFile(scriptPath, []byte(script), 0o600); err != nil { + t.Fatalf("write reconcile script: %v", err) + } + + b := New(Options{Host: model.Host{Home: home}, ReconcileScriptPath: scriptPath}) + if err := os.RemoveAll(filepath.Join(root, "assets")); err != nil { + t.Fatalf("remove asset cache: %v", err) + } + if b.reconcileScriptErr != nil { + t.Fatalf("snapshot reconcile script: %v", b.reconcileScriptErr) + } + if b.reconcileScript != script { + t.Fatalf("reconcile script = %q, want %q", b.reconcileScript, script) + } + + installFakeDocker(t) + configDir, authDir := authSyncTempDirs(t) + if err := b.syncSharedAuthStores(context.Background(), "enclave-test:latest", "codex", []string{"auth.json"}, configDir, authDir); err != nil { + t.Fatalf("sync after deleting asset cache: %v", err) + } +} + func TestMountedSourceDirResolvesBindMountSource(t *testing.T) { info := dockercmd.InspectResponse{ Mounts: []dockercmd.MountPoint{ @@ -314,7 +348,11 @@ func runSharedAuthSyncCommand(t *testing.T, tool string, authFiles []string, con func runSharedAuthSyncCommandOutput(t *testing.T, tool string, authFiles []string, configDir string, authDir string) string { t.Helper() scriptPath := filepath.Join("..", "..", "..", "runtime-assets", "auth-reconcile.sh") - cmdText := sharedAuthSyncCommand(scriptPath, tool, authFiles, "", configDir, authDir) + script, err := os.ReadFile(scriptPath) + if err != nil { + t.Fatalf("read reconcile script: %v", err) + } + cmdText := sharedAuthSyncCommand(string(script), tool, authFiles, "", configDir, authDir) cmd := exec.Command("sh", "-c", cmdText) cmd.Env = append(os.Environ(), "PATH="+fakeJQDir(t)+string(os.PathListSeparator)+os.Getenv("PATH")) out, err := cmd.CombinedOutput() @@ -382,7 +420,7 @@ func assertFileContent(t *testing.T, path string, want string) { } func TestSharedAuthSyncHostConfigRelabelsForSELinux(t *testing.T) { - hostConfig := sharedAuthSyncHostConfig("/host/config", "/host/auth", "/host/reconcile.sh", true) + hostConfig := sharedAuthSyncHostConfig("/host/config", "/host/auth", true) if len(hostConfig.Mounts) != 0 { t.Fatalf("Mounts = %v, want none (binds should carry the relabel flag)", hostConfig.Mounts) @@ -390,7 +428,6 @@ func TestSharedAuthSyncHostConfigRelabelsForSELinux(t *testing.T) { want := []string{ "/host/config:/config:ro,z", "/host/auth:/auth:z", - "/host/reconcile.sh:/auth-reconcile.sh:ro,z", } if len(hostConfig.Binds) != len(want) { t.Fatalf("Binds = %v, want %v", hostConfig.Binds, want) @@ -406,17 +443,34 @@ func TestSharedAuthSyncHostConfigRelabelsForSELinux(t *testing.T) { } func TestSharedAuthSyncHostConfigWithoutSELinux(t *testing.T) { - hostConfig := sharedAuthSyncHostConfig("/host/config", "/host/auth", "/host/reconcile.sh", false) + hostConfig := sharedAuthSyncHostConfig("/host/config", "/host/auth", false) if len(hostConfig.Binds) != 0 { t.Fatalf("Binds = %v, want none without SELinux", hostConfig.Binds) } - if len(hostConfig.Mounts) != 3 { - t.Fatalf("Mounts = %v, want 3 bind mounts", hostConfig.Mounts) + if len(hostConfig.Mounts) != 2 { + t.Fatalf("Mounts = %v, want the config and auth store binds only", hostConfig.Mounts) + } + // Regression: the reconcile helper must not bind-mount the script from the + // extracted asset cache; a deleted cache root would break reconciliation. + for _, m := range hostConfig.Mounts { + if m.Target == "/auth-reconcile.sh" { + t.Errorf("unexpected reconcile script mount: %+v", m) + } + } +} + +func TestSharedAuthSyncCommandInlinesScript(t *testing.T) { + script := "enclave_sync_shared_auth() { :; }\n" + cmdText := sharedAuthSyncCommand(script, "claude", []string{".credentials.json"}, "1000:1000", "/config", "/auth") + + if !strings.Contains(cmdText, script) { + t.Errorf("command does not inline the script content:\n%s", cmdText) + } + if strings.Contains(cmdText, "/auth-reconcile.sh") { + t.Errorf("command still references the script mount path:\n%s", cmdText) } - script := hostConfig.Mounts[2] - if script.Type != dockercmd.MountTypeBind || script.Source != "/host/reconcile.sh" || - script.Target != "/auth-reconcile.sh" || !script.ReadOnly { - t.Errorf("script mount = %+v, want read-only bind /host/reconcile.sh -> /auth-reconcile.sh", script) + if !strings.Contains(cmdText, "enclave_sync_shared_auth 'claude' '/config' '/auth' '1000:1000' '0' '.credentials.json'") { + t.Errorf("command does not invoke the sync helper with expected args:\n%s", cmdText) } } diff --git a/internal/backend/docker/devcontainer.go b/internal/backend/docker/devcontainer.go index dcc3e78..59d34e8 100644 --- a/internal/backend/docker/devcontainer.go +++ b/internal/backend/docker/devcontainer.go @@ -234,7 +234,7 @@ func parseVolumeSpec(spec string, projectDir string, home string) (backend.Mount // dockerMount converts a neutral backend.Mount into the Docker CLI mount type. func dockerMount(m backend.Mount) dockercmd.Mount { - return dockercmd.Mount{Type: dockercmd.MountType(m.Type), Source: m.Source, Target: m.ContainerPath, ReadOnly: m.ReadOnly} + return dockercmd.Mount{Type: dockercmd.MountType(m.Type), Source: m.Source, Target: m.ContainerPath, ReadOnly: m.ReadOnly, CreateSourceDir: m.CreateSourceDir} } func splitTmpfs(value string) (string, string) { diff --git a/internal/backend/docker/docker.go b/internal/backend/docker/docker.go index ce97afb..7cca46d 100644 --- a/internal/backend/docker/docker.go +++ b/internal/backend/docker/docker.go @@ -39,13 +39,26 @@ type Options struct { } type Backend struct { - opts Options - storage *StoreManager + opts Options + storage *StoreManager + reconcileScript string + reconcileScriptErr error } func New(opts Options) *Backend { b := &Backend{opts: opts} b.storage = &StoreManager{host: opts.Host} + scriptPath := strings.TrimSpace(opts.ReconcileScriptPath) + if scriptPath == "" { + b.reconcileScriptErr = fmt.Errorf("auth reconcile script path is empty") + } else { + script, err := os.ReadFile(scriptPath) // #nosec G304 -- path is resolved from the trusted application root. + if err != nil { + b.reconcileScriptErr = fmt.Errorf("read auth reconcile script %s: %w", scriptPath, err) + } else { + b.reconcileScript = string(script) + } + } return b } diff --git a/internal/backend/qemu/bundle.go b/internal/backend/qemu/bundle.go index 2598d85..86562f4 100644 --- a/internal/backend/qemu/bundle.go +++ b/internal/backend/qemu/bundle.go @@ -210,6 +210,11 @@ func (b *Backend) buildRuntimeMounts(req backend.Request, controlDir string, fil if mount.Type != "" && mount.Type != backend.MountTypeBind { return nil, fmt.Errorf("qemu backend: unsupported mount type %q", mount.Type) } + if mount.CreateSourceDir { + if err := os.MkdirAll(mount.Source, 0o700); err != nil { + return nil, fmt.Errorf("qemu backend: create disposable mount source %q: %w", mount.Source, err) + } + } info, err := os.Stat(mount.Source) if err != nil { return nil, fmt.Errorf("qemu backend: inspect mount source %q: %w", mount.Source, err) diff --git a/internal/backend/types.go b/internal/backend/types.go index 90e233b..bab69cf 100644 --- a/internal/backend/types.go +++ b/internal/backend/types.go @@ -85,6 +85,12 @@ type Mount struct { Source string ContainerPath string ReadOnly bool + // CreateSourceDir marks a disposable directory bind mount (package caches): + // cache data whose loss must never prevent a session from starting. Backends + // create a missing source as an empty directory instead of failing. Required + // project, config, auth, and file mounts must not set it, so their missing + // sources stay hard errors. + CreateSourceDir bool } type StoreKind string diff --git a/internal/docker/cli.go b/internal/docker/cli.go index 5963a59..36ae15c 100644 --- a/internal/docker/cli.go +++ b/internal/docker/cli.go @@ -229,9 +229,26 @@ func buildRunArgs(config *ContainerConfig, hostConfig *HostConfig, name string, } // mountFlags renders Mounts as `--mount type=...,source=...,target=...` args. +// +// Bind mounts marked CreateSourceDir are rendered as `--volume src:dst[:ro]` +// instead: an absolute host path in `--volume` is still a bind mount, but it is +// the only Docker syntax that creates a missing source directory in the +// daemon's filesystem view rather than rejecting the mount. Strict `--mount` +// fails on Docker Desktop for macOS when the daemon VM's view of a freshly +// recreated host directory lags behind the host ("bind source path does not +// exist" under /host_mnt), so disposable cache directories must not depend on +// daemon-side existence. func mountFlags(mounts []Mount) []string { out := make([]string, 0, len(mounts)*2) for _, m := range mounts { + if m.Type == MountTypeBind && m.CreateSourceDir { + spec := m.Source + ":" + m.Target + if m.ReadOnly { + spec += ":ro" + } + out = append(out, "--volume", spec) + continue + } var spec strings.Builder fmt.Fprintf(&spec, "type=%s", m.Type) if m.Source != "" { diff --git a/internal/docker/cli_test.go b/internal/docker/cli_test.go index 3e479f8..b67c9fb 100644 --- a/internal/docker/cli_test.go +++ b/internal/docker/cli_test.go @@ -96,3 +96,26 @@ func TestBuildRunArgsDetachedInteractive(t *testing.T) { t.Fatalf("buildRunArgs() = %v, want %v", got, wantPrefix) } } + +// Regression for macOS cache deletion: disposable directory binds must use the +// source-creating `--volume` form, while every other mount keeps the strict +// `--mount` form so a missing required source stays an error. +func TestMountFlagsSourceCreatingBinds(t *testing.T) { + got := mountFlags([]Mount{ + {Type: MountTypeBind, Source: "/host/project", Target: "/work"}, + {Type: MountTypeBind, Source: "/host/secret.json", Target: "/auth.json", ReadOnly: true}, + {Type: MountTypeBind, Source: "/host/cache/npm", Target: "/home/agent/.npm", CreateSourceDir: true}, + {Type: MountTypeBind, Source: "/host/cache/inbox", Target: "/mnt/host-images", ReadOnly: true, CreateSourceDir: true}, + {Type: MountTypeVolume, Source: "vol", Target: "/data", CreateSourceDir: true}, + }) + want := []string{ + "--mount", "type=bind,source=/host/project,target=/work", + "--mount", "type=bind,source=/host/secret.json,target=/auth.json,readonly", + "--volume", "/host/cache/npm:/home/agent/.npm", + "--volume", "/host/cache/inbox:/mnt/host-images:ro", + "--mount", "type=volume,source=vol,target=/data", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("mountFlags() = %v, want %v", got, want) + } +} diff --git a/internal/docker/types.go b/internal/docker/types.go index 6e7f698..e3e5fe2 100644 --- a/internal/docker/types.go +++ b/internal/docker/types.go @@ -33,6 +33,9 @@ type Mount struct { Source string Target string ReadOnly bool + // CreateSourceDir requests the source-creating `--volume` rendering for a + // disposable directory bind mount; see mountFlags. + CreateSourceDir bool } // ContainerConfig is the subset of container configuration we translate to diff --git a/internal/gateway/gateway.go b/internal/gateway/gateway.go index 9b61389..4e3eb6f 100644 --- a/internal/gateway/gateway.go +++ b/internal/gateway/gateway.go @@ -10,7 +10,6 @@ package gateway import ( "context" "fmt" - "io" "io/fs" "os" "path/filepath" @@ -187,11 +186,14 @@ func buildGatewayImage(paths model.Paths, profile model.Profile, allowlistPath s model.GatewayLabelAgent: profile.Name, }, } - if err := docker.Build(context.Background(), req, io.Discard); err != nil { + // Build output goes to the terminal like the runtime image build, so a + // gateway build failure is attributable instead of surfacing later as an + // unrelated container-start error. + if err := docker.Build(context.Background(), req, os.Stdout); err != nil { // Some Docker BuildKit setups fail DNS resolution in the default build // network for Alpine index fetches. Retry once with host build network. req.NetworkMode = "host" - if retryErr := docker.Build(context.Background(), req, io.Discard); retryErr != nil { + if retryErr := docker.Build(context.Background(), req, os.Stdout); retryErr != nil { return fmt.Errorf("failed to build gateway image: %w (retry with host build network failed: %v)", err, retryErr) } logx.Warnf("Gateway build failed on default build network; retry with host build network succeeded") diff --git a/internal/runtime/cache_mount_test.go b/internal/runtime/cache_mount_test.go new file mode 100644 index 0000000..68147e0 --- /dev/null +++ b/internal/runtime/cache_mount_test.go @@ -0,0 +1,135 @@ +// Copyright (C) 2026 EclipseSource GmbH and others. +// +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// +// SPDX-License-Identifier: MIT + +package runtime + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "enclave/internal/config" + "enclave/internal/model" +) + +func newCacheMountRuntime(home string) *Runtime { + return &Runtime{ + host: model.Host{Home: home}, + project: model.Project{Hash: "projhash"}, + profile: model.Profile{Name: "claude"}, + containerHome: "/home/agent", + } +} + +// Regression for macOS cache deletion: every package-cache source must be +// created as a directory before use and mounted as a disposable, +// source-creatable directory, so a deleted cache root costs performance only. +func TestAddCacheMountsCreatesDisposableSources(t *testing.T) { + t.Parallel() + + home := t.TempDir() + r := newCacheMountRuntime(home) + acc := newMountAccumulator(nil, nil) + if err := r.addCacheMounts(acc); err != nil { + t.Fatalf("addCacheMounts() error: %v", err) + } + + wantCount := len(packageCacheDirs(r.containerHome)) + if len(acc.Mounts()) != wantCount { + t.Fatalf("mounts = %d, want %d", len(acc.Mounts()), wantCount) + } + cacheRoot := config.HostCacheToolProjectDir(home, "claude", "projhash") + for _, m := range acc.Mounts() { + if !m.CreateSourceDir { + t.Errorf("cache mount %s -> %s is not marked CreateSourceDir", m.Source, m.ContainerPath) + } + if m.ReadOnly { + t.Errorf("cache mount %s must be writable", m.ContainerPath) + } + if filepath.Dir(m.Source) != cacheRoot { + t.Errorf("cache mount source %s is outside the project cache root %s", m.Source, cacheRoot) + } + info, err := os.Stat(m.Source) + if err != nil { + t.Errorf("cache source %s was not created: %v", m.Source, err) + } else if !info.IsDir() { + t.Errorf("cache source %s is not a directory", m.Source) + } + } + if source, ok := lookupMountSource(acc.Mounts(), "/home/agent/.npm"); !ok || source != filepath.Join(cacheRoot, "npm") { + t.Errorf("npm cache mount source = %q, ok = %v", source, ok) + } +} + +func TestAddCacheMountsRespectsNoCache(t *testing.T) { + t.Parallel() + + r := newCacheMountRuntime(t.TempDir()) + r.run = model.RunOptions{NoCache: true} + acc := newMountAccumulator(nil, nil) + if err := r.addCacheMounts(acc); err != nil { + t.Fatalf("addCacheMounts() error: %v", err) + } + if len(acc.Mounts()) != 0 { + t.Fatalf("expected no mounts with NoCache, got %d", len(acc.Mounts())) + } +} + +// Host-side creation failures must propagate instead of deferring to a +// container-start error the user cannot attribute. +func TestAddCacheMountsPropagatesCreationFailure(t *testing.T) { + t.Parallel() + + home := t.TempDir() + r := newCacheMountRuntime(home) + cacheRoot := config.HostCacheToolProjectDir(home, "claude", "projhash") + if err := os.MkdirAll(filepath.Dir(cacheRoot), 0o700); err != nil { + t.Fatalf("mkdir cache parent: %v", err) + } + // A regular file where the cache tree belongs makes every MkdirAll fail. + if err := os.WriteFile(cacheRoot, []byte(""), 0o600); err != nil { + t.Fatalf("write blocker file: %v", err) + } + + acc := newMountAccumulator(nil, nil) + err := r.addCacheMounts(acc) + if err == nil { + t.Fatal("addCacheMounts() succeeded despite uncreatable cache directory") + } + if !strings.Contains(err.Error(), "package cache") { + t.Errorf("error %q does not mention the package cache", err) + } +} + +// Required mounts must stay strict: only disposable cache directories may +// carry the source-creating marker. +func TestOnlyDisposableMountsCreateSources(t *testing.T) { + t.Parallel() + + home := t.TempDir() + r := newCacheMountRuntime(home) + r.profile.MemoryDir = ".claude/memory" + r.run.ImageInbox = true + + acc := newMountAccumulator(nil, nil) + r.addMemoryMounts(acc) + r.addHistoryMounts(acc) + r.addImageInboxMount(acc) + if err := r.addCacheMounts(acc); err != nil { + t.Fatalf("addCacheMounts() error: %v", err) + } + + cacheRoot := config.HostCacheDir(home) + for _, m := range acc.Mounts() { + underCacheRoot := strings.HasPrefix(m.Source, cacheRoot+string(filepath.Separator)) + if m.CreateSourceDir != underCacheRoot { + t.Errorf("mount %s -> %s: CreateSourceDir = %v, want %v (disposable == under cache root)", + m.Source, m.ContainerPath, m.CreateSourceDir, underCacheRoot) + } + } +} diff --git a/internal/runtime/docker_helpers.go b/internal/runtime/docker_helpers.go index e631672..407f913 100644 --- a/internal/runtime/docker_helpers.go +++ b/internal/runtime/docker_helpers.go @@ -22,6 +22,15 @@ func bindMount(source string, target string, readOnly bool) backend.Mount { } } +// disposableDirMount is a bind mount of a disposable cache directory: deleting +// the source must only cost performance, never a session start, so the backend +// may recreate a missing source as an empty directory (backend.Mount.CreateSourceDir). +func disposableDirMount(source string, target string, readOnly bool) backend.Mount { + mount := bindMount(source, target, readOnly) + mount.CreateSourceDir = true + return mount +} + func lookupEnv(entries []string, key string) (string, bool) { if key == "" { return "", false diff --git a/internal/runtime/runtime.go b/internal/runtime/runtime.go index d385efb..41fd651 100644 --- a/internal/runtime/runtime.go +++ b/internal/runtime/runtime.go @@ -300,7 +300,9 @@ func (r *Runtime) prepareMounts() (*mountAccumulator, error) { r.addSSHMount(mountArgs) r.addImageInboxMount(mountArgs) r.addSessionMonitorEnv(mountArgs) - r.addCacheMounts(mountArgs) + if err := r.addCacheMounts(mountArgs); err != nil { + return nil, err + } r.addHistoryMounts(mountArgs) r.addMemoryMounts(mountArgs) r.addToolConfigMounts(mountArgs) @@ -1063,7 +1065,9 @@ func (r *Runtime) addImageInboxMount(mounts *mountAccumulator) { logx.Warnf("Failed to create image inbox directory %s: %v", inboxDir, err) return } - mounts.AddMount(bindMount(inboxDir, model.ContainerImageInboxDir, true)) + // The inbox lives under the disposable cache root; an empty recreated inbox + // must not block a session start. + mounts.AddMount(disposableDirMount(inboxDir, model.ContainerImageInboxDir, true)) mounts.AddEnv(model.EnvImageInbox, model.ContainerImageInboxDir) logx.Infof("Host image inbox mounted read-only at %s", model.ContainerImageInboxDir) } @@ -1095,39 +1099,47 @@ func (r *Runtime) addSessionMonitorEnv(mounts *mountAccumulator) { mounts.AddEnv(model.EnvSessionMonitorUser, r.containerUser) } -func (r *Runtime) addCacheMounts(mounts *mountAccumulator) { +// packageCacheDirs maps each package-cache directory under the per-project +// cache root to its in-container mount point (relative to the container home). +func packageCacheDirs(containerHome string) [][2]string { + return [][2]string{ + {"npm", containerHome + "/.npm"}, + {"pip", containerHome + "/.cache/pip"}, + // Go caches + {"go", containerHome + "/go/pkg/mod"}, + {"go-build", containerHome + "/.cache/go-build"}, + // Rust/Cargo cache + {"cargo", containerHome + "/.cargo"}, + // pnpm store + {"pnpm", containerHome + "/.local/share/pnpm"}, + // uv (Python) cache + {"uv", containerHome + "/.cache/uv"}, + // Yarn cache + {"yarn", containerHome + "/.cache/yarn"}, + // Bun cache + {"bun", containerHome + "/.bun"}, + // nvm installed Node.js versions + {"nvm", containerHome + "/.nvm/versions"}, + } +} + +// addCacheMounts mounts the per-project package caches. The sources live under +// the platform cache root, so they may vanish at any time; each is created +// host-side before use and mounted as a disposable directory the backend may +// recreate, keeping cache deletion a performance cost rather than a failure. +func (r *Runtime) addCacheMounts(mounts *mountAccumulator) error { if r.run.NoCache { - return + return nil } cacheDir := config.HostCacheToolProjectDir(r.host.Home, r.profile.Name, r.project.Hash) - - // Create all cache directories - cacheDirs := []string{ - "npm", "pip", - "go", "go-build", "cargo", "pnpm", "uv", "yarn", "bun", - "nvm", - } - for _, dir := range cacheDirs { - _ = os.MkdirAll(filepath.Join(cacheDir, dir), 0o700) - } - - mounts.AddMount(bindMount(filepath.Join(cacheDir, "npm"), r.containerHome+"/.npm", false)) - mounts.AddMount(bindMount(filepath.Join(cacheDir, "pip"), r.containerHome+"/.cache/pip", false)) - // Go caches - mounts.AddMount(bindMount(filepath.Join(cacheDir, "go"), r.containerHome+"/go/pkg/mod", false)) - mounts.AddMount(bindMount(filepath.Join(cacheDir, "go-build"), r.containerHome+"/.cache/go-build", false)) - // Rust/Cargo cache - mounts.AddMount(bindMount(filepath.Join(cacheDir, "cargo"), r.containerHome+"/.cargo", false)) - // pnpm store - mounts.AddMount(bindMount(filepath.Join(cacheDir, "pnpm"), r.containerHome+"/.local/share/pnpm", false)) - // uv (Python) cache - mounts.AddMount(bindMount(filepath.Join(cacheDir, "uv"), r.containerHome+"/.cache/uv", false)) - // Yarn cache - mounts.AddMount(bindMount(filepath.Join(cacheDir, "yarn"), r.containerHome+"/.cache/yarn", false)) - // Bun cache - mounts.AddMount(bindMount(filepath.Join(cacheDir, "bun"), r.containerHome+"/.bun", false)) - // nvm installed Node.js versions - mounts.AddMount(bindMount(filepath.Join(cacheDir, "nvm"), r.containerHome+"/.nvm/versions", false)) + for _, entry := range packageCacheDirs(r.containerHome) { + source := filepath.Join(cacheDir, entry[0]) + if err := os.MkdirAll(source, 0o700); err != nil { + return fmt.Errorf("create package cache directory %s: %w", source, err) + } + mounts.AddMount(disposableDirMount(source, entry[1], false)) + } + return nil } func (r *Runtime) addHistoryMounts(mounts *mountAccumulator) {