Add guest sessions and agent VM defaults

Add daemon-backed workspace and guest-session primitives so host
orchestrators can prepare /root/repo, launch long-lived guest commands,
and attach to pipe-mode sessions over the local stdio mux bridge.

Persist richer session metadata and launch diagnostics, preflight guest
cwd/command requirements, make pipe-mode attach rehydratable from guest
state after daemon restart, and allow submodules when workspace prepare
runs in full_copy mode.

At the same time, stop vm run from auto-attaching opencode, make it
print next-step commands instead, and make glibc guest images more
agent-ready by installing node, opencode, claude, and pi while syncing
opencode/claude/pi auth files into work disks on VM start.

Validation:
- GOCACHE=/tmp/banger-gocache go test ./...
- make build
- banger vm workspace prepare --help
- banger vm session --help
- banger vm session start --help
- banger vm session attach --help
This commit is contained in:
Thales Maciel 2026-04-12 23:48:42 -03:00
parent 497e6dca3d
commit 37c4c091ec
No known key found for this signature in database
GPG key ID: 33112E6833C34679
18 changed files with 3212 additions and 405 deletions

View file

@ -29,6 +29,7 @@ import (
"banger/internal/model"
"banger/internal/paths"
"banger/internal/rpc"
"banger/internal/sessionstream"
"banger/internal/system"
"banger/internal/toolingplan"
"banger/internal/vmdns"
@ -50,15 +51,7 @@ var (
sshCmd.Stdin = stdin
return sshCmd.Run()
}
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
opencodeCmd := exec.CommandContext(ctx, "opencode", args...)
opencodeCmd.Stdout = stdout
opencodeCmd.Stderr = stderr
opencodeCmd.Stdin = stdin
return opencodeCmd.Run()
}
hostOpencodeAttachSupportedFunc = hostOpencodeAttachSupported
hostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) {
hostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
output, err := cmd.CombinedOutput()
if err == nil {
@ -93,6 +86,30 @@ var (
vmPortsFunc = func(ctx context.Context, socketPath, idOrName string) (api.VMPortsResult, error) {
return rpc.Call[api.VMPortsResult](ctx, socketPath, "vm.ports", api.VMRefParams{IDOrName: idOrName})
}
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
return rpc.Call[api.VMWorkspacePrepareResult](ctx, socketPath, "vm.workspace.prepare", params)
}
guestSessionStartFunc = func(ctx context.Context, socketPath string, params api.GuestSessionStartParams) (api.GuestSessionShowResult, error) {
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.start", params)
}
guestSessionGetFunc = func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error) {
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.get", params)
}
guestSessionListFunc = func(ctx context.Context, socketPath, idOrName string) (api.GuestSessionListResult, error) {
return rpc.Call[api.GuestSessionListResult](ctx, socketPath, "guest.session.list", api.VMRefParams{IDOrName: idOrName})
}
guestSessionStopFunc = func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error) {
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.stop", params)
}
guestSessionKillFunc = func(ctx context.Context, socketPath string, params api.GuestSessionRefParams) (api.GuestSessionShowResult, error) {
return rpc.Call[api.GuestSessionShowResult](ctx, socketPath, "guest.session.kill", params)
}
guestSessionLogsFunc = func(ctx context.Context, socketPath string, params api.GuestSessionLogsParams) (api.GuestSessionLogsResult, error) {
return rpc.Call[api.GuestSessionLogsResult](ctx, socketPath, "guest.session.logs", params)
}
guestSessionAttachBeginFunc = func(ctx context.Context, socketPath string, params api.GuestSessionAttachBeginParams) (api.GuestSessionAttachBeginResult, error) {
return rpc.Call[api.GuestSessionAttachBeginResult](ctx, socketPath, "guest.session.attach.begin", params)
}
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
return guest.WaitForSSH(ctx, address, privateKeyPath, interval)
}
@ -119,6 +136,7 @@ type vmRunRepoSpec struct {
HeadCommit string
CurrentBranch string
BranchName string
FromRef string
BaseCommit string
OriginURL string
GitUserName string
@ -128,22 +146,8 @@ type vmRunRepoSpec struct {
const vmRunShallowFetchDepth = 10
const vmRunToolingHarnessModel = "opencode/mimo-v2-pro-free"
const vmRunToolingHarnessTimeoutSeconds = 45
const vmRunToolingInstallTimeoutSeconds = 120
const vmRunToolingHarnessPrompt = `You are preparing a development VM for this repository.
Inspect the repository for developer tools and binaries that are clearly needed to work on it. Look at files like .mise.toml, .tool-versions, README/setup docs, CI config, task runners, scripts, and build manifests.
Rules:
- Use mise only for installs.
- Do not edit repository files.
- Prefer repo-declared versions first.
- If a tool is clearly required but not pinned, you may install a conservative guest-global tool with mise.
- Skip ambiguous installs instead of guessing.
- End with a short summary of what you installed and what you skipped.`
func NewBangerCommand() *cobra.Command {
root := &cobra.Command{
Use: "banger",
@ -464,6 +468,8 @@ func newVMCommand() *cobra.Command {
newVMSetCommand(),
newVMSSHCommand(),
newVMACPCommand(),
newVMWorkspaceCommand(),
newVMSessionCommand(),
newVMLogsCommand(),
newVMStatsCommand(),
newVMPortsCommand(),
@ -485,8 +491,13 @@ func newVMRunCommand() *cobra.Command {
)
cmd := &cobra.Command{
Use: "run [path]",
Short: "Create a repo-backed VM session and attach opencode",
Short: "Create repo-backed VM and print next steps",
Long: "Create a VM for a local git repository, prepare /root/repo inside the guest, start best-effort mise tooling bootstrap, and print manual access commands.",
Args: maxArgsUsage(1, "usage: banger vm run [path]"),
Example: strings.TrimSpace(`
banger vm run
banger vm run ../repo --name agent-box --branch feature/demo
`),
RunE: func(cmd *cobra.Command, args []string) error {
if cmd.Flags().Changed("branch") && strings.TrimSpace(branchName) == "" {
return errors.New("--branch requires a branch name")
@ -835,7 +846,7 @@ func newVMACPCommand() *cobra.Command {
var cwd string
cmd := &cobra.Command{
Use: "acp <id-or-name>",
Short: "Bridge ACP to a running VM over SSH",
Short: "Bridge local stdio to guest opencode acp 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())
@ -852,6 +863,393 @@ func newVMACPCommand() *cobra.Command {
return cmd
}
func newVMWorkspaceCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "workspace",
Short: "Manage repository workspaces inside a running VM",
RunE: helpNoArgs,
}
cmd.AddCommand(newVMWorkspacePrepareCommand())
return cmd
}
func newVMWorkspacePrepareCommand() *cobra.Command {
var guestPath string
var branchName string
var fromRef string
var mode string
var readOnly bool
cmd := &cobra.Command{
Use: "prepare <id-or-name> [path]",
Short: "Copy a local repo into a running VM",
Long: "Prepare a repository workspace from a local git checkout into a running VM. The default guest path is /root/repo and the default mode is shallow_overlay. Repositories with git submodules must use --mode full_copy.",
Args: minArgsUsage(1, "usage: banger vm workspace prepare <id-or-name> [path]"),
Example: strings.TrimSpace(`
banger vm workspace prepare devbox
banger vm workspace prepare devbox ../repo --guest-path /root/repo --readonly
banger vm workspace prepare devbox ../repo --mode full_copy
`),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
sourcePath := ""
if len(args) > 1 {
sourcePath = args[1]
}
resolvedPath, err := resolveVMRunSourcePath(sourcePath)
if err != nil {
return err
}
prepareFrom := ""
if strings.TrimSpace(branchName) != "" {
prepareFrom = fromRef
}
result, err := vmWorkspacePrepareFunc(cmd.Context(), layout.SocketPath, api.VMWorkspacePrepareParams{
IDOrName: args[0],
SourcePath: resolvedPath,
GuestPath: guestPath,
Branch: branchName,
From: prepareFrom,
Mode: mode,
ReadOnly: readOnly,
})
if err != nil {
return err
}
return printJSON(cmd.OutOrStdout(), result.Workspace)
},
}
cmd.Flags().StringVar(&guestPath, "guest-path", "/root/repo", "guest workspace path")
cmd.Flags().StringVar(&branchName, "branch", "", "create and switch to a new guest branch")
cmd.Flags().StringVar(&fromRef, "from", "HEAD", "base ref for --branch")
cmd.Flags().StringVar(&mode, "mode", string(model.WorkspacePrepareModeShallowOverlay), "workspace mode: shallow_overlay, full_copy, metadata_only")
cmd.Flags().BoolVar(&readOnly, "readonly", false, "make the prepared workspace read-only")
return cmd
}
func newVMSessionCommand() *cobra.Command {
cmd := &cobra.Command{
Use: "session",
Short: "Manage long-lived guest commands inside a VM",
Long: "Start, inspect, stop, and attach to daemon-managed guest commands. Pipe-mode sessions expose live stdio for interactive protocols. Attach is exclusive and currently uses a same-host local bridge.",
RunE: helpNoArgs,
}
cmd.AddCommand(
newVMSessionStartCommand(),
newVMSessionListCommand(),
newVMSessionShowCommand(),
newVMSessionLogsCommand(),
newVMSessionStopCommand(),
newVMSessionKillCommand(),
newVMSessionAttachCommand(),
)
return cmd
}
func newVMSessionStartCommand() *cobra.Command {
var name string
var cwd string
var stdinMode string
var envPairs []string
var tagPairs []string
var requiredCommands []string
cmd := &cobra.Command{
Use: "start <id-or-name> <command> [args...]",
Short: "Start a managed guest command",
Long: "Start a daemon-managed guest command. The daemon verifies that the guest working directory exists and that the requested command is present in guest PATH before launch. Use --stdin-mode pipe when you need live attach.",
Args: minArgsUsage(2, "usage: banger vm session start <id-or-name> [flags] -- <command> [args...]"),
Example: strings.TrimSpace(`
banger vm session start devbox --name planner --cwd /root/repo --stdin-mode pipe --require-command git -- pi --mode rpc --no-session
banger vm session start devbox --name shell --stdin-mode pipe -- bash -lc 'exec bash'
`),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
env, err := parseKeyValuePairs(envPairs)
if err != nil {
return err
}
tags, err := parseKeyValuePairs(tagPairs)
if err != nil {
return err
}
result, err := guestSessionStartFunc(cmd.Context(), layout.SocketPath, api.GuestSessionStartParams{
VMIDOrName: args[0],
Name: name,
Command: args[1],
Args: append([]string(nil), args[2:]...),
CWD: cwd,
Env: env,
StdinMode: stdinMode,
Tags: tags,
RequiredCommands: append([]string(nil), requiredCommands...),
})
if err != nil {
return err
}
if err := printGuestSessionSummary(cmd.OutOrStdout(), result.Session); err != nil {
return err
}
if result.Session.Status == model.GuestSessionStatusFailed && strings.TrimSpace(result.Session.LaunchMessage) != "" {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "warning: session failed at %s: %s\n", result.Session.LaunchStage, result.Session.LaunchMessage)
}
return nil
},
}
cmd.Flags().StringVar(&name, "name", "", "session name")
cmd.Flags().StringVar(&cwd, "cwd", "", "guest working directory; must already exist")
cmd.Flags().StringVar(&stdinMode, "stdin-mode", string(model.GuestSessionStdinClosed), "stdin mode: closed or pipe (pipe enables attach)")
cmd.Flags().StringArrayVar(&envPairs, "env", nil, "environment entry in KEY=VALUE form")
cmd.Flags().StringArrayVar(&tagPairs, "tag", nil, "session tag in KEY=VALUE form")
cmd.Flags().StringArrayVar(&requiredCommands, "require-command", nil, "extra guest command that must exist in PATH before launch; repeatable")
return cmd
}
func newVMSessionListCommand() *cobra.Command {
return &cobra.Command{
Use: "list <id-or-name>",
Short: "List managed guest commands for a VM",
Args: exactArgsUsage(1, "usage: banger vm session list <id-or-name>"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := guestSessionListFunc(cmd.Context(), layout.SocketPath, args[0])
if err != nil {
return err
}
return printGuestSessionTable(cmd.OutOrStdout(), result.Sessions)
},
}
}
func newVMSessionShowCommand() *cobra.Command {
return &cobra.Command{
Use: "show <id-or-name> <session>",
Short: "Show managed guest command details",
Args: exactArgsUsage(2, "usage: banger vm session show <id-or-name> <session>"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := guestSessionGetFunc(cmd.Context(), layout.SocketPath, api.GuestSessionRefParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
if err != nil {
return err
}
return printJSON(cmd.OutOrStdout(), result.Session)
},
}
}
func newVMSessionLogsCommand() *cobra.Command {
var stream string
var tailLines int
cmd := &cobra.Command{
Use: "logs <id-or-name> <session>",
Short: "Show stdout or stderr for a guest session",
Args: exactArgsUsage(2, "usage: banger vm session logs [--stream stdout|stderr] [-n LINES] <id-or-name> <session>"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := guestSessionLogsFunc(cmd.Context(), layout.SocketPath, api.GuestSessionLogsParams{VMIDOrName: args[0], SessionIDOrName: args[1], Stream: stream, TailLines: tailLines})
if err != nil {
return err
}
_, err = fmt.Fprint(cmd.OutOrStdout(), result.Content)
return err
},
}
cmd.Flags().StringVar(&stream, "stream", "stdout", "log stream to read")
cmd.Flags().IntVarP(&tailLines, "lines", "n", 200, "number of lines to tail")
return cmd
}
func newVMSessionStopCommand() *cobra.Command {
return &cobra.Command{
Use: "stop <id-or-name> <session>",
Short: "Send SIGTERM to a guest session",
Args: exactArgsUsage(2, "usage: banger vm session stop <id-or-name> <session>"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := guestSessionStopFunc(cmd.Context(), layout.SocketPath, api.GuestSessionRefParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
if err != nil {
return err
}
return printGuestSessionSummary(cmd.OutOrStdout(), result.Session)
},
}
}
func newVMSessionKillCommand() *cobra.Command {
return &cobra.Command{
Use: "kill <id-or-name> <session>",
Short: "Send SIGKILL to a guest session",
Args: exactArgsUsage(2, "usage: banger vm session kill <id-or-name> <session>"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := guestSessionKillFunc(cmd.Context(), layout.SocketPath, api.GuestSessionRefParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
if err != nil {
return err
}
return printGuestSessionSummary(cmd.OutOrStdout(), result.Session)
},
}
}
func newVMSessionAttachCommand() *cobra.Command {
return &cobra.Command{
Use: "attach <id-or-name> <session>",
Short: "Attach local stdio to an attachable guest session",
Long: "Attach local stdio to a pipe-mode session through a daemon-created local Unix socket bridge. Only one active attach is allowed at a time, and the client must run on the same host as the daemon.",
Args: exactArgsUsage(2, "usage: banger vm session attach <id-or-name> <session>"),
RunE: func(cmd *cobra.Command, args []string) error {
layout, _, err := ensureDaemon(cmd.Context())
if err != nil {
return err
}
result, err := guestSessionAttachBeginFunc(cmd.Context(), layout.SocketPath, api.GuestSessionAttachBeginParams{VMIDOrName: args[0], SessionIDOrName: args[1]})
if err != nil {
return err
}
socketPath := strings.TrimSpace(result.SocketPath)
if socketPath == "" && result.TransportKind == "unix_socket" {
socketPath = strings.TrimSpace(result.TransportTarget)
}
return runGuestSessionAttach(cmd.Context(), cmd.InOrStdin(), cmd.OutOrStdout(), cmd.ErrOrStderr(), socketPath)
},
}
}
func parseKeyValuePairs(values []string) (map[string]string, error) {
if len(values) == 0 {
return nil, nil
}
result := make(map[string]string, len(values))
for _, value := range values {
key, raw, ok := strings.Cut(value, "=")
if !ok || strings.TrimSpace(key) == "" {
return nil, fmt.Errorf("invalid key=value entry %q", value)
}
result[strings.TrimSpace(key)] = raw
}
return result, nil
}
func printGuestSessionSummary(out anyWriter, session model.GuestSession) error {
_, err := fmt.Fprintf(out, "%s\t%s\t%s\t%s\t%s\n", session.ID, session.Name, session.Status, session.Command, session.CWD)
return err
}
func printGuestSessionTable(out io.Writer, sessions []model.GuestSession) error {
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(tw, "ID\tNAME\tSTATUS\tATTACH\tCOMMAND\tCWD"); err != nil {
return err
}
for _, session := range sessions {
attach := "no"
if session.Attachable {
attach = "yes"
}
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\t%s\n", shortID(session.ID), session.Name, session.Status, attach, session.Command, session.CWD); err != nil {
return err
}
}
return tw.Flush()
}
func runGuestSessionAttach(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, socketPath string) error {
conn, err := (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
if err != nil {
return err
}
defer conn.Close()
writeErrCh := make(chan error, 1)
go func() {
writeErrCh <- streamGuestSessionAttachInput(conn, stdin)
}()
for {
channel, payload, err := sessionstream.ReadFrame(conn)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
if errors.Is(err, io.EOF) {
return nil
}
return err
}
switch channel {
case sessionstream.ChannelStdout:
if _, err := stdout.Write(payload); err != nil {
return err
}
case sessionstream.ChannelStderr:
if _, err := stderr.Write(payload); err != nil {
return err
}
case sessionstream.ChannelControl:
message, err := sessionstream.ReadControl(payload)
if err != nil {
return err
}
switch message.Type {
case "exit":
if message.ExitCode != nil && *message.ExitCode != 0 {
return fmt.Errorf("guest session exited with code %d", *message.ExitCode)
}
return nil
case "error":
if strings.TrimSpace(message.Error) == "" {
return errors.New("guest session attach failed")
}
return errors.New(message.Error)
}
}
select {
case err := <-writeErrCh:
if err != nil {
return err
}
default:
}
}
}
func streamGuestSessionAttachInput(conn net.Conn, stdin io.Reader) error {
if stdin == nil {
return sessionstream.WriteControl(conn, sessionstream.ControlMessage{Type: "eof"})
}
buffer := make([]byte, 32*1024)
for {
n, err := stdin.Read(buffer)
if n > 0 {
if writeErr := sessionstream.WriteFrame(conn, sessionstream.ChannelStdin, buffer[:n]); writeErr != nil {
return writeErr
}
}
if err != nil {
if errors.Is(err, io.EOF) {
return sessionstream.WriteControl(conn, sessionstream.ControlMessage{Type: "eof"})
}
return err
}
}
}
func newVMLogsCommand() *cobra.Command {
var follow bool
cmd := &cobra.Command{
@ -1532,7 +1930,6 @@ func validateSSHPrereqs(cfg model.DaemonConfig) error {
func validateVMRunPrereqs(cfg model.DaemonConfig) error {
checks := system.NewPreflight()
checks.RequireCommand("git", "install git")
checks.RequireCommand("opencode", "install opencode")
if strings.TrimSpace(cfg.SSHKeyPath) != "" {
checks.RequireFile(cfg.SSHKeyPath, "ssh private key", `set "ssh_key_path" or let banger create its default key`)
}
@ -1570,12 +1967,14 @@ func inspectVMRunRepo(ctx context.Context, rawPath, branchName, fromRef string)
}
baseCommit := headCommit
resolvedFromRef := ""
branchName = strings.TrimSpace(branchName)
if branchName != "" {
fromRef = strings.TrimSpace(fromRef)
if fromRef == "" {
return vmRunRepoSpec{}, errors.New("--from cannot be empty")
}
resolvedFromRef = fromRef
baseCommit, err = gitTrimmedOutput(ctx, repoRoot, "rev-parse", fromRef+"^{commit}")
if err != nil {
return vmRunRepoSpec{}, fmt.Errorf("resolve --from %q: %w", fromRef, err)
@ -1607,6 +2006,7 @@ func inspectVMRunRepo(ctx context.Context, rawPath, branchName, fromRef string)
HeadCommit: headCommit,
CurrentBranch: currentBranch,
BranchName: branchName,
FromRef: resolvedFromRef,
BaseCommit: baseCommit,
OriginURL: originURL,
GitUserName: gitUserName,
@ -1733,6 +2133,17 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st
if vmRef == "" {
vmRef = shortID(vm.ID)
}
progress.render("preparing guest workspace")
if _, err := vmWorkspacePrepareFunc(ctx, socketPath, api.VMWorkspacePrepareParams{
IDOrName: vmRef,
SourcePath: spec.SourcePath,
GuestPath: vmRunGuestDir(),
Branch: spec.BranchName,
From: spec.FromRef,
Mode: string(model.WorkspacePrepareModeShallowOverlay),
}); err != nil {
return fmt.Errorf("vm %q is running but workspace prepare failed: %w", vmRef, err)
}
sshAddress := net.JoinHostPort(vm.Runtime.GuestIP, "22")
progress.render("waiting for guest ssh")
if err := guestWaitForSSHFunc(ctx, sshAddress, cfg.SSHKeyPath, 250*time.Millisecond); err != nil {
@ -1743,16 +2154,13 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st
return fmt.Errorf("vm %q is running but guest ssh is unavailable: %w", vmRef, err)
}
defer client.Close()
if err := importVMRunRepoToGuest(ctx, client, spec, progress); err != nil {
return fmt.Errorf("vm %q is running but repo import failed: %w", vmRef, err)
}
if err := startVMRunToolingHarness(ctx, client, spec, progress); err != nil {
printVMRunWarning(stderr, fmt.Sprintf("tooling harness start failed: %v", err))
printVMRunWarning(stderr, fmt.Sprintf("guest tooling bootstrap start failed: %v", err))
}
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)
if progress != nil {
progress.render("printing next steps")
}
return nil
return printVMRunNextSteps(stdout, vm)
}
func importVMRunRepoToGuest(ctx context.Context, client vmRunGuestClient, spec vmRunRepoSpec, progress *vmRunProgressRenderer) error {
@ -1876,59 +2284,29 @@ func vmRunToolingHarnessPath(repoName string) string {
return filepath.ToSlash(filepath.Join("/tmp", "banger-vm-run-tooling-"+repoName+".sh"))
}
func vmRunToolingHarnessPromptPath(repoName string) string {
return filepath.ToSlash(filepath.Join("/tmp", "banger-vm-run-tooling-"+repoName+".prompt.txt"))
}
func vmRunToolingHarnessLogPath(repoName string) string {
return filepath.ToSlash(filepath.Join("/root/.cache/banger", "vm-run-tooling-"+repoName+".log"))
}
func startVMRunToolingHarness(ctx context.Context, client vmRunGuestClient, spec vmRunRepoSpec, progress *vmRunProgressRenderer) error {
if progress != nil {
progress.render("starting tooling harness")
progress.render("starting guest tooling bootstrap")
}
plan := buildVMRunToolingPlanFunc(ctx, spec.RepoRoot)
var uploadLog bytes.Buffer
if err := client.UploadFile(ctx, vmRunToolingHarnessPromptPath(spec.RepoName), 0o644, []byte(vmRunToolingHarnessPromptData(plan)), &uploadLog); err != nil {
return formatVMRunStepError("upload tooling harness prompt", err, uploadLog.String())
}
uploadLog.Reset()
if err := client.UploadFile(ctx, vmRunToolingHarnessPath(spec.RepoName), 0o755, []byte(vmRunToolingHarnessScript(spec, plan)), &uploadLog); err != nil {
return formatVMRunStepError("upload tooling harness", err, uploadLog.String())
return formatVMRunStepError("upload guest tooling bootstrap", err, uploadLog.String())
}
var launchLog bytes.Buffer
if err := client.RunScript(ctx, vmRunToolingHarnessLaunchScript(spec), &launchLog); err != nil {
return formatVMRunStepError("launch tooling harness", err, launchLog.String())
return formatVMRunStepError("launch guest tooling bootstrap", err, launchLog.String())
}
if progress != nil {
progress.render("tooling harness log: " + vmRunToolingHarnessLogPath(spec.RepoName))
progress.render("guest tooling log: " + vmRunToolingHarnessLogPath(spec.RepoName))
}
return nil
}
func vmRunToolingHarnessPromptData(plan toolingplan.Plan) string {
var prompt strings.Builder
prompt.WriteString(vmRunToolingHarnessPrompt)
lines := make([]string, 0, len(plan.RepoManagedTools)+len(plan.Steps)+len(plan.Skips))
for _, tool := range plan.RepoManagedTools {
lines = append(lines, fmt.Sprintf("- Repo already declares %s through mise", tool))
}
for _, step := range plan.Steps {
lines = append(lines, fmt.Sprintf("- Planned deterministic install: %s@%s from %s", step.Tool, step.Version, step.Source))
}
for _, skip := range plan.Skips {
lines = append(lines, fmt.Sprintf("- Deterministic skip: %s (%s)", skip.Target, skip.Reason))
}
if len(lines) == 0 {
lines = append(lines, "- No deterministic prepass actions were planned")
}
prompt.WriteString("\n\nDeterministic prepass summary:\n")
prompt.WriteString(strings.Join(lines, "\n"))
prompt.WriteString("\n\nDo not repeat the deterministic prepass work unless it clearly failed. Focus on the remaining gaps.\n")
return prompt.String()
}
func vmRunToolingHarnessScript(spec vmRunRepoSpec, plan toolingplan.Plan) string {
var script strings.Builder
script.WriteString("set -uo pipefail\n")
@ -1980,12 +2358,11 @@ func vmRunToolingHarnessScript(spec vmRunRepoSpec, plan toolingplan.Plan) string
script.WriteString("}\n")
script.WriteString("cd \"$DIR\" || { log \"missing repo directory: $DIR\"; exit 0; }\n")
script.WriteString("MISE_BIN=\"$(command -v mise || true)\"\n")
script.WriteString("OPENCODE_BIN=\"$(command -v opencode || true)\"\n")
script.WriteString("if [ -z \"$MISE_BIN\" ]; then log \"mise not found; skipping tooling harness\"; exit 0; fi\n")
script.WriteString("if [ -z \"$OPENCODE_BIN\" ]; then log \"opencode not found; skipping tooling harness\"; exit 0; fi\n")
fmt.Fprintf(&script, "PROMPT_FILE=%s\n", shellQuote(vmRunToolingHarnessPromptPath(spec.RepoName)))
script.WriteString("if [ ! -f \"$PROMPT_FILE\" ]; then log \"tooling prompt file missing: $PROMPT_FILE\"; exit 0; fi\n")
script.WriteString("log \"starting tooling harness in $DIR\"\n")
script.WriteString("if [ -z \"$MISE_BIN\" ]; then log \"mise not found; skipping guest tooling bootstrap\"; exit 0; fi\n")
script.WriteString("log \"starting guest tooling bootstrap in $DIR\"\n")
if len(plan.RepoManagedTools) > 0 {
fmt.Fprintf(&script, "log %s\n", shellQuote("repo-managed mise tools: "+strings.Join(plan.RepoManagedTools, ", ")))
}
script.WriteString("if [ -f .mise.toml ] || [ -f .tool-versions ]; then\n")
script.WriteString(" log \"running mise install from repo declarations\"\n")
script.WriteString(" run_best_effort \"$MISE_BIN\" install\n")
@ -2003,11 +2380,7 @@ func vmRunToolingHarnessScript(spec vmRunRepoSpec, plan toolingplan.Plan) string
if len(plan.Steps) > 0 {
script.WriteString("run_best_effort \"$MISE_BIN\" reshim\n")
}
fmt.Fprintf(&script, "MODEL=%s\n", shellQuote(vmRunToolingHarnessModel))
fmt.Fprintf(&script, "TIMEOUT_SECS=%d\n", vmRunToolingHarnessTimeoutSeconds)
script.WriteString("log \"running bounded opencode repo tooling inspection with $MODEL for up to ${TIMEOUT_SECS}s\"\n")
script.WriteString("run_bounded_best_effort \"$TIMEOUT_SECS\" bash -lc 'exec \"$1\" run --format json -m \"$2\" \"$(cat \"$3\")\"' _ \"$OPENCODE_BIN\" \"$MODEL\" \"$PROMPT_FILE\"\n")
script.WriteString("log \"tooling harness finished\"\n")
script.WriteString("log \"guest tooling bootstrap finished\"\n")
return script.String()
}
@ -2022,46 +2395,31 @@ func vmRunToolingHarnessLaunchScript(spec vmRunRepoSpec) string {
return script.String()
}
func runVMRunAttach(ctx context.Context, socketPath, vmRef string, cfg model.DaemonConfig, stdin io.Reader, stdout, stderr io.Writer, guestIP, guestDir string, progress *vmRunProgressRenderer) error {
guestIP = strings.TrimSpace(guestIP)
if guestIP == "" {
return errors.New("vm has no guest IP")
func printVMRunNextSteps(out io.Writer, vm model.VMRecord) error {
if out == nil {
return nil
}
supportsAttach, err := hostOpencodeAttachSupportedFunc(ctx)
if err != nil {
printVMRunWarning(stderr, fmt.Sprintf("could not detect host opencode attach support: %v", err))
vmRef := strings.TrimSpace(vm.Name)
if vmRef == "" {
vmRef = shortID(vm.ID)
}
if supportsAttach {
if progress != nil {
progress.render("attaching opencode")
}
return opencodeExecFunc(ctx, stdin, stdout, stderr, []string{
"attach",
"--dir", guestDir,
"http://" + net.JoinHostPort(guestIP, "4096"),
})
hostRef := strings.TrimSpace(vm.Runtime.DNSName)
if hostRef == "" {
hostRef = strings.TrimSpace(vm.Runtime.GuestIP)
}
if progress != nil {
progress.render("host opencode has no attach support; starting guest opencode over ssh")
}
sshArgs, err := sshCommandArgs(cfg, guestIP, []string{"bash", "-lc", fmt.Sprintf("cd %s && exec opencode .", shellQuote(guestDir))})
if err != nil {
return err
}
return runSSHSession(ctx, socketPath, vmRef, stdin, stdout, stderr, sshArgs)
}
func hostOpencodeAttachSupported(ctx context.Context) (bool, error) {
output, err := hostCommandOutputFunc(ctx, "opencode", "attach", "--help")
if err != nil {
return false, err
}
return opencodeAttachHelpOutputSupported(output), nil
}
func opencodeAttachHelpOutputSupported(output []byte) bool {
text := strings.ToLower(string(output))
return strings.Contains(text, "opencode attach")
guestDir := vmRunGuestDir()
_, err := fmt.Fprintf(out, `VM ready.
Name: %s
Host: %s
Repo: %s
Next:
banger vm ssh %s
opencode attach http://%s:4096 --dir %s
banger vm acp %s
banger vm ssh %s -- "cd %s && claude"
banger vm ssh %s -- "cd %s && pi"
`, vmRef, hostRef, guestDir, vmRef, hostRef, guestDir, vmRef, vmRef, guestDir, vmRef, guestDir)
return err
}
func formatVMRunStepError(action string, err error, log string) error {
@ -2098,6 +2456,7 @@ func (r *vmRunProgressRenderer) render(detail string) {
}
func formatVMRunProgress(detail string) string {
detail = strings.TrimSpace(detail)
if detail == "" {
return ""