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

@ -113,17 +113,45 @@ Create and use a VM:
`vm create` stays synchronous by default, but on a TTY it now shows live progress until the VM is fully ready.
Start a repo-backed VM session and attach `opencode` automatically:
Start a repo-backed VM session:
```bash
./build/bin/banger vm run
./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`, 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 bootstrap that only uses `mise`, prints next-step commands, and exits. It does not auto-attach `opencode` anymore. The bootstrap runs asynchronously and logs its output inside the guest.
After `vm run`, use one of:
```bash
./build/bin/banger vm ssh <vm-name>
opencode attach http://<vm-name>.vm:4096 --dir /root/repo
./build/bin/banger vm acp <vm-name>
./build/bin/banger vm ssh <vm-name> -- "cd /root/repo && claude"
./build/bin/banger vm ssh <vm-name> -- "cd /root/repo && pi"
```
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.
If you want reusable orchestration primitives instead of the `vm run` convenience flow, use the daemon-backed workspace and session commands directly:
```bash
./build/bin/banger vm workspace prepare <vm-name>
./build/bin/banger vm workspace prepare <vm-name> ../other-repo --guest-path /root/repo --readonly
./build/bin/banger vm session start <vm-name> --name planner --cwd /root/repo --stdin-mode pipe -- pi --mode rpc --no-session
./build/bin/banger vm session list <vm-name>
./build/bin/banger vm session attach <vm-name> planner
./build/bin/banger vm session logs <vm-name> planner --stream stderr
./build/bin/banger vm session stop <vm-name> planner
```
`vm workspace prepare` materializes a local git checkout into a running VM. The default guest path is `/root/repo` and the default mode is a shallow metadata copy plus tracked and untracked non-ignored overlay. Repositories with git submodules must use `--mode full_copy`; the metadata-based modes still reject them.
`vm session start` creates a daemon-managed long-lived guest command. The daemon preflights that the requested guest `cwd` exists and that the main command, plus any repeated `--require-command` entries, exist in guest `PATH` before launch. Use `--stdin-mode pipe` when you need live `attach`; otherwise use the default detached mode and inspect sessions with `list`, `show`, `logs`, `stop`, and `kill`.
`vm session attach` is currently exclusive and same-host only. The daemon exposes a local Unix socket bridge using `stdio_mux_v1`, so only one active attach is allowed at a time. Pipe-mode sessions keep enough guest-side state for the daemon to rebuild that bridge after a daemon restart.
## Web UI
`bangerd` serves a local web UI by default at:
@ -144,15 +172,25 @@ web_listen_addr = ""
## Guest Services
Provisioned images include:
Provisioned glibc-backed images include:
- `banger-vsock-agent`
- guest networking bootstrap
- `mise`
- `opencode`
- `claude`
- `pi`
- a default guest `opencode` service on `0.0.0.0:4096`
If host `~/.local/share/opencode/auth.json` exists, `banger` syncs it into the guest at `/root/.local/share/opencode/auth.json` on VM start. Changes on the host take effect after the VM is restarted.
Alpine currently remains `opencode`-only.
If these host auth files exist, `banger` syncs them into the guest on VM start:
- `~/.local/share/opencode/auth.json` -> `/root/.local/share/opencode/auth.json`
- `~/.claude/.credentials.json` -> `/root/.claude/.credentials.json`
- `~/.pi/agent/auth.json` -> `/root/.pi/agent/auth.json`
Changes on the host take effect after the VM is restarted. Session/history directories are not copied.
From the host:

View file

