From aaf4fff3b19791157989c0722a2859d6beff24a9 Mon Sep 17 00:00:00 2001 From: Dmitry Kireev Date: Fri, 28 Aug 2026 07:46:21 +0000 Subject: [PATCH 01/11] =?UTF-8?q?Delete=20WSL=20transplant=20stubs=20?= =?UTF-8?q?=E2=80=94=20logic=20moved=20to=20go-winkit/winpe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refactor(qemu): remove transplant_wsl*.go — WSLEngineFiles, WSLInboxShim and TransplantWSLIntoBootWim now live in go-winkit/winpe, committed there in 3b7f219 --- internal/vm/qemu/transplant_wsl.go | 48 ---------- internal/vm/qemu/transplant_wsl_test.go | 69 -------------- internal/vm/qemu/transplant_wsl_wimlib.go | 111 ---------------------- 3 files changed, 228 deletions(-) delete mode 100644 internal/vm/qemu/transplant_wsl.go delete mode 100644 internal/vm/qemu/transplant_wsl_test.go delete mode 100644 internal/vm/qemu/transplant_wsl_wimlib.go diff --git a/internal/vm/qemu/transplant_wsl.go b/internal/vm/qemu/transplant_wsl.go deleted file mode 100644 index 7b62f4a8..00000000 --- a/internal/vm/qemu/transplant_wsl.go +++ /dev/null @@ -1,48 +0,0 @@ -package qemu - -// WSLEngineDestDir is where the WSL engine lands inside boot.wim — the same -// path the MSI installs to, so wslservice finds its own layout. -const WSLEngineDestDir = "Program Files/WSL" - -// WSLEngineFiles returns the trimmed WSL engine payload, relative to the -// MSI's WSL directory (PFiles64/WSL in an msiextract layout). -// -// Kept: the engine core plus the Linux kernel side — ~310 MB. Dropped: -// WSLg/RDP (incl. system.vhd, 411 MB), the wslsettings GUI (a .NET app, -// WinPE has no CLR anyway), msal auth and the language packs — ~650 MB -// that a headless WinPE guest cannot use. -func WSLEngineFiles() []string { - return []string{ - "wslservice.exe", - "wsl.exe", - "libwsl.dll", - "wsldeps.dll", - "wsldevicehost.dll", - "wslserviceproxystub.dll", - "wslhost.exe", - "wslrelay.exe", - "tools/kernel", - "tools/modules.vhd", - "tools/initrd.img", - "tools/init", - "tools/bsdtar", - } -} - -// WSLInboxShim returns the inbox WSL client files plus the kernel-side -// drivers that wslservice needs. The client trio (wsl.exe, wslapi.dll, -// wslsupport.dll) lives in System32 as real PEs even with WSL disabled. -// The WSL subsystem driver (lxss.sys) is a DCS stub in WinSxS and gets -// decompressed during extraction. p9rdr.sys, p9rdrservice.dll and -// lxutil.dll are real PEs in System32. -func WSLInboxShim() []ParityFile { - return []ParityFile{ - {Dest: "Windows/System32/wsl.exe"}, - {Dest: "Windows/System32/wslapi.dll"}, - {Dest: "Windows/System32/lxss/wslsupport.dll"}, - {Dest: "Windows/System32/drivers/lxss.sys", Component: "microsoft-windows-lxss"}, - {Dest: "Windows/System32/drivers/p9rdr.sys"}, - {Dest: "Windows/System32/p9rdrservice.dll"}, - {Dest: "Windows/System32/lxutil.dll"}, - } -} diff --git a/internal/vm/qemu/transplant_wsl_test.go b/internal/vm/qemu/transplant_wsl_test.go deleted file mode 100644 index da773773..00000000 --- a/internal/vm/qemu/transplant_wsl_test.go +++ /dev/null @@ -1,69 +0,0 @@ -package qemu - -import ( - "strings" - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" -) - -// The WSL engine is not a Windows feature — since 2022 it ships as an MSI -// from github.com/microsoft/WSL. WinPE has no Windows Installer service -// (verified against the stock hive), so the payload is laid down by the -// transplant and registered at boot by the pass4 script. - -func TestWSLEngineFiles_TrimmedPayload(t *testing.T) { - files := WSLEngineFiles() - require.NotEmpty(t, files) - - set := map[string]bool{} - for _, f := range files { - assert.False(t, strings.HasPrefix(f, "/"), "%s must be relative under the MSI's WSL dir", f) - set[f] = true - } - - // The engine core and the Linux side WSL2 cannot boot without. - for _, want := range []string{ - "wslservice.exe", "wsl.exe", "libwsl.dll", "wsldeps.dll", - "wsldevicehost.dll", "wslserviceproxystub.dll", "wslhost.exe", "wslrelay.exe", - "tools/kernel", "tools/modules.vhd", "tools/initrd.img", "tools/init", "tools/bsdtar", - } { - assert.True(t, set[want], "%s must be in the trimmed payload", want) - } - - // The trim IS the point: WSLg, the settings GUI and msrdc stay out - // (~650 MB). If the engine turns out to need system.vhd, add it back - // deliberately, not by reflex. - for _, drop := range []string{"system.vhd", "msrdc.exe", "wslg.exe", "msal.wsl.proxy.exe"} { - assert.False(t, set[drop], "%s must NOT be in the trimmed payload", drop) - } -} - -func TestWSLInboxShim_ComesFromInstallWimSystem32(t *testing.T) { - shim := WSLInboxShim() - require.NotEmpty(t, shim) - - dests := map[string]bool{} - for _, f := range shim { - dests[f.Dest] = true - } - assert.True(t, dests["Windows/System32/wsl.exe"]) - assert.True(t, dests["Windows/System32/wslapi.dll"]) - assert.True(t, dests["Windows/System32/lxss/wslsupport.dll"]) - assert.True(t, dests["Windows/System32/drivers/lxss.sys"]) - assert.True(t, dests["Windows/System32/drivers/p9rdr.sys"]) - assert.True(t, dests["Windows/System32/p9rdrservice.dll"]) - assert.True(t, dests["Windows/System32/lxutil.dll"]) -} - -func TestWSLInboxShim_LxssSysHasComponent(t *testing.T) { - for _, f := range WSLInboxShim() { - if f.Dest == "Windows/System32/drivers/lxss.sys" { - assert.Equal(t, "microsoft-windows-lxss", f.Component, - "lxss.sys is a DCS stub in WinSxS and needs a Component for resolution") - return - } - } - t.Fatal("lxss.sys not found in WSLInboxShim()") -} diff --git a/internal/vm/qemu/transplant_wsl_wimlib.go b/internal/vm/qemu/transplant_wsl_wimlib.go deleted file mode 100644 index c23b2f01..00000000 --- a/internal/vm/qemu/transplant_wsl_wimlib.go +++ /dev/null @@ -1,111 +0,0 @@ -//go:build wimlib - -package qemu - -import ( - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "github.com/devcell-sh/go-wimlib" -) - -// TransplantWSLIntoBootWim lays the WSL engine into a WinPE boot.wim. -// -// The engine ships as an MSI and WinPE has no Windows Installer service, -// so this places the trimmed payload at the MSI's own destination -// (Program Files/WSL) from an msiextract of the release, plus the inbox -// client shim out of install.wim's System32. Registration — WSLService, -// the proxy stub COM class — happens at boot in the pass4 script; files -// alone are inert, which is what makes this safe to always inject. -// -// wslDir is the extracted MSI's WSL directory (PFiles64/WSL). -func TransplantWSLIntoBootWim(bootWimPath, wslDir, installWimPath string) error { - return TransplantWSLIntoBootWimLogged(bootWimPath, wslDir, installWimPath, nil) -} - -// TransplantWSLIntoBootWimLogged is TransplantWSLIntoBootWim with a hook -// for per-step events. onEvent may be nil. -func TransplantWSLIntoBootWimLogged(bootWimPath, wslDir, installWimPath string, onEvent func(TransplantEvent)) error { - emit := func(e TransplantEvent) { - if onEvent == nil { - return - } - e.TS = time.Now().Format(time.RFC3339Nano) - onEvent(e) - } - fail := func(stage string, err error) error { - emit(TransplantEvent{Event: stage, Status: "fail", Error: err.Error()}) - return err - } - - engine := WSLEngineFiles() - shim := WSLInboxShim() - emit(TransplantEvent{Event: "wsl_transplant_start", File: bootWimPath, - Count: len(engine) + len(shim)}) - - // The engine payload must exist on disk before we touch the wim. - for _, f := range engine { - if _, err := os.Stat(filepath.Join(wslDir, filepath.FromSlash(f))); err != nil { - return fail("check_engine", fmt.Errorf("engine payload incomplete: %w", err)) - } - } - - shimStaging, err := os.MkdirTemp("", "devcell-wsl-shim-stage-*") - if err != nil { - return fmt.Errorf("shim staging dir: %w", err) - } - defer os.RemoveAll(shimStaging) - - if err := ExtractParityFiles(installWimPath, shim, shimStaging); err != nil { - return fail("stage_shim", fmt.Errorf("staging inbox shim: %w", err)) - } - emit(TransplantEvent{Event: "stage_shim", Status: "ok", Count: len(shim)}) - - wim, err := wimlib.OpenWIM(bootWimPath) - if err != nil { - return fail("open_wim", fmt.Errorf("opening boot.wim: %w", err)) - } - defer wim.Close() - - for _, f := range engine { - local := filepath.Join(wslDir, filepath.FromSlash(f)) - dest := WSLEngineDestDir + "/" + f - wimPath := `\` + strings.ReplaceAll(dest, "/", `\`) - - var size int - if info, err := os.Stat(local); err == nil { - size = int(info.Size()) - } - if err := wim.UpdateImageAdd(2, local, wimPath); err != nil { - return fail("add_file", fmt.Errorf("adding %s: %w", dest, err)) - } - emit(TransplantEvent{Event: "add_file", Status: "ok", - File: dest, Source: "wsl-msi", Bytes: size}) - } - - for _, f := range shim { - local := filepath.Join(shimStaging, filepath.FromSlash(f.Dest)) - wimPath := `\` + strings.ReplaceAll(f.Dest, "/", `\`) - - var size int - if info, err := os.Stat(local); err == nil { - size = int(info.Size()) - } - if err := wim.UpdateImageAdd(2, local, wimPath); err != nil { - return fail("add_file", fmt.Errorf("adding %s: %w", f.Dest, err)) - } - emit(TransplantEvent{Event: "add_file", Status: "ok", - File: f.Dest, Source: "install.wim System32", Bytes: size}) - } - - if err := wim.Overwrite(); err != nil { - return fail("commit", fmt.Errorf("committing boot.wim: %w", err)) - } - emit(TransplantEvent{Event: "commit", Status: "ok", File: bootWimPath}) - emit(TransplantEvent{Event: "wsl_transplant_complete", Status: "ok", - Count: len(engine) + len(shim)}) - return nil -} From 73b4f5c9b01da16f3e4b2613addcdda3a33860ec Mon Sep 17 00:00:00 2001 From: Dmitry Kireev Date: Sun, 30 Aug 2026 06:04:38 +0000 Subject: [PATCH 02/11] Extract engine resolution into a shared function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refactor(cmd): extract resolveEngine() from three call sites — init, build, and run now share one priority chain (CLI flag > TOML [cell].engine > "docker" default) instead of each reimplementing it - test(cmd): add resolveEngine unit tests covering flag, TOML fallback, macOS alias, and default --- cmd/build.go | 20 +++++++++----------- cmd/engine.go | 20 ++++++++++++++++++++ cmd/engine_test.go | 42 ++++++++++++++++++++++++++++++++++++++++++ cmd/init.go | 28 +++++++++++++--------------- cmd/root.go | 10 +--------- 5 files changed, 85 insertions(+), 35 deletions(-) create mode 100644 cmd/engine.go create mode 100644 cmd/engine_test.go diff --git a/cmd/build.go b/cmd/build.go index c089cd74..e6c36af5 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -40,24 +40,22 @@ func init() { func runBuild(cmd *cobra.Command, _ []string) error { applyOutputFlagsWithLog("build") + c, err := config.LoadFromOS() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + + cellCfgForEngine := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) + engine := resolveEngine(scanStringFlag("--engine"), cellCfgForEngine.Cell.Engine, scanFlag("--macos")) + telemetry.Track("build", map[string]any{ - "engine": scanStringFlag("--engine"), + "engine": engine, "subcommand": "build", "update": scanFlag("--update"), "no_cache": scanFlag("--no-cache"), "force": scanFlag("--force"), }) - c, err := config.LoadFromOS() - if err != nil { - return fmt.Errorf("load config: %w", err) - } - - engine := scanStringFlag("--engine") - if scanFlag("--macos") { - engine = "vagrant" - } - // ── tart engine ────────────────────────────────────────────────────────── if engine == "tart" { cellCfgTart, cfgErr := cfg.LoadFromOSWithDirs(c.ConfigDir, c.BaseDir) diff --git a/cmd/engine.go b/cmd/engine.go new file mode 100644 index 00000000..8286f113 --- /dev/null +++ b/cmd/engine.go @@ -0,0 +1,20 @@ +package main + +// resolveEngine returns the engine name from the first non-empty source: +// +// 1. macOS flag (always "vagrant") +// 2. CLI --engine flag +// 3. TOML [cell].engine +// 4. "docker" (default) +func resolveEngine(flagEngine, tomlEngine string, macosFlag bool) string { + if macosFlag { + return "vagrant" + } + if flagEngine != "" { + return flagEngine + } + if tomlEngine != "" { + return tomlEngine + } + return "docker" +} diff --git a/cmd/engine_test.go b/cmd/engine_test.go new file mode 100644 index 00000000..a3abf36f --- /dev/null +++ b/cmd/engine_test.go @@ -0,0 +1,42 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestResolveEngine_FlagWins(t *testing.T) { + got := resolveEngine("tart", "docker", false) + assert.Equal(t, "tart", got) +} + +func TestResolveEngine_TOMLFallback(t *testing.T) { + got := resolveEngine("", "qemu", false) + assert.Equal(t, "qemu", got) +} + +func TestResolveEngine_DefaultDocker(t *testing.T) { + got := resolveEngine("", "", false) + assert.Equal(t, "docker", got) +} + +func TestResolveEngine_MacOSAlias(t *testing.T) { + got := resolveEngine("", "", true) + assert.Equal(t, "vagrant", got) +} + +func TestResolveEngine_MacOSOverridesFlag(t *testing.T) { + got := resolveEngine("qemu", "", true) + assert.Equal(t, "vagrant", got) +} + +func TestResolveEngine_ExplicitDocker(t *testing.T) { + got := resolveEngine("docker", "", false) + assert.Equal(t, "docker", got) +} + +func TestResolveEngine_TOMLVagrant(t *testing.T) { + got := resolveEngine("", "vagrant", false) + assert.Equal(t, "vagrant", got) +} diff --git a/cmd/init.go b/cmd/init.go index c77d89d9..33fa9fa0 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -4,6 +4,7 @@ import ( "fmt" "os" + "github.com/DimmKirr/devcell/internal/cfg" "github.com/DimmKirr/devcell/internal/config" "github.com/DimmKirr/devcell/internal/telemetry" "github.com/DimmKirr/devcell/internal/ux" @@ -30,13 +31,19 @@ func init() { func runInit(cmd *cobra.Command, _ []string) error { applyOutputFlagsWithLog("init") - engine := scanStringFlag("--engine") + // Engine resolution uses the same priority as build/run: + // CLI flag > TOML [cell].engine > "docker" default. + // init may run before any TOML exists, so LoadFromOS silently + // returns zero-value config when there's no file yet. + c, err := config.LoadFromOS() + if err != nil { + return fmt.Errorf("load config: %w", err) + } + cellCfgForEngine := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) + engine := resolveEngine(scanStringFlag("--engine"), cellCfgForEngine.Cell.Engine, scanFlag("--macos")) telemetry.Track("init", map[string]any{"engine": engine, "stack": cmd.Flags().Lookup("stack").Value.String()}) + if engine == "tart" { - c, err := config.LoadFromOS() - if err != nil { - return fmt.Errorf("load config: %w", err) - } stack, _ := cmd.Flags().GetString("stack") force, _ := cmd.Flags().GetBool("force") noCache, _ := cmd.Flags().GetBool("no-cache") @@ -46,17 +53,12 @@ func runInit(cmd *cobra.Command, _ []string) error { if engine == "qemu" || engine == "libvirt" { // libvirt reuses the qemu scaffold: init only creates directories, // an SSH keypair, and VirtIO drivers on the shared mount (CELL-372). - c, err := config.LoadFromOS() - if err != nil { - return fmt.Errorf("load config: %w", err) - } stack, _ := cmd.Flags().GetString("stack") force, _ := cmd.Flags().GetBool("force") return runInitQemu(c.CellName, c.HostHome, stack, force) } - macos, _ := cmd.Flags().GetBool("macos") - if macos { + if engine == "vagrant" { return runInitMacOS() } yes, _ := cmd.Flags().GetBool("yes") @@ -70,10 +72,6 @@ func runInit(cmd *cobra.Command, _ []string) error { ux.Debugf("DEVCELL_BASE_IMAGE: %s (env)", bi) } - c, err := config.LoadFromOS() - if err != nil { - return fmt.Errorf("load config: %w", err) - } ux.Debugf("BaseDir: %s, ConfigDir: %s", c.BaseDir, c.ConfigDir) stack, _ := cmd.Flags().GetString("stack") diff --git a/cmd/root.go b/cmd/root.go index ad4eaa94..6faccb5a 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -325,16 +325,8 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin fmt.Printf(" First run — scaffolding %s (stack: %s)\n", c.BaseDir, result.Stack) } - // Vagrant engine branch - // Priority: CLI flag > [cell] config > default. cellCfgForEngine := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) - engine := scanStringFlag("--engine") - if engine == "" { - engine = cellCfgForEngine.Cell.Engine - } - if scanFlag("--macos") { - engine = "vagrant" - } + engine := resolveEngine(scanStringFlag("--engine"), cellCfgForEngine.Cell.Engine, scanFlag("--macos")) if engine == "vagrant" { telemetry.Track("command_run", map[string]any{"command": filepath.Base(binary), "engine": "vagrant"}) vagrantBox := scanStringFlag("--vagrant-box") From 53bed7ca074206725b9d32cc09ec0a41438214dd Mon Sep 17 00:00:00 2001 From: Dmitry Kireev Date: Sun, 30 Aug 2026 06:04:40 +0000 Subject: [PATCH 03/11] Delegate WinPE build steps to go-winkit winpe.Build() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refactor(build): replace ~90 lines of inline stage/inject/master with one winpe.Build() call — WinPE ISO creation is now the library's responsibility - chore(deps): point go-winkit at local working copy (replace directive) — drop before merging --- cmd/build_qemu.go | 119 +++++++--------------------------------------- go.mod | 2 + go.sum | 2 - 3 files changed, 20 insertions(+), 103 deletions(-) diff --git a/cmd/build_qemu.go b/cmd/build_qemu.go index 1db9c122..01171aa7 100644 --- a/cmd/build_qemu.go +++ b/cmd/build_qemu.go @@ -848,111 +848,28 @@ func runWimBuilder(ctx context.Context, templateDir, windowsISO, virtioISO, runD wimSerialLog := filepath.Join(wimDebugDir, "serial.log") wimProgressLog := filepath.Join(wimDebugDir, "guest-progress.log") - // 1. Extract boot.wim and EFI boot files from Windows ISO - stageDir := filepath.Join(tmpDir, "stage") - if err := winpe.ExtractStage(windowsISO, stageDir); err != nil { - return "", fmt.Errorf("extracting WinPE stage: %w", err) - } - - // 2. Extract vioserial + vioscsi drivers for WinPE injection - vioserialDrivers, err := winpe.LoadWinPEVioserialDrivers(virtioISO) - if err != nil { - return "", fmt.Errorf("loading vioserial drivers: %w", err) - } - vioscsiDrivers, err := winpe.LoadWinPEStorageDrivers(virtioISO) - if err != nil { - return "", fmt.Errorf("loading vioscsi drivers: %w", err) - } - - // 3. Read boot.wim and create the shared FAT volume - bootWimPath := filepath.Join(stageDir, "sources", "boot.wim") - bootWimData, err := os.ReadFile(bootWimPath) + // Steps 1-5 (extract, drivers, boot.wim, inject, ISO) delegated to go-winkit. + result, err := winpe.Build(winpe.BuildConfig{ + WindowsISO: windowsISO, + VirtIOISO: virtioISO, + PwshFiles: pwshFiles, + OutputDir: tmpDir, + HyperV: true, + WSL2: true, + OpenSSH: true, + VirtIO: true, + ProgressPort: `\\.\Global\` + qemu.ProgressPortName, + }) if err != nil { - return "", fmt.Errorf("reading boot.wim: %w", err) + return "", fmt.Errorf("winpe build: %w", err) } - - // Extract BOOTAA64.EFI for the startup.nsh chainload path. - // EDK2 pflash can't read ISO9660 on SCSI CDs, so the FAT volume - // ships the bootloader and startup.nsh does the chainload. - var efiBootLoader []byte - if bl, err := winpe.InstallerBootloader(windowsISO); err != nil { - ux.Debugf("wim-builder: could not extract BOOTAA64.EFI: %v", err) - } else if _, err := winpe.ValidateBootloaderPE(bl); err != nil { - ux.Debugf("wim-builder: BOOTAA64.EFI validation failed: %v", err) - } else { - efiBootLoader = bl - ux.Debugf("wim-builder: embedded BOOTAA64.EFI (%d bytes) on shared volume", len(bl)) - } - - var ops []winpe.WimPrepOp - ops = append(ops, winpe.HyperVPrepOps()...) - ops = append(ops, winpe.WSL2PrepOps()...) - ops = append(ops, winpe.OpenSSHPrepOps()...) - ops = append(ops, winpe.VirtIODriverPrepOps()...) - cfg := winpe.WimPrepConfig{ - Ops: ops, - } - sharedFiles := winpe.SharedVolumeFiles(cfg, efiBootLoader, pwshFiles) - sharedFiles["/boot.wim"] = bootWimData + winpeISO := result.WinPEISO sharedImg := filepath.Join(tmpDir, "shared.qcow2") - if err := qemu.CreateFATQcow2(sharedImg, sharedFiles, 20*1024*1024*1024); err != nil { + if err := qemu.CreateFATQcow2(sharedImg, result.SharedFiles, 20*1024*1024*1024); err != nil { return "", fmt.Errorf("creating shared volume: %w", err) } - // 4. Inject agent into boot.wim so it boots into the builder - injectDir := filepath.Join(tmpDir, "inject") - if err := os.MkdirAll(injectDir, 0755); err != nil { - return "", fmt.Errorf("creating inject dir: %w", err) - } - - for _, driverSet := range []map[string][]byte{vioserialDrivers, vioscsiDrivers} { - for answerPath, data := range driverSet { - hostPath := filepath.Join(injectDir, filepath.FromSlash(answerPath)) - if err := os.MkdirAll(filepath.Dir(hostPath), 0755); err != nil { - return "", err - } - if err := os.WriteFile(hostPath, data, 0644); err != nil { - return "", err - } - } - } - - payloadCfg := winpe.PayloadConfig{ - WPEInit: true, - ProgressPort: `\\.\Global\` + qemu.ProgressPortName, - PollSeconds: 5, - SyncAgent: true, - } - var driverINFs []string - if len(vioserialDrivers) > 0 { - driverINFs = append(driverINFs, `X:\devcell\drivers\vioserial\vioser.inf`) - } - if len(vioscsiDrivers) > 0 { - driverINFs = append(driverINFs, `X:\devcell\drivers\vioscsi\vioscsi.inf`) - } - payloadCfg.DriverINFs = driverINFs - - for name, gen := range map[string]func() []byte{ - "winpeshl.ini": func() []byte { return winpe.GenerateShellINI_NoSetup() }, - "bootstrap.ps1": func() []byte { return winpe.GenerateBootstrap(payloadCfg) }, - "agent.ps1": func() []byte { return winpe.GenerateAgent(payloadCfg) }, - } { - if err := os.WriteFile(filepath.Join(injectDir, name), gen(), 0644); err != nil { - return "", fmt.Errorf("writing %s: %w", name, err) - } - } - - if err := wim.InjectWinPEPayload(bootWimPath, injectDir); err != nil { - return "", fmt.Errorf("injecting WinPE payload: %w", err) - } - - // 5. Create WinPE ISO - winpeISO := filepath.Join(tmpDir, "winpe-builder.iso") - if err := isokit.CreateWindowsISO(winpeISO, stageDir, "WINPE"); err != nil { - return "", fmt.Errorf("creating WinPE ISO: %w", err) - } - // 6. Build QEMU command diskPath := filepath.Join(tmpDir, "scratch.qcow2") if err := qemu.CreateDisk(diskPath, 8); err != nil { @@ -1094,9 +1011,9 @@ func runWimBuilder(ctx context.Context, templateDir, windowsISO, virtioISO, runD agentOut := readFATFile(sharedImg, "/"+winpe.AgentResultFile) ux.Debugf("wim-builder output:\n%s", agentOut) - result := strings.TrimSpace(doneMarker) - if result != "SUCCESS" { - return "", fmt.Errorf("builder reported %s — DISM offline servicing may not work in WinPE", result) + doneResult := strings.TrimSpace(doneMarker) + if doneResult != "SUCCESS" { + return "", fmt.Errorf("builder reported %s — DISM offline servicing may not work in WinPE", doneResult) } // 8. Extract devcell.wim from the shared volume and cache it diff --git a/go.mod b/go.mod index d3f9a72a..22c02a85 100644 --- a/go.mod +++ b/go.mod @@ -2,6 +2,8 @@ module github.com/DimmKirr/devcell go 1.26.0 +replace github.com/devcell-sh/go-winkit => /Users/dmitry/dev/devcell-sh/go-winkit + require ( github.com/BurntSushi/toml v1.4.0 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 diff --git a/go.sum b/go.sum index 476d0627..3f00275d 100644 --- a/go.sum +++ b/go.sum @@ -106,8 +106,6 @@ github.com/devcell-sh/go-regedit v0.1.0 h1:+eT+eLZQZtDpKhzjlGBP2DdyNuUhFTX+8354j github.com/devcell-sh/go-regedit v0.1.0/go.mod h1:pWEerGIoAAVu00kuyFaXCmIzIWghcJYwiK8xGha1L5E= github.com/devcell-sh/go-wimlib v0.1.0 h1:pg1WxyqfMm/9Anmp9amfr+nMKwzAc4D5ra4n8qkZkMg= github.com/devcell-sh/go-wimlib v0.1.0/go.mod h1:BFGCDzvSqoVtHxQ8j5GhXHmO8c3w/kLnHDJaOogPstE= -github.com/devcell-sh/go-winkit v0.2.0 h1:qx4udUGbd33q3mE1Cg2nNzN5bpGMymD2VI49sPCC4PU= -github.com/devcell-sh/go-winkit v0.2.0/go.mod h1:knVXG2/GhrkkHZIvie9+NeZLH93F0NbISzeXtMROTUk= github.com/digitalocean/go-libvirt v0.0.0-20260609165003-6254771e63a8 h1:R4zqGCPowg1bfJXFxsS122mzkivaMz+wPLxBsF9qsiY= github.com/digitalocean/go-libvirt v0.0.0-20260609165003-6254771e63a8/go.mod h1:qb0Ofa71d3oXARQf633h2tNaeBxLsVxuDp+jcsVO2+4= github.com/diskfs/go-diskfs v1.9.4 h1:0j2d7eG4IjyxL6+ChWbDPocdBCF6HQ4HBWU2WDYWVnc= From 170b40bc117751c4405c385c30043b284133138d Mon Sep 17 00:00:00 2001 From: Dmitry Kireev Date: Sun, 6 Sep 2026 05:51:07 +0000 Subject: [PATCH 04/11] [CELL-426] Resolve nixhome path in integration tests instead of hardcoding ../nixhome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test(helpers): add nixhomeDir() honoring DEVCELL_NIXHOME > DEVCELL_NIXHOME_PATH > ../nixhome — tests keep finding nixhome once it moves to devcell-sh/community-home - test(image,mise,modules): replace hardcoded ../nixhome with nixhomeDir() at every call site — tests survive the external repo split without editing each file again --- test/helpers_test.go | 20 ++++++++++++++++++-- test/image_test.go | 4 ++-- test/mise_node_install_test.go | 4 ++-- test/mise_test.go | 4 ++-- test/modules_2_0_integration_test.go | 6 +++--- 5 files changed, 27 insertions(+), 11 deletions(-) diff --git a/test/helpers_test.go b/test/helpers_test.go index 22052151..9f04b0ab 100644 --- a/test/helpers_test.go +++ b/test/helpers_test.go @@ -24,6 +24,22 @@ import ( "github.com/testcontainers/testcontainers-go/wait" ) +// nixhomeDir resolves the nixhome checkout that file-reading tests assert +// against. Mirrors runner.ResolveNixhomeRef precedence: +// DEVCELL_NIXHOME > DEVCELL_NIXHOME_PATH (legacy) > ../nixhome (in-repo). +// Env values that aren't existing local directories (e.g. github: flake +// refs) are skipped — these tests read files from disk. +func nixhomeDir() string { + for _, env := range []string{"DEVCELL_NIXHOME", "DEVCELL_NIXHOME_PATH"} { + if v := os.Getenv(env); v != "" { + if fi, err := os.Stat(v); err == nil && fi.IsDir() { + return v + } + } + } + return filepath.Join("..", "nixhome") +} + var ( ultimateOnce sync.Once ultimateTag string @@ -514,7 +530,7 @@ func buildElectronicsImage() (string, error) { } // Copy local nixhome/ into the build context. - nixhomeSrc := filepath.Join("..", "nixhome") + nixhomeSrc := nixhomeDir() nixhomeDst := filepath.Join(dir, "nixhome") if err := copyDirRecursive(nixhomeSrc, nixhomeDst); err != nil { return "", fmt.Errorf("copy nixhome: %w", err) @@ -667,7 +683,7 @@ func buildTestdataImage() (string, error) { // Replace testdata nixhome with current repo nixhome for iteration. nixhomeDst := filepath.Join(dir, "nixhome") os.RemoveAll(nixhomeDst) - if err := copyDirRecursive(filepath.Join("..", "nixhome"), nixhomeDst); err != nil { + if err := copyDirRecursive(nixhomeDir(), nixhomeDst); err != nil { return "", fmt.Errorf("copy nixhome: %w", err) } diff --git a/test/image_test.go b/test/image_test.go index 2e370b6d..4688b454 100644 --- a/test/image_test.go +++ b/test/image_test.go @@ -99,7 +99,7 @@ func TestScaffold_BuildPipeline(t *testing.T) { configDir := t.TempDir() t.Setenv("DEVCELL_BASE_IMAGE", ultimateImg) - nixhomePath, _ := filepath.Abs(filepath.Join("..", "nixhome")) + nixhomePath, _ := filepath.Abs(nixhomeDir()) if err := scaffold.Scaffold(configDir, "", nixhomePath, false); err != nil { t.Fatalf("scaffold: %v", err) } @@ -338,7 +338,7 @@ func TestCell_Shell(t *testing.T) { devcellConfigDir := filepath.Join(configDir, "devcell") // Pass repo nixhome so generated flakes use path:./nixhome instead of // a GitHub commit URL that may predate the lib.mkHome export. - repoNixhome, _ := filepath.Abs(filepath.Join("..", "nixhome")) + repoNixhome, _ := filepath.Abs(nixhomeDir()) if err := scaffold.Scaffold(devcellConfigDir, "", repoNixhome, false); err != nil { t.Fatalf("scaffold: %v", err) } diff --git a/test/mise_node_install_test.go b/test/mise_node_install_test.go index c9194db5..bc701105 100644 --- a/test/mise_node_install_test.go +++ b/test/mise_node_install_test.go @@ -98,7 +98,7 @@ func TestNixLd_ImageNixSetsEnv(t *testing.T) { // gpg: GLIBC_2.42 not found (libgpg-error-1.59) (libc mismatch) // x11vnc: same func TestNixLd_FragmentExportsLdLibraryPath(t *testing.T) { - frag, err := os.ReadFile(filepath.Join("..", "nixhome", "modules", "fragments", "06-nix-ldpath.sh")) + frag, err := os.ReadFile(filepath.Join(nixhomeDir(), "modules", "fragments", "06-nix-ldpath.sh")) if err != nil { t.Fatalf("read 06-nix-ldpath.sh: %v", err) } @@ -117,7 +117,7 @@ func TestNixLd_FragmentExportsLdLibraryPath(t *testing.T) { // Returns the file content and nil error if readable; otherwise returns // an empty string and the underlying error. func tryReadNixhomeFile(relPath string) (string, error) { - data, err := os.ReadFile(filepath.Join("..", "nixhome", relPath)) + data, err := os.ReadFile(filepath.Join(nixhomeDir(), relPath)) if err != nil { return "", err } diff --git a/test/mise_test.go b/test/mise_test.go index 9da47ae7..81963054 100644 --- a/test/mise_test.go +++ b/test/mise_test.go @@ -440,7 +440,7 @@ func TestMise_SharedInstalls_NoUserCopies(t *testing.T) { func readNixhomeFile(t *testing.T, relPath string) string { t.Helper() - data, err := os.ReadFile(filepath.Join("..", "nixhome", relPath)) + data, err := os.ReadFile(filepath.Join(nixhomeDir(), relPath)) if err != nil { t.Fatalf("read nixhome/%s: %v", relPath, err) } @@ -452,7 +452,7 @@ func readNixhomeFile(t *testing.T, relPath string) string { // declarations and returns the list of tool names. func declaredMiseTools(t *testing.T) []string { t.Helper() - modulesDir := filepath.Join("..", "nixhome", "modules") + modulesDir := filepath.Join(nixhomeDir(), "modules") entries, err := os.ReadDir(modulesDir) if err != nil { t.Fatalf("read nixhome/modules: %v", err) diff --git a/test/modules_2_0_integration_test.go b/test/modules_2_0_integration_test.go index 1bfa1c81..d0aa3a53 100644 --- a/test/modules_2_0_integration_test.go +++ b/test/modules_2_0_integration_test.go @@ -454,13 +454,13 @@ func TestModules2_CellModulesListEndToEnd(t *testing.T) { // devcell thin cells and sandboxed CI where the daemon socket is absent. probe := osexec.Command("nix", "eval", "--json", "--extra-experimental-features", "nix-command flakes", - "path:../nixhome#devcellProfiles.base") + "path:"+nixhomeDir()+"#devcellProfiles.base") if err := probe.Run(); err != nil { t.Skipf("nix can't evaluate the local flake in this env (%v); skipping — works on hosts with running nix daemon", err) } cmd := osexec.Command("../bin/cell", "modules", "list") - cmd.Env = append(os.Environ(), "DEVCELL_NIXHOME_PATH=../nixhome") + cmd.Env = append(os.Environ(), "DEVCELL_NIXHOME_PATH="+nixhomeDir()) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("cell modules list: %v\noutput tail: %s", err, lastNLines(string(out), 20)) @@ -519,7 +519,7 @@ func TestModules2_LongE2E_CleanVolume_TwoModulesFromGlobalAndProject(t *testing. // reads [cell].nixhome as tier 1 (explicit user setting), skipping the // github fallback. Mirrors how other long tests cite a known-good nixhome // instead of relying on the network + upstream pin. - localNixhome, err := filepath.Abs("../nixhome") + localNixhome, err := filepath.Abs(nixhomeDir()) if err != nil { t.Fatalf("abs nixhome: %v", err) } From 0f0e5b444193f52984f78cfa19c680920b6abe01 Mon Sep 17 00:00:00 2001 From: Dmitry Kireev Date: Sun, 6 Sep 2026 05:51:10 +0000 Subject: [PATCH 05/11] Catch automation tells in stealth checks that live BrowserScan/CreepJS runs slipped through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test(stealth): require navigator.webdriver as native boolean false, not deleted — matches BrowserScan's expectation that the property exists - test(stealth): flag any override of Navigator.prototype.webdriver — forces reliance on AutomationControlled flag, cutting lie surface - test(stealth): fail when window.chrome.runtime is fabricated on ordinary pages — closes CreepJS hasBadChromeRuntime signal - test(stealth): require window.chrome.app present alongside the runtime check — keeps real Chrome's baseline shape intact - test(stealth): reject a SwiftShader string in the spoofed WebGL renderer — closes the top headless/datacenter GPU tell - test(stealth): require a named Function.prototype.toString wrapper with stack scrubbing — closes CreepJS hasToStringProxy signal - test(stealth): resolve nixhome path via nixhomeDir() helper instead of a hardcoded relative path — keeps test working after nixhome relocation --- test/stealth_l2_test.go | 25 ++++++++------ test/stealth_test.go | 74 ++++++++++++++++++++++++++++++++++++++--- 2 files changed, 85 insertions(+), 14 deletions(-) diff --git a/test/stealth_l2_test.go b/test/stealth_l2_test.go index c5e5fe73..f6484eeb 100644 --- a/test/stealth_l2_test.go +++ b/test/stealth_l2_test.go @@ -428,20 +428,25 @@ func TestStealth_L2_AllLayersConsistent(t *testing.T) { } else { t.Logf("PASS Layer 2: navigator.platform=%q", results.Main.Platform) } - // navigator.webdriver must NOT be `true`. Both `undefined` (patchright's - // preferred spoof — property deleted entirely) and `false` (real Chrome - // non-automated value) are acceptable. The existing TestMcp_PatchrightUndetected - // at mcp_test.go:317 documents `undefined` as the expected stealth output. - if results.Main.Webdriver == true { - t.Errorf("FAIL Layer 2: navigator.webdriver=true — browser detected as automated (CELL-169)") + // navigator.webdriver must be boolean false — the real non-automated + // Chrome value. `undefined` (property deleted) reads as tampering: + // BrowserScan's Navigator check flags a missing webdriver property + // (seen live 2026-09-03). + if results.Main.WebdriverType != "boolean" || results.Main.Webdriver == true { + t.Errorf("FAIL Layer 2: navigator.webdriver=%v (type=%s), want boolean false — missing/undefined is itself a bot signal", + results.Main.Webdriver, results.Main.WebdriverType) } else { t.Logf("PASS Layer 2: navigator.webdriver=%v (type=%s)", results.Main.Webdriver, results.Main.WebdriverType) } - if !results.Main.HasChromeRuntime { - t.Errorf("FAIL Layer 2: window.chrome.runtime missing (CELL-169 arm64 regression); hasChrome=%v", - results.Main.HasChrome) + // Real Chrome has NO chrome.runtime on ordinary pages — a fabricated + // runtime is CreepJS's hasBadChromeRuntime signal. window.chrome itself + // (app/csi/loadTimes) must exist. + if !results.Main.HasChrome { + t.Errorf("FAIL Layer 2: window.chrome missing entirely") + } else if results.Main.HasChromeRuntime { + t.Errorf("FAIL Layer 2: window.chrome.runtime fabricated — real Chrome pages have no chrome.runtime (CreepJS hasBadChromeRuntime)") } else { - t.Logf("PASS Layer 2: window.chrome.runtime present") + t.Logf("PASS Layer 2: window.chrome present, no fabricated chrome.runtime") } if results.Main.HeaArch == "" { t.Errorf("FAIL Layer 2: getHighEntropyValues().architecture empty — main-thread spoof not running (CELL-68). hea_error=%q", diff --git a/test/stealth_test.go b/test/stealth_test.go index 01a5c742..6f16d717 100644 --- a/test/stealth_test.go +++ b/test/stealth_test.go @@ -18,7 +18,7 @@ import ( // readScrapingNix returns the contents of nixhome/modules/scraping/default.nix. func readScrapingNix(t *testing.T) string { t.Helper() - data, err := os.ReadFile(filepath.Join("..", "nixhome", "modules", "scraping", "default.nix")) + data, err := os.ReadFile(filepath.Join(nixhomeDir(), "modules", "scraping", "default.nix")) if err != nil { t.Fatalf("read scraping/default.nix: %v", err) } @@ -38,15 +38,81 @@ func TestStealth_ChromeRuntime_DefensiveDefine(t *testing.T) { src := readScrapingNix(t) // The stealth init.js is a writeTextFile heredoc. Find the chrome mock region. - if !strings.Contains(src, "Ensure chrome.runtime exists") { - t.Fatal("scraping/default.nix doesn't contain the stealth-init `Ensure chrome.runtime exists` block — file shape changed") + if !strings.Contains(src, "Align window.chrome with real Chrome") { + t.Fatal("scraping/default.nix doesn't contain the stealth-init `Align window.chrome with real Chrome` block — file shape changed") } // Must use Object.defineProperty on window for the `chrome` slot. // Either `Object.defineProperty(window, 'chrome', ...)` or `defineProperty(window, "chrome", ...)`. re := regexp.MustCompile(`Object\.defineProperty\s*\(\s*window\s*,\s*['"]chrome['"]`) if !re.MatchString(src) { - t.Fatal("stealth init.js still uses plain `window.chrome = {...}` for the chrome mock — must use `Object.defineProperty(window, 'chrome', ...)` so late Chromium injection can't overwrite chrome.runtime (CELL-169 arm64 regression)") + t.Fatal("stealth init.js still uses plain `window.chrome = {...}` for the chrome mock — must use `Object.defineProperty(window, 'chrome', ...)` so late Chromium injection can't overwrite the shim (CELL-169 arm64 regression)") + } +} + +// TestStealth_ChromeShim_NoFakeRuntime asserts the chrome shim does NOT +// fabricate chrome.runtime. Real Chrome exposes chrome.runtime only to +// pages that can message an extension; on ordinary pages it is undefined, +// while chrome.app / chrome.csi / chrome.loadTimes are always present. +// CreepJS flags a fabricated runtime as `hasBadChromeRuntime` (seen live +// 2026-09-03: 40% stealth score with this shim active). +func TestStealth_ChromeShim_NoFakeRuntime(t *testing.T) { + src := readScrapingNix(t) + if strings.Contains(src, "window.chrome.runtime = {") { + t.Fatal("stealth init.js still fabricates window.chrome.runtime — real Chrome has no chrome.runtime on ordinary pages; CreepJS flags it as hasBadChromeRuntime") + } + if !strings.Contains(src, "window.chrome.app") { + t.Fatal("stealth init.js chrome shim missing chrome.app — real Chrome always exposes chrome.app on ordinary pages") + } +} + +// TestStealth_WebdriverFalse asserts navigator.webdriver is left NATIVE: +// no getter override at all. --disable-blink-features=AutomationControlled +// (asserted below) already yields the real non-automated value `false`; +// any override is extra lie surface. The old `get: () => undefined` spoof +// read as a deleted property — real Chrome always has the property, and +// BrowserScan flagged its absence (seen live 2026-09-03). Verified live +// same day: flag + no override → webdriver=false, Webdriver tab passes. +func TestStealth_WebdriverFalse(t *testing.T) { + src := readScrapingNix(t) + re := regexp.MustCompile(`defineProperty\s*\(\s*Navigator\.prototype\s*,\s*['"]webdriver['"]`) + if re.MatchString(src) { + t.Fatal("stealth init.js overrides navigator.webdriver — remove it; AutomationControlled flag already yields native `false` with zero lie surface") + } + if !strings.Contains(src, "--disable-blink-features=AutomationControlled") { + t.Fatal("scraping/default.nix missing --disable-blink-features=AutomationControlled — without it navigator.webdriver is natively true") + } +} + +// TestStealth_WebGLRendererNotSwiftShader asserts the spoofed +// UNMASKED_RENDERER_WEBGL string does not contain "SwiftShader" — the +// software-rasterizer name is a top headless/datacenter signal (CreepJS +// hasSwiftShader:true, Sannysoft hard FAIL, seen live 2026-09-03). +func TestStealth_WebGLRendererNotSwiftShader(t *testing.T) { + src := readScrapingNix(t) + re := regexp.MustCompile(`_wglRenderer = '([^']+)'`) + m := re.FindStringSubmatch(src) + if m == nil { + t.Fatal("stealth init.js missing _wglRenderer assignment — file shape changed") + } + if strings.Contains(m[1], "SwiftShader") { + t.Fatalf("_wglRenderer=%q still advertises SwiftShader — the spoof must present a plausible hardware GPU string", m[1]) + } +} + +// TestStealth_ToStringWrapperHardened asserts the Function.prototype.toString +// wrapper is a *named* function expression (name inference does not apply to +// member assignment, so an anonymous wrapper leaks name === "" where real +// Chrome reports "toString") and scrubs its own frame from thrown TypeError +// stacks via Error.captureStackTrace (the wrapper frame carries the init +// script's source URL — CreepJS hasToStringProxy, seen live 2026-09-03). +func TestStealth_ToStringWrapperHardened(t *testing.T) { + src := readScrapingNix(t) + if !strings.Contains(src, "Function.prototype.toString = function toString()") { + t.Fatal("toString wrapper must be a named function expression — anonymous wrapper leaks .name === \"\"") + } + if !strings.Contains(src, "Error.captureStackTrace") { + t.Fatal("toString wrapper must scrub its frame from rethrown TypeError stacks via Error.captureStackTrace — the frame exposes the init script source URL") } } From 921ddbf9ddb4acedc99d389cf068808ef30cb1f6 Mon Sep 17 00:00:00 2001 From: Dmitry Kireev Date: Sun, 6 Sep 2026 05:51:10 +0000 Subject: [PATCH 06/11] Fix nixhome self-reference incident that corrupted .devcell/ mid-copy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(scaffold): reject nixhome source nested in its own build dir — stops DEVCELL_NIXHOME self-reference from corrupting .devcell/ - fix(scaffold): CopyDir skips a destination nested inside its source — prevents silent partial-copy corruption if the upfront guard is bypassed - test(scaffold): add regression tests for self-referential nixhome source and nested-destination CopyDir calls --- internal/scaffold/scaffold.go | 79 ++++++++++++++++++++- internal/scaffold/scaffold_test.go | 106 +++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/internal/scaffold/scaffold.go b/internal/scaffold/scaffold.go index 1dddefc4..8815a613 100644 --- a/internal/scaffold/scaffold.go +++ b/internal/scaffold/scaffold.go @@ -129,7 +129,19 @@ func generatePyprojectTOML(pkgs map[string]string) []byte { // stack is a stack name (e.g. "go"), modules is a list of module names, // ver is the version tag, nixhomePath overrides the input URL to path:./nixhome. // nixPkgs adds arbitrary nixpkgs packages with lib.hiPri (user override semantics). +// mcpEnabled lists MCP server names to enable (from [mcp] enabled in .devcell.toml); +// each emits devcell.managedMcp.servers."".enabled = true; in the flake. func GenerateFlakeNix(stack string, modules []string, ver string, withNixhome bool, nixPkgs ...cfg.NixPackages) string { + return generateFlakeNixFull(stack, modules, ver, withNixhome, nil, nixPkgs...) +} + +// GenerateFlakeNixWithMcp is like GenerateFlakeNix but also emits MCP server +// enablement lines from [mcp] enabled in .devcell.toml. +func GenerateFlakeNixWithMcp(stack string, modules []string, ver string, withNixhome bool, mcpEnabled []string, nixPkgs ...cfg.NixPackages) string { + return generateFlakeNixFull(stack, modules, ver, withNixhome, mcpEnabled, nixPkgs...) +} + +func generateFlakeNixFull(stack string, modules []string, ver string, withNixhome bool, mcpEnabled []string, nixPkgs ...cfg.NixPackages) string { if stack == "" { stack = "base" } @@ -176,6 +188,10 @@ func GenerateFlakeNix(stack string, modules []string, ver string, withNixhome bo moduleExpr += fmt.Sprintf(" ++ [ { home.packages = %s; } ]", strings.Join(parts, " ++ ")) } + // [mcp] enabled is now resolved at container start via DEVCELL_MCP_ENABLED + // env var — no longer baked into the flake overlay. The mcpEnabled parameter + // is kept for API compatibility but ignored. + return fmt.Sprintf(`{ description = "DevCell user stack — customise and run 'cell build'"; @@ -477,6 +493,18 @@ func syncNixhomeFromLocal(srcPath, configDir, origin string) error { return fmt.Errorf("nixhome source %s: %w", srcPath, err) } dest := filepath.Join(configDir, "nixhome") + + if nested, err := isPathNestedIn(dest, srcPath); err != nil { + return fmt.Errorf("resolve nixhome paths: %w", err) + } else if nested { + return fmt.Errorf( + "nixhome source %s contains its own build directory (%s) — "+ + "DEVCELL_NIXHOME/--nixhome must point at a separate nixhome checkout, "+ + "not the project's own directory (or an ancestor of it)", + srcPath, dest, + ) + } + if err := os.RemoveAll(dest); err != nil { return fmt.Errorf("remove old nixhome: %w", err) } @@ -535,12 +563,61 @@ func materializeGithubFlakeRef(ref string) (string, func(), error) { return src, cleanup, nil } +// isPathNestedIn reports whether child is inside (or equal to) parent, after +// resolving symlinks so aliasing can't defeat the check. Tolerates paths +// that don't exist yet (e.g. dest before its first sync) by falling back to +// the unresolved absolute path. +func isPathNestedIn(child, parent string) (bool, error) { + absChild, err := filepath.Abs(child) + if err != nil { + return false, err + } + absParent, err := filepath.Abs(parent) + if err != nil { + return false, err + } + if resolved, err := filepath.EvalSymlinks(absChild); err == nil { + absChild = resolved + } + if resolved, err := filepath.EvalSymlinks(absParent); err == nil { + absParent = resolved + } + rel, err := filepath.Rel(absParent, absChild) + if err != nil { + return false, nil + } + if rel == "." { + return true, nil + } + return rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)), nil +} + // CopyDir recursively copies src directory to dst. +// +// Guards against dst being nested inside src (e.g. a caller accidentally +// pointing a nixhome sync at a project's own directory): without this, the +// walk would recurse into paths it is itself writing, corrupting the copy +// partway through. This is a defensive backstop — callers should also +// reject that configuration upfront (see isPathNestedIn in +// syncNixhomeFromLocal) so the failure is a clear error instead of a silent +// partial copy. func CopyDir(src, dst string) error { + absDst, err := filepath.Abs(dst) + if err != nil { + return err + } return filepath.Walk(src, func(path string, info os.FileInfo, err error) error { if err != nil { return err } + if absPath, aerr := filepath.Abs(path); aerr == nil { + if absPath == absDst || strings.HasPrefix(absPath, absDst+string(filepath.Separator)) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + } rel, _ := filepath.Rel(src, path) target := filepath.Join(dst, rel) if info.IsDir() { @@ -657,7 +734,7 @@ func RegenerateBuildContext(configDir string, cellCfg cfg.CellConfig) error { stack := cellCfg.Cell.ResolvedStack() // Regenerate flake.nix from stack + modules. - flake := GenerateFlakeNix(stack, cellCfg.Cell.Modules, version.Version, withNixhome, cellCfg.Packages.Nix) + flake := GenerateFlakeNixWithMcp(stack, cellCfg.Cell.Modules, version.Version, withNixhome, cellCfg.Mcp.Enabled, cellCfg.Packages.Nix) if err := os.WriteFile(filepath.Join(configDir, "flake.nix"), []byte(flake), 0644); err != nil { return fmt.Errorf("write flake.nix: %w", err) } diff --git a/internal/scaffold/scaffold_test.go b/internal/scaffold/scaffold_test.go index f9ac4720..b6c04bd2 100644 --- a/internal/scaffold/scaffold_test.go +++ b/internal/scaffold/scaffold_test.go @@ -425,6 +425,79 @@ func TestSyncNixhome_ErrorOnMissingPath(t *testing.T) { } } +// TestSyncNixhome_RejectsSelfReferentialSource — SyncNixhome errors clearly +// (instead of corrupting configDir/nixhome via a partial recursive copy) when +// srcPath is the project directory itself, i.e. configDir/nixhome would be +// nested inside srcPath. Regression test for a real incident: pointing +// DEVCELL_NIXHOME at a project's own root caused CopyDir to walk into the +// destination it was writing, die on a dangling entrypoint.sh symlink +// mid-copy, and leave the project's .devcell/ permanently corrupted for +// every subsequent build referencing that source. +func TestSyncNixhome_RejectsSelfReferentialSource(t *testing.T) { + projectDir := t.TempDir() + configDir := filepath.Join(projectDir, ".devcell") + if err := os.MkdirAll(configDir, 0755); err != nil { + t.Fatal(err) + } + // Give the project dir some pre-existing build artifacts, mirroring the + // real .devcell/ layout (entrypoint.sh symlinked into nixhome/). + if err := os.WriteFile(filepath.Join(configDir, "cell.json"), []byte("{}"), 0644); err != nil { + t.Fatal(err) + } + + err := scaffold.SyncNixhome(projectDir, configDir) + if err == nil { + t.Fatal("expected SyncNixhome to reject a self-referential source, got nil error") + } + if !strings.Contains(err.Error(), "own build directory") { + t.Errorf("expected a self-reference error, got: %v", err) + } + + // Nothing should have been touched — no partial/corrupted nixhome dir. + if _, statErr := os.Stat(filepath.Join(configDir, "nixhome")); !os.IsNotExist(statErr) { + t.Errorf("expected no nixhome dir to be created on rejection, stat err: %v", statErr) + } + // The pre-existing artifact must survive untouched. + if _, statErr := os.Stat(filepath.Join(configDir, "cell.json")); statErr != nil { + t.Errorf("expected pre-existing cell.json to survive, got: %v", statErr) + } +} + +// TestCopyDir_SkipsDestinationNestedInSource — even if a caller bypasses the +// SyncNixhome-level guard and calls CopyDir directly with dst nested inside +// src, CopyDir must not recurse into (or corrupt) its own output. +func TestCopyDir_SkipsDestinationNestedInSource(t *testing.T) { + src := t.TempDir() + if err := os.WriteFile(filepath.Join(src, "flake.nix"), []byte("# nixhome flake"), 0644); err != nil { + t.Fatal(err) + } + dst := filepath.Join(src, "build", "nixhome") + if err := os.MkdirAll(filepath.Dir(dst), 0755); err != nil { + t.Fatal(err) + } + // Pre-existing file inside the nested dst tree — CopyDir must not touch it. + if err := os.MkdirAll(dst, 0755); err != nil { + t.Fatal(err) + } + sentinel := filepath.Join(dst, "sentinel.txt") + if err := os.WriteFile(sentinel, []byte("untouched"), 0644); err != nil { + t.Fatal(err) + } + + if err := scaffold.CopyDir(src, dst); err != nil { + t.Fatalf("CopyDir failed: %v", err) + } + + // The real top-level file must have been copied. + if data, err := os.ReadFile(filepath.Join(dst, "flake.nix")); err != nil || string(data) != "# nixhome flake" { + t.Errorf("expected flake.nix copied into dst, err=%v data=%q", err, data) + } + // The nested dst tree must not have been walked into and rewritten. + if data, err := os.ReadFile(sentinel); err != nil || string(data) != "untouched" { + t.Errorf("expected sentinel inside nested dst untouched, err=%v data=%q", err, data) + } +} + // --- Scaffold with stack --- func TestScaffold_WithStack_FlakeUsesChosenStack(t *testing.T) { @@ -687,6 +760,39 @@ func TestGenerateFlakeNix_NixPackagesWithModules(t *testing.T) { } } +// ── MCP enabled in GenerateFlakeNixWithMcp ────────────────────────────────── + +func TestGenerateFlakeNixWithMcp_EnabledServers(t *testing.T) { + content := scaffold.GenerateFlakeNixWithMcp("ultimate", nil, "v1.0.0", false, []string{"aws-api", "terraform"}) + if !strings.Contains(content, `devcell.managedMcp.servers."aws-api".enabled = true;`) { + t.Errorf("expected aws-api enabled line:\n%s", content) + } + if !strings.Contains(content, `devcell.managedMcp.servers."terraform".enabled = true;`) { + t.Errorf("expected terraform enabled line:\n%s", content) + } +} + +func TestGenerateFlakeNixWithMcp_EmptyNoMcpBlock(t *testing.T) { + content := scaffold.GenerateFlakeNixWithMcp("go", nil, "v1.0.0", false, nil) + if strings.Contains(content, "managedMcp") { + t.Errorf("no MCP block expected when enabled list is nil:\n%s", content) + } +} + +func TestGenerateFlakeNixWithMcp_WithModulesAndNixPkgs(t *testing.T) { + pkgs := cfg.NixPackages{Stable: []string{"cowsay"}} + content := scaffold.GenerateFlakeNixWithMcp("go", []string{"electronics"}, "v1.0.0", false, []string{"aws-api"}, pkgs) + if !strings.Contains(content, "devcell.modules.electronics") { + t.Errorf("expected modules still present:\n%s", content) + } + if !strings.Contains(content, "map lib.hiPri") { + t.Errorf("expected nix packages still present:\n%s", content) + } + if !strings.Contains(content, `devcell.managedMcp.servers."aws-api".enabled = true;`) { + t.Errorf("expected MCP enabled line:\n%s", content) + } +} + // --- GenerateDockerfile --- // TestGenerateDockerfile_UsesLocalProfile — must reference devcell-local, not devcell-ultimate. From 8054245001e2d2edda2b3d91c7907660a589cbc2 Mon Sep 17 00:00:00 2001 From: Dmitry Kireev Date: Sun, 6 Sep 2026 05:51:11 +0000 Subject: [PATCH 07/11] [CELL-491] Let users target a guest OS with --os instead of naming an engine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(engine): add --os linux/macos/windows, mapped to its default engine — targets a platform without knowing engine names - feat(engine): reject incompatible --os/--engine pairs (e.g. windows+tart) — catches misconfigured builds before they run - feat(cli): keep --macos as a working alias for --os=macos — existing scripts and muscle memory keep working - feat(cfg): add [cell].os TOML fallback — lets teams pin an OS default in project config instead of passing --os every time - refactor(engine): resolveEngine now returns an error and takes os args — no user-facing impact - test(engine): cover os precedence, macos alias, and invalid/incompatible combinations — locks in the new resolution order - docs(nix): expose [cell].os in home-manager module options — keeps generated config docs in sync with the new setting --- cmd/build.go | 7 ++- cmd/engine.go | 62 ++++++++++++++++--- cmd/engine_test.go | 114 ++++++++++++++++++++++++++++++++--- cmd/init.go | 18 +++--- cmd/root.go | 21 +++++-- internal/cfg/cfg.go | 25 ++++++++ nix/home-manager/options.nix | 5 ++ 7 files changed, 222 insertions(+), 30 deletions(-) diff --git a/cmd/build.go b/cmd/build.go index e6c36af5..adda5093 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -46,7 +46,10 @@ func runBuild(cmd *cobra.Command, _ []string) error { } cellCfgForEngine := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) - engine := resolveEngine(scanStringFlag("--engine"), cellCfgForEngine.Cell.Engine, scanFlag("--macos")) + engine, err := resolveEngine(scanStringFlag("--engine"), scanStringFlag("--os"), cellCfgForEngine.Cell.Engine, cellCfgForEngine.Cell.OS, scanFlag("--macos")) + if err != nil { + return err + } telemetry.Track("build", map[string]any{ "engine": engine, @@ -212,7 +215,7 @@ func runBuildThin(c config.Config, stackOverride, imageOverride string, forceRec // merged TOML modules. home-manager will switch against this overlay's // `devcell-local` output, not the upstream stack outputs directly, // so [cell].modules takes effect in thin builds (CELL-38 + CELL-61). - overlayFlake := scaffold.GenerateFlakeNix(stack, cellCfg.Cell.Modules, version.Version, true, cellCfg.Packages.Nix) + overlayFlake := scaffold.GenerateFlakeNixWithMcp(stack, cellCfg.Cell.Modules, version.Version, true, cellCfg.Mcp.Enabled, cellCfg.Packages.Nix) overlayPath := filepath.Join(c.BuildDir, "flake.nix") if err := os.WriteFile(overlayPath, []byte(overlayFlake), 0o644); err != nil { return fmt.Errorf("write overlay flake: %w", err) diff --git a/cmd/engine.go b/cmd/engine.go index 8286f113..057324e4 100644 --- a/cmd/engine.go +++ b/cmd/engine.go @@ -1,20 +1,66 @@ package main +import "fmt" + +// osToEngine maps --os values to their default engine. +var osToEngine = map[string]string{ + "linux": "docker", + "macos": "tart", + "windows": "qemu", +} + +// engineAllowedOS lists which OS values are compatible with each engine. +var engineAllowedOS = map[string]map[string]bool{ + "docker": {"linux": true}, + "tart": {"macos": true}, + "qemu": {"windows": true}, + "libvirt": {"windows": true}, + "vagrant": {"macos": true, "linux": true}, +} + // resolveEngine returns the engine name from the first non-empty source: // -// 1. macOS flag (always "vagrant") +// 1. --macos flag (always "vagrant", kept as undocumented alias for --os=macos) // 2. CLI --engine flag -// 3. TOML [cell].engine -// 4. "docker" (default) -func resolveEngine(flagEngine, tomlEngine string, macosFlag bool) string { +// 3. CLI --os flag (mapped via osToEngine) +// 4. TOML [cell].engine +// 5. TOML [cell].os (mapped via osToEngine) +// 6. "docker" (default) +// +// When both --engine and --os are set, the combination is validated: +// incompatible pairs (e.g. --os windows --engine tart) return an error. +func resolveEngine(flagEngine, flagOS, tomlEngine, tomlOS string, macosFlag bool) (string, error) { if macosFlag { - return "vagrant" + return "vagrant", nil } + + if flagOS != "" { + mapped, ok := osToEngine[flagOS] + if !ok { + return "", fmt.Errorf("unsupported --os value %q (valid: linux, macos, windows)", flagOS) + } + if flagEngine != "" { + if allowed, exists := engineAllowedOS[flagEngine]; exists && !allowed[flagOS] { + return "", fmt.Errorf("--os %s and --engine %s are incompatible", flagOS, flagEngine) + } + return flagEngine, nil + } + return mapped, nil + } + if flagEngine != "" { - return flagEngine + return flagEngine, nil } + if tomlEngine != "" { - return tomlEngine + return tomlEngine, nil } - return "docker" + + if tomlOS != "" { + if mapped, ok := osToEngine[tomlOS]; ok { + return mapped, nil + } + } + + return "docker", nil } diff --git a/cmd/engine_test.go b/cmd/engine_test.go index a3abf36f..204d1ada 100644 --- a/cmd/engine_test.go +++ b/cmd/engine_test.go @@ -4,39 +4,139 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestResolveEngine_FlagWins(t *testing.T) { - got := resolveEngine("tart", "docker", false) + got, err := resolveEngine("tart", "", "docker", "", false) + require.NoError(t, err) assert.Equal(t, "tart", got) } func TestResolveEngine_TOMLFallback(t *testing.T) { - got := resolveEngine("", "qemu", false) + got, err := resolveEngine("", "", "qemu", "", false) + require.NoError(t, err) assert.Equal(t, "qemu", got) } func TestResolveEngine_DefaultDocker(t *testing.T) { - got := resolveEngine("", "", false) + got, err := resolveEngine("", "", "", "", false) + require.NoError(t, err) assert.Equal(t, "docker", got) } func TestResolveEngine_MacOSAlias(t *testing.T) { - got := resolveEngine("", "", true) + got, err := resolveEngine("", "", "", "", true) + require.NoError(t, err) assert.Equal(t, "vagrant", got) } func TestResolveEngine_MacOSOverridesFlag(t *testing.T) { - got := resolveEngine("qemu", "", true) + got, err := resolveEngine("qemu", "", "", "", true) + require.NoError(t, err) assert.Equal(t, "vagrant", got) } func TestResolveEngine_ExplicitDocker(t *testing.T) { - got := resolveEngine("docker", "", false) + got, err := resolveEngine("docker", "", "", "", false) + require.NoError(t, err) assert.Equal(t, "docker", got) } func TestResolveEngine_TOMLVagrant(t *testing.T) { - got := resolveEngine("", "vagrant", false) + got, err := resolveEngine("", "", "vagrant", "", false) + require.NoError(t, err) assert.Equal(t, "vagrant", got) } + +// --- --os flag tests (CELL-491 2d) --- + +func TestResolveEngine_OSLinux(t *testing.T) { + got, err := resolveEngine("", "linux", "", "", false) + require.NoError(t, err) + assert.Equal(t, "docker", got) +} + +func TestResolveEngine_OSWindows(t *testing.T) { + got, err := resolveEngine("", "windows", "", "", false) + require.NoError(t, err) + assert.Equal(t, "qemu", got) +} + +func TestResolveEngine_OSMacos(t *testing.T) { + got, err := resolveEngine("", "macos", "", "", false) + require.NoError(t, err) + assert.Equal(t, "tart", got) +} + +func TestResolveEngine_EngineFlagOverridesOS(t *testing.T) { + got, err := resolveEngine("libvirt", "windows", "", "", false) + require.NoError(t, err) + assert.Equal(t, "libvirt", got) +} + +func TestResolveEngine_OSOverridesTomlEngine(t *testing.T) { + got, err := resolveEngine("", "windows", "docker", "", false) + require.NoError(t, err) + assert.Equal(t, "qemu", got) +} + +func TestResolveEngine_TomlOSFallback(t *testing.T) { + got, err := resolveEngine("", "", "", "windows", false) + require.NoError(t, err) + assert.Equal(t, "qemu", got) +} + +func TestResolveEngine_TomlOSOverridesTomlEngine(t *testing.T) { + got, err := resolveEngine("", "", "docker", "linux", false) + require.NoError(t, err) + assert.Equal(t, "docker", got, "--os (TOML) maps linux→docker, which matches toml engine anyway") +} + +func TestResolveEngine_Precedence_EngineFlagFirst(t *testing.T) { + got, err := resolveEngine("libvirt", "windows", "qemu", "linux", false) + require.NoError(t, err) + assert.Equal(t, "libvirt", got) +} + +func TestResolveEngine_Precedence_OSFlagSecond(t *testing.T) { + got, err := resolveEngine("", "windows", "docker", "linux", false) + require.NoError(t, err) + assert.Equal(t, "qemu", got) +} + +func TestResolveEngine_Precedence_TomlEngineThird(t *testing.T) { + got, err := resolveEngine("", "", "tart", "", false) + require.NoError(t, err) + assert.Equal(t, "tart", got) +} + +func TestResolveEngine_Precedence_TomlOSFourth(t *testing.T) { + got, err := resolveEngine("", "", "", "windows", false) + require.NoError(t, err) + assert.Equal(t, "qemu", got) +} + +func TestResolveEngine_MacOSFlagIsOSAlias(t *testing.T) { + got, err := resolveEngine("", "", "", "", true) + require.NoError(t, err) + assert.Equal(t, "vagrant", got) +} + +func TestResolveEngine_ImpossibleCombo_WindowsTart(t *testing.T) { + _, err := resolveEngine("tart", "windows", "", "", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "incompatible") +} + +func TestResolveEngine_ImpossibleCombo_LinuxQemu(t *testing.T) { + _, err := resolveEngine("qemu", "linux", "", "", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "incompatible") +} + +func TestResolveEngine_InvalidOS(t *testing.T) { + _, err := resolveEngine("", "freebsd", "", "", false) + require.Error(t, err) + assert.Contains(t, err.Error(), "unsupported") +} diff --git a/cmd/init.go b/cmd/init.go index 33fa9fa0..772153c4 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -40,7 +40,10 @@ func runInit(cmd *cobra.Command, _ []string) error { return fmt.Errorf("load config: %w", err) } cellCfgForEngine := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) - engine := resolveEngine(scanStringFlag("--engine"), cellCfgForEngine.Cell.Engine, scanFlag("--macos")) + engine, err := resolveEngine(scanStringFlag("--engine"), scanStringFlag("--os"), cellCfgForEngine.Cell.Engine, cellCfgForEngine.Cell.OS, scanFlag("--macos")) + if err != nil { + return err + } telemetry.Track("init", map[string]any{"engine": engine, "stack": cmd.Flags().Lookup("stack").Value.String()}) if engine == "tart" { @@ -82,12 +85,13 @@ func runInit(cmd *cobra.Command, _ []string) error { modules, _ := cmd.Flags().GetStringSlice("modules") result, err := RunInitFlow(InitFlowOptions{ - BaseDir: c.BaseDir, - ConfigDir: c.ConfigDir, - Stack: stack, - Modules: modules, - Yes: yes, - Force: force, + BaseDir: c.BaseDir, + ConfigDir: c.ConfigDir, + NixhomeSrc: cellCfgForEngine.Nix.NixhomePath, + Stack: stack, + Modules: modules, + Yes: yes, + Force: force, }) if err != nil { return err diff --git a/cmd/root.go b/cmd/root.go index 6faccb5a..0fe26b47 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -149,7 +149,8 @@ func init() { rootCmd.PersistentFlags().Bool("plain-text", false, "disable spinners, use plain log output (for CI/non-TTY)") rootCmd.PersistentFlags().Bool("debug", false, "plain-text mode plus stream full build log to stdout") rootCmd.PersistentFlags().String("format", "text", "output format: text, yaml, or json") - rootCmd.PersistentFlags().String("engine", "docker", "execution engine: docker, vagrant, tart, qemu, or libvirt") + rootCmd.PersistentFlags().String("engine", "", "execution engine: docker, vagrant, tart, qemu, or libvirt") + rootCmd.PersistentFlags().String("os", "", "guest OS: linux, macos, or windows (derives engine when --engine is unset)") rootCmd.PersistentFlags().Bool("local", false, "pin --engine=qemu to the in-container path (skip the libvirt auto-default)") rootCmd.PersistentFlags().Bool("background", false, "keep VM/container running after shell exit") rootCmd.PersistentFlags().Bool("macos", false, "use macOS VM via Vagrant (alias for --engine=vagrant)") @@ -235,13 +236,17 @@ var cellBoolFlags = map[string]bool{ "--no-1password": true, // skip [op] documents resolution at cell-open (CELL-42) "--local": true, // pin --engine=qemu to the in-container path (CELL-378) "--auto-cleanup": true, // run the CELL-334 root reaper at cell start (CELL-390) - "--skip-flake": true, // skip project-level flake.nix install (CELL-447) + "--use-flake": true, // opt-in to project-level flake.nix install (CELL-447) + "--no-flake": true, // legacy, ignored (flake is off by default now) + "--skip-flake": true, // legacy, ignored + "--no-ports": true, // skip allocating docker ports even if [ports] is configured } // cellStringFlags are string flags consumed by devcell: strip the flag token // AND its value (handles both "--flag value" and "--flag=value" forms). var cellStringFlags = map[string]bool{ "--engine": true, + "--os": true, "--vagrant-provider": true, "--vagrant-box": true, "--tart-ssh-port": true, @@ -326,7 +331,10 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin } cellCfgForEngine := cfg.LoadFromOS(c.ConfigDir, c.BaseDir) - engine := resolveEngine(scanStringFlag("--engine"), cellCfgForEngine.Cell.Engine, scanFlag("--macos")) + engine, engineErr := resolveEngine(scanStringFlag("--engine"), scanStringFlag("--os"), cellCfgForEngine.Cell.Engine, cellCfgForEngine.Cell.OS, scanFlag("--macos")) + if engineErr != nil { + return engineErr + } if engine == "vagrant" { telemetry.Track("command_run", map[string]any{"command": filepath.Base(binary), "engine": "vagrant"}) vagrantBox := scanStringFlag("--vagrant-box") @@ -796,9 +804,10 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin } // CELL-447: detect project flake.nix and prompt for trust host-side. - skipFlake := scanFlag("--skip-flake") + // Flake is opt-in: enabled by --use-flake flag or flake=true in [cell] config. + useFlake := scanFlag("--use-flake") || cellCfg.Cell.FlakeEnabled() trustFlake := false - if !skipFlake { + if useFlake { trustFlake = resolveTrustFlake(c.BaseDir, c.CellHome) } @@ -810,7 +819,7 @@ func runAgent(binary string, defaultFlags, userArgs []string, extraEnv map[strin UserArgs: userArgs, Debug: ux.Verbose, NixDaemon: scanFlag("--nix-daemon"), - SkipFlake: skipFlake, + NoPorts: scanFlag("--no-ports"), TrustFlake: trustFlake, Image: imageID, ExtraEnv: extraEnv, diff --git a/internal/cfg/cfg.go b/internal/cfg/cfg.go index 59c942f1..ca259ced 100644 --- a/internal/cfg/cfg.go +++ b/internal/cfg/cfg.go @@ -39,6 +39,7 @@ type CellSection struct { Stack string `toml:"stack"` // nix stack name (e.g. "go", "python"); default: "base" (see ResolvedStack) Modules []string `toml:"modules"` // extra nix modules to compose on top of stack NixhomePath string `toml:"nixhome"` // deprecated: use [nix] nixhome instead + OS string `toml:"os"` // guest OS: "linux" (default), "macos", "windows"; derives engine when [cell].engine is unset Engine string `toml:"engine"` // execution engine: "docker" (default) or "vagrant" VagrantProvider string `toml:"vagrant_provider"` // vagrant provider: "utm" (default) or "libvirt" VagrantBox string `toml:"vagrant_box"` // vagrant box name override (default: "utm/bookworm") @@ -65,6 +66,7 @@ type CellSection struct { LibvirtPathMap map[string]string `toml:"libvirt_path_map"` // container prefix -> host prefix rewrites for domain XML paths (CELL-375); empty = CLI runs on the host QemuProjectSync string `toml:"qemu_project_sync"` // project sync for qemu/libvirt engines: "push" (default), "two-way", "off"; env: DEVCELL_QEMU_PROJECT_SYNC (CELL-383) DefaultCommand string `toml:"default_command"` // subcommand to run when `cell` is invoked with no args; env: DEVCELL_DEFAULT_COMMAND + Flake *bool `toml:"flake"` // enable project-level flake.nix install; default: false (opt-in); env: DEVCELL_FLAKE } // ResolvedQemuProjectSync returns the effective project sync mode: @@ -151,6 +153,19 @@ func (c CellSection) ResolvedBackground() bool { return false } +// FlakeEnabled returns whether project-level flake.nix install is enabled: env > toml > false. +func (c CellSection) FlakeEnabled() bool { + if v := os.Getenv("DEVCELL_FLAKE"); v == "1" { + return true + } else if v == "0" { + return false + } + if c.Flake != nil { + return *c.Flake + } + return false +} + // ResolvedTartSSHPort returns the effective SSH port: env > toml > default 22. func (c CellSection) ResolvedTartSSHPort() int { if v := os.Getenv("DEVCELL_TART_SSH_PORT"); v != "" { @@ -563,6 +578,11 @@ func (o OpSection) ResolvedDocuments() []string { return out } +// McpSection holds [mcp] config for per-repo MCP server enablement. +type McpSection struct { + Enabled []string `toml:"enabled"` // MCP server names to enable (e.g. ["aws-api", "terraform"]) +} + // StealthSection holds [stealth] config for browser fingerprint spoofing. type StealthSection struct { Arch string `toml:"arch"` @@ -784,6 +804,7 @@ type CellConfig struct { Ports PortsSection `toml:"ports"` Op OpSection `toml:"op"` Aws AwsSection `toml:"aws"` + Mcp McpSection `toml:"mcp"` Stealth StealthSection `toml:"stealth"` GUI GUISection `toml:"gui"` Env map[string]string @@ -1025,6 +1046,10 @@ func Merge(global, project CellConfig) CellConfig { out.Aws.ReadOnly = project.Aws.ReadOnly } + // Mcp: enabled list accumulates (union-dedup, sorted) like [cell].modules. + out.Mcp.Enabled = unionDedupStrings(global.Mcp.Enabled, project.Mcp.Enabled) + sort.Strings(out.Mcp.Enabled) + // Stealth: project wins when non-empty out.Stealth = global.Stealth if project.Stealth.Arch != "" { diff --git a/nix/home-manager/options.nix b/nix/home-manager/options.nix index b72aef80..15d2bc3e 100644 --- a/nix/home-manager/options.nix +++ b/nix/home-manager/options.nix @@ -19,6 +19,7 @@ in stack = opt types.str; modules = opt (types.listOf types.str); nixhome = opt types.str; + os = opt types.str; engine = opt types.str; vagrant_provider = opt types.str; vagrant_box = opt types.str; @@ -45,6 +46,7 @@ in libvirt_path_map = opt (types.attrsOf types.str); qemu_project_sync = opt types.str; default_command = opt types.str; + flake = opt types.bool; }; docker = { privileged = opt types.bool; @@ -97,6 +99,9 @@ in aws = { read_only = opt types.bool; }; + mcp = { + enabled = opt (types.listOf types.str); + }; stealth = { arch = opt types.str; platform = opt types.str; From b8603465e8887600c002482824ed1feee75c0e86 Mon Sep 17 00:00:00 2001 From: Dmitry Kireev Date: Sun, 6 Sep 2026 05:51:11 +0000 Subject: [PATCH 08/11] Fix flake lock/update and stack discovery failing in bootstrap nix container MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(runner): enable nix-command/flakes for flake lock/update — `nix flake lock`/`update` no longer fails - fix(runner): enable nix-command/flakes for stack discovery — `stack list` no longer fails in bootstrap container --- internal/runner/runner.go | 55 +++++++++++++++++++++------------------ 1 file changed, 30 insertions(+), 25 deletions(-) diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 1ea3fa74..233eecce 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -217,7 +217,7 @@ type RunSpec struct { UserArgs []string Debug bool // pass DEVCELL_DEBUG=true into the container NixDaemon bool // pass DEVCELL_NIX_DAEMON=true into the container - SkipFlake bool // pass DEVCELL_SKIP_FLAKE=1 into the container + NoPorts bool // skip all -p port mappings (user ports and GUI) TrustFlake bool // pass DEVCELL_FLAKE_TRUST=1 into the container Image string // image ID or tag to run; defaults to UserImageTag ExtraEnv map[string]string // additional env vars injected by the command handler @@ -387,11 +387,6 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str argv = append(argv, "-e", "DEVCELL_NIX_DAEMON=true") } - // Skip project flake — degrades install failure to warning instead of boot abort - if spec.SkipFlake { - argv = append(argv, "-e", "DEVCELL_SKIP_FLAKE=1") - } - // Project flake trust — user confirmed host-side that flake.nix packages should be installed if spec.TrustFlake { argv = append(argv, "-e", "DEVCELL_FLAKE_TRUST=1") @@ -443,6 +438,11 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str e("DEVCELL_STEALTH_ARCH", spec.CellCfg.Stealth.ResolvedArch()) e("DEVCELL_STEALTH_PLATFORM", spec.CellCfg.Stealth.ResolvedPlatform()) + // [mcp] enabled — runtime MCP server selection (fragments filter at start) + if len(spec.CellCfg.Mcp.Enabled) > 0 { + e("DEVCELL_MCP_ENABLED", strings.Join(spec.CellCfg.Mcp.Enabled, ",")) + } + // cfg [env] entries for k, v := range spec.CellCfg.Env { argv = append(argv, "-e", k+"="+v) @@ -520,28 +520,31 @@ func BuildArgv(spec RunSpec, fs FS, lookPath func(string) (string, error)) []str argv = append(argv, "-v", vol.Resolved()) } - // [ports].publish_ip — host interface prefix for `docker run -p`. - // Defaults to "0.0.0.0" (set by ResolvedPublishIP) so cells are reachable - // from other hosts on the LAN regardless of dockerd bind defaults. - publishPrefix := spec.CellCfg.Ports.ResolvedPublishIP() + ":" - - // cfg [ports] entries - for _, port := range spec.CellCfg.Ports.Forward { - if !strings.Contains(port, ":") { - // "54321/udp" → host=54321, container=54321/udp - num := port - if idx := strings.IndexByte(num, '/'); idx != -1 { - num = num[:idx] + // --no-ports: skip all -p mappings (user ports and GUI). + if !spec.NoPorts { + // [ports].publish_ip — host interface prefix for `docker run -p`. + // Defaults to "0.0.0.0" (set by ResolvedPublishIP) so cells are reachable + // from other hosts on the LAN regardless of dockerd bind defaults. + publishPrefix := spec.CellCfg.Ports.ResolvedPublishIP() + ":" + + // cfg [ports] entries + for _, port := range spec.CellCfg.Ports.Forward { + if !strings.Contains(port, ":") { + // "54321/udp" → host=54321, container=54321/udp + num := port + if idx := strings.IndexByte(num, '/'); idx != -1 { + num = num[:idx] + } + port = num + ":" + port } - port = num + ":" + port + argv = append(argv, "-p", publishPrefix+port) } - argv = append(argv, "-p", publishPrefix+port) - } - // GUI port mapping - if spec.CellCfg.GUI.ResolvedEnabled() { - argv = append(argv, "-p", publishPrefix+c.VNCPort+":5900") - argv = append(argv, "-p", publishPrefix+c.RDPPort+":3389") + // GUI port mapping + if spec.CellCfg.GUI.ResolvedEnabled() { + argv = append(argv, "-p", publishPrefix+c.VNCPort+":5900") + argv = append(argv, "-p", publishPrefix+c.RDPPort+":3389") + } } // Wireguard env + config mount @@ -1073,6 +1076,7 @@ func UpdateFlakeLock(ctx context.Context, configDir string, lockOnly bool, verbo args := []string{ "run", "--rm", "-v", configDir + ":/work", + "-e", "NIX_CONFIG=experimental-features = nix-command flakes", "--entrypoint", "sh", FlakeNixImage(), "-c", "cd /work && " + nixCmd, @@ -1113,6 +1117,7 @@ ls "$SRC/stacks/" 2>/dev/null | sed 's/\.nix$//' | sort` args := []string{ "run", "--rm", "-v", configDir + ":/work", + "-e", "NIX_CONFIG=experimental-features = nix-command flakes", "--entrypoint", "sh", FlakeNixImage(), "-c", script, From b6626f302c11e476108a056efc40e13140b3c432 Mon Sep 17 00:00:00 2001 From: Dmitry Kireev Date: Sun, 6 Sep 2026 05:51:12 +0000 Subject: [PATCH 09/11] [CELL-491] Add OCI-backed VM disk cache with a disk-store CLI and QEMU build fast path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - feat(cmd): add `cell disk-store push/pull/resolve` wrapping go-diskoci — lets operators seed or inspect cached disk images outside a build - feat(cmd): probe/pull a cached base-profile disk in runBuildQemu — skips the full winkit provisioning pipeline entirely on a cache hit, cutting build time - feat(cmd): push ssh-able and base-profile disks after a successful build — populates the cache so later builds can hit it - feat(cmd): support `--no-cache` to bypass the pull probe only — pushes still happen, so cache stays warm even when forcing a fresh build - feat(cmd): support DEVCELL_DISK_CACHE_REGISTRY/REF and DISKOCI_USERNAME/PASSWORD env vars — lets registry and auth be overridden per environment - test(cmd): cover disk-cache ref/fingerprint derivation and push/pull round-trip against an in-process registry - build(go.mod): drop the local go-winkit replace directive — build now resolves the published module instead of a developer-machine path --- cmd/build_qemu.go | 21 +++++ cmd/disk_cache.go | 113 +++++++++++++++++++++++++++ cmd/disk_cache_test.go | 108 ++++++++++++++++++++++++++ cmd/disk_store.go | 172 +++++++++++++++++++++++++++++++++++++++++ cmd/disk_store_test.go | 64 +++++++++++++++ go.mod | 2 - 6 files changed, 478 insertions(+), 2 deletions(-) create mode 100644 cmd/disk_cache.go create mode 100644 cmd/disk_cache_test.go create mode 100644 cmd/disk_store.go create mode 100644 cmd/disk_store_test.go diff --git a/cmd/build_qemu.go b/cmd/build_qemu.go index 01171aa7..9846c086 100644 --- a/cmd/build_qemu.go +++ b/cmd/build_qemu.go @@ -75,6 +75,24 @@ func runBuildQemu(cellName, hostHome, baseDir, stack string, force, noCache, dry } }() + // --- Disk cache probe --- + // Pull a cached base-profile image if available, skipping the entire + // build pipeline. --no-cache bypasses this probe but still pushes after + // a successful build. + if _, err := os.Stat(templateDisk); err != nil { + ref := diskCacheRefFromEnv(stack, "base-profile", modules) + if diskCachePullIfEnabled(ctx, templateDisk, ref, noCache) { + if err := os.MkdirAll(templateDir, 0755); err != nil { + return fmt.Errorf("creating template dir for cache pull: %w", err) + } + if err := os.WriteFile(marker, []byte("pulled from disk cache\n"), 0644); err != nil { + return fmt.Errorf("stamping provisioned marker: %w", err) + } + fmt.Printf(" Pulled cached template from %s\n", ref) + return nil + } + } + runTS := time.Now().UTC().Format("20060102T150405Z") runDir := filepath.Join(baseDir, ".scratch", "debug", runTS) if err := os.MkdirAll(runDir, 0755); err != nil { @@ -713,6 +731,7 @@ func runBuildQemu(cellName, hostHome, baseDir, stack string, force, noCache, dry } pr.Seal(fmt.Sprintf("qemu template %s built (ssh-able; dev-env finalization skipped)", qemu.TemplateVMName(stack, modules))) + diskCachePush(ctx, templateDisk, stack, "ssh-able", modules) return nil } @@ -721,6 +740,8 @@ func runBuildQemu(cellName, hostHome, baseDir, stack string, force, noCache, dry return err } + dest := qemu.BaseProfileImagePath(hostHome, stack, modules) + diskCachePush(ctx, dest, stack, "base-profile", modules) pr.Seal(fmt.Sprintf("qemu template %s built (WSL2 + nix + home-manager)", qemu.TemplateVMName(stack, modules))) return nil } diff --git a/cmd/disk_cache.go b/cmd/disk_cache.go new file mode 100644 index 00000000..5a92ca29 --- /dev/null +++ b/cmd/disk_cache.go @@ -0,0 +1,113 @@ +//go:build darwin || linux + +package main + +import ( + "context" + "crypto/sha256" + "errors" + "fmt" + "os" + "runtime" + + diskoci "github.com/devcell-sh/go-diskoci" + "github.com/DimmKirr/devcell/internal/ux" +) + +const diskCacheRegistry = "ghcr.io/devcell-sh/winkit" + +func diskCacheRef(stack, phase string, modules []string) string { + fp := diskCacheFingerprint(modules) + return fmt.Sprintf("%s/%s:%s-%s-%s", diskCacheRegistry, stack, phase, runtime.GOARCH, fp) +} + +func diskCacheFingerprint(modules []string) string { + h := sha256.New() + h.Write([]byte(runtime.GOARCH)) + for _, m := range modules { + h.Write([]byte(m)) + } + return fmt.Sprintf("%x", h.Sum(nil))[:12] +} + +func diskCacheRefFromEnv(stack, phase string, modules []string) string { + if custom := os.Getenv("DEVCELL_DISK_CACHE_REF"); custom != "" { + return custom + } + if reg := os.Getenv("DEVCELL_DISK_CACHE_REGISTRY"); reg != "" { + fp := diskCacheFingerprint(modules) + return fmt.Sprintf("%s/%s:%s-%s-%s", reg, stack, phase, runtime.GOARCH, fp) + } + return diskCacheRef(stack, phase, modules) +} + +func diskCachePush(ctx context.Context, diskPath, stack, phase string, modules []string) { + ref := diskCacheRefFromEnv(stack, phase, modules) + diskCachePushWithRef(ctx, diskPath, ref) +} + +func diskCachePushWithRef(ctx context.Context, diskPath, ref string) { + ux.Debugf("disk cache: pushing %s → %s", diskPath, ref) + opts := diskCacheAuthOptions() + _, err := diskoci.Push(ctx, ref, diskPath, opts...) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: disk cache push failed: %v\n", err) + return + } + fmt.Fprintf(os.Stderr, "disk cache: pushed %s\n", ref) +} + +func diskCachePullWithRef(ctx context.Context, destPath, ref string) bool { + ux.Debugf("disk cache: probing %s", ref) + opts := diskCacheAuthOptions() + + _, err := diskoci.ResolveImage(ctx, ref, opts...) + if err != nil { + if errors.Is(err, diskoci.ErrNotFound) { + ux.Debugf("disk cache: MISS %s", ref) + } else { + ux.Debugf("disk cache: resolve error: %v", err) + } + return false + } + + ux.Debugf("disk cache: HIT %s — pulling", ref) + if err := diskoci.Pull(ctx, ref, destPath, opts...); err != nil { + fmt.Fprintf(os.Stderr, "warning: disk cache pull failed: %v\n", err) + return false + } + fmt.Fprintf(os.Stderr, "disk cache: pulled %s → %s\n", ref, destPath) + return true +} + +func diskCachePullIfEnabled(ctx context.Context, destPath, ref string, noCache bool) bool { + if noCache { + ux.Debugf("disk cache: --no-cache — skipping pull probe") + return false + } + return diskCachePullWithRef(ctx, destPath, ref) +} + +func diskCacheAuthOptions() []diskoci.Option { + u := os.Getenv("DISKOCI_USERNAME") + p := os.Getenv("DISKOCI_PASSWORD") + if u == "" && p == "" { + if ghToken := os.Getenv("GITHUB_TOKEN"); ghToken != "" { + u = "devcell" + p = ghToken + } + } + if u != "" || p != "" { + return []diskoci.Option{diskoci.WithCredentials(u, p)} + } + return nil +} + +var validDiskCachePhases = map[string]bool{ + "ssh-able": true, + "base-profile": true, +} + +func isValidDiskCachePhase(phase string) bool { + return validDiskCachePhases[phase] +} diff --git a/cmd/disk_cache_test.go b/cmd/disk_cache_test.go new file mode 100644 index 00000000..2fc82045 --- /dev/null +++ b/cmd/disk_cache_test.go @@ -0,0 +1,108 @@ +//go:build darwin || linux + +package main + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDiskCacheRef_Format(t *testing.T) { + ref := diskCacheRef("base", "ssh-able", nil) + expected := fmt.Sprintf("ghcr.io/devcell-sh/winkit/base:ssh-able-%s-", runtime.GOARCH) + assert.Contains(t, ref, expected) +} + +func TestDiskCacheRef_ModulesChangeFingerprint(t *testing.T) { + ref1 := diskCacheRef("base", "ssh-able", nil) + ref2 := diskCacheRef("base", "ssh-able", []string{"extra"}) + assert.NotEqual(t, ref1, ref2, "different modules should produce different fingerprints") +} + +func TestDiskCacheRef_DeterministicFingerprint(t *testing.T) { + ref1 := diskCacheRef("go", "base-profile", []string{"a", "b"}) + ref2 := diskCacheRef("go", "base-profile", []string{"a", "b"}) + assert.Equal(t, ref1, ref2, "same inputs should produce the same ref") +} + +func TestDiskCacheFingerprint_Length(t *testing.T) { + fp := diskCacheFingerprint(nil) + assert.Len(t, fp, 12) +} + +func TestDiskCachePushPull_RoundTrip(t *testing.T) { + addr := startTestRegistry(t) + t.Setenv("DEVCELL_DISK_CACHE_REGISTRY", addr+"/test") + + diskPath := writeTempDisk(t, 8192) + stack := "base" + phase := "ssh-able" + var modules []string + + ctx := context.Background() + + ref := diskCacheRefFromEnv(stack, phase, modules) + assert.Contains(t, ref, addr) + + // Push succeeds + diskCachePushWithRef(ctx, diskPath, ref) + + // Pull succeeds and produces identical file + destPath := filepath.Join(t.TempDir(), "pulled.qcow2") + ok := diskCachePullWithRef(ctx, destPath, ref) + require.True(t, ok, "pull should succeed after push") + + orig, err := os.ReadFile(diskPath) + require.NoError(t, err) + pulled, err := os.ReadFile(destPath) + require.NoError(t, err) + assert.Equal(t, orig, pulled) +} + +func TestDiskCachePull_MissReturnsFalse(t *testing.T) { + addr := startTestRegistry(t) + ref := fmt.Sprintf("%s/test/nonexistent:v1", addr) + destPath := filepath.Join(t.TempDir(), "pulled.qcow2") + ok := diskCachePullWithRef(context.Background(), destPath, ref) + assert.False(t, ok, "pull should return false on cache miss") + _, err := os.Stat(destPath) + assert.True(t, os.IsNotExist(err), "no file should be left on miss") +} + +func TestDiskCachePush_FailureIsWarningNotError(t *testing.T) { + // Push to an unreachable registry + ctx := context.Background() + diskPath := writeTempDisk(t, 4096) + ref := "localhost:1/unreachable/repo:v1" + + // Should not panic or return error — just print a warning + diskCachePushWithRef(ctx, diskPath, ref) +} + +func TestDiskCacheNoCache_SkipsPull(t *testing.T) { + addr := startTestRegistry(t) + diskPath := writeTempDisk(t, 4096) + ref := fmt.Sprintf("%s/test/disk:v1", addr) + ctx := context.Background() + + diskCachePushWithRef(ctx, diskPath, ref) + + destPath := filepath.Join(t.TempDir(), "pulled.qcow2") + + // With noCache=true, pull should be skipped + ok := diskCachePullIfEnabled(ctx, destPath, ref, true) + assert.False(t, ok, "pull should be skipped when noCache is true") +} + +func TestValidDiskCachePhases(t *testing.T) { + assert.True(t, isValidDiskCachePhase("ssh-able")) + assert.True(t, isValidDiskCachePhase("base-profile")) + assert.False(t, isValidDiskCachePhase("other")) +} diff --git a/cmd/disk_store.go b/cmd/disk_store.go new file mode 100644 index 00000000..99888be1 --- /dev/null +++ b/cmd/disk_store.go @@ -0,0 +1,172 @@ +package main + +import ( + "errors" + "fmt" + "os" + + diskoci "github.com/devcell-sh/go-diskoci" + "github.com/spf13/cobra" +) + +var diskStoreCmd = &cobra.Command{ + Use: "disk-store", + Short: "Push or pull VM disk images as OCI artifacts (cache pipeline)", +} + +var diskStorePushCmd = &cobra.Command{ + Use: "push", + Short: "Push a local disk image to an OCI registry", + Long: `Push uploads a local VM disk image (qcow2, raw, or VHDX) as an OCI +artifact to a registry. The disk is chunked, compressed with zstd, and +stored under custom devcell media types. + +Examples: + cell disk-store push --image ghcr.io/devcell-sh/winkit/base:v1 --disk /path/to/template.qcow2 + cell disk-store push --image ghcr.io/devcell-sh/winkit/base:v1 --disk /path/to/template.qcow2 --chunk-size 256`, + RunE: runDiskStorePush, +} + +var diskStorePullCmd = &cobra.Command{ + Use: "pull", + Short: "Pull a VM disk image from an OCI registry", + Long: `Pull downloads a VM disk image from an OCI registry to a local path. +The download is atomic (temp file + rename) and returns an error on +cache miss. + +Examples: + cell disk-store pull --image ghcr.io/devcell-sh/winkit/base:v1 --disk /path/to/dest.qcow2 + cell disk-store pull --image ghcr.io/devcell-sh/winkit/base:v1 --disk /path/to/dest.raw --output-format raw`, + RunE: runDiskStorePull, +} + +var diskStoreResolveCmd = &cobra.Command{ + Use: "resolve", + Short: "Check if a disk image exists in the registry (HEAD-only probe)", + Long: `Resolve checks whether an image reference exists in the registry +without downloading it. Prints the digest and size on hit, exits 1 on miss. + +Examples: + cell disk-store resolve --image ghcr.io/devcell-sh/winkit/base:v1`, + RunE: runDiskStoreResolve, +} + +func init() { + diskStorePushCmd.Flags().String("image", "", "OCI image reference (e.g. ghcr.io/devcell-sh/winkit/base:v1)") + diskStorePushCmd.Flags().String("disk", "", "path to the local disk image") + diskStorePushCmd.Flags().Int64("chunk-size", 0, "layer chunk size in MiB (default: 512)") + diskStorePushCmd.Flags().String("source-format", "", "override input format detection (raw, vhdx, qcow2)") + diskStorePushCmd.Flags().StringToString("annotations", nil, "extra manifest annotations (key=value,...)") + diskStorePushCmd.Flags().String("username", "", "registry username (env: DISKOCI_USERNAME)") + diskStorePushCmd.Flags().String("password", "", "registry password (env: DISKOCI_PASSWORD)") + _ = diskStorePushCmd.MarkFlagRequired("image") + _ = diskStorePushCmd.MarkFlagRequired("disk") + + diskStorePullCmd.Flags().String("image", "", "OCI image reference to pull") + diskStorePullCmd.Flags().String("disk", "", "destination path for the disk image") + diskStorePullCmd.Flags().String("output-format", "", "convert pulled image to format (requires qemu-img)") + diskStorePullCmd.Flags().String("username", "", "registry username (env: DISKOCI_USERNAME)") + diskStorePullCmd.Flags().String("password", "", "registry password (env: DISKOCI_PASSWORD)") + _ = diskStorePullCmd.MarkFlagRequired("image") + _ = diskStorePullCmd.MarkFlagRequired("disk") + + diskStoreResolveCmd.Flags().String("image", "", "OCI image reference to check") + diskStoreResolveCmd.Flags().String("username", "", "registry username (env: DISKOCI_USERNAME)") + diskStoreResolveCmd.Flags().String("password", "", "registry password (env: DISKOCI_PASSWORD)") + _ = diskStoreResolveCmd.MarkFlagRequired("image") + + diskStoreCmd.AddCommand(diskStorePushCmd) + diskStoreCmd.AddCommand(diskStorePullCmd) + diskStoreCmd.AddCommand(diskStoreResolveCmd) + rootCmd.AddCommand(diskStoreCmd) +} + +func diskStoreCredentials(cmd *cobra.Command) (string, string) { + u, _ := cmd.Flags().GetString("username") + p, _ := cmd.Flags().GetString("password") + if u == "" { + u = os.Getenv("DISKOCI_USERNAME") + } + if p == "" { + p = os.Getenv("DISKOCI_PASSWORD") + } + return u, p +} + +func diskStoreOptions(cmd *cobra.Command) []diskoci.Option { + var opts []diskoci.Option + u, p := diskStoreCredentials(cmd) + if u != "" || p != "" { + opts = append(opts, diskoci.WithCredentials(u, p)) + } + return opts +} + +func runDiskStorePush(cmd *cobra.Command, _ []string) error { + image, _ := cmd.Flags().GetString("image") + disk, _ := cmd.Flags().GetString("disk") + chunkMiB, _ := cmd.Flags().GetInt64("chunk-size") + srcFmt, _ := cmd.Flags().GetString("source-format") + annotations, _ := cmd.Flags().GetStringToString("annotations") + + var opts []diskoci.PushOption + opts = append(opts, diskStoreOptions(cmd)...) + + if chunkMiB > 0 { + opts = append(opts, diskoci.WithChunkSize(chunkMiB*1024*1024)) + } + if srcFmt != "" { + opts = append(opts, diskoci.WithSourceFormat(srcFmt)) + } + if len(annotations) > 0 { + opts = append(opts, diskoci.WithAnnotations(annotations)) + } + + digest, err := diskoci.Push(cmd.Context(), image, disk, opts...) + if err != nil { + return err + } + fmt.Fprintf(os.Stderr, "pushed %s → %s\n", disk, image) + fmt.Println(string(digest)) + return nil +} + +func runDiskStorePull(cmd *cobra.Command, _ []string) error { + image, _ := cmd.Flags().GetString("image") + disk, _ := cmd.Flags().GetString("disk") + outFmt, _ := cmd.Flags().GetString("output-format") + + var opts []diskoci.PullOption + opts = append(opts, diskStoreOptions(cmd)...) + + if outFmt != "" { + opts = append(opts, diskoci.WithOutputFormat(outFmt)) + } + + if err := diskoci.Pull(cmd.Context(), image, disk, opts...); err != nil { + if errors.Is(err, diskoci.ErrNotFound) { + fmt.Fprintln(os.Stderr, "cache MISS — image not found") + os.Exit(1) + } + return err + } + fmt.Fprintf(os.Stderr, "pulled %s → %s\n", image, disk) + return nil +} + +func runDiskStoreResolve(cmd *cobra.Command, _ []string) error { + image, _ := cmd.Flags().GetString("image") + opts := diskStoreOptions(cmd) + + desc, err := diskoci.ResolveImage(cmd.Context(), image, opts...) + if err != nil { + if errors.Is(err, diskoci.ErrNotFound) { + fmt.Fprintln(os.Stderr, "MISS") + os.Exit(1) + } + return err + } + fmt.Fprintf(os.Stderr, "HIT: %s (%d bytes, %s)\n", desc.Digest, desc.Size, desc.MediaType) + fmt.Println(string(desc.Digest)) + return nil +} diff --git a/cmd/disk_store_test.go b/cmd/disk_store_test.go new file mode 100644 index 00000000..61112a0f --- /dev/null +++ b/cmd/disk_store_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "fmt" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/google/go-containerregistry/pkg/registry" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func startTestRegistry(t *testing.T) string { + t.Helper() + srv := httptest.NewServer(registry.New()) + t.Cleanup(srv.Close) + return srv.Listener.Addr().String() +} + +func writeTempDisk(t *testing.T, size int) string { + t.Helper() + f, err := os.CreateTemp(t.TempDir(), "test-disk-*.qcow2") + require.NoError(t, err) + data := make([]byte, size) + // qcow2 magic: "QFI\xfb" + copy(data, []byte{'Q', 'F', 'I', 0xfb}) + _, err = f.Write(data) + require.NoError(t, err) + require.NoError(t, f.Close()) + return f.Name() +} + +func TestDiskStoreCmd_PushAndPull(t *testing.T) { + addr := startTestRegistry(t) + diskPath := writeTempDisk(t, 4096) + ref := fmt.Sprintf("%s/test/disk:v1", addr) + + rootCmd.SetArgs([]string{"disk-store", "push", "--image", ref, "--disk", diskPath}) + require.NoError(t, rootCmd.Execute()) + + destPath := filepath.Join(t.TempDir(), "pulled.qcow2") + rootCmd.SetArgs([]string{"disk-store", "pull", "--image", ref, "--disk", destPath}) + require.NoError(t, rootCmd.Execute()) + + orig, err := os.ReadFile(diskPath) + require.NoError(t, err) + pulled, err := os.ReadFile(destPath) + require.NoError(t, err) + assert.Equal(t, orig, pulled, "round-trip should produce identical file") +} + +func TestDiskStoreCmd_ResolveHit(t *testing.T) { + addr := startTestRegistry(t) + diskPath := writeTempDisk(t, 4096) + ref := fmt.Sprintf("%s/test/disk:v1", addr) + + rootCmd.SetArgs([]string{"disk-store", "push", "--image", ref, "--disk", diskPath}) + require.NoError(t, rootCmd.Execute()) + + rootCmd.SetArgs([]string{"disk-store", "resolve", "--image", ref}) + require.NoError(t, rootCmd.Execute()) +} diff --git a/go.mod b/go.mod index 22c02a85..d3f9a72a 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,6 @@ module github.com/DimmKirr/devcell go 1.26.0 -replace github.com/devcell-sh/go-winkit => /Users/dmitry/dev/devcell-sh/go-winkit - require ( github.com/BurntSushi/toml v1.4.0 github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 From b55fdd9cf968f2e13e5b99fe33e2f500ae3e49c9 Mon Sep 17 00:00:00 2001 From: Dmitry Kireev Date: Sun, 6 Sep 2026 05:58:48 +0000 Subject: [PATCH 10/11] CELL-491: Fix go.mod/go.sum missing entry for go-diskoci, unblocking clean-module-cache builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - build(deps): declare github.com/devcell-sh/go-diskoci v0.1.0 in go.mod/go.sum — lets CI's Deploy Site build resolve the import from a clean checkout instead of failing go mod tidy - build(deps): bump go-containerregistry, docker/cli, klauspost/compress, x/mod, x/net, x/tools as incidental go mod tidy fallout — picks up upstream point-release fixes - build(nix): refresh flake.nix vendorHash to match the updated go.sum — keeps `nix build` from failing on a hash mismatch --- flake.nix | 2 +- go.mod | 13 +++++++------ go.sum | 28 ++++++++++++++++------------ 3 files changed, 24 insertions(+), 19 deletions(-) diff --git a/flake.nix b/flake.nix index 6ea4dfcf..6e83e080 100644 --- a/flake.nix +++ b/flake.nix @@ -55,7 +55,7 @@ version = nixpkgs.lib.removePrefix "v" cellVersion; src = cellSrc; - vendorHash = "sha256-Jl7DQv3SXJ6H/BY97LQH1Zm47nOgaYAKyN1AczTNpro="; + vendorHash = "sha256-G3kp9kXXi9wcO+cz+R4hFo1XDKPABxAZ+pesRe38gvE="; subPackages = ["cmd"]; diff --git a/go.mod b/go.mod index d3f9a72a..8fc66a1e 100644 --- a/go.mod +++ b/go.mod @@ -12,9 +12,10 @@ require ( github.com/charmbracelet/x/vt v0.0.0-20260712004152-b16d026a9d2e github.com/charmbracelet/x/xpty v0.1.3 github.com/creack/pty v1.1.24 + github.com/devcell-sh/go-diskoci v0.1.0 github.com/digitalocean/go-libvirt v0.0.0-20260609165003-6254771e63a8 github.com/docker/docker v28.5.1+incompatible - github.com/google/go-containerregistry v0.21.9 + github.com/google/go-containerregistry v0.22.0 github.com/google/uuid v1.6.0 github.com/hydrz/wireguard v0.0.1 github.com/mattn/go-isatty v0.0.20 @@ -29,7 +30,7 @@ require ( github.com/testcontainers/testcontainers-go v0.40.0 golang.org/x/crypto v0.55.0 golang.org/x/image v0.41.0 - golang.org/x/mod v0.38.0 + golang.org/x/mod v0.39.0 gopkg.in/yaml.v3 v3.0.1 howett.net/plist v1.0.1 k8s.io/client-go v0.36.2 @@ -85,7 +86,7 @@ require ( github.com/devcell-sh/go-winkit v0.2.0 github.com/distribution/reference v0.6.0 // indirect github.com/djherbis/times v1.6.0 // indirect - github.com/docker/cli v29.6.2+incompatible // indirect + github.com/docker/cli v29.7.2+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/docker/go-connections v0.7.0 // indirect github.com/docker/go-units v0.5.0 // indirect @@ -111,7 +112,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect - github.com/klauspost/compress v1.19.1 // indirect + github.com/klauspost/compress v1.19.2 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/magiconair/properties v1.8.10 // indirect @@ -168,13 +169,13 @@ require ( go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 // indirect - golang.org/x/net v0.57.0 // indirect + golang.org/x/net v0.58.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.22.0 // indirect golang.org/x/term v0.45.0 // indirect golang.org/x/text v0.41.0 // indirect golang.org/x/time v0.14.0 // indirect - golang.org/x/tools v0.48.0 // indirect + golang.org/x/tools v0.49.0 // indirect google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect diff --git a/go.sum b/go.sum index 3f00275d..3b0bf383 100644 --- a/go.sum +++ b/go.sum @@ -100,12 +100,16 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/devcell-sh/go-diskoci v0.1.0 h1:smRDsbhId4CuVKd3pGoyH7JsKRFaCsOtGDJMry45rqk= +github.com/devcell-sh/go-diskoci v0.1.0/go.mod h1:TnHyrwvNvtR/gdhXRIM07nzkI7QrDWw8m9Mg+exzrwo= github.com/devcell-sh/go-nixoci v0.1.0 h1:SFC8JXEyBvUn/rLKKVWCwTlEmVPTc71ExGkd/NPS5gA= github.com/devcell-sh/go-nixoci v0.1.0/go.mod h1:YSZipF+SryYurcvQ5LvtRkWzkM/YJCDHeJYB+h/BZBg= github.com/devcell-sh/go-regedit v0.1.0 h1:+eT+eLZQZtDpKhzjlGBP2DdyNuUhFTX+8354jTw8W5Y= github.com/devcell-sh/go-regedit v0.1.0/go.mod h1:pWEerGIoAAVu00kuyFaXCmIzIWghcJYwiK8xGha1L5E= github.com/devcell-sh/go-wimlib v0.1.0 h1:pg1WxyqfMm/9Anmp9amfr+nMKwzAc4D5ra4n8qkZkMg= github.com/devcell-sh/go-wimlib v0.1.0/go.mod h1:BFGCDzvSqoVtHxQ8j5GhXHmO8c3w/kLnHDJaOogPstE= +github.com/devcell-sh/go-winkit v0.2.0 h1:qx4udUGbd33q3mE1Cg2nNzN5bpGMymD2VI49sPCC4PU= +github.com/devcell-sh/go-winkit v0.2.0/go.mod h1:knVXG2/GhrkkHZIvie9+NeZLH93F0NbISzeXtMROTUk= github.com/digitalocean/go-libvirt v0.0.0-20260609165003-6254771e63a8 h1:R4zqGCPowg1bfJXFxsS122mzkivaMz+wPLxBsF9qsiY= github.com/digitalocean/go-libvirt v0.0.0-20260609165003-6254771e63a8/go.mod h1:qb0Ofa71d3oXARQf633h2tNaeBxLsVxuDp+jcsVO2+4= github.com/diskfs/go-diskfs v1.9.4 h1:0j2d7eG4IjyxL6+ChWbDPocdBCF6HQ4HBWU2WDYWVnc= @@ -114,8 +118,8 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/djherbis/times v1.6.0 h1:w2ctJ92J8fBvWPxugmXIv7Nz7Q3iDMKNx9v5ocVH20c= github.com/djherbis/times v1.6.0/go.mod h1:gOHeRAz2h+VJNZ5Gmc/o7iD9k4wW7NMVqieYCY99oc0= -github.com/docker/cli v29.6.2+incompatible h1:/bjePvcbbFTnRrMfWJBY7AjfICdsiLVgHn6LwTVOcqw= -github.com/docker/cli v29.6.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v29.7.2+incompatible h1:dlkwallR8XqfeVnA2ELEhdwvb4lsSwuB4IgsG8Q9cLY= +github.com/docker/cli v29.7.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/docker v28.5.1+incompatible h1:Bm8DchhSD2J6PsFzxC35TZo4TLGR2PdW/E69rU45NhM= github.com/docker/docker v28.5.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= @@ -181,8 +185,8 @@ github.com/google/gnostic-models v0.7.0/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7O github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/go-containerregistry v0.21.9 h1:F+D4uZ3iA3DLMJLfhaqMdHJbzeqm/216WGQq2dokuLs= -github.com/google/go-containerregistry v0.21.9/go.mod h1:dP5XNKcL7kMFF/TB3LfvWmVhAcv7iqkHb3oDK8aauTo= +github.com/google/go-containerregistry v0.22.0 h1:eGbCiPeYxAH/7WLLq6zTBALP0tUIFsoyRauhxXDJ53I= +github.com/google/go-containerregistry v0.22.0/go.mod h1:bJR35SK8XgisYmhg/FMQ/5RK0S/XrOAqLBV5/LR2XE0= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= @@ -201,8 +205,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= -github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= -github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/compress v1.19.2 h1:hMRETovs/pu/dVWN7zIT1PGG8t509MwT6bO7XSi26R8= +github.com/klauspost/compress v1.19.2/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= @@ -393,10 +397,10 @@ golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93 h1:fQsdNF2N+/YewlRZiricy4P1i golang.org/x/exp v0.0.0-20251219203646-944ab1f22d93/go.mod h1:EPRbTFwzwjXj9NpYyyrvenVh9Y+GFeEvMNh7Xuz7xgU= golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo= golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= -golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= -golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= -golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= -golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/mod v0.39.0 h1:UF5zwQdCRRUpHfyPwr7d4UrGiVeldIsogtzWVnczL74= +golang.org/x/mod v0.39.0/go.mod h1:bvIbwjQ0HUFFf5AKukeeYQG4ZBUG9yxQbR9aEweIwYY= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= @@ -418,8 +422,8 @@ golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= -golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= -golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= +golang.org/x/tools v0.49.0 h1:3NI7VXzL9+1WZD52Dx2ttoPwD5DWrFGpl9mFZDlmisI= +golang.org/x/tools v0.49.0/go.mod h1:SJNXV9DBKT0UbdttsQjbfJlAE/q+y36++zo3uL3N0Oo= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= From 9b7b89952d14dcca142689e4178ba564042eb4ce Mon Sep 17 00:00:00 2001 From: Dmitry Kireev Date: Sun, 6 Sep 2026 05:58:49 +0000 Subject: [PATCH 11/11] [CELL-447] Add test coverage for shipped flake, MCP, and no-ports behavior MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - test(cfg): cover FlakeEnabled() env>toml>false precedence — no user-facing impact - test(cfg): cover [mcp].enabled merge/dedup and TOML load — no user-facing impact - test(runner): rename SkipFlake tests to TrustFlake, add NoPorts coverage — no user-facing impact --- internal/cfg/cfg_test.go | 79 ++++++++++++++++++++++++++++++++++ internal/runner/runner_test.go | 49 ++++++++++++++------- 2 files changed, 113 insertions(+), 15 deletions(-) diff --git a/internal/cfg/cfg_test.go b/internal/cfg/cfg_test.go index 2e3f67cf..000dc37f 100644 --- a/internal/cfg/cfg_test.go +++ b/internal/cfg/cfg_test.go @@ -289,6 +289,43 @@ func TestMerge_MiseAccumulates(t *testing.T) { } } +// --- MCP section --- + +func TestMerge_McpEnabledAccumulates(t *testing.T) { + global := cfg.CellConfig{} + global.Mcp.Enabled = []string{"aws-api", "terraform"} + project := cfg.CellConfig{} + project.Mcp.Enabled = []string{"terraform", "playwright"} + merged := cfg.Merge(global, project) + want := []string{"aws-api", "playwright", "terraform"} + if len(merged.Mcp.Enabled) != len(want) { + t.Fatalf("got %v, want %v", merged.Mcp.Enabled, want) + } + for i, v := range want { + if merged.Mcp.Enabled[i] != v { + t.Errorf("[%d] got %q, want %q", i, merged.Mcp.Enabled[i], v) + } + } +} + +func TestLoadFile_McpEnabled(t *testing.T) { + dir := t.TempDir() + writeTOML(t, dir, "devcell.toml", ` +[mcp] +enabled = ["aws-api", "terraform"] +`) + c, err := cfg.LoadFile(filepath.Join(dir, "devcell.toml")) + if err != nil { + t.Fatal(err) + } + if len(c.Mcp.Enabled) != 2 { + t.Fatalf("got %v, want 2 entries", c.Mcp.Enabled) + } + if c.Mcp.Enabled[0] != "aws-api" || c.Mcp.Enabled[1] != "terraform" { + t.Errorf("got %v", c.Mcp.Enabled) + } +} + // --- GUI field --- func boolPtr(b bool) *bool { return &b } @@ -3046,3 +3083,45 @@ AllowedIPs = 0.0.0.0/0`, t.Fatalf("PrivateKey should not be required (loaded via PostUp), got: %v", err) } } + +// --- FlakeEnabled --- + +func TestFlakeEnabled_DefaultFalse(t *testing.T) { + t.Setenv("DEVCELL_FLAKE", "") + c := cfg.CellSection{} + if c.FlakeEnabled() { + t.Error("FlakeEnabled() should default to false") + } +} + +func TestFlakeEnabled_TOMLTrue(t *testing.T) { + t.Setenv("DEVCELL_FLAKE", "") + c := cfg.CellSection{Flake: boolPtr(true)} + if !c.FlakeEnabled() { + t.Error("FlakeEnabled() should return true when TOML sets flake=true") + } +} + +func TestFlakeEnabled_TOMLFalse(t *testing.T) { + t.Setenv("DEVCELL_FLAKE", "") + c := cfg.CellSection{Flake: boolPtr(false)} + if c.FlakeEnabled() { + t.Error("FlakeEnabled() should return false when TOML sets flake=false") + } +} + +func TestFlakeEnabled_EnvOverridesToTrue(t *testing.T) { + t.Setenv("DEVCELL_FLAKE", "1") + c := cfg.CellSection{Flake: boolPtr(false)} + if !c.FlakeEnabled() { + t.Error("DEVCELL_FLAKE=1 should override TOML flake=false") + } +} + +func TestFlakeEnabled_EnvOverridesToFalse(t *testing.T) { + t.Setenv("DEVCELL_FLAKE", "0") + c := cfg.CellSection{Flake: boolPtr(true)} + if c.FlakeEnabled() { + t.Error("DEVCELL_FLAKE=0 should override TOML flake=true") + } +} diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index 5b1a1eb4..5ad1c38b 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -1311,38 +1311,57 @@ func TestArgv_CrossToolAgentMounts_DedupAgainstCfgVolumes(t *testing.T) { } } -func TestArgv_SkipFlakeEnvVar(t *testing.T) { - argv := buildArgv(t, func(s *runner.RunSpec) { s.SkipFlake = true }) - if !hasConsecutive(argv, "-e", "DEVCELL_SKIP_FLAKE=1") { - t.Fatal("expected DEVCELL_SKIP_FLAKE=1 when SkipFlake is true") +func TestArgv_TrustFlakeEnvVar(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { s.TrustFlake = true }) + if !hasConsecutive(argv, "-e", "DEVCELL_FLAKE_TRUST=1") { + t.Fatal("expected DEVCELL_FLAKE_TRUST=1 when TrustFlake is true") } } -func TestArgv_SkipFlakeAbsentByDefault(t *testing.T) { +func TestArgv_TrustFlakeAbsentByDefault(t *testing.T) { argv := buildArgv(t) for _, a := range argv { - if strings.Contains(a, "DEVCELL_SKIP_FLAKE") { - t.Fatalf("DEVCELL_SKIP_FLAKE should not appear by default, got: %s", a) + if strings.Contains(a, "DEVCELL_FLAKE_TRUST") { + t.Fatalf("DEVCELL_FLAKE_TRUST should not appear by default, got: %s", a) } } } -func TestArgv_TrustFlakeEnvVar(t *testing.T) { - argv := buildArgv(t, func(s *runner.RunSpec) { s.TrustFlake = true }) - if !hasConsecutive(argv, "-e", "DEVCELL_FLAKE_TRUST=1") { - t.Fatal("expected DEVCELL_FLAKE_TRUST=1 when TrustFlake is true") +// --- NoPorts --- + +func TestArgv_NoPorts_SkipsUserPorts(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.NoPorts = true + s.CellCfg.Ports = cfg.PortsSection{Forward: []string{"3000", "8080:3000"}} + }) + for _, a := range argv { + if strings.Contains(a, "3000") { + t.Fatalf("expected no port mappings with NoPorts, got: %s", a) + } } } -func TestArgv_TrustFlakeAbsentByDefault(t *testing.T) { - argv := buildArgv(t) +func TestArgv_NoPorts_SkipsGUIPorts(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.NoPorts = true + s.CellCfg.GUI.Enabled = boolPtr(true) + }) for _, a := range argv { - if strings.Contains(a, "DEVCELL_FLAKE_TRUST") { - t.Fatalf("DEVCELL_FLAKE_TRUST should not appear by default, got: %s", a) + if strings.Contains(a, "5900") || strings.Contains(a, "3389") { + t.Fatalf("expected no GUI port mappings with NoPorts, got: %s", a) } } } +func TestArgv_NoPorts_DefaultFalse(t *testing.T) { + argv := buildArgv(t, func(s *runner.RunSpec) { + s.CellCfg.Ports = cfg.PortsSection{Forward: []string{"3000"}} + }) + if !hasConsecutive(argv, "-p", "0.0.0.0:3000:3000") { + t.Errorf("expected -p 0.0.0.0:3000:3000 when NoPorts is false: %v", argv) + } +} + // --- Wireguard --- func TestArgv_WireguardEnabled_AddsNetAdmin(t *testing.T) {