file_sync: config-driven replacement for hardcoded auth sync

Replace the three hardcoded host→guest credential syncs (opencode,
claude, pi) with a generic `[[file_sync]]` config list. Default is
empty — users opt in to exactly what they want synced, with no
surprise about which tools banger "supports".

```toml
[[file_sync]]
host = "~/.local/share/opencode/auth.json"
guest = "~/.local/share/opencode/auth.json"

[[file_sync]]
host = "~/.aws"          # directories are copied recursively
guest = "~/.aws"

[[file_sync]]
host = "~/bin/my-script"
guest = "~/bin/my-script"
mode = "0755"            # optional; default 0600 for files
```

Semantics:
- Host `~/...` expands against the host user's $HOME. Absolute host
  paths are used as-is.
- Guest must live under `~/` or `/root/...` — banger's work disk is
  mounted at /root in the guest, so that's the syncable namespace.
  Anything outside is rejected at config load.
- Validation at config load: reject empty paths, relative paths,
  `..` traversal, `~user/...`, malformed mode strings. Errors name
  the offending entry index.
- Missing host paths are a soft skip with a warn log (existing
  behaviour). Other errors (read, mkdir, install) abort VM create.
- File entries: `install -o 0 -g 0 -m <mode>` (default 0600).
- Directory entries: walked in Go; each source file is installed
  with its own source permissions preserved. The entry's `mode` is
  ignored for directories.

Removed (all dead after this):
- `ensureOpencodeAuthOnWorkDisk`, `ensureClaudeAuthOnWorkDisk`,
  `ensurePiAuthOnWorkDisk`, the shared `ensureAuthFileOnWorkDisk`,
  their `warn*Skipped` helpers, `resolveHost{Opencode,Claude,Pi}AuthPath`,
  and the work-disk relative-path + default display-path constants.
- The capability hook registering the three syncs now calls the
  generic `runFileSync` once.

Seven tests exercising the old codepath deleted; six new tests cover
the new runFileSync (no-op on empty config, file copy, custom mode,
missing-host-skip, overwrite, recursive directory). Config-layer
test adds happy-path parsing and a case-per-shape table of invalid
entries (empty, relative host, guest outside /root, '..' traversal,
`~user`, bad mode).

README updated: replaces the "Credential sync" section with a
"File sync" section showing the new config shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Thales Maciel 2026-04-18 16:40:11 -03:00
parent 843314be5e
commit 0933deaeb1
No known key found for this signature in database
GPG key ID: 33112E6833C34679
7 changed files with 572 additions and 398 deletions

View file

