From c8637b0fe42c606b036e25fc560adc388b40657c Mon Sep 17 00:00:00 2001 From: Thales Maciel Date: Sun, 26 Apr 2026 23:08:41 -0300 Subject: [PATCH] daemon: auto-trust mise configs on workspace prepare MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit vm run ./repo (and the explicit vm workspace prepare) imports the host user's own checkout. Any .mise.toml that lands in the guest would otherwise prompt on the first guest command — 'mise trust: hash mismatch, run "mise trust"' — and stall what should be a zero-friction sandbox launch. The repo just came from the host, the guest is single-tenant root@.vm, the user already trusts this checkout: auto-trust is the right default here. After workspaceImportHook succeeds, run if command -v mise >/dev/null 2>&1; then mise trust --quiet --all || true fi inside the guest. Best effort: a missing mise binary, a non-zero exit, or a no-op trust all log at debug only and never fail prepare. The path is shell-quoted via ws.ShellQuote so guest paths with spaces or quotes don't break the argument. Tests pin the script shape (command -v guard + --quiet --all flag + trailing `|| true`) and assert the script actually fires after a successful import. A path with an apostrophe round-trips via ws.ShellQuote without truncation. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/daemon/workspace.go | 40 ++++++++++++++ internal/daemon/workspace_test.go | 90 +++++++++++++++++++++++++++++-- 2 files changed, 126 insertions(+), 4 deletions(-) diff --git a/internal/daemon/workspace.go b/internal/daemon/workspace.go index 2dcf441..7a78ef6 100644 --- a/internal/daemon/workspace.go +++ b/internal/daemon/workspace.go @@ -184,6 +184,39 @@ func (s *WorkspaceService) PrepareVMWorkspace(ctx context.Context, params api.VM return s.prepareVMWorkspaceGuestIO(ctx, vm, strings.TrimSpace(params.SourcePath), guestPath, branchName, fromRef, mode, params.IncludeUntracked) } +// miseTrustGuestRepo runs `mise trust` against guestPath inside the +// guest so any .mise.toml / .tool-versions / mise.toml files in the +// imported repo become trusted without an interactive prompt. Best +// effort: a missing mise binary, a non-zero exit, or a trust that +// finds nothing all log at debug only and don't fail prepare. +// +// The guest is single-tenant root@.vm and the repo just came +// from the host user's own checkout, so auto-trust is safe in this +// context — the user has already validated the repo on the host. +func (s *WorkspaceService) miseTrustGuestRepo(ctx context.Context, client ws.GuestClient, guestPath string) { + script := miseTrustScript(guestPath) + if err := client.RunScript(ctx, script, miseTrustLogSink{}); err != nil && s.logger != nil { + s.logger.Debug("mise trust on imported workspace skipped", "guest_path", guestPath, "error", err.Error()) + } +} + +// miseTrustScript is the exact shell run inside the guest. Kept +// separate so a unit test can pin the string and confirm a future +// edit doesn't accidentally drop the `command -v` guard. +func miseTrustScript(guestPath string) string { + return fmt.Sprintf( + "if command -v mise >/dev/null 2>&1; then mise trust --quiet --all %s 2>/dev/null || true; fi\n", + ws.ShellQuote(guestPath), + ) +} + +// miseTrustLogSink discards anything mise wrote to stdout/stderr. +// We don't care about the output — success leaves mise silent and a +// failure is already covered by the err return path. +type miseTrustLogSink struct{} + +func (miseTrustLogSink) Write(p []byte) (int, error) { return len(p), nil } + // prepareVMWorkspaceGuestIO performs the actual guest-side work: // inspect the local repo, dial SSH, stream the tar. Called without // holding the VM mutex. @@ -207,6 +240,13 @@ func (s *WorkspaceService) prepareVMWorkspaceGuestIO(ctx context.Context, vm mod if err := s.workspaceImportHook(ctx, client, spec, guestPath, mode); err != nil { return model.WorkspacePrepareResult{}, err } + // Auto-trust mise configs in the imported repo. The guest is + // single-tenant (root@.vm), the repo just came from the + // host user's own checkout, and any .mise.toml landing in /root + // would otherwise prompt on the first guest command and stall a + // 'banger vm run ./repo -- ' invocation. Best-effort: a + // missing mise binary or a 'trust' that does nothing is fine. + s.miseTrustGuestRepo(ctx, client, guestPath) return model.WorkspacePrepareResult{ VMID: vm.ID, SourcePath: spec.SourcePath, diff --git a/internal/daemon/workspace_test.go b/internal/daemon/workspace_test.go index e98477d..c5aae6d 100644 --- a/internal/daemon/workspace_test.go +++ b/internal/daemon/workspace_test.go @@ -19,9 +19,10 @@ import ( // exportGuestClient is a scriptable fake for RunScriptOutput used in export tests. // Each call to RunScriptOutput returns the next response from the queue. type exportGuestClient struct { - responses []exportGuestResponse - scripts []string - callIndex int + responses []exportGuestResponse + scripts []string + callIndex int + runScriptLog []string } type exportGuestResponse struct { @@ -31,7 +32,8 @@ type exportGuestResponse struct { func (e *exportGuestClient) Close() error { return nil } -func (e *exportGuestClient) RunScript(_ context.Context, _ string, _ io.Writer) error { +func (e *exportGuestClient) RunScript(_ context.Context, script string, _ io.Writer) error { + e.runScriptLog = append(e.runScriptLog, script) return nil } @@ -602,3 +604,83 @@ func TestExportVMWorkspace_DoesNotMutateRealIndex(t *testing.T) { } } } + +// TestMiseTrustScriptShape pins the exact shell run inside the +// guest by miseTrustGuestRepo. The two contracts other code paths +// rely on: +// +// 1. The script never fails the prepare — `mise trust` is wrapped +// in `... || true` and gated on `command -v mise`, so a guest +// image without mise simply no-ops. +// 2. The path is shell-quoted via ws.ShellQuote, so a guest_path +// containing spaces, quotes, or other oddballs doesn't break +// out of the argument. +func TestMiseTrustScriptShape(t *testing.T) { + got := miseTrustScript("/root/repo") + for _, want := range []string{ + "command -v mise", + "mise trust --quiet --all '/root/repo'", + "|| true", + } { + if !strings.Contains(got, want) { + t.Errorf("script missing %q:\n%s", want, got) + } + } + + // Path with a single quote in it must come back quoted, not + // truncated. ws.ShellQuote escapes by closing/reopening the + // quoted string around each apostrophe. + exotic := miseTrustScript("/root/it's odd") + if !strings.Contains(exotic, `'/root/it'"'"'s odd'`) { + t.Errorf("path with apostrophe was not shell-quoted safely:\n%s", exotic) + } +} + +// TestPrepareVMWorkspace_RunsMiseTrustAfterImport asserts the auto- +// trust step fires once a successful import lands. Failure-path +// behaviour (no import → no trust) is covered by the existing +// rejection tests. +func TestPrepareVMWorkspace_RunsMiseTrustAfterImport(t *testing.T) { + t.Parallel() + ctx := context.Background() + + apiSock := filepath.Join(t.TempDir(), "fc.sock") + firecracker := startFakeFirecracker(t, apiSock) + + vm := testVM("trustbox", "image-trust", "172.16.0.211") + vm.State = model.VMStateRunning + vm.Runtime.State = model.VMStateRunning + vm.Runtime.APISockPath = apiSock + + fake := &exportGuestClient{} + d := newExportTestDaemonStore(t, fake) + d.guestWaitForSSH = func(_ context.Context, _, _ string, _ time.Duration) error { return nil } + upsertDaemonVM(t, ctx, d.store, vm) + d.vm.setVMHandlesInMemory(vm.ID, model.VMHandles{PID: firecracker.Process.Pid}) + + d.ws.workspaceInspectRepo = func(context.Context, string, string, string, bool) (workspace.RepoSpec, error) { + return workspace.RepoSpec{RepoName: "x", RepoRoot: "/tmp/x"}, nil + } + d.ws.workspaceImport = func(context.Context, workspace.GuestClient, workspace.RepoSpec, string, model.WorkspacePrepareMode) error { + return nil + } + + if _, err := d.ws.PrepareVMWorkspace(ctx, api.VMWorkspacePrepareParams{ + IDOrName: vm.Name, + SourcePath: "/tmp/x", + GuestPath: "/root/repo", + }); err != nil { + t.Fatalf("PrepareVMWorkspace: %v", err) + } + + var sawTrust bool + for _, script := range fake.runScriptLog { + if strings.Contains(script, "mise trust") { + sawTrust = true + break + } + } + if !sawTrust { + t.Fatalf("expected mise trust script after import; saw %d scripts: %v", len(fake.runScriptLog), fake.runScriptLog) + } +}