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

@ -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 != "" {

View file

@ -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)
}
})
}
}