Fix vm run guest repo path and add vm acp bridge

Normalize repo-backed guest checkouts to /root/repo so vm run, attach, and
follow-on guest tooling stop depending on the source repository name.

Add `banger vm acp [--cwd] <vm>` as an SSH stdio bridge to guest `opencode acp`,
defaulting to /root/repo when that checkout exists and falling back to /root.
Update the README and CLI coverage around the fixed guest path and ACP command.

Validation: go test ./internal/cli, go test ./..., make build.
This commit is contained in:
Thales Maciel 2026-04-01 19:42:00 -03:00
parent dbc70643c3
commit 5f89c07fc0
No known key found for this signature in database
GPG key ID: 33112E6833C34679
3 changed files with 210 additions and 12 deletions

View file

@ -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/<repo-name>`, 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 <vm-name>` 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

View file

@ -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 <id-or-name>",
Short: "Bridge ACP to a running VM over SSH",
Args: exactArgsUsage(1, "usage: banger vm acp [--cwd PATH] <id-or-name>"),
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, &copyLog); 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, "'", `'"'"'`) + "'"
}

View file

@ -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 {