From e8bb0bfc4d0113e98c63e93fec678c0c6eacebbc Mon Sep 17 00:00:00 2001 From: Joseph Yaksich Date: Sat, 1 Aug 2026 19:55:13 +0000 Subject: [PATCH] Fix fresh Linux connector and Windows reboot setup --- CHANGELOG.md | 16 ++++++++- README.md | 2 +- package-lock.json | 4 +-- package.json | 2 +- scripts/install-wsl-runtime.ps1 | 54 +++++++++++++++++++++++++----- site/public/install-linux-units.sh | 1 + src/server/channel-computers.ts | 2 +- src/server/connectors.ts | 13 ++++--- src/server/db.ts | 2 +- test/channel-computers.mjs | 2 +- test/connectors.mjs | 29 +++++++++++++++- test/desktop.mjs | 4 +++ test/site.mjs | 1 + 13 files changed, 111 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0861129..3e006b6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.34] - 2026-08-01 + +### Fixed + +- Fixed fresh Linux collaboration so the systemd service resolves the + architecture-specific Cloudflare connector already shipped inside the + verified host archive. +- Fixed fresh Windows setup so enabling WSL 2 features always stops at the + required reboot boundary, and mapped the Windows VM-compute-not-ready import + response to that same actionable restart state. +- Made Windows retries safely recover an app-owned partial shared-runtime + import left behind when Windows required the feature-activation reboot. + ## [0.0.33] - 2026-08-01 ### Fixed @@ -955,7 +968,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 notarization, stapled tickets, Gatekeeper verification, persistent Application Support, and isolated Apple container machines. -[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.33...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.34...HEAD +[0.0.34]: https://github.com/gitcommit90/1Helm/compare/v0.0.33...v0.0.34 [0.0.33]: https://github.com/gitcommit90/1Helm/compare/v0.0.32...v0.0.33 [0.0.32]: https://github.com/gitcommit90/1Helm/compare/v0.0.31...v0.0.32 [0.0.31]: https://github.com/gitcommit90/1Helm/compare/v0.0.30...v0.0.31 diff --git a/README.md b/README.md index 5268c38..e25a61c 100644 --- a/README.md +++ b/README.md @@ -313,7 +313,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to | `PORT` | `8123` | HTTP/WebSocket control-plane port. | | `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and non-OCI development/Apple workspace mirrors. | | `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `oci` on Linux and Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. | -| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.33` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.34` | Versioned channel-machine image contract. | ### Agent-first JSON CLI diff --git a/package-lock.json b/package-lock.json index 09a7286..30104e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.33", + "version": "0.0.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.33", + "version": "0.0.34", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { diff --git a/package.json b/package.json index 901a4e5..7bee699 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.33", + "version": "0.0.34", "private": true, "type": "module", "license": "AGPL-3.0-only", diff --git a/scripts/install-wsl-runtime.ps1 b/scripts/install-wsl-runtime.ps1 index b02bcab..f75633e 100644 --- a/scripts/install-wsl-runtime.ps1 +++ b/scripts/install-wsl-runtime.ps1 @@ -66,6 +66,18 @@ function Test-RestartRequired { return $value -eq "Required" -or $value -eq "1" -or $value -eq "True" } +function Test-WslRestartFailure { + param([string]$Text) + return $Text -match 'HCS_E_SERVICE_NOT_AVAILABLE|required feature is not installed' +} + +function Require-WindowsRestart { + $message = "WSL 2 features are enabled. Restart Windows once, then retry 1Helm computer setup." + Write-SetupStatus -Status "restart_required" -Step $message -Progress 20 -ErrorMessage "Windows restart required to finish enabling WSL 2." + Write-Host $message + exit 10 +} + function Get-WslDistributionNames { $result = Get-WslText -ArgumentList @("--list", "--quiet") if ($result.ExitCode -ne 0) { return @() } @@ -123,11 +135,16 @@ if ($HostSetup) { Write-SetupStatus -Status "running" -Step "Enabling Windows WSL features..." -Progress 8 $wslFeature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux $vmFeature = Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform - if ($wslFeature.State -ne "Enabled") { Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux -All -NoRestart | Out-Null } - if ($vmFeature.State -ne "Enabled") { Enable-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -All -NoRestart | Out-Null } + $enabledWslFeatureNow = $wslFeature.State -ne "Enabled" + $enabledVmFeatureNow = $vmFeature.State -ne "Enabled" + if ($enabledWslFeatureNow) { Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux -All -NoRestart | Out-Null } + if ($enabledVmFeatureNow) { Enable-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -All -NoRestart | Out-Null } $wslFeature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux $vmFeature = Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform - $restartRequired = (Test-RestartRequired $wslFeature) -or (Test-RestartRequired $vmFeature) + # DISM's RestartRequired enum can stringify as "Possible" even though its + # numeric value is 1. Enabling either feature in this invocation is itself + # authoritative evidence that Windows must reboot before a WSL 2 VM import. + $restartRequired = $enabledWslFeatureNow -or $enabledVmFeatureNow -or (Test-RestartRequired $wslFeature) -or (Test-RestartRequired $vmFeature) $hostTemporary = Join-Path ([System.IO.Path]::GetTempPath()) ("1helm-wsl-host-" + [Guid]::NewGuid().ToString("N")) New-Item -ItemType Directory -Path $hostTemporary | Out-Null try { @@ -160,14 +177,20 @@ if ($HostSetup) { } else { Write-SetupStatus -Status "running" -Step "Microsoft WSL $wslVersion is already installed." -Progress 18 } + # A feature may already report Enabled before the reboot has registered + # WSL's VM compute service. This is the concrete pre-reboot state that + # otherwise lets setup continue into HCS_E_SERVICE_NOT_AVAILABLE. + if ($null -eq (Get-Service -Name vmcompute -ErrorAction SilentlyContinue)) { $restartRequired = $true } if ($restartRequired) { - Write-SetupStatus -Status "restart_required" -Step "WSL 2 features are enabled. Restart Windows once, then retry 1Helm computer setup." -Progress 20 - exit 10 + Require-WindowsRestart } if (-not (Test-PinnedWslRuntime)) { Fail-Setup "Microsoft WSL $wslVersion was installed but could not be verified." } Write-SetupStatus -Status "running" -Step "Setting WSL 2 as the default..." -Progress 22 $defaultVersion = Get-WslText -ArgumentList @("--set-default-version", "2") - if ($defaultVersion.ExitCode -ne 0) { Fail-Setup "WSL could not set version 2 as the default. $($defaultVersion.Text)" } + if ($defaultVersion.ExitCode -ne 0) { + if (Test-WslRestartFailure $defaultVersion.Text) { Require-WindowsRestart } + Fail-Setup "WSL could not set version 2 as the default. $($defaultVersion.Text)" + } } finally { if (Test-Path -LiteralPath $hostTemporary) { Remove-Item -LiteralPath $hostTemporary -Recurse -Force } } @@ -242,11 +265,22 @@ try { $names = @(Get-WslDistributionNames) $runtimeRoot = Join-Path $env:LOCALAPPDATA "1Helm-Runtime" $installDirectory = Join-Path $runtimeRoot $RuntimeName + $partialMarker = "$installDirectory.1helm-partial-import" if ($names -notcontains $RuntimeName) { if (Test-Path -LiteralPath $installDirectory) { - Fail-Setup "The shared runtime disk directory already exists without a registered runtime. Remove `"$installDirectory`" or unregister the partial distro, then retry." + $entries = @(Get-ChildItem -LiteralPath $installDirectory -Force -ErrorAction SilentlyContinue) + $ownedPartial = (Test-Path -LiteralPath $partialMarker -PathType Leaf) -and ((Get-Content -LiteralPath $partialMarker -Raw).Trim() -eq $RuntimeName) + # v0.0.33 could leave an empty app-owned directory when Windows rejected + # the import before creating its VM. New attempts carry an ownership + # marker so an interrupted partial VHD can also be retried safely. + if ($entries.Count -eq 0 -or $ownedPartial) { + Remove-Item -LiteralPath $installDirectory -Recurse -Force + } else { + Fail-Setup "The shared runtime disk directory already exists without a registered runtime. Remove `"$installDirectory`" or unregister the partial distro, then retry." + } } New-Item -ItemType Directory -Path $installDirectory -Force | Out-Null + [System.IO.File]::WriteAllText($partialMarker, $RuntimeName, [System.Text.UTF8Encoding]::new($false)) $rootfs = Join-Path $temporary "ubuntu-noble-wsl.rootfs.tar.gz" Write-SetupStatus -Status "running" -Step "Downloading shared Linux runtime base..." -Progress 35 Fetch-File -Url $rootfsUrl -Destination $rootfs @@ -255,7 +289,11 @@ try { } Write-SetupStatus -Status "running" -Step "Importing shared Linux runtime..." -Progress 48 $imported = Get-WslText -ArgumentList @("--import", $RuntimeName, $installDirectory, $rootfs, "--version", "2") - if ($imported.ExitCode -ne 0) { Fail-Setup "The shared 1Helm WSL runtime could not be imported. $($imported.Text)" } + if ($imported.ExitCode -ne 0) { + if (Test-WslRestartFailure $imported.Text) { Require-WindowsRestart } + Fail-Setup "The shared 1Helm WSL runtime could not be imported. $($imported.Text)" + } + Remove-Item -LiteralPath $partialMarker -Force } Write-SetupStatus -Status "running" -Step "Installing shared runtime packages (podman, crun, ...)..." -Progress 58 diff --git a/site/public/install-linux-units.sh b/site/public/install-linux-units.sh index d89e93e..3f3d665 100755 --- a/site/public/install-linux-units.sh +++ b/site/public/install-linux-units.sh @@ -65,6 +65,7 @@ Environment=NODE_ENV=production Environment=PORT=8123 Environment=HELM_HOST=0.0.0.0 Environment=CTRL_DATA_DIR=$STATE_ROOT +Environment=HELM_APP_ROOT=$INSTALL_ROOT/current Environment=HELM_CHANNEL_COMPUTER_BACKEND=oci Environment=HELM_OCI_HELPER=/usr/libexec/1helm-oci-runtime Environment=HELM_INSTALL_KIND=linux-systemd diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index 7480b36..d83d5fd 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -68,7 +68,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0"; export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`; export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`; export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"; -export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.33"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.34"; const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[]; const OCI_RUNTIME_VERSION = "1helm-oci-runtime-v1"; const OCI_HELPER_CANDIDATES = [ diff --git a/src/server/connectors.ts b/src/server/connectors.ts index 1ad928f..090f15e 100644 --- a/src/server/connectors.ts +++ b/src/server/connectors.ts @@ -16,7 +16,10 @@ let shuttingDown = false; function connectorBinary(): string { const resources = process.env.HELM_RESOURCES_PATH || ""; - const appRoot = process.env.HELM_APP_ROOT || ""; + // Linux systemd releases run with /opt/1helm/current as their working + // directory. Keep that installed-root contract usable even if an older unit + // omitted HELM_APP_ROOT, while preferring the explicit packaged root. + const appRoots = [...new Set([process.env.HELM_APP_ROOT || "", process.cwd()].filter(Boolean))]; const pathSep = process.platform === "win32" ? ";" : ":"; const pathNames = process.platform === "win32" ? ["cloudflared.exe", "cloudflared"] : ["cloudflared"]; const linuxConnector = process.platform === "linux" && (process.arch === "x64" || process.arch === "arm64") @@ -28,9 +31,11 @@ function connectorBinary(): string { // Packaged desktop apps (macOS Resources/cloudflared, Windows resources/cloudflared.exe). resources ? join(resources, "cloudflared.exe") : "", resources ? join(resources, "cloudflared") : "", - appRoot ? join(appRoot, "cloudflared.exe") : "", - appRoot ? join(appRoot, "cloudflared") : "", - appRoot && linuxConnector ? join(appRoot, "resources", linuxConnector) : "", + ...appRoots.flatMap((appRoot) => [ + join(appRoot, "cloudflared.exe"), + join(appRoot, "cloudflared"), + linuxConnector ? join(appRoot, "resources", linuxConnector) : "", + ]), "/opt/homebrew/bin/cloudflared", "/usr/local/bin/cloudflared", "/usr/bin/cloudflared", diff --git a/src/server/db.ts b/src/server/db.ts index ca99041..a28d589 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -939,7 +939,7 @@ export function migrate(): void { const platformBackend = process.platform === "darwin" ? "apple" : "oci"; const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend); const backend = ["apple", "oci", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend; - const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.33"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.34"); for (const channel of q(`SELECT c.id FROM channels c JOIN agent_channels ac ON ac.channel_id=c.id WHERE c.kind='channel' AND c.status<>'deleted'`)) { const channelId = Number(channel.id); diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index 984c330..5e6e553 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -181,7 +181,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive test("runtime digest and packaged image recipe stay pinned", async () => { assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"); assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/); - assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.33"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.34"); const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets"); const image = await readFile(join(root, "container", "Containerfile"), "utf8"); diff --git a/test/connectors.mjs b/test/connectors.mjs index 5fc15e1..c442cee 100644 --- a/test/connectors.mjs +++ b/test/connectors.mjs @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; import { readFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -17,6 +17,33 @@ test("Linux release packaging ships pinned connectors for every supported host a assert.match(packageLinux, /cloudflared-linux-\$\{connector\.arch\}/, "the verified binaries enter the Linux release archive"); assert.match(packageLinux, /chmodSync\(destination, 0o755\)/, "packaged Linux connectors retain executable mode"); assert.match(resolver, /cloudflared-linux-\$\{process\.arch\}/, "Linux resolves only the binary matching the running host architecture"); + assert.match(resolver, /process\.env\.HELM_APP_ROOT[\s\S]*process\.cwd\(\)/, "an installed Linux service can resolve its bundled connector from its release working directory"); +}); + +test("Linux service working directory resolves the bundled connector without a legacy app-root environment", async (t) => { + const root = await mkdtemp(join(tmpdir(), "1helm-linux-connector-root-")); + const resources = join(root, "resources"); + const binary = join(resources, `cloudflared-linux-${process.arch}`); + const originalCwd = process.cwd(); + const originalAppRoot = process.env.HELM_APP_ROOT; + const originalResources = process.env.HELM_RESOURCES_PATH; + const originalBinary = process.env.CLOUDFLARED_BIN; + await mkdir(resources); + await writeFile(binary, "#!/bin/sh\nexit 0\n"); + await chmod(binary, 0o755); + process.chdir(root); + delete process.env.HELM_APP_ROOT; + delete process.env.HELM_RESOURCES_PATH; + delete process.env.CLOUDFLARED_BIN; + t.after(async () => { + process.chdir(originalCwd); + if (originalAppRoot === undefined) delete process.env.HELM_APP_ROOT; else process.env.HELM_APP_ROOT = originalAppRoot; + if (originalResources === undefined) delete process.env.HELM_RESOURCES_PATH; else process.env.HELM_RESOURCES_PATH = originalResources; + if (originalBinary === undefined) delete process.env.CLOUDFLARED_BIN; else process.env.CLOUDFLARED_BIN = originalBinary; + await rm(root, { recursive: true, force: true }); + }); + const connectors = await import(`../src/server/connectors.ts?linux-root-test=${Date.now()}`); + assert.equal(connectors.connectorAvailable(), true); }); test("stopping a connector cancels automatic relaunch while preserving its credentials", async (t) => { diff --git a/test/desktop.mjs b/test/desktop.mjs index 0d29cb6..c8322a9 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -194,6 +194,10 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(windowsRuntime, /if \(\$HostSetup\)[\s\S]*try \{[\s\S]*catch \{[\s\S]*Fail-Setup \$message/, "unexpected elevated host-setup errors are written to shared status instead of collapsing to an unexplained exit code"); assert.match(windowsRuntime, /StatusPath/, "elevated HostSetup receives the shared status path so real errors reach the app"); assert.match(windowsRuntime, /1603[\s\S]*Test-PinnedWslRuntime|Test-PinnedWslRuntime[\s\S]*1603/, "MSI 1603 falls back to re-verifying an already-present pinned WSL runtime"); + assert.match(windowsRuntime, /\$enabledWslFeatureNow[\s\S]*\$enabledVmFeatureNow[\s\S]*\$restartRequired/, "features enabled in the current pass force a reboot before WSL import regardless of DISM enum formatting"); + assert.match(windowsRuntime, /Get-Service -Name vmcompute[\s\S]*\$restartRequired = \$true/, "an enabled-but-not-registered WSL VM compute service stops setup at the reboot boundary"); + assert.match(windowsRuntime, /HCS_E_SERVICE_NOT_AVAILABLE[\s\S]*Require-WindowsRestart|Test-WslRestartFailure[\s\S]*Require-WindowsRestart/, "an unavailable VM compute service is reported as restart-required instead of a broken runtime"); + assert.match(windowsRuntime, /\.1helm-partial-import[\s\S]*\$ownedPartial[\s\S]*Remove-Item -LiteralPath \$installDirectory/, "an app-owned partial WSL import can recover safely after reboot"); assert.match(windowsRuntime, /\[automount\][\s\S]*enabled=false[\s\S]*\[interop\][\s\S]*enabled=false/, "the shared runtime exposes neither Windows drives nor process interop"); assert.doesNotMatch(windowsRuntime, /--update/); assert.match(channelComputers, /HELM_WSL_SETUP_STATUS/, "Windows runtime install is tracked through a status file instead of fire-and-forget Start-Process"); diff --git a/test/site.mjs b/test/site.mjs index 0972584..18a2112 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -212,6 +212,7 @@ test("installer assets are explicit and syntax-valid", () => { const linuxUnits = readFileSync(`${root}/site/public/install-linux-units.sh`, "utf8"); const releaseApply = readFileSync(`${root}/site/public/apply-linux-release.sh`, "utf8"); assert.match(updater, /browser_download_url/); + assert.match(linuxUnits, /Environment=HELM_APP_ROOT=\$INSTALL_ROOT\/current/, "Linux explicitly exposes the active packaged root to runtime resource resolvers"); assert.match(updater, /\^sha256:\[a-f0-9\]\{64\}\$/, "the Linux updater requires GitHub's exact SHA-256 asset digest"); assert.match(updater, /sha256sum -c -/); assert.match(updater, /CONNECTOR_ARCH[\s\S]*resources\/cloudflared-linux-\$CONNECTOR_ARCH/, "Linux updates reject archives without the connector for the current architecture");