@ -153,19 +153,33 @@ Commonly set:
Full key list in `internal/config/config.go`. 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 Host → guest file/directory copies, declared per-user in
VM start: `~/.config/banger/config.toml`:
| Host | Guest | ```toml
|------|-------| [[file_sync]]
| `~/.local/share/opencode/auth.json` | `/root/.local/share/opencode/auth.json` | host = "~/.local/share/opencode/auth.json"
| `~/.claude/.credentials.json` | `/root/.claude/.credentials.json` | guest = "~/.local/share/opencode/auth.json"
| `~/.pi/agent/auth.json` | `/root/.pi/agent/auth.json` |
Host-side changes take effect after the VM restarts. Session/history [[file_sync]]
directories are not copied. 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) ## Web UI (experimental)

View file

@ -5,6 +5,7 @@ import (
"crypto/rand" "crypto/rand"
"crypto/x509" "crypto/x509"
"encoding/pem" "encoding/pem"
"fmt"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
@ -19,19 +20,26 @@ import (
) )
type fileConfig struct { type fileConfig struct {
LogLevel string `toml:"log_level"` LogLevel string `toml:"log_level"`
WebListenAddr *string `toml:"web_listen_addr"` WebListenAddr *string `toml:"web_listen_addr"`
FirecrackerBin string `toml:"firecracker_bin"` FirecrackerBin string `toml:"firecracker_bin"`
SSHKeyPath string `toml:"ssh_key_path"` SSHKeyPath string `toml:"ssh_key_path"`
DefaultImageName string `toml:"default_image_name"` DefaultImageName string `toml:"default_image_name"`
AutoStopStaleAfter string `toml:"auto_stop_stale_after"` AutoStopStaleAfter string `toml:"auto_stop_stale_after"`
StatsPollInterval string `toml:"stats_poll_interval"` StatsPollInterval string `toml:"stats_poll_interval"`
MetricsPoll string `toml:"metrics_poll_interval"` MetricsPoll string `toml:"metrics_poll_interval"`
BridgeName string `toml:"bridge_name"` BridgeName string `toml:"bridge_name"`
BridgeIP string `toml:"bridge_ip"` BridgeIP string `toml:"bridge_ip"`
CIDR string `toml:"cidr"` CIDR string `toml:"cidr"`
TapPoolSize int `toml:"tap_pool_size"` TapPoolSize int `toml:"tap_pool_size"`
DefaultDNS string `toml:"default_dns"` 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) { func Load(layout paths.Layout) (model.DaemonConfig, error) {
@ -122,9 +130,95 @@ func Load(layout paths.Layout) (model.DaemonConfig, error) {
return cfg, err return cfg, err
} }
cfg.SSHKeyPath = sshKeyPath 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 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) { func resolveSSHKeyPath(layout paths.Layout, configured string) (string, error) {
configured = strings.TrimSpace(configured) configured = strings.TrimSpace(configured)
if configured != "" { if configured != "" {

View file

@ -3,6 +3,7 @@ package config
import ( import (
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
@ -115,3 +116,92 @@ func TestLoadAppliesLogLevelEnvOverride(t *testing.T) {
t.Fatalf("LogLevel = %q, want warn", cfg.LogLevel) 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)
}
})
}
}

View file

@ -210,13 +210,7 @@ func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model.
if err := d.ensureGitIdentityOnWorkDisk(ctx, vm); err != nil { if err := d.ensureGitIdentityOnWorkDisk(ctx, vm); err != nil {
return err return err
} }
if err := d.ensureOpencodeAuthOnWorkDisk(ctx, vm); err != nil { return d.runFileSync(ctx, vm)
return err
}
if err := d.ensureClaudeAuthOnWorkDisk(ctx, vm); err != nil {
return err
}
return d.ensurePiAuthOnWorkDisk(ctx, vm)
} }
func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) { func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) {

View file

@ -14,17 +14,8 @@ import (
) )
const ( const (
workDiskGitConfigRelativePath = ".gitconfig" workDiskGitConfigRelativePath = ".gitconfig"
workDiskOpencodeAuthDirRelativePath = ".local/share/opencode" hostGlobalGitIdentitySource = "git config --global"
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
) )
type gitIdentity struct { 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) return writeGitIdentity(ctx, runner, filepath.Join(workMount, workDiskGitConfigRelativePath), identity)
} }
func (d *Daemon) ensureOpencodeAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error { // runFileSync applies every [[file_sync]] entry from the daemon config
return d.ensureAuthFileOnWorkDisk( // to the VM's work disk. Missing host paths are skipped with a warn.
ctx, // Other errors abort the VM create (since the user explicitly asked
vm, // for the sync).
"syncing opencode auth", //
hostOpencodeAuthDefaultDisplayPath, // File entries: `install -o 0 -g 0 -m <mode>` (mode defaults to 0600).
resolveHostOpencodeAuthPath, // Directory entries: walked in Go — each file is installed with its
workDiskOpencodeAuthRelativePath, // source permissions, each subdir is mkdir'd. The entry's `mode`
d.warnOpencodeAuthSyncSkipped, // field is only honoured for file entries.
) func (d *Daemon) runFileSync(ctx context.Context, vm *model.VMRecord) error {
} if len(d.config.FileSync) == 0 {
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)
return nil return nil
} }
@ -178,61 +135,141 @@ func (d *Daemon) ensureAuthFileOnWorkDisk(ctx context.Context, vm *model.VMRecor
runner = system.NewRunner() runner = system.NewRunner()
} }
vmCreateStage(ctx, "prepare_work_disk", stageDetail) hostHome, err := os.UserHomeDir()
workMount, cleanupWork, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false) 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 { if err != nil {
return err 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 { info, err := os.Stat(hostChild)
return err 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
}
}
} }
return nil
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
} }
func resolveHostOpencodeAuthPath() (string, error) { // expandHostPath expands a leading "~/" against the host user's
return resolveHostAuthPath(workDiskOpencodeAuthRelativePath) // home. Already-absolute paths pass through unchanged.
} func expandHostPath(raw, home string) string {
raw = strings.TrimSpace(raw)
func resolveHostClaudeAuthPath() (string, error) { if strings.HasPrefix(raw, "~/") {
return resolveHostAuthPath(workDiskClaudeAuthRelativePath) return filepath.Join(home, strings.TrimPrefix(raw, "~/"))
}
func resolveHostPiAuthPath() (string, error) {
return resolveHostAuthPath(workDiskPiAuthRelativePath)
}
func resolveHostAuthPath(relativePath string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
} }
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) { 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 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 { if d.logger == nil || err == nil {
return return
} }
d.logger.Warn("guest opencode 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) 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())...)
} }
func (d *Daemon) warnGitIdentitySyncSkipped(vm model.VMRecord, source string, err error) { func (d *Daemon) warnGitIdentitySyncSkipped(vm model.VMRecord, source string, err error) {

View file

@ -923,300 +923,210 @@ func TestEnsureGitIdentityOnWorkDiskWarnsAndSkipsWhenHostIdentityIncomplete(t *t
} }
} }
func TestEnsureOpencodeAuthOnWorkDiskCopiesHostAuth(t *testing.T) { func TestRunFileSyncNoOpWhenConfigEmpty(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()
d := &Daemon{runner: &filesystemRunner{t: t}} d := &Daemon{runner: &filesystemRunner{t: t}}
vm := testVM("auth-sync", "image-auth-sync", "172.16.0.63") vm := testVM("no-sync", "image", "172.16.0.70")
vm.Runtime.WorkDiskPath = workDiskDir if err := d.runFileSync(context.Background(), &vm); err != nil {
t.Fatalf("runFileSync: %v", err)
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)
} }
} }
func TestEnsureOpencodeAuthOnWorkDiskReplacesExistingGuestAuth(t *testing.T) { func TestRunFileSyncCopiesFile(t *testing.T) {
homeDir := t.TempDir() homeDir := t.TempDir()
t.Setenv("HOME", homeDir) t.Setenv("HOME", homeDir)
hostAuthPath := filepath.Join(homeDir, workDiskOpencodeAuthRelativePath) srcPath := filepath.Join(homeDir, ".secrets", "token")
if err := os.MkdirAll(filepath.Dir(hostAuthPath), 0o755); err != nil { if err := os.MkdirAll(filepath.Dir(srcPath), 0o755); err != nil {
t.Fatalf("MkdirAll(host auth dir): %v", err) t.Fatal(err)
} }
hostAuth := []byte("{\"token\":\"fresh\"}\n") srcData := []byte(`{"token":"abc"}`)
if err := os.WriteFile(hostAuthPath, hostAuth, 0o600); err != nil { if err := os.WriteFile(srcPath, srcData, 0o600); err != nil {
t.Fatalf("WriteFile(host auth): %v", err) t.Fatal(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)
} }
workDisk := t.TempDir()
d := &Daemon{ d := &Daemon{
runner: &filesystemRunner{t: t}, 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 := testVM("sync-file", "image", "172.16.0.71")
vm.Runtime.WorkDiskPath = workDiskDir vm.Runtime.WorkDiskPath = workDisk
if err := d.runFileSync(context.Background(), &vm); err != nil {
if err := d.ensureOpencodeAuthOnWorkDisk(context.Background(), &vm); err != nil { t.Fatalf("runFileSync: %v", err)
t.Fatalf("ensureOpencodeAuthOnWorkDisk: %v", err)
} }
got, err := os.ReadFile(guestAuthPath) dst := filepath.Join(workDisk, ".secrets", "token")
got, err := os.ReadFile(dst)
if err != nil { if err != nil {
t.Fatalf("ReadFile(guest auth): %v", err) t.Fatal(err)
} }
if string(got) != string(original) { if string(got) != string(srcData) {
t.Fatalf("guest auth = %q, want preserved %q", string(got), string(original)) t.Fatalf("dst = %q, want %q", got, srcData)
} }
info, err := os.Stat(dst)
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")
if err != nil { if err != nil {
t.Fatalf("newDaemonLogger: %v", err) t.Fatal(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)
} }
if info.Mode().Perm() != 0o600 { 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() homeDir := t.TempDir()
t.Setenv("HOME", homeDir) t.Setenv("HOME", homeDir)
hostAuthPath := filepath.Join(homeDir, workDiskPiAuthRelativePath) srcPath := filepath.Join(homeDir, "script")
if err := os.MkdirAll(filepath.Dir(hostAuthPath), 0o755); err != nil { if err := os.WriteFile(srcPath, []byte("#!/bin/sh\nexit 0\n"), 0o600); err != nil {
t.Fatalf("MkdirAll(host auth dir): %v", err) t.Fatal(err)
}
hostAuth := []byte("{\"token\":\"pi\"}\n")
if err := os.WriteFile(hostAuthPath, hostAuth, 0o600); err != nil {
t.Fatalf("WriteFile(host auth): %v", err)
} }
workDiskDir := t.TempDir() workDisk := t.TempDir()
d := &Daemon{runner: &filesystemRunner{t: t}} d := &Daemon{
vm := testVM("pi-auth", "image-pi-auth", "172.16.0.68") runner: &filesystemRunner{t: t},
vm.Runtime.WorkDiskPath = workDiskDir config: model.DaemonConfig{
FileSync: []model.FileSyncEntry{
if err := d.ensurePiAuthOnWorkDisk(context.Background(), &vm); err != nil { {Host: "~/script", Guest: "~/bin/my-script", Mode: "0755"},
t.Fatalf("ensurePiAuthOnWorkDisk: %v", err) },
},
}
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) info, err := os.Stat(filepath.Join(workDisk, "bin", "my-script"))
got, err := os.ReadFile(guestAuthPath)
if err != nil { if err != nil {
t.Fatalf("ReadFile(guest auth): %v", err) t.Fatal(err)
} }
if string(got) != string(hostAuth) { if info.Mode().Perm() != 0o755 {
t.Fatalf("guest auth = %q, want %q", string(got), string(hostAuth)) t.Fatalf("mode = %v, want 0755", info.Mode().Perm())
} }
} }
func TestEnsurePiAuthOnWorkDiskWarnsAndSkipsWhenHostAuthMissing(t *testing.T) { func TestRunFileSyncSkipsMissingHostPath(t *testing.T) {
homeDir := t.TempDir() homeDir := t.TempDir()
t.Setenv("HOME", homeDir) 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 var buf bytes.Buffer
logger, _, err := newDaemonLogger(&buf, "info") logger, _, err := newDaemonLogger(&buf, "info")
if err != nil { if err != nil {
t.Fatalf("newDaemonLogger: %v", err) t.Fatal(err)
} }
workDisk := t.TempDir()
d := &Daemon{ d := &Daemon{
runner: &filesystemRunner{t: t}, runner: &filesystemRunner{t: t},
logger: logger, 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 := testVM("sync-missing", "image", "172.16.0.73")
vm.Runtime.WorkDiskPath = workDiskDir vm.Runtime.WorkDiskPath = workDisk
if err := d.runFileSync(context.Background(), &vm); err != nil {
if err := d.ensurePiAuthOnWorkDisk(context.Background(), &vm); err != nil { t.Fatalf("runFileSync: %v", err)
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))
} }
entries := parseLogEntries(t, buf.Bytes()) entries := parseLogEntries(t, buf.Bytes())
if !hasLogEntry(entries, map[string]string{ if !hasLogEntry(entries, map[string]string{
"msg": "guest pi auth sync skipped", "msg": "file_sync skipped",
"vm_name": vm.Name, "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]) return os.ReadFile(args[1])
case "install": case "install":
if len(args) != 5 || args[1] != "-m" { // Minimal install(1): expected forms are
return nil, fmt.Errorf("unexpected install args: %v", args) // install -m MODE SRC DST (5 args)
} // install -o 0 -g 0 -m MODE SRC DST (9 args, ignored owners)
mode, err := strconv.ParseUint(args[2], 8, 32) src, dst, mode, err := parseInstallArgs(args)
if err != nil { if err != nil {
return nil, err return nil, err
} }
data, err := os.ReadFile(args[3]) data, err := os.ReadFile(src)
if err != nil { if err != nil {
return nil, err 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, 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: default:
return nil, fmt.Errorf("unexpected sudo command: %v", args) 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 { func copyIntoDir(sourcePath, targetDir string) error {
targetDir = strings.TrimSuffix(targetDir, "/") targetDir = strings.TrimSuffix(targetDir, "/")
info, err := os.Stat(sourcePath) info, err := os.Stat(sourcePath)

View file

@ -65,6 +65,20 @@ type DaemonConfig struct {
TapPoolSize int TapPoolSize int
DefaultDNS string DefaultDNS string
DefaultImageName 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 { type Image struct {