@ -150,6 +150,73 @@ type VMPortsResult struct {
Ports []VMPort `json:"ports"`
}
type GuestSessionStartParams struct {
VMIDOrName string `json:"vm_id_or_name"`
Name string `json:"name,omitempty"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
CWD string `json:"cwd,omitempty"`
Env map[string]string `json:"env,omitempty"`
StdinMode string `json:"stdin_mode,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
RequiredCommands []string `json:"required_commands,omitempty"`
}
type GuestSessionRefParams struct {
VMIDOrName string `json:"vm_id_or_name"`
SessionIDOrName string `json:"session_id_or_name"`
}
type GuestSessionLogsParams struct {
VMIDOrName string `json:"vm_id_or_name"`
SessionIDOrName string `json:"session_id_or_name"`
Stream string `json:"stream,omitempty"`
TailLines int `json:"tail_lines,omitempty"`
}
type GuestSessionAttachBeginParams struct {
VMIDOrName string `json:"vm_id_or_name"`
SessionIDOrName string `json:"session_id_or_name"`
}
type GuestSessionListResult struct {
Sessions []model.GuestSession `json:"sessions"`
}
type GuestSessionShowResult struct {
Session model.GuestSession `json:"session"`
}
type GuestSessionLogsResult struct {
Session model.GuestSession `json:"session"`
Stream string `json:"stream"`
Path string `json:"path,omitempty"`
Content string `json:"content,omitempty"`
}
type GuestSessionAttachBeginResult struct {
Session model.GuestSession `json:"session"`
AttachID string `json:"attach_id"`
TransportKind string `json:"transport_kind"`
TransportTarget string `json:"transport_target"`
SocketPath string `json:"socket_path,omitempty"`
StreamFormat string `json:"stream_format"`
}
type VMWorkspacePrepareParams struct {
IDOrName string `json:"id_or_name"`
SourcePath string `json:"source_path"`
GuestPath string `json:"guest_path,omitempty"`
Branch string `json:"branch,omitempty"`
From string `json:"from,omitempty"`
Mode string `json:"mode,omitempty"`
ReadOnly bool `json:"readonly,omitempty"`
}
type VMWorkspacePrepareResult struct {
Workspace model.WorkspacePrepareResult `json:"workspace"`
}
type ImageBuildParams struct {
Name string `json:"name,omitempty"`
FromImage string `json:"from_image,omitempty"`

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 ""

View file

@ -1232,7 +1232,7 @@ func TestInspectVMRunRepoRejectsSubmodules(t *testing.T) {
}
}
func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
func TestRunVMRunCreatesImportsAndPrintsNextSteps(t *testing.T) {
repoRoot := t.TempDir()
repoCopyDir := filepath.Join(t.TempDir(), "repo-copy")
@ -1243,8 +1243,7 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
origGuestDial := guestDialFunc
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
origBuildVMRunToolingPlan := buildVMRunToolingPlanFunc
origOpencodeExec := opencodeExecFunc
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
origVMWorkspacePrepare := vmWorkspacePrepareFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
vmCreateStatusFunc = origStatus
@ -1253,8 +1252,7 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
guestDialFunc = origGuestDial
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
buildVMRunToolingPlanFunc = origBuildVMRunToolingPlan
opencodeExecFunc = origOpencodeExec
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
vmWorkspacePrepareFunc = origVMWorkspacePrepare
})
vm := model.VMRecord{
@ -1310,22 +1308,21 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
}
return repoCopyDir, func() {}, nil
}
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
return true, nil
var workspaceParams api.VMWorkspacePrepareParams
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
workspaceParams = params
return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil
}
buildVMRunToolingPlanFunc = func(context.Context, string) toolingplan.Plan {
return toolingplan.Plan{
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
RepoManagedTools: []string{"go"},
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
}
}
var attachArgs []string
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
attachArgs = append([]string(nil), args...)
return nil
}
spec := vmRunRepoSpec{
SourcePath: repoRoot,
RepoRoot: repoRoot,
RepoName: "repo",
HeadCommit: "deadbeef",
@ -1336,13 +1333,15 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
GitUserEmail: "repo@example.com",
OverlayPaths: []string{"tracked.txt", "nested/keep.txt"},
}
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runVMRun(
context.Background(),
"/tmp/bangerd.sock",
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
strings.NewReader(""),
&bytes.Buffer{},
&bytes.Buffer{},
&stdout,
&stderr,
api.VMCreateParams{Name: "devbox"},
spec,
)
@ -1365,29 +1364,20 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
if dialKeyPath != waitKeyPath {
t.Fatalf("dialKeyPath = %q, want %q", dialKeyPath, waitKeyPath)
}
if fakeClient.tarSourceDir != repoCopyDir {
t.Fatalf("tarSourceDir = %q, want %q", fakeClient.tarSourceDir, repoCopyDir)
if workspaceParams.IDOrName != "devbox" {
t.Fatalf("workspaceParams.IDOrName = %q, want devbox", workspaceParams.IDOrName)
}
if fakeClient.tarCommand != "rm -rf '/root/repo' && mkdir -p '/root/repo' && tar -o -C '/root/repo' --strip-components=1 -xf -" {
t.Fatalf("tarCommand = %q", fakeClient.tarCommand)
if workspaceParams.SourcePath != repoRoot {
t.Fatalf("workspaceParams.SourcePath = %q, want %q", workspaceParams.SourcePath, repoRoot)
}
if len(fakeClient.uploads) != 2 {
t.Fatalf("uploads = %d, want 2", len(fakeClient.uploads))
if workspaceParams.GuestPath != "/root/repo" {
t.Fatalf("workspaceParams.GuestPath = %q, want /root/repo", workspaceParams.GuestPath)
}
if fakeClient.uploads[0].path != vmRunToolingHarnessPromptPath("repo") {
t.Fatalf("prompt upload path = %q, want %q", fakeClient.uploads[0].path, vmRunToolingHarnessPromptPath("repo"))
if workspaceParams.Mode != string(model.WorkspacePrepareModeShallowOverlay) {
t.Fatalf("workspaceParams.Mode = %q", workspaceParams.Mode)
}
if fakeClient.uploads[0].mode != 0o644 {
t.Fatalf("prompt upload mode = %v, want 0644", fakeClient.uploads[0].mode)
}
if !strings.Contains(string(fakeClient.uploads[0].data), `Do not edit repository files.`) {
t.Fatalf("prompt upload data = %q, want prompt body", string(fakeClient.uploads[0].data))
}
if !strings.Contains(string(fakeClient.uploads[0].data), `Planned deterministic install: go@1.25.0 from go.mod`) {
t.Fatalf("prompt upload data = %q, want deterministic install summary", string(fakeClient.uploads[0].data))
}
if !strings.Contains(string(fakeClient.uploads[0].data), `Deterministic skip: python (no .python-version)`) {
t.Fatalf("prompt upload data = %q, want deterministic skip summary", string(fakeClient.uploads[0].data))
if len(fakeClient.uploads) != 1 {
t.Fatalf("uploads = %d, want 1", len(fakeClient.uploads))
}
if fakeClient.uploadPath != vmRunToolingHarnessPath("repo") {
t.Fatalf("uploadPath = %q, want %q", fakeClient.uploadPath, vmRunToolingHarnessPath("repo"))
@ -1395,23 +1385,17 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
if fakeClient.uploadMode != 0o755 {
t.Fatalf("uploadMode = %v, want 0755", fakeClient.uploadMode)
}
if !strings.Contains(string(fakeClient.uploadData), `repo-managed mise tools: go`) {
t.Fatalf("uploadData = %q, want repo-managed tool log", string(fakeClient.uploadData))
}
if !strings.Contains(string(fakeClient.uploadData), `run_best_effort "$MISE_BIN" install`) {
t.Fatalf("uploadData = %q, want mise install best-effort step", string(fakeClient.uploadData))
}
if !strings.Contains(string(fakeClient.uploadData), fmt.Sprintf(`INSTALL_TIMEOUT_SECS=%d`, vmRunToolingInstallTimeoutSeconds)) {
t.Fatalf("uploadData = %q, want deterministic install timeout", string(fakeClient.uploadData))
}
if !strings.Contains(string(fakeClient.uploadData), `deterministic install: go@1.25.0 (go.mod)`) {
t.Fatalf("uploadData = %q, want deterministic install log", string(fakeClient.uploadData))
t.Fatalf("uploadData = %q, want repo mise install step", string(fakeClient.uploadData))
}
if !strings.Contains(string(fakeClient.uploadData), `run_bounded_best_effort "$INSTALL_TIMEOUT_SECS" "$MISE_BIN" use -g --pin 'go@1.25.0'`) {
t.Fatalf("uploadData = %q, want deterministic go install step", string(fakeClient.uploadData))
}
if !strings.Contains(string(fakeClient.uploadData), `deterministic skip: python (no .python-version)`) {
t.Fatalf("uploadData = %q, want deterministic skip log", string(fakeClient.uploadData))
}
if !strings.Contains(string(fakeClient.uploadData), `run_best_effort "$MISE_BIN" reshim`) {
t.Fatalf("uploadData = %q, want deterministic reshim step", string(fakeClient.uploadData))
if strings.Contains(string(fakeClient.uploadData), `opencode run`) {
t.Fatalf("uploadData = %q, want no opencode harness run", string(fakeClient.uploadData))
}
if !strings.Contains(fakeClient.launchScript, `nohup bash "$HELPER" >"$LOG" 2>&1 </dev/null &`) {
t.Fatalf("launchScript = %q, want nohup launcher", fakeClient.launchScript)
@ -1419,33 +1403,21 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
if !strings.Contains(fakeClient.launchScript, vmRunToolingHarnessLogPath("repo")) {
t.Fatalf("launchScript = %q, want tooling harness log path", fakeClient.launchScript)
}
if !strings.Contains(fakeClient.script, `git -C "$DIR" checkout -B 'feature' 'cafebabe'`) {
t.Fatalf("script = %q, want guest branch checkout", fakeClient.script)
}
if !strings.Contains(fakeClient.script, `find "$DIR" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +`) {
t.Fatalf("script = %q, want guest worktree reset", fakeClient.script)
}
if !strings.Contains(fakeClient.script, `git config --global --add safe.directory "$DIR"`) {
t.Fatalf("script = %q, want guest safe.directory config", fakeClient.script)
}
if !strings.Contains(fakeClient.script, `git -C "$DIR" config user.name 'Repo User'`) {
t.Fatalf("script = %q, want guest repo user.name config", fakeClient.script)
}
if !strings.Contains(fakeClient.script, `git -C "$DIR" config user.email 'repo@example.com'`) {
t.Fatalf("script = %q, want guest repo user.email config", fakeClient.script)
}
if fakeClient.streamSourceDir != repoRoot {
t.Fatalf("streamSourceDir = %q, want %q", fakeClient.streamSourceDir, repoRoot)
}
if !reflect.DeepEqual(fakeClient.streamEntries, spec.OverlayPaths) {
t.Fatalf("streamEntries = %v, want %v", fakeClient.streamEntries, spec.OverlayPaths)
}
if fakeClient.streamCommand != "tar -o -C '/root/repo' --strip-components=1 -xf -" {
t.Fatalf("streamCommand = %q", fakeClient.streamCommand)
}
wantAttach := []string{"attach", "--dir", "/root/repo", "http://172.16.0.2:4096"}
if !reflect.DeepEqual(attachArgs, wantAttach) {
t.Fatalf("attachArgs = %v, want %v", attachArgs, wantAttach)
output := stdout.String()
for _, want := range []string{
"VM ready.",
"Name: devbox",
"Host: devbox.vm",
"Repo: /root/repo",
"banger vm ssh devbox",
"opencode attach http://devbox.vm:4096 --dir /root/repo",
"banger vm acp devbox",
`banger vm ssh devbox -- "cd /root/repo && claude"`,
`banger vm ssh devbox -- "cd /root/repo && pi"`,
} {
if !strings.Contains(output, want) {
t.Fatalf("stdout = %q, want %q", output, want)
}
}
if !fakeClient.closed {
t.Fatal("guest client should be closed")
@ -1459,8 +1431,7 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
origWaitForSSH := guestWaitForSSHFunc
origGuestDial := guestDialFunc
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
origOpencodeExec := opencodeExecFunc
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
origVMWorkspacePrepare := vmWorkspacePrepareFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
vmCreateStatusFunc = origStatus
@ -1468,8 +1439,7 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
guestWaitForSSHFunc = origWaitForSSH
guestDialFunc = origGuestDial
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
opencodeExecFunc = origOpencodeExec
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
vmWorkspacePrepareFunc = origVMWorkspacePrepare
})
vm := model.VMRecord{
@ -1509,20 +1479,18 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
return t.TempDir(), func() {}, nil
}
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
return true, nil
}
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
return nil
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil
}
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runVMRun(
context.Background(),
"/tmp/bangerd.sock",
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
strings.NewReader(""),
&bytes.Buffer{},
&stdout,
&stderr,
api.VMCreateParams{Name: "devbox"},
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
@ -1533,19 +1501,19 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
output := stderr.String()
for _, want := range []string{
"[vm run] preparing guest workspace",
"[vm run] waiting for guest ssh",
"[vm run] preparing shallow repo",
"[vm run] copying repo metadata to guest",
"[vm run] preparing guest checkout",
"[vm run] overlaying host working tree",
"[vm run] starting tooling harness",
"[vm run] tooling harness log: /root/.cache/banger/vm-run-tooling-repo.log",
"[vm run] attaching opencode",
"[vm run] starting guest tooling bootstrap",
"[vm run] guest tooling log: /root/.cache/banger/vm-run-tooling-repo.log",
"[vm run] printing next steps",
} {
if !strings.Contains(output, want) {
t.Fatalf("stderr = %q, want %q", output, want)
}
}
if strings.Contains(output, "[vm run] attaching opencode") {
t.Fatalf("stderr = %q, want no auto-attach progress", output)
}
}
func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
@ -1555,8 +1523,7 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
origWaitForSSH := guestWaitForSSHFunc
origGuestDial := guestDialFunc
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
origOpencodeExec := opencodeExecFunc
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
origVMWorkspacePrepare := vmWorkspacePrepareFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
vmCreateStatusFunc = origStatus
@ -1564,8 +1531,7 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
guestWaitForSSHFunc = origWaitForSSH
guestDialFunc = origGuestDial
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
opencodeExecFunc = origOpencodeExec
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
vmWorkspacePrepareFunc = origVMWorkspacePrepare
})
vm := model.VMRecord{
@ -1597,22 +1563,18 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
return t.TempDir(), func() {}, nil
}
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
return true, nil
}
attachCalled := false
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
attachCalled = true
return nil
vmWorkspacePrepareFunc = func(ctx context.Context, socketPath string, params api.VMWorkspacePrepareParams) (api.VMWorkspacePrepareResult, error) {
return api.VMWorkspacePrepareResult{Workspace: model.WorkspacePrepareResult{VMID: vm.ID, GuestPath: "/root/repo"}}, nil
}
var stdout bytes.Buffer
var stderr bytes.Buffer
err := runVMRun(
context.Background(),
"/tmp/bangerd.sock",
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
strings.NewReader(""),
&bytes.Buffer{},
&stdout,
&stderr,
api.VMCreateParams{Name: "devbox"},
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
@ -1620,147 +1582,38 @@ func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
if err != nil {
t.Fatalf("runVMRun: %v", err)
}
if !attachCalled {
t.Fatal("opencode attach should still run when tooling harness launch fails")
if !strings.Contains(stderr.String(), "[vm run] warning: guest tooling bootstrap start failed: launch guest tooling bootstrap") {
t.Fatalf("stderr = %q, want tooling bootstrap warning", stderr.String())
}
if !strings.Contains(stderr.String(), "[vm run] warning: tooling harness start failed: launch tooling harness: launch failed") {
t.Fatalf("stderr = %q, want tooling harness warning", stderr.String())
}
}
func TestRunVMRunFallsBackToGuestOpencodeWhenHostAttachUnsupported(t *testing.T) {
repoRoot := t.TempDir()
origBegin := vmCreateBeginFunc
origStatus := vmCreateStatusFunc
origCancel := vmCreateCancelFunc
origWaitForSSH := guestWaitForSSHFunc
origGuestDial := guestDialFunc
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
origOpencodeExec := opencodeExecFunc
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
origSSHExec := sshExecFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
vmCreateStatusFunc = origStatus
vmCreateCancelFunc = origCancel
guestWaitForSSHFunc = origWaitForSSH
guestDialFunc = origGuestDial
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
opencodeExecFunc = origOpencodeExec
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
sshExecFunc = origSSHExec
})
vm := model.VMRecord{
ID: "vm-id",
Name: "devbox",
Runtime: model.VMRuntime{
State: model.VMStateRunning,
GuestIP: "172.16.0.2",
},
}
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
return api.VMCreateBeginResult{Operation: api.VMCreateOperation{ID: "op-1", Stage: "ready", Detail: "vm is ready", Done: true, Success: true, VM: &vm}}, nil
}
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
t.Fatal("vmCreateStatusFunc should not be called")
return api.VMCreateStatusResult{}, nil
}
vmCreateCancelFunc = func(context.Context, string, string) error {
t.Fatal("vmCreateCancelFunc should not be called")
return nil
}
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
return nil
}
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
return &testVMRunGuestClient{}, nil
}
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
return t.TempDir(), func() {}, nil
}
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
return false, nil
}
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
t.Fatalf("opencodeExecFunc should not be called when host attach is unsupported: %v", args)
return nil
}
var sshArgs []string
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
sshArgs = append([]string(nil), args...)
return nil
}
var stderr bytes.Buffer
err := runVMRun(
context.Background(),
"/tmp/bangerd.sock",
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
strings.NewReader(""),
&bytes.Buffer{},
&stderr,
api.VMCreateParams{Name: "devbox"},
vmRunRepoSpec{RepoRoot: repoRoot, RepoName: "repo", HeadCommit: "deadbeef"},
)
if err != nil {
t.Fatalf("runVMRun: %v", err)
}
if len(sshArgs) < 3 {
t.Fatalf("sshArgs = %v, want fallback SSH invocation", sshArgs)
}
if sshArgs[len(sshArgs)-3] != "bash" || sshArgs[len(sshArgs)-2] != "-lc" {
t.Fatalf("sshArgs = %v, want bash -lc fallback command", sshArgs)
}
if sshArgs[len(sshArgs)-1] != "cd '/root/repo' && exec opencode ." {
t.Fatalf("ssh fallback command = %q, want guest opencode launch", sshArgs[len(sshArgs)-1])
}
if !strings.Contains(stderr.String(), "[vm run] host opencode has no attach support; starting guest opencode over ssh") {
t.Fatalf("stderr = %q, want SSH fallback progress", stderr.String())
}
}
func TestOpencodeAttachHelpOutputSupported(t *testing.T) {
if !opencodeAttachHelpOutputSupported([]byte("opencode attach [url]\n\nAttach a terminal")) {
t.Fatal("expected attach help output to be recognized")
}
if opencodeAttachHelpOutputSupported([]byte("opencode [project]\n\nCommands:\n opencode run [message..]")) {
t.Fatal("unexpected attach support for top-level help output")
if !strings.Contains(stdout.String(), "VM ready.") {
t.Fatalf("stdout = %q, want next steps summary", stdout.String())
}
}
func TestVMRunToolingHarnessScriptUsesMiseOnly(t *testing.T) {
script := vmRunToolingHarnessScript(vmRunRepoSpec{RepoName: "repo"}, toolingplan.Plan{
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
RepoManagedTools: []string{"node"},
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
})
for _, want := range []string{
`if [ -f .mise.toml ] || [ -f .tool-versions ]; then`,
"PROMPT_FILE=" + shellQuote(vmRunToolingHarnessPromptPath("repo")),
fmt.Sprintf("INSTALL_TIMEOUT_SECS=%d", vmRunToolingInstallTimeoutSeconds),
"MODEL=" + shellQuote(vmRunToolingHarnessModel),
fmt.Sprintf("TIMEOUT_SECS=%d", vmRunToolingHarnessTimeoutSeconds),
`repo-managed mise tools: node`,
`run_best_effort "$MISE_BIN" install`,
`deterministic install: go@1.25.0 (go.mod)`,
`run_bounded_best_effort "$INSTALL_TIMEOUT_SECS" "$MISE_BIN" use -g --pin 'go@1.25.0'`,
`deterministic skip: python (no .python-version)`,
`run_best_effort "$MISE_BIN" reshim`,
`run_bounded_best_effort "$TIMEOUT_SECS" bash -lc 'exec "$1" run --format json -m "$2" "$(cat "$3")"' _ "$OPENCODE_BIN" "$MODEL" "$PROMPT_FILE"`,
`command timed out after ${timeout_secs}s: $*`,
`tooling prompt file missing: $PROMPT_FILE`,
} {
if !strings.Contains(script, want) {
t.Fatalf("script = %q, want %q", script, want)
}
}
for _, unwanted := range []string{"git add", "cat > .mise.toml", "cat > .tool-versions"} {
for _, unwanted := range []string{`opencode run`, `PROMPT_FILE=`, `--format json`, `mimo-v2-pro-free`} {
if strings.Contains(script, unwanted) {
t.Fatalf("script = %q, want no %q", script, unwanted)
}
}
}
func TestPrepareVMRunRepoCopyCreatesShallowMetadataCopy(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
@ -2065,14 +1918,16 @@ func (c *testVMRunGuestClient) StreamTar(ctx context.Context, sourceDir, remoteC
func (c *testVMRunGuestClient) RunScript(ctx context.Context, script string, logWriter io.Writer) error {
c.runScriptCalls++
switch c.runScriptCalls {
case 1:
if c.runScriptCalls == 1 {
c.script = script
return c.checkoutErr
default:
c.launchScript = script
if c.checkoutErr != nil {
return c.checkoutErr
}
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 {

View file

@ -210,7 +210,13 @@ func (workDiskCapability) PrepareHost(ctx context.Context, d *Daemon, vm *model.
if err := d.ensureGitIdentityOnWorkDisk(ctx, vm); err != nil {
return err
}
return d.ensureOpencodeAuthOnWorkDisk(ctx, vm)
if err := d.ensureOpencodeAuthOnWorkDisk(ctx, vm); err != nil {
return err
}
if err := d.ensureClaudeAuthOnWorkDisk(ctx, vm); err != nil {
return err
}
return d.ensurePiAuthOnWorkDisk(ctx, vm)
}
func (workDiskCapability) AddDoctorChecks(_ context.Context, d *Daemon, report *system.Report) {

View file

@ -27,32 +27,33 @@ import (
)
type Daemon struct {
layout paths.Layout
config model.DaemonConfig
store *store.Store
runner system.CommandRunner
logger *slog.Logger
mu sync.Mutex
createOpsMu sync.Mutex
createOps map[string]*vmCreateOperationState
imageBuildOpsMu sync.Mutex
imageBuildOps map[string]*imageBuildOperationState
vmLocksMu sync.Mutex
vmLocks map[string]*sync.Mutex
tapPoolMu sync.Mutex
tapPool []string
tapPoolNext int
closing chan struct{}
once sync.Once
pid int
listener net.Listener
webListener net.Listener
webServer *http.Server
webURL string
vmDNS *vmdns.Server
vmCaps []vmCapability
imageBuild func(context.Context, imageBuildSpec) error
requestHandler func(context.Context, rpc.Request) rpc.Response
layout paths.Layout
config model.DaemonConfig
store *store.Store
runner system.CommandRunner
logger *slog.Logger
mu sync.Mutex
createOpsMu sync.Mutex
createOps map[string]*vmCreateOperationState
imageBuildOpsMu sync.Mutex
imageBuildOps map[string]*imageBuildOperationState
vmLocksMu sync.Mutex
vmLocks map[string]*sync.Mutex
sessionControllers map[string]*guestSessionController
tapPoolMu sync.Mutex
tapPool []string
tapPoolNext int
closing chan struct{}
once sync.Once
pid int
listener net.Listener
webListener net.Listener
webServer *http.Server
webURL string
vmDNS *vmdns.Server
vmCaps []vmCapability
imageBuild func(context.Context, imageBuildSpec) error
requestHandler func(context.Context, rpc.Request) rpc.Response
}
func Open(ctx context.Context) (d *Daemon, err error) {
@ -125,7 +126,7 @@ func (d *Daemon) Close() error {
if d.webListener != nil {
_ = d.webListener.Close()
}
err = errors.Join(d.clearVMDNSResolverRouting(context.Background()), d.stopVMDNS(), d.store.Close())
err = errors.Join(d.clearVMDNSResolverRouting(context.Background()), d.stopVMDNS(), d.closeGuestSessionControllers(), d.store.Close())
})
return err
}
@ -396,6 +397,62 @@ func (d *Daemon) dispatch(ctx context.Context, req rpc.Request) rpc.Response {
}
result, err := d.PortsVM(ctx, params.IDOrName)
return marshalResultOrError(result, err)
case "vm.workspace.prepare":
params, err := rpc.DecodeParams[api.VMWorkspacePrepareParams](req)
if err != nil {
return rpc.NewError("bad_request", err.Error())
}
workspace, err := d.PrepareVMWorkspace(ctx, params)
return marshalResultOrError(api.VMWorkspacePrepareResult{Workspace: workspace}, err)
case "guest.session.start":
params, err := rpc.DecodeParams[api.GuestSessionStartParams](req)
if err != nil {
return rpc.NewError("bad_request", err.Error())
}
session, err := d.StartGuestSession(ctx, params)
return marshalResultOrError(api.GuestSessionShowResult{Session: session}, err)
case "guest.session.get":
params, err := rpc.DecodeParams[api.GuestSessionRefParams](req)
if err != nil {
return rpc.NewError("bad_request", err.Error())
}
session, err := d.GetGuestSession(ctx, params)
return marshalResultOrError(api.GuestSessionShowResult{Session: session}, err)
case "guest.session.list":
params, err := rpc.DecodeParams[api.VMRefParams](req)
if err != nil {
return rpc.NewError("bad_request", err.Error())
}
sessions, err := d.ListGuestSessions(ctx, params)
return marshalResultOrError(api.GuestSessionListResult{Sessions: sessions}, err)
case "guest.session.stop":
params, err := rpc.DecodeParams[api.GuestSessionRefParams](req)
if err != nil {
return rpc.NewError("bad_request", err.Error())
}
session, err := d.StopGuestSession(ctx, params)
return marshalResultOrError(api.GuestSessionShowResult{Session: session}, err)
case "guest.session.kill":
params, err := rpc.DecodeParams[api.GuestSessionRefParams](req)
if err != nil {
return rpc.NewError("bad_request", err.Error())
}
session, err := d.KillGuestSession(ctx, params)
return marshalResultOrError(api.GuestSessionShowResult{Session: session}, err)
case "guest.session.logs":
params, err := rpc.DecodeParams[api.GuestSessionLogsParams](req)
if err != nil {
return rpc.NewError("bad_request", err.Error())
}
result, err := d.GuestSessionLogs(ctx, params)
return marshalResultOrError(result, err)
case "guest.session.attach.begin":
params, err := rpc.DecodeParams[api.GuestSessionAttachBeginParams](req)
if err != nil {
return rpc.NewError("bad_request", err.Error())
}
result, err := d.BeginGuestSessionAttach(ctx, params)
return marshalResultOrError(result, err)
case "image.list":
images, err := d.store.ListImages(ctx)
return marshalResultOrError(api.ImageListResult{Images: images}, err)

File diff suppressed because it is too large Load diff

View file

@ -23,17 +23,21 @@ import (
)
const (
defaultMiseVersion = "v2025.12.0"
defaultMiseInstallPath = "/usr/local/bin/mise"
defaultMiseActivateLine = `eval "$(/usr/local/bin/mise activate bash)"`
defaultOpenCodeTool = "github:anomalyco/opencode"
defaultTPMRepo = "https://github.com/tmux-plugins/tpm"
defaultResurrectRepo = "https://github.com/tmux-plugins/tmux-resurrect"
defaultContinuumRepo = "https://github.com/tmux-plugins/tmux-continuum"
defaultTMUXPluginDir = "/root/.tmux/plugins"
defaultTMUXResurrectDir = "/root/.tmux/resurrect"
tmuxManagedBlockStart = "# >>> banger tmux plugins >>>"
tmuxManagedBlockEnd = "# <<< banger tmux plugins <<<"
defaultMiseVersion = "v2025.12.0"
defaultMiseInstallPath = "/usr/local/bin/mise"
defaultMiseActivateLine = `eval "$(/usr/local/bin/mise activate bash)"`
defaultNodeTool = "node@22"
defaultOpenCodeTool = "github:anomalyco/opencode"
defaultClaudeCodePackage = "@anthropic-ai/claude-code"
defaultPiPackage = "@mariozechner/pi-coding-agent"
defaultNPMGlobalPrefix = "/root/.local/share/banger/npm-global"
defaultTPMRepo = "https://github.com/tmux-plugins/tpm"
defaultResurrectRepo = "https://github.com/tmux-plugins/tmux-resurrect"
defaultContinuumRepo = "https://github.com/tmux-plugins/tmux-continuum"
defaultTMUXPluginDir = "/root/.tmux/plugins"
defaultTMUXResurrectDir = "/root/.tmux/resurrect"
tmuxManagedBlockStart = "# >>> banger tmux plugins >>>"
tmuxManagedBlockEnd = "# <<< banger tmux plugins <<<"
)
type imageBuildSpec struct {
@ -302,11 +306,27 @@ func buildModulesCommand(modulesBase string) string {
}
func appendMiseSetup(script *bytes.Buffer) {
const (
nodeShimPath = "/root/.local/share/mise/shims/node"
npmShimPath = "/root/.local/share/mise/shims/npm"
)
claudePath := filepath.ToSlash(filepath.Join(defaultNPMGlobalPrefix, "bin", "claude"))
piPath := filepath.ToSlash(filepath.Join(defaultNPMGlobalPrefix, "bin", "pi"))
fmt.Fprintf(script, "curl -fsSL https://mise.run | MISE_INSTALL_PATH=%s MISE_VERSION=%s sh\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultMiseVersion))
fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultNodeTool))
fmt.Fprintf(script, "%s use -g %s\n", shellQuote(defaultMiseInstallPath), shellQuote(defaultOpenCodeTool))
fmt.Fprintf(script, "%s reshim\n", shellQuote(defaultMiseInstallPath))
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'node shim not found after mise install' >&2; exit 1; fi\n", shellQuote(nodeShimPath))
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'npm shim not found after mise install' >&2; exit 1; fi\n", shellQuote(npmShimPath))
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'opencode shim not found after mise install' >&2; exit 1; fi\n", shellQuote(opencode.ShimPath))
fmt.Fprintf(script, "mkdir -p %s\n", shellQuote(defaultNPMGlobalPrefix))
fmt.Fprintf(script, "NPM_CONFIG_PREFIX=%s %s install -g %s %s\n", shellQuote(defaultNPMGlobalPrefix), shellQuote(npmShimPath), shellQuote(defaultClaudeCodePackage), shellQuote(defaultPiPackage))
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'claude binary not found after npm install' >&2; exit 1; fi\n", shellQuote(claudePath))
fmt.Fprintf(script, "if [[ ! -e %s ]]; then echo 'pi binary not found after npm install' >&2; exit 1; fi\n", shellQuote(piPath))
fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(opencode.ShimPath), shellQuote(opencode.GuestBinaryPath))
fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(claudePath), shellQuote("/usr/local/bin/claude"))
fmt.Fprintf(script, "ln -snf %s %s\n", shellQuote(piPath), shellQuote("/usr/local/bin/pi"))
script.WriteString("mkdir -p /etc/profile.d\n")
script.WriteString("cat > /etc/profile.d/mise.sh <<'EOF'\n")
fmt.Fprintf(script, "if [ -n \"${BASH_VERSION:-}\" ] && [ -x %s ]; then\n", shellQuote(defaultMiseInstallPath))

View file

@ -18,10 +18,19 @@ func TestBuildProvisionScriptInstallsDefaultTools(t *testing.T) {
"cat > /etc/systemd/system/banger-network.service <<'EOF'",
"systemctl enable --now banger-network.service || true",
"curl -fsSL https://mise.run | MISE_INSTALL_PATH='/usr/local/bin/mise' MISE_VERSION='v2025.12.0' sh",
"'/usr/local/bin/mise' use -g 'node@22'",
"'/usr/local/bin/mise' use -g 'github:anomalyco/opencode'",
"'/usr/local/bin/mise' reshim",
"if [[ ! -e '/root/.local/share/mise/shims/node' ]]; then echo 'node shim not found after mise install' >&2; exit 1; fi",
"if [[ ! -e '/root/.local/share/mise/shims/npm' ]]; then echo 'npm shim not found after mise install' >&2; exit 1; fi",
"if [[ ! -e '/root/.local/share/mise/shims/opencode' ]]; then echo 'opencode shim not found after mise install' >&2; exit 1; fi",
"mkdir -p '/root/.local/share/banger/npm-global'",
"NPM_CONFIG_PREFIX='/root/.local/share/banger/npm-global' '/root/.local/share/mise/shims/npm' install -g '@anthropic-ai/claude-code' '@mariozechner/pi-coding-agent'",
"if [[ ! -e '/root/.local/share/banger/npm-global/bin/claude' ]]; then echo 'claude binary not found after npm install' >&2; exit 1; fi",
"if [[ ! -e '/root/.local/share/banger/npm-global/bin/pi' ]]; then echo 'pi binary not found after npm install' >&2; exit 1; fi",
"ln -snf '/root/.local/share/mise/shims/opencode' '/usr/local/bin/opencode'",
"ln -snf '/root/.local/share/banger/npm-global/bin/claude' '/usr/local/bin/claude'",
"ln -snf '/root/.local/share/banger/npm-global/bin/pi' '/usr/local/bin/pi'",
"cat > /etc/profile.d/mise.sh <<'EOF'",
"if [ -n \"${BASH_VERSION:-}\" ] && [ -x '/usr/local/bin/mise' ]; then",
`eval "$(/usr/local/bin/mise activate bash)"`,

View file

@ -35,8 +35,14 @@ const (
workDiskGitConfigRelativePath = ".gitconfig"
workDiskOpencodeAuthDirRelativePath = ".local/share/opencode"
workDiskOpencodeAuthRelativePath = workDiskOpencodeAuthDirRelativePath + "/auth.json"
workDiskClaudeAuthDirRelativePath = ".claude"
workDiskClaudeAuthRelativePath = workDiskClaudeAuthDirRelativePath + "/.credentials.json"
workDiskPiAuthDirRelativePath = ".pi/agent"
workDiskPiAuthRelativePath = workDiskPiAuthDirRelativePath + "/auth.json"
hostGlobalGitIdentitySource = "git config --global"
hostOpencodeAuthDefaultDisplayPath = "~/" + workDiskOpencodeAuthRelativePath
hostClaudeAuthDefaultDisplayPath = "~/" + workDiskClaudeAuthRelativePath
hostPiAuthDefaultDisplayPath = "~/" + workDiskPiAuthRelativePath
)
type gitIdentity struct {
@ -967,19 +973,60 @@ func (d *Daemon) ensureGitIdentityOnWorkDisk(ctx context.Context, vm *model.VMRe
}
func (d *Daemon) ensureOpencodeAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
hostAuthPath, err := resolveHostOpencodeAuthPath()
return d.ensureAuthFileOnWorkDisk(
ctx,
vm,
"syncing opencode auth",
hostOpencodeAuthDefaultDisplayPath,
resolveHostOpencodeAuthPath,
workDiskOpencodeAuthRelativePath,
d.warnOpencodeAuthSyncSkipped,
)
}
func (d *Daemon) ensureClaudeAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
return d.ensureAuthFileOnWorkDisk(
ctx,
vm,
"syncing claude auth",
hostClaudeAuthDefaultDisplayPath,
resolveHostClaudeAuthPath,
workDiskClaudeAuthRelativePath,
d.warnClaudeAuthSyncSkipped,
)
}
func (d *Daemon) ensurePiAuthOnWorkDisk(ctx context.Context, vm *model.VMRecord) error {
return d.ensureAuthFileOnWorkDisk(
ctx,
vm,
"syncing pi auth",
hostPiAuthDefaultDisplayPath,
resolveHostPiAuthPath,
workDiskPiAuthRelativePath,
d.warnPiAuthSyncSkipped,
)
}
func (d *Daemon) ensureAuthFileOnWorkDisk(ctx context.Context, vm *model.VMRecord, stageDetail, defaultDisplayPath string, resolveHostPath func() (string, error), guestRelativePath string, warn func(model.VMRecord, string, error)) error {
hostAuthPath, err := resolveHostPath()
if err != nil {
d.warnOpencodeAuthSyncSkipped(*vm, hostOpencodeAuthDefaultDisplayPath, err)
warn(*vm, defaultDisplayPath, err)
return nil
}
authData, err := os.ReadFile(hostAuthPath)
if err != nil {
d.warnOpencodeAuthSyncSkipped(*vm, hostAuthPath, err)
warn(*vm, hostAuthPath, err)
return nil
}
vmCreateStage(ctx, "prepare_work_disk", "syncing opencode auth")
workMount, cleanupWork, err := system.MountTempDir(ctx, d.runner, vm.Runtime.WorkDiskPath, false)
runner := d.runner
if runner == nil {
runner = system.NewRunner()
}
vmCreateStage(ctx, "prepare_work_disk", stageDetail)
workMount, cleanupWork, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false)
if err != nil {
return err
}
@ -989,13 +1036,13 @@ func (d *Daemon) ensureOpencodeAuthOnWorkDisk(ctx context.Context, vm *model.VMR
return err
}
authDir := filepath.Join(workMount, workDiskOpencodeAuthDirRelativePath)
if _, err := d.runner.RunSudo(ctx, "mkdir", "-p", authDir); err != nil {
authDir := filepath.Join(workMount, filepath.Dir(guestRelativePath))
if _, err := runner.RunSudo(ctx, "mkdir", "-p", authDir); err != nil {
return err
}
authPath := filepath.Join(workMount, workDiskOpencodeAuthRelativePath)
authPath := filepath.Join(workMount, guestRelativePath)
tmpFile, err := os.CreateTemp("", "banger-opencode-auth-*")
tmpFile, err := os.CreateTemp("", "banger-auth-*")
if err != nil {
return err
}
@ -1011,16 +1058,28 @@ func (d *Daemon) ensureOpencodeAuthOnWorkDisk(ctx context.Context, vm *model.VMR
}
defer os.Remove(tmpPath)
_, err = d.runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authPath)
_, err = runner.RunSudo(ctx, "install", "-m", "600", tmpPath, authPath)
return err
}
func resolveHostOpencodeAuthPath() (string, error) {
return resolveHostAuthPath(workDiskOpencodeAuthRelativePath)
}
func resolveHostClaudeAuthPath() (string, error) {
return resolveHostAuthPath(workDiskClaudeAuthRelativePath)
}
func resolveHostPiAuthPath() (string, error) {
return resolveHostAuthPath(workDiskPiAuthRelativePath)
}
func resolveHostAuthPath(relativePath string) (string, error) {
home, err := os.UserHomeDir()
if err != nil {
return "", err
}
return filepath.Join(home, workDiskOpencodeAuthRelativePath), nil
return filepath.Join(home, relativePath), nil
}
func resolveHostGlobalGitIdentity(ctx context.Context, runner system.CommandRunner) (gitIdentity, error) {
@ -1093,6 +1152,20 @@ func (d *Daemon) warnOpencodeAuthSyncSkipped(vm model.VMRecord, hostPath string,
d.logger.Warn("guest opencode auth sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...)
}
func (d *Daemon) warnClaudeAuthSyncSkipped(vm model.VMRecord, hostPath string, err error) {
if d.logger == nil || err == nil {
return
}
d.logger.Warn("guest claude auth sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...)
}
func (d *Daemon) warnPiAuthSyncSkipped(vm model.VMRecord, hostPath string, err error) {
if d.logger == nil || err == nil {
return
}
d.logger.Warn("guest pi auth sync skipped", append(vmLogAttrs(vm), "host_path", hostPath, "error", err.Error())...)
}
func (d *Daemon) warnGitIdentitySyncSkipped(vm model.VMRecord, source string, err error) {
if d.logger == nil || err == nil {
return

View file

@ -1102,6 +1102,124 @@ func TestEnsureOpencodeAuthOnWorkDiskWarnsAndSkipsWhenHostAuthUnreadable(t *test
}
}
func TestEnsureClaudeAuthOnWorkDiskCopiesHostAuth(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
hostAuthPath := filepath.Join(homeDir, workDiskClaudeAuthRelativePath)
if err := os.MkdirAll(filepath.Dir(hostAuthPath), 0o755); err != nil {
t.Fatalf("MkdirAll(host auth dir): %v", err)
}
hostAuth := []byte("{\"token\":\"claude\"}\n")
if err := os.WriteFile(hostAuthPath, hostAuth, 0o600); err != nil {
t.Fatalf("WriteFile(host auth): %v", err)
}
workDiskDir := t.TempDir()
d := &Daemon{runner: &filesystemRunner{t: t}}
vm := testVM("claude-auth", "image-claude-auth", "172.16.0.67")
vm.Runtime.WorkDiskPath = workDiskDir
if err := d.ensureClaudeAuthOnWorkDisk(context.Background(), &vm); err != nil {
t.Fatalf("ensureClaudeAuthOnWorkDisk: %v", err)
}
guestAuthPath := filepath.Join(workDiskDir, workDiskClaudeAuthRelativePath)
got, err := os.ReadFile(guestAuthPath)
if err != nil {
t.Fatalf("ReadFile(guest auth): %v", err)
}
if string(got) != string(hostAuth) {
t.Fatalf("guest auth = %q, want %q", string(got), string(hostAuth))
}
info, err := os.Stat(guestAuthPath)
if err != nil {
t.Fatalf("Stat(guest auth): %v", err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("guest auth mode = %v, want 0600", info.Mode().Perm())
}
}
func TestEnsurePiAuthOnWorkDiskCopiesHostAuth(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
hostAuthPath := filepath.Join(homeDir, workDiskPiAuthRelativePath)
if err := os.MkdirAll(filepath.Dir(hostAuthPath), 0o755); err != nil {
t.Fatalf("MkdirAll(host auth dir): %v", err)
}
hostAuth := []byte("{\"token\":\"pi\"}\n")
if err := os.WriteFile(hostAuthPath, hostAuth, 0o600); err != nil {
t.Fatalf("WriteFile(host auth): %v", err)
}
workDiskDir := t.TempDir()
d := &Daemon{runner: &filesystemRunner{t: t}}
vm := testVM("pi-auth", "image-pi-auth", "172.16.0.68")
vm.Runtime.WorkDiskPath = workDiskDir
if err := d.ensurePiAuthOnWorkDisk(context.Background(), &vm); err != nil {
t.Fatalf("ensurePiAuthOnWorkDisk: %v", err)
}
guestAuthPath := filepath.Join(workDiskDir, workDiskPiAuthRelativePath)
got, err := os.ReadFile(guestAuthPath)
if err != nil {
t.Fatalf("ReadFile(guest auth): %v", err)
}
if string(got) != string(hostAuth) {
t.Fatalf("guest auth = %q, want %q", string(got), string(hostAuth))
}
}
func TestEnsurePiAuthOnWorkDiskWarnsAndSkipsWhenHostAuthMissing(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
workDiskDir := t.TempDir()
guestAuthPath := filepath.Join(workDiskDir, workDiskPiAuthRelativePath)
if err := os.MkdirAll(filepath.Dir(guestAuthPath), 0o755); err != nil {
t.Fatalf("MkdirAll(guest auth dir): %v", err)
}
original := []byte("{\"token\":\"keep\"}\n")
if err := os.WriteFile(guestAuthPath, original, 0o600); err != nil {
t.Fatalf("WriteFile(guest auth): %v", err)
}
var buf bytes.Buffer
logger, _, err := newDaemonLogger(&buf, "info")
if err != nil {
t.Fatalf("newDaemonLogger: %v", err)
}
d := &Daemon{
runner: &filesystemRunner{t: t},
logger: logger,
}
vm := testVM("pi-auth-missing", "image-pi-auth-missing", "172.16.0.69")
vm.Runtime.WorkDiskPath = workDiskDir
if err := d.ensurePiAuthOnWorkDisk(context.Background(), &vm); err != nil {
t.Fatalf("ensurePiAuthOnWorkDisk: %v", err)
}
got, err := os.ReadFile(guestAuthPath)
if err != nil {
t.Fatalf("ReadFile(guest auth): %v", err)
}
if string(got) != string(original) {
t.Fatalf("guest auth = %q, want preserved %q", string(got), string(original))
}
entries := parseLogEntries(t, buf.Bytes())
if !hasLogEntry(entries, map[string]string{
"msg": "guest pi auth sync skipped",
"vm_name": vm.Name,
"host_path": filepath.Join(homeDir, workDiskPiAuthRelativePath),
}) {
t.Fatalf("expected warn log, got %v", entries)
}
}
func TestCreateVMRejectsNonPositiveCPUAndMemory(t *testing.T) {
d := &Daemon{}
if _, err := d.CreateVM(context.Background(), api.VMCreateParams{VCPUCount: ptr(0)}); err == nil || !strings.Contains(err.Error(), "vcpu must be a positive integer") {

View file

@ -0,0 +1,417 @@
package daemon
import (
"bytes"
"context"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"time"
"banger/internal/api"
"banger/internal/guest"
"banger/internal/model"
"banger/internal/system"
)
const workspaceShallowFetchDepth = 10
type workspaceRepoSpec struct {
SourcePath string
RepoRoot string
RepoName string
HeadCommit string
CurrentBranch string
BranchName string
BaseCommit string
OriginURL string
GitUserName string
GitUserEmail string
OverlayPaths []string
Submodules []string
}
func (d *Daemon) PrepareVMWorkspace(ctx context.Context, params api.VMWorkspacePrepareParams) (model.WorkspacePrepareResult, error) {
mode, err := parseWorkspacePrepareMode(params.Mode)
if err != nil {
return model.WorkspacePrepareResult{}, err
}
guestPath := strings.TrimSpace(params.GuestPath)
if guestPath == "" {
guestPath = "/root/repo"
}
branchName := strings.TrimSpace(params.Branch)
fromRef := strings.TrimSpace(params.From)
if branchName != "" && fromRef == "" {
fromRef = "HEAD"
}
if branchName == "" && strings.TrimSpace(params.From) != "" {
return model.WorkspacePrepareResult{}, errors.New("workspace from requires branch")
}
var prepared model.WorkspacePrepareResult
_, err = d.withVMLockByRef(ctx, params.IDOrName, func(vm model.VMRecord) (model.VMRecord, error) {
if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) {
return model.VMRecord{}, fmt.Errorf("vm %q is not running", vm.Name)
}
result, err := d.prepareVMWorkspaceLocked(ctx, vm, strings.TrimSpace(params.SourcePath), guestPath, branchName, fromRef, mode, params.ReadOnly)
if err != nil {
return model.VMRecord{}, err
}
prepared = result
return vm, nil
})
return prepared, err
}
func (d *Daemon) prepareVMWorkspaceLocked(ctx context.Context, vm model.VMRecord, sourcePath, guestPath, branchName, fromRef string, mode model.WorkspacePrepareMode, readOnly bool) (model.WorkspacePrepareResult, error) {
spec, err := inspectWorkspaceRepo(ctx, sourcePath, branchName, fromRef)
if err != nil {
return model.WorkspacePrepareResult{}, err
}
if len(spec.Submodules) > 0 && mode != model.WorkspacePrepareModeFullCopy {
return model.WorkspacePrepareResult{}, fmt.Errorf("workspace mode %q does not support git submodules in %s (%s); use --mode full_copy", mode, spec.RepoRoot, strings.Join(spec.Submodules, ", "))
}
address := net.JoinHostPort(vm.Runtime.GuestIP, "22")
if err := guest.WaitForSSH(ctx, address, d.config.SSHKeyPath, 250*time.Millisecond); err != nil {
return model.WorkspacePrepareResult{}, fmt.Errorf("guest ssh unavailable: %w", err)
}
client, err := guest.Dial(ctx, address, d.config.SSHKeyPath)
if err != nil {
return model.WorkspacePrepareResult{}, fmt.Errorf("dial guest ssh: %w", err)
}
defer client.Close()
if err := importWorkspaceRepoToGuest(ctx, client, spec, guestPath, mode); err != nil {
return model.WorkspacePrepareResult{}, err
}
if readOnly {
var chmodLog bytes.Buffer
chmodScript := fmt.Sprintf("set -euo pipefail\nchmod -R a-w %s\n", guestShellQuote(guestPath))
if err := client.RunScript(ctx, chmodScript, &chmodLog); err != nil {
return model.WorkspacePrepareResult{}, formatGuestSessionStepError("set workspace readonly", err, chmodLog.String())
}
}
return model.WorkspacePrepareResult{
VMID: vm.ID,
SourcePath: spec.SourcePath,
RepoRoot: spec.RepoRoot,
RepoName: spec.RepoName,
GuestPath: guestPath,
Mode: mode,
ReadOnly: readOnly,
HeadCommit: spec.HeadCommit,
CurrentBranch: spec.CurrentBranch,
BranchName: spec.BranchName,
BaseCommit: spec.BaseCommit,
PreparedAt: model.Now(),
}, nil
}
func inspectWorkspaceRepo(ctx context.Context, rawPath, branchName, fromRef string) (workspaceRepoSpec, error) {
sourcePath, err := resolveWorkspaceSourcePath(rawPath)
if err != nil {
return workspaceRepoSpec{}, err
}
repoRoot, err := workspaceGitTrimmedOutput(ctx, sourcePath, "rev-parse", "--show-toplevel")
if err != nil {
return workspaceRepoSpec{}, fmt.Errorf("%s is not inside a git repository", sourcePath)
}
isBare, err := workspaceGitTrimmedOutput(ctx, repoRoot, "rev-parse", "--is-bare-repository")
if err != nil {
return workspaceRepoSpec{}, fmt.Errorf("inspect git repository %s: %w", repoRoot, err)
}
if isBare == "true" {
return workspaceRepoSpec{}, fmt.Errorf("workspace prepare requires a non-bare git repository: %s", repoRoot)
}
submodules, err := listWorkspaceSubmodules(ctx, repoRoot)
if err != nil {
return workspaceRepoSpec{}, err
}
headCommit, err := workspaceGitTrimmedOutput(ctx, repoRoot, "rev-parse", "HEAD^{commit}")
if err != nil {
return workspaceRepoSpec{}, fmt.Errorf("git repository %s must have at least one commit", repoRoot)
}
currentBranch, err := workspaceGitTrimmedOutput(ctx, repoRoot, "branch", "--show-current")
if err != nil {
return workspaceRepoSpec{}, fmt.Errorf("resolve current branch for %s: %w", repoRoot, err)
}
baseCommit := headCommit
branchName = strings.TrimSpace(branchName)
if branchName != "" {
baseCommit, err = workspaceGitTrimmedOutput(ctx, repoRoot, "rev-parse", fromRef+"^{commit}")
if err != nil {
return workspaceRepoSpec{}, fmt.Errorf("resolve workspace from %q: %w", fromRef, err)
}
}
gitUserName, err := workspaceGitResolvedConfigValue(ctx, repoRoot, "user.name")
if err != nil {
return workspaceRepoSpec{}, fmt.Errorf("resolve git user.name for %s: %w", repoRoot, err)
}
gitUserEmail, err := workspaceGitResolvedConfigValue(ctx, repoRoot, "user.email")
if err != nil {
return workspaceRepoSpec{}, fmt.Errorf("resolve git user.email for %s: %w", repoRoot, err)
}
originURL, err := workspaceGitResolvedConfigValue(ctx, repoRoot, "remote.origin.url")
if err != nil {
return workspaceRepoSpec{}, fmt.Errorf("resolve origin url for %s: %w", repoRoot, err)
}
overlayPaths, err := listWorkspaceOverlayPaths(ctx, repoRoot)
if err != nil {
return workspaceRepoSpec{}, err
}
return workspaceRepoSpec{
SourcePath: sourcePath,
RepoRoot: repoRoot,
RepoName: filepath.Base(repoRoot),
HeadCommit: headCommit,
CurrentBranch: currentBranch,
BranchName: branchName,
BaseCommit: baseCommit,
OriginURL: originURL,
GitUserName: gitUserName,
GitUserEmail: gitUserEmail,
OverlayPaths: overlayPaths,
Submodules: submodules,
}, nil
}
func importWorkspaceRepoToGuest(ctx context.Context, client *guest.Client, spec workspaceRepoSpec, guestPath string, mode model.WorkspacePrepareMode) error {
switch mode {
case model.WorkspacePrepareModeFullCopy:
var copyLog bytes.Buffer
command := fmt.Sprintf("rm -rf %s && mkdir -p %s && tar -o -C %s --strip-components=1 -xf -", guestShellQuote(guestPath), guestShellQuote(guestPath), guestShellQuote(guestPath))
if err := client.StreamTar(ctx, spec.RepoRoot, command, &copyLog); err != nil {
return formatGuestSessionStepError("copy full workspace", err, copyLog.String())
}
var finalizeLog bytes.Buffer
if err := client.RunScript(ctx, workspaceFinalizeScript(spec, guestPath, mode), &finalizeLog); err != nil {
return formatGuestSessionStepError("finalize full workspace", err, finalizeLog.String())
}
return nil
case model.WorkspacePrepareModeMetadataOnly, model.WorkspacePrepareModeShallowOverlay:
repoCopyDir, cleanup, err := prepareWorkspaceRepoCopy(ctx, spec)
if err != nil {
return err
}
defer cleanup()
var copyLog bytes.Buffer
command := fmt.Sprintf("rm -rf %s && mkdir -p %s && tar -o -C %s --strip-components=1 -xf -", guestShellQuote(guestPath), guestShellQuote(guestPath), guestShellQuote(guestPath))
if err := client.StreamTar(ctx, repoCopyDir, command, &copyLog); err != nil {
return formatGuestSessionStepError("copy guest git metadata", err, copyLog.String())
}
var scriptLog bytes.Buffer
if err := client.RunScript(ctx, workspaceFinalizeScript(spec, guestPath, mode), &scriptLog); err != nil {
return formatGuestSessionStepError("prepare guest checkout", err, scriptLog.String())
}
if mode == model.WorkspacePrepareModeMetadataOnly {
return nil
}
var overlayLog bytes.Buffer
command = fmt.Sprintf("tar -o -C %s --strip-components=1 -xf -", guestShellQuote(guestPath))
if err := client.StreamTarEntries(ctx, spec.RepoRoot, spec.OverlayPaths, command, &overlayLog); err != nil {
return formatGuestSessionStepError("overlay workspace working tree", err, overlayLog.String())
}
return nil
default:
return fmt.Errorf("unsupported workspace mode %q", mode)
}
}
func workspaceFinalizeScript(spec workspaceRepoSpec, guestPath string, mode model.WorkspacePrepareMode) string {
var script strings.Builder
script.WriteString("set -euo pipefail\n")
fmt.Fprintf(&script, "DIR=%s\n", guestShellQuote(guestPath))
script.WriteString("git config --global --add safe.directory \"$DIR\"\n")
if mode != model.WorkspacePrepareModeFullCopy {
script.WriteString("find \"$DIR\" -mindepth 1 -maxdepth 1 ! -name .git -exec rm -rf {} +\n")
}
switch {
case strings.TrimSpace(spec.BranchName) != "":
fmt.Fprintf(&script, "git -C \"$DIR\" checkout -B %s %s\n", guestShellQuote(spec.BranchName), guestShellQuote(spec.BaseCommit))
case strings.TrimSpace(spec.CurrentBranch) != "":
fmt.Fprintf(&script, "git -C \"$DIR\" checkout -B %s %s\n", guestShellQuote(spec.CurrentBranch), guestShellQuote(spec.HeadCommit))
default:
fmt.Fprintf(&script, "git -C \"$DIR\" checkout --detach %s\n", guestShellQuote(spec.HeadCommit))
}
if strings.TrimSpace(spec.GitUserName) != "" && strings.TrimSpace(spec.GitUserEmail) != "" {
fmt.Fprintf(&script, "git -C \"$DIR\" config user.name %s\n", guestShellQuote(spec.GitUserName))
fmt.Fprintf(&script, "git -C \"$DIR\" config user.email %s\n", guestShellQuote(spec.GitUserEmail))
}
return script.String()
}
func prepareWorkspaceRepoCopy(ctx context.Context, spec workspaceRepoSpec) (string, func(), error) {
tempRoot, err := os.MkdirTemp("", "banger-workspace-*")
if err != nil {
return "", nil, err
}
cleanup := func() { _ = os.RemoveAll(tempRoot) }
repoCopyDir := filepath.Join(tempRoot, spec.RepoName)
cloneArgs := []string{"clone", "--no-checkout", "--depth", fmt.Sprintf("%d", workspaceShallowFetchDepth)}
if strings.TrimSpace(spec.CurrentBranch) != "" {
cloneArgs = append(cloneArgs, "--single-branch", "--branch", spec.CurrentBranch)
}
cloneArgs = append(cloneArgs, workspaceGitFileURL(spec.RepoRoot), repoCopyDir)
if err := workspaceRunHostCommand(ctx, "git", cloneArgs...); err != nil {
cleanup()
return "", nil, fmt.Errorf("clone shallow workspace repo copy: %w", err)
}
checkoutCommit := spec.HeadCommit
if strings.TrimSpace(spec.BranchName) != "" {
checkoutCommit = spec.BaseCommit
}
if err := workspaceRunHostCommand(ctx, "git", "-C", repoCopyDir, "cat-file", "-e", checkoutCommit+"^{commit}"); err != nil {
if err := workspaceRunHostCommand(ctx, "git", "-C", repoCopyDir, "fetch", "--depth", fmt.Sprintf("%d", workspaceShallowFetchDepth), workspaceGitFileURL(spec.RepoRoot), checkoutCommit); err != nil {
cleanup()
return "", nil, fmt.Errorf("fetch shallow workspace repo commit %s: %w", checkoutCommit, err)
}
}
if strings.TrimSpace(spec.OriginURL) != "" {
if err := workspaceRunHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "set-url", "origin", spec.OriginURL); err != nil {
cleanup()
return "", nil, fmt.Errorf("set workspace origin remote: %w", err)
}
} else {
if err := workspaceRunHostCommand(ctx, "git", "-C", repoCopyDir, "remote", "remove", "origin"); err != nil {
cleanup()
return "", nil, fmt.Errorf("remove workspace placeholder origin remote: %w", err)
}
}
return repoCopyDir, cleanup, nil
}
func resolveWorkspaceSourcePath(rawPath string) (string, error) {
if strings.TrimSpace(rawPath) == "" {
return "", errors.New("workspace source path is required")
}
absPath, err := filepath.Abs(rawPath)
if err != nil {
return "", err
}
info, err := os.Stat(absPath)
if err != nil {
return "", err
}
if !info.IsDir() {
return "", fmt.Errorf("%s is not a directory", absPath)
}
return absPath, nil
}
func listWorkspaceSubmodules(ctx context.Context, repoRoot string) ([]string, error) {
output, err := workspaceGitOutput(ctx, repoRoot, "ls-files", "--stage", "-z")
if err != nil {
return nil, fmt.Errorf("inspect workspace git index for %s: %w", repoRoot, err)
}
var submodules []string
for _, record := range workspaceParseNullSeparatedOutput(output) {
if !strings.HasPrefix(record, "160000 ") {
continue
}
_, path, ok := strings.Cut(record, " ")
if !ok {
continue
}
submodules = append(submodules, strings.TrimSpace(path))
}
sort.Strings(submodules)
return submodules, nil
}
func listWorkspaceOverlayPaths(ctx context.Context, repoRoot string) ([]string, error) {
trackedOutput, err := workspaceGitOutput(ctx, repoRoot, "ls-files", "-z")
if err != nil {
return nil, fmt.Errorf("list tracked files for %s: %w", repoRoot, err)
}
untrackedOutput, err := workspaceGitOutput(ctx, repoRoot, "ls-files", "--others", "--exclude-standard", "-z")
if err != nil {
return nil, fmt.Errorf("list untracked files for %s: %w", repoRoot, err)
}
paths := make([]string, 0)
seen := make(map[string]struct{})
for _, relPath := range workspaceParseNullSeparatedOutput(trackedOutput) {
if relPath == "" {
continue
}
if _, err := os.Lstat(filepath.Join(repoRoot, relPath)); err != nil {
if os.IsNotExist(err) {
continue
}
return nil, err
}
seen[relPath] = struct{}{}
paths = append(paths, relPath)
}
for _, relPath := range workspaceParseNullSeparatedOutput(untrackedOutput) {
if relPath == "" {
continue
}
if _, ok := seen[relPath]; ok {
continue
}
seen[relPath] = struct{}{}
paths = append(paths, relPath)
}
sort.Strings(paths)
return paths, nil
}
func parseWorkspacePrepareMode(raw string) (model.WorkspacePrepareMode, error) {
switch strings.TrimSpace(raw) {
case "", string(model.WorkspacePrepareModeShallowOverlay):
return model.WorkspacePrepareModeShallowOverlay, nil
case string(model.WorkspacePrepareModeFullCopy):
return model.WorkspacePrepareModeFullCopy, nil
case string(model.WorkspacePrepareModeMetadataOnly):
return model.WorkspacePrepareModeMetadataOnly, nil
default:
return "", fmt.Errorf("unsupported workspace mode %q", raw)
}
}
func workspaceGitOutput(ctx context.Context, dir string, args ...string) ([]byte, error) {
fullArgs := make([]string, 0, len(args)+2)
if strings.TrimSpace(dir) != "" {
fullArgs = append(fullArgs, "-C", dir)
}
fullArgs = append(fullArgs, args...)
return guestSessionHostCommandOutputFunc(ctx, "git", fullArgs...)
}
func workspaceGitTrimmedOutput(ctx context.Context, dir string, args ...string) (string, error) {
output, err := workspaceGitOutput(ctx, dir, args...)
if err != nil {
return "", err
}
return strings.TrimSpace(string(output)), nil
}
func workspaceGitResolvedConfigValue(ctx context.Context, dir, key string) (string, error) {
return workspaceGitTrimmedOutput(ctx, dir, "config", "--default", "", "--get", key)
}
func workspaceParseNullSeparatedOutput(output []byte) []string {
chunks := bytes.Split(output, []byte{0})
values := make([]string, 0, len(chunks))
for _, chunk := range chunks {
value := strings.TrimSpace(string(chunk))
if value == "" {
continue
}
values = append(values, value)
}
return values
}
func workspaceRunHostCommand(ctx context.Context, name string, args ...string) error {
_, err := guestSessionHostCommandOutputFunc(ctx, name, args...)
return err
}
func workspaceGitFileURL(path string) string {
return (&url.URL{Scheme: "file", Path: filepath.ToSlash(path)}).String()
}

View file

@ -15,6 +15,7 @@ import (
"path/filepath"
"sort"
"strings"
"sync"
"time"
"golang.org/x/crypto/ssh"
@ -24,6 +25,16 @@ type Client struct {
client *ssh.Client
}
type StreamSession struct {
client *Client
session *ssh.Session
stdin io.WriteCloser
stdout io.Reader
stderr io.Reader
waitCh chan error
closeOnce sync.Once
}
func WaitForSSH(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
if interval <= 0 {
interval = time.Second
@ -109,6 +120,116 @@ func (c *Client) StreamTarEntries(ctx context.Context, sourceDir string, entries
return errors.Join(runErr, tarErr)
}
func (c *Client) StartCommand(ctx context.Context, command string) (*StreamSession, error) {
if c == nil || c.client == nil {
return nil, fmt.Errorf("ssh client is not connected")
}
session, err := c.client.NewSession()
if err != nil {
return nil, err
}
stdin, err := session.StdinPipe()
if err != nil {
_ = session.Close()
return nil, err
}
stdout, err := session.StdoutPipe()
if err != nil {
_ = session.Close()
return nil, err
}
stderr, err := session.StderrPipe()
if err != nil {
_ = session.Close()
return nil, err
}
done := make(chan struct{})
go func() {
select {
case <-ctx.Done():
_ = session.Close()
_ = c.client.Close()
case <-done:
}
}()
if err := session.Start(command); err != nil {
close(done)
_ = session.Close()
return nil, err
}
stream := &StreamSession{
client: c,
session: session,
stdin: stdin,
stdout: stdout,
stderr: stderr,
waitCh: make(chan error, 1),
}
go func() {
err := session.Wait()
close(done)
stream.waitCh <- err
close(stream.waitCh)
}()
return stream, nil
}
func (s *StreamSession) Stdin() io.WriteCloser {
if s == nil {
return nil
}
return s.stdin
}
func (s *StreamSession) Stdout() io.Reader {
if s == nil {
return nil
}
return s.stdout
}
func (s *StreamSession) Stderr() io.Reader {
if s == nil {
return nil
}
return s.stderr
}
func (s *StreamSession) Wait() error {
if s == nil || s.waitCh == nil {
return nil
}
err, ok := <-s.waitCh
if !ok {
return nil
}
return err
}
func (s *StreamSession) Close() error {
if s == nil {
return nil
}
var err error
s.closeOnce.Do(func() {
err = errors.Join(
func() error {
if s.session != nil {
return s.session.Close()
}
return nil
}(),
func() error {
if s.client != nil {
return s.client.Close()
}
return nil
}(),
)
})
return err
}
func (c *Client) runSession(ctx context.Context, command string, stdin io.Reader, logWriter io.Writer) error {
if c == nil || c.client == nil {
return fmt.Errorf("ssh client is not connected")

View file

@ -34,6 +34,23 @@ const (
VMStateError VMState = "error"
)
type GuestSessionStatus string
const (
GuestSessionStatusStarting GuestSessionStatus = "starting"
GuestSessionStatusRunning GuestSessionStatus = "running"
GuestSessionStatusExited GuestSessionStatus = "exited"
GuestSessionStatusFailed GuestSessionStatus = "failed"
GuestSessionStatusStopping GuestSessionStatus = "stopping"
)
type GuestSessionStdinMode string
const (
GuestSessionStdinClosed GuestSessionStdinMode = "closed"
GuestSessionStdinPipe GuestSessionStdinMode = "pipe"
)
type DaemonConfig struct {
LogLevel string
WebListenAddr string
@ -148,6 +165,60 @@ type ImageBuildRequest struct {
Docker bool
}
type GuestSession struct {
ID string `json:"id"`
VMID string `json:"vm_id"`
Name string `json:"name"`
Backend string `json:"backend"`
AttachBackend string `json:"attach_backend,omitempty"`
AttachMode string `json:"attach_mode,omitempty"`
Command string `json:"command"`
Args []string `json:"args,omitempty"`
CWD string `json:"cwd,omitempty"`
Env map[string]string `json:"env,omitempty"`
StdinMode GuestSessionStdinMode `json:"stdin_mode,omitempty"`
Status GuestSessionStatus `json:"status"`
ExitCode *int `json:"exit_code,omitempty"`
GuestPID int `json:"guest_pid,omitempty"`
GuestStateDir string `json:"guest_state_dir,omitempty"`
StdoutLogPath string `json:"stdout_log_path,omitempty"`
StderrLogPath string `json:"stderr_log_path,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
LastError string `json:"last_error,omitempty"`
Attachable bool `json:"attachable"`
Reattachable bool `json:"reattachable"`
LaunchStage string `json:"launch_stage,omitempty"`
LaunchMessage string `json:"launch_message,omitempty"`
LaunchRawLog string `json:"launch_raw_log,omitempty"`
CreatedAt time.Time `json:"created_at"`
StartedAt time.Time `json:"started_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
EndedAt time.Time `json:"ended_at,omitempty"`
}
type WorkspacePrepareMode string
const (
WorkspacePrepareModeShallowOverlay WorkspacePrepareMode = "shallow_overlay"
WorkspacePrepareModeFullCopy WorkspacePrepareMode = "full_copy"
WorkspacePrepareModeMetadataOnly WorkspacePrepareMode = "metadata_only"
)
type WorkspacePrepareResult struct {
VMID string `json:"vm_id"`
SourcePath string `json:"source_path"`
RepoRoot string `json:"repo_root"`
RepoName string `json:"repo_name"`
GuestPath string `json:"guest_path"`
Mode WorkspacePrepareMode `json:"mode"`
ReadOnly bool `json:"readonly"`
HeadCommit string `json:"head_commit,omitempty"`
CurrentBranch string `json:"current_branch,omitempty"`
BranchName string `json:"branch_name,omitempty"`
BaseCommit string `json:"base_commit,omitempty"`
PreparedAt time.Time `json:"prepared_at"`
}
func Now() time.Time {
return time.Now().UTC().Truncate(time.Second)
}

View file

@ -0,0 +1,76 @@
package sessionstream
import (
"encoding/binary"
"encoding/json"
"fmt"
"io"
)
const (
ChannelStdin byte = 0x01
ChannelStdout byte = 0x02
ChannelStderr byte = 0x03
ChannelControl byte = 0x04
FormatV1 = "stdio_mux_v1"
)
type ControlMessage struct {
Type string `json:"type"`
ExitCode *int `json:"exit_code,omitempty"`
Error string `json:"error,omitempty"`
}
func WriteFrame(w io.Writer, channel byte, payload []byte) error {
var header [5]byte
header[0] = channel
binary.BigEndian.PutUint32(header[1:], uint32(len(payload)))
if _, err := w.Write(header[:]); err != nil {
return err
}
if len(payload) == 0 {
return nil
}
_, err := w.Write(payload)
return err
}
func ReadFrame(r io.Reader) (byte, []byte, error) {
var header [5]byte
if _, err := io.ReadFull(r, header[:]); err != nil {
return 0, nil, err
}
length := binary.BigEndian.Uint32(header[1:])
payload := make([]byte, length)
if _, err := io.ReadFull(r, payload); err != nil {
return 0, nil, err
}
return header[0], payload, nil
}
func WriteControl(w io.Writer, message ControlMessage) error {
payload, err := json.Marshal(message)
if err != nil {
return err
}
return WriteFrame(w, ChannelControl, payload)
}
func ReadControl(payload []byte) (ControlMessage, error) {
var message ControlMessage
if err := json.Unmarshal(payload, &message); err != nil {
return ControlMessage{}, err
}
return message, nil
}
func ReadNextControl(r io.Reader) (ControlMessage, error) {
channel, payload, err := ReadFrame(r)
if err != nil {
return ControlMessage{}, err
}
if channel != ChannelControl {
return ControlMessage{}, fmt.Errorf("unexpected channel %d", channel)
}
return ReadControl(payload)
}

View file

@ -99,6 +99,32 @@ func (s *Store) migrate() error {
stats_json TEXT NOT NULL DEFAULT '{}',
FOREIGN KEY(image_id) REFERENCES images(id) ON DELETE RESTRICT
);`,
`CREATE TABLE IF NOT EXISTS guest_sessions (
id TEXT PRIMARY KEY,
vm_id TEXT NOT NULL,
name TEXT NOT NULL,
backend TEXT NOT NULL,
command TEXT NOT NULL,
args_json TEXT NOT NULL DEFAULT '[]',
cwd TEXT,
env_json TEXT NOT NULL DEFAULT '{}',
stdin_mode TEXT NOT NULL,
status TEXT NOT NULL,
exit_code INTEGER,
guest_pid INTEGER NOT NULL DEFAULT 0,
guest_state_dir TEXT,
stdout_log_path TEXT,
stderr_log_path TEXT,
tags_json TEXT NOT NULL DEFAULT '{}',
last_error TEXT,
attachable INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL,
started_at TEXT,
updated_at TEXT NOT NULL,
ended_at TEXT,
UNIQUE(vm_id, name),
FOREIGN KEY(vm_id) REFERENCES vms(id) ON DELETE CASCADE
);`,
}
for _, stmt := range stmts {
if _, err := s.db.Exec(stmt); err != nil {
@ -111,6 +137,18 @@ func (s *Store) migrate() error {
if err := ensureColumnExists(s.db, "images", "seeded_ssh_public_key_fingerprint", "TEXT"); err != nil {
return err
}
for _, spec := range []struct{ table, column, typ string }{
{"guest_sessions", "attach_backend", "TEXT"},
{"guest_sessions", "attach_mode", "TEXT"},
{"guest_sessions", "reattachable", "INTEGER NOT NULL DEFAULT 0"},
{"guest_sessions", "launch_stage", "TEXT"},
{"guest_sessions", "launch_message", "TEXT"},
{"guest_sessions", "launch_raw_log", "TEXT"},
} {
if err := ensureColumnExists(s.db, spec.table, spec.column, spec.typ); err != nil {
return err
}
}
return nil
}
@ -298,6 +336,122 @@ func (s *Store) FindVMsUsingImage(ctx context.Context, imageID string) ([]model.
return vms, rows.Err()
}
func (s *Store) UpsertGuestSession(ctx context.Context, session model.GuestSession) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
argsJSON, err := json.Marshal(session.Args)
if err != nil {
return err
}
envJSON, err := json.Marshal(session.Env)
if err != nil {
return err
}
tagsJSON, err := json.Marshal(session.Tags)
if err != nil {
return err
}
const query = `
INSERT INTO guest_sessions (
id, vm_id, name, backend, attach_backend, attach_mode, command, args_json, cwd, env_json, stdin_mode, status,
exit_code, guest_pid, guest_state_dir, stdout_log_path, stderr_log_path, tags_json,
last_error, attachable, reattachable, launch_stage, launch_message, launch_raw_log,
created_at, started_at, updated_at, ended_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
vm_id=excluded.vm_id,
name=excluded.name,
backend=excluded.backend,
attach_backend=excluded.attach_backend,
attach_mode=excluded.attach_mode,
command=excluded.command,
args_json=excluded.args_json,
cwd=excluded.cwd,
env_json=excluded.env_json,
stdin_mode=excluded.stdin_mode,
status=excluded.status,
exit_code=excluded.exit_code,
guest_pid=excluded.guest_pid,
guest_state_dir=excluded.guest_state_dir,
stdout_log_path=excluded.stdout_log_path,
stderr_log_path=excluded.stderr_log_path,
tags_json=excluded.tags_json,
last_error=excluded.last_error,
attachable=excluded.attachable,
reattachable=excluded.reattachable,
launch_stage=excluded.launch_stage,
launch_message=excluded.launch_message,
launch_raw_log=excluded.launch_raw_log,
started_at=excluded.started_at,
updated_at=excluded.updated_at,
ended_at=excluded.ended_at`
_, err = s.db.ExecContext(ctx, query,
session.ID,
session.VMID,
session.Name,
session.Backend,
session.AttachBackend,
session.AttachMode,
session.Command,
string(argsJSON),
session.CWD,
string(envJSON),
string(session.StdinMode),
string(session.Status),
nullableInt(session.ExitCode),
session.GuestPID,
session.GuestStateDir,
session.StdoutLogPath,
session.StderrLogPath,
string(tagsJSON),
session.LastError,
boolToInt(session.Attachable),
boolToInt(session.Reattachable),
session.LaunchStage,
session.LaunchMessage,
session.LaunchRawLog,
session.CreatedAt.Format(time.RFC3339),
nullableTimeString(session.StartedAt),
session.UpdatedAt.Format(time.RFC3339),
nullableTimeString(session.EndedAt),
)
return err
}
func (s *Store) GetGuestSessionByID(ctx context.Context, id string) (model.GuestSession, error) {
row := s.db.QueryRowContext(ctx, guestSessionSelectSQL+" WHERE id = ?", id)
return scanGuestSessionRow(row)
}
func (s *Store) GetGuestSession(ctx context.Context, vmID, idOrName string) (model.GuestSession, error) {
row := s.db.QueryRowContext(ctx, guestSessionSelectSQL+" WHERE vm_id = ? AND (id = ? OR name = ?)", vmID, idOrName, idOrName)
return scanGuestSessionRow(row)
}
func (s *Store) ListGuestSessionsByVM(ctx context.Context, vmID string) ([]model.GuestSession, error) {
rows, err := s.db.QueryContext(ctx, guestSessionSelectSQL+" WHERE vm_id = ? ORDER BY created_at ASC", vmID)
if err != nil {
return nil, err
}
defer rows.Close()
var sessions []model.GuestSession
for rows.Next() {
session, err := scanGuestSession(rows)
if err != nil {
return nil, err
}
sessions = append(sessions, session)
}
return sessions, rows.Err()
}
func (s *Store) DeleteGuestSession(ctx context.Context, id string) error {
s.writeMu.Lock()
defer s.writeMu.Unlock()
_, err := s.db.ExecContext(ctx, "DELETE FROM guest_sessions WHERE id = ?", id)
return err
}
func (s *Store) NextGuestIP(ctx context.Context, bridgeIPPrefix string) (string, error) {
used := map[string]struct{}{}
rows, err := s.db.QueryContext(ctx, "SELECT guest_ip FROM vms")
@ -467,3 +621,124 @@ func boolToInt(value bool) int {
}
return 0
}
const guestSessionSelectSQL = `
SELECT id, vm_id, name, backend, attach_backend, attach_mode, command, args_json, cwd, env_json, stdin_mode, status,
exit_code, guest_pid, guest_state_dir, stdout_log_path, stderr_log_path, tags_json,
last_error, attachable, reattachable, launch_stage, launch_message, launch_raw_log,
created_at, started_at, updated_at, ended_at
FROM guest_sessions`
func scanGuestSession(rows scanner) (model.GuestSession, error) {
return scanGuestSessionRow(rows)
}
func scanGuestSessionRow(row scanner) (model.GuestSession, error) {
var session model.GuestSession
var (
argsJSON string
envJSON string
tagsJSON string
stdinMode string
status string
exitCode sql.NullInt64
startedAt sql.NullString
endedAt sql.NullString
attachable int
reattachable int
createdRaw string
updatedRaw string
)
err := row.Scan(
&session.ID,
&session.VMID,
&session.Name,
&session.Backend,
&session.AttachBackend,
&session.AttachMode,
&session.Command,
&argsJSON,
&session.CWD,
&envJSON,
&stdinMode,
&status,
&exitCode,
&session.GuestPID,
&session.GuestStateDir,
&session.StdoutLogPath,
&session.StderrLogPath,
&tagsJSON,
&session.LastError,
&attachable,
&reattachable,
&session.LaunchStage,
&session.LaunchMessage,
&session.LaunchRawLog,
&createdRaw,
&startedAt,
&updatedRaw,
&endedAt,
)
if err != nil {
return session, err
}
session.StdinMode = model.GuestSessionStdinMode(stdinMode)
session.Status = model.GuestSessionStatus(status)
session.Attachable = attachable == 1
session.Reattachable = reattachable == 1
if argsJSON != "" {
if err := json.Unmarshal([]byte(argsJSON), &session.Args); err != nil {
return session, err
}
}
if envJSON != "" {
if err := json.Unmarshal([]byte(envJSON), &session.Env); err != nil {
return session, err
}
}
if tagsJSON != "" {
if err := json.Unmarshal([]byte(tagsJSON), &session.Tags); err != nil {
return session, err
}
}
if exitCode.Valid {
value := int(exitCode.Int64)
session.ExitCode = &value
}
var parseErr error
session.CreatedAt, parseErr = time.Parse(time.RFC3339, createdRaw)
if parseErr != nil {
return session, parseErr
}
session.UpdatedAt, parseErr = time.Parse(time.RFC3339, updatedRaw)
if parseErr != nil {
return session, parseErr
}
if startedAt.Valid && startedAt.String != "" {
session.StartedAt, parseErr = time.Parse(time.RFC3339, startedAt.String)
if parseErr != nil {
return session, parseErr
}
}
if endedAt.Valid && endedAt.String != "" {
session.EndedAt, parseErr = time.Parse(time.RFC3339, endedAt.String)
if parseErr != nil {
return session, parseErr
}
}
return session, nil
}
func nullableTimeString(value time.Time) any {
if value.IsZero() {
return nil
}
return value.Format(time.RFC3339)
}
func nullableInt(value *int) any {
if value == nil {
return nil
}
return *value
}

View file

@ -94,6 +94,10 @@ INITRD=""
MISE_VERSION="v2025.12.0"
MISE_INSTALL_PATH="/usr/local/bin/mise"
MISE_ACTIVATE_LINE='eval "$(/usr/local/bin/mise activate bash)"'
NODE_TOOL="node@22"
CLAUDE_CODE_PACKAGE="@anthropic-ai/claude-code"
PI_PACKAGE="@mariozechner/pi-coding-agent"
NPM_GLOBAL_PREFIX="/root/.local/share/banger/npm-global"
TMUX_PLUGIN_DIR="/root/.tmux/plugins"
TMUX_RESURRECT_DIR="/root/.tmux/resurrect"
TMUX_TPM_REPO="https://github.com/tmux-plugins/tpm"
@ -399,14 +403,35 @@ fi
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get -y upgrade
DEBIAN_FRONTEND=noninteractive apt-get -y install ${APT_PACKAGES_ESCAPED}
curl -fsSL https://mise.run | MISE_INSTALL_PATH=\"$MISE_INSTALL_PATH\" MISE_VERSION=\"$MISE_VERSION\" sh
\"$MISE_INSTALL_PATH\" use -g github:anomalyco/opencode
\"$MISE_INSTALL_PATH\" reshim
curl -fsSL https://mise.run | MISE_INSTALL_PATH="$MISE_INSTALL_PATH" MISE_VERSION="$MISE_VERSION" sh
"$MISE_INSTALL_PATH" use -g "$NODE_TOOL"
"$MISE_INSTALL_PATH" use -g github:anomalyco/opencode
"$MISE_INSTALL_PATH" reshim
if [[ ! -e /root/.local/share/mise/shims/node ]]; then
echo 'node shim not found after mise install' >&2
exit 1
fi
if [[ ! -e /root/.local/share/mise/shims/npm ]]; then
echo 'npm shim not found after mise install' >&2
exit 1
fi
if [[ ! -e /root/.local/share/mise/shims/opencode ]]; then
echo 'opencode shim not found after mise install' >&2
exit 1
fi
mkdir -p "$NPM_GLOBAL_PREFIX"
NPM_CONFIG_PREFIX="$NPM_GLOBAL_PREFIX" /root/.local/share/mise/shims/npm install -g "$CLAUDE_CODE_PACKAGE" "$PI_PACKAGE"
if [[ ! -e "$NPM_GLOBAL_PREFIX/bin/claude" ]]; then
echo 'claude binary not found after npm install' >&2
exit 1
fi
if [[ ! -e "$NPM_GLOBAL_PREFIX/bin/pi" ]]; then
echo 'pi binary not found after npm install' >&2
exit 1
fi
ln -snf /root/.local/share/mise/shims/opencode /usr/local/bin/opencode
ln -snf "$NPM_GLOBAL_PREFIX/bin/claude" /usr/local/bin/claude
ln -snf "$NPM_GLOBAL_PREFIX/bin/pi" /usr/local/bin/pi
mkdir -p /etc/profile.d
cat > /etc/profile.d/mise.sh <<'MISEPROFILE'
if [ -n \"\${BASH_VERSION:-}\" ] && [ -x \"$MISE_INSTALL_PATH\" ]; then

View file

@ -332,7 +332,7 @@ EOF
sudo chmod 0644 "$bashrc" "$bash_profile" "$profile_prompt"
}
install_mise_and_opencode() {
install_guest_tools() {
local profile_mise="$ROOT_MOUNT/etc/profile.d/mise.sh"
sudo mkdir -p "$ROOT_MOUNT/etc/profile.d"
@ -340,19 +340,37 @@ install_mise_and_opencode() {
sudo install -m 0644 /etc/resolv.conf "$ROOT_MOUNT/etc/resolv.conf"
fi
sudo env \
HOME=/root \
PATH=/usr/local/bin:/usr/bin:/bin \
chroot "$ROOT_MOUNT" /bin/bash -se <<EOF
sudo env HOME=/root PATH=/usr/local/bin:/usr/bin:/bin chroot "$ROOT_MOUNT" /bin/bash -se <<EOF
set -euo pipefail
curl -fsSL https://mise.run | MISE_INSTALL_PATH="$MISE_INSTALL_PATH" MISE_VERSION="$MISE_VERSION" sh
"$MISE_INSTALL_PATH" use -g "$NODE_TOOL"
"$MISE_INSTALL_PATH" use -g "$OPENCODE_TOOL"
"$MISE_INSTALL_PATH" reshim
if [[ ! -e /root/.local/share/mise/shims/node ]]; then
echo "node shim not found after mise install" >&2
exit 1
fi
if [[ ! -e /root/.local/share/mise/shims/npm ]]; then
echo "npm shim not found after mise install" >&2
exit 1
fi
if [[ ! -e /root/.local/share/mise/shims/opencode ]]; then
echo "opencode shim not found after mise install" >&2
exit 1
fi
mkdir -p "$NPM_GLOBAL_PREFIX"
NPM_CONFIG_PREFIX="$NPM_GLOBAL_PREFIX" /root/.local/share/mise/shims/npm install -g "$CLAUDE_CODE_PACKAGE" "$PI_PACKAGE"
if [[ ! -e "$NPM_GLOBAL_PREFIX/bin/claude" ]]; then
echo "claude binary not found after npm install" >&2
exit 1
fi
if [[ ! -e "$NPM_GLOBAL_PREFIX/bin/pi" ]]; then
echo "pi binary not found after npm install" >&2
exit 1
fi
ln -snf /root/.local/share/mise/shims/opencode /usr/local/bin/opencode
ln -snf "$NPM_GLOBAL_PREFIX/bin/claude" /usr/local/bin/claude
ln -snf "$NPM_GLOBAL_PREFIX/bin/pi" /usr/local/bin/pi
EOF
cat <<'EOF' | sudo tee "$profile_mise" >/dev/null
@ -387,7 +405,11 @@ MIRROR="https://repo-default.voidlinux.org"
ARCH="x86_64"
MISE_VERSION="v2025.12.0"
MISE_INSTALL_PATH="/usr/local/bin/mise"
NODE_TOOL="node@22"
OPENCODE_TOOL="github:anomalyco/opencode"
CLAUDE_CODE_PACKAGE="@anthropic-ai/claude-code"
PI_PACKAGE="@mariozechner/pi-coding-agent"
NPM_GLOBAL_PREFIX="/root/.local/share/banger/npm-global"
GUESTNET_BOOTSTRAP_SCRIPT="$REPO_ROOT/internal/guestnet/assets/bootstrap.sh"
GUESTNET_VOID_CORE_SERVICE="$REPO_ROOT/internal/guestnet/assets/void-core-service.sh"
MODULES_DIR=""
@ -556,8 +578,8 @@ configure_docker_bootstrap
enable_docker_service
normalize_root_shell
configure_root_bash_prompt
log "installing mise and opencode"
install_mise_and_opencode
log "installing guest tools"
install_guest_tools
install_opencode_service
install_root_authorized_key
sudo touch "$ROOT_MOUNT/etc/fstab" "$ROOT_MOUNT/etc/hostname"