daemon: auto-trust mise configs on workspace prepare
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>.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 <guest_path> || 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) <noreply@anthropic.com>
This commit is contained in:
parent
fa4292756d
commit
c8637b0fe4
2 changed files with 126 additions and 4 deletions
|
|
@ -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)
|
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>.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:
|
// prepareVMWorkspaceGuestIO performs the actual guest-side work:
|
||||||
// inspect the local repo, dial SSH, stream the tar. Called without
|
// inspect the local repo, dial SSH, stream the tar. Called without
|
||||||
// holding the VM mutex.
|
// 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 {
|
if err := s.workspaceImportHook(ctx, client, spec, guestPath, mode); err != nil {
|
||||||
return model.WorkspacePrepareResult{}, err
|
return model.WorkspacePrepareResult{}, err
|
||||||
}
|
}
|
||||||
|
// Auto-trust mise configs in the imported repo. The guest is
|
||||||
|
// single-tenant (root@<vm>.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 -- <cmd>' invocation. Best-effort: a
|
||||||
|
// missing mise binary or a 'trust' that does nothing is fine.
|
||||||
|
s.miseTrustGuestRepo(ctx, client, guestPath)
|
||||||
return model.WorkspacePrepareResult{
|
return model.WorkspacePrepareResult{
|
||||||
VMID: vm.ID,
|
VMID: vm.ID,
|
||||||
SourcePath: spec.SourcePath,
|
SourcePath: spec.SourcePath,
|
||||||
|
|
|
||||||
|
|
@ -19,9 +19,10 @@ import (
|
||||||
// exportGuestClient is a scriptable fake for RunScriptOutput used in export tests.
|
// exportGuestClient is a scriptable fake for RunScriptOutput used in export tests.
|
||||||
// Each call to RunScriptOutput returns the next response from the queue.
|
// Each call to RunScriptOutput returns the next response from the queue.
|
||||||
type exportGuestClient struct {
|
type exportGuestClient struct {
|
||||||
responses []exportGuestResponse
|
responses []exportGuestResponse
|
||||||
scripts []string
|
scripts []string
|
||||||
callIndex int
|
callIndex int
|
||||||
|
runScriptLog []string
|
||||||
}
|
}
|
||||||
|
|
||||||
type exportGuestResponse struct {
|
type exportGuestResponse struct {
|
||||||
|
|
@ -31,7 +32,8 @@ type exportGuestResponse struct {
|
||||||
|
|
||||||
func (e *exportGuestClient) Close() error { return nil }
|
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
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue