Bootstrap vm run tooling before attach

Speed up first use of repo backed VMs by bootstrapping obvious tools before
the best effort LLM harness runs.

Add a host side tooling plan for pinned Go, Node, Python, and Rust versions,
summarize that plan in the uploaded prompt, and run repo mise install plus
guest global mise use -g --pin steps before the bounded opencode inspection.

Keep the harness non fatal, prefer host opencode attach when the client
supports it, fall back to guest opencode over SSH for older clients, and
cover the new flow with CLI plus planner tests.

Validation:
- go test ./internal/cli ./internal/toolingplan
- GOCACHE=/tmp/banger-gocache go test ./...
- make build
This commit is contained in:
Thales Maciel 2026-03-29 11:38:05 -03:00
parent 1e967140c3
commit 4813e844e2
No known key found for this signature in database
GPG key ID: 33112E6833C34679
10 changed files with 1126 additions and 13 deletions

View file

@ -30,6 +30,7 @@ import (
"banger/internal/paths"
"banger/internal/rpc"
"banger/internal/system"
"banger/internal/toolingplan"
"banger/internal/vmdns"
"banger/internal/vsockagent"
@ -56,7 +57,8 @@ var (
opencodeCmd.Stdin = stdin
return opencodeCmd.Run()
}
hostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) {
hostOpencodeAttachSupportedFunc = hostOpencodeAttachSupported
hostCommandOutputFunc = func(ctx context.Context, name string, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, name, args...)
output, err := cmd.CombinedOutput()
if err == nil {
@ -94,12 +96,14 @@ var (
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
return guest.Dial(ctx, address, privateKeyPath)
}
prepareVMRunRepoCopyFunc = prepareVMRunRepoCopy
cwdFunc = os.Getwd
prepareVMRunRepoCopyFunc = prepareVMRunRepoCopy
buildVMRunToolingPlanFunc = toolingplan.Build
cwdFunc = os.Getwd
)
type vmRunGuestClient interface {
Close() error
UploadFile(ctx context.Context, remotePath string, mode os.FileMode, data []byte, logWriter io.Writer) error
RunScript(ctx context.Context, script string, logWriter io.Writer) error
StreamTar(ctx context.Context, sourceDir, remoteCommand string, logWriter io.Writer) error
StreamTarEntries(ctx context.Context, sourceDir string, entries []string, remoteCommand string, logWriter io.Writer) error
@ -121,6 +125,22 @@ 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",
@ -1613,8 +1633,10 @@ func runVMRun(ctx context.Context, socketPath string, cfg model.DaemonConfig, st
if err := importVMRunRepoToGuest(ctx, client, spec, progress); err != nil {
return fmt.Errorf("vm %q is running but repo import failed: %w", vmRef, err)
}
progress.render("attaching opencode")
if err := runVMRunAttach(ctx, stdin, stdout, stderr, vm.Runtime.GuestIP, vmRunGuestDir(spec.RepoName)); err != nil {
if err := startVMRunToolingHarness(ctx, client, spec, progress); err != nil {
printVMRunWarning(stderr, fmt.Sprintf("tooling harness start failed: %v", err))
}
if err := runVMRunAttach(ctx, socketPath, vmRef, cfg, stdin, stdout, stderr, vm.Runtime.GuestIP, vmRunGuestDir(spec.RepoName), progress); err != nil {
return fmt.Errorf("vm %q is running but opencode attach failed: %w", vmRef, err)
}
return nil
@ -1736,16 +1758,196 @@ func vmRunGuestDir(repoName string) string {
return filepath.ToSlash(filepath.Join("/root", repoName))
}
func runVMRunAttach(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, guestIP, guestDir string) error {
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")
}
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())
}
var launchLog bytes.Buffer
if err := client.RunScript(ctx, vmRunToolingHarnessLaunchScript(spec), &launchLog); err != nil {
return formatVMRunStepError("launch tooling harness", err, launchLog.String())
}
if progress != nil {
progress.render("tooling harness 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")
fmt.Fprintf(&script, "DIR=%s\n", shellQuote(vmRunGuestDir(spec.RepoName)))
script.WriteString("export PATH=/usr/local/bin:/root/.local/share/mise/shims:$PATH\n")
script.WriteString("if [ -f /etc/profile.d/mise.sh ]; then . /etc/profile.d/mise.sh || true; fi\n")
script.WriteString("log() { printf '%s\\n' \"$*\"; }\n")
script.WriteString("run_best_effort() {\n")
script.WriteString(" \"$@\"\n")
script.WriteString(" rc=$?\n")
script.WriteString(" if [ \"$rc\" -ne 0 ]; then\n")
script.WriteString(" log \"command failed ($rc): $*\"\n")
script.WriteString(" fi\n")
script.WriteString(" return 0\n")
script.WriteString("}\n")
script.WriteString("run_bounded_best_effort() {\n")
script.WriteString(" timeout_secs=\"$1\"\n")
script.WriteString(" shift\n")
script.WriteString(" timeout_marker=\"$(mktemp)\"\n")
script.WriteString(" rm -f \"$timeout_marker\"\n")
script.WriteString(" \"$@\" &\n")
script.WriteString(" cmd_pid=$!\n")
script.WriteString(" (\n")
script.WriteString(" sleep \"$timeout_secs\"\n")
script.WriteString(" if kill -0 \"$cmd_pid\" 2>/dev/null; then\n")
script.WriteString(" : >\"$timeout_marker\"\n")
script.WriteString(" log \"command timed out after ${timeout_secs}s: $*\"\n")
script.WriteString(" kill -TERM \"$cmd_pid\" 2>/dev/null || true\n")
script.WriteString(" if command -v pkill >/dev/null 2>&1; then pkill -TERM -P \"$cmd_pid\" 2>/dev/null || true; fi\n")
script.WriteString(" sleep 2\n")
script.WriteString(" kill -KILL \"$cmd_pid\" 2>/dev/null || true\n")
script.WriteString(" if command -v pkill >/dev/null 2>&1; then pkill -KILL -P \"$cmd_pid\" 2>/dev/null || true; fi\n")
script.WriteString(" fi\n")
script.WriteString(" ) &\n")
script.WriteString(" watchdog_pid=$!\n")
script.WriteString(" wait \"$cmd_pid\"\n")
script.WriteString(" rc=$?\n")
script.WriteString(" kill \"$watchdog_pid\" 2>/dev/null || true\n")
script.WriteString(" wait \"$watchdog_pid\" 2>/dev/null || true\n")
script.WriteString(" if [ -f \"$timeout_marker\" ]; then\n")
script.WriteString(" rm -f \"$timeout_marker\"\n")
script.WriteString(" return 0\n")
script.WriteString(" fi\n")
script.WriteString(" rm -f \"$timeout_marker\"\n")
script.WriteString(" if [ \"$rc\" -ne 0 ]; then\n")
script.WriteString(" log \"command failed ($rc): $*\"\n")
script.WriteString(" fi\n")
script.WriteString(" return 0\n")
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 [ -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")
script.WriteString("fi\n")
fmt.Fprintf(&script, "INSTALL_TIMEOUT_SECS=%d\n", vmRunToolingInstallTimeoutSeconds)
for _, step := range plan.Steps {
stepLabel := fmt.Sprintf("deterministic install: %s@%s (%s)", step.Tool, step.Version, step.Source)
fmt.Fprintf(&script, "log %s\n", shellQuote(stepLabel))
fmt.Fprintf(&script, "run_bounded_best_effort \"$INSTALL_TIMEOUT_SECS\" \"$MISE_BIN\" use -g --pin %s\n", shellQuote(step.Tool+"@"+step.Version))
}
for _, skip := range plan.Skips {
skipLabel := fmt.Sprintf("deterministic skip: %s (%s)", skip.Target, skip.Reason)
fmt.Fprintf(&script, "log %s\n", shellQuote(skipLabel))
}
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")
return script.String()
}
func vmRunToolingHarnessLaunchScript(spec vmRunRepoSpec) string {
var script strings.Builder
script.WriteString("set -euo pipefail\n")
fmt.Fprintf(&script, "HELPER=%s\n", shellQuote(vmRunToolingHarnessPath(spec.RepoName)))
fmt.Fprintf(&script, "LOG=%s\n", shellQuote(vmRunToolingHarnessLogPath(spec.RepoName)))
script.WriteString("mkdir -p \"$(dirname \"$LOG\")\"\n")
script.WriteString("nohup bash \"$HELPER\" >\"$LOG\" 2>&1 </dev/null &\n")
script.WriteString("disown || true\n")
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")
}
return opencodeExecFunc(ctx, stdin, stdout, stderr, []string{
"attach",
"--dir", guestDir,
"http://" + net.JoinHostPort(guestIP, "4096"),
})
supportsAttach, err := hostOpencodeAttachSupportedFunc(ctx)
if err != nil {
printVMRunWarning(stderr, fmt.Sprintf("could not detect host opencode attach support: %v", err))
}
if supportsAttach {
if progress != nil {
progress.render("attaching opencode")
}
return opencodeExecFunc(ctx, stdin, stdout, stderr, []string{
"attach",
"--dir", guestDir,
"http://" + net.JoinHostPort(guestIP, "4096"),
})
}
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")
}
func formatVMRunStepError(action string, err error, log string) error {
@ -1789,6 +1991,14 @@ func formatVMRunProgress(detail string) string {
return "[vm run] " + detail
}
func printVMRunWarning(out io.Writer, detail string) {
detail = strings.TrimSpace(detail)
if out == nil || detail == "" {
return
}
_, _ = fmt.Fprintln(out, "[vm run] warning: "+detail)
}
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'"'"'`) + "'"
}

View file

@ -18,6 +18,7 @@ import (
"banger/internal/buildinfo"
"banger/internal/model"
"banger/internal/system"
"banger/internal/toolingplan"
)
func TestNewBangerCommandHasExpectedSubcommands(t *testing.T) {
@ -1050,7 +1051,9 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
origWaitForSSH := guestWaitForSSHFunc
origGuestDial := guestDialFunc
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
origBuildVMRunToolingPlan := buildVMRunToolingPlanFunc
origOpencodeExec := opencodeExecFunc
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
vmCreateStatusFunc = origStatus
@ -1058,7 +1061,9 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
guestWaitForSSHFunc = origWaitForSSH
guestDialFunc = origGuestDial
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
buildVMRunToolingPlanFunc = origBuildVMRunToolingPlan
opencodeExecFunc = origOpencodeExec
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
})
vm := model.VMRecord{
@ -1113,6 +1118,15 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
}
return repoCopyDir, func() {}, nil
}
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
return true, 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"}},
}
}
var attachArgs []string
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
attachArgs = append([]string(nil), args...)
@ -1165,6 +1179,54 @@ func TestRunVMRunCreatesImportsAndAttaches(t *testing.T) {
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 len(fakeClient.uploads) != 2 {
t.Fatalf("uploads = %d, want 2", len(fakeClient.uploads))
}
if fakeClient.uploads[0].path != vmRunToolingHarnessPromptPath("repo") {
t.Fatalf("prompt upload path = %q, want %q", fakeClient.uploads[0].path, vmRunToolingHarnessPromptPath("repo"))
}
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 fakeClient.uploadPath != vmRunToolingHarnessPath("repo") {
t.Fatalf("uploadPath = %q, want %q", fakeClient.uploadPath, vmRunToolingHarnessPath("repo"))
}
if fakeClient.uploadMode != 0o755 {
t.Fatalf("uploadMode = %v, want 0755", fakeClient.uploadMode)
}
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))
}
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(fakeClient.launchScript, `nohup bash "$HELPER" >"$LOG" 2>&1 </dev/null &`) {
t.Fatalf("launchScript = %q, want nohup launcher", fakeClient.launchScript)
}
if !strings.Contains(fakeClient.launchScript, vmRunToolingHarnessLogPath("repo")) {
t.Fatalf("launchScript = %q, want tooling harness log path", fakeClient.launchScript)
}
if !strings.Contains(fakeClient.script, `git -C "$DIR" checkout -B 'feature' 'cafebabe'`) {
t.Fatalf("script = %q, want guest branch checkout", fakeClient.script)
}
@ -1206,6 +1268,7 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
origGuestDial := guestDialFunc
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
origOpencodeExec := opencodeExecFunc
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
vmCreateStatusFunc = origStatus
@ -1214,6 +1277,7 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
guestDialFunc = origGuestDial
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
opencodeExecFunc = origOpencodeExec
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
})
vm := model.VMRecord{
@ -1253,6 +1317,9 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
return t.TempDir(), func() {}, nil
}
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
return true, nil
}
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
return nil
}
@ -1279,6 +1346,8 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
"[vm run] copying repo metadata to guest",
"[vm run] preparing guest checkout",
"[vm run] overlaying host working tree",
"[vm run] starting tooling harness",
"[vm run] tooling harness log: /root/.cache/banger/vm-run-tooling-repo.log",
"[vm run] attaching opencode",
} {
if !strings.Contains(output, want) {
@ -1287,6 +1356,219 @@ func TestVMRunPrintsPostCreateProgress(t *testing.T) {
}
}
func TestRunVMRunWarnsWhenToolingHarnessStartFails(t *testing.T) {
origBegin := vmCreateBeginFunc
origStatus := vmCreateStatusFunc
origCancel := vmCreateCancelFunc
origWaitForSSH := guestWaitForSSHFunc
origGuestDial := guestDialFunc
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
origOpencodeExec := opencodeExecFunc
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
vmCreateStatusFunc = origStatus
vmCreateCancelFunc = origCancel
guestWaitForSSHFunc = origWaitForSSH
guestDialFunc = origGuestDial
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
opencodeExecFunc = origOpencodeExec
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
})
vm := model.VMRecord{
ID: "vm-id",
Name: "devbox",
Runtime: model.VMRuntime{
State: model.VMStateRunning,
GuestIP: "172.16.0.2",
},
}
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
return api.VMCreateBeginResult{Operation: api.VMCreateOperation{ID: "op-1", Stage: "ready", Detail: "vm is ready", Done: true, Success: true, VM: &vm}}, nil
}
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
t.Fatal("vmCreateStatusFunc should not be called")
return api.VMCreateStatusResult{}, nil
}
vmCreateCancelFunc = func(context.Context, string, string) error {
t.Fatal("vmCreateCancelFunc should not be called")
return nil
}
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
return nil
}
fakeClient := &testVMRunGuestClient{launchErr: errors.New("launch failed")}
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
return fakeClient, nil
}
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
return t.TempDir(), func() {}, nil
}
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
return true, nil
}
attachCalled := false
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
attachCalled = true
return nil
}
var stderr bytes.Buffer
err := runVMRun(
context.Background(),
"/tmp/bangerd.sock",
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
strings.NewReader(""),
&bytes.Buffer{},
&stderr,
api.VMCreateParams{Name: "devbox"},
vmRunRepoSpec{RepoRoot: t.TempDir(), RepoName: "repo", HeadCommit: "deadbeef"},
)
if err != nil {
t.Fatalf("runVMRun: %v", err)
}
if !attachCalled {
t.Fatal("opencode attach should still run when tooling harness launch fails")
}
if !strings.Contains(stderr.String(), "[vm run] warning: tooling harness start failed: launch tooling harness: launch failed") {
t.Fatalf("stderr = %q, want tooling harness warning", stderr.String())
}
}
func TestRunVMRunFallsBackToGuestOpencodeWhenHostAttachUnsupported(t *testing.T) {
repoRoot := t.TempDir()
origBegin := vmCreateBeginFunc
origStatus := vmCreateStatusFunc
origCancel := vmCreateCancelFunc
origWaitForSSH := guestWaitForSSHFunc
origGuestDial := guestDialFunc
origPrepareVMRunRepoCopy := prepareVMRunRepoCopyFunc
origOpencodeExec := opencodeExecFunc
origHostOpencodeAttachSupported := hostOpencodeAttachSupportedFunc
origSSHExec := sshExecFunc
t.Cleanup(func() {
vmCreateBeginFunc = origBegin
vmCreateStatusFunc = origStatus
vmCreateCancelFunc = origCancel
guestWaitForSSHFunc = origWaitForSSH
guestDialFunc = origGuestDial
prepareVMRunRepoCopyFunc = origPrepareVMRunRepoCopy
opencodeExecFunc = origOpencodeExec
hostOpencodeAttachSupportedFunc = origHostOpencodeAttachSupported
sshExecFunc = origSSHExec
})
vm := model.VMRecord{
ID: "vm-id",
Name: "devbox",
Runtime: model.VMRuntime{
State: model.VMStateRunning,
GuestIP: "172.16.0.2",
},
}
vmCreateBeginFunc = func(context.Context, string, api.VMCreateParams) (api.VMCreateBeginResult, error) {
return api.VMCreateBeginResult{Operation: api.VMCreateOperation{ID: "op-1", Stage: "ready", Detail: "vm is ready", Done: true, Success: true, VM: &vm}}, nil
}
vmCreateStatusFunc = func(context.Context, string, string) (api.VMCreateStatusResult, error) {
t.Fatal("vmCreateStatusFunc should not be called")
return api.VMCreateStatusResult{}, nil
}
vmCreateCancelFunc = func(context.Context, string, string) error {
t.Fatal("vmCreateCancelFunc should not be called")
return nil
}
guestWaitForSSHFunc = func(ctx context.Context, address, privateKeyPath string, interval time.Duration) error {
return nil
}
guestDialFunc = func(ctx context.Context, address, privateKeyPath string) (vmRunGuestClient, error) {
return &testVMRunGuestClient{}, nil
}
prepareVMRunRepoCopyFunc = func(ctx context.Context, spec vmRunRepoSpec) (string, func(), error) {
return t.TempDir(), func() {}, nil
}
hostOpencodeAttachSupportedFunc = func(context.Context) (bool, error) {
return false, nil
}
opencodeExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
t.Fatalf("opencodeExecFunc should not be called when host attach is unsupported: %v", args)
return nil
}
var sshArgs []string
sshExecFunc = func(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args []string) error {
sshArgs = append([]string(nil), args...)
return nil
}
var stderr bytes.Buffer
err := runVMRun(
context.Background(),
"/tmp/bangerd.sock",
model.DaemonConfig{SSHKeyPath: "/tmp/id_ed25519"},
strings.NewReader(""),
&bytes.Buffer{},
&stderr,
api.VMCreateParams{Name: "devbox"},
vmRunRepoSpec{RepoRoot: repoRoot, RepoName: "repo", HeadCommit: "deadbeef"},
)
if err != nil {
t.Fatalf("runVMRun: %v", err)
}
if len(sshArgs) < 3 {
t.Fatalf("sshArgs = %v, want fallback SSH invocation", sshArgs)
}
if sshArgs[len(sshArgs)-3] != "bash" || sshArgs[len(sshArgs)-2] != "-lc" {
t.Fatalf("sshArgs = %v, want bash -lc fallback command", sshArgs)
}
if sshArgs[len(sshArgs)-1] != "cd '/root/repo' && exec opencode ." {
t.Fatalf("ssh fallback command = %q, want guest opencode launch", sshArgs[len(sshArgs)-1])
}
if !strings.Contains(stderr.String(), "[vm run] host opencode has no attach support; starting guest opencode over ssh") {
t.Fatalf("stderr = %q, want SSH fallback progress", stderr.String())
}
}
func TestOpencodeAttachHelpOutputSupported(t *testing.T) {
if !opencodeAttachHelpOutputSupported([]byte("opencode attach [url]\n\nAttach a terminal")) {
t.Fatal("expected attach help output to be recognized")
}
if opencodeAttachHelpOutputSupported([]byte("opencode [project]\n\nCommands:\n opencode run [message..]")) {
t.Fatal("unexpected attach support for top-level help output")
}
}
func TestVMRunToolingHarnessScriptUsesMiseOnly(t *testing.T) {
script := vmRunToolingHarnessScript(vmRunRepoSpec{RepoName: "repo"}, toolingplan.Plan{
Steps: []toolingplan.InstallStep{{Tool: "go", Version: "1.25.0", Source: "go.mod"}},
Skips: []toolingplan.SkipNote{{Target: "python", Reason: "no .python-version"}},
})
for _, want := range []string{
`if [ -f .mise.toml ] || [ -f .tool-versions ]; then`,
"PROMPT_FILE=" + shellQuote(vmRunToolingHarnessPromptPath("repo")),
fmt.Sprintf("INSTALL_TIMEOUT_SECS=%d", vmRunToolingInstallTimeoutSeconds),
"MODEL=" + shellQuote(vmRunToolingHarnessModel),
fmt.Sprintf("TIMEOUT_SECS=%d", vmRunToolingHarnessTimeoutSeconds),
`run_best_effort "$MISE_BIN" install`,
`deterministic install: go@1.25.0 (go.mod)`,
`run_bounded_best_effort "$INSTALL_TIMEOUT_SECS" "$MISE_BIN" use -g --pin 'go@1.25.0'`,
`deterministic skip: python (no .python-version)`,
`run_best_effort "$MISE_BIN" reshim`,
`run_bounded_best_effort "$TIMEOUT_SECS" bash -lc 'exec "$1" run --format json -m "$2" "$(cat "$3")"' _ "$OPENCODE_BIN" "$MODEL" "$PROMPT_FILE"`,
`command timed out after ${timeout_secs}s: $*`,
`tooling prompt file missing: $PROMPT_FILE`,
} {
if !strings.Contains(script, want) {
t.Fatalf("script = %q, want %q", script, want)
}
}
for _, unwanted := range []string{"git add", "cat > .mise.toml", "cat > .tool-versions"} {
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")
@ -1538,9 +1820,24 @@ func testRunGit(t *testing.T, dir string, args ...string) string {
return string(output)
}
type testVMRunUpload struct {
path string
mode os.FileMode
data []byte
}
type testVMRunGuestClient struct {
closed bool
uploads []testVMRunUpload
uploadPath string
uploadMode os.FileMode
uploadData []byte
uploadErr error
checkoutErr error
launchErr error
script string
launchScript string
runScriptCalls int
tarSourceDir string
tarCommand string
streamSourceDir string
@ -1553,6 +1850,15 @@ func (c *testVMRunGuestClient) Close() error {
return nil
}
func (c *testVMRunGuestClient) UploadFile(ctx context.Context, remotePath string, mode os.FileMode, data []byte, logWriter io.Writer) error {
copyData := append([]byte(nil), data...)
c.uploads = append(c.uploads, testVMRunUpload{path: remotePath, mode: mode, data: copyData})
c.uploadPath = remotePath
c.uploadMode = mode
c.uploadData = copyData
return c.uploadErr
}
func (c *testVMRunGuestClient) StreamTar(ctx context.Context, sourceDir, remoteCommand string, logWriter io.Writer) error {
c.tarSourceDir = sourceDir
c.tarCommand = remoteCommand
@ -1560,8 +1866,13 @@ func (c *testVMRunGuestClient) StreamTar(ctx context.Context, sourceDir, remoteC
}
func (c *testVMRunGuestClient) RunScript(ctx context.Context, script string, logWriter io.Writer) error {
c.script = script
return nil
c.runScriptCalls++
if c.runScriptCalls == 1 {
c.script = script
return c.checkoutErr
}
c.launchScript = script
return c.launchErr
}
func (c *testVMRunGuestClient) StreamTarEntries(ctx context.Context, sourceDir string, entries []string, remoteCommand string, logWriter io.Writer) error {