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:
parent
497e6dca3d
commit
37c4c091ec
18 changed files with 3212 additions and 405 deletions
|
|
@ -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 ""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue