diff --git a/cmd/bbox-init/main.go b/cmd/bbox-init/main.go index be72f47..3f3c504 100644 --- a/cmd/bbox-init/main.go +++ b/cmd/bbox-init/main.go @@ -77,7 +77,8 @@ func main() { return } - // Make /home/sandbox writable. On certain host kernels (e.g. openSUSE + // Make /home/sandbox writable and reconcile ownership of host-injected files. + // On certain host kernels (e.g. openSUSE // MicroOS / Tumbleweed), the root virtiofs rejects writes even though // the mount is nominally rw. A tmpfs on the home directory works // around this so agents can create config files. diff --git a/internal/guest/homefs/copy.go b/internal/guest/homefs/copy.go index 7e12bfa..e985c3a 100644 --- a/internal/guest/homefs/copy.go +++ b/internal/guest/homefs/copy.go @@ -106,13 +106,27 @@ func copyOwnership(src, dst string) { _ = os.Lchown(dst, int(stat.Uid), int(stat.Gid)) } -// chownRecursive sets uid:gid on all entries under root. Failures are -// logged rather than silently discarded because a failed chown means -// the sandbox user cannot access its own SSH keys or config files. +// chownRecursive sets uid:gid on all entries under root. Walk errors and +// chown errors are logged and the walk continues; a failed chown means +// the sandbox user cannot access that one path. Entries already owned by +// uid:gid are skipped to avoid triggering overlayfs copy-up on every file +// when the home tree is already correctly owned (the Linux common case). func chownRecursive(root string, uid, gid int, logger *slog.Logger) { - _ = filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error { + _ = filepath.WalkDir(root, func(path string, _ fs.DirEntry, walkErr error) error { + if walkErr != nil { + logger.Warn("walk error during chown, continuing", + "path", path, "error", walkErr) + return nil + } + info, err := os.Lstat(path) if err != nil { - return err + logger.Warn("lstat failed during chown, continuing", + "path", path, "error", err) + return nil + } + stat, ok := info.Sys().(*syscall.Stat_t) + if ok && int(stat.Uid) == uid && int(stat.Gid) == gid { + return nil } if err := os.Lchown(path, uid, gid); err != nil { logger.Warn("chown failed, sandbox user may lack access", diff --git a/internal/guest/homefs/homefs.go b/internal/guest/homefs/homefs.go index bd7c039..7246be0 100644 --- a/internal/guest/homefs/homefs.go +++ b/internal/guest/homefs/homefs.go @@ -33,14 +33,20 @@ const ( overlayWork = "/tmp/.home-work" ) -// MakeWritable mounts an overlayfs on the sandbox home directory so that -// writes go to a tmpfs-backed upper layer. The original virtiofs contents -// remain visible as the lower layer. If overlayfs is not supported, a -// tmpfs + copy fallback is used. This must be called before seccomp -// blocks mount(2). -// -// If the home directory is already writable, this is a no-op. +// MakeWritable ensures the sandbox user's home directory is both writable +// and owned by the sandbox user. It mounts an overlayfs (with tmpfs upper) +// when the underlying virtiofs rejects writes, falling back to a tmpfs + +// copy approach on kernels without overlayfs. It then recursively chowns +// the tree so host-injected files (settings, credentials, skills) are +// readable by the sandbox user; macOS host-side rootfs hooks cannot chown +// to UID 1000, so reconciliation must happen inside the guest. Must be +// called before seccomp blocks mount(2). func MakeWritable(logger *slog.Logger, home string, uid, gid int) error { + // Reconcile ownership unconditionally on return so host-injected files + // are readable by the sandbox user even if the mount step fails. On a + // read-only FS, Lchown returns EROFS and is logged as a warning. + defer chownRecursive(home, uid, gid, logger) + // Probe writability as the sandbox user (not root). We run as PID 1 // (root), so os.Create() would always succeed on a normal FS — we // must test as the actual sandbox user to catch permission issues. @@ -51,7 +57,12 @@ func MakeWritable(logger *slog.Logger, home string, uid, gid int) error { logger.Info("home directory is read-only via virtiofs, mounting overlay", "home", home) + return mountOverlayOrTmpfs(logger, home) +} +// mountOverlayOrTmpfs mounts an overlayfs with a tmpfs upper layer on home, +// falling back to a plain tmpfs + copy when overlayfs is unavailable. +func mountOverlayOrTmpfs(logger *slog.Logger, home string) error { // Create upper and work directories on tmpfs. for _, dir := range []string{overlayUpper, overlayWork} { if err := os.MkdirAll(dir, 0o700); err != nil { @@ -66,12 +77,7 @@ func MakeWritable(logger *slog.Logger, home string, uid, gid int) error { if err := syscall.Mount("overlay", home, "overlay", 0, opts); err != nil { logger.Warn("overlayfs mount failed, falling back to tmpfs copy", "error", err) - return fallbackTmpfs(logger, home, uid, gid) - } - - // Chown the overlay mount point so the sandbox user owns it. - if err := os.Chown(home, uid, gid); err != nil { - logger.Warn("chown overlay mount point failed", "error", err) + return fallbackTmpfs(logger, home) } logger.Info("overlay mounted on home directory", "home", home) @@ -136,7 +142,7 @@ func probeWritableAs(dir string, uid, gid int) bool { // fallbackTmpfs is used when overlayfs is not available in the guest kernel. // It mounts a tmpfs directly on the home directory after copying existing // contents into a staging area. -func fallbackTmpfs(logger *slog.Logger, home string, uid, gid int) error { +func fallbackTmpfs(logger *slog.Logger, home string) error { logger.Info("falling back to tmpfs + copy for writable home") staging := "/tmp/.home-staging" @@ -164,9 +170,6 @@ func fallbackTmpfs(logger *slog.Logger, home string, uid, gid int) error { return fmt.Errorf("restoring home from staging: %w", err) } - // Fix ownership. - chownRecursive(home, uid, gid, logger) - // Clean up staging. _ = os.RemoveAll(staging)