From 8e79f103e9237f1478f87d3967d2c519374b1ece Mon Sep 17 00:00:00 2001 From: Danilo Alberto Morocho Valladarez Date: Fri, 16 Oct 2026 18:28:19 -0500 Subject: [PATCH] fix(installer): recover Windows installs with isolated pnpm home Add an explicit Windows private-home recovery path for populated or unsafe PNPM_HOME locations without weakening ACL checks or touching the existing package tree. Fall back to a separate checked profile folder when .pnpm is occupied, isolate unclaimed-home probes from startup cache writes, and exclude the rejected pnpm tree from the installer's PATH so its existing commands do not appear unmanaged. Document the recovery and consented pnpm setup --force behavior, and cover the decision, probes, runner, browser plan, and repeated native-Windows preflight. Also retain the existing Gentle AI tagged-main verification changes in this working tree; upstream PR #2340 covers overlapping main-channel work. Validation: 401 scoped regression tests passed (9 skipped); the focused native Windows cache/PATH regression passed; real-host release and main preflight each passed twice with no blockers. No RDD or application build was run. See #2350. --- README.md | 27 ++++ docs/install-wizard.md | 60 +++++++- scripts/bootstrap.cmd | 11 ++ scripts/installer-preflight.mjs | 3 +- scripts/installer-probes.mjs | 30 +++- scripts/installer-runner.mjs | 5 +- scripts/installer-server.mjs | 29 ++-- scripts/installer-windows.mjs | 68 ++++++--- scripts/main-channel.mjs | 35 ++++- tests/installer-probes.test.ts | 41 ++++++ tests/installer-runner.test.ts | 12 ++ tests/installer-server.test.ts | 14 +- tests/installer-windows-bootstrap.test.ts | 162 ++++++++++++++++++++++ tests/main-channel.test.ts | 43 ++++++ 14 files changed, 486 insertions(+), 54 deletions(-) diff --git a/README.md b/README.md index abba02eb8..254f72dc4 100644 --- a/README.md +++ b/README.md @@ -456,6 +456,33 @@ It installs what is missing (Node.js, pnpm, Pi, Gentle Shell) and updates an exi - **Latest release** (recommended): the published Gentle Shell with its pinned Gentle AI binary. - **Latest main**: development builds of Gentle Shell and Gentle AI from the latest commit of `main`, built on your computer with Go. If your Go is missing, older or its version cannot be checked, the installer downloads its own pinned, verified Go only for that build. +**Windows 11: `untrusted-pnpm-home` / “pnpm home folder”.** This means another +account (possibly an unresolved SID from an old account or a sandbox) can modify +`PNPM_HOME` or one of its parent folders. Do not disable the permission check or +remove your own account's access. To install separately without changing the old +folder's permissions or deleting its packages, close the wizard and run from +PowerShell in this checkout: + +```powershell +.\scripts\bootstrap.cmd --private-pnpm-home +``` + +If the current pnpm folder fails the check, this option selects +`%USERPROFILE%\.pnpm` instead. If that folder is already occupied without the +installer's marker (including `bin` or `tmp` left by earlier checks), it leaves +it untouched and tries `%USERPROFILE%\.gentle-shell-pnpm` once. The selected +folder must also pass the permission check and be absent, empty, or previously +created by this installer. Version checks and their caches use the bootstrap's +disposable folder until you confirm, not the selected installation folder. Review the new +plan before confirming: it uses `pnpm setup --force` to save the new `PNPM_HOME` +in your Windows user environment and add its `bin` to your user PATH. Existing +global packages stay in the old folder and are **not migrated**; reinstall any +you still need in the new home. The rejected pnpm folder and its subfolders are +excluded from the installer's own PATH, so an old `gentle-shell` or `pi` there +is not reused, updated, or mistaken for an unmanaged installation in the new +home. Other PATH entries (including unrelated manual installations) are kept. +Open a new terminal after installation. +
More How the installer treats the tools you already have — what it reuses, updates, or leaves alone diff --git a/docs/install-wizard.md b/docs/install-wizard.md index 170ce3405..30d8de825 100644 --- a/docs/install-wizard.md +++ b/docs/install-wizard.md @@ -1466,12 +1466,61 @@ yet (held to the strict depth-0 mask). | Not set: `%LOCALAPPDATA%\pnpm` | Passes | Used as before; the plan is unchanged. | | Not set: `%LOCALAPPDATA%\pnpm` | Fails, and nothing is installed there | `%USERPROFILE%\.pnpm` is used, if it passes the walk too. | | Not set: `%LOCALAPPDATA%\pnpm` | Fails, and something is installed there | Blocked: `untrusted-pnpm-home`. | +| Explicit `--private-pnpm-home` option; configured/default folder holds files or is set by the user | Fails | Leave that folder unchanged and use the checked `%USERPROFILE%\.pnpm` instead. | | Any | The walk cannot finish (policy, PowerShell) | Blocked: `unknown-tool` for `pnpmHome`. | +To recover from `untrusted-pnpm-home` on Windows 11 without changing the old +folder's ACLs or removing existing packages, close the wizard and run from the +checkout in PowerShell: + +```powershell +.\scripts\bootstrap.cmd --private-pnpm-home +``` + +The bootstrap accepts only that optional switch and sets +`GENTLE_INSTALL_PRIVATE_PNPM_HOME=1` for its children. The decision still checks +the configured/default folder first and keeps it when safe. Only when it fails +does this explicit option allow selecting the same checked private folder even +if the old home was configured by the user or already holds files. When `.pnpm` +is occupied and unmarked, the option leaves it intact and tries exactly one +alternative, `%USERPROFILE%\.gentle-shell-pnpm`, under the same permission and +marker checks. This also recovers a folder containing only `bin`, `tmp` or +Node compile caches from earlier checks without pretending those files prove +installer ownership. If both candidates are occupied and unmarked, or the +selected candidate fails its walk, installation still blocks. No package is +copied or migrated, and an unavailable ACL check still blocks. No installation +folder is created before consent. A case-insensitive `Pnpm_Home` environment key is removed before the +selected `PNPM_HOME` is passed to children, so only one spelling remains. +The same decision excludes PATH entries at or beneath the rejected pnpm home +from both the probe and standard runner environments. Windows case, trailing +separators, quoted entries and normalized `..` segments are handled without +matching sibling names such as `pnpm-other`. This prevents an old pnpm-owned +`gentle-shell` or `pi` from being seen on PATH while absent from the new home's +global listing, then being misclassified as unmanaged. The old programs are +not executed, updated, removed, or adopted. The caller's PATH and unrelated +manual/npm installations remain unchanged; genuinely unmanaged commands +elsewhere still block. The review discloses this isolation before consent. + +The private decision and plan record `replaceEnvironment: true` only for this +explicit option. The review explains that `pnpm setup --force` will replace an +existing Windows **user** `PNPM_HOME` and update the user PATH after consent. +Without this option, normal `pnpm setup` still refuses conflicting settings. + A blocked decision (`untrusted-pnpm-home`, or `unknown-tool` for `pnpmHome`) runs no probe at all: the node probe would otherwise run the user's npm with that PNPM_HOME's `bin` first on PATH. The plan then holds only that blocker. +For a selected private folder without the exact ownership marker, **all** +probe subprocesses (including the internal npm/shim checks) get scratch +`PNPM_HOME`, XDG folders, npm cache, TEMP and TMP under the already-private +bootstrap tools directory; Node's compile cache is disabled. Even `--version` +can otherwise create `bin` or temporary caches. Global package listing is +skipped for both absent and empty unclaimed homes, not just absent ones. A +refresh or the pre-consent re-inventory therefore leaves the selected home +unchanged. Missing bootstrap scratch storage fails closed rather than running +against the unclaimed home. Once the runner has claimed and marked the folder +after consent, fresh probes list its real packages normally. + "Nothing is installed" has one exact meaning: `%LOCALAPPDATA%\pnpm` does not exist, or it is a real directory (not a link) with no entries at all. Any entry, even an empty `store` folder or a pnpm config file, counts as an @@ -1488,8 +1537,9 @@ The plan says all of this before consent: the default folder, the account folder, and that `pnpm setup` will save `PNPM_HOME=%USERPROFILE%\.pnpm` and add `%USERPROFILE%\.pnpm\bin` to the user PATH for new terminals. A blocker names the same folder, account and rights, plus the remedy: remove that -account's write access, or set PNPM_HOME to a private folder such as -`%USERPROFILE%\.pnpm` and reinstall the global pnpm packages there. +account's write access and check again, or restart the checkout bootstrap with +`--private-pnpm-home` to install separately in `%USERPROFILE%\.pnpm`. +Reinstall any other global packages you need there; the old packages are not migrated. After consent the runner's first step, `prepare-pnpm-home`, claims the private folder before any pnpm command could create it with inherited permissions. It @@ -1515,8 +1565,10 @@ to `HKCU\Environment` as `PNPM_HOME`, then puts `%PNPM_HOME%\bin` first in the user Path (`addDirToWindowsEnvPath`). The runner gives `pnpm setup` the private PNPM_HOME, and runs it even for an update, which otherwise keeps the PATH its installation already uses. A registry `PNPM_HOME` that already holds -another value makes `pnpm setup` fail with `ERR_PNPM_BAD_ENV_FOUND` instead of -overwriting it. The native Windows tests check the decision, the claim and the +another value makes normal `pnpm setup` fail with `ERR_PNPM_BAD_ENV_FOUND` +instead of overwriting it. The explicitly opted-in private-home plan uses +`pnpm setup --force` to authorize that replacement after consent. The native +Windows tests check the decision, the claim and the probe. No test runs `pnpm setup` against a real user registry: the registry write is pnpm's own behavior, read from the pnpm 11.1.1 package source. diff --git a/scripts/bootstrap.cmd b/scripts/bootstrap.cmd index a7f8da0ff..c87ebcec6 100644 --- a/scripts/bootstrap.cmd +++ b/scripts/bootstrap.cmd @@ -1,5 +1,12 @@ @echo off setlocal DisableDelayedExpansion +rem Explicit opt-in only; never silently replace the user's configured pnpm home. +set "GENTLE_INSTALL_PRIVATE_PNPM_HOME=" +if "%~1"=="" goto bootstrap +if /i not "%~1"=="--private-pnpm-home" goto usage +if not "%~2"=="" goto usage +set "GENTLE_INSTALL_PRIVATE_PNPM_HOME=1" +:bootstrap rem Trusted extracted/checkout bundle only. Never pipe a remote script here. rem Fixed stock PowerShell commands; paths are environment DATA, not PS source. rem Each invocation is below CMD's logical command length limit. @@ -237,6 +244,10 @@ rem through reparse points. A removal problem never fails the installation. "if ([Console]::IsErrorRedirected) { $bytes = [Text.Encoding]::UTF8.GetBytes($message + [Environment]::NewLine); $stderr = [Console]::OpenStandardError(); $stderr.Write($bytes, 0, $bytes.Length); $stderr.Flush() } else { [Console]::Error.WriteLine($message) }; exit 1 } }" endlocal & exit /b 0 +:usage +>&2 echo Usage: scripts\bootstrap.cmd [--private-pnpm-home] +endlocal & exit /b 1 + :unavailable >&2 echo Bootstrap: stock Windows PowerShell is unavailable. No acquisition attempted. :failed diff --git a/scripts/installer-preflight.mjs b/scripts/installer-preflight.mjs index 3b21c2e2e..126553d90 100644 --- a/scripts/installer-preflight.mjs +++ b/scripts/installer-preflight.mjs @@ -274,7 +274,8 @@ export function planPreflight(inventory, { channel = "release" } = {}) { if (home?.available === true && home.source === "private" && typeof home.path === "string" && typeof home.rejected?.path === "string") { const finding = Object.fromEntries(["check", "at", "sid", "account", "rights"] .filter((key) => typeof home.rejected[key] === "string").map((key) => [key, home.rejected[key]])); - tools.pnpmHome = { status: "private", path: home.path, default: home.rejected.path, finding }; + tools.pnpmHome = { status: "private", path: home.path, default: home.rejected.path, finding, + ...(home.replaceEnvironment === true ? { replaceEnvironment: true } : {}) }; } // Windows only (S6 notice, never a blocker): reused tools whose folders another // account can change, from the inventory's one `folders` walk. Only tools this diff --git a/scripts/installer-probes.mjs b/scripts/installer-probes.mjs index a8f019b5d..f64f2f0bb 100644 --- a/scripts/installer-probes.mjs +++ b/scripts/installer-probes.mjs @@ -21,7 +21,7 @@ import { succeeded, windowsInvocation, } from "./installer-runner.mjs"; -import { verifyWindowsStorage, verifyWindowsStorageMany } from "./installer-windows.mjs"; +import { privatePnpmHome, verifyWindowsStorage, verifyWindowsStorageMany } from "./installer-windows.mjs"; // Real host probes for collectInventory. Every effect goes through injected // adapters; probes only run fixed read-only argv (`--version`, `go version`, @@ -205,9 +205,30 @@ export function createProbes({ platform, env, run, fs, home, verifyGentleAi = pa storageMany = platform === "win32" ? (files) => verifyWindowsStorageMany(files, env) : null, pnpmHome = null }) { const path = pathOf(platform); const user = userEnvironment({ platform, env }); + const roots = bootstrapRoots({ platform, env }); const globalBin = pnpmGlobalBin({ platform, env: user }); // pnpm 11 global commands need `$PNPM_HOME/bin` on PATH, as in the runner. const privateHome = platform === "win32" && pnpmHome?.source === "private"; + const unclaimedPrivateHome = async () => privateHome && + (await fs.readText(path.join(globalBin.pnpmHome, privatePnpmHome.marker)).catch(() => null)) !== privatePnpmHome.text; + if (privateHome) { + // Even --version can create caches or PNPM_HOME/bin. Until consent and + // the private claim, redirect EVERY subprocess (including npmInvocation's + // internal probes) into the bootstrap's already-private disposable root. + const execute = run; + const tools = roots[0]; + run = async (command, argv, options = {}) => { + if (!(await unclaimedPrivateHome())) return execute(command, argv, options); + if (!tools) throw new Error("Private Windows probe storage unavailable"); + const kept = Object.fromEntries(Object.entries(options.env ?? env).filter(([key]) => + !/^(?:PNPM_HOME|TEMP|TMP|XDG_CONFIG_HOME|XDG_CACHE_HOME|XDG_STATE_HOME|XDG_DATA_HOME|NPM_CONFIG_CACHE|NODE_COMPILE_CACHE|NODE_DISABLE_COMPILE_CACHE)$/i.test(key))); + return execute(command, argv, { ...options, env: { ...kept, + PNPM_HOME: path.join(tools, "probe-pnpm-home"), TEMP: tools, TMP: tools, NODE_DISABLE_COMPILE_CACHE: "1", + XDG_CONFIG_HOME: path.join(tools, "probe-config"), XDG_CACHE_HOME: path.join(tools, "probe-cache"), + XDG_STATE_HOME: path.join(tools, "probe-state"), XDG_DATA_HOME: path.join(tools, "probe-data"), + NPM_CONFIG_CACHE: path.join(tools, "probe-npm-cache") } }); + }; + } const child = globalBin ? childEnvironment(env, platform, globalBin, { privateHome }) : env; const userChild = globalBin ? childEnvironment(user, platform, globalBin, { privateHome }) : user; /** Every file passes the storage walk; any failure means it is not usable. */ @@ -226,7 +247,6 @@ export function createProbes({ platform, env, run, fs, home, verifyGentleAi = pa return succeeded(result) && result.truncated !== true ? String(result.stdout ?? "").trim() : null; }; const persistentOn = async (name) => (await lookPath(name, user, platform, fs)) !== null; - const roots = bootstrapRoots({ platform, env }); const marker = platform === "win32" ? new RegExp(BOOTSTRAP_TOOLS.source, "i") : BOOTSTRAP_TOOLS; /** A file inside the bootstrap's temporary tools directory. */ const fromBootstrap = (file) => path.isAbsolute(file) && (roots.some((root) => contains(root, file, platform)) || @@ -235,9 +255,9 @@ export function createProbes({ platform, env, run, fs, home, verifyGentleAi = pa let listing; /** Output of the single `list -g` call, or null when it is unavailable. */ const globalListing = () => (listing ??= (async () => { - // A private PNPM_HOME this run has not created yet holds nothing; listing it - // could make pnpm create it, unprotected, before consent. - if (privateHome && globalBin && !(await fs.exists(globalBin.pnpmHome))) return "[]"; + // Absent AND empty unclaimed private homes hold no packages. Never run + // pnpm list there: it creates bin/config/cache before the ownership marker. + if (await unclaimedPrivateHome()) return "[]"; const pnpm = globalBin ? await pnpmInvocation(env, platform, fs) : null; if (!pnpm) return null; return output(pnpm.command, [...pnpm.prefix, "list", "-g", "--depth", "0", "--json"], child, deadlines.list); diff --git a/scripts/installer-runner.mjs b/scripts/installer-runner.mjs index 213ca12f5..6f1f0e5ac 100644 --- a/scripts/installer-runner.mjs +++ b/scripts/installer-runner.mjs @@ -1080,7 +1080,10 @@ export async function runStandardInstall(request, adapters) { const persistPath = !globalBin.onPath && (privateHome || (!update && !piOnly)); if (persistPath) { steps.push(["persist-path", async () => { - const result = await adapters.run(pnpm.command, [...pnpm.prefix, "setup"], { env: child, deadlineMs: deadlines.setup, stderrTail: 4096 }); + // Only the explicit private-home plan authorizes replacing an existing + // HKCU PNPM_HOME; normal setup still refuses conflicting settings. + const force = privateHome && request.plan.tools.pnpmHome.replaceEnvironment === true; + const result = await adapters.run(pnpm.command, [...pnpm.prefix, "setup", ...(force ? ["--force"] : [])], { env: child, deadlineMs: deadlines.setup, stderrTail: 4096 }); if (succeeded(result)) return true; setupDetail = outputDetail(result); return false; diff --git a/scripts/installer-server.mjs b/scripts/installer-server.mjs index cfff3060e..77e895442 100644 --- a/scripts/installer-server.mjs +++ b/scripts/installer-server.mjs @@ -114,7 +114,7 @@ export const guidance = Object.freeze({ "existing-stack-unverified": "The installed Pi and Gentle Shell changed after the plan was made, or are no longer the versions this installer set up. Nothing was changed; run the installer again to check this computer again.", }), failed: Object.freeze({ - "prepare-pnpm-home": "The private pnpm folder (%USERPROFILE%\\.pnpm) could not be created, or kept, with access for you, SYSTEM and Administrators only, or it changed after the plan was made. Nothing was installed. " + + "prepare-pnpm-home": "The private pnpm folder shown in the plan (%USERPROFILE%\\.pnpm or %USERPROFILE%\\.gentle-shell-pnpm) could not be created, or kept, with access for you, SYSTEM and Administrators only, or it changed after the plan was made. Nothing was installed. " + "If that folder holds files this installer did not put there, move them aside. Then run the installer again.", "persist-node": `Installing Node.js under PNPM_HOME failed. Check your network connection. ${tryAgain}`, "persist-package-managers": `Installing npm and pnpm under PNPM_HOME failed. Check your network connection. ${tryAgain}`, @@ -287,20 +287,19 @@ function findingText(finding) { if (check.endsWith("-reparse")) return `${at} is a link (reparse point) to another location`; return `${at} did not pass the permission check`; } -const privateRemedy = "set PNPM_HOME to a private folder such as %USERPROFILE%\\.pnpm and reinstall your global pnpm packages there"; +const privateRemedy = "close this installer and run `.\\scripts\\bootstrap.cmd --private-pnpm-home` from the checkout to install separately in %USERPROFILE%\\.pnpm (or %USERPROFILE%\\.gentle-shell-pnpm when the first folder is occupied). " + + "The installer checks that folder's permissions too and never copies, moves or deletes your existing global pnpm packages"; function pnpmHomeGuidance(home) { const path = plainText(home?.path); if (!path || typeof home.untrusted !== "object" || home.untrusted === null) return guidance.blockers["untrusted-pnpm-home"]; const found = findingText(home.untrusted); const at = plainText(home.untrusted.at) ?? path; - if (home.source === "user") { - return `PNPM_HOME is set to ${path}, but ${found}. pnpm installs and runs programs there, so nothing was installed. ` + - `Remove that account's write access, or ${privateRemedy}, then select Check again.`; - } - const intro = `pnpm's default folder ${path} is not private: ${found}.`; + const intro = home.source === "user" + ? `PNPM_HOME is set to ${path}, but ${found}.` + : `pnpm's default folder ${path} is not private: ${found}.`; if (home.installed === true) { return `${intro} It already holds files. This installer never runs programs from a folder another account can change, and never moves or deletes an existing installation, so nothing was installed. ` + - `Remove that account's write access to ${at}, or ${privateRemedy}, then select Check again.`; + `Remove that account's write access to ${at} and select Check again, or ${privateRemedy}.`; } const own = plainText(home.private?.path); if (own && home.private.foreign === true) { @@ -311,7 +310,7 @@ function pnpmHomeGuidance(home) { return `${intro} ${own}, the private folder this installer would use instead, is not private either: ${findingText(home.private.untrusted)}. Nothing was changed. ` + "Remove the extra access shown, then select Check again."; } - return `${intro} Nothing was installed. Remove that account's write access to ${at}, or ${privateRemedy}, then select Check again.`; + return `${intro} Nothing was installed. Remove that account's write access to ${at} and select Check again, or ${privateRemedy}.`; } /** Names the found and required versions when an incompatible tool is simply @@ -394,9 +393,13 @@ function alongsideNotes(plan) { function privateHomeDescription(home) { const path = plainText(home.path); const fallback = plainText(home.default); - return `pnpm's default folder ${fallback} is not private: ${findingText(home.finding)}. So the installer uses a new private folder, ${path}, as PNPM_HOME, ` + - `which only you, SYSTEM and Administrators can change. \`pnpm setup\` will save PNPM_HOME=${path} in your user environment and add ${path}\\bin ` + - `to your user PATH, so new terminals use it. Nothing in ${fallback} is changed. Open a new terminal afterwards. ` + + const setup = home.replaceEnvironment === true ? "pnpm setup --force" : "pnpm setup"; + const replace = home.replaceEnvironment === true ? "This replaces any previous PNPM_HOME in your Windows user environment, after you confirm. " : ""; + return `The previous pnpm folder ${fallback} is not private: ${findingText(home.finding)}. So the installer uses a separate private folder, ${path}, as PNPM_HOME, ` + + `which only you, SYSTEM and Administrators can change. \`${setup}\` will save PNPM_HOME=${path} in your user environment and add ${path}\\bin ` + + `to your user PATH, so new terminals use it. Nothing in ${fallback} is changed; existing global packages are not copied or migrated. ` + + "The previous pnpm folder and its subfolders are excluded from the installer's PATH, so their programs are not reused or updated; other PATH entries are kept. " + + `${replace}Open a new terminal afterwards. ` + // S13: pnpm reads its configuration folder only from XDG_CONFIG_HOME or %LOCALAPPDATA%\pnpm\config. "Only the installer's own pnpm steps keep pnpm's configuration, cache and state inside that private folder: pnpm commands you run later keep " + "pnpm's default configuration, cache and state under %LOCALAPPDATA%, which pnpm offers no safe persistent setting to move. This is an accepted risk."; @@ -516,7 +519,7 @@ function planView(planId, { inventory, plan }) { })), profileChange: { changesProfile, - command: "pnpm setup", + command: privateHome?.replaceEnvironment === true ? "pnpm setup --force" : "pnpm setup", binDir, description: privateHome ? privateHomeDescription(privateHome) : changesProfile ? `\`pnpm setup\` will add ${binDir ?? "the pnpm global bin directory"} to your PATH: it edits your shell profile on macOS and Linux, or your user PATH on Windows. Open a new terminal afterwards.` diff --git a/scripts/installer-windows.mjs b/scripts/installer-windows.mjs index 7a7b2ef12..85f9f51ac 100644 --- a/scripts/installer-windows.mjs +++ b/scripts/installer-windows.mjs @@ -264,7 +264,7 @@ export function verifyWindowsStorageMany(paths, env, { processAdapter = windowsP // S6: the wizard's PNPM_HOME on Windows. pnpm runs and persists binaries there, // so it gets the same walk as the bootstrap's tools directory before consent. -export const privatePnpmHome = Object.freeze({ directory: ".pnpm", marker: ".gentle-shell-pnpm-home", text: "gentle-pi private pnpm home", temp: "tmp" }); +export const privatePnpmHome = Object.freeze({ directory: ".pnpm", fallbackDirectory: ".gentle-shell-pnpm", marker: ".gentle-shell-pnpm-home", text: "gentle-pi private pnpm home", temp: "tmp" }); const hostHomeFs = Object.freeze({ /** "missing", "directory" or "file" (neither a link), or "other". */ kind(path) { @@ -289,6 +289,11 @@ function windowsEnv(env, name) { * the private `%USERPROFILE%\.pnpm`, which must pass the walk too and be absent, * empty or marked as created by this flow: * { available: true, path, source: "private", rejected: { path, check, at, sid?, account?, rights? } }; + * - with GENTLE_INSTALL_PRIVATE_PNPM_HOME=1 (bootstrap's explicit option), a + * rejected user/default home may stay untouched, even when populated, while + * the same checked private folder is selected. If .pnpm is occupied and + * unmarked, try .gentle-shell-pnpm once, without adopting the occupied folder. + * No global packages are migrated; * - otherwise a blocker: { available: true, path, source, untrusted, installed?, private? }. * The walk covers the folder (or its nearest existing ancestor when it is absent) * and its `bin` when present. Null when PNPM_HOME is unknowable, as for @@ -325,32 +330,43 @@ export function windowsPnpmHome({ env, storage = verifyWindowsStorage, fs = host return fs.kind(marker) === "file" && fs.readText(marker) === privatePnpmHome.text ? "marked" : "used"; }; const set = windowsEnv(env, "PNPM_HOME"); - if (set !== undefined) { - if (!absolute(set)) return null; - const path = normal(set); - const untrusted = walk(path); - return { available: true, path, source: "user", ...(untrusted ? { untrusted } : {}) }; - } const local = windowsEnv(env, "LOCALAPPDATA"); - if (!absolute(local)) return null; - const path = normal(win32.join(local, "pnpm")); + if (!absolute(set === undefined ? local : set)) return null; + const path = normal(set === undefined ? win32.join(local, "pnpm") : set); + const source = set === undefined ? "default" : "user"; const untrusted = walk(path); - if (!untrusted) return { available: true, path, source: "default" }; - const blocked = { available: true, path, source: "default", untrusted }; - // Never moved, deleted or adopted: anything already there is an installation. - if (!["absent", "empty"].includes(contents(path))) return { ...blocked, installed: true }; + if (!untrusted) return { available: true, path, source }; + const blocked = { available: true, path, source, untrusted }; + const optIn = windowsEnv(env, "GENTLE_INSTALL_PRIVATE_PNPM_HOME") === "1"; + if (!optIn && source === "user") return blocked; + // Never move, delete or adopt the old installation. Only explicit opt-in + // permits leaving a populated/configured home behind for a separate install. + if (!optIn && !["absent", "empty"].includes(contents(path))) return { ...blocked, installed: true }; const profile = windowsEnv(env, "USERPROFILE"); if (!absolute(profile)) return blocked; - const own = normal(win32.join(profile, privatePnpmHome.directory)); - if (contents(own) === "used") return { ...blocked, private: { path: own, foreign: true } }; - const ownUntrusted = walk(own); - if (ownUntrusted) return { ...blocked, private: { path: own, untrusted: ownUntrusted } }; - return { available: true, path: own, source: "private", rejected: { path, ...untrusted } }; + const directories = [privatePnpmHome.directory, ...(optIn ? [privatePnpmHome.fallbackDirectory] : [])]; + for (const [index, directory] of directories.entries()) { + const own = normal(win32.join(profile, directory)); + if (own.toLowerCase() === path.toLowerCase()) return blocked; + const state = contents(own); + if (state === "used") { + // An earlier probe (or the user) may have populated .pnpm without our + // marker. Never adopt or delete it; opt-in permits one separate candidate. + if (index + 1 < directories.length) continue; + return { ...blocked, private: { path: own, foreign: true } }; + } + const ownUntrusted = walk(own); + if (ownUntrusted) return { ...blocked, private: { path: own, untrusted: ownUntrusted } }; + return { available: true, path: own, source: "private", rejected: { path, ...untrusted }, + ...(optIn ? { replaceEnvironment: true } : {}) }; + } } /** The wizard's environment and PNPM_HOME decision (windowsPnpmHome): a private * PNPM_HOME is set for this process's children only (childEnvironment adds pnpm's - * own folders there). A walk that cannot finish is { available: null, failed: true }: + * own folders there). The rejected home's PATH entries are excluded too: its old + * commands are neither reused nor misclassified as unmanaged in the new home. + * A walk that cannot finish is { available: null, failed: true }: * preflight then blocks. */ export function windowsWizardEnvironment({ env, ...adapters }) { @@ -361,7 +377,19 @@ export function windowsWizardEnvironment({ env, ...adapters }) { return { env, pnpmHome: { available: null, failed: true } }; } if (pnpmHome?.source !== "private") return { env, pnpmHome }; - return { env: { ...env, PNPM_HOME: pnpmHome.path }, pnpmHome }; + // A plain copied environment is case-sensitive: emit only one home and PATH + // spelling. Isolate the rejected home for both probes AND the install runner, + // without changing the caller's environment or hiding unrelated manual installs. + const pathKey = Object.keys(env).find((key) => key.toUpperCase() === "PATH"); + const rejectedEntry = (entry) => { + const candidate = entry.trim().replace(/^"(.*)"$/, "$1"); + if (!win32.isAbsolute(candidate)) return false; + const relative = win32.relative(pnpmHome.rejected.path, candidate); + return relative === "" || (relative !== ".." && !relative.startsWith("..\\") && !win32.isAbsolute(relative)); + }; + const inherited = Object.entries(env).filter(([key]) => !["PNPM_HOME", "PATH"].includes(key.toUpperCase())); + const isolatedPath = pathKey === undefined ? {} : { [pathKey]: env[pathKey].split(";").filter((entry) => !rejectedEntry(entry)).join(";") }; + return { env: { ...Object.fromEntries(inherited), ...isolatedPath, PNPM_HOME: pnpmHome.path }, pnpmHome }; } // The private PNPM_HOME, after consent: created, or an empty one adopted, with the diff --git a/scripts/main-channel.mjs b/scripts/main-channel.mjs index 88e2d9d66..2dd0264ea 100644 --- a/scripts/main-channel.mjs +++ b/scripts/main-channel.mjs @@ -5,7 +5,8 @@ // - Gentle AI: `go install @` in a sealed Go environment, so the // module source is verified by Go's checksum database (sum.golang.org), as // the Windows release build already is. The binary must report a version -// naming that commit, then it is activated through the existing dev-binary +// naming that commit (its pseudo-version suffix, or the exact tag Go resolved +// that commit to), then it is activated through the existing dev-binary // override, which never falls back to the pinned binary silently. // - Gentle Shell: the exact commit's source tarball, versioned // `-main.` and packed without `prepack` (which runs the full @@ -18,7 +19,8 @@ import { goAcquisition, pnpmGlobalBin, requirements } from "./installer-prefligh export const SHELL_REPOSITORY = "Gentleman-Programming/gentle-shell"; export const GENTLE_AI_REPOSITORY = "Gentleman-Programming/gentle-ai"; -export const GENTLE_AI_MAIN_PACKAGE = "github.com/gentleman-programming/gentle-ai/v4/cmd/gentle-ai"; +export const GENTLE_AI_MAIN_MODULE = "github.com/gentleman-programming/gentle-ai/v4"; +export const GENTLE_AI_MAIN_PACKAGE = `${GENTLE_AI_MAIN_MODULE}/cmd/gentle-ai`; export const CHANNEL_SCHEMA = "gentle-shell.channel/v1"; export const CHANNELS = Object.freeze(["release", "main"]); @@ -130,6 +132,15 @@ function sealedGoEnvironment(goPath, buildDirectory, platform) { return { ...base, HOME: buildDirectory, TMPDIR: temporary, PATH: [dirname(goPath), "/usr/bin", "/bin"].join(":") }; } +/** The module version Go resolves an exact commit to (`go list -m @` prints + * " "), or null when it cannot be read. Only Go's own resolution answers what a + * commit is when that commit is exactly a tagged release. */ +async function resolvedModuleVersion({ goPath, commit, env, buildDirectory, run }) { + const result = await run(goPath, ["list", "-m", `${GENTLE_AI_MAIN_MODULE}@${commit}`], { env, cwd: buildDirectory, deadlineMs: deadlines.version }); + const line = succeeded(result) ? /^(\S+) (v[0-9][^\s]*)$/m.exec(String(result.stdout ?? "").trim()) : null; + return line?.[1] === GENTLE_AI_MAIN_MODULE ? line[2] : null; +} + /** Builds Gentle AI from `commit`, verifies the binary names it and registers it as the override. */ export async function buildMainGentleAi({ commit, ctx, platform, goPath, run, fs }) { if (!SHA.test(commit)) throw new MainChannelError("main-gentle-ai-unverified", "the Gentle AI commit is not an exact SHA"); @@ -146,15 +157,25 @@ export async function buildMainGentleAi({ commit, ctx, platform, goPath, run, fs const binaryPath = join(directory, executable); await fs.copyFile(join(env.GOBIN, executable), binaryPath); if (platform !== "win32") await fs.chmod(binaryPath, 0o755); - // Go stamps the module pseudo-version, whose suffix is the commit's first 12 characters. + // Go stamps the module pseudo-version, whose suffix is the commit's first 12 characters. When + // the commit is exactly a tagged release (main's HEAD right after one), Go stamps the tag + // instead: the stamp then has to equal the version Go itself resolved for that commit. const probe = await run(binaryPath, ["version"], { env: { PATH: env.PATH }, deadlineMs: deadlines.version }); - const match = succeeded(probe) && /^gentle-ai (\S+)$/m.exec(String(probe.stdout ?? "")); - if (!match || !match[1].endsWith(`-${commit.slice(0, 12)}`)) { + const match = succeeded(probe) ? /^gentle-ai (\S+)$/m.exec(String(probe.stdout ?? "")) : null; + const version = match?.[1] ?? null; + const suffix = `-${commit.slice(0, 12)}`; + let verified = version !== null && version.endsWith(suffix); + if (!verified && version !== null) { + const resolved = await resolvedModuleVersion({ goPath, commit, env, buildDirectory, run }); + verified = resolved !== null && resolved.replace(/^v/, "") === version.replace(/^v/, ""); + } + if (!verified) { await fs.rm(directory, { recursive: true, force: true }); - throw new MainChannelError("main-gentle-ai-unverified", `the built Gentle AI does not report commit ${commit.slice(0, 12)}`); + throw new MainChannelError("main-gentle-ai-unverified", + `the built Gentle AI reports ${version ?? "no version"}, which names neither commit ${commit.slice(0, 12)} nor the version Go resolved for it`); } registerGentleAiDevBinary(binaryPath, ctx, platform); - return { binaryPath, version: match[1] }; + return { binaryPath, version }; } finally { await fs.rm(buildDirectory, { recursive: true, force: true }); } diff --git a/tests/installer-probes.test.ts b/tests/installer-probes.test.ts index 0b204cc81..12c760cf8 100644 --- a/tests/installer-probes.test.ts +++ b/tests/installer-probes.test.ts @@ -1047,6 +1047,46 @@ test("Windows: the user's pnpm in a folder another principal may write is never assert.deepEqual(await trusted.probes.pnpm(), { available: true, version: "11.1.1", usable: true, compatible: true, persistent: false, found: "10.0.0" }); }); +test("Windows: unclaimed private homes never receive probe subprocess caches or pnpm startup writes", async () => { + const env = { LOCALAPPDATA: W_LOCAL, USERPROFILE: "C:\\Users\\u", PNPM_HOME: W_PRIVATE, Path: W_NODE_DIR, + GENTLE_BOOTSTRAP_TOOLS: W_TOOLS, GENTLE_INSTALL_PNPM_NODE: W_TOOLS_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_TOOLS_ENTRY, + tmp: `${W_PRIVATE}\\tmp`, TEMP: `${W_PRIVATE}\\tmp`, XDG_CACHE_HOME: `${W_PRIVATE}\\.cache`, NODE_COMPILE_CACHE: `${W_PRIVATE}\\tmp\\node-compile-cache` }; + for (const dirs of [["C:\\Users\\u"], ["C:\\Users\\u", W_PRIVATE]]) { + const h = probes({ platform: "win32", env, dirs, + pnpmHome: { available: true, path: W_PRIVATE, source: "private" }, + files: [W_NODE, `${W_NODE_DIR}\\npm.cmd`, W_NPM_CLI], texts: { [`${W_NODE_DIR}\\npm.cmd`]: nodeNpmCmd }, + results: { [`${W_NODE} --version`]: { code: 0, stdout: "v24.18.0\r\n" }, + [`${W_NODE} ${W_NPM_CLI} --version`]: { code: 0, stdout: "11.6.2\r\n" }, + [`${W_TOOLS_NODE} ${W_TOOLS_ENTRY} --version`]: { code: 0, stdout: "11.1.1\r\n" } } }); + assert.equal((await h.probes.node()).available, true); + assert.equal((await h.probes.pnpm()).available, true); + assert.deepEqual(await h.probes.shell(), { available: false }); + assert.ok(h.calls.length >= 2); + for (const call of h.calls) { + assert.equal(call.env.PNPM_HOME, `${W_TOOLS}\\probe-pnpm-home`); + assert.equal(call.env.TEMP, W_TOOLS); + assert.equal(call.env.TMP, W_TOOLS); + assert.equal(call.env.NODE_DISABLE_COMPILE_CACHE, "1"); + assert.equal(Object.keys(call.env).filter((key) => /^tmp$/i.test(key)).length, 1); + assert.equal(call.env.NODE_COMPILE_CACHE, undefined); + for (const key of ["XDG_CONFIG_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME", "NPM_CONFIG_CACHE"]) { + assert.ok(call.env[key].startsWith(`${W_TOOLS}\\`), key); + } + } + assert.equal(h.calls.some((call) => call.args.includes("list")), false); + } + assert.equal(env.PNPM_HOME, W_PRIVATE, "the wizard's environment is not changed"); +}); + +test("Windows: unclaimed private-home probes fail closed without bootstrap scratch storage", async () => { + const h = probes({ platform: "win32", env: { USERPROFILE: "C:\\Users\\u", PNPM_HOME: W_PRIVATE, Path: "C:\\Windows", + GENTLE_INSTALL_PNPM_COMMAND: "C:\\external\\pnpm.exe" }, pnpmHome: { available: true, path: W_PRIVATE, source: "private" }, + results: { "C:\\external\\pnpm.exe --version": { code: 0, stdout: "11.1.1\r\n" } } }); + assert.deepEqual(await h.probes.pnpm(), { available: null }); + assert.deepEqual(h.calls, []); + assert.deepEqual(await h.probes.shell(), { available: false }); +}); + test("Windows: a private PNPM_HOME not created yet holds no global packages, so pnpm list -g never runs before consent", async () => { const env = { LOCALAPPDATA: W_LOCAL, USERPROFILE: "C:\\Users\\u", PNPM_HOME: W_PRIVATE, Path: "C:\\Windows", GENTLE_INSTALL_PNPM_NODE: W_TOOLS_NODE, GENTLE_INSTALL_PNPM_ENTRY: W_TOOLS_ENTRY }; @@ -1057,6 +1097,7 @@ test("Windows: a private PNPM_HOME not created yet holds no global packages, so assert.deepEqual(absentHome.calls, []); // Once it exists (created by an earlier run), its packages are listed as usual. const existing = probes({ platform: "win32", env, pnpmHome, dirs: ["C:\\Users\\u", W_PRIVATE], + texts: { [`${W_PRIVATE}\\.gentle-shell-pnpm-home`]: "gentle-pi private pnpm home" }, results: { [`${W_TOOLS_NODE} ${W_TOOLS_ENTRY} ${LIST}`]: { code: 0, stdout: "[]" } } }); assert.deepEqual(await existing.probes.shell(), { available: false }); assert.equal(existing.calls.length, 1); diff --git a/tests/installer-runner.test.ts b/tests/installer-runner.test.ts index 8fbb217ed..5990cb04a 100644 --- a/tests/installer-runner.test.ts +++ b/tests/installer-runner.test.ts @@ -2098,6 +2098,18 @@ test("a private Windows PNPM_HOME is claimed before any command and holds pnpm's assert.deepEqual(posix.prepared, []); }); +test("only an explicitly consented private-home plan may replace the Windows user PNPM_HOME", async () => { + const fixed = privatePlan({ pnpmHome: { available: true, path: W_PNPM_HOME, source: "private", + rejected: { path: W_LOCAL_DEFAULT, check: "target-acl-mask" }, replaceEnvironment: true } }); + for (const consent of [false, true]) { + const h = preparing(harness({ layout: windowsLayout, env: { Path: `${W_NODE_DIR};C:\\Windows` }, results: { "setup --force": { code: 0 } } })); + const result = await runStandardInstall({ plan: fixed, consent }, h.adapters); + assert.equal(result.outcome, consent ? "terminal-action-required" : "blocked"); + if (consent) assert.equal(h.pnpmCalls().at(-1), "setup --force"); + else assert.deepEqual(h.calls, []); + } +}); + test("a private PNPM_HOME that changed, or could not be claimed, stops before any command", async () => { const moved = preparing(harness({ layout: windowsLayout })); const changed = await runStandardInstall({ plan: privatePlan({}, "C:\\Users\\u\\.pnpm"), consent: true }, moved.adapters); diff --git a/tests/installer-server.test.ts b/tests/installer-server.test.ts index 28e4822df..983f8fffe 100644 --- a/tests/installer-server.test.ts +++ b/tests/installer-server.test.ts @@ -1328,7 +1328,8 @@ test("/api/plan says before consent that a private PNPM_HOME replaces a default assert.deepEqual(view.blockers, []); assert.equal(view.profileChange.changesProfile, true); names(view.profileChange.description, [W_DEFAULT, weak.at, "S-1-5-21-1-2-3-1002 (PC\\other)", "0x001301BF", W_PRIVATE, "`pnpm setup`", - `PNPM_HOME=${W_PRIVATE}`, `${W_PRIVATE}\\bin`, "new terminals", "only you, SYSTEM and Administrators"]); + `PNPM_HOME=${W_PRIVATE}`, `${W_PRIVATE}\\bin`, "new terminals", "only you, SYSTEM and Administrators", "existing global packages are not copied or migrated", + "excluded from the installer's PATH", "not reused or updated", "other PATH entries are kept"]); assert.equal(view.persistence.pnpmHome, W_PRIVATE); // S13: the accepted residual risk of later pnpm commands, stated before consent. names(view.profileChange.description, ["pnpm commands you run later", "configuration, cache and state", "%LOCALAPPDATA%", W_DEFAULT]); @@ -1337,6 +1338,13 @@ test("/api/plan says before consent that a private PNPM_HOME replaces a default assert.doesNotMatch(plain.profileChange.description, /private|%LOCALAPPDATA%/); }); +test("/api/plan discloses replacing the Windows user environment only for the explicit private-home plan", async () => { + const view = await windowsView({ available: true, path: W_PRIVATE, source: "private", rejected: { path: "D:\\custom-pnpm", ...weak }, replaceEnvironment: true }); + assert.deepEqual(view.blockers, []); + assert.equal(view.profileChange.command, "pnpm setup --force"); + names(view.profileChange.description, ["D:\\custom-pnpm", W_PRIVATE, "`pnpm setup --force`", "replaces any previous PNPM_HOME", "after you confirm", "not copied or migrated"]); +}); + // S6 notice: reused tools in folders another account can change, before consent. test("/api/plan notes reused tools in folders another account can change, without blocking or changing the plan", async () => { const roaming = { check: "parent-acl-mask", at: "C:\\Users\\m\\AppData\\Roaming", sid: "S-1-5-21-1-2-3-1002", account: "PC\\other", rights: "0x001301BF" }; @@ -1363,8 +1371,8 @@ test("/api/plan notes reused tools in folders another account can change, withou test("/api/plan names the folder, principal, rights and remedy when PNPM_HOME is not private", async () => { const cases = [ - [{ available: true, path: W_DEFAULT, source: "default", untrusted: weak, installed: true }, [W_DEFAULT, "already holds files", "never moves or deletes", "%USERPROFILE%\\.pnpm"]], - [{ available: true, path: "D:\\pnpm", source: "user", untrusted: { ...weak, at: "D:\\pnpm" } }, ["PNPM_HOME is set to D:\\pnpm", "%USERPROFILE%\\.pnpm"]], + [{ available: true, path: W_DEFAULT, source: "default", untrusted: weak, installed: true }, [W_DEFAULT, "already holds files", "never moves or deletes", "%USERPROFILE%\\.pnpm", "scripts\\bootstrap.cmd --private-pnpm-home"]], + [{ available: true, path: "D:\\pnpm", source: "user", untrusted: { ...weak, at: "D:\\pnpm" } }, ["PNPM_HOME is set to D:\\pnpm", "%USERPROFILE%\\.pnpm", "scripts\\bootstrap.cmd --private-pnpm-home", "never copies, moves or deletes"]], [{ available: true, path: W_DEFAULT, source: "default", untrusted: weak, private: { path: W_PRIVATE, foreign: true } }, [W_PRIVATE, "did not create"]], [{ available: true, path: W_DEFAULT, source: "default", untrusted: weak, private: { path: W_PRIVATE, untrusted: { check: "target-owner", at: "C:\\Users\\m", sid: "S-1-5-21-9" } } }, [W_PRIVATE, "C:\\Users\\m is owned by S-1-5-21-9"]], diff --git a/tests/installer-windows-bootstrap.test.ts b/tests/installer-windows-bootstrap.test.ts index ce2b58db0..0e3a6d455 100644 --- a/tests/installer-windows-bootstrap.test.ts +++ b/tests/installer-windows-bootstrap.test.ts @@ -1110,6 +1110,99 @@ test("a weak default holding nothing switches to %USERPROFILE%\\.pnpm, which mus assert.deepEqual(wizard.env, { ...homeEnv, PNPM_HOME: W_PRIVATE }); }); +test("the explicit private-home option leaves populated or configured weak homes untouched", () => { + for (const configured of [false, true]) { + const env = { ...homeEnv, ...(configured ? { Pnpm_Home: W_DEFAULT } : {}), GENTLE_INSTALL_PRIVATE_PNPM_HOME: "1" }; + const h = homeDecision({ env, dirs: [W_DEFAULT, `${W_DEFAULT}\\store`], weak: { [W_DEFAULT]: "target-acl-mask" } }); + const wizard = h.wizard(); + assert.deepEqual(wizard.pnpmHome, { available: true, path: W_PRIVATE, source: "private", rejected: { path: W_DEFAULT, ...finding(W_DEFAULT) }, replaceEnvironment: true }); + assert.deepEqual(h.walked, [W_DEFAULT, "C:\\Users\\m"]); + assert.equal(wizard.env.PNPM_HOME, W_PRIVATE); + assert.equal(Object.keys(wizard.env).filter((key) => key.toUpperCase() === "PNPM_HOME").length, 1); + assert.equal(env.Pnpm_Home, configured ? W_DEFAULT : undefined, "the caller's environment is unchanged"); + const plan = planPreflight({ platform: "win32", arch: "x64", node: { available: false }, pnpm: { available: false }, pi: { available: false }, + shell: { available: false }, gentleAi: { available: false }, go: { available: false }, setup: false, + globalBin: { available: true, path: `${W_PRIVATE}\\bin`, writable: true, onPath: false }, pnpmHome: wizard.pnpmHome }); + assert.deepEqual(plan.blockers, []); + assert.equal(plan.tools.pnpmHome.status, "private"); + assert.equal(plan.tools.pnpmHome.replaceEnvironment, true); + } +}); + +test("private-home selection excludes only the rejected pnpm home from the installer PATH", () => { + const tools = "C:\\Users\\m\\.gentle-shell-bootstrap-tools.test"; + const other = `${W_DEFAULT}-other\\bin`; + const manual = "C:\\source-checkout\\bin"; + const env = { ...homeEnv, PNPM_HOME: W_DEFAULT, GENTLE_INSTALL_PRIVATE_PNPM_HOME: "1", + pAtH: [tools, `${W_DEFAULT}\\bin`, W_DEFAULT.toUpperCase(), `${W_DEFAULT}\\global\\v11\\group`, + `\"${W_DEFAULT}\\bin\\\"`, `${W_DEFAULT}\\bin\\..\\bin`, other, manual, "C:\\Windows\\System32"].join(";") }; + const h = homeDecision({ env, dirs: [W_DEFAULT, `${W_DEFAULT}\\bin`], weak: { [W_DEFAULT]: "target-acl-mask" } }); + const wizard = h.wizard(); + assert.equal(wizard.env.PNPM_HOME, W_PRIVATE); + assert.equal(wizard.env.pAtH, [tools, other, manual, "C:\\Windows\\System32"].join(";")); + assert.equal(Object.keys(wizard.env).filter((key) => key.toUpperCase() === "PATH").length, 1); + assert.equal(env.pAtH.split(";").length, 9, "the caller's PATH is untouched"); + // Neither a reusable home nor a blocked decision authorizes hiding programs. + assert.equal(homeDecision({ env, dirs: [W_DEFAULT] }).wizard().env, env); + const blocked = { ...env, GENTLE_INSTALL_PRIVATE_PNPM_HOME: "" }; + assert.equal(homeDecision({ env: blocked, dirs: [W_DEFAULT], weak: { [W_DEFAULT]: "target-acl-mask" } }).wizard().env, blocked); +}); + +test("private-home opt-in skips an occupied .pnpm without adopting or modifying its contents", () => { + const env = { ...homeEnv, PNPM_HOME: W_DEFAULT, GENTLE_INSTALL_PRIVATE_PNPM_HOME: "1" }; + const fallback = "C:\\Users\\m\\.gentle-shell-pnpm"; + const weak = { [W_DEFAULT]: "target-acl-mask" }; + const h = homeDecision({ env, dirs: [W_DEFAULT, W_PRIVATE, `${W_PRIVATE}\\bin`, `${W_PRIVATE}\\tmp`], + files: { [`${W_PRIVATE}\\tmp\\cache`]: "preserve" }, weak }); + assert.equal(h.wizard().env.PNPM_HOME, fallback); + assert.equal(h.decide().path, fallback); + assert.equal(h.decide().source, "private"); + assert.equal(homeDecision({ env, dirs: [W_DEFAULT, W_PRIVATE, fallback], + files: { [`${W_PRIVATE}\\notes`]: "preserve", [`${fallback}\\notes`]: "preserve" }, weak }).decide().private.foreign, true); + assert.equal(homeDecision({ env, dirs: [W_DEFAULT, W_PRIVATE, fallback], + files: { [`${W_PRIVATE}\\notes`]: "preserve", [`${fallback}\\.gentle-shell-pnpm-home`]: "gentle-pi private pnpm home" }, weak }).decide().source, "private"); + assert.equal(homeDecision({ env, dirs: [W_DEFAULT, W_PRIVATE, fallback], files: { [`${W_PRIVATE}\\notes`]: "preserve" }, + weak: { ...weak, [fallback]: "target-owner" } }).decide().private.untrusted.check, "target-owner"); +}); + +test("private-home opt-in never bypasses failed walks or adopts foreign private folders", () => { + const env = { ...homeEnv, PNPM_HOME: W_DEFAULT, GENTLE_INSTALL_PRIVATE_PNPM_HOME: "1" }; + const weak = { [W_DEFAULT]: "target-acl-mask" }; + assert.equal(homeDecision({ env, dirs: [W_DEFAULT], weak: { ...weak, "C:\\Users\\m": "target-owner" } }).decide().private.untrusted.check, "target-owner"); + assert.equal(homeDecision({ env, dirs: [W_DEFAULT, W_PRIVATE, "C:\\Users\\m\\.gentle-shell-pnpm"], + files: { [`${W_PRIVATE}\\notes.txt`]: "keep", "C:\\Users\\m\\.gentle-shell-pnpm\\notes.txt": "keep" }, weak }).decide().private.foreign, true); + assert.equal(homeDecision({ env, throws: new Error("unavailable ACL check") }).wizard().pnpmHome.failed, true); + assert.equal(homeDecision({ env: { ...env, PNPM_HOME: "relative" } }).decide(), null); + assert.equal(homeDecision({ env: { ...env, PNPM_HOME: W_PRIVATE }, dirs: [W_PRIVATE], weak: { [W_PRIVATE]: "target-acl-mask" } }).decide().source, "user"); + // Only the exact opt-in enables the new behavior. A trusted folder is always kept. + for (const flag of ["", "true", "0"]) assert.equal(homeDecision({ env: { ...env, GENTLE_INSTALL_PRIVATE_PNPM_HOME: flag }, dirs: [W_DEFAULT], weak }).decide().source, "user"); + assert.equal(homeDecision({ env }).decide().source, "user"); +}); + +test("bootstrap accepts only the explicit private-home switch before doing any work", () => { + const source = readFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), "utf8"); + assert.ok(source.indexOf('set "GENTLE_INSTALL_PRIVATE_PNPM_HOME="') < source.indexOf('set "GENTLE_INSTALL_PRIVATE_PNPM_HOME=1"')); + assert.ok(source.includes('if /i not "%~1"=="--private-pnpm-home" goto usage')); + assert.ok(source.includes('if not "%~2"=="" goto usage')); + assert.ok(source.indexOf(":bootstrap") < source.indexOf("GENTLE_BOOTSTRAP_BUNDLE=")); +}); + +test("native Windows: CMD passes private-home opt-in only for the supported switch", { skip: process.platform !== "win32" }, () => { + const f = fixture(); + try { + const source = readFileSync(new URL("../scripts/bootstrap.cmd", import.meta.url), "utf8"); + const prefix = source.slice(0, source.indexOf("rem Trusted extracted/checkout bundle only.")); + writeFileSync(join(f.root, "option.cmd"), `${prefix}\r\necho PRIVATE=%GENTLE_INSTALL_PRIVATE_PNPM_HOME%\r\nexit /b 0\r\n:usage\r\nexit /b 2\r\n`); + for (const [args, status, output] of [["", 0, "PRIVATE="], ["--private-pnpm-home", 0, "PRIVATE=1"], + ["--unknown", 2, ""], ["--private-pnpm-home extra", 2, ""]] as const) { + const result = spawnSync(join(process.env.SystemRoot!, "System32/cmd.exe"), ["/d", "/c", `option.cmd ${args}`], + { cwd: f.root, env: { ...process.env, GENTLE_INSTALL_PRIVATE_PNPM_HOME: "1" }, encoding: "utf8", timeout: 5000, windowsHide: true }); + assert.equal(result.status, status, result.stderr); + assert.equal(result.stdout.trim(), output); + } + } finally { f.cleanup(); } +}); + test("a weak default that already holds files, a weak or foreign private folder, or a weak user PNPM_HOME blocks", () => { const installed = homeDecision({ dirs: [W_DEFAULT, `${W_DEFAULT}\\store`], weak: { [W_DEFAULT]: "target-acl-mask" } }); assert.deepEqual(installed.decide(), { available: true, path: W_DEFAULT, source: "default", untrusted: finding(W_DEFAULT), installed: true }); @@ -1679,6 +1772,64 @@ test("native Windows: a weak default PNPM_HOME holding nothing makes the plan us } finally { f.cleanup(); } }); +test("native Windows: repeated preflight leaves an unclaimed fallback untouched even when probe children create caches", { skip: nativeUnavailable }, async () => { + const f = await ownedNativeFixture(); + try { + const local = join(f.root, "local"); const profile = join(f.root, "profile"); const tools = join(f.root, "tools"); + const old = join(local, "pnpm"); const occupied = join(profile, ".pnpm"); const own = join(profile, ".gentle-shell-pnpm"); + const oldBin = join(old, "bin"); + mkdirSync(oldBin, { recursive: true }); mkdirSync(join(occupied, "bin"), { recursive: true }); + // Existing global commands in the rejected home used to become unknown + // tools after PNPM_HOME changed, blocking the separate private install. + writeFileSync(join(oldBin, "gentle-shell.exe"), "old shell sentinel"); + writeFileSync(join(oldBin, "pi.exe"), "old Pi sentinel"); + mkdirSync(join(occupied, "tmp")); mkdirSync(tools); + writeFileSync(join(occupied, "tmp", "cache"), "keep"); + await grantEveryone(f.root, old, modifyRights); + const env = { SystemRoot: process.env.SystemRoot!, LOCALAPPDATA: local, USERPROFILE: profile, Path: `${tools};${oldBin}`, PNPM_HOME: old, + GENTLE_BOOTSTRAP_TOOLS: tools, GENTLE_INSTALL_PRIVATE_PNPM_HOME: "1", GENTLE_INSTALL_PNPM_COMMAND: join(tools, "pnpm.exe") }; + const { fs } = hostAdapters(); + const calls: Array<{ argv: string[]; home: string }> = []; + const run = async (_command: string, argv: string[], options: { env: NodeJS.ProcessEnv }) => { + calls.push({ argv, home: options.env.PNPM_HOME! }); + // Simulate real npm/pnpm startup effects in a real child process, not + // just a mocked return value. Before consent all writes must stay in tools. + const script = `const fs = require('node:fs'), path = require('node:path'); + fs.mkdirSync(path.join(process.env.PNPM_HOME, 'bin'), { recursive: true }); + fs.mkdirSync(path.join(process.env.TEMP, 'node-compile-cache'), { recursive: true }); + process.stdout.write(${JSON.stringify(argv.includes("list") ? "[]" : "11.1.1")});`; + const child = spawnSync(process.execPath, ["-e", script], { env: options.env, encoding: "utf8", timeout: 5000, windowsHide: true }); + return { code: child.status, stdout: child.stdout }; + }; + const probesFor = (wizard: ReturnType) => createProbes({ platform: "win32", env: wizard.env, + pnpmHome: wizard.pnpmHome, run, fs, storageMany: null }); + for (let attempt = 0; attempt < 3; attempt++) { + const wizard = windowsModule.windowsWizardEnvironment({ env }); + assert.equal(wizard.pnpmHome.path, own, JSON.stringify(wizard.pnpmHome)); + const probes = probesFor(wizard); + assert.equal((await probes.pnpm()).available, true); + assert.deepEqual(await probes.shell(), { available: false }); + assert.deepEqual(await probes.pi(), { available: false }); + assert.equal(existsSync(own), false, "refresh and consent re-inventory must not create the selected home"); + } + assert.ok(calls.every((call) => call.home === join(tools, "probe-pnpm-home"))); + assert.equal(calls.some((call) => call.argv.includes("list")), false); + assert.equal(windowsModule.ensureWindowsPnpmHome(own, env), "claimed"); + const after = probesFor(windowsModule.windowsWizardEnvironment({ env })); + assert.deepEqual(await after.shell(), { available: false }); + assert.equal(calls.at(-1)!.home, own, "after the real claim, installed packages are listed in the selected home"); + assert.ok(calls.at(-1)!.argv.includes("list")); + assert.deepEqual(readdirSync(occupied).sort(), ["bin", "tmp"]); + assert.equal(readFileSync(join(occupied, "tmp", "cache"), "utf8"), "keep"); + assert.equal(readFileSync(join(oldBin, "gentle-shell.exe"), "utf8"), "old shell sentinel"); + // An unrelated manual command remains visible and blocked as unknown. + const manual = join(f.root, "manual"); mkdirSync(manual); + writeFileSync(join(manual, "gentle-shell.exe"), "manual shell sentinel"); + const manualWizard = windowsModule.windowsWizardEnvironment({ env: { ...env, Path: `${env.Path};${manual}` } }); + assert.deepEqual(await probesFor(manualWizard).shell(), { available: null, outsidePnpm: true }); + } finally { f.cleanup(); } +}); + test("native Windows: a weak default PNPM_HOME that already holds files blocks and names the folder, principal and rights", { skip: nativeUnavailable }, async () => { const f = await ownedNativeFixture(); try { @@ -1702,6 +1853,17 @@ test("native Windows: a weak default PNPM_HOME that already holds files blocks a // A PNPM_HOME the user set is held to the same walk. const user = windowsModule.windowsPnpmHome({ env: { ...env, PNPM_HOME: join(local, "pnpm") } }); assert.deepEqual([user.source, user.untrusted?.check], ["user", "target-acl-mask"]); + // Explicit recovery for this exact report: configured PNPM_HOME with a + // foreign Modify ACE and existing files. No ACLs or contents are changed there. + const isolated = windowsModule.windowsWizardEnvironment({ env: { ...env, PNPM_HOME: join(local, "pnpm"), GENTLE_INSTALL_PRIVATE_PNPM_HOME: "1" } }); + const own = join(profile, ".pnpm"); + assert.equal(isolated.pnpmHome.source, "private", JSON.stringify(isolated.pnpmHome)); + assert.equal(isolated.env.PNPM_HOME, own); + assert.equal(existsSync(own), false, "planning is read-only"); + assert.equal(windowsModule.ensureWindowsPnpmHome(own, isolated.env), "claimed"); + verifyWindowsStorage(join(own, "tmp"), isolated.env); + assert.deepEqual(readdirSync(join(local, "pnpm")), ["store"]); + assert.throws(() => verifyWindowsStorage(join(local, "pnpm"), env), (error: { check?: string }) => error.check === "target-acl-mask"); } finally { f.cleanup(); } }); diff --git a/tests/main-channel.test.ts b/tests/main-channel.test.ts index 61c3def0f..04229bc81 100644 --- a/tests/main-channel.test.ts +++ b/tests/main-channel.test.ts @@ -6,6 +6,7 @@ import { join, win32 } from "node:path"; import test from "node:test"; import { CHANNEL_SCHEMA, + GENTLE_AI_MAIN_MODULE, GENTLE_AI_MAIN_PACKAGE, MainChannelError, buildMainGentleAi, @@ -165,6 +166,48 @@ test("a failed source extraction or Go build keeps the command's bounded stderr } finally { s.cleanup(); } }); +// main's HEAD can be exactly a tagged release commit: `go install @` then stamps the +// tag (v4.2.0) instead of a pseudo-version, so the built binary never names the commit's sha12. +test("a Gentle AI commit Go resolves to an exact tag is verified through that resolved version", async () => { + const s = sandbox(); + try { + const run = async (_command: string, argv: string[], options: Call["options"]) => { + if (argv[0] === "install") { + mkdirSync(options.env!.GOBIN, { recursive: true }); + writeFileSync(join(options.env!.GOBIN, "gentle-ai.exe"), "MZ\n", { mode: 0o755 }); + return { code: 0, stdout: "" }; + } + if (argv[0] === "list") return { code: 0, stdout: `${GENTLE_AI_MAIN_MODULE} v4.2.0\n` }; + return { code: 0, stdout: "gentle-ai 4.2.0\n" }; + }; + const built = await buildMainGentleAi({ commit: AI_SHA, ctx: s.ctx, platform: "win32", goPath: "C:\\go\\bin\\go.exe", run, fs: fsPromises }); + assert.equal(built.version, "4.2.0"); + assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "dev-binary.json")), true); + } finally { s.cleanup(); } +}); + +// A resolved tag only proves anything when the binary carries exactly that version, and only when +// the resolution actually happened: a failed or unreadable `go list -m` never becomes a pass. +test("a tag-resolved Gentle AI build is rejected when its stamp differs or the resolution is unavailable", async () => { + for (const [name, probe, list] of [["another stamp", "gentle-ai 4.1.9\n", null], ["unavailable resolution", "gentle-ai 4.2.0\n", { code: 1, stdout: "" }]] as const) { + const s = sandbox(); + try { + const run = async (_command: string, argv: string[], options: Call["options"]) => { + if (argv[0] === "install") { + mkdirSync(options.env!.GOBIN, { recursive: true }); + writeFileSync(join(options.env!.GOBIN, "gentle-ai.exe"), "MZ\n", { mode: 0o755 }); + return { code: 0, stdout: "" }; + } + if (argv[0] === "list") return list ?? { code: 0, stdout: `${GENTLE_AI_MAIN_MODULE} v4.2.0\n` }; + return { code: 0, stdout: probe }; + }; + await assert.rejects(buildMainGentleAi({ commit: AI_SHA, ctx: s.ctx, platform: "win32", goPath: "C:\\go\\bin\\go.exe", run, fs: fsPromises }), + (error: MainChannelError) => error.code === "main-gentle-ai-unverified", name); + assert.equal(existsSync(join(s.home, ".pi", "gentle-ai", "dev-binary.json")), false, name); + } finally { s.cleanup(); } + } +}); + test("Gentle AI main is built from the exact commit through Go's checksum database and registered as the override", posixOnly, async () => { const s = sandbox(); try {