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
7 changes: 7 additions & 0 deletions docs/persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`
Expand Down
11 changes: 7 additions & 4 deletions docs/runtime/stores.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 16 additions & 9 deletions extensions/features/node-dev/feature-entrypoint.d/setup.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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=""
Expand Down
40 changes: 18 additions & 22 deletions internal/backend/docker/authsync.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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{
Expand All @@ -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{
Expand All @@ -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))
}
Expand Down
74 changes: 64 additions & 10 deletions internal/backend/docker/authsync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"

"enclave/internal/backend"
Expand All @@ -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{
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -382,15 +420,14 @@ 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)
}
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)
Expand All @@ -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)
}
}
2 changes: 1 addition & 1 deletion internal/backend/docker/devcontainer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
17 changes: 15 additions & 2 deletions internal/backend/docker/docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
5 changes: 5 additions & 0 deletions internal/backend/qemu/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions internal/backend/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "missing sources stay hard errors" guarantee only holds when SELinux is not enforcing. applySELinuxMounts moves every bind mount into Binds (formatBind), and buildRunArgs renders those as --volume (here), so on those hosts required mounts are already source-creating. The wording should be scoped, or the strictness made explicit for required mounts.

CreateSourceDir bool
}

type StoreKind string
Expand Down
17 changes: 17 additions & 0 deletions internal/docker/cli.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 != "" {
Expand Down
23 changes: 23 additions & 0 deletions internal/docker/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
3 changes: 3 additions & 0 deletions internal/docker/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading