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 {