diff --git a/README.md b/README.md index c7e0809..558f5f1 100644 --- a/README.md +++ b/README.md @@ -120,7 +120,9 @@ Start a repo-backed VM session and attach `opencode` automatically: ./build/bin/banger vm run ../some-repo --branch feature/alpine --from HEAD ``` -`vm run` resolves the enclosing git repository, creates a VM, copies a git checkout plus current tracked and untracked non-ignored files into `/root/`, starts a best-effort guest tooling harness that inspects the repo and installs clearly-needed tools with `mise`, and then prefers a host-side `opencode attach` session when the local client supports it. Older host opencode clients fall back to starting `opencode` inside the guest over SSH. The harness runs asynchronously and logs its output inside the guest. +`vm run` resolves the enclosing git repository, creates a VM, copies a git checkout plus current tracked and untracked non-ignored files into `/root/repo`, starts a best-effort guest tooling harness that inspects the repo and installs clearly-needed tools with `mise`, and then prefers a host-side `opencode attach` session when the local client supports it. Older host opencode clients fall back to starting `opencode` inside the guest over SSH. The harness runs asynchronously and logs its output inside the guest. + +For ACP-aware host tools, `./build/bin/banger vm acp ` bridges stdio to guest `opencode acp` over SSH. It uses `/root/repo` when that checkout exists, otherwise `/root`, and `--cwd` lets you override the guest working directory explicitly. ## Web UI diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 49aa74f..0c7cf4d 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -74,6 +74,9 @@ var ( vmHealthFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMHealthResult, error) { return rpc.Call[api.VMHealthResult](ctx, socketPath, "vm.health", api.VMRefParams{IDOrName: idOrName}) } + vmSSHFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMSSHResult, error) { + return rpc.Call[api.VMSSHResult](ctx, socketPath, "vm.ssh", api.VMRefParams{IDOrName: idOrName}) + } daemonPingFunc = func(ctx context.Context, socketPath string) (api.PingResult, error) { return rpc.Call[api.PingResult](ctx, socketPath, "ping", api.Empty{}) } @@ -460,6 +463,7 @@ func newVMCommand() *cobra.Command { newVMActionCommand("delete", "Delete a VM", "vm.delete"), newVMSetCommand(), newVMSSHCommand(), + newVMACPCommand(), newVMLogsCommand(), newVMStatsCommand(), newVMPortsCommand(), @@ -814,7 +818,7 @@ func newVMSSHCommand() *cobra.Command { if err := validateSSHPrereqs(cfg); err != nil { return err } - result, err := rpc.Call[api.VMSSHResult](cmd.Context(), layout.SocketPath, "vm.ssh", api.VMRefParams{IDOrName: args[0]}) + result, err := vmSSHFunc(cmd.Context(), layout.SocketPath, args[0]) if err != nil { return err } @@ -827,6 +831,27 @@ func newVMSSHCommand() *cobra.Command { } } +func newVMACPCommand() *cobra.Command { + var cwd string + cmd := &cobra.Command{ + Use: "acp ", + Short: "Bridge ACP to a running VM over SSH", + Args: exactArgsUsage(1, "usage: banger vm acp [--cwd PATH] "), + RunE: func(cmd *cobra.Command, args []string) error { + layout, cfg, err := ensureDaemon(cmd.Context()) + if err != nil { + return err + } + if err := validateSSHPrereqs(cfg); err != nil { + return err + } + return runVMACP(cmd.Context(), layout.SocketPath, cfg, cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), args[0], cwd) + }, + } + cmd.Flags().StringVar(&cwd, "cwd", "", "guest working directory for opencode acp") + return cmd +} + func newVMLogsCommand() *cobra.Command { var follow bool cmd := &cobra.Command{ @@ -1424,6 +1449,18 @@ func runSSHSession(ctx context.Context, socketPath, vmRef string, stdin io.Reade return sshErr } +func runVMACP(ctx context.Context, socketPath string, cfg model.DaemonConfig, stdin io.Reader, stdout, stderr io.Writer, idOrName, cwd string) error { + result, err := vmSSHFunc(ctx, socketPath, idOrName) + if err != nil { + return err + } + sshArgs, err := sshACPCommandArgs(cfg, result.GuestIP, vmACPRemoteCommand(cwd)) + if err != nil { + return err + } + return sshExecFunc(ctx, stdin, stdout, stderr, sshArgs) +} + func shouldCheckSSHReminder(err error) bool { if err == nil { return true @@ -1459,6 +1496,30 @@ func sshCommandArgs(cfg model.DaemonConfig, guestIP string, extra []string) ([]s return args, nil } +func sshACPCommandArgs(cfg model.DaemonConfig, guestIP, remoteCommand string) ([]string, error) { + if guestIP == "" { + return nil, errors.New("vm has no guest IP") + } + args := []string{"-T", "-F", "/dev/null"} + if cfg.SSHKeyPath != "" { + args = append(args, "-i", cfg.SSHKeyPath) + } + args = append( + args, + "-o", "IdentitiesOnly=yes", + "-o", "BatchMode=yes", + "-o", "PreferredAuthentications=publickey", + "-o", "PasswordAuthentication=no", + "-o", "KbdInteractiveAuthentication=no", + "-o", "StrictHostKeyChecking=no", + "-o", "UserKnownHostsFile=/dev/null", + "-o", "LogLevel=ERROR", + "root@"+guestIP, + "bash", "-lc", remoteCommand, + ) + return args, nil +} + func validateSSHPrereqs(cfg model.DaemonConfig) error { checks := system.NewPreflight() checks.RequireCommand("ssh", "install openssh-client") @@ -1688,7 +1749,7 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st if err := startVMRunToolingHarness(ctx, client, spec, progress); err != nil { printVMRunWarning(stderr, fmt.Sprintf("tooling harness start failed: %v", err)) } - if err := runVMRunAttach(ctx, socketPath, vmRef, cfg, stdin, stdout, stderr, vm.Runtime.GuestIP, vmRunGuestDir(spec.RepoName), progress); err != nil { + if err := runVMRunAttach(ctx, socketPath, vmRef, cfg, stdin, stdout, stderr, vm.Runtime.GuestIP, vmRunGuestDir(), progress); err != nil { return fmt.Errorf("vm %q is running but opencode attach failed: %w", vmRef, err) } return nil @@ -1707,7 +1768,7 @@ func importVMRunRepoToGuest(ctx context.Context, client vmRunGuestClient, spec v progress.render("copying repo metadata to guest") } var copyLog bytes.Buffer - remoteCommand := fmt.Sprintf("rm -rf %s && mkdir -p %s && tar -o -C %s --strip-components=1 -xf -", shellQuote(vmRunGuestDir(spec.RepoName)), shellQuote(vmRunGuestDir(spec.RepoName)), shellQuote(vmRunGuestDir(spec.RepoName))) + remoteCommand := fmt.Sprintf("rm -rf %s && mkdir -p %s && tar -o -C %s --strip-components=1 -xf -", shellQuote(vmRunGuestDir()), shellQuote(vmRunGuestDir()), shellQuote(vmRunGuestDir())) if err := client.StreamTar(ctx, repoCopyDir, remoteCommand, ©Log); err != nil { return formatVMRunStepError("copy guest git metadata", err, copyLog.String()) } @@ -1722,7 +1783,7 @@ func importVMRunRepoToGuest(ctx context.Context, client vmRunGuestClient, spec v progress.render("overlaying host working tree") } var overlayLog bytes.Buffer - remoteCommand = fmt.Sprintf("tar -o -C %s --strip-components=1 -xf -", shellQuote(vmRunGuestDir(spec.RepoName))) + remoteCommand = fmt.Sprintf("tar -o -C %s --strip-components=1 -xf -", shellQuote(vmRunGuestDir())) if err := client.StreamTarEntries(ctx, spec.RepoRoot, spec.OverlayPaths, remoteCommand, &overlayLog); err != nil { return formatVMRunStepError("overlay host working tree", err, overlayLog.String()) } @@ -1785,7 +1846,7 @@ func runHostCommand(ctx context.Context, name string, args ...string) error { } func vmRunCheckoutScript(spec vmRunRepoSpec) string { - guestDir := vmRunGuestDir(spec.RepoName) + guestDir := vmRunGuestDir() var script strings.Builder script.WriteString("set -euo pipefail\n") fmt.Fprintf(&script, "DIR=%s\n", shellQuote(guestDir)) @@ -1806,11 +1867,12 @@ func vmRunCheckoutScript(spec vmRunRepoSpec) string { return script.String() } -func vmRunGuestDir(repoName string) string { - return filepath.ToSlash(filepath.Join("/root", repoName)) +func vmRunGuestDir() string { + return "/root/repo" } func vmRunToolingHarnessPath(repoName string) string { + return filepath.ToSlash(filepath.Join("/tmp", "banger-vm-run-tooling-"+repoName+".sh")) } @@ -1870,7 +1932,7 @@ func vmRunToolingHarnessPromptData(plan toolingplan.Plan) string { func vmRunToolingHarnessScript(spec vmRunRepoSpec, plan toolingplan.Plan) string { var script strings.Builder script.WriteString("set -uo pipefail\n") - fmt.Fprintf(&script, "DIR=%s\n", shellQuote(vmRunGuestDir(spec.RepoName))) + fmt.Fprintf(&script, "DIR=%s\n", shellQuote(vmRunGuestDir())) script.WriteString("export PATH=/usr/local/bin:/root/.local/share/mise/shims:$PATH\n") script.WriteString("if [ -f /etc/profile.d/mise.sh ]; then . /etc/profile.d/mise.sh || true; fi\n") script.WriteString("log() { printf '%s\\n' \"$*\"; }\n") @@ -2051,6 +2113,24 @@ func printVMRunWarning(out io.Writer, detail string) { _, _ = fmt.Fprintln(out, "[vm run] warning: "+detail) } +func vmACPRemoteCommand(cwd string) string { + var script strings.Builder + script.WriteString("set -euo pipefail\n") + if strings.TrimSpace(cwd) != "" { + fmt.Fprintf(&script, "DIR=%s\n", shellQuote(cwd)) + } else { + fmt.Fprintf(&script, "REPO_DIR=%s\n", shellQuote(vmRunGuestDir())) + fmt.Fprintf(&script, "DEFAULT_DIR=%s\n", shellQuote("/root")) + script.WriteString(`if [ -d "$REPO_DIR" ]; then DIR="$REPO_DIR"; else DIR="$DEFAULT_DIR"; fi +`) + } + script.WriteString(`cd "$DIR" +`) + script.WriteString(`exec opencode acp --cwd "$DIR" +`) + return script.String() +} + func shellQuote(value string) string { return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" } diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index d625555..3c24330 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -233,6 +233,21 @@ func TestVMRunFlagsExist(t *testing.T) { } } +func TestVMACPFlagsExist(t *testing.T) { + root := NewBangerCommand() + vm, _, err := root.Find([]string{"vm"}) + if err != nil { + t.Fatalf("find vm: %v", err) + } + acp, _, err := vm.Find([]string{"acp"}) + if err != nil { + t.Fatalf("find acp: %v", err) + } + if acp.Flags().Lookup("cwd") == nil { + t.Fatal("missing flag \"cwd\"") + } +} + func TestVMCreateFlagsShowStaticDefaults(t *testing.T) { root := NewBangerCommand() vm, _, err := root.Find([]string{"vm"}) @@ -967,6 +982,98 @@ func TestSSHCommandArgs(t *testing.T) { } } +func TestRunVMACPBridgesOverSSH(t *testing.T) { + origVMSSH := vmSSHFunc + origSSHExec := sshExecFunc + t.Cleanup(func() { + vmSSHFunc = origVMSSH + sshExecFunc = origSSHExec + }) + + vmSSHFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMSSHResult, error) { + if socketPath != "/tmp/bangerd.sock" { + t.Fatalf("socketPath = %q, want /tmp/bangerd.sock", socketPath) + } + if idOrName != "devbox" { + t.Fatalf("idOrName = %q, want devbox", idOrName) + } + return api.VMSSHResult{Name: "devbox", GuestIP: "172.16.0.2"}, nil + } + + var gotArgs []string + var gotStdin string + sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error { + gotArgs = append([]string(nil), args...) + data, err := io.ReadAll(stdin) + if err != nil { + t.Fatalf("ReadAll(stdin): %v", err) + } + gotStdin = string(data) + return nil + } + + if err := runVMACP( + context.Background(), + "/tmp/bangerd.sock", + model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"}, + strings.NewReader("client stream"), + &bytes.Buffer{}, + &bytes.Buffer{}, + "devbox", + "", + ); err != nil { + t.Fatalf("runVMACP: %v", err) + } + + if gotStdin != "client stream" { + t.Fatalf("stdin = %q, want client stream", gotStdin) + } + joined := strings.Join(gotArgs, " ") + for _, want := range []string{ + "-T", + "-F /dev/null", + "-i /tmp/id_ed25519", + "-o LogLevel=ERROR", + "root@172.16.0.2", + "bash -lc", + } { + if !strings.Contains(joined, want) { + t.Fatalf("ssh args = %q, want %q", joined, want) + } + } + remoteCommand := gotArgs[len(gotArgs)-1] + if !strings.Contains(remoteCommand, `exec opencode acp --cwd "$DIR"`) { + t.Fatalf("remote command = %q, want ACP exec", remoteCommand) + } + if !strings.Contains(remoteCommand, "REPO_DIR='/root/repo'") { + t.Fatalf("remote command = %q, want repo fallback", remoteCommand) + } +} + +func TestVMACPRemoteCommandDefaultsToRepoThenRoot(t *testing.T) { + got := vmACPRemoteCommand("") + for _, want := range []string{ + "REPO_DIR='/root/repo'", + "DEFAULT_DIR='/root'", + `if [ -d "$REPO_DIR" ]; then DIR="$REPO_DIR"; else DIR="$DEFAULT_DIR"; fi`, + `exec opencode acp --cwd "$DIR"`, + } { + if !strings.Contains(got, want) { + t.Fatalf("vmACPRemoteCommand() = %q, want %q", got, want) + } + } +} + +func TestVMACPRemoteCommandUsesExplicitCWD(t *testing.T) { + got := vmACPRemoteCommand("/workspace/project") + if !strings.Contains(got, "DIR='/workspace/project'") { + t.Fatalf("vmACPRemoteCommand() = %q, want explicit cwd", got) + } + if strings.Contains(got, "REPO_DIR=") { + t.Fatalf("vmACPRemoteCommand() = %q, want no repo fallback", got) + } +} + func TestValidateSSHPrereqs(t *testing.T) { dir := t.TempDir() keyPath := filepath.Join(dir, "id_ed25519") @@ -1156,6 +1263,7 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) { Runtime: model.VMRuntime{ State: model.VMStateRunning, GuestIP: "172.16.0.2", + DNSName: "devbox.vm", }, } vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) { @@ -1716,6 +1824,12 @@ func TestVMRunCheckoutScriptSkipsRepoGitIdentityWhenIncomplete(t *testing.T) { } } +func TestVMRunGuestDirIsFixed(t *testing.T) { + if got := vmRunGuestDir(); got != "/root/repo" { + t.Fatalf("vmRunGuestDir() = %q, want /root/repo", got) + } +} + func TestNewBangerdCommandRejectsArgs(t *testing.T) { cmd := NewBangerdCommand() cmd.SetArgs([]string{"extra"}) @@ -1951,12 +2065,14 @@ func (c *testVMRunGuestClient) StreamTar(ctx context.Context, sourceDir, remoteC func (c *testVMRunGuestClient) RunScript(ctx context.Context, script string, logWriter io.Writer) error { c.runScriptCalls++ - if c.runScriptCalls == 1 { + switch c.runScriptCalls { + case 1: c.script = script return c.checkoutErr + default: + c.launchScript = script + return c.launchErr } - c.launchScript = script - return c.launchErr } func (c *testVMRunGuestClient) StreamTarEntries(ctx context.Context, sourceDir string, entries []string, remoteCommand string, logWriter io.Writer) error {