From 37c4c091ecedc71321ddb473d8af10f66624115f Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sun, 12 Apr 2026 23:48:42 -0300 Subject: [PATCH] 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 --- README.md | 46 +- internal/api/types.go | 67 ++ internal/cli/banger.go | 589 ++++++++--- internal/cli/cli_test.go | 307 ++---- internal/daemon/capabilities.go | 8 +- internal/daemon/daemon.go | 111 ++- internal/daemon/guest_sessions.go | 1198 +++++++++++++++++++++++ internal/daemon/imagebuild.go | 42 +- internal/daemon/imagebuild_test.go | 9 + internal/daemon/vm.go | 95 +- internal/daemon/vm_test.go | 118 +++ internal/daemon/workspace.go | 417 ++++++++ internal/guest/ssh.go | 121 +++ internal/model/types.go | 71 ++ internal/sessionstream/sessionstream.go | 76 ++ internal/store/store.go | 275 ++++++ scripts/customize.sh | 31 +- scripts/make-rootfs-void.sh | 36 +- 18 files changed, 3212 insertions(+), 405 deletions(-) create mode 100644 internal/daemon/guest_sessions.go create mode 100644 internal/daemon/workspace.go create mode 100644 internal/sessionstream/sessionstream.go diff --git a/README.md b/README.md index 558f5f1..bad5d00 100644 --- a/README.md +++ b/README.md @@ -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 +opencode attach http://.vm:4096 --dir /root/repo +./build/bin/banger vm acp +./build/bin/banger vm ssh -- "cd /root/repo && claude" +./build/bin/banger vm ssh -- "cd /root/repo && pi" +``` For ACP-aware host tools, `./build/bin/banger vm acp ` 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 +./build/bin/banger vm workspace prepare ../other-repo --guest-path /root/repo --readonly +./build/bin/banger vm session start --name planner --cwd /root/repo --stdin-mode pipe -- pi --mode rpc --no-session +./build/bin/banger vm session list +./build/bin/banger vm session attach planner +./build/bin/banger vm session logs planner --stream stderr +./build/bin/banger vm session stop 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: diff --git a/internal/api/types.go b/internal/api/types.go index 5c5b334..6756d7e 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -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"` diff --git a/internal/cli/banger.go b/internal/cli/banger.go index 0c7cf4d..458f706 100644 --- a/internal/cli/banger.go +++ b/internal/cli/banger.go @@ -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 ", - 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] "), 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 [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 [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 [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 [flags] -- [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 ", + Short: "List managed guest commands for a VM", + Args: exactArgsUsage(1, "usage: banger vm session list "), + 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 ", + Short: "Show managed guest command details", + Args: exactArgsUsage(2, "usage: banger vm session show "), + 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 ", + Short: "Show stdout or stderr for a guest session", + Args: exactArgsUsage(2, "usage: banger vm session logs [--stream stdout|stderr] [-n LINES] "), + 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 ", + Short: "Send SIGTERM to a guest session", + Args: exactArgsUsage(2, "usage: banger vm session stop "), + 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 ", + Short: "Send SIGKILL to a guest session", + Args: exactArgsUsage(2, "usage: banger vm session kill "), + 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 ", + 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 "), + 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 "" diff --git a/internal/cli/cli_test.go b/internal/cli/cli_test.go index 3c24330..11e2b57 100644 --- a/internal/cli/cli_test.go +++ b/internal/cli/cli_test.go @@ -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 .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 { diff --git a/internal/daemon/capabilities.go b/internal/daemon/capabilities.go index 779078d..e44c3b9 100644 --- a/internal/daemon/capabilities.go +++ b/internal/daemon/capabilities.go @@ -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) { diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 4a29f24..de747a2 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -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) diff --git a/internal/daemon/guest_sessions.go b/internal/daemon/guest_sessions.go new file mode 100644 index 0000000..b0f9dcd --- /dev/null +++ b/internal/daemon/guest_sessions.go @@ -0,0 +1,1198 @@ +package daemon + +import ( + "bufio" + "bytes" + "context" + "errors" + "fmt" + "io" + "net" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "syscall" + "time" + + "banger/internal/api" + "banger/internal/guest" + "banger/internal/model" + "banger/internal/sessionstream" + "banger/internal/system" + + "golang.org/x/crypto/ssh" +) + +const ( + guestSessionBackendSSH = "ssh" + guestSessionAttachBackendNone = "none" + guestSessionAttachBackendSSHBridge = "ssh_rehydratable" + guestSessionAttachModeExclusive = "exclusive" + guestSessionTransportUnixSocket = "unix_socket" + guestSessionStateRoot = "/root/.local/state/banger/sessions" + guestSessionLogTailLine = 200 +) + +var guestSessionHostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) { + runner := system.NewRunner() + output, err := runner.Run(ctx, name, args...) + if err == nil { + return output, nil + } + command := strings.TrimSpace(strings.Join(append([]string{name}, args...), " ")) + detail := strings.TrimSpace(string(output)) + if detail == "" { + return output, fmt.Errorf("%s: %w", command, err) + } + return output, fmt.Errorf("%s: %w: %s", command, err, detail) +} + +type guestSessionController struct { + stream *guest.StreamSession + streams []*guest.StreamSession + stdin io.WriteCloser + attachMu sync.Mutex + attach net.Conn + writeMu sync.Mutex + closeOnce sync.Once +} + +func (c *guestSessionController) setAttach(conn net.Conn) error { + c.attachMu.Lock() + defer c.attachMu.Unlock() + if c.attach != nil { + return errors.New("session already has an active attach") + } + c.attach = conn + return nil +} + +func (c *guestSessionController) clearAttach(conn net.Conn) { + c.attachMu.Lock() + defer c.attachMu.Unlock() + if c.attach == conn { + c.attach = nil + } +} + +func (c *guestSessionController) writeFrame(channel byte, payload []byte) { + c.attachMu.Lock() + conn := c.attach + c.attachMu.Unlock() + if conn == nil { + return + } + c.writeMu.Lock() + err := sessionstream.WriteFrame(conn, channel, payload) + c.writeMu.Unlock() + if err != nil { + _ = conn.Close() + c.clearAttach(conn) + } +} + +func (c *guestSessionController) writeControl(message sessionstream.ControlMessage) { + c.attachMu.Lock() + conn := c.attach + c.attachMu.Unlock() + if conn == nil { + return + } + c.writeMu.Lock() + err := sessionstream.WriteControl(conn, message) + c.writeMu.Unlock() + if err != nil { + _ = conn.Close() + c.clearAttach(conn) + } +} + +func (c *guestSessionController) close() error { + if c == nil { + return nil + } + var err error + c.closeOnce.Do(func() { + c.attachMu.Lock() + conn := c.attach + c.attach = nil + c.attachMu.Unlock() + if conn != nil { + err = errors.Join(err, conn.Close()) + } + if c.stdin != nil { + err = errors.Join(err, c.stdin.Close()) + } + if c.stream != nil { + err = errors.Join(err, c.stream.Close()) + } + for _, stream := range c.streams { + if stream != nil { + err = errors.Join(err, stream.Close()) + } + } + }) + return err +} + +type guestSessionStateSnapshot struct { + Status string + GuestPID int + ExitCode *int + Alive bool + LastError string +} + +func (d *Daemon) StartGuestSession(ctx context.Context, params api.GuestSessionStartParams) (model.GuestSession, error) { + stdinMode := model.GuestSessionStdinMode(strings.TrimSpace(params.StdinMode)) + if stdinMode == "" { + stdinMode = model.GuestSessionStdinClosed + } + if stdinMode != model.GuestSessionStdinClosed && stdinMode != model.GuestSessionStdinPipe { + return model.GuestSession{}, fmt.Errorf("unsupported stdin mode %q", params.StdinMode) + } + if strings.TrimSpace(params.Command) == "" { + return model.GuestSession{}, errors.New("session command is required") + } + var created model.GuestSession + _, err := d.withVMLockByRef(ctx, params.VMIDOrName, 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) + } + session, err := d.startGuestSessionLocked(ctx, vm, params, stdinMode) + if err != nil { + return model.VMRecord{}, err + } + created = session + return vm, nil + }) + return created, err +} + +func (d *Daemon) startGuestSessionLocked(ctx context.Context, vm model.VMRecord, params api.GuestSessionStartParams, stdinMode model.GuestSessionStdinMode) (model.GuestSession, error) { + id, err := model.NewID() + if err != nil { + return model.GuestSession{}, err + } + now := model.Now() + session := model.GuestSession{ + ID: id, + VMID: vm.ID, + Name: defaultGuestSessionName(id, params.Command, params.Name), + Backend: guestSessionBackendSSH, + Command: params.Command, + Args: append([]string(nil), params.Args...), + CWD: strings.TrimSpace(params.CWD), + Env: cloneStringMap(params.Env), + StdinMode: stdinMode, + Status: model.GuestSessionStatusStarting, + GuestStateDir: guestSessionStateDir(id), + StdoutLogPath: guestSessionStdoutLogPath(id), + StderrLogPath: guestSessionStderrLogPath(id), + Tags: cloneStringMap(params.Tags), + Attachable: stdinMode == model.GuestSessionStdinPipe, + Reattachable: stdinMode == model.GuestSessionStdinPipe, + CreatedAt: now, + UpdatedAt: now, + } + if session.Attachable { + session.AttachBackend = guestSessionAttachBackendSSHBridge + session.AttachMode = guestSessionAttachModeExclusive + } else { + session.AttachBackend = guestSessionAttachBackendNone + } + if err := d.store.UpsertGuestSession(ctx, session); err != nil { + return model.GuestSession{}, err + } + fail := func(stage, message, rawLog string) (model.GuestSession, error) { + session = failGuestSessionLaunch(session, stage, message, rawLog) + if err := d.store.UpsertGuestSession(ctx, session); err != nil { + return model.GuestSession{}, err + } + return session, nil + } + address := net.JoinHostPort(vm.Runtime.GuestIP, "22") + if err := guest.WaitForSSH(ctx, address, d.config.SSHKeyPath, 250*time.Millisecond); err != nil { + return fail("ssh_unavailable", fmt.Sprintf("guest ssh unavailable: %v", err), "") + } + client, err := guest.Dial(ctx, address, d.config.SSHKeyPath) + if err != nil { + return fail("dial_guest", fmt.Sprintf("dial guest ssh: %v", err), "") + } + defer client.Close() + var preflightLog bytes.Buffer + if err := client.RunScript(ctx, guestSessionCWDPreflightScript(session.CWD), &preflightLog); err != nil { + return fail("preflight_cwd", fmt.Sprintf("guest working directory is unavailable: %s", defaultGuestSessionCWD(session.CWD)), preflightLog.String()) + } + preflightLog.Reset() + requiredCommands := normalizeGuestSessionRequiredCommands(params.Command, params.RequiredCommands) + if err := client.RunScript(ctx, guestSessionCommandPreflightScript(requiredCommands), &preflightLog); err != nil { + return fail("preflight_command", fmt.Sprintf("required guest command is unavailable: %s", strings.TrimSpace(preflightLog.String())), preflightLog.String()) + } + var uploadLog bytes.Buffer + if err := client.UploadFile(ctx, guestSessionScriptPath(id), 0o755, []byte(guestSessionScript(session)), &uploadLog); err != nil { + return fail("upload_script", "upload guest session script failed", uploadLog.String()) + } + var launchLog bytes.Buffer + launchScript := fmt.Sprintf("set -euo pipefail\nnohup bash %s >/dev/null 2>&1 0 { + controller.writeFrame(channel, buffer[:n]) + } + if err != nil { + if !errors.Is(err, io.EOF) { + controller.writeControl(sessionstream.ControlMessage{Type: "error", Error: err.Error()}) + } + return + } + } +} + +func (d *Daemon) waitForGuestSessionExit(id string, controller *guestSessionController, session model.GuestSession) { + err := controller.stream.Wait() + updated := session + updated.Attachable = false + now := model.Now() + updated.UpdatedAt = now + updated.EndedAt = now + if exitCode, ok := guestSessionExitCode(err); ok { + updated.ExitCode = &exitCode + if exitCode == 0 { + updated.Status = model.GuestSessionStatusExited + } else { + updated.Status = model.GuestSessionStatusFailed + } + } + if err != nil && updated.LastError == "" { + updated.LastError = err.Error() + } + if vm, getErr := d.store.GetVMByID(context.Background(), updated.VMID); getErr == nil { + if refreshed, refreshErr := d.refreshGuestSession(context.Background(), vm, updated); refreshErr == nil { + updated = refreshed + } + } + _ = d.store.UpsertGuestSession(context.Background(), updated) + controller.writeControl(sessionstream.ControlMessage{Type: "exit", ExitCode: updated.ExitCode}) + _ = controller.close() + d.clearGuestSessionController(id) +} + +func (d *Daemon) serveGuestSessionAttach(session model.GuestSession, controller *guestSessionController, _ string, socketPath string, listener net.Listener) { + defer func() { + _ = listener.Close() + _ = os.Remove(socketPath) + _ = controller.close() + d.clearGuestSessionController(session.ID) + }() + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + if err := controller.setAttach(conn); err != nil { + _ = sessionstream.WriteControl(conn, sessionstream.ControlMessage{Type: "error", Error: err.Error()}) + return + } + defer controller.clearAttach(conn) + if err := d.attachGuestSessionBridge(session, controller); err != nil { + _ = sessionstream.WriteControl(conn, sessionstream.ControlMessage{Type: "error", Error: err.Error()}) + return + } + for { + channel, payload, err := sessionstream.ReadFrame(conn) + if err != nil { + return + } + switch channel { + case sessionstream.ChannelStdin: + if controller.stdin == nil { + continue + } + if _, err := controller.stdin.Write(payload); err != nil { + _ = sessionstream.WriteControl(conn, sessionstream.ControlMessage{Type: "error", Error: err.Error()}) + return + } + case sessionstream.ChannelControl: + message, err := sessionstream.ReadControl(payload) + if err != nil { + _ = sessionstream.WriteControl(conn, sessionstream.ControlMessage{Type: "error", Error: err.Error()}) + return + } + if message.Type == "eof" && controller.stdin != nil { + _ = controller.stdin.Close() + } + } + } +} + +func (d *Daemon) attachGuestSessionBridge(session model.GuestSession, controller *guestSessionController) error { + vm, err := d.store.GetVMByID(context.Background(), session.VMID) + if err != nil { + return err + } + if vm.State != model.VMStateRunning || !system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) { + return fmt.Errorf("vm %q is not running", vm.Name) + } + address := net.JoinHostPort(vm.Runtime.GuestIP, "22") + stdinStream, err := d.openGuestSessionAttachStream(address, guestSessionAttachInputCommand(session.ID)) + if err != nil { + return fmt.Errorf("open guest session stdin stream: %w", err) + } + stdoutStream, err := d.openGuestSessionAttachStream(address, guestSessionAttachTailCommand(session.StdoutLogPath)) + if err != nil { + _ = stdinStream.Close() + return fmt.Errorf("open guest session stdout stream: %w", err) + } + stderrStream, err := d.openGuestSessionAttachStream(address, guestSessionAttachTailCommand(session.StderrLogPath)) + if err != nil { + _ = stdinStream.Close() + _ = stdoutStream.Close() + return fmt.Errorf("open guest session stderr stream: %w", err) + } + controller.streams = append(controller.streams, stdinStream, stdoutStream, stderrStream) + controller.stdin = stdinStream.Stdin() + go d.forwardGuestSessionOutput(session.ID, controller, sessionstream.ChannelStdout, stdoutStream.Stdout()) + go d.forwardGuestSessionOutput(session.ID, controller, sessionstream.ChannelStderr, stderrStream.Stdout()) + go d.watchGuestSessionAttach(session.ID, controller, session) + return nil +} + +func (d *Daemon) openGuestSessionAttachStream(address, command string) (*guest.StreamSession, error) { + client, err := guest.Dial(context.Background(), address, d.config.SSHKeyPath) + if err != nil { + return nil, err + } + stream, err := client.StartCommand(context.Background(), command) + if err != nil { + _ = client.Close() + return nil, err + } + return stream, nil +} + +func (d *Daemon) watchGuestSessionAttach(id string, controller *guestSessionController, session model.GuestSession) { + ticker := time.NewTicker(250 * time.Millisecond) + defer ticker.Stop() + for range ticker.C { + vm, err := d.store.GetVMByID(context.Background(), session.VMID) + if err != nil { + controller.writeControl(sessionstream.ControlMessage{Type: "error", Error: err.Error()}) + _ = controller.close() + return + } + refreshed, err := d.refreshGuestSession(context.Background(), vm, session) + if err == nil { + session = refreshed + } + if session.Status == model.GuestSessionStatusExited || session.Status == model.GuestSessionStatusFailed { + controller.writeControl(sessionstream.ControlMessage{Type: "exit", ExitCode: session.ExitCode}) + _ = controller.close() + return + } + } +} + +func (d *Daemon) waitForGuestSessionReady(ctx context.Context, vm model.VMRecord, session model.GuestSession) (model.GuestSession, error) { + for { + updated, err := d.refreshGuestSession(ctx, vm, session) + if err == nil { + session = updated + if session.GuestPID != 0 || session.ExitCode != nil || session.Status == model.GuestSessionStatusRunning || session.Status == model.GuestSessionStatusFailed || session.Status == model.GuestSessionStatusExited { + return session, nil + } + } + select { + case <-ctx.Done(): + return session, ctx.Err() + case <-time.After(100 * time.Millisecond): + } + } +} + +func (d *Daemon) refreshGuestSession(ctx context.Context, vm model.VMRecord, session model.GuestSession) (model.GuestSession, error) { + if session.Status != model.GuestSessionStatusStarting && session.Status != model.GuestSessionStatusRunning && session.Status != model.GuestSessionStatusStopping { + return session, nil + } + snapshot, err := d.inspectGuestSessionState(ctx, vm, session) + if err != nil { + return session, err + } + original := session + applyGuestSessionSnapshot(&session, snapshot, vm.State == model.VMStateRunning && system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath)) + if guestSessionStateChanged(original, session) { + session.UpdatedAt = model.Now() + if err := d.store.UpsertGuestSession(ctx, session); err != nil { + return session, err + } + } + return session, nil +} + +func applyGuestSessionSnapshot(session *model.GuestSession, snapshot guestSessionStateSnapshot, vmRunning bool) { + if session == nil { + return + } + if snapshot.GuestPID != 0 { + session.GuestPID = snapshot.GuestPID + } + if snapshot.LastError != "" { + session.LastError = snapshot.LastError + } + if snapshot.ExitCode != nil { + session.ExitCode = snapshot.ExitCode + session.Attachable = false + session.Reattachable = false + if session.StartedAt.IsZero() { + session.StartedAt = model.Now() + } + if session.EndedAt.IsZero() { + session.EndedAt = model.Now() + } + if *snapshot.ExitCode == 0 { + session.Status = model.GuestSessionStatusExited + } else { + session.Status = model.GuestSessionStatusFailed + } + return + } + if snapshot.Alive { + if session.StartedAt.IsZero() { + session.StartedAt = model.Now() + } + session.Status = model.GuestSessionStatusRunning + return + } + if !vmRunning && (session.Status == model.GuestSessionStatusStarting || session.Status == model.GuestSessionStatusRunning || session.Status == model.GuestSessionStatusStopping) { + session.Status = model.GuestSessionStatusFailed + session.Attachable = false + session.Reattachable = false + if session.LastError == "" { + session.LastError = "vm is not running" + } + if session.EndedAt.IsZero() { + session.EndedAt = model.Now() + } + return + } + if snapshot.Status == string(model.GuestSessionStatusRunning) { + if session.StartedAt.IsZero() { + session.StartedAt = model.Now() + } + session.Status = model.GuestSessionStatusRunning + } + if session.Status == model.GuestSessionStatusRunning && session.StdinMode == model.GuestSessionStdinPipe { + session.Attachable = true + session.Reattachable = true + if session.AttachBackend == "" { + session.AttachBackend = guestSessionAttachBackendSSHBridge + } + if session.AttachMode == "" { + session.AttachMode = guestSessionAttachModeExclusive + } + } +} + +func (d *Daemon) inspectGuestSessionState(ctx context.Context, vm model.VMRecord, session model.GuestSession) (guestSessionStateSnapshot, error) { + if vm.State == model.VMStateRunning && system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) { + client, err := guest.Dial(ctx, net.JoinHostPort(vm.Runtime.GuestIP, "22"), d.config.SSHKeyPath) + if err != nil { + return guestSessionStateSnapshot{}, err + } + defer client.Close() + var output bytes.Buffer + if err := client.RunScript(ctx, guestSessionInspectScript(session.ID), &output); err != nil { + return guestSessionStateSnapshot{}, formatGuestSessionStepError("inspect guest session state", err, output.String()) + } + return parseGuestSessionState(output.String()) + } + return d.inspectGuestSessionStateFromWorkDisk(ctx, vm, session.ID) +} + +func (d *Daemon) inspectGuestSessionStateFromWorkDisk(ctx context.Context, vm model.VMRecord, sessionID string) (guestSessionStateSnapshot, error) { + runner := d.runner + if runner == nil { + runner = system.NewRunner() + } + workMount, cleanup, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false) + if err != nil { + return guestSessionStateSnapshot{}, err + } + defer cleanup() + stateDir := filepath.Join(workMount, guestSessionRelativeStateDir(sessionID)) + return inspectGuestSessionStateFromDir(stateDir) +} + +func inspectGuestSessionStateFromDir(stateDir string) (guestSessionStateSnapshot, error) { + var snapshot guestSessionStateSnapshot + statusData, _ := os.ReadFile(filepath.Join(stateDir, "status")) + snapshot.Status = strings.TrimSpace(string(statusData)) + pidData, _ := os.ReadFile(filepath.Join(stateDir, "pid")) + if pidValue, err := strconv.Atoi(strings.TrimSpace(string(pidData))); err == nil { + snapshot.GuestPID = pidValue + } + exitData, _ := os.ReadFile(filepath.Join(stateDir, "exit_code")) + if exitValue, err := strconv.Atoi(strings.TrimSpace(string(exitData))); err == nil { + snapshot.ExitCode = &exitValue + } + errorData, _ := os.ReadFile(filepath.Join(stateDir, "error")) + snapshot.LastError = strings.TrimSpace(string(errorData)) + if snapshot.GuestPID != 0 { + snapshot.Alive = processAlive(snapshot.GuestPID) + } + return snapshot, nil +} + +func (d *Daemon) readGuestSessionLog(ctx context.Context, vm model.VMRecord, session model.GuestSession, stream string, tailLines int) (string, error) { + if vm.State == model.VMStateRunning && system.ProcessRunning(vm.Runtime.PID, vm.Runtime.APISockPath) { + client, err := guest.Dial(ctx, net.JoinHostPort(vm.Runtime.GuestIP, "22"), d.config.SSHKeyPath) + if err != nil { + return "", err + } + defer client.Close() + path := session.StdoutLogPath + if stream == "stderr" { + path = session.StderrLogPath + } + var output bytes.Buffer + script := fmt.Sprintf("set -euo pipefail\nif [ -f %s ]; then tail -n %d %s; fi\n", guestShellQuote(path), tailLines, guestShellQuote(path)) + if err := client.RunScript(ctx, script, &output); err != nil { + return "", formatGuestSessionStepError("read guest session log", err, output.String()) + } + return output.String(), nil + } + runner := d.runner + if runner == nil { + runner = system.NewRunner() + } + workMount, cleanup, err := system.MountTempDir(ctx, runner, vm.Runtime.WorkDiskPath, false) + if err != nil { + return "", err + } + defer cleanup() + logPath := filepath.Join(workMount, guestSessionRelativeStateDir(session.ID), stream+".log") + return tailFileContent(logPath, tailLines) +} + +func (d *Daemon) findGuestSession(ctx context.Context, vmID, idOrName string) (model.GuestSession, error) { + if strings.TrimSpace(idOrName) == "" { + return model.GuestSession{}, errors.New("session id or name is required") + } + if session, err := d.store.GetGuestSession(ctx, vmID, idOrName); err == nil { + return session, nil + } + sessions, err := d.store.ListGuestSessionsByVM(ctx, vmID) + if err != nil { + return model.GuestSession{}, err + } + matches := make([]model.GuestSession, 0, 1) + for _, session := range sessions { + if strings.HasPrefix(session.ID, idOrName) || strings.HasPrefix(session.Name, idOrName) { + matches = append(matches, session) + } + } + switch len(matches) { + case 0: + return model.GuestSession{}, fmt.Errorf("session %q not found", idOrName) + case 1: + return matches[0], nil + default: + return model.GuestSession{}, fmt.Errorf("multiple sessions match %q", idOrName) + } +} + +func guestSessionScript(session model.GuestSession) string { + var script strings.Builder + script.WriteString("set -euo pipefail\n") + fmt.Fprintf(&script, "STATE_DIR=%s\n", guestShellQuote(session.GuestStateDir)) + fmt.Fprintf(&script, "STDOUT_LOG=%s\n", guestShellQuote(session.StdoutLogPath)) + fmt.Fprintf(&script, "STDERR_LOG=%s\n", guestShellQuote(session.StderrLogPath)) + fmt.Fprintf(&script, "PID_FILE=%s\n", guestShellQuote(guestSessionPIDPath(session.ID))) + fmt.Fprintf(&script, "MONITOR_PID_FILE=%s\n", guestShellQuote(guestSessionMonitorPIDPath(session.ID))) + fmt.Fprintf(&script, "EXIT_FILE=%s\n", guestShellQuote(guestSessionExitCodePath(session.ID))) + fmt.Fprintf(&script, "STATUS_FILE=%s\n", guestShellQuote(guestSessionStatusPath(session.ID))) + fmt.Fprintf(&script, "ERROR_FILE=%s\n", guestShellQuote(guestSessionErrorPath(session.ID))) + fmt.Fprintf(&script, "STDIN_PIPE=%s\n", guestShellQuote(guestSessionStdinPipePath(session.ID))) + fmt.Fprintf(&script, "STDIN_KEEPALIVE_PID_FILE=%s\n", guestShellQuote(guestSessionStdinKeepalivePIDPath(session.ID))) + fmt.Fprintf(&script, "SESSION_CWD=%s\n", guestShellQuote(defaultGuestSessionCWD(session.CWD))) + script.WriteString("mkdir -p \"$STATE_DIR\"\n") + script.WriteString(": >\"$STDOUT_LOG\"\n") + script.WriteString(": >\"$STDERR_LOG\"\n") + script.WriteString("rm -f \"$EXIT_FILE\" \"$ERROR_FILE\" \"$STDIN_KEEPALIVE_PID_FILE\"\n") + if session.StdinMode == model.GuestSessionStdinPipe { + script.WriteString("rm -f \"$STDIN_PIPE\"\n") + script.WriteString("mkfifo -m 600 \"$STDIN_PIPE\"\n") + } + script.WriteString("printf '%s\\n' \"${BASHPID:-$$}\" >\"$MONITOR_PID_FILE\"\n") + script.WriteString("printf 'starting\\n' >\"$STATUS_FILE\"\n") + script.WriteString("cd \"$SESSION_CWD\"\n") + script.WriteString("exec > >(tee -a \"$STDOUT_LOG\") 2> >(tee -a \"$STDERR_LOG\" >&2)\n") + for _, line := range guestSessionEnvLines(session.Env) { + script.WriteString(line) + script.WriteByte('\n') + } + script.WriteString("COMMAND=(") + for _, value := range append([]string{session.Command}, session.Args...) { + script.WriteByte(' ') + script.WriteString(guestShellQuote(value)) + } + script.WriteString(" )\n") + if session.StdinMode == model.GuestSessionStdinPipe { + script.WriteString("( while :; do sleep 3600; done ) >\"$STDIN_PIPE\" &\n") + script.WriteString("keepalive=$!\n") + script.WriteString("printf '%s\\n' \"$keepalive\" >\"$STDIN_KEEPALIVE_PID_FILE\"\n") + script.WriteString("\"${COMMAND[@]}\" <\"$STDIN_PIPE\" &\n") + } else { + script.WriteString("\"${COMMAND[@]}\" &\n") + } + script.WriteString("child=$!\n") + script.WriteString("printf '%s\\n' \"$child\" >\"$PID_FILE\"\n") + script.WriteString("printf 'running\\n' >\"$STATUS_FILE\"\n") + script.WriteString("wait \"$child\"\n") + script.WriteString("rc=$?\n") + if session.StdinMode == model.GuestSessionStdinPipe { + script.WriteString("if [ -f \"$STDIN_KEEPALIVE_PID_FILE\" ]; then kill \"$(cat \"$STDIN_KEEPALIVE_PID_FILE\")\" 2>/dev/null || true; fi\n") + } + script.WriteString("printf '%s\\n' \"$rc\" >\"$EXIT_FILE\"\n") + script.WriteString("if [ \"$rc\" -eq 0 ]; then printf 'exited\\n' >\"$STATUS_FILE\"; else printf 'failed\\n' >\"$STATUS_FILE\"; fi\n") + script.WriteString("exit \"$rc\"\n") + return script.String() +} + +func guestSessionInspectScript(sessionID string) string { + var script strings.Builder + script.WriteString("set -euo pipefail\n") + fmt.Fprintf(&script, "DIR=%s\n", guestShellQuote(guestSessionStateDir(sessionID))) + script.WriteString("status=''\n") + script.WriteString("pid=''\n") + script.WriteString("exit_code=''\n") + script.WriteString("last_error=''\n") + script.WriteString("alive=false\n") + script.WriteString("[ -f \"$DIR/status\" ] && status=\"$(cat \"$DIR/status\")\"\n") + script.WriteString("[ -f \"$DIR/pid\" ] && pid=\"$(cat \"$DIR/pid\")\"\n") + script.WriteString("[ -f \"$DIR/exit_code\" ] && exit_code=\"$(cat \"$DIR/exit_code\")\"\n") + script.WriteString("[ -f \"$DIR/error\" ] && last_error=\"$(cat \"$DIR/error\")\"\n") + script.WriteString("if [ -n \"$pid\" ] && kill -0 \"$pid\" 2>/dev/null; then alive=true; fi\n") + script.WriteString("printf 'status=%s\\n' \"$status\"\n") + script.WriteString("printf 'pid=%s\\n' \"$pid\"\n") + script.WriteString("printf 'exit=%s\\n' \"$exit_code\"\n") + script.WriteString("printf 'alive=%s\\n' \"$alive\"\n") + script.WriteString("printf 'error=%s\\n' \"$last_error\"\n") + return script.String() +} + +func guestSessionSignalScript(sessionID, signal string) string { + var script strings.Builder + script.WriteString("set -euo pipefail\n") + fmt.Fprintf(&script, "DIR=%s\n", guestShellQuote(guestSessionStateDir(sessionID))) + fmt.Fprintf(&script, "SIGNAL=%s\n", guestShellQuote(signal)) + script.WriteString("pid=''\n") + script.WriteString("monitor=''\n") + script.WriteString("keepalive=''\n") + script.WriteString("[ -f \"$DIR/pid\" ] && pid=\"$(cat \"$DIR/pid\")\"\n") + script.WriteString("[ -f \"$DIR/monitor_pid\" ] && monitor=\"$(cat \"$DIR/monitor_pid\")\"\n") + script.WriteString("[ -f \"$DIR/stdin_keepalive.pid\" ] && keepalive=\"$(cat \"$DIR/stdin_keepalive.pid\")\"\n") + script.WriteString("printf 'stopping\\n' >\"$DIR/status\"\n") + script.WriteString("if [ -n \"$pid\" ]; then kill -${SIGNAL} \"$pid\" 2>/dev/null || true; fi\n") + script.WriteString("if [ -n \"$monitor\" ]; then kill -${SIGNAL} \"$monitor\" 2>/dev/null || true; fi\n") + script.WriteString("if [ -n \"$keepalive\" ]; then kill -${SIGNAL} \"$keepalive\" 2>/dev/null || true; fi\n") + return script.String() +} + +func guestSessionStateDir(id string) string { + return filepath.ToSlash(filepath.Join(guestSessionStateRoot, id)) +} + +func guestSessionRelativeStateDir(id string) string { + return strings.TrimPrefix(guestSessionStateDir(id), "/root/") +} + +func guestSessionScriptPath(id string) string { + return filepath.ToSlash(filepath.Join(guestSessionStateDir(id), "run.sh")) +} + +func guestSessionPIDPath(id string) string { + return filepath.ToSlash(filepath.Join(guestSessionStateDir(id), "pid")) +} + +func guestSessionMonitorPIDPath(id string) string { + return filepath.ToSlash(filepath.Join(guestSessionStateDir(id), "monitor_pid")) +} + +func guestSessionExitCodePath(id string) string { + return filepath.ToSlash(filepath.Join(guestSessionStateDir(id), "exit_code")) +} + +func guestSessionStdinPipePath(id string) string { + return filepath.ToSlash(filepath.Join(guestSessionStateDir(id), "stdin.pipe")) +} + +func guestSessionStdinKeepalivePIDPath(id string) string { + return filepath.ToSlash(filepath.Join(guestSessionStateDir(id), "stdin_keepalive.pid")) +} + +func guestSessionStatusPath(id string) string { + return filepath.ToSlash(filepath.Join(guestSessionStateDir(id), "status")) +} + +func guestSessionErrorPath(id string) string { + return filepath.ToSlash(filepath.Join(guestSessionStateDir(id), "error")) +} + +func guestSessionStdoutLogPath(id string) string { + return filepath.ToSlash(filepath.Join(guestSessionStateDir(id), "stdout.log")) +} + +func guestSessionStderrLogPath(id string) string { + return filepath.ToSlash(filepath.Join(guestSessionStateDir(id), "stderr.log")) +} + +func defaultGuestSessionName(id, command, explicit string) string { + if trimmed := strings.TrimSpace(explicit); trimmed != "" { + return trimmed + } + base := filepath.Base(strings.TrimSpace(command)) + if base == "." || base == string(filepath.Separator) || base == "" { + base = "session" + } + return base + "-" + system.ShortID(id) +} + +func defaultGuestSessionCWD(value string) string { + if trimmed := strings.TrimSpace(value); trimmed != "" { + return trimmed + } + return "/root" +} + +func failGuestSessionLaunch(session model.GuestSession, stage, message, rawLog string) model.GuestSession { + now := model.Now() + session.Status = model.GuestSessionStatusFailed + session.LastError = strings.TrimSpace(message) + session.Attachable = false + session.Reattachable = false + session.LaunchStage = strings.TrimSpace(stage) + session.LaunchMessage = strings.TrimSpace(message) + session.LaunchRawLog = strings.TrimSpace(rawLog) + session.UpdatedAt = now + session.EndedAt = now + return session +} + +func normalizeGuestSessionRequiredCommands(command string, extras []string) []string { + ordered := make([]string, 0, len(extras)+1) + seen := map[string]struct{}{} + appendValue := func(value string) { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return + } + if _, ok := seen[trimmed]; ok { + return + } + seen[trimmed] = struct{}{} + ordered = append(ordered, trimmed) + } + appendValue(command) + for _, extra := range extras { + appendValue(extra) + } + return ordered +} + +func guestSessionCWDPreflightScript(cwd string) string { + var script strings.Builder + script.WriteString("set -euo pipefail\\n") + fmt.Fprintf(&script, "DIR=%s\\n", guestShellQuote(defaultGuestSessionCWD(cwd))) + script.WriteString("if [ ! -d \"$DIR\" ]; then echo \"missing cwd: $DIR\"; exit 1; fi\\n") + return script.String() +} + +func guestSessionCommandPreflightScript(commands []string) string { + var script strings.Builder + script.WriteString("set -euo pipefail\\n") + script.WriteString("check_command() {\\n") + script.WriteString(" cmd=\\\"$1\\\"\\n") + script.WriteString(" case \\\"$cmd\\\" in\\n") + script.WriteString(" */*) [ -x \\\"$cmd\\\" ] || { echo \\\"missing command: $cmd\\\"; exit 1; } ;;\\n") + script.WriteString(" *) command -v \\\"$cmd\\\" >/dev/null 2>&1 || { echo \\\"missing command: $cmd\\\"; exit 1; } ;;\\n") + script.WriteString(" esac\\n") + script.WriteString("}\\n") + for _, command := range commands { + fmt.Fprintf(&script, "check_command %s\\n", guestShellQuote(command)) + } + return script.String() +} + +func guestSessionAttachInputCommand(sessionID string) string { + path := guestSessionStdinPipePath(sessionID) + return "bash -lc " + guestShellQuote(fmt.Sprintf("set -euo pipefail\\n[ -p %s ] || mkfifo -m 600 %s\\nexec cat > %s\\n", guestShellQuote(path), guestShellQuote(path), guestShellQuote(path))) +} + +func guestSessionAttachTailCommand(path string) string { + return "bash -lc " + guestShellQuote(fmt.Sprintf("set -euo pipefail\\ntouch %s\\nexec tail -n 0 -F %s 2>/dev/null\\n", guestShellQuote(path), guestShellQuote(path))) +} + +func guestSessionEnvLines(values map[string]string) []string { + if len(values) == 0 { + return nil + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + lines := make([]string, 0, len(keys)) + for _, key := range keys { + lines = append(lines, "export "+key+"="+guestShellQuote(values[key])) + } + return lines +} + +func guestShellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'" +} + +func parseGuestSessionState(raw string) (guestSessionStateSnapshot, error) { + var snapshot guestSessionStateSnapshot + scanner := bufio.NewScanner(strings.NewReader(raw)) + for scanner.Scan() { + line := scanner.Text() + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + switch strings.TrimSpace(key) { + case "status": + snapshot.Status = strings.TrimSpace(value) + case "pid": + if pid, err := strconv.Atoi(strings.TrimSpace(value)); err == nil { + snapshot.GuestPID = pid + } + case "exit": + if exitCode, err := strconv.Atoi(strings.TrimSpace(value)); err == nil { + snapshot.ExitCode = &exitCode + } + case "alive": + snapshot.Alive = strings.TrimSpace(value) == "true" + case "error": + snapshot.LastError = strings.TrimSpace(value) + } + } + return snapshot, scanner.Err() +} + +func guestSessionExitCode(err error) (int, bool) { + if err == nil { + return 0, true + } + var exitErr *ssh.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitStatus(), true + } + return 0, false +} + +func cloneStringMap(values map[string]string) map[string]string { + if len(values) == 0 { + return nil + } + cloned := make(map[string]string, len(values)) + for key, value := range values { + cloned[key] = value + } + return cloned +} + +func tailFileContent(path string, lines int) (string, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + if lines <= 0 { + return string(data), nil + } + parts := strings.Split(string(data), "\n") + if len(parts) <= lines { + return string(data), nil + } + return strings.Join(parts[len(parts)-lines-1:], "\n"), nil +} + +func processAlive(pid int) bool { + if pid <= 0 { + return false + } + return syscallKill(pid, syscall.Signal(0)) == nil +} + +var syscallKill = func(pid int, signal os.Signal) error { + proc, err := os.FindProcess(pid) + if err != nil { + return err + } + return proc.Signal(signal) +} + +func formatGuestSessionStepError(action string, err error, log string) error { + log = strings.TrimSpace(log) + if log == "" { + return fmt.Errorf("%s: %w", action, err) + } + return fmt.Errorf("%s: %w: %s", action, err, log) +} + +func guestSessionStateChanged(before, after model.GuestSession) bool { + if before.Status != after.Status || before.GuestPID != after.GuestPID || before.LastError != after.LastError || before.Attachable != after.Attachable || before.Reattachable != after.Reattachable || before.AttachBackend != after.AttachBackend || before.AttachMode != after.AttachMode || before.LaunchStage != after.LaunchStage || before.LaunchMessage != after.LaunchMessage || before.LaunchRawLog != after.LaunchRawLog { + return true + } + if before.StartedAt != after.StartedAt || before.EndedAt != after.EndedAt { + return true + } + switch { + case before.ExitCode == nil && after.ExitCode == nil: + return false + case before.ExitCode == nil || after.ExitCode == nil: + return true + default: + return *before.ExitCode != *after.ExitCode + } +} diff --git a/internal/daemon/imagebuild.go b/internal/daemon/imagebuild.go index fbff27b..ff19215 100644 --- a/internal/daemon/imagebuild.go +++ b/internal/daemon/imagebuild.go @@ -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)) diff --git a/internal/daemon/imagebuild_test.go b/internal/daemon/imagebuild_test.go index 3a42612..9c7fc44 100644 --- a/internal/daemon/imagebuild_test.go +++ b/internal/daemon/imagebuild_test.go @@ -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)"`, diff --git a/internal/daemon/vm.go b/internal/daemon/vm.go index 450bd4e..b2dff48 100644 --- a/internal/daemon/vm.go +++ b/internal/daemon/vm.go @@ -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 diff --git a/internal/daemon/vm_test.go b/internal/daemon/vm_test.go index d487125..8050423 100644 --- a/internal/daemon/vm_test.go +++ b/internal/daemon/vm_test.go @@ -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") { diff --git a/internal/daemon/workspace.go b/internal/daemon/workspace.go new file mode 100644 index 0000000..1bc396a --- /dev/null +++ b/internal/daemon/workspace.go @@ -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, ©Log); 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, ©Log); 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() +} diff --git a/internal/guest/ssh.go b/internal/guest/ssh.go index 2f6af93..193e058 100644 --- a/internal/guest/ssh.go +++ b/internal/guest/ssh.go @@ -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") diff --git a/internal/model/types.go b/internal/model/types.go index 0cfb904..b171311 100644 --- a/internal/model/types.go +++ b/internal/model/types.go @@ -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) } diff --git a/internal/sessionstream/sessionstream.go b/internal/sessionstream/sessionstream.go new file mode 100644 index 0000000..7167f43 --- /dev/null +++ b/internal/sessionstream/sessionstream.go @@ -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) +} diff --git a/internal/store/store.go b/internal/store/store.go index 1ef1dca..ca73a1d 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -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 +} diff --git a/scripts/customize.sh b/scripts/customize.sh index eacc51e..13eebed 100755 --- a/scripts/customize.sh +++ b/scripts/customize.sh @@ -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 diff --git a/scripts/make-rootfs-void.sh b/scripts/make-rootfs-void.sh index b8f62da..f8cb9fb 100755 --- a/scripts/make-rootfs-void.sh +++ b/scripts/make-rootfs-void.sh @@ -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 <&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"