diff --git a/README.md b/README.md index 21c880d..fe1636f 100644 --- a/README.md +++ b/README.md @@ -153,19 +153,33 @@ Commonly set: Full key list in `internal/config/config.go`. -## Credential sync +## File sync -If these host auth files exist, banger syncs them into the guest at -VM start: +Host → guest file/directory copies, declared per-user in +`~/.config/banger/config.toml`: -| Host | Guest | -|------|-------| -| `~/.local/share/opencode/auth.json` | `/root/.local/share/opencode/auth.json` | -| `~/.claude/.credentials.json` | `/root/.claude/.credentials.json` | -| `~/.pi/agent/auth.json` | `/root/.pi/agent/auth.json` | +```toml +[[file_sync]] +host = "~/.local/share/opencode/auth.json" +guest = "~/.local/share/opencode/auth.json" -Host-side changes take effect after the VM restarts. Session/history -directories are not copied. +[[file_sync]] +host = "~/.aws" # whole directory, recursive +guest = "~/.aws" + +[[file_sync]] +host = "~/bin/my-script" +guest = "~/bin/my-script" +mode = "0755" # optional; defaults to 0600 for files +``` + +Runs at `vm create` time. Each entry copies `host` → `guest` onto +the VM's work disk (mounted at `/root` in the guest). Guest paths +must live under `~/` or `/root/...`. Host-side changes take effect +after the next `vm create`. Missing host paths are a soft skip with +a warning in the daemon log. + +Default is no entries — add the ones you want. ## Web UI (experimental) diff --git a/internal/config/config.go b/internal/config/config.go index c2066d7..bf34e01 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -5,6 +5,7 @@ import ( "crypto/rand" "crypto/x509" "encoding/pem" + "fmt" "os" "path/filepath" "strings" @@ -19,19 +20,26 @@ import ( ) type fileConfig struct { - LogLevel string `toml:"log_level"` - WebListenAddr *string `toml:"web_listen_addr"` - FirecrackerBin string `toml:"firecracker_bin"` - SSHKeyPath string `toml:"ssh_key_path"` - DefaultImageName string `toml:"default_image_name"` - AutoStopStaleAfter string `toml:"auto_stop_stale_after"` - StatsPollInterval string `toml:"stats_poll_interval"` - MetricsPoll string `toml:"metrics_poll_interval"` - BridgeName string `toml:"bridge_name"` - BridgeIP string `toml:"bridge_ip"` - CIDR string `toml:"cidr"` - TapPoolSize int `toml:"tap_pool_size"` - DefaultDNS string `toml:"default_dns"` + LogLevel string `toml:"log_level"` + WebListenAddr *string `toml:"web_listen_addr"` + FirecrackerBin string `toml:"firecracker_bin"` + SSHKeyPath string `toml:"ssh_key_path"` + DefaultImageName string `toml:"default_image_name"` + AutoStopStaleAfter string `toml:"auto_stop_stale_after"` + StatsPollInterval string `toml:"stats_poll_interval"` + MetricsPoll string `toml:"metrics_poll_interval"` + BridgeName string `toml:"bridge_name"` + BridgeIP string `toml:"bridge_ip"` + CIDR string `toml:"cidr"` + TapPoolSize int `toml:"tap_pool_size"` + DefaultDNS string `toml:"default_dns"` + FileSync []fileSyncEntryFile `toml:"file_sync"` +} + +type fileSyncEntryFile struct { + Host string `toml:"host"` + Guest string `toml:"guest"` + Mode string `toml:"mode"` } func Load(layout paths.Layout) (model.DaemonConfig, error) { @@ -122,9 +130,95 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) { return cfg, err } cfg.SSHKeyPath = sshKeyPath + + for i, entry := range file.FileSync { + validated, err := validateFileSyncEntry(entry) + if err != nil { + return cfg, fmt.Errorf("file_sync[%d]: %w", i, err) + } + cfg.FileSync = append(cfg.FileSync, validated) + } return cfg, nil } +// validateFileSyncEntry normalises a single `[[file_sync]]` entry +// and rejects anything the operator would regret later: empty +// paths, unsupported leading characters, path traversal, or +// non-absolute guest targets. +func validateFileSyncEntry(entry fileSyncEntryFile) (model.FileSyncEntry, error) { + host := strings.TrimSpace(entry.Host) + guest := strings.TrimSpace(entry.Guest) + if host == "" { + return model.FileSyncEntry{}, fmt.Errorf("host path is required") + } + if guest == "" { + return model.FileSyncEntry{}, fmt.Errorf("guest path is required") + } + if err := validateFileSyncPath("host", host, true); err != nil { + return model.FileSyncEntry{}, err + } + if err := validateFileSyncPath("guest", guest, true); err != nil { + return model.FileSyncEntry{}, err + } + // Guest paths must resolve under /root — that's where banger mounts + // the work disk. Syncing to /etc, /var, etc. would require writing + // to the rootfs snapshot, which file_sync deliberately doesn't do. + if !strings.HasPrefix(guest, "~/") && !strings.HasPrefix(guest, "/root/") && guest != "~" && guest != "/root" { + return model.FileSyncEntry{}, fmt.Errorf("guest path %q: must be under /root or ~/ (the work disk is mounted at /root)", guest) + } + mode := strings.TrimSpace(entry.Mode) + if mode != "" { + if err := validateFileSyncMode(mode); err != nil { + return model.FileSyncEntry{}, err + } + } + return model.FileSyncEntry{Host: host, Guest: guest, Mode: mode}, nil +} + +// validateFileSyncPath rejects relative paths (other than a leading +// "~/"), "..", empty segments, and "~user/..." forms banger doesn't +// expand. Absolute paths and home-anchored paths pass through — the +// actual expansion happens at sync time. +func validateFileSyncPath(label, raw string, allowHome bool) error { + if raw == "~" { + return fmt.Errorf("%s path %q: bare '~' is not supported, point at a file or directory under it", label, raw) + } + // "~user/..." must be rejected specifically — catch it before the + // generic "must be absolute" message so the error names the real + // problem. + if strings.HasPrefix(raw, "~") && !strings.HasPrefix(raw, "~/") { + return fmt.Errorf("%s path %q: only '~/' is expanded, not '~user/'", label, raw) + } + if strings.HasPrefix(raw, "~/") { + if !allowHome { + return fmt.Errorf("%s path %q: home-relative paths are not supported here", label, raw) + } + } else if !strings.HasPrefix(raw, "/") { + return fmt.Errorf("%s path %q: must be absolute (start with '/') or home-anchored (start with '~/')", label, raw) + } + for _, segment := range strings.Split(raw, "/") { + if segment == ".." { + return fmt.Errorf("%s path %q: '..' segments are not allowed", label, raw) + } + } + return nil +} + +// validateFileSyncMode accepts three- or four-digit octal strings. +// Three-digit modes like "600" are auto-prefixed with a leading 0 +// when parsed by the consumer. +func validateFileSyncMode(mode string) error { + if len(mode) < 3 || len(mode) > 4 { + return fmt.Errorf("mode %q: must be a 3- or 4-digit octal string", mode) + } + for _, r := range mode { + if r < '0' || r > '7' { + return fmt.Errorf("mode %q: must be octal (digits 0-7)", mode) + } + } + return nil +} + func resolveSSHKeyPath(layout paths.Layout, configured string) (string, error) { configured = strings.TrimSpace(configured) if configured != "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e22fce5..ed70d37 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "os" "path/filepath" + "strings" "testing" "time" @@ -115,3 +116,92 @@ func TestLoadAppliesLogLevelEnvOverride(t *testing.T) { t.Fatalf("LogLevel = %q, want warn", cfg.LogLevel) } } + +func TestLoadAcceptsFileSyncEntries(t *testing.T) { + configDir := t.TempDir() + data := []byte(` +[[file_sync]] +host = "~/.aws" +guest = "~/.aws" + +[[file_sync]] +host = "/etc/resolv.conf" +guest = "/root/.config/resolv.conf" +mode = "0644" +`) + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), data, 0o644); err != nil { + t.Fatal(err) + } + cfg, err := Load(paths.Layout{ConfigDir: configDir}) + if err != nil { + t.Fatalf("Load: %v", err) + } + if len(cfg.FileSync) != 2 { + t.Fatalf("FileSync = %+v", cfg.FileSync) + } + if cfg.FileSync[0].Host != "~/.aws" || cfg.FileSync[0].Guest != "~/.aws" { + t.Fatalf("entry[0] = %+v", cfg.FileSync[0]) + } + if cfg.FileSync[1].Mode != "0644" { + t.Fatalf("entry[1] mode = %q", cfg.FileSync[1].Mode) + } +} + +func TestLoadRejectsInvalidFileSyncEntries(t *testing.T) { + cases := []struct { + name string + toml string + want string + }{ + { + "empty host", + `[[file_sync]]` + "\n" + `host = ""` + "\n" + `guest = "~/foo"`, + "host path is required", + }, + { + "empty guest", + `[[file_sync]]` + "\n" + `host = "~/foo"` + "\n" + `guest = ""`, + "guest path is required", + }, + { + "relative host", + `[[file_sync]]` + "\n" + `host = "foo/bar"` + "\n" + `guest = "~/foo"`, + "must be absolute", + }, + { + "guest outside /root", + `[[file_sync]]` + "\n" + `host = "~/x"` + "\n" + `guest = "/etc/resolv.conf"`, + "must be under /root or ~/", + }, + { + "path traversal", + `[[file_sync]]` + "\n" + `host = "~/../secrets"` + "\n" + `guest = "~/secrets"`, + "'..' segments", + }, + { + "tilde user", + `[[file_sync]]` + "\n" + `host = "~other/foo"` + "\n" + `guest = "~/foo"`, + "only '~/' is expanded", + }, + { + "invalid mode", + `[[file_sync]]` + "\n" + `host = "~/x"` + "\n" + `guest = "~/x"` + "\n" + `mode = "rwx"`, + "must be octal", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + configDir := t.TempDir() + if err := os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(tc.toml+"\n"), 0o644); err != nil { + t.Fatal(err) + } + _, err := Load(paths.Layout{ConfigDir: configDir}) + if err == nil { + t.Fatalf("Load: want error containing %q", tc.want) + } + if !strings.Contains(err.Error(), tc.want) { + t.Fatalf("Load error = %v, want contains %q", err, tc.want) + } + }) + } +} diff --git a/internal/daemon/capabilities.go b/internal/daemon/capabilities.go index e44c3b9..eef39d5 100644 --- a/internal/daemon/capabilities.go +++ b/internal/daemon/capabilities.go @@ -210,13 +210,7 @@ func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model. if err := d.ensureGitIdentityOnWorkDisk(ctx, vm); err != nil { return err } - if err := d.ensureOpencodeAuthOnWorkDisk(ctx, vm); err != nil { - return err - } - if err := d.ensureClaudeAuthOnWorkDisk(ctx, vm); err != nil { - return err - } - return d.ensurePiAuthOnWorkDisk(ctx, vm) + return d.runFileSync(ctx, vm) } func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) { diff --git a/internal/daemon/vm_authsync.go b/internal/daemon/vm_authsync.go index 709afb5..9702083 100644 --- a/internal/daemon/vm_authsync.go +++ b/internal/daemon/vm_authsync.go @@ -14,17 +14,8 @@ import ( ) const ( - workDiskGitConfigRelativePath = ".gitconfig" - workDiskOpencodeAuthDirRelativePath = ".local/share/opencode" - workDiskOpencodeAuthRelativePath = workDiskOpencodeAuthDirRelativePath + "/auth.json" - workDiskClaudeAuthDirRelativePath = ".claude" - workDiskClaudeAuthRelativePath = workDiskClaudeAuthDirRelativePath + "/.credentials.json" - workDiskPiAuthDirRelativePath = ".pi/agent" - workDiskPiAuthRelativePath = workDiskPiAuthDirRelativePath + "/auth.json" - hostGlobalGitIdentitySource = "git config --global" - hostOpencodeAuthDefaultDisplayPath = "~/" + workDiskOpencodeAuthRelativePath - hostClaudeAuthDefaultDisplayPath = "~/" + workDiskClaudeAuthRelativePath - hostPiAuthDefaultDisplayPath = "~/" + workDiskPiAuthRelativePath + workDiskGitConfigRelativePath = ".gitconfig" + hostGlobalGitIdentitySource = "git config --global" ) type gitIdentity struct { @@ -125,51 +116,17 @@ func (d *Daemon) ensureGitIdentityOnWorkDisk(ctx context.Context, vm *model.VMRe return writeGitIdentity(ctx, runner, filepath.Join(workMount, workDiskGitConfigRelativePath), identity) } -func (d *Daemon) ensureOpencodeAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error { - return d.ensureAuthFileOnWorkDisk( - ctx, - vm, - "syncing opencode auth", - hostOpencodeAuthDefaultDisplayPath, - resolveHostOpencodeAuthPath, - workDiskOpencodeAuthRelativePath, - d.warnOpencodeAuthSyncSkipped, - ) -} - -func (d *Daemon) ensureClaudeAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error { - return d.ensureAuthFileOnWorkDisk( - ctx, - vm, - "syncing claude auth", - hostClaudeAuthDefaultDisplayPath, - resolveHostClaudeAuthPath, - workDiskClaudeAuthRelativePath, - d.warnClaudeAuthSyncSkipped, - ) -} - -func (d *Daemon) ensurePiAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error { - return d.ensureAuthFileOnWorkDisk( - ctx, - vm, - "syncing pi auth", - hostPiAuthDefaultDisplayPath, - resolveHostPiAuthPath, - workDiskPiAuthRelativePath, - d.warnPiAuthSyncSkipped, - ) -} - -func (d *Daemon) ensureAuthFileOnWorkDisk(ctx context.Context, vm *model.VMRecord, stageDetail, defaultDisplayPath string, resolveHostPath func() (string, error), guestRelativePath string, warn func(model.VMRecord, string, error)) error { - hostAuthPath, err := resolveHostPath() - if err != nil { - warn(*vm, defaultDisplayPath, err) - return nil - } - authData, err := os.ReadFile(hostAuthPath) - if err != nil { - warn(*vm, hostAuthPath, err) +// runFileSync applies every [[file_sync]] entry from the daemon config +// to the VM's work disk. Missing host paths are skipped with a warn. +// Other errors abort the VM create (since the user explicitly asked +// for the sync). +// +// File entries: `install -o 0 -g 0 -m ` (mode defaults to 0600). +// Directory entries: walked in Go — each file is installed with its +// source permissions, each subdir is mkdir'd. The entry's `mode` +// field is only honoured for file entries. +func (d *Daemon) runFileSync(ctx context.Context, vm *model.VMRecord) error { + if len(d.config.FileSync) == 0 { return nil } @@ -178,61 +135,141 @@ func (d *Daemon) ensureAuthFileOnWorkDisk(ctx context.Context, vm *model.VMRecor runner = system.NewRunner() } - vmCreateStage(ctx, "prepare_work_disk", stageDetail) - workMount, cleanupWork, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false) + hostHome, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("resolve host user home: %w", err) + } + + // Mount the work disk once and reuse for every entry. + var workMount string + var cleanupWork func() error + ensureMount := func() (string, error) { + if workMount != "" { + return workMount, nil + } + m, c, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false) + if err != nil { + return "", err + } + workMount = m + cleanupWork = c + if err := d.flattenNestedWorkHome(ctx, workMount); err != nil { + return "", err + } + return workMount, nil + } + defer func() { + if cleanupWork != nil { + cleanupWork() + } + }() + + for _, entry := range d.config.FileSync { + hostPath := expandHostPath(entry.Host, hostHome) + guestRel := guestPathRelativeToRoot(entry.Guest) + + info, err := os.Stat(hostPath) + if err != nil { + if os.IsNotExist(err) { + d.warnFileSyncSkipped(*vm, hostPath, err) + continue + } + return fmt.Errorf("file_sync: stat %s: %w", hostPath, err) + } + + mount, err := ensureMount() + if err != nil { + return err + } + + vmCreateStage(ctx, "prepare_work_disk", "file sync: "+entry.Host+" → "+entry.Guest) + + target := filepath.Join(mount, guestRel) + if _, err := runner.RunSudo(ctx, "mkdir", "-p", filepath.Dir(target)); err != nil { + return fmt.Errorf("file_sync: mkdir %s: %w", filepath.Dir(target), err) + } + + if info.IsDir() { + if err := copyHostDir(ctx, runner, hostPath, target); err != nil { + return fmt.Errorf("file_sync: copy directory %s → %s: %w", hostPath, target, err) + } + continue + } + + mode := entry.Mode + if mode == "" { + mode = "0600" + } + if _, err := runner.RunSudo(ctx, "install", "-o", "0", "-g", "0", "-m", mode, hostPath, target); err != nil { + return fmt.Errorf("file_sync: install %s → %s: %w", hostPath, target, err) + } + } + return nil +} + +// copyHostDir recursively copies hostDir into guestTarget using only +// `mkdir` (for subdirs) and `install` (for files). Each file's source +// permissions are preserved; ownership is forced to root:root via +// `install -o 0 -g 0`. Symlinks are followed (target content is +// copied as a regular file). Other special types (devices, FIFOs) +// are skipped silently. +func copyHostDir(ctx context.Context, runner system.CommandRunner, hostDir, guestTarget string) error { + if _, err := runner.RunSudo(ctx, "mkdir", "-p", guestTarget); err != nil { + return err + } + entries, err := os.ReadDir(hostDir) if err != nil { return err } - defer cleanupWork() + for _, entry := range entries { + hostChild := filepath.Join(hostDir, entry.Name()) + guestChild := filepath.Join(guestTarget, entry.Name()) - if err := d.flattenNestedWorkHome(ctx, workMount); err != nil { - return err + info, err := os.Stat(hostChild) + if err != nil { + return err + } + switch { + case info.IsDir(): + if err := copyHostDir(ctx, runner, hostChild, guestChild); err != nil { + return err + } + case info.Mode().IsRegular(): + mode := fmt.Sprintf("%04o", info.Mode().Perm()) + if _, err := runner.RunSudo(ctx, "install", "-o", "0", "-g", "0", "-m", mode, hostChild, guestChild); err != nil { + return err + } + } } - - authDir := filepath.Join(workMount, filepath.Dir(guestRelativePath)) - if _, err := runner.RunSudo(ctx, "mkdir", "-p", authDir); err != nil { - return err - } - authPath := filepath.Join(workMount, guestRelativePath) - - tmpFile, err := os.CreateTemp("", "banger-auth-*") - if err != nil { - return err - } - tmpPath := tmpFile.Name() - if _, err := tmpFile.Write(authData); err != nil { - _ = tmpFile.Close() - _ = os.Remove(tmpPath) - return err - } - if err := tmpFile.Close(); err != nil { - _ = os.Remove(tmpPath) - return err - } - defer os.Remove(tmpPath) - - _, err = runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authPath) - return err + return nil } -func resolveHostOpencodeAuthPath() (string, error) { - return resolveHostAuthPath(workDiskOpencodeAuthRelativePath) -} - -func resolveHostClaudeAuthPath() (string, error) { - return resolveHostAuthPath(workDiskClaudeAuthRelativePath) -} - -func resolveHostPiAuthPath() (string, error) { - return resolveHostAuthPath(workDiskPiAuthRelativePath) -} - -func resolveHostAuthPath(relativePath string) (string, error) { - home, err := os.UserHomeDir() - if err != nil { - return "", err +// expandHostPath expands a leading "~/" against the host user's +// home. Already-absolute paths pass through unchanged. +func expandHostPath(raw, home string) string { + raw = strings.TrimSpace(raw) + if strings.HasPrefix(raw, "~/") { + return filepath.Join(home, strings.TrimPrefix(raw, "~/")) } - return filepath.Join(home, relativePath), nil + return raw +} + +// guestPathRelativeToRoot returns the guest path as a relative path +// under /root (banger's work disk is mounted at /root in the guest, +// so everything syncable lives there). "~/foo" and "/root/foo" both +// return "foo"; config validation rejects anything outside that +// scope, so the string prefixes are the only forms we see here. +func guestPathRelativeToRoot(raw string) string { + raw = strings.TrimSpace(raw) + switch { + case raw == "~" || raw == "/root": + return "" + case strings.HasPrefix(raw, "~/"): + return strings.TrimPrefix(raw, "~/") + case strings.HasPrefix(raw, "/root/"): + return strings.TrimPrefix(raw, "/root/") + } + return raw } func resolveHostGlobalGitIdentity(ctx context.Context, runner system.CommandRunner) (gitIdentity, error) { @@ -298,25 +335,11 @@ func writeGitIdentity(ctx context.Context, runner system.CommandRunner, gitConfi return err } -func (d *Daemon) warnOpencodeAuthSyncSkipped(vm model.VMRecord, hostPath string, err error) { +func (d *Daemon) warnFileSyncSkipped(vm model.VMRecord, hostPath string, err error) { if d.logger == nil || err == nil { return } - d.logger.Warn("guest opencode auth sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...) -} - -func (d *Daemon) warnClaudeAuthSyncSkipped(vm model.VMRecord, hostPath string, err error) { - if d.logger == nil || err == nil { - return - } - d.logger.Warn("guest claude auth sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...) -} - -func (d *Daemon) warnPiAuthSyncSkipped(vm model.VMRecord, hostPath string, err error) { - if d.logger == nil || err == nil { - return - } - d.logger.Warn("guest pi auth sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...) + d.logger.Warn("file_sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...) } func (d *Daemon) warnGitIdentitySyncSkipped(vm model.VMRecord, source string, err error) { diff --git a/internal/daemon/vm_test.go b/internal/daemon/vm_test.go index 8050423..6ee16a8 100644 --- a/internal/daemon/vm_test.go +++ b/internal/daemon/vm_test.go @@ -923,300 +923,210 @@ func TestEnsureGitIdentityOnWorkDiskWarnsAndSkipsWhenHostIdentityIncomplete(t *t } } -func TestEnsureOpencodeAuthOnWorkDiskCopiesHostAuth(t *testing.T) { - homeDir := t.TempDir() - t.Setenv("HOME", homeDir) - hostAuthPath := filepath.Join(homeDir, workDiskOpencodeAuthRelativePath) - if err := os.MkdirAll(filepath.Dir(hostAuthPath), 0o755); err != nil { - t.Fatalf("MkdirAll(host auth dir): %v", err) - } - hostAuth := []byte("{\"provider\":\"openai\"}\n") - if err := os.WriteFile(hostAuthPath, hostAuth, 0o600); err != nil { - t.Fatalf("WriteFile(host auth): %v", err) - } - - workDiskDir := t.TempDir() +func TestRunFileSyncNoOpWhenConfigEmpty(t *testing.T) { d := &Daemon{runner: &filesystemRunner{t: t}} - vm := testVM("auth-sync", "image-auth-sync", "172.16.0.63") - vm.Runtime.WorkDiskPath = workDiskDir - - if err := d.ensureOpencodeAuthOnWorkDisk(context.Background(), &vm); err != nil { - t.Fatalf("ensureOpencodeAuthOnWorkDisk: %v", err) - } - - guestAuthPath := filepath.Join(workDiskDir, workDiskOpencodeAuthRelativePath) - got, err := os.ReadFile(guestAuthPath) - if err != nil { - t.Fatalf("ReadFile(guest auth): %v", err) - } - if string(got) != string(hostAuth) { - t.Fatalf("guest auth = %q, want %q", string(got), string(hostAuth)) - } - info, err := os.Stat(guestAuthPath) - if err != nil { - t.Fatalf("Stat(guest auth): %v", err) - } - if gotMode := info.Mode().Perm(); gotMode != 0o600 { - t.Fatalf("guest auth mode = %o, want 600", gotMode) + vm := testVM("no-sync", "image", "172.16.0.70") + if err := d.runFileSync(context.Background(), &vm); err != nil { + t.Fatalf("runFileSync: %v", err) } } -func TestEnsureOpencodeAuthOnWorkDiskReplacesExistingGuestAuth(t *testing.T) { +func TestRunFileSyncCopiesFile(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) - hostAuthPath := filepath.Join(homeDir, workDiskOpencodeAuthRelativePath) - if err := os.MkdirAll(filepath.Dir(hostAuthPath), 0o755); err != nil { - t.Fatalf("MkdirAll(host auth dir): %v", err) + srcPath := filepath.Join(homeDir, ".secrets", "token") + if err := os.MkdirAll(filepath.Dir(srcPath), 0o755); err != nil { + t.Fatal(err) } - hostAuth := []byte("{\"token\":\"fresh\"}\n") - if err := os.WriteFile(hostAuthPath, hostAuth, 0o600); err != nil { - t.Fatalf("WriteFile(host auth): %v", err) - } - - workDiskDir := t.TempDir() - guestAuthPath := filepath.Join(workDiskDir, workDiskOpencodeAuthRelativePath) - if err := os.MkdirAll(filepath.Dir(guestAuthPath), 0o755); err != nil { - t.Fatalf("MkdirAll(guest auth dir): %v", err) - } - if err := os.WriteFile(guestAuthPath, []byte("{\"token\":\"stale\"}\n"), 0o600); err != nil { - t.Fatalf("WriteFile(guest auth): %v", err) - } - - d := &Daemon{runner: &filesystemRunner{t: t}} - vm := testVM("auth-replace", "image-auth-replace", "172.16.0.64") - vm.Runtime.WorkDiskPath = workDiskDir - - if err := d.ensureOpencodeAuthOnWorkDisk(context.Background(), &vm); err != nil { - t.Fatalf("ensureOpencodeAuthOnWorkDisk: %v", err) - } - - got, err := os.ReadFile(guestAuthPath) - if err != nil { - t.Fatalf("ReadFile(guest auth): %v", err) - } - if string(got) != string(hostAuth) { - t.Fatalf("guest auth = %q, want %q", string(got), string(hostAuth)) - } -} - -func TestEnsureOpencodeAuthOnWorkDiskWarnsAndSkipsWhenHostAuthMissing(t *testing.T) { - homeDir := t.TempDir() - t.Setenv("HOME", homeDir) - - workDiskDir := t.TempDir() - guestAuthPath := filepath.Join(workDiskDir, workDiskOpencodeAuthRelativePath) - if err := os.MkdirAll(filepath.Dir(guestAuthPath), 0o755); err != nil { - t.Fatalf("MkdirAll(guest auth dir): %v", err) - } - original := []byte("{\"token\":\"keep\"}\n") - if err := os.WriteFile(guestAuthPath, original, 0o600); err != nil { - t.Fatalf("WriteFile(guest auth): %v", err) - } - - var buf bytes.Buffer - logger, _, err := newDaemonLogger(&buf, "info") - if err != nil { - t.Fatalf("newDaemonLogger: %v", err) + srcData := []byte(`{"token":"abc"}`) + if err := os.WriteFile(srcPath, srcData, 0o600); err != nil { + t.Fatal(err) } + workDisk := t.TempDir() d := &Daemon{ runner: &filesystemRunner{t: t}, - logger: logger, + config: model.DaemonConfig{ + FileSync: []model.FileSyncEntry{ + {Host: "~/.secrets/token", Guest: "~/.secrets/token"}, + }, + }, } - vm := testVM("auth-missing", "image-auth-missing", "172.16.0.65") - vm.Runtime.WorkDiskPath = workDiskDir - - if err := d.ensureOpencodeAuthOnWorkDisk(context.Background(), &vm); err != nil { - t.Fatalf("ensureOpencodeAuthOnWorkDisk: %v", err) + vm := testVM("sync-file", "image", "172.16.0.71") + vm.Runtime.WorkDiskPath = workDisk + if err := d.runFileSync(context.Background(), &vm); err != nil { + t.Fatalf("runFileSync: %v", err) } - got, err := os.ReadFile(guestAuthPath) + dst := filepath.Join(workDisk, ".secrets", "token") + got, err := os.ReadFile(dst) if err != nil { - t.Fatalf("ReadFile(guest auth): %v", err) + t.Fatal(err) } - if string(got) != string(original) { - t.Fatalf("guest auth = %q, want preserved %q", string(got), string(original)) + if string(got) != string(srcData) { + t.Fatalf("dst = %q, want %q", got, srcData) } - - entries := parseLogEntries(t, buf.Bytes()) - if !hasLogEntry(entries, map[string]string{ - "msg": "guest opencode auth sync skipped", - "vm_name": vm.Name, - "host_path": filepath.Join(homeDir, workDiskOpencodeAuthRelativePath), - }) { - t.Fatalf("expected warn log, got %v", entries) - } -} - -func TestEnsureOpencodeAuthOnWorkDiskWarnsAndSkipsWhenHostAuthUnreadable(t *testing.T) { - homeDir := t.TempDir() - t.Setenv("HOME", homeDir) - hostAuthPath := filepath.Join(homeDir, workDiskOpencodeAuthRelativePath) - if err := os.MkdirAll(hostAuthPath, 0o755); err != nil { - t.Fatalf("MkdirAll(host auth path as dir): %v", err) - } - - workDiskDir := t.TempDir() - guestAuthPath := filepath.Join(workDiskDir, workDiskOpencodeAuthRelativePath) - if err := os.MkdirAll(filepath.Dir(guestAuthPath), 0o755); err != nil { - t.Fatalf("MkdirAll(guest auth dir): %v", err) - } - original := []byte("{\"token\":\"keep\"}\n") - if err := os.WriteFile(guestAuthPath, original, 0o600); err != nil { - t.Fatalf("WriteFile(guest auth): %v", err) - } - - var buf bytes.Buffer - logger, _, err := newDaemonLogger(&buf, "info") + info, err := os.Stat(dst) if err != nil { - t.Fatalf("newDaemonLogger: %v", err) - } - - d := &Daemon{ - runner: &filesystemRunner{t: t}, - logger: logger, - } - vm := testVM("auth-unreadable", "image-auth-unreadable", "172.16.0.66") - vm.Runtime.WorkDiskPath = workDiskDir - - if err := d.ensureOpencodeAuthOnWorkDisk(context.Background(), &vm); err != nil { - t.Fatalf("ensureOpencodeAuthOnWorkDisk: %v", err) - } - - got, err := os.ReadFile(guestAuthPath) - if err != nil { - t.Fatalf("ReadFile(guest auth): %v", err) - } - if string(got) != string(original) { - t.Fatalf("guest auth = %q, want preserved %q", string(got), string(original)) - } - - entries := parseLogEntries(t, buf.Bytes()) - if !hasLogEntry(entries, map[string]string{ - "msg": "guest opencode auth sync skipped", - "vm_name": vm.Name, - "host_path": hostAuthPath, - "error": "is a directory", - }) { - t.Fatalf("expected warn log, got %v", entries) - } -} - -func TestEnsureClaudeAuthOnWorkDiskCopiesHostAuth(t *testing.T) { - homeDir := t.TempDir() - t.Setenv("HOME", homeDir) - hostAuthPath := filepath.Join(homeDir, workDiskClaudeAuthRelativePath) - if err := os.MkdirAll(filepath.Dir(hostAuthPath), 0o755); err != nil { - t.Fatalf("MkdirAll(host auth dir): %v", err) - } - hostAuth := []byte("{\"token\":\"claude\"}\n") - if err := os.WriteFile(hostAuthPath, hostAuth, 0o600); err != nil { - t.Fatalf("WriteFile(host auth): %v", err) - } - - workDiskDir := t.TempDir() - d := &Daemon{runner: &filesystemRunner{t: t}} - vm := testVM("claude-auth", "image-claude-auth", "172.16.0.67") - vm.Runtime.WorkDiskPath = workDiskDir - - if err := d.ensureClaudeAuthOnWorkDisk(context.Background(), &vm); err != nil { - t.Fatalf("ensureClaudeAuthOnWorkDisk: %v", err) - } - - guestAuthPath := filepath.Join(workDiskDir, workDiskClaudeAuthRelativePath) - got, err := os.ReadFile(guestAuthPath) - if err != nil { - t.Fatalf("ReadFile(guest auth): %v", err) - } - if string(got) != string(hostAuth) { - t.Fatalf("guest auth = %q, want %q", string(got), string(hostAuth)) - } - info, err := os.Stat(guestAuthPath) - if err != nil { - t.Fatalf("Stat(guest auth): %v", err) + t.Fatal(err) } if info.Mode().Perm() != 0o600 { - t.Fatalf("guest auth mode = %v, want 0600", info.Mode().Perm()) + t.Fatalf("mode = %v, want 0600", info.Mode().Perm()) } } -func TestEnsurePiAuthOnWorkDiskCopiesHostAuth(t *testing.T) { +func TestRunFileSyncRespectsCustomMode(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) - hostAuthPath := filepath.Join(homeDir, workDiskPiAuthRelativePath) - if err := os.MkdirAll(filepath.Dir(hostAuthPath), 0o755); err != nil { - t.Fatalf("MkdirAll(host auth dir): %v", err) - } - hostAuth := []byte("{\"token\":\"pi\"}\n") - if err := os.WriteFile(hostAuthPath, hostAuth, 0o600); err != nil { - t.Fatalf("WriteFile(host auth): %v", err) + srcPath := filepath.Join(homeDir, "script") + if err := os.WriteFile(srcPath, []byte("#!/bin/sh\nexit 0\n"), 0o600); err != nil { + t.Fatal(err) } - workDiskDir := t.TempDir() - d := &Daemon{runner: &filesystemRunner{t: t}} - vm := testVM("pi-auth", "image-pi-auth", "172.16.0.68") - vm.Runtime.WorkDiskPath = workDiskDir - - if err := d.ensurePiAuthOnWorkDisk(context.Background(), &vm); err != nil { - t.Fatalf("ensurePiAuthOnWorkDisk: %v", err) + workDisk := t.TempDir() + d := &Daemon{ + runner: &filesystemRunner{t: t}, + config: model.DaemonConfig{ + FileSync: []model.FileSyncEntry{ + {Host: "~/script", Guest: "~/bin/my-script", Mode: "0755"}, + }, + }, + } + vm := testVM("sync-mode", "image", "172.16.0.72") + vm.Runtime.WorkDiskPath = workDisk + if err := d.runFileSync(context.Background(), &vm); err != nil { + t.Fatalf("runFileSync: %v", err) } - guestAuthPath := filepath.Join(workDiskDir, workDiskPiAuthRelativePath) - got, err := os.ReadFile(guestAuthPath) + info, err := os.Stat(filepath.Join(workDisk, "bin", "my-script")) if err != nil { - t.Fatalf("ReadFile(guest auth): %v", err) + t.Fatal(err) } - if string(got) != string(hostAuth) { - t.Fatalf("guest auth = %q, want %q", string(got), string(hostAuth)) + if info.Mode().Perm() != 0o755 { + t.Fatalf("mode = %v, want 0755", info.Mode().Perm()) } } -func TestEnsurePiAuthOnWorkDiskWarnsAndSkipsWhenHostAuthMissing(t *testing.T) { +func TestRunFileSyncSkipsMissingHostPath(t *testing.T) { homeDir := t.TempDir() t.Setenv("HOME", homeDir) - workDiskDir := t.TempDir() - guestAuthPath := filepath.Join(workDiskDir, workDiskPiAuthRelativePath) - if err := os.MkdirAll(filepath.Dir(guestAuthPath), 0o755); err != nil { - t.Fatalf("MkdirAll(guest auth dir): %v", err) - } - original := []byte("{\"token\":\"keep\"}\n") - if err := os.WriteFile(guestAuthPath, original, 0o600); err != nil { - t.Fatalf("WriteFile(guest auth): %v", err) - } - var buf bytes.Buffer logger, _, err := newDaemonLogger(&buf, "info") if err != nil { - t.Fatalf("newDaemonLogger: %v", err) + t.Fatal(err) } + workDisk := t.TempDir() d := &Daemon{ runner: &filesystemRunner{t: t}, logger: logger, + config: model.DaemonConfig{ + FileSync: []model.FileSyncEntry{ + {Host: "~/does-not-exist", Guest: "~/wherever"}, + }, + }, } - vm := testVM("pi-auth-missing", "image-pi-auth-missing", "172.16.0.69") - vm.Runtime.WorkDiskPath = workDiskDir - - if err := d.ensurePiAuthOnWorkDisk(context.Background(), &vm); err != nil { - t.Fatalf("ensurePiAuthOnWorkDisk: %v", err) - } - - got, err := os.ReadFile(guestAuthPath) - if err != nil { - t.Fatalf("ReadFile(guest auth): %v", err) - } - if string(got) != string(original) { - t.Fatalf("guest auth = %q, want preserved %q", string(got), string(original)) + vm := testVM("sync-missing", "image", "172.16.0.73") + vm.Runtime.WorkDiskPath = workDisk + if err := d.runFileSync(context.Background(), &vm); err != nil { + t.Fatalf("runFileSync: %v", err) } entries := parseLogEntries(t, buf.Bytes()) if !hasLogEntry(entries, map[string]string{ - "msg": "guest pi auth sync skipped", + "msg": "file_sync skipped", "vm_name": vm.Name, - "host_path": filepath.Join(homeDir, workDiskPiAuthRelativePath), + "host_path": filepath.Join(homeDir, "does-not-exist"), }) { - t.Fatalf("expected warn log, got %v", entries) + t.Fatalf("expected skipped log, got %v", entries) + } +} + +func TestRunFileSyncOverwritesExistingGuestFile(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + srcPath := filepath.Join(homeDir, "token") + if err := os.WriteFile(srcPath, []byte("fresh"), 0o600); err != nil { + t.Fatal(err) + } + workDisk := t.TempDir() + // Work disk is mounted at /root in the guest, so the guest path + // "/root/token" maps to workDisk/token here. + existing := filepath.Join(workDisk, "token") + if err := os.WriteFile(existing, []byte("stale"), 0o600); err != nil { + t.Fatal(err) + } + + d := &Daemon{ + runner: &filesystemRunner{t: t}, + config: model.DaemonConfig{ + FileSync: []model.FileSyncEntry{ + {Host: "~/token", Guest: "/root/token"}, + }, + }, + } + vm := testVM("sync-overwrite", "image", "172.16.0.74") + vm.Runtime.WorkDiskPath = workDisk + if err := d.runFileSync(context.Background(), &vm); err != nil { + t.Fatalf("runFileSync: %v", err) + } + + got, err := os.ReadFile(existing) + if err != nil { + t.Fatal(err) + } + if string(got) != "fresh" { + t.Fatalf("guest file = %q, want fresh", got) + } +} + +func TestRunFileSyncCopiesDirectoryRecursively(t *testing.T) { + homeDir := t.TempDir() + t.Setenv("HOME", homeDir) + srcDir := filepath.Join(homeDir, ".aws") + if err := os.MkdirAll(srcDir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(srcDir, "credentials"), []byte("access"), 0o600); err != nil { + t.Fatal(err) + } + sub := filepath.Join(srcDir, "sso", "cache") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "token.json"), []byte("sso-token"), 0o600); err != nil { + t.Fatal(err) + } + + workDisk := t.TempDir() + d := &Daemon{ + runner: &filesystemRunner{t: t}, + config: model.DaemonConfig{ + FileSync: []model.FileSyncEntry{ + {Host: "~/.aws", Guest: "~/.aws"}, + }, + }, + } + vm := testVM("sync-dir", "image", "172.16.0.75") + vm.Runtime.WorkDiskPath = workDisk + if err := d.runFileSync(context.Background(), &vm); err != nil { + t.Fatalf("runFileSync: %v", err) + } + + creds, err := os.ReadFile(filepath.Join(workDisk, ".aws", "credentials")) + if err != nil { + t.Fatal(err) + } + if string(creds) != "access" { + t.Fatalf("credentials = %q, want access", creds) + } + ssoToken, err := os.ReadFile(filepath.Join(workDisk, ".aws", "sso", "cache", "token.json")) + if err != nil { + t.Fatal(err) + } + if string(ssoToken) != "sso-token" { + t.Fatalf("sso token = %q, want sso-token", ssoToken) } } @@ -1931,26 +1841,61 @@ func (r *filesystemRunner) RunSudo(ctx context.Context, args ...string) ([]byte, } return os.ReadFile(args[1]) case "install": - if len(args) != 5 || args[1] != "-m" { - return nil, fmt.Errorf("unexpected install args: %v", args) - } - mode, err := strconv.ParseUint(args[2], 8, 32) + // Minimal install(1): expected forms are + // install -m MODE SRC DST (5 args) + // install -o 0 -g 0 -m MODE SRC DST (9 args, ignored owners) + src, dst, mode, err := parseInstallArgs(args) if err != nil { return nil, err } - data, err := os.ReadFile(args[3]) + data, err := os.ReadFile(src) if err != nil { return nil, err } - if err := os.MkdirAll(filepath.Dir(args[4]), 0o755); err != nil { + if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil { return nil, err } - return nil, os.WriteFile(args[4], data, os.FileMode(mode)) + return nil, os.WriteFile(dst, data, os.FileMode(mode)) + case "chown": + // chown -R OWNER TARGET — owner is ignored under test; we + // already run as the test user and os.Chown would require + // CAP_CHOWN. + if len(args) != 4 || args[1] != "-R" { + return nil, fmt.Errorf("unexpected chown args: %v", args) + } + return nil, nil default: return nil, fmt.Errorf("unexpected sudo command: %v", args) } } +// parseInstallArgs recognises the `install` invocations banger emits +// and returns (source, destination, parsed mode). Anything else is an +// error so the test stub stays a closed set. +func parseInstallArgs(args []string) (string, string, os.FileMode, error) { + switch len(args) { + case 5: + if args[1] != "-m" { + return "", "", 0, fmt.Errorf("unexpected install args: %v", args) + } + mode, err := strconv.ParseUint(args[2], 8, 32) + if err != nil { + return "", "", 0, err + } + return args[3], args[4], os.FileMode(mode), nil + case 9: + if args[1] != "-o" || args[3] != "-g" || args[5] != "-m" { + return "", "", 0, fmt.Errorf("unexpected install args: %v", args) + } + mode, err := strconv.ParseUint(args[6], 8, 32) + if err != nil { + return "", "", 0, err + } + return args[7], args[8], os.FileMode(mode), nil + } + return "", "", 0, fmt.Errorf("unexpected install args: %v", args) +} + func copyIntoDir(sourcePath, targetDir string) error { targetDir = strings.TrimSuffix(targetDir, "/") info, err := os.Stat(sourcePath) diff --git a/internal/model/types.go b/internal/model/types.go index bc14c3c..ef87a0e 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -65,6 +65,20 @@ type DaemonConfig struct { TapPoolSize int DefaultDNS string DefaultImageName string + FileSync []FileSyncEntry +} + +// FileSyncEntry is a user-declared host→guest file or directory copy +// applied to each VM's work disk at vm create time. Host is expanded +// against the host user's $HOME for "~/..."; Guest is expanded +// against /root (banger VMs are single-user root). If the host path +// is a directory, it's copied recursively; if it's a file, it's +// copied as a file. Missing host paths are a soft skip (warned, not +// fatal). Mode defaults to 0600 for files and 0755 for directories. +type FileSyncEntry struct { + Host string + Guest string + Mode string } type Image struct {