diff --git a/README.md b/README.md index fe1636f..edd0b84 100644 --- a/README.md +++ b/README.md @@ -131,9 +131,6 @@ banger vm session logs planner --stream stderr banger vm session stop planner ``` -For ACP-aware host tooling: `banger vm acp ` bridges stdio to -guest `opencode acp` over SSH. - ## Config Config lives at `~/.config/banger/config.toml`. All keys optional. @@ -159,14 +156,14 @@ Host → guest file/directory copies, declared per-user in `~/.config/banger/config.toml`: ```toml -[[file_sync]] -host = "~/.local/share/opencode/auth.json" -guest = "~/.local/share/opencode/auth.json" - [[file_sync]] host = "~/.aws" # whole directory, recursive guest = "~/.aws" +[[file_sync]] +host = "~/.config/gh/hosts.yml" +guest = "~/.config/gh/hosts.yml" + [[file_sync]] host = "~/bin/my-script" guest = "~/bin/my-script" diff --git a/internal/cli/banger.go b/internal/cli/banger.go index fa313f2..393acbd 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -736,7 +736,6 @@ func newVMCommand() *cobra.Command { newVMActionCommand("delete", "Delete a VM", "vm.delete"), newVMSetCommand(), newVMSSHCommand(), - newVMACPCommand(), newVMWorkspaceCommand(), newVMSessionCommand(), newVMLogsCommand(), @@ -1140,27 +1139,6 @@ func newVMSSHCommand() *cobra.Command { } } -func newVMACPCommand() *cobra.Command { - var cwd string - cmd := &cobra.Command{ - Use: "acp ", - Short: "Bridge local stdio to guest opencode acp 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 newVMWorkspaceCommand() *cobra.Command { cmd := &cobra.Command{ Use: "workspace", @@ -2497,18 +2475,6 @@ 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 @@ -2544,30 +2510,6 @@ 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") @@ -3175,24 +3117,6 @@ 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, "'", `'"'"'`) + "'" } @@ -3530,8 +3454,6 @@ func vmCreateStageLabel(stage string) string { return "waiting for vsock agent" case "wait_guest_ready": return "waiting for guest services" - case "wait_opencode": - return "waiting for opencode" case "apply_dns": return "publishing dns" case "apply_nat": diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 469f790..aed211e 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -268,21 +268,6 @@ 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"}) @@ -516,8 +501,8 @@ func TestRunVMCreatePollsUntilDone(t *testing.T) { return api.VMCreateStatusResult{ Operation: api.VMCreateOperation{ ID: "op-1", - Stage: "wait_opencode", - Detail: "waiting for opencode on guest port 4096", + Stage: "wait_vsock_agent", + Detail: "waiting for guest vsock agent", }, }, nil } @@ -555,7 +540,7 @@ func TestVMCreateProgressRendererSuppressesDuplicateLines(t *testing.T) { renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"}) renderer.render(api.VMCreateOperation{Stage: "prepare_work_disk", Detail: "cloning work seed"}) - renderer.render(api.VMCreateOperation{Stage: "wait_opencode", Detail: "waiting for opencode on guest port 4096"}) + renderer.render(api.VMCreateOperation{Stage: "wait_vsock_agent", Detail: "waiting for guest vsock agent"}) lines := strings.Split(strings.TrimSpace(stderr.String()), "\n") if len(lines) != 2 { @@ -564,7 +549,7 @@ func TestVMCreateProgressRendererSuppressesDuplicateLines(t *testing.T) { if lines[0] != "[vm create] preparing work disk: cloning work seed" { t.Fatalf("first line = %q", lines[0]) } - if lines[1] != "[vm create] waiting for opencode: waiting for opencode on guest port 4096" { + if lines[1] != "[vm create] waiting for vsock agent: waiting for guest vsock agent" { t.Fatalf("second line = %q", lines[1]) } } @@ -1017,98 +1002,6 @@ 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") diff --git a/internal/daemon/capabilities.go b/internal/daemon/capabilities.go index eef39d5..c1bbd25 100644 --- a/internal/daemon/capabilities.go +++ b/internal/daemon/capabilities.go @@ -56,7 +56,6 @@ func (d *Daemon) registeredCapabilities() []vmCapability { } return []vmCapability{ workDiskCapability{}, - opencodeCapability{}, dnsCapability{}, natCapability{}, } diff --git a/internal/daemon/capabilities_test.go b/internal/daemon/capabilities_test.go index 13a6350..6a7be4e 100644 --- a/internal/daemon/capabilities_test.go +++ b/internal/daemon/capabilities_test.go @@ -144,13 +144,13 @@ func TestContributeHooksPopulateGuestAndMachineConfig(t *testing.T) { } } -func TestRegisteredCapabilitiesIncludeOpencode(t *testing.T) { +func TestRegisteredCapabilitiesInOrder(t *testing.T) { d := &Daemon{} var names []string for _, capability := range d.registeredCapabilities() { names = append(names, capability.Name()) } - want := []string{"work-disk", "opencode", "dns", "nat"} + want := []string{"work-disk", "dns", "nat"} if !reflect.DeepEqual(names, want) { t.Fatalf("capabilities = %v, want %v", names, want) } diff --git a/internal/daemon/doc.go b/internal/daemon/doc.go index 2a4c184..8d68090 100644 --- a/internal/daemon/doc.go +++ b/internal/daemon/doc.go @@ -58,7 +58,6 @@ // session_controller.go guestSessionController, sessionRegistry // ssh_client_config.go daemon-managed SSH client key material // workspace.go ExportVMWorkspace, PrepareVMWorkspace -// opencode.go opencode host-side helpers // // Host bootstrap (in this package): // diff --git a/internal/daemon/opencode.go b/internal/daemon/opencode.go deleted file mode 100644 index fb3b3bb..0000000 --- a/internal/daemon/opencode.go +++ /dev/null @@ -1,25 +0,0 @@ -package daemon - -import ( - "context" - "strings" - - "banger/internal/model" - "banger/internal/opencode" -) - -type opencodeCapability struct{} - -func (opencodeCapability) Name() string { return "opencode" } - -func (opencodeCapability) PostStart(ctx context.Context, d *Daemon, vm model.VMRecord, image model.Image) error { - if strings.TrimSpace(image.InitrdPath) == "" { - // Direct-boot images (OCI pulls) don't ship the opencode - // service — skip the readiness check so the VM isn't marked - // as error for lacking an opinionated add-on. - return nil - } - return opencode.WaitReady(ctx, d.logger, vm.Runtime.VSockPath, func(stage, detail string) { - vmCreateStage(ctx, stage, detail) - }) -} diff --git a/internal/opencode/opencode.go b/internal/opencode/opencode.go deleted file mode 100644 index 7a2af47..0000000 --- a/internal/opencode/opencode.go +++ /dev/null @@ -1,104 +0,0 @@ -package opencode - -import ( - "context" - "fmt" - "log/slog" - "strings" - "time" - - "banger/internal/vsockagent" -) - -const ( - Port = 4096 - Host = "0.0.0.0" - GuestBinaryPath = "/usr/local/bin/opencode" - ShimPath = "/root/.local/share/mise/shims/opencode" - ServiceName = "banger-opencode.service" - RunitServiceName = "banger-opencode" - ReadyTimeout = 45 * time.Second - pollInterval = 200 * time.Millisecond -) - -func ServiceUnit() string { - return fmt.Sprintf(`[Unit] -Description=Banger opencode server -After=network.target -RequiresMountsFor=/root - -[Service] -Type=simple -Environment=HOME=/root -WorkingDirectory=/root -ExecStart=%s serve --hostname %s --port %d -Restart=on-failure -RestartSec=1 - -[Install] -WantedBy=multi-user.target -`, GuestBinaryPath, Host, Port) -} - -func RunitRunScript() string { - return fmt.Sprintf(`#!/bin/sh -set -e -export HOME=/root -cd /root -exec %s serve --hostname %s --port %d -`, GuestBinaryPath, Host, Port) -} - -func Ready(listeners []vsockagent.PortListener) bool { - for _, listener := range listeners { - if strings.ToLower(strings.TrimSpace(listener.Proto)) != "tcp" { - continue - } - if listener.Port == Port { - return true - } - } - return false -} - -func WaitReady(ctx context.Context, logger *slog.Logger, socketPath string, report func(stage, detail string)) error { - return waitReady(ctx, logger, socketPath, ReadyTimeout, report) -} - -func waitReady(ctx context.Context, logger *slog.Logger, socketPath string, timeout time.Duration, report func(stage, detail string)) error { - waitCtx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - ticker := time.NewTicker(pollInterval) - defer ticker.Stop() - - var lastErr error - for { - portsCtx, portsCancel := context.WithTimeout(waitCtx, 3*time.Second) - listeners, err := vsockagent.Ports(portsCtx, logger, socketPath) - portsCancel() - if err == nil { - if Ready(listeners) { - return nil - } - if report != nil { - report("wait_opencode", fmt.Sprintf("waiting for opencode on guest port %d", Port)) - } - lastErr = fmt.Errorf("guest port %d is not listening yet", Port) - } else { - if report != nil { - report("wait_guest_ready", "waiting for guest services") - } - lastErr = err - } - - select { - case <-waitCtx.Done(): - if lastErr != nil { - return fmt.Errorf("opencode server did not become ready on guest port %d: %w", Port, lastErr) - } - return fmt.Errorf("opencode server did not become ready on guest port %d before timeout", Port) - case <-ticker.C: - } - } -} diff --git a/internal/opencode/opencode_test.go b/internal/opencode/opencode_test.go deleted file mode 100644 index 8855960..0000000 --- a/internal/opencode/opencode_test.go +++ /dev/null @@ -1,151 +0,0 @@ -package opencode - -import ( - "context" - "fmt" - "net" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "banger/internal/vsockagent" -) - -func TestServiceUnitContainsExpectedExecStart(t *testing.T) { - unit := ServiceUnit() - for _, snippet := range []string{ - "RequiresMountsFor=/root", - "WorkingDirectory=/root", - "Environment=HOME=/root", - "ExecStart=/usr/local/bin/opencode serve --hostname 0.0.0.0 --port 4096", - "WantedBy=multi-user.target", - } { - if !strings.Contains(unit, snippet) { - t.Fatalf("service unit missing snippet %q\nunit:\n%s", snippet, unit) - } - } -} - -func TestRunitRunScriptContainsExpectedExec(t *testing.T) { - script := RunitRunScript() - for _, snippet := range []string{ - "export HOME=/root", - "cd /root", - "exec /usr/local/bin/opencode serve --hostname 0.0.0.0 --port 4096", - } { - if !strings.Contains(script, snippet) { - t.Fatalf("runit script missing snippet %q\nscript:\n%s", snippet, script) - } - } -} - -func TestReadyMatchesTCPPort(t *testing.T) { - if Ready([]vsockagent.PortListener{{Proto: "udp", Port: Port}}) { - t.Fatal("udp listener should not satisfy readiness") - } - if Ready([]vsockagent.PortListener{{Proto: "tcp", Port: 8080}}) { - t.Fatal("wrong tcp port should not satisfy readiness") - } - if !Ready([]vsockagent.PortListener{{Proto: "tcp", Port: Port}}) { - t.Fatal("tcp listener on opencode port should satisfy readiness") - } -} - -func TestWaitReadyReturnsWhenPortIsListening(t *testing.T) { - socketPath := filepath.Join(t.TempDir(), "opencode.vsock") - listener, err := net.Listen("unix", socketPath) - if err != nil { - skipIfSocketRestricted(t, err) - t.Fatalf("listen: %v", err) - } - t.Cleanup(func() { - _ = listener.Close() - _ = os.Remove(socketPath) - }) - - serverDone := make(chan error, 1) - go func() { - conn, err := listener.Accept() - if err != nil { - serverDone <- err - return - } - defer conn.Close() - buf := make([]byte, 512) - n, err := conn.Read(buf) - if err != nil { - serverDone <- err - return - } - if got := string(buf[:n]); got != "CONNECT 42070\n" { - serverDone <- fmt.Errorf("unexpected connect message %q", got) - return - } - if _, err := conn.Write([]byte("OK 1\n")); err != nil { - serverDone <- err - return - } - reqBuf := make([]byte, 0, 512) - for { - n, err = conn.Read(buf) - if err != nil { - serverDone <- err - return - } - reqBuf = append(reqBuf, buf[:n]...) - if strings.Contains(string(reqBuf), "\r\n\r\n") { - break - } - } - if !strings.Contains(string(reqBuf), "GET /ports HTTP/1.1\r\n") { - serverDone <- fmt.Errorf("unexpected ports payload %q", string(reqBuf)) - return - } - body := []byte(`{"listeners":[{"proto":"tcp","bind_address":"0.0.0.0","port":4096}]}`) - _, err = conn.Write([]byte(fmt.Sprintf("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: %d\r\n\r\n%s", len(body), body))) - serverDone <- err - }() - - if err := waitReady(context.Background(), nil, socketPath, time.Second, nil); err != nil { - t.Fatalf("waitReady: %v", err) - } - if err := <-serverDone; err != nil { - t.Fatalf("server: %v", err) - } -} - -func TestWaitReadyReportsGuestServicesWhenPortsUnavailable(t *testing.T) { - t.Parallel() - - var reports []string - err := waitReady( - context.Background(), - nil, - filepath.Join(t.TempDir(), "missing.vsock"), - 50*time.Millisecond, - func(stage, detail string) { - reports = append(reports, stage+":"+detail) - }, - ) - if err == nil { - t.Fatal("waitReady() error = nil, want timeout") - } - if len(reports) == 0 { - t.Fatal("waitReady() did not report progress") - } - if got := reports[0]; got != "wait_guest_ready:waiting for guest services" { - t.Fatalf("first report = %q, want guest services wait", got) - } -} - -func skipIfSocketRestricted(t *testing.T, err error) { - t.Helper() - if err == nil { - return - } - if strings.Contains(strings.ToLower(err.Error()), "operation not permitted") { - t.Skipf("socket creation is restricted in this environment: %v", err) - } -} diff --git a/internal/webui/server_test.go b/internal/webui/server_test.go index ba6317e..9ee5db9 100644 --- a/internal/webui/server_test.go +++ b/internal/webui/server_test.go @@ -171,7 +171,7 @@ func TestVMShowPageRendersRunningActions(t *testing.T) { ports: api.VMPortsResult{ Name: "smth", Ports: []api.VMPort{ - {Proto: "tcp", Port: 4096, Endpoint: "http://172.16.0.2:4096", Process: "opencode"}, + {Proto: "tcp", Port: 4096, Endpoint: "http://172.16.0.2:4096", Process: "devserver"}, }, }, } @@ -189,7 +189,7 @@ func TestVMShowPageRendersRunningActions(t *testing.T) { t.Fatalf("body missing %q\n%s", want, body) } } - for _, unwanted := range []string{"opencode attach", "root@172.16.0.2"} { + for _, unwanted := range []string{"root@172.16.0.2"} { if strings.Contains(body, unwanted) { t.Fatalf("body unexpectedly contains %q\n%s", unwanted, body) }