package daemon import ( "context" "errors" "fmt" "os" "path/filepath" "strings" "banger/internal/guest" "banger/internal/model" "banger/internal/system" ) 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 ) type gitIdentity struct { Name string Email string } func (d *Daemon) ensureAuthorizedKeyOnWorkDisk(ctx context.Context, vm *model.VMRecord, image model.Image, prep workDiskPreparation) error { fingerprint, err := guest.AuthorizedPublicKeyFingerprint(d.config.SSHKeyPath) if err != nil { return fmt.Errorf("derive authorized ssh key fingerprint: %w", err) } if prep.ClonedFromSeed && image.SeededSSHPublicKeyFingerprint != "" && image.SeededSSHPublicKeyFingerprint == fingerprint { vmCreateStage(ctx, "prepare_work_disk", "using seeded SSH access") return nil } publicKey, err := guest.AuthorizedPublicKey(d.config.SSHKeyPath) if err != nil { return fmt.Errorf("derive authorized ssh key: %w", err) } vmCreateStage(ctx, "prepare_work_disk", "repairing SSH access on work disk") 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 } sshDir := filepath.Join(workMount, ".ssh") if _, err := d.runner.RunSudo(ctx, "mkdir", "-p", sshDir); err != nil { return err } if _, err := d.runner.RunSudo(ctx, "chmod", "700", sshDir); err != nil { return err } authorizedKeysPath := filepath.Join(sshDir, "authorized_keys") existing, err := d.runner.RunSudo(ctx, "cat", authorizedKeysPath) if err != nil { existing = nil } merged := mergeAuthorizedKey(existing, publicKey) tmpFile, err := os.CreateTemp("", "banger-authorized-keys-*") if err != nil { return err } tmpPath := tmpFile.Name() if _, err := tmpFile.Write(merged); err != nil { _ = tmpFile.Close() _ = os.Remove(tmpPath) return err } if err := tmpFile.Close(); err != nil { _ = os.Remove(tmpPath) return err } defer os.Remove(tmpPath) if _, err := d.runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authorizedKeysPath); err != nil { return err } if prep.ClonedFromSeed && image.Managed { vmCreateStage(ctx, "prepare_work_disk", "refreshing managed work seed") if err := d.refreshManagedWorkSeedFingerprint(ctx, image, fingerprint); err != nil { return err } } return nil } func (d *Daemon) ensureGitIdentityOnWorkDisk(ctx context.Context, vm *model.VMRecord) error { runner := d.runner if runner == nil { runner = system.NewRunner() } identity, err := resolveHostGlobalGitIdentity(ctx, runner) if err != nil { d.warnGitIdentitySyncSkipped(*vm, hostGlobalGitIdentitySource, err) return nil } vmCreateStage(ctx, "prepare_work_disk", "syncing git identity") workMount, cleanupWork, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false) if err != nil { return err } defer cleanupWork() if err := d.flattenNestedWorkHome(ctx, workMount); err != nil { return err } 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) return nil } runner := d.runner if runner == nil { runner = system.NewRunner() } vmCreateStage(ctx, "prepare_work_disk", stageDetail) workMount, cleanupWork, err := system.MountTempDir(ctx, 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, 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) { 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 } return filepath.Join(home, relativePath), nil } func resolveHostGlobalGitIdentity(ctx context.Context, runner system.CommandRunner) (gitIdentity, error) { name, err := gitConfigValue(ctx, runner, nil, "user.name") if err != nil { return gitIdentity{}, err } if name == "" { return gitIdentity{}, errors.New("host git user.name is empty") } email, err := gitConfigValue(ctx, runner, nil, "user.email") if err != nil { return gitIdentity{}, err } if email == "" { return gitIdentity{}, errors.New("host git user.email is empty") } return gitIdentity{Name: name, Email: email}, nil } func gitConfigValue(ctx context.Context, runner system.CommandRunner, extraArgs []string, key string) (string, error) { args := []string{"config"} args = append(args, extraArgs...) args = append(args, "--default", "", "--get", key) out, err := runner.Run(ctx, "git", args...) if err != nil { return "", err } return strings.TrimSpace(string(out)), nil } func writeGitIdentity(ctx context.Context, runner system.CommandRunner, gitConfigPath string, identity gitIdentity) error { existing, err := runner.RunSudo(ctx, "cat", gitConfigPath) if err != nil { existing = nil } tmpFile, err := os.CreateTemp("", "banger-gitconfig-*") if err != nil { return err } tmpPath := tmpFile.Name() if _, err := tmpFile.Write(existing); err != nil { _ = tmpFile.Close() _ = os.Remove(tmpPath) return err } if err := tmpFile.Close(); err != nil { _ = os.Remove(tmpPath) return err } defer os.Remove(tmpPath) if _, err := runner.Run(ctx, "git", "config", "--file", tmpPath, "user.name", identity.Name); err != nil { return err } if _, err := runner.Run(ctx, "git", "config", "--file", tmpPath, "user.email", identity.Email); err != nil { return err } _, err = runner.RunSudo(ctx, "install", "-m", "644", tmpPath, gitConfigPath) return err } 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 (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) { if d.logger == nil || err == nil { return } d.logger.Warn("guest git identity sync skipped", append(vmLogAttrs(vm), "source", source, "error", err.Error())...) } func mergeAuthorizedKey(existing, managed []byte) []byte { managedLine := strings.TrimSpace(string(managed)) if managedLine == "" { return append([]byte(nil), existing...) } lines := strings.Split(strings.ReplaceAll(string(existing), "\r\n", "\n"), "\n") out := make([]string, 0, len(lines)+1) found := false for _, line := range lines { line = strings.TrimRight(line, "\r") trimmed := strings.TrimSpace(line) if trimmed == "" { continue } if trimmed == managedLine { found = true } out = append(out, line) } if !found { out = append(out, managedLine) } return []byte(strings.Join(out, "\n") + "\n") }