From 8bcc767824da631d950baf48ae2969812cbaa565 Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sat, 21 Mar 2026 22:36:13 -0300 Subject: [PATCH] Sync host opencode auth into guest work disks Refresh guest opencode auth from the host at VM start so guest opencode can reuse the local login without baking secrets into managed images. Reuse the existing work-disk preparation path to copy ~/.local/share/opencode/auth.json into /root/.local/share/opencode/auth.json with mode 0600, and warn and skip when the host file is missing or unreadable so any existing guest auth stays in place. Add daemon coverage for copy, replacement, and warn-and-skip cases, document the restart behavior in the README, and validate with go test ./... plus make build. Existing VMs pick the new auth up on their next restart. --- README.md | 2 + internal/daemon/capabilities.go | 5 +- internal/daemon/vm.go | 70 +++++++++++++ internal/daemon/vm_test.go | 180 ++++++++++++++++++++++++++++++++ 4 files changed, 256 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 869f814..eea367d 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,8 @@ Provisioned images include: - `opencode` - a default guest `opencode` service on `0.0.0.0:4096` +If host `~/.local/share/opencode/auth.json` exists, `banger` syncs it into the guest at `/root/.local/share/opencode/auth.json` on VM start. Changes on the host take effect after the VM is restarted. + From the host: ```bash diff --git a/internal/daemon/capabilities.go b/internal/daemon/capabilities.go index d2ec524..78031e3 100644 --- a/internal/daemon/capabilities.go +++ b/internal/daemon/capabilities.go @@ -204,7 +204,10 @@ func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model. if err != nil { return err } - return d.ensureAuthorizedKeyOnWorkDisk(ctx, vm, image, prep) + if err := d.ensureAuthorizedKeyOnWorkDisk(ctx, vm, image, prep); err != nil { + return err + } + return d.ensureOpencodeAuthOnWorkDisk(ctx, vm) } func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) { diff --git a/internal/daemon/vm.go b/internal/daemon/vm.go index 9ed25cd..afb34ad 100644 --- a/internal/daemon/vm.go +++ b/internal/daemon/vm.go @@ -31,6 +31,12 @@ var ( vsockReadyPoll = 200 * time.Millisecond ) +const ( + workDiskOpencodeAuthDirRelativePath = ".local/share/opencode" + workDiskOpencodeAuthRelativePath = workDiskOpencodeAuthDirRelativePath + "/auth.json" + hostOpencodeAuthDefaultDisplayPath = "~/" + workDiskOpencodeAuthRelativePath +) + func (d *Daemon) CreateVM(ctx context.Context, params api.VMCreateParams) (vm model.VMRecord, err error) { d.mu.Lock() defer d.mu.Unlock() @@ -927,6 +933,70 @@ func (d *Daemon) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VM return nil } +func (d *Daemon) ensureOpencodeAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error { + hostAuthPath, err := resolveHostOpencodeAuthPath() + if err != nil { + d.warnOpencodeAuthSyncSkipped(*vm, hostOpencodeAuthDefaultDisplayPath, err) + return nil + } + authData, err := os.ReadFile(hostAuthPath) + if err != nil { + d.warnOpencodeAuthSyncSkipped(*vm, hostAuthPath, err) + return nil + } + + vmCreateStage(ctx, "prepare_work_disk", "syncing opencode auth") + workMount, cleanupWork, err := system.MountTempDir(ctx, d.runner, vm.Runtime.WorkDiskPath, false) + if err != nil { + return err + } + defer cleanupWork() + + if err := d.flattenNestedWorkHome(ctx, workMount); err != nil { + return err + } + + authDir := filepath.Join(workMount, workDiskOpencodeAuthDirRelativePath) + if _, err := d.runner.RunSudo(ctx, "mkdir", "-p", authDir); err != nil { + return err + } + authPath := filepath.Join(workMount, workDiskOpencodeAuthRelativePath) + + tmpFile, err := os.CreateTemp("", "banger-opencode-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 = d.runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authPath) + return err +} + +func resolveHostOpencodeAuthPath() (string, error) { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, workDiskOpencodeAuthRelativePath), nil +} + +func (d *Daemon) warnOpencodeAuthSyncSkipped(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 mergeAuthorizedKey(existing, managed []byte) []byte { managedLine := strings.TrimSpace(string(managed)) if managedLine == "" { diff --git a/internal/daemon/vm_test.go b/internal/daemon/vm_test.go index 0dfb223..a3ddc76 100644 --- a/internal/daemon/vm_test.go +++ b/internal/daemon/vm_test.go @@ -1,6 +1,7 @@ package daemon import ( + "bytes" "context" "crypto/rand" "crypto/rsa" @@ -807,6 +808,185 @@ func TestEnsureAuthorizedKeyOnWorkDiskRepairsNestedRootLayout(t *testing.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() + 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) + } +} + +func TestEnsureOpencodeAuthOnWorkDiskReplacesExistingGuestAuth(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("{\"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) + } + + d := &Daemon{ + runner: &filesystemRunner{t: t}, + logger: logger, + } + 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) + } + + 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": 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 { + 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 TestCreateVMRejectsNonPositiveCPUAndMemory(t *testing.T) { d := &Daemon{} if _, err := d.CreateVM(context.Background(), api.VMCreateParams{VCPUCount: ptr(0)}); err == nil || !strings.Contains(err.Error(), "vcpu must be a positive integer